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; } }