1421 lines
60 KiB
PHP
1421 lines
60 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Worlds;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\Collection;
|
|
use App\Models\Group;
|
|
use App\Models\GroupChallenge;
|
|
use App\Models\GroupChallengeOutcome;
|
|
use App\Models\User;
|
|
use App\Models\World;
|
|
use App\Models\WorldEditorialSuggestionState;
|
|
use App\Models\WorldRelation;
|
|
use App\Models\WorldSubmission;
|
|
use App\Services\Maturity\ArtworkMaturityService;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection as SupportCollection;
|
|
use Illuminate\Support\Str;
|
|
use cPad\Plugins\News\Models\NewsArticle;
|
|
|
|
final class WorldEditorialSuggestionService
|
|
{
|
|
private const GROUP_ORDER = [
|
|
'challenge',
|
|
'community',
|
|
'artworks',
|
|
'creators',
|
|
'collections',
|
|
'groups',
|
|
'news',
|
|
];
|
|
|
|
private const STOP_WORDS = [
|
|
'about', 'after', 'again', 'also', 'around', 'because', 'before', 'being', 'build', 'campaign',
|
|
'from', 'have', 'into', 'just', 'more', 'over', 'season', 'skinbase', 'that', 'their', 'there',
|
|
'these', 'this', 'through', 'world', 'with', 'your', 'edition', 'across', 'will', 'while', 'when',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly WorldService $worlds,
|
|
private readonly WorldAnalyticsService $analytics,
|
|
private readonly ArtworkMaturityService $maturity,
|
|
) {
|
|
}
|
|
|
|
public function editorPayload(World $world, ?User $viewer = null): array
|
|
{
|
|
$context = $this->buildContext($world, $viewer);
|
|
$stateRows = $world->editorialSuggestionStates()->get();
|
|
$stateMap = $stateRows->keyBy(fn (WorldEditorialSuggestionState $state): string => $this->itemKey((string) $state->related_type, (int) $state->related_id));
|
|
|
|
$candidateGroups = [
|
|
'challenge' => $this->challengeHighlightSuggestions($world, $context),
|
|
'community' => $this->communitySuggestions($world, $context),
|
|
'artworks' => $this->artworkSuggestions($world, $context, $viewer),
|
|
'creators' => $this->creatorSuggestions($world, $context),
|
|
'collections' => $this->collectionSuggestions($world, $context, $viewer),
|
|
'groups' => $this->groupSuggestions($world, $context, $viewer),
|
|
'news' => $this->newsSuggestions($world, $context),
|
|
];
|
|
|
|
$candidateMap = collect($candidateGroups)
|
|
->flatten(1)
|
|
->keyBy(fn (array $item): string => (string) $item['key']);
|
|
|
|
$groups = [];
|
|
$seenKeys = [];
|
|
|
|
foreach (self::GROUP_ORDER as $groupKey) {
|
|
$definition = $this->groupDefinition($groupKey);
|
|
$items = collect($candidateGroups[$groupKey] ?? [])
|
|
->reject(function (array $item) use ($seenKeys, $stateMap, $context): bool {
|
|
return in_array((string) $item['key'], $seenKeys, true)
|
|
|| $stateMap->has((string) $item['key'])
|
|
|| $this->isAlreadyAttached($item, $context);
|
|
})
|
|
->take(8)
|
|
->values();
|
|
|
|
$seenKeys = array_values(array_unique(array_merge($seenKeys, $items->pluck('key')->all())));
|
|
|
|
$groups[] = [
|
|
'key' => $groupKey,
|
|
'label' => $definition['label'],
|
|
'description' => $definition['description'],
|
|
'empty_label' => $definition['empty_label'],
|
|
'items' => $items->all(),
|
|
'count' => $items->count(),
|
|
];
|
|
}
|
|
|
|
$pinnedItems = $stateRows
|
|
->where('status', WorldEditorialSuggestionState::STATUS_PINNED)
|
|
->map(fn (WorldEditorialSuggestionState $state): ?array => $this->stateBackedItem($state, $candidateMap, $viewer))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$suppressedItems = $stateRows
|
|
->whereIn('status', [
|
|
WorldEditorialSuggestionState::STATUS_DISMISSED,
|
|
WorldEditorialSuggestionState::STATUS_NOT_RELEVANT,
|
|
])
|
|
->map(fn (WorldEditorialSuggestionState $state): ?array => $this->stateBackedItem($state, $candidateMap, $viewer))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$availableCount = (int) collect($groups)->sum('count');
|
|
$analyticsSignalCount = (int) collect($groups)
|
|
->flatMap(fn (array $group): array => (array) ($group['items'] ?? []))
|
|
->filter(fn (array $item): bool => (bool) data_get($item, 'signals.analytics_informed', false))
|
|
->count();
|
|
|
|
return [
|
|
'enabled' => true,
|
|
'summary' => [
|
|
'available_count' => $availableCount,
|
|
'pinned_count' => count($pinnedItems),
|
|
'suppressed_count' => count($suppressedItems),
|
|
'analytics_signal_count' => $analyticsSignalCount,
|
|
'world_is_recurring' => (bool) $world->is_recurring,
|
|
'has_linked_challenge' => $context['linked_challenge'] instanceof GroupChallenge,
|
|
'family_signal_count' => count($context['family_creator_ids']) + count($context['family_group_ids']) + count($context['family_collection_ids']),
|
|
'community_submission_count' => count($context['live_submission_artwork_ids']),
|
|
],
|
|
'filters' => [
|
|
'category_options' => array_values(array_filter(array_map(function (array $group): ?array {
|
|
if (($group['count'] ?? 0) < 1) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'value' => (string) $group['key'],
|
|
'label' => (string) $group['label'],
|
|
'count' => (int) $group['count'],
|
|
];
|
|
}, $groups))),
|
|
'type_options' => $this->typeFilterOptions(),
|
|
'section_options' => $this->sectionFilterOptions(),
|
|
'sort_options' => $this->sortFilterOptions(),
|
|
],
|
|
'groups' => $groups,
|
|
'pinned_items' => $pinnedItems,
|
|
'suppressed_items' => $suppressedItems,
|
|
'generated_at' => now()->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
public function addSuggestionToSection(World $world, User $actor, string $relatedType, int $relatedId, string $sectionKey, bool $featured = false): array
|
|
{
|
|
$this->assertSectionCompatibility($relatedType, $sectionKey);
|
|
|
|
$existing = $world->worldRelations()
|
|
->where('related_type', $relatedType)
|
|
->where('related_id', $relatedId)
|
|
->first();
|
|
|
|
if ($existing) {
|
|
if ($featured && ! (bool) $existing->is_featured) {
|
|
$existing->forceFill(['is_featured' => true])->save();
|
|
}
|
|
|
|
$world->editorialSuggestionStates()
|
|
->where('related_type', $relatedType)
|
|
->where('related_id', $relatedId)
|
|
->delete();
|
|
|
|
return [
|
|
'message' => 'Suggestion was already attached to this world.',
|
|
'relation' => $this->relationPayload($existing->fresh(), $actor),
|
|
'already_attached' => true,
|
|
];
|
|
}
|
|
|
|
$relation = $world->worldRelations()->create([
|
|
'section_key' => $sectionKey,
|
|
'related_type' => $relatedType,
|
|
'related_id' => $relatedId,
|
|
'context_label' => null,
|
|
'sort_order' => (int) $world->worldRelations()->where('section_key', $sectionKey)->max('sort_order') + 1,
|
|
'is_featured' => $featured,
|
|
]);
|
|
|
|
$world->editorialSuggestionStates()
|
|
->where('related_type', $relatedType)
|
|
->where('related_id', $relatedId)
|
|
->delete();
|
|
|
|
return [
|
|
'message' => $featured ? 'Suggestion added to the featured section.' : 'Suggestion added to the section.',
|
|
'relation' => $this->relationPayload($relation->fresh(), $actor),
|
|
'already_attached' => false,
|
|
];
|
|
}
|
|
|
|
public function pinSuggestion(World $world, User $actor, string $relatedType, int $relatedId, ?string $sectionKey = null): array
|
|
{
|
|
if ($sectionKey !== null && $sectionKey !== '') {
|
|
$this->assertSectionCompatibility($relatedType, $sectionKey);
|
|
}
|
|
|
|
$state = $world->editorialSuggestionStates()->updateOrCreate(
|
|
[
|
|
'related_type' => $relatedType,
|
|
'related_id' => $relatedId,
|
|
],
|
|
[
|
|
'status' => WorldEditorialSuggestionState::STATUS_PINNED,
|
|
'section_key' => $sectionKey !== '' ? $sectionKey : null,
|
|
'acted_by_user_id' => (int) $actor->id,
|
|
],
|
|
);
|
|
|
|
return [
|
|
'message' => 'Suggestion pinned for later.',
|
|
'state' => [
|
|
'status' => (string) $state->status,
|
|
'section_key' => $state->section_key,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function dismissSuggestion(World $world, User $actor, string $relatedType, int $relatedId): array
|
|
{
|
|
return $this->storeFeedbackState($world, $actor, $relatedType, $relatedId, WorldEditorialSuggestionState::STATUS_DISMISSED, 'Suggestion dismissed for this edition.');
|
|
}
|
|
|
|
public function markSuggestionNotRelevant(World $world, User $actor, string $relatedType, int $relatedId): array
|
|
{
|
|
return $this->storeFeedbackState($world, $actor, $relatedType, $relatedId, WorldEditorialSuggestionState::STATUS_NOT_RELEVANT, 'Suggestion marked not relevant for this edition.');
|
|
}
|
|
|
|
public function restoreSuggestion(World $world, string $relatedType, int $relatedId): array
|
|
{
|
|
$world->editorialSuggestionStates()
|
|
->where('related_type', $relatedType)
|
|
->where('related_id', $relatedId)
|
|
->delete();
|
|
|
|
return [
|
|
'message' => 'Suggestion restored to the review queue.',
|
|
];
|
|
}
|
|
|
|
private function storeFeedbackState(World $world, User $actor, string $relatedType, int $relatedId, string $status, string $message): array
|
|
{
|
|
$state = $world->editorialSuggestionStates()->updateOrCreate(
|
|
[
|
|
'related_type' => $relatedType,
|
|
'related_id' => $relatedId,
|
|
],
|
|
[
|
|
'status' => $status,
|
|
'section_key' => null,
|
|
'acted_by_user_id' => (int) $actor->id,
|
|
],
|
|
);
|
|
|
|
return [
|
|
'message' => $message,
|
|
'state' => [
|
|
'status' => (string) $state->status,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function buildContext(World $world, ?User $viewer): array
|
|
{
|
|
$world->loadMissing(['worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes']);
|
|
|
|
$themeTags = collect((array) data_get(config('worlds.themes'), ($world->theme_key ?: '') . '.related_tags_json', []))
|
|
->map(fn ($value): string => Str::lower(trim((string) $value)))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$worldTags = collect((array) ($world->related_tags_json ?? []))
|
|
->map(fn ($value): string => Str::lower(trim((string) $value)))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$keywords = $this->keywordTokens(implode(' ', array_filter([
|
|
(string) $world->title,
|
|
(string) ($world->slug ?? ''),
|
|
(string) ($world->tagline ?? ''),
|
|
(string) ($world->summary ?? ''),
|
|
trim(strip_tags((string) ($world->description ?? ''))),
|
|
(string) ($world->campaign_label ?? ''),
|
|
(string) ($world->recurrence_key ?? ''),
|
|
(string) ($world->linkedChallenge?->title ?? ''),
|
|
(string) ($world->linkedChallenge?->group?->name ?? ''),
|
|
implode(' ', $themeTags),
|
|
implode(' ', $worldTags),
|
|
])));
|
|
|
|
$relations = $world->worldRelations;
|
|
$attachedByType = $relations
|
|
->groupBy('related_type')
|
|
->map(fn (SupportCollection $items): array => $items->pluck('related_id')->map(fn ($id): int => (int) $id)->unique()->values()->all())
|
|
->all();
|
|
|
|
$liveSubmissions = WorldSubmission::query()
|
|
->where('world_id', $world->id)
|
|
->where('status', WorldSubmission::STATUS_LIVE)
|
|
->with(['artwork.user.profile', 'artwork.tags', 'artwork.categories.contentType', 'artwork.stats'])
|
|
->orderByDesc('is_featured')
|
|
->orderByDesc('featured_at')
|
|
->orderByDesc('reviewed_at')
|
|
->limit(18)
|
|
->get();
|
|
|
|
$linkedChallenge = $world->linkedChallenge && $world->linkedChallenge->group && $world->linkedChallenge->canBeViewedBy($viewer)
|
|
? $world->linkedChallenge
|
|
: null;
|
|
|
|
$challengeArtworks = $linkedChallenge
|
|
? $this->visibleChallengeArtworkQuery($linkedChallenge, $viewer)
|
|
->orderBy('group_challenge_artworks.sort_order')
|
|
->limit(16)
|
|
->get()
|
|
: collect();
|
|
|
|
$familyCreatorIds = [];
|
|
$familyGroupIds = [];
|
|
$familyCollectionIds = [];
|
|
|
|
if ((bool) $world->is_recurring && trim((string) ($world->recurrence_key ?? '')) !== '') {
|
|
$familyWorlds = World::query()
|
|
->with('worldRelations')
|
|
->where('recurrence_key', (string) $world->recurrence_key)
|
|
->where('id', '!=', $world->id)
|
|
->orderByDesc('edition_year')
|
|
->limit(6)
|
|
->get();
|
|
|
|
$familyRelationArtworkIds = $familyWorlds
|
|
->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_ARTWORK)->pluck('related_id')->all())
|
|
->map(fn ($id): int => (int) $id)
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values();
|
|
|
|
$familySubmissionArtworkIds = WorldSubmission::query()
|
|
->whereIn('world_id', $familyWorlds->pluck('id')->all())
|
|
->where('status', WorldSubmission::STATUS_LIVE)
|
|
->pluck('artwork_id')
|
|
->map(fn ($id): int => (int) $id)
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values();
|
|
|
|
$familyArtworks = Artwork::query()
|
|
->with('tags')
|
|
->whereIn('id', $familyRelationArtworkIds->merge($familySubmissionArtworkIds)->unique()->all())
|
|
->get(['id', 'user_id', 'group_id']);
|
|
|
|
$familyCreatorIds = $familyWorlds
|
|
->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_USER)->pluck('related_id')->all())
|
|
->map(fn ($id): int => (int) $id)
|
|
->merge($familyArtworks->pluck('user_id')->map(fn ($id): int => (int) $id))
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$familyGroupIds = $familyWorlds
|
|
->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_GROUP)->pluck('related_id')->all())
|
|
->map(fn ($id): int => (int) $id)
|
|
->merge($familyArtworks->pluck('group_id')->map(fn ($id): int => (int) $id))
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$familyCollectionIds = $familyWorlds
|
|
->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_COLLECTION)->pluck('related_id')->all())
|
|
->map(fn ($id): int => (int) $id)
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
$analyticsReport = $this->analytics->studioReport($world);
|
|
$analyticsRange = (array) data_get($analyticsReport, 'ranges.30d', []);
|
|
$analyticsEntityClicks = collect((array) data_get($analyticsRange, 'entity_performance', []))
|
|
->filter(fn (array $item): bool => trim((string) ($item['entity_type'] ?? '')) !== '' && (int) ($item['entity_id'] ?? 0) > 0)
|
|
->mapWithKeys(fn (array $item): array => [
|
|
$this->itemKey((string) $item['entity_type'], (int) $item['entity_id']) => [
|
|
'clicks' => (int) ($item['clicks'] ?? 0),
|
|
'section_key' => trim((string) ($item['section_key'] ?? '')),
|
|
],
|
|
])
|
|
->all();
|
|
$analyticsSectionClicks = collect((array) data_get($analyticsRange, 'section_performance', []))
|
|
->mapWithKeys(fn (array $item): array => [
|
|
trim((string) ($item['section_key'] ?? '')) => (int) ($item['clicks'] ?? 0),
|
|
])
|
|
->filter(fn (int $clicks, string $sectionKey): bool => $sectionKey !== '')
|
|
->all();
|
|
$underperformingSections = $this->underperformingSectionKeys($world, $analyticsSectionClicks, (int) data_get($analyticsRange, 'summary.views', 0));
|
|
|
|
return [
|
|
'keywords' => $keywords,
|
|
'tag_slugs' => array_values(array_unique(array_merge($worldTags, $themeTags))),
|
|
'attached_by_type' => $attachedByType,
|
|
'live_submissions' => $liveSubmissions,
|
|
'live_submission_artwork_ids' => $liveSubmissions->pluck('artwork_id')->map(fn ($id): int => (int) $id)->unique()->values()->all(),
|
|
'community_creator_ids' => $liveSubmissions->map(fn (WorldSubmission $submission): int => (int) ($submission->artwork?->user_id ?? 0))->filter(fn (int $id): bool => $id > 0)->unique()->values()->all(),
|
|
'linked_challenge' => $linkedChallenge,
|
|
'challenge_artworks' => $challengeArtworks,
|
|
'challenge_artwork_ids' => $challengeArtworks->pluck('id')->map(fn ($id): int => (int) $id)->unique()->values()->all(),
|
|
'challenge_creator_ids' => $challengeArtworks->pluck('user_id')->map(fn ($id): int => (int) $id)->filter(fn (int $id): bool => $id > 0)->unique()->values()->all(),
|
|
'challenge_group_id' => (int) ($linkedChallenge?->group_id ?? 0),
|
|
'family_creator_ids' => $familyCreatorIds,
|
|
'family_group_ids' => $familyGroupIds,
|
|
'family_collection_ids' => $familyCollectionIds,
|
|
'analytics_entity_clicks' => $analyticsEntityClicks,
|
|
'analytics_section_clicks' => $analyticsSectionClicks,
|
|
'underperforming_section_keys' => $underperformingSections,
|
|
];
|
|
}
|
|
|
|
private function artworkSuggestions(World $world, array $context, ?User $viewer): array
|
|
{
|
|
$excludedArtworkIds = array_merge(
|
|
$context['attached_by_type'][WorldRelation::TYPE_ARTWORK] ?? [],
|
|
$context['live_submission_artwork_ids'] ?? [],
|
|
$context['challenge_artwork_ids'] ?? [],
|
|
);
|
|
|
|
$query = Artwork::query()
|
|
->with(['user.profile', 'tags', 'categories.contentType', 'stats'])
|
|
->catalogVisible()
|
|
->when($excludedArtworkIds !== [], fn (Builder $builder): Builder => $builder->whereNotIn('artworks.id', $excludedArtworkIds))
|
|
->where(function (Builder $builder) use ($context): void {
|
|
$this->applyArtworkThemeFilters($builder, $context['keywords'], $context['tag_slugs']);
|
|
|
|
if ($context['family_creator_ids'] !== []) {
|
|
$builder->orWhereIn('artworks.user_id', $context['family_creator_ids']);
|
|
}
|
|
|
|
if ($context['community_creator_ids'] !== []) {
|
|
$builder->orWhereIn('artworks.user_id', $context['community_creator_ids']);
|
|
}
|
|
})
|
|
->orderByDesc('published_at')
|
|
->limit(28);
|
|
|
|
$this->maturity->applyViewerFilter($query, $viewer);
|
|
|
|
return $query->get()
|
|
->map(fn (Artwork $artwork): ?array => $this->buildArtworkSuggestionItem($artwork, $context, 'artworks', 'Artwork suggestion'))
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function communitySuggestions(World $world, array $context): array
|
|
{
|
|
return collect($context['live_submissions'])
|
|
->map(function (WorldSubmission $submission) use ($context): ?array {
|
|
$artwork = $submission->artwork;
|
|
|
|
if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) {
|
|
return null;
|
|
}
|
|
|
|
if (in_array((int) $artwork->id, $context['attached_by_type'][WorldRelation::TYPE_ARTWORK] ?? [], true)) {
|
|
return null;
|
|
}
|
|
|
|
$reasons = [
|
|
$this->reason($submission->is_featured ? 'Already a featured community submission' : 'Already live in this world', $submission->is_featured ? 'amber' : 'emerald'),
|
|
];
|
|
$score = 34 + ($submission->is_featured ? 14 : 0) + $this->artworkPerformanceScore($artwork) + $this->freshnessScore($artwork->published_at, 14, 14, 6);
|
|
|
|
if (in_array((int) $artwork->user_id, $context['family_creator_ids'], true)) {
|
|
$score += 8;
|
|
$reasons[] = $this->reason('Returning creator from this world family', 'sky');
|
|
}
|
|
|
|
$signals = [
|
|
'challenge_linked' => false,
|
|
'community_submission' => true,
|
|
'recurring_history_informed' => in_array((int) $artwork->user_id, $context['family_creator_ids'], true),
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_ARTWORK, (int) $artwork->id, $score, $reasons, $signals, 'Already drawing clicks from this world', 4, 18);
|
|
|
|
if ($this->artworkPerformanceScore($artwork) >= 12) {
|
|
$reasons[] = $this->reason('Strong engagement on platform', 'rose');
|
|
}
|
|
|
|
$preview = $this->worlds->previewArtwork($artwork, 'Community standout');
|
|
|
|
if ($preview) {
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, 'community', $score, $reasons, $signals, [
|
|
'performance_value' => $this->artworkPerformanceScore($artwork),
|
|
'freshness_timestamp' => $artwork->published_at?->timestamp,
|
|
]) : null;
|
|
})
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function challengeHighlightSuggestions(World $world, array $context): array
|
|
{
|
|
$challenge = $context['linked_challenge'];
|
|
|
|
if (! $challenge) {
|
|
return [];
|
|
}
|
|
|
|
$winnerIds = $challenge->outcomes
|
|
->where('outcome_type', GroupChallengeOutcome::TYPE_WINNER)
|
|
->pluck('artwork_id')
|
|
->map(fn ($id): int => (int) $id)
|
|
->all();
|
|
$finalistIds = $challenge->outcomes
|
|
->where('outcome_type', GroupChallengeOutcome::TYPE_FINALIST)
|
|
->pluck('artwork_id')
|
|
->map(fn ($id): int => (int) $id)
|
|
->all();
|
|
|
|
return collect($context['challenge_artworks'])
|
|
->reject(fn (Artwork $artwork): bool => in_array((int) $artwork->id, $context['attached_by_type'][WorldRelation::TYPE_ARTWORK] ?? [], true))
|
|
->map(function (Artwork $artwork) use ($winnerIds, $finalistIds, $context): ?array {
|
|
$score = 28 + $this->artworkPerformanceScore($artwork) + $this->freshnessScore($artwork->published_at, 14, 12, 6);
|
|
$reasons = [];
|
|
$signals = [
|
|
'challenge_linked' => true,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
if (in_array((int) $artwork->id, $winnerIds, true)) {
|
|
$score += 22;
|
|
$reasons[] = $this->reason('Challenge winner', 'amber');
|
|
} elseif (in_array((int) $artwork->id, $finalistIds, true)) {
|
|
$score += 16;
|
|
$reasons[] = $this->reason('Challenge finalist', 'sky');
|
|
} else {
|
|
$reasons[] = $this->reason('Linked challenge entry', 'emerald');
|
|
}
|
|
|
|
if (in_array((int) $artwork->user_id, $context['family_creator_ids'], true)) {
|
|
$score += 8;
|
|
$reasons[] = $this->reason('Creator has prior world-family momentum', 'sky');
|
|
$signals['recurring_history_informed'] = true;
|
|
}
|
|
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_ARTWORK, (int) $artwork->id, $score, $reasons, $signals);
|
|
|
|
if ($this->artworkPerformanceScore($artwork) >= 12) {
|
|
$reasons[] = $this->reason('Strong engagement on platform', 'rose');
|
|
}
|
|
|
|
$preview = $this->worlds->previewArtwork($artwork, 'Challenge highlight');
|
|
|
|
if ($preview) {
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, 'challenge', $score, $reasons, $signals, [
|
|
'performance_value' => $this->artworkPerformanceScore($artwork),
|
|
'freshness_timestamp' => $artwork->published_at?->timestamp,
|
|
]) : null;
|
|
})
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function creatorSuggestions(World $world, array $context): array
|
|
{
|
|
$candidateUserIds = collect()
|
|
->merge($context['community_creator_ids'])
|
|
->merge($context['challenge_creator_ids'])
|
|
->merge($context['family_creator_ids'])
|
|
->merge($this->matchingArtworkCreatorIds($context))
|
|
->filter(fn ($id): bool => (int) $id > 0)
|
|
->unique()
|
|
->values();
|
|
|
|
if ($candidateUserIds->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
return User::query()
|
|
->with(['profile', 'statistics'])
|
|
->whereIn('id', $candidateUserIds->all())
|
|
->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_USER] ?? [])
|
|
->get()
|
|
->map(function (User $user) use ($context): ?array {
|
|
if (! $user->username) {
|
|
return null;
|
|
}
|
|
|
|
$score = 0;
|
|
$reasons = [];
|
|
$signals = [
|
|
'challenge_linked' => false,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
if (in_array((int) $user->id, $context['community_creator_ids'], true)) {
|
|
$score += 22;
|
|
$reasons[] = $this->reason('Creator already active in this world', 'emerald');
|
|
$signals['community_submission'] = true;
|
|
}
|
|
|
|
if (in_array((int) $user->id, $context['challenge_creator_ids'], true)) {
|
|
$score += 14;
|
|
$reasons[] = $this->reason('Participating in the linked challenge', 'sky');
|
|
$signals['challenge_linked'] = true;
|
|
}
|
|
|
|
if (in_array((int) $user->id, $context['family_creator_ids'], true)) {
|
|
$score += 14;
|
|
$reasons[] = $this->reason('Strong in this world family', 'sky');
|
|
$signals['recurring_history_informed'] = true;
|
|
}
|
|
|
|
$followers = (int) ($user->statistics?->followers_count ?? 0);
|
|
if ($followers > 0) {
|
|
$score += min(12, (int) floor(log10(max(1, $followers)) * 4));
|
|
if ($followers >= 100) {
|
|
$reasons[] = $this->reason('Healthy follower momentum', 'rose');
|
|
}
|
|
}
|
|
|
|
if ((bool) $user->nova_featured_creator) {
|
|
$score += 6;
|
|
$reasons[] = $this->reason('Editorially featured creator', 'amber');
|
|
}
|
|
|
|
if ($score < 12) {
|
|
return null;
|
|
}
|
|
|
|
$preview = $this->worlds->previewUser($user, 'Creator suggestion');
|
|
|
|
if ($preview) {
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_USER, (int) $user->id, $score, $reasons, $signals);
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, 'creators', $score, $reasons, $signals, [
|
|
'performance_value' => $followers,
|
|
]) : null;
|
|
})
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function collectionSuggestions(World $world, array $context, ?User $viewer): array
|
|
{
|
|
$candidateCreatorIds = collect($context['community_creator_ids'])
|
|
->merge($context['family_creator_ids'])
|
|
->merge($context['challenge_creator_ids'])
|
|
->filter(fn ($id): bool => (int) $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
return Collection::query()
|
|
->with(['user.profile', 'group', 'coverArtwork.tags'])
|
|
->publicEligible()
|
|
->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_COLLECTION] ?? [])
|
|
->where(function (Builder $builder) use ($context, $candidateCreatorIds): void {
|
|
$this->applyTextFilters($builder, ['title', 'summary', 'description', 'subtitle', 'campaign_label', 'theme_token'], $context['keywords']);
|
|
|
|
if ($context['tag_slugs'] !== []) {
|
|
$builder->orWhereHas('coverArtwork.tags', fn (Builder $tagQuery): Builder => $tagQuery->whereIn('slug', $context['tag_slugs']));
|
|
}
|
|
|
|
if ($candidateCreatorIds !== []) {
|
|
$builder->orWhereIn('user_id', $candidateCreatorIds);
|
|
}
|
|
|
|
if ($context['family_collection_ids'] !== []) {
|
|
$builder->orWhereIn('id', $context['family_collection_ids']);
|
|
}
|
|
})
|
|
->orderByDesc('featured_at')
|
|
->orderByDesc('published_at')
|
|
->limit(24)
|
|
->get()
|
|
->map(function (Collection $collection) use ($context, $candidateCreatorIds, $viewer): ?array {
|
|
$score = $this->collectionPerformanceScore($collection);
|
|
$reasons = [];
|
|
$signals = [
|
|
'challenge_linked' => false,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
if (in_array((int) $collection->user_id, $candidateCreatorIds, true)) {
|
|
$score += 12;
|
|
$reasons[] = $this->reason('Built by a relevant creator', 'emerald');
|
|
}
|
|
|
|
if (in_array((int) $collection->id, $context['family_collection_ids'], true)) {
|
|
$score += 12;
|
|
$reasons[] = $this->reason('Recurring-world editorial signal', 'sky');
|
|
$signals['recurring_history_informed'] = true;
|
|
}
|
|
|
|
$tagOverlap = $this->overlapCount($context['tag_slugs'], $collection->coverArtwork?->tags?->pluck('slug')->map(fn ($tag): string => Str::lower((string) $tag))->all() ?? []);
|
|
if ($tagOverlap > 0) {
|
|
$score += min(16, $tagOverlap * 8);
|
|
$reasons[] = $this->reason('Cover artwork matches world tags', 'sky');
|
|
}
|
|
|
|
if ((bool) $collection->is_featured) {
|
|
$score += 6;
|
|
$reasons[] = $this->reason('Already proven in editorial surfaces', 'amber');
|
|
}
|
|
|
|
if ($this->collectionPerformanceScore($collection) >= 12) {
|
|
$reasons[] = $this->reason('Strong collection engagement', 'rose');
|
|
}
|
|
|
|
if ($score < 12) {
|
|
return null;
|
|
}
|
|
|
|
$preview = $this->worlds->previewCollection($collection, $viewer, 'Collection suggestion');
|
|
|
|
if ($preview) {
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_COLLECTION, (int) $collection->id, $score, $reasons, $signals);
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, 'collections', $score, $reasons, $signals, [
|
|
'performance_value' => $this->collectionPerformanceScore($collection),
|
|
'freshness_timestamp' => $collection->published_at?->timestamp,
|
|
]) : null;
|
|
})
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function groupSuggestions(World $world, array $context, ?User $viewer): array
|
|
{
|
|
$candidateOwnerIds = collect($context['community_creator_ids'])
|
|
->merge($context['challenge_creator_ids'])
|
|
->merge($context['family_creator_ids'])
|
|
->filter(fn ($id): bool => (int) $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$priorityGroupIds = collect($context['family_group_ids'])
|
|
->when(($context['challenge_group_id'] ?? 0) > 0, fn (SupportCollection $items): SupportCollection => $items->push((int) $context['challenge_group_id']))
|
|
->filter(fn ($id): bool => (int) $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
return Group::query()
|
|
->with('owner.profile')
|
|
->where('visibility', Group::VISIBILITY_PUBLIC)
|
|
->where('status', Group::LIFECYCLE_ACTIVE)
|
|
->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_GROUP] ?? [])
|
|
->where(function (Builder $builder) use ($context, $candidateOwnerIds, $priorityGroupIds): void {
|
|
$this->applyTextFilters($builder, ['name', 'headline', 'bio'], $context['keywords']);
|
|
|
|
if ($candidateOwnerIds !== []) {
|
|
$builder->orWhereIn('owner_user_id', $candidateOwnerIds);
|
|
}
|
|
|
|
if ($priorityGroupIds !== []) {
|
|
$builder->orWhereIn('id', $priorityGroupIds);
|
|
}
|
|
})
|
|
->orderByDesc('followers_count')
|
|
->orderByDesc('last_activity_at')
|
|
->limit(24)
|
|
->get()
|
|
->map(function (Group $group) use ($context, $candidateOwnerIds, $priorityGroupIds, $viewer): ?array {
|
|
$score = 0;
|
|
$reasons = [];
|
|
$signals = [
|
|
'challenge_linked' => false,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
if (in_array((int) $group->id, $priorityGroupIds, true)) {
|
|
$score += ((int) $group->id === (int) ($context['challenge_group_id'] ?? 0)) ? 24 : 12;
|
|
$reasons[] = $this->reason((int) $group->id === (int) ($context['challenge_group_id'] ?? 0) ? 'Group behind the linked challenge' : 'Returning world-family group', 'sky');
|
|
$signals[(int) $group->id === (int) ($context['challenge_group_id'] ?? 0) ? 'challenge_linked' : 'recurring_history_informed'] = true;
|
|
}
|
|
|
|
if (in_array((int) $group->owner_user_id, $candidateOwnerIds, true)) {
|
|
$score += 10;
|
|
$reasons[] = $this->reason('Owned by a relevant creator', 'emerald');
|
|
}
|
|
|
|
if ((bool) $group->is_verified) {
|
|
$score += 5;
|
|
$reasons[] = $this->reason('Verified group', 'amber');
|
|
}
|
|
|
|
$engagement = min(14, (int) floor(log10(max(1, (int) $group->followers_count + (int) $group->artworks_count + (int) $group->collections_count + 1)) * 6));
|
|
$score += $engagement;
|
|
if ($engagement >= 8) {
|
|
$reasons[] = $this->reason('Healthy group momentum', 'rose');
|
|
}
|
|
|
|
if ($score < 12) {
|
|
return null;
|
|
}
|
|
|
|
$preview = $this->worlds->previewGroup($group, $viewer, 'Group suggestion');
|
|
|
|
if ($preview) {
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_GROUP, (int) $group->id, $score, $reasons, $signals);
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, 'groups', $score, $reasons, $signals, [
|
|
'performance_value' => (int) $group->followers_count + (int) $group->artworks_count + (int) $group->collections_count,
|
|
]) : null;
|
|
})
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function newsSuggestions(World $world, array $context): array
|
|
{
|
|
return NewsArticle::query()
|
|
->with(['author.profile', 'category'])
|
|
->published()
|
|
->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_NEWS] ?? [])
|
|
->where(function (Builder $builder) use ($context): void {
|
|
$this->applyTextFilters($builder, ['title', 'excerpt', 'content'], $context['keywords']);
|
|
})
|
|
->orderByDesc('published_at')
|
|
->limit(24)
|
|
->get()
|
|
->map(function (NewsArticle $article) use ($world, $context): ?array {
|
|
$score = $this->freshnessScore($article->published_at, 10, 18, 8);
|
|
$reasons = [];
|
|
$signals = [
|
|
'challenge_linked' => false,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
$textHits = $this->textMatchCount(
|
|
$context['keywords'],
|
|
[(string) $article->title, (string) ($article->excerpt ?? ''), strip_tags((string) ($article->content ?? ''))],
|
|
);
|
|
|
|
if ($textHits > 0) {
|
|
$score += min(20, $textHits * 6);
|
|
$reasons[] = $this->reason('Story language lines up with this world', 'sky');
|
|
}
|
|
|
|
$headline = Str::lower((string) $article->title . ' ' . (string) ($article->excerpt ?? ''));
|
|
if ($world->isPubliclyVisible() && (Str::contains($headline, 'results') || Str::contains($headline, 'recap'))) {
|
|
$score += 8;
|
|
$reasons[] = $this->reason('Good fit for editorial follow-through', 'amber');
|
|
}
|
|
|
|
if ($score < 10) {
|
|
return null;
|
|
}
|
|
|
|
$preview = $this->worlds->previewNews($article, 'Related story suggestion');
|
|
|
|
if ($preview) {
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_NEWS, (int) $article->id, $score, $reasons, $signals);
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, 'news', $score, $reasons, $signals, [
|
|
'performance_value' => $textHits,
|
|
'freshness_timestamp' => $article->published_at?->timestamp,
|
|
]) : null;
|
|
})
|
|
->filter()
|
|
->sortByDesc('score')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function buildArtworkSuggestionItem(Artwork $artwork, array $context, string $categoryKey, string $contextLabel): ?array
|
|
{
|
|
$score = 0;
|
|
$reasons = [];
|
|
$signals = [
|
|
'challenge_linked' => false,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
];
|
|
|
|
$tagOverlap = $this->overlapCount(
|
|
$context['tag_slugs'],
|
|
$artwork->tags->pluck('slug')->map(fn ($tag): string => Str::lower((string) $tag))->all(),
|
|
);
|
|
|
|
if ($tagOverlap > 0) {
|
|
$score += min(20, $tagOverlap * 10);
|
|
$reasons[] = $this->reason('Matches world tags', 'sky');
|
|
}
|
|
|
|
$textHits = $this->textMatchCount(
|
|
$context['keywords'],
|
|
[(string) $artwork->title, (string) ($artwork->description ?? ''), implode(' ', $artwork->tags->pluck('name')->all())],
|
|
);
|
|
|
|
if ($textHits > 0) {
|
|
$score += min(18, $textHits * 6);
|
|
$reasons[] = $this->reason('Theme language lines up with the brief', 'emerald');
|
|
}
|
|
|
|
if (in_array((int) $artwork->user_id, $context['family_creator_ids'], true)) {
|
|
$score += 10;
|
|
$reasons[] = $this->reason('Creator has prior world-family momentum', 'sky');
|
|
$signals['recurring_history_informed'] = true;
|
|
}
|
|
|
|
if (in_array((int) $artwork->user_id, $context['community_creator_ids'], true)) {
|
|
$score += 8;
|
|
$reasons[] = $this->reason('Creator is already active in this world', 'emerald');
|
|
$signals['community_submission'] = true;
|
|
}
|
|
|
|
$performance = $this->artworkPerformanceScore($artwork);
|
|
$score += $performance;
|
|
if ($performance >= 12) {
|
|
$reasons[] = $this->reason('Strong engagement on platform', 'rose');
|
|
}
|
|
|
|
$this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_ARTWORK, (int) $artwork->id, $score, $reasons, $signals);
|
|
|
|
$freshness = $this->freshnessScore($artwork->published_at, 14, 12, 6);
|
|
$score += $freshness;
|
|
if ($freshness >= 8) {
|
|
$reasons[] = $this->reason('Freshly published', 'amber');
|
|
}
|
|
|
|
if ($score < 12) {
|
|
return null;
|
|
}
|
|
|
|
$preview = $this->worlds->previewArtwork($artwork, $contextLabel);
|
|
|
|
if ($preview) {
|
|
$this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
|
|
}
|
|
|
|
return $preview ? $this->finalizeItem($preview, $categoryKey, $score, $reasons, $signals, [
|
|
'performance_value' => $performance,
|
|
'freshness_timestamp' => $artwork->published_at?->timestamp,
|
|
]) : null;
|
|
}
|
|
|
|
private function matchingArtworkCreatorIds(array $context): array
|
|
{
|
|
if ($context['keywords'] === [] && $context['tag_slugs'] === []) {
|
|
return [];
|
|
}
|
|
|
|
return Artwork::query()
|
|
->with('tags')
|
|
->catalogVisible()
|
|
->where(function (Builder $builder) use ($context): void {
|
|
$this->applyArtworkThemeFilters($builder, $context['keywords'], $context['tag_slugs']);
|
|
})
|
|
->orderByDesc('published_at')
|
|
->limit(20)
|
|
->get(['id', 'user_id'])
|
|
->pluck('user_id')
|
|
->map(fn ($id): int => (int) $id)
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function stateBackedItem(WorldEditorialSuggestionState $state, SupportCollection $candidateMap, ?User $viewer): ?array
|
|
{
|
|
$candidate = $candidateMap->get($this->itemKey((string) $state->related_type, (int) $state->related_id));
|
|
|
|
if (is_array($candidate)) {
|
|
return array_merge($candidate, [
|
|
'state' => [
|
|
'status' => (string) $state->status,
|
|
'section_key' => $state->section_key,
|
|
'label' => $this->stateLabel((string) $state->status),
|
|
],
|
|
]);
|
|
}
|
|
|
|
$preview = $this->worlds->resolveEntityPreview((string) $state->related_type, (int) $state->related_id, $viewer, (string) ($state->status === WorldEditorialSuggestionState::STATUS_PINNED ? 'Pinned for later' : 'Suppressed suggestion'));
|
|
|
|
if (! $preview) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($this->finalizeItem(
|
|
$preview,
|
|
$state->status === WorldEditorialSuggestionState::STATUS_PINNED ? 'saved' : 'suppressed',
|
|
50,
|
|
[$this->reason($state->status === WorldEditorialSuggestionState::STATUS_PINNED ? 'Saved by an editor' : 'Suppressed for this edition', 'slate')],
|
|
), [
|
|
'state' => [
|
|
'status' => (string) $state->status,
|
|
'section_key' => $state->section_key,
|
|
'label' => $this->stateLabel((string) $state->status),
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function finalizeItem(array $preview, string $categoryKey, int $score, array $reasons, array $signals = [], array $ranking = []): array
|
|
{
|
|
$sectionTargets = $this->sectionTargetsForType((string) ($preview['entity_type'] ?? ''));
|
|
$defaultSection = $sectionTargets[0] ?? null;
|
|
$normalizedScore = max(0, min(99, $score));
|
|
|
|
return array_merge($preview, [
|
|
'key' => $this->itemKey((string) ($preview['entity_type'] ?? ''), (int) ($preview['id'] ?? 0)),
|
|
'entity_id' => (int) ($preview['id'] ?? 0),
|
|
'category_key' => $categoryKey,
|
|
'category_label' => $this->groupDefinition($categoryKey)['label'] ?? Str::headline($categoryKey),
|
|
'score' => $normalizedScore,
|
|
'score_label' => $this->scoreLabel($score),
|
|
'reasons' => collect($reasons)->filter()->unique('label')->values()->take(4)->all(),
|
|
'section_targets' => $sectionTargets,
|
|
'default_section_key' => $defaultSection['value'] ?? null,
|
|
'default_section_label' => $defaultSection['label'] ?? null,
|
|
'signals' => array_merge([
|
|
'challenge_linked' => false,
|
|
'community_submission' => false,
|
|
'recurring_history_informed' => false,
|
|
'analytics_informed' => false,
|
|
'not_yet_featured' => true,
|
|
], $signals),
|
|
'ranking' => [
|
|
'score' => $normalizedScore,
|
|
'performance_value' => (int) ($ranking['performance_value'] ?? $normalizedScore),
|
|
'freshness_timestamp' => isset($ranking['freshness_timestamp']) ? (int) $ranking['freshness_timestamp'] : null,
|
|
],
|
|
'state' => [
|
|
'status' => 'available',
|
|
'section_key' => null,
|
|
'label' => 'Available',
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function relationPayload(WorldRelation $relation, ?User $viewer = null): array
|
|
{
|
|
return [
|
|
'id' => (int) $relation->id,
|
|
'section_key' => (string) $relation->section_key,
|
|
'related_type' => (string) $relation->related_type,
|
|
'related_id' => (int) $relation->related_id,
|
|
'context_label' => (string) ($relation->context_label ?? ''),
|
|
'sort_order' => (int) $relation->sort_order,
|
|
'is_featured' => (bool) $relation->is_featured,
|
|
'preview' => $this->worlds->resolveEntityPreview((string) $relation->related_type, (int) $relation->related_id, $viewer, (string) ($relation->context_label ?? '')),
|
|
];
|
|
}
|
|
|
|
private function assertSectionCompatibility(string $relatedType, string $sectionKey): void
|
|
{
|
|
$valid = collect((array) config('worlds.sections', []))
|
|
->filter(fn (array $section): bool => in_array($relatedType, (array) ($section['relation_types'] ?? []), true))
|
|
->keys()
|
|
->all();
|
|
|
|
if (! in_array($sectionKey, $valid, true)) {
|
|
abort(422, 'That suggestion cannot be attached to the requested section.');
|
|
}
|
|
}
|
|
|
|
private function applyArtworkThemeFilters(Builder $builder, array $keywords, array $tagSlugs): void
|
|
{
|
|
if ($tagSlugs !== []) {
|
|
$builder->whereHas('tags', fn (Builder $tagQuery): Builder => $tagQuery->whereIn('slug', $tagSlugs));
|
|
}
|
|
|
|
foreach ($keywords as $keyword) {
|
|
$builder->orWhere('artworks.title', 'like', '%' . $keyword . '%')
|
|
->orWhere('artworks.description', 'like', '%' . $keyword . '%');
|
|
}
|
|
}
|
|
|
|
private function applyTextFilters(Builder $builder, array $columns, array $keywords): void
|
|
{
|
|
foreach ($keywords as $keyword) {
|
|
foreach ($columns as $column) {
|
|
$builder->orWhere($column, 'like', '%' . $keyword . '%');
|
|
}
|
|
}
|
|
}
|
|
|
|
private function sectionTargetsForType(string $relatedType): array
|
|
{
|
|
return collect((array) config('worlds.sections', []))
|
|
->filter(fn (array $section): bool => in_array($relatedType, (array) ($section['relation_types'] ?? []), true))
|
|
->map(fn (array $section, string $key): array => [
|
|
'value' => $key,
|
|
'label' => (string) ($section['label'] ?? Str::headline($key)),
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function sectionFilterOptions(): array
|
|
{
|
|
return collect((array) config('worlds.sections', []))
|
|
->map(fn (array $section, string $key): array => [
|
|
'value' => $key,
|
|
'label' => (string) ($section['label'] ?? Str::headline($key)),
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function sortFilterOptions(): array
|
|
{
|
|
return [
|
|
['value' => 'relevance', 'label' => 'Best fit'],
|
|
['value' => 'newest', 'label' => 'Newest'],
|
|
['value' => 'performance', 'label' => 'Highest performing'],
|
|
];
|
|
}
|
|
|
|
private function typeFilterOptions(): array
|
|
{
|
|
return collect((array) config('worlds.relation_types', []))
|
|
->map(fn (string $label, string $value): array => [
|
|
'value' => $value,
|
|
'label' => $label,
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function groupDefinition(string $groupKey): array
|
|
{
|
|
return match ($groupKey) {
|
|
'challenge' => [
|
|
'label' => 'Challenge highlights',
|
|
'description' => 'Winners, finalists, and standout entries pulled from the linked challenge.',
|
|
'empty_label' => 'No challenge highlights are ready yet.',
|
|
],
|
|
'community' => [
|
|
'label' => 'Community standouts',
|
|
'description' => 'Strong creator submissions already live inside this world.',
|
|
'empty_label' => 'No live community standouts are available yet.',
|
|
],
|
|
'artworks' => [
|
|
'label' => 'Artwork candidates',
|
|
'description' => 'Public artworks that match the world theme, freshness, and editorial quality signals.',
|
|
'empty_label' => 'No extra artwork candidates rose above the current threshold.',
|
|
],
|
|
'creators' => [
|
|
'label' => 'Creator candidates',
|
|
'description' => 'Creators with relevant world, challenge, or recurring-family momentum.',
|
|
'empty_label' => 'No creator suggestions are ready yet.',
|
|
],
|
|
'collections' => [
|
|
'label' => 'Collection candidates',
|
|
'description' => 'Collections that deepen the theme without requiring manual discovery sweeps.',
|
|
'empty_label' => 'No collection suggestions are ready yet.',
|
|
],
|
|
'groups' => [
|
|
'label' => 'Group candidates',
|
|
'description' => 'Relevant scenes, crews, and collectives connected to this world or its challenge.',
|
|
'empty_label' => 'No group suggestions are ready yet.',
|
|
],
|
|
'news' => [
|
|
'label' => 'Related editorial content',
|
|
'description' => 'Published stories and announcements that strengthen the world framing.',
|
|
'empty_label' => 'No related stories are ready yet.',
|
|
],
|
|
'saved' => [
|
|
'label' => 'Saved for later',
|
|
'description' => 'Pinned items stay visible here until an editor acts on them.',
|
|
'empty_label' => 'No saved suggestions yet.',
|
|
],
|
|
default => [
|
|
'label' => Str::headline($groupKey),
|
|
'description' => '',
|
|
'empty_label' => 'No suggestions are ready.',
|
|
],
|
|
};
|
|
}
|
|
|
|
private function keywordTokens(string $value): array
|
|
{
|
|
return collect(preg_split('/[^a-z0-9]+/i', Str::lower($value)) ?: [])
|
|
->map(fn ($token): string => trim((string) $token))
|
|
->filter(fn (string $token): bool => strlen($token) >= 3 && ! in_array($token, self::STOP_WORDS, true))
|
|
->unique()
|
|
->take(12)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function overlapCount(array $left, array $right): int
|
|
{
|
|
return count(array_intersect(array_map('strval', $left), array_map('strval', $right)));
|
|
}
|
|
|
|
private function underperformingSectionKeys(World $world, array $sectionClicks, int $viewCount): array
|
|
{
|
|
$visibleSections = collect($world->sectionOrder())
|
|
->filter(fn (string $key): bool => ($world->sectionVisibility()[$key] ?? true) === true)
|
|
->values();
|
|
|
|
if ($visibleSections->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$maxClicks = max([0, ...array_values($sectionClicks)]);
|
|
|
|
if ($maxClicks < 4 && $viewCount < 20) {
|
|
return [];
|
|
}
|
|
|
|
return $visibleSections
|
|
->filter(function (string $sectionKey) use ($sectionClicks, $maxClicks): bool {
|
|
$clicks = (int) ($sectionClicks[$sectionKey] ?? 0);
|
|
|
|
if ($clicks === 0) {
|
|
return true;
|
|
}
|
|
|
|
if ($maxClicks >= 10 && $clicks <= (int) floor($maxClicks / 4)) {
|
|
return true;
|
|
}
|
|
|
|
return $maxClicks >= 6 && $clicks <= 2;
|
|
})
|
|
->take(3)
|
|
->all();
|
|
}
|
|
|
|
private function textMatchCount(array $keywords, array $haystacks): int
|
|
{
|
|
$haystack = Str::lower(implode(' ', array_filter(array_map(static fn ($value): string => trim((string) $value), $haystacks))));
|
|
|
|
return collect($keywords)
|
|
->filter(fn (string $keyword): bool => $keyword !== '' && Str::contains($haystack, $keyword))
|
|
->count();
|
|
}
|
|
|
|
private function applyAnalyticsEntityBoost(array $context, string $relatedType, int $relatedId, int &$score, array &$reasons, array &$signals, string $fallbackLabel = 'Already drawing clicks in this world', int $multiplier = 3, int $maxBoost = 16): void
|
|
{
|
|
$analytics = (array) ($context['analytics_entity_clicks'][$this->itemKey($relatedType, $relatedId)] ?? []);
|
|
$clicks = (int) ($analytics['clicks'] ?? 0);
|
|
|
|
if ($clicks < 1) {
|
|
return;
|
|
}
|
|
|
|
$score += min($maxBoost, $clicks * $multiplier);
|
|
$reasons[] = $this->reason($clicks >= 4 ? 'Top-clicked in this world' : $fallbackLabel, 'amber');
|
|
$signals['analytics_informed'] = true;
|
|
}
|
|
|
|
private function applyUnderperformingSectionBoost(array $context, array $preview, int &$score, array &$reasons, array &$signals): void
|
|
{
|
|
$sectionTarget = collect($this->sectionTargetsForType((string) ($preview['entity_type'] ?? '')))
|
|
->first(fn (array $target): bool => in_array((string) ($target['value'] ?? ''), $context['underperforming_section_keys'] ?? [], true));
|
|
|
|
if (! is_array($sectionTarget)) {
|
|
return;
|
|
}
|
|
|
|
$score += 4;
|
|
$reasons[] = $this->reason('Can strengthen the quieter ' . Str::lower((string) ($sectionTarget['label'] ?? 'target')) . ' section', 'slate');
|
|
$signals['analytics_informed'] = true;
|
|
}
|
|
|
|
private function artworkPerformanceScore(Artwork $artwork): int
|
|
{
|
|
$views = (int) ($artwork->stats?->views ?? 0);
|
|
$likes = (int) ($artwork->stats?->favorites ?? 0);
|
|
$downloads = (int) ($artwork->stats?->downloads ?? 0);
|
|
$heatScore = (float) ($artwork->stats?->heat_score ?? 0);
|
|
|
|
return min(20,
|
|
(int) floor(log10(max(1, $views)) * 4)
|
|
+ (int) floor(log10(max(1, $likes + 1)) * 6)
|
|
+ (int) floor(log10(max(1, $downloads + 1)) * 4)
|
|
+ ($heatScore >= 25 ? 4 : ($heatScore >= 8 ? 2 : 0))
|
|
);
|
|
}
|
|
|
|
private function collectionPerformanceScore(Collection $collection): int
|
|
{
|
|
$score = (int) floor(log10(max(1, (int) $collection->views_count + 1)) * 3)
|
|
+ (int) floor(log10(max(1, (int) $collection->likes_count + 1)) * 5)
|
|
+ (int) floor(log10(max(1, (int) $collection->saves_count + 1)) * 5)
|
|
+ (int) floor(log10(max(1, (int) $collection->followers_count + 1)) * 4);
|
|
|
|
return min(18, $score);
|
|
}
|
|
|
|
private function freshnessScore(mixed $date, int $withinDays, int $freshPoints, int $stalePoints): int
|
|
{
|
|
if (! $date) {
|
|
return 0;
|
|
}
|
|
|
|
$days = now()->diffInDays($date);
|
|
|
|
if ($days <= $withinDays) {
|
|
return $freshPoints;
|
|
}
|
|
|
|
if ($days <= $withinDays * 3) {
|
|
return $stalePoints;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private function reason(string $label, string $tone = 'default'): array
|
|
{
|
|
return [
|
|
'label' => $label,
|
|
'tone' => $tone,
|
|
];
|
|
}
|
|
|
|
private function itemKey(string $relatedType, int $relatedId): string
|
|
{
|
|
return $relatedType . ':' . $relatedId;
|
|
}
|
|
|
|
private function isAlreadyAttached(array $item, array $context): bool
|
|
{
|
|
return in_array((int) ($item['entity_id'] ?? 0), $context['attached_by_type'][(string) ($item['entity_type'] ?? '')] ?? [], true);
|
|
}
|
|
|
|
private function scoreLabel(int $score): string
|
|
{
|
|
return match (true) {
|
|
$score >= 70 => 'Outstanding fit',
|
|
$score >= 48 => 'Strong fit',
|
|
$score >= 28 => 'Worth review',
|
|
default => 'Light signal',
|
|
};
|
|
}
|
|
|
|
private function stateLabel(string $status): string
|
|
{
|
|
return match ($status) {
|
|
WorldEditorialSuggestionState::STATUS_PINNED => 'Pinned',
|
|
WorldEditorialSuggestionState::STATUS_DISMISSED => 'Dismissed',
|
|
WorldEditorialSuggestionState::STATUS_NOT_RELEVANT => 'Not relevant',
|
|
default => 'Saved',
|
|
};
|
|
}
|
|
|
|
private function visibleChallengeArtworkQuery(GroupChallenge $challenge, ?User $viewer = null): Builder
|
|
{
|
|
$query = Artwork::query()
|
|
->select('artworks.*', 'group_challenge_artworks.sort_order as challenge_sort_order')
|
|
->join('group_challenge_artworks', function ($join) use ($challenge): void {
|
|
$join->on('group_challenge_artworks.artwork_id', '=', 'artworks.id')
|
|
->where('group_challenge_artworks.group_challenge_id', '=', $challenge->id);
|
|
})
|
|
->with(['user.profile', 'tags', 'categories.contentType', 'stats'])
|
|
->catalogVisible();
|
|
|
|
$this->maturity->applyViewerFilter($query, $viewer);
|
|
|
|
return $query;
|
|
}
|
|
} |