Files
SkinbaseNova/app/Services/Worlds/WorldEditorialSuggestionService.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;
}
}