Build world campaigns rewards and recaps

This commit is contained in:
2026-05-01 11:44:41 +02:00
parent 28e7e46e13
commit 257b0dbef6
100 changed files with 11300 additions and 367 deletions

View File

@@ -0,0 +1,455 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\WorldRewardType;
use App\Models\Artwork;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeOutcome;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WorldProfileHistoryService
{
public function publicPayloadForUser(User $user): array
{
return $this->buildPayload($user, false);
}
public function ownerPayloadForUser(User $user): array
{
return $this->buildPayload($user, true);
}
private function buildPayload(User $user, bool $includeOwnerContext): array
{
$submissions = $this->submissionsForUser($user);
$rewardGrants = $this->rewardGrantsForUser($user);
$challengeOutcomes = $this->challengeOutcomesForUser($user);
$challengeWorldMap = $this->challengeWorldMap($challengeOutcomes->pluck('group_challenge_id')->unique()->values());
$entries = [];
$hiddenPublicEntries = 0;
foreach ($rewardGrants as $grant) {
if (! $this->grantQualifiesForPublicHistory($grant, $submissions)) {
$hiddenPublicEntries++;
continue;
}
$this->addRecognition(
$entries,
$grant->world,
$grant->reward_type->value,
$grant->artwork,
$grant->granted_at,
$grant->grant_source === 'challenge' ? $this->challengeContextForWorld($grant->world) : null,
'reward'
);
}
$liveSubmissions = $submissions
->filter(fn (WorldSubmission $submission): bool => $this->submissionQualifiesForPublicHistory($submission));
foreach ($liveSubmissions as $submission) {
$recognitionKey = $submission->is_featured ? WorldRewardType::Featured->value : WorldRewardType::Participant->value;
$this->addRecognition(
$entries,
$submission->world,
$recognitionKey,
$submission->artwork,
$submission->featured_at ?? $submission->reviewed_at ?? $submission->created_at,
$this->challengeContextForWorld($submission->world),
'participation'
);
}
foreach ($challengeOutcomes as $outcome) {
$recognitionKey = $this->recognitionKeyForOutcome($outcome->outcome_type);
if ($recognitionKey === null || ! $this->artworkIsPubliclyVisible($outcome->artwork)) {
$hiddenPublicEntries++;
continue;
}
$worlds = $challengeWorldMap->get((int) $outcome->group_challenge_id, Collection::make());
if ($worlds->isEmpty()) {
continue;
}
foreach ($worlds as $world) {
$this->addRecognition(
$entries,
$world,
$recognitionKey,
$outcome->artwork,
$outcome->awarded_at ?? $outcome->created_at,
$this->challengeContextFromChallenge($outcome->challenge),
'challenge_outcome'
);
}
}
$normalizedEntries = Collection::make($entries)
->map(fn (array $entry): array => $this->normalizeEntry($entry))
->sort(function (array $left, array $right): int {
if ($left['occurred_at'] !== $right['occurred_at']) {
return strcmp((string) $right['occurred_at'], (string) $left['occurred_at']);
}
if ($left['primary_recognition']['priority'] !== $right['primary_recognition']['priority']) {
return $left['primary_recognition']['priority'] <=> $right['primary_recognition']['priority'];
}
return strcmp((string) $left['world']['title'], (string) $right['world']['title']);
})
->values();
$yearValues = $normalizedEntries
->pluck('world.edition_year')
->filter(fn ($year): bool => is_int($year) || ctype_digit((string) $year))
->map(fn ($year): int => (int) $year)
->values();
$worldAppearances = $normalizedEntries->count();
$highlights = $normalizedEntries->take(3)->values();
$mostRecent = $normalizedEntries->first();
return [
'summary' => [
'available' => $normalizedEntries->isNotEmpty(),
'world_appearances' => $worldAppearances,
'worlds_joined' => $worldAppearances,
'featured_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('featured', $entry['recognition_keys'], true))->count(),
'winner_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('winner', $entry['recognition_keys'], true))->count(),
'finalist_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('finalist', $entry['recognition_keys'], true))->count(),
'spotlight_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('spotlight', $entry['recognition_keys'], true))->count(),
'finalist_winner_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('winner', $entry['recognition_keys'], true) || in_array('finalist', $entry['recognition_keys'], true))->count(),
'active_year_span' => $this->yearSpanPayload($yearValues),
'most_recent_world_activity' => $mostRecent ? [
'world_title' => $mostRecent['world']['title'],
'primary_recognition' => $mostRecent['primary_recognition'],
'recognition_label' => $mostRecent['primary_recognition']['label'],
'world_url' => $mostRecent['world']['url'],
'occurred_at' => $mostRecent['occurred_at'],
] : null,
],
'highlights' => $highlights->all(),
'entries' => $normalizedEntries->all(),
'owner_context' => $includeOwnerContext ? [
'pending_submissions' => $submissions->where('status', WorldSubmission::STATUS_PENDING)->count(),
'removed_or_blocked_submissions' => $submissions->filter(fn (WorldSubmission $submission): bool => in_array((string) $submission->status, [WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true))->count(),
'hidden_public_entries' => $hiddenPublicEntries,
] : null,
'filters' => [
'default_order' => 'recent_first',
'groupable_by' => ['year', 'world_family', 'recognition_type'],
],
];
}
private function submissionsForUser(User $user): Collection
{
return WorldSubmission::query()
->with([
'world:id,title,slug,type,recurrence_key,edition_year,linked_challenge_id,status,published_at,deleted_at',
'world.linkedChallenge.group:id,name,slug,visibility,status',
'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
])
->where('submitted_by_user_id', (int) $user->id)
->whereHas('world', fn ($builder) => $builder->publiclyVisible())
->whereHas('artwork', fn ($builder) => $builder->where('user_id', (int) $user->id))
->get();
}
private function rewardGrantsForUser(User $user): Collection
{
return WorldRewardGrant::query()
->with([
'world:id,title,slug,type,recurrence_key,edition_year,linked_challenge_id,status,published_at,deleted_at',
'world.linkedChallenge.group:id,name,slug,visibility,status',
'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
'worldSubmission:id,world_id,artwork_id,status,is_featured,featured_at,reviewed_at,created_at',
])
->where('user_id', (int) $user->id)
->whereHas('world', fn ($builder) => $builder->publiclyVisible())
->orderByDesc('granted_at')
->orderByDesc('id')
->get();
}
private function challengeOutcomesForUser(User $user): Collection
{
return GroupChallengeOutcome::query()
->with([
'challenge.group:id,name,slug,visibility,status',
'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
])
->where('user_id', (int) $user->id)
->whereHas('artwork', fn ($builder) => $builder->where('user_id', (int) $user->id))
->get();
}
private function challengeWorldMap(Collection $challengeIds): Collection
{
if ($challengeIds->isEmpty()) {
return Collection::make();
}
$map = Collection::make();
World::query()
->with('linkedChallenge.group')
->publiclyVisible()
->whereIn('linked_challenge_id', $challengeIds->all())
->get()
->each(function (World $world) use (&$map): void {
$challengeId = (int) ($world->linked_challenge_id ?? 0);
if ($challengeId <= 0) {
return;
}
$items = $map->get($challengeId, Collection::make());
$map->put($challengeId, $items->push($world)->unique('id')->values());
});
WorldRelation::query()
->with(['world.linkedChallenge.group'])
->where('related_type', WorldRelation::TYPE_CHALLENGE)
->whereIn('related_id', $challengeIds->all())
->whereHas('world', fn ($builder) => $builder->publiclyVisible())
->get()
->each(function (WorldRelation $relation) use (&$map): void {
$challengeId = (int) $relation->related_id;
$world = $relation->world;
if (! $world) {
return;
}
$items = $map->get($challengeId, Collection::make());
$map->put($challengeId, $items->push($world)->unique('id')->values());
});
return $map;
}
private function grantQualifiesForPublicHistory(WorldRewardGrant $grant, Collection $submissions): bool
{
if (! $grant->world || ! $this->artworkIsPubliclyVisible($grant->artwork)) {
return false;
}
return match ($grant->reward_type) {
WorldRewardType::Participant => $submissions->contains(fn (WorldSubmission $submission): bool => (int) $submission->world_id === (int) $grant->world_id && $this->submissionQualifiesForPublicHistory($submission)),
WorldRewardType::Featured => $submissions->contains(fn (WorldSubmission $submission): bool => (int) $submission->world_id === (int) $grant->world_id && $this->submissionQualifiesForPublicHistory($submission) && (bool) $submission->is_featured),
default => true,
};
}
private function submissionQualifiesForPublicHistory(WorldSubmission $submission): bool
{
return $submission->world !== null
&& (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $this->artworkIsPubliclyVisible($submission->artwork);
}
private function artworkIsPubliclyVisible(?Artwork $artwork): bool
{
if (! $artwork) {
return false;
}
return $artwork->deleted_at === null
&& (bool) $artwork->is_approved
&& (bool) $artwork->is_public
&& $artwork->published_at !== null
&& $artwork->published_at->isPast()
&& in_array((string) ($artwork->visibility ?? Artwork::VISIBILITY_PUBLIC), ['', Artwork::VISIBILITY_PUBLIC], true);
}
private function recognitionKeyForOutcome(string $outcomeType): ?string
{
return match ($outcomeType) {
GroupChallengeOutcome::TYPE_WINNER => 'winner',
GroupChallengeOutcome::TYPE_FINALIST => 'finalist',
GroupChallengeOutcome::TYPE_FEATURED => 'featured',
GroupChallengeOutcome::TYPE_RUNNER_UP => 'runner_up',
GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'honorable_mention',
default => null,
};
}
private function addRecognition(array &$entries, World $world, string $recognitionKey, ?Artwork $artwork, ?\DateTimeInterface $occurredAt, ?array $challengeContext, string $sourceType): void
{
$worldId = (int) $world->id;
$timestamp = $occurredAt?->format(DATE_ATOM) ?? date(DATE_ATOM);
if (! array_key_exists($worldId, $entries)) {
$entries[$worldId] = [
'world' => $world,
'recognitions' => [],
'occurred_at' => $timestamp,
];
}
if (! array_key_exists($recognitionKey, $entries[$worldId]['recognitions'])) {
$entries[$worldId]['recognitions'][$recognitionKey] = [
'recognition' => $this->recognitionPayload($recognitionKey),
'linked_artwork' => $this->artworkPayload($artwork),
'challenge' => $challengeContext,
'occurred_at' => $timestamp,
'source_types' => [$sourceType],
];
} else {
$current = $entries[$worldId]['recognitions'][$recognitionKey];
$entries[$worldId]['recognitions'][$recognitionKey] = [
'recognition' => $current['recognition'],
'linked_artwork' => $current['linked_artwork'] ?? $this->artworkPayload($artwork),
'challenge' => $current['challenge'] ?? $challengeContext,
'occurred_at' => max((string) $current['occurred_at'], $timestamp),
'source_types' => array_values(array_unique([...$current['source_types'], $sourceType])),
];
}
if ($timestamp > (string) $entries[$worldId]['occurred_at']) {
$entries[$worldId]['occurred_at'] = $timestamp;
}
}
private function normalizeEntry(array $entry): array
{
/** @var World $world */
$world = $entry['world'];
$recognitions = Collection::make($entry['recognitions'])
->sort(function (array $left, array $right): int {
if ($left['recognition']['priority'] !== $right['recognition']['priority']) {
return $left['recognition']['priority'] <=> $right['recognition']['priority'];
}
return strcmp((string) $right['occurred_at'], (string) $left['occurred_at']);
})
->values();
$primary = $recognitions->first();
$linkedArtwork = $primary['linked_artwork'] ?? $recognitions->pluck('linked_artwork')->first(fn ($item) => $item !== null);
$challenge = $primary['challenge'] ?? $recognitions->pluck('challenge')->first(fn ($item) => $item !== null);
return [
'id' => 'world-history-' . (int) $world->id,
'world' => [
'id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'url' => $world->publicUrl(),
'type' => (string) $world->type,
'type_label' => Str::headline((string) $world->type),
'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
'family_key' => (string) ($world->recurrence_key ?: 'world-' . $world->id),
'family_label' => $this->familyLabelForWorld($world),
],
'primary_recognition' => $primary['recognition'],
'recognitions' => $recognitions->map(fn (array $recognition): array => [
...$recognition['recognition'],
'source_types' => $recognition['source_types'],
])->all(),
'recognition_keys' => $recognitions->map(fn (array $recognition): string => (string) $recognition['recognition']['key'])->values()->all(),
'linked_artwork' => $linkedArtwork,
'challenge' => $challenge,
'occurred_at' => (string) $entry['occurred_at'],
'source_types' => $recognitions->flatMap(fn (array $recognition): array => $recognition['source_types'])->unique()->values()->all(),
];
}
private function artworkPayload(?Artwork $artwork): ?array
{
if (! $artwork || ! $this->artworkIsPubliclyVisible($artwork)) {
return null;
}
return [
'id' => (int) $artwork->id,
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
'thumbnail_url' => $artwork->thumb_url,
];
}
private function challengeContextForWorld(?World $world): ?array
{
if (! $world || ! $world->linkedChallenge || ! $world->linkedChallenge->canBeViewedBy(null)) {
return null;
}
return $this->challengeContextFromChallenge($world->linkedChallenge);
}
private function challengeContextFromChallenge(?GroupChallenge $challenge): ?array
{
if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy(null)) {
return null;
}
return [
'id' => (int) $challenge->id,
'title' => (string) $challenge->title,
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
'group_name' => (string) $challenge->group->name,
];
}
private function recognitionPayload(string $recognitionKey): array
{
return match ($recognitionKey) {
'winner' => ['key' => 'winner', 'label' => 'Winner', 'tone' => 'emerald', 'priority' => 0],
'finalist' => ['key' => 'finalist', 'label' => 'Finalist', 'tone' => 'violet', 'priority' => 1],
'featured' => ['key' => 'featured', 'label' => 'Featured', 'tone' => 'amber', 'priority' => 2],
'spotlight' => ['key' => 'spotlight', 'label' => 'Spotlight', 'tone' => 'rose', 'priority' => 3],
'participant' => ['key' => 'participant', 'label' => 'Participant', 'tone' => 'sky', 'priority' => 4],
'runner_up' => ['key' => 'runner_up', 'label' => 'Runner-up', 'tone' => 'slate', 'priority' => 5],
'honorable_mention' => ['key' => 'honorable_mention', 'label' => 'Honorable Mention', 'tone' => 'slate', 'priority' => 6],
default => ['key' => $recognitionKey, 'label' => Str::headline(str_replace('_', ' ', $recognitionKey)), 'tone' => 'slate', 'priority' => 7],
};
}
private function familyLabelForWorld(World $world): string
{
if ($world->recurrence_key) {
return Str::headline(str_replace('-', ' ', (string) $world->recurrence_key));
}
if ($world->edition_year) {
return trim((string) preg_replace('/\s+' . preg_quote((string) $world->edition_year, '/') . '$/', '', (string) $world->title));
}
return (string) $world->title;
}
private function yearSpanPayload(Collection $years): ?array
{
if ($years->isEmpty()) {
return null;
}
$start = (int) $years->min();
$end = (int) $years->max();
return [
'start' => $start,
'end' => $end,
'label' => $start === $end ? (string) $start : sprintf('%d-%d', $start, $end),
];
}
}

View File

@@ -0,0 +1,847 @@
<?php
declare(strict_types=1);
namespace App\Services\Worlds;
use App\Enums\WorldRewardType;
use App\Models\User;
use App\Models\World;
use App\Models\WorldAnalyticsEvent;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class WorldAnalyticsService
{
public const EVENT_SOURCE_IMPRESSION = 'world_source_impression';
public const EVENT_VIEWED = 'world_viewed';
public const EVENT_SOURCE_CLICKED = 'world_source_clicked';
public const EVENT_CTA_CLICKED = 'world_cta_clicked';
public const EVENT_SECTION_CLICKED = 'world_section_clicked';
public const EVENT_ENTITY_CLICKED = 'world_entity_clicked';
public const EVENT_SUBMISSION_STARTED = 'world_submission_started';
public const EVENT_SUBMISSION_CREATED = 'world_submission_created';
public const EVENT_SUBMISSION_APPROVED = 'world_submission_approved';
public const EVENT_SUBMISSION_REMOVED = 'world_submission_removed';
public const EVENT_SUBMISSION_BLOCKED = 'world_submission_blocked';
public const EVENT_SUBMISSION_FEATURED = 'world_submission_featured';
public const EVENT_CHALLENGE_CTA_CLICKED = 'world_challenge_cta_clicked';
public const EVENT_REWARD_GRANTED = 'world_reward_granted';
public const SOURCE_HOMEPAGE_SPOTLIGHT = 'homepage_spotlight';
public const SOURCE_HOMEPAGE_WORLDS_RAIL = 'homepage_worlds_rail';
public const SOURCE_WORLDS_INDEX = 'worlds_index';
public const SOURCE_NAVIGATION = 'navigation';
public const SOURCE_UPLOAD_FLOW = 'upload_flow';
public const SOURCE_CHALLENGE_PAGE = 'challenge_page';
public const SOURCE_NEWS_ARTICLE = 'news_article';
public const SOURCE_PROFILE = 'profile';
public const SOURCE_DIRECT = 'direct';
public const SOURCE_UNKNOWN = 'unknown';
private const RANGE_WINDOWS = [
'7d' => 7,
'30d' => 30,
'all' => null,
];
private const PORTFOLIO_RANGE_WINDOWS = [
'30d' => 30,
'all' => null,
];
public function allowedEventTypes(): array
{
return [
self::EVENT_SOURCE_IMPRESSION,
self::EVENT_VIEWED,
self::EVENT_SOURCE_CLICKED,
self::EVENT_CTA_CLICKED,
self::EVENT_SECTION_CLICKED,
self::EVENT_ENTITY_CLICKED,
self::EVENT_SUBMISSION_STARTED,
self::EVENT_SUBMISSION_CREATED,
self::EVENT_SUBMISSION_APPROVED,
self::EVENT_SUBMISSION_REMOVED,
self::EVENT_SUBMISSION_BLOCKED,
self::EVENT_SUBMISSION_FEATURED,
self::EVENT_CHALLENGE_CTA_CLICKED,
self::EVENT_REWARD_GRANTED,
];
}
public function allowedSourceSurfaces(): array
{
return [
self::SOURCE_HOMEPAGE_SPOTLIGHT,
self::SOURCE_HOMEPAGE_WORLDS_RAIL,
self::SOURCE_WORLDS_INDEX,
self::SOURCE_NAVIGATION,
self::SOURCE_UPLOAD_FLOW,
self::SOURCE_CHALLENGE_PAGE,
self::SOURCE_NEWS_ARTICLE,
self::SOURCE_PROFILE,
self::SOURCE_DIRECT,
self::SOURCE_UNKNOWN,
];
}
public function recordEvent(Request $request, array $payload): void
{
$world = World::query()
->select(['id', 'slug', 'type', 'recurrence_key', 'edition_year'])
->findOrFail((int) $payload['world_id']);
$user = $request->user();
WorldAnalyticsEvent::query()->create([
'world_id' => (int) $world->id,
'event_type' => (string) $payload['event_type'],
'world_slug' => (string) $world->slug,
'world_type' => (string) $world->type,
'recurrence_key' => $this->nullableString($world->recurrence_key),
'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
'section_key' => $this->nullableString($payload['section_key'] ?? null),
'cta_key' => $this->nullableString($payload['cta_key'] ?? null),
'entity_type' => $this->nullableString($payload['entity_type'] ?? null),
'entity_id' => isset($payload['entity_id']) ? (int) $payload['entity_id'] : null,
'entity_title' => $this->nullableString($payload['entity_title'] ?? null),
'challenge_id' => isset($payload['challenge_id']) ? (int) $payload['challenge_id'] : null,
'source_surface' => $this->nullableString($payload['source_surface'] ?? null),
'source_detail' => $this->nullableString($payload['source_detail'] ?? null),
'viewer_type' => $user ? 'user' : 'guest',
'user_id' => $user ? (int) $user->id : null,
'visitor_key' => $this->resolveVisitorKey($request, $user, (string) ($payload['visitor_token'] ?? '')),
'meta' => $this->sanitizeMeta($payload['meta'] ?? []),
'occurred_at' => now(),
]);
}
public function recordSubmissionLifecycle(WorldSubmission $submission, string $eventType, ?User $actor = null, ?string $sourceSurface = null, array $meta = []): void
{
$submission->loadMissing(['world', 'artwork']);
$world = $submission->world;
if (! $world) {
return;
}
WorldAnalyticsEvent::query()->create([
'world_id' => (int) $world->id,
'event_type' => $eventType,
'world_slug' => (string) $world->slug,
'world_type' => (string) $world->type,
'recurrence_key' => $this->nullableString($world->recurrence_key),
'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
'section_key' => 'community_submissions',
'entity_type' => 'artwork',
'entity_id' => $submission->artwork_id ? (int) $submission->artwork_id : null,
'entity_title' => $this->nullableString($submission->artwork?->title),
'source_surface' => $this->nullableString($sourceSurface),
'viewer_type' => $actor ? 'user' : 'guest',
'user_id' => $actor ? (int) $actor->id : null,
'visitor_key' => $actor ? hash('sha256', 'user:' . $actor->id) : hash('sha256', 'system:world_submission'),
'meta' => $this->sanitizeMeta(array_merge([
'submission_id' => (int) $submission->id,
'status' => (string) $submission->status,
'is_featured' => (bool) $submission->is_featured,
'mode_snapshot' => (string) ($submission->mode_snapshot ?? ''),
], $meta)),
'occurred_at' => now(),
]);
}
public function recordRewardGrant(WorldRewardGrant $grant): void
{
$grant->loadMissing(['world', 'artwork']);
$world = $grant->world;
if (! $world) {
return;
}
WorldAnalyticsEvent::query()->create([
'world_id' => (int) $world->id,
'event_type' => self::EVENT_REWARD_GRANTED,
'world_slug' => (string) $world->slug,
'world_type' => (string) $world->type,
'recurrence_key' => $this->nullableString($world->recurrence_key),
'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
'section_key' => 'rewards',
'entity_type' => 'artwork',
'entity_id' => $grant->artwork_id ? (int) $grant->artwork_id : null,
'entity_title' => $this->nullableString($grant->artwork?->title),
'viewer_type' => $grant->user_id ? 'user' : 'guest',
'user_id' => $grant->user_id ? (int) $grant->user_id : null,
'visitor_key' => $grant->user_id ? hash('sha256', 'user:' . $grant->user_id) : hash('sha256', 'system:world_reward'),
'meta' => $this->sanitizeMeta([
'reward_type' => $grant->reward_type->value,
'grant_source' => (string) $grant->grant_source,
'world_submission_id' => $grant->world_submission_id ? (int) $grant->world_submission_id : null,
]),
'occurred_at' => now(),
]);
}
public function studioReport(World $world): array
{
$ranges = [];
foreach (self::RANGE_WINDOWS as $key => $days) {
$ranges[$key] = $this->rangePayload($world, $days ? now()->subDays($days) : null);
}
return [
'default_range' => '30d',
'range_options' => [
['value' => '7d', 'label' => 'Last 7 days'],
['value' => '30d', 'label' => 'Last 30 days'],
['value' => 'all', 'label' => 'Lifetime'],
],
'ranges' => $ranges,
'edition_comparison' => $this->editionComparisonPayload($world),
];
}
public function portfolioReport(): array
{
$ranges = [];
foreach (self::PORTFOLIO_RANGE_WINDOWS as $key => $days) {
$ranges[$key] = $this->portfolioRangePayload($days ? now()->subDays($days) : null);
}
return [
'default_range' => '30d',
'range_options' => [
['value' => '30d', 'label' => 'Last 30 days'],
['value' => 'all', 'label' => 'Lifetime'],
],
'ranges' => $ranges,
];
}
public function sourceSurfaceLabel(?string $surface): string
{
return match ((string) $surface) {
self::SOURCE_HOMEPAGE_SPOTLIGHT => 'Homepage spotlight',
self::SOURCE_HOMEPAGE_WORLDS_RAIL => 'Homepage worlds rail',
self::SOURCE_WORLDS_INDEX => 'Worlds index',
self::SOURCE_NAVIGATION => 'Navigation',
self::SOURCE_UPLOAD_FLOW => 'Upload flow',
self::SOURCE_CHALLENGE_PAGE => 'Challenge page',
self::SOURCE_NEWS_ARTICLE => 'News article',
self::SOURCE_PROFILE => 'Profile',
self::SOURCE_DIRECT => 'Direct',
default => 'Unknown',
};
}
private function rangePayload(World $world, ?Carbon $start): array
{
$eventCounts = $this->eventCounts($world, $start);
$currentSubmissionCounts = $this->currentSubmissionCounts($world);
$submissionActivity = $this->submissionActivityCounts($world, $start, $eventCounts);
$rewardCounts = $this->rewardCounts($world, $start);
$sources = $this->sourceBreakdown($world, $start);
$sectionPerformance = $this->sectionPerformance($world, $start);
$entityPerformance = $this->entityPerformance($world, $start);
$ctaPerformance = $this->ctaPerformance($world, $start);
$challengeMetrics = $this->challengeMetrics($world, $start, $eventCounts);
$promotionImpressions = (int) ($eventCounts[self::EVENT_SOURCE_IMPRESSION] ?? 0);
$sourceClicks = (int) ($eventCounts[self::EVENT_SOURCE_CLICKED] ?? 0);
$views = (int) ($eventCounts[self::EVENT_VIEWED] ?? 0);
$uniqueVisitors = $this->uniqueViewers($world, $start);
$ctaClicks = (int) ($eventCounts[self::EVENT_CTA_CLICKED] ?? 0);
$topSource = collect($sources)->sortByDesc('views')->first();
$topSection = collect($sectionPerformance)->sortByDesc('clicks')->first();
$topEntity = collect($entityPerformance)->sortByDesc('clicks')->first();
return [
'summary' => [
'views' => $views,
'unique_visitors' => $uniqueVisitors,
'promotion_impressions' => $promotionImpressions,
'cta_clicks' => $ctaClicks,
'submissions' => $submissionActivity['submitted'],
'approved_live_participations' => $currentSubmissionCounts['live'],
'featured_participations' => $currentSubmissionCounts['featured'],
'reward_grants' => $rewardCounts['total'],
'challenge_clicks' => $challengeMetrics['total_clicks'],
'approval_rate' => $submissionActivity['approval_rate'],
'promotion_clickthrough_rate' => $promotionImpressions > 0 ? round($sourceClicks / $promotionImpressions, 4) : 0.0,
'view_to_submission_conversion' => $submissionActivity['view_to_submission_conversion'],
'top_source_surface' => $topSource ? [
'key' => $topSource['source_surface'],
'label' => $this->sourceSurfaceLabel($topSource['source_surface']),
'views' => $topSource['views'],
'impressions' => $topSource['impressions'],
'clickthrough_rate' => $topSource['clickthrough_rate'],
] : null,
'top_clicked_section' => $topSection,
'top_clicked_entity' => $topEntity,
],
'traffic' => [
'views' => $views,
'unique_visitors' => $uniqueVisitors,
'trend' => $this->trafficTrend($world, $start),
],
'sources' => $sources,
'cta_performance' => $ctaPerformance,
'section_performance' => $sectionPerformance,
'entity_performance' => $entityPerformance,
'participation' => [
...$currentSubmissionCounts,
...$submissionActivity,
],
'challenge' => $challengeMetrics,
'rewards' => $rewardCounts,
];
}
private function eventCounts(World $world, ?Carbon $start): array
{
return $this->baseEventQuery($world, $start)
->select('event_type', DB::raw('COUNT(*) as total'))
->groupBy('event_type')
->pluck('total', 'event_type')
->map(fn ($count): int => (int) $count)
->all();
}
private function uniqueViewers(World $world, ?Carbon $start): int
{
return (int) $this->baseEventQuery($world, $start)
->where('event_type', self::EVENT_VIEWED)
->distinct('visitor_key')
->count('visitor_key');
}
private function sourceBreakdown(World $world, ?Carbon $start): array
{
$impressions = $this->baseEventQuery($world, $start)
->where('event_type', self::EVENT_SOURCE_IMPRESSION)
->select('source_surface', DB::raw('COUNT(*) as impressions'))
->groupBy('source_surface')
->get()
->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN));
$views = $this->baseEventQuery($world, $start)
->where('event_type', self::EVENT_VIEWED)
->select('source_surface', DB::raw('COUNT(*) as views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors'))
->groupBy('source_surface')
->get()
->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN));
$clicks = $this->baseEventQuery($world, $start)
->where('event_type', self::EVENT_SOURCE_CLICKED)
->select('source_surface', DB::raw('COUNT(*) as clicks'))
->groupBy('source_surface')
->get()
->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN));
return collect($this->allowedSourceSurfaces())
->map(function (string $surface) use ($impressions, $views, $clicks): array {
$impressionRow = $impressions->get($surface);
$viewRow = $views->get($surface);
$clickRow = $clicks->get($surface);
$impressionCount = (int) ($impressionRow?->impressions ?? 0);
$clickCount = (int) ($clickRow?->clicks ?? 0);
$viewCount = (int) ($viewRow?->views ?? 0);
return [
'source_surface' => $surface,
'label' => $this->sourceSurfaceLabel($surface),
'impressions' => $impressionCount,
'views' => $viewCount,
'unique_visitors' => (int) ($viewRow?->unique_visitors ?? 0),
'clicks' => $clickCount,
'clickthrough_rate' => $impressionCount > 0 ? round($clickCount / $impressionCount, 4) : 0.0,
'visit_rate' => $impressionCount > 0 ? round($viewCount / $impressionCount, 4) : 0.0,
];
})
->filter(fn (array $row): bool => $row['impressions'] > 0 || $row['views'] > 0 || $row['clicks'] > 0)
->sortByDesc('views')
->values()
->all();
}
private function sectionPerformance(World $world, ?Carbon $start): array
{
return $this->baseEventQuery($world, $start)
->whereNotNull('section_key')
->whereIn('event_type', [
self::EVENT_SECTION_CLICKED,
self::EVENT_CTA_CLICKED,
self::EVENT_ENTITY_CLICKED,
self::EVENT_CHALLENGE_CTA_CLICKED,
])
->select('section_key', DB::raw('COUNT(*) as clicks'))
->groupBy('section_key')
->orderByDesc('clicks')
->get()
->map(fn (WorldAnalyticsEvent $event): array => [
'section_key' => (string) $event->section_key,
'label' => Str::headline((string) $event->section_key),
'clicks' => (int) $event->clicks,
])
->all();
}
private function entityPerformance(World $world, ?Carbon $start): array
{
return $this->baseEventQuery($world, $start)
->where('event_type', self::EVENT_ENTITY_CLICKED)
->whereNotNull('entity_type')
->whereNotNull('entity_id')
->select('section_key', 'entity_type', 'entity_id', 'entity_title', DB::raw('COUNT(*) as clicks'))
->groupBy('section_key', 'entity_type', 'entity_id', 'entity_title')
->orderByDesc('clicks')
->limit(8)
->get()
->map(fn (WorldAnalyticsEvent $event): array => [
'section_key' => (string) ($event->section_key ?? ''),
'entity_type' => (string) ($event->entity_type ?? ''),
'entity_id' => (int) ($event->entity_id ?? 0),
'entity_title' => (string) ($event->entity_title ?: Str::headline((string) $event->entity_type)),
'clicks' => (int) $event->clicks,
])
->all();
}
private function ctaPerformance(World $world, ?Carbon $start): array
{
return $this->baseEventQuery($world, $start)
->whereIn('event_type', [self::EVENT_CTA_CLICKED, self::EVENT_CHALLENGE_CTA_CLICKED])
->select('event_type', 'section_key', 'cta_key', DB::raw('COUNT(*) as clicks'))
->groupBy('event_type', 'section_key', 'cta_key')
->orderByDesc('clicks')
->get()
->map(fn (WorldAnalyticsEvent $event): array => [
'event_type' => (string) $event->event_type,
'section_key' => (string) ($event->section_key ?? ''),
'cta_key' => (string) ($event->cta_key ?? ''),
'label' => $this->ctaLabel((string) ($event->cta_key ?? '')),
'clicks' => (int) $event->clicks,
])
->all();
}
private function currentSubmissionCounts(World $world): array
{
$counts = WorldSubmission::query()
->where('world_id', (int) $world->id)
->select('status', DB::raw('COUNT(*) as total'))
->groupBy('status')
->pluck('total', 'status');
$featured = (int) WorldSubmission::query()
->where('world_id', (int) $world->id)
->where('status', WorldSubmission::STATUS_LIVE)
->where('is_featured', true)
->count();
return [
'pending' => (int) ($counts[WorldSubmission::STATUS_PENDING] ?? 0),
'live' => (int) ($counts[WorldSubmission::STATUS_LIVE] ?? 0),
'removed' => (int) ($counts[WorldSubmission::STATUS_REMOVED] ?? 0),
'blocked' => (int) ($counts[WorldSubmission::STATUS_BLOCKED] ?? 0),
'featured' => $featured,
];
}
private function submissionActivityCounts(World $world, ?Carbon $start, array $eventCounts): array
{
$createdQuery = WorldSubmission::query()->where('world_id', (int) $world->id);
if ($start) {
$createdQuery->where('created_at', '>=', $start);
}
$submitted = (int) $createdQuery->count();
$approved = (int) ($eventCounts[self::EVENT_SUBMISSION_APPROVED] ?? 0);
$removed = (int) ($eventCounts[self::EVENT_SUBMISSION_REMOVED] ?? 0);
$blocked = (int) ($eventCounts[self::EVENT_SUBMISSION_BLOCKED] ?? 0);
$featured = (int) ($eventCounts[self::EVENT_SUBMISSION_FEATURED] ?? 0);
$views = max(1, (int) ($eventCounts[self::EVENT_VIEWED] ?? 0));
return [
'submitted' => $submitted,
'approved' => $approved,
'removed_actions' => $removed,
'blocked_actions' => $blocked,
'featured_actions' => $featured,
'approval_rate' => $submitted > 0 ? round($approved / $submitted, 4) : 0.0,
'removal_rate' => $submitted > 0 ? round($removed / $submitted, 4) : 0.0,
'block_rate' => $submitted > 0 ? round($blocked / $submitted, 4) : 0.0,
'view_to_submission_conversion' => round($submitted / $views, 4),
];
}
private function rewardCounts(World $world, ?Carbon $start): array
{
$query = WorldRewardGrant::query()->where('world_id', (int) $world->id);
if ($start) {
$query->where('granted_at', '>=', $start);
}
$counts = $query
->select('reward_type', DB::raw('COUNT(*) as total'))
->groupBy('reward_type')
->pluck('total', 'reward_type');
return [
'total' => (int) collect($counts)->sum(),
'participant' => (int) ($counts[WorldRewardType::Participant->value] ?? 0),
'featured' => (int) ($counts[WorldRewardType::Featured->value] ?? 0),
'finalist' => (int) ($counts[WorldRewardType::Finalist->value] ?? 0),
'winner' => (int) ($counts[WorldRewardType::Winner->value] ?? 0),
'spotlight' => (int) ($counts[WorldRewardType::Spotlight->value] ?? 0),
];
}
private function challengeMetrics(World $world, ?Carbon $start, array $eventCounts): array
{
$query = $this->baseEventQuery($world, $start)->whereNotNull('challenge_id');
$recapClicks = (clone $query)
->where('event_type', self::EVENT_CHALLENGE_CTA_CLICKED)
->whereIn('cta_key', ['challenge_story', 'challenge_recap'])
->count();
$entryClicks = (clone $query)
->where('event_type', self::EVENT_ENTITY_CLICKED)
->where('section_key', 'challenge_entries')
->count();
$winnerClicks = (clone $query)
->where('event_type', self::EVENT_ENTITY_CLICKED)
->where('section_key', 'challenge_winners')
->count();
$finalistClicks = (clone $query)
->where('event_type', self::EVENT_ENTITY_CLICKED)
->where('section_key', 'challenge_finalists')
->count();
$challengeCtaClicks = (int) ($eventCounts[self::EVENT_CHALLENGE_CTA_CLICKED] ?? 0);
$submissionStarts = (int) ($eventCounts[self::EVENT_SUBMISSION_STARTED] ?? 0);
$submissionsCreated = (int) ($eventCounts[self::EVENT_SUBMISSION_CREATED] ?? 0);
$totalClicks = $challengeCtaClicks + (int) $recapClicks + (int) $entryClicks + (int) $winnerClicks + (int) $finalistClicks;
return [
'linked_challenge_id' => $world->linked_challenge_id ? (int) $world->linked_challenge_id : null,
'challenge_cta_clicks' => $challengeCtaClicks,
'recap_clicks' => (int) $recapClicks,
'entry_clicks' => (int) $entryClicks,
'winner_clicks' => (int) $winnerClicks,
'finalist_clicks' => (int) $finalistClicks,
'submission_starts' => $submissionStarts,
'submissions_created' => $submissionsCreated,
'click_to_submission_start_conversion' => $totalClicks > 0 ? round($submissionStarts / $totalClicks, 4) : 0.0,
'click_to_submission_conversion' => $totalClicks > 0 ? round($submissionsCreated / $totalClicks, 4) : 0.0,
'total_clicks' => $totalClicks,
];
}
private function portfolioRangePayload(?Carbon $start): array
{
$worlds = World::query()
->select(['id', 'title', 'slug', 'recurrence_key', 'edition_year'])
->get()
->keyBy('id');
$viewRows = WorldAnalyticsEvent::query()
->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start))
->where('event_type', self::EVENT_VIEWED)
->select('world_id', DB::raw('COUNT(*) as views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$impressionRows = WorldAnalyticsEvent::query()
->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start))
->where('event_type', self::EVENT_SOURCE_IMPRESSION)
->select('world_id', DB::raw('COUNT(*) as impressions'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$sourceClickRows = WorldAnalyticsEvent::query()
->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start))
->where('event_type', self::EVENT_SOURCE_CLICKED)
->select('world_id', DB::raw('COUNT(*) as source_clicks'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$submissionRows = WorldSubmission::query()
->when($start, fn (Builder $builder): Builder => $builder->where('created_at', '>=', $start))
->select('world_id', DB::raw('COUNT(*) as submissions'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$rewardRows = WorldRewardGrant::query()
->when($start, fn (Builder $builder): Builder => $builder->where('granted_at', '>=', $start))
->select('world_id', DB::raw('COUNT(*) as reward_grants'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$trackedWorldIds = collect()
->merge($viewRows->keys())
->merge($impressionRows->keys())
->merge($sourceClickRows->keys())
->merge($submissionRows->keys())
->merge($rewardRows->keys())
->map(fn ($id): int => (int) $id)
->unique()
->values();
$rows = $trackedWorldIds
->map(function (int $worldId) use ($worlds, $viewRows, $impressionRows, $sourceClickRows, $submissionRows, $rewardRows): ?array {
/** @var World|null $world */
$world = $worlds->get($worldId);
if (! $world) {
return null;
}
$views = (int) ($viewRows->get($worldId)?->views ?? 0);
$uniqueVisitors = (int) ($viewRows->get($worldId)?->unique_visitors ?? 0);
$impressions = (int) ($impressionRows->get($worldId)?->impressions ?? 0);
$sourceClicks = (int) ($sourceClickRows->get($worldId)?->source_clicks ?? 0);
$submissions = (int) ($submissionRows->get($worldId)?->submissions ?? 0);
$rewardGrants = (int) ($rewardRows->get($worldId)?->reward_grants ?? 0);
return [
'world_id' => (int) $world->id,
'title' => (string) $world->title,
'slug' => (string) $world->slug,
'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
'recurrence_key' => $this->nullableString($world->recurrence_key),
'edit_url' => route('studio.worlds.edit', ['world' => $world->id]),
'public_url' => $world->publicUrl(),
'views' => $views,
'unique_visitors' => $uniqueVisitors,
'impressions' => $impressions,
'source_clicks' => $sourceClicks,
'submissions' => $submissions,
'reward_grants' => $rewardGrants,
'view_to_submission_conversion' => $views > 0 ? round($submissions / $views, 4) : 0.0,
'promotion_clickthrough_rate' => $impressions > 0 ? round($sourceClicks / $impressions, 4) : 0.0,
];
})
->filter()
->values();
return [
'summary' => [
'tracked_worlds' => $rows->count(),
'views' => (int) $rows->sum('views'),
'unique_visitors' => (int) $rows->sum('unique_visitors'),
'promotion_impressions' => (int) $rows->sum('impressions'),
'submissions' => (int) $rows->sum('submissions'),
'reward_grants' => (int) $rows->sum('reward_grants'),
],
'leaderboards' => [
'views' => $rows->sortByDesc('views')->take(5)->values()->all(),
'unique_visitors' => $rows->sortByDesc('unique_visitors')->take(5)->values()->all(),
'submissions' => $rows->sortByDesc('submissions')->take(5)->values()->all(),
'reward_grants' => $rows->sortByDesc('reward_grants')->take(5)->values()->all(),
'conversion' => $rows
->filter(fn (array $row): bool => $row['views'] > 0 || $row['submissions'] > 0)
->sortByDesc('view_to_submission_conversion')
->take(5)
->values()
->all(),
],
];
}
private function trafficTrend(World $world, ?Carbon $start): array
{
$events = $this->baseEventQuery($world, $start)
->where('event_type', self::EVENT_VIEWED)
->orderBy('occurred_at')
->pluck('occurred_at')
->map(fn ($timestamp): Carbon => Carbon::parse($timestamp));
if ($events->isEmpty()) {
return [];
}
$bucketByMonth = $start === null && $events->first()?->diffInDays($events->last()) > 90;
return $events
->groupBy(fn (Carbon $date): string => $bucketByMonth ? $date->format('Y-m') : $date->toDateString())
->map(fn (Collection $items, string $label): array => [
'label' => $label,
'views' => $items->count(),
])
->values()
->all();
}
private function editionComparisonPayload(World $world): ?array
{
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
if ($recurrenceKey === '') {
return null;
}
$editions = World::query()
->where('recurrence_key', $recurrenceKey)
->orderByDesc('edition_year')
->orderByDesc('starts_at')
->get();
if ($editions->count() < 2) {
return null;
}
$worldIds = $editions->pluck('id')->map(fn ($id): int => (int) $id)->all();
$viewCounts = WorldAnalyticsEvent::query()
->whereIn('world_id', $worldIds)
->where('event_type', self::EVENT_VIEWED)
->select('world_id', DB::raw('COUNT(*) as total_views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$submissionCounts = WorldSubmission::query()
->whereIn('world_id', $worldIds)
->select(
'world_id',
DB::raw('COUNT(*) as submitted_total'),
DB::raw("SUM(CASE WHEN status = 'live' THEN 1 ELSE 0 END) as live_total"),
DB::raw("SUM(CASE WHEN is_featured = 1 AND status = 'live' THEN 1 ELSE 0 END) as featured_total")
)
->groupBy('world_id')
->get()
->keyBy('world_id');
$challengeClicks = WorldAnalyticsEvent::query()
->whereIn('world_id', $worldIds)
->whereIn('event_type', [self::EVENT_CHALLENGE_CTA_CLICKED, self::EVENT_ENTITY_CLICKED])
->where(function (Builder $builder): void {
$builder->whereNotNull('challenge_id')
->orWhereIn('section_key', ['challenge_entries', 'challenge_winners', 'challenge_finalists', 'challenge']);
})
->select('world_id', DB::raw('COUNT(*) as total_challenge_clicks'))
->groupBy('world_id')
->get()
->keyBy('world_id');
$rewardCounts = WorldRewardGrant::query()
->whereIn('world_id', $worldIds)
->select('world_id', DB::raw('COUNT(*) as total_rewards'))
->groupBy('world_id')
->get()
->keyBy('world_id');
return [
'recurrence_key' => $recurrenceKey,
'label' => trim((string) ($world->title ?: Str::headline($recurrenceKey))),
'editions' => $editions->map(function (World $edition) use ($world, $viewCounts, $submissionCounts, $challengeClicks, $rewardCounts): array {
$views = $viewCounts->get((int) $edition->id);
$submissions = $submissionCounts->get((int) $edition->id);
$challenge = $challengeClicks->get((int) $edition->id);
$rewards = $rewardCounts->get((int) $edition->id);
return [
'world_id' => (int) $edition->id,
'title' => (string) $edition->title,
'edition_year' => $edition->edition_year ? (int) $edition->edition_year : null,
'public_url' => $edition->publicUrl(),
'is_current_world' => (int) $edition->id === (int) $world->id,
'metrics' => [
'views' => (int) ($views?->total_views ?? 0),
'unique_visitors' => (int) ($views?->unique_visitors ?? 0),
'submissions' => (int) ($submissions?->submitted_total ?? 0),
'live_participations' => (int) ($submissions?->live_total ?? 0),
'featured_participations' => (int) ($submissions?->featured_total ?? 0),
'challenge_clicks' => (int) ($challenge?->total_challenge_clicks ?? 0),
'reward_grants' => (int) ($rewards?->total_rewards ?? 0),
],
];
})->all(),
];
}
private function baseEventQuery(World $world, ?Carbon $start): Builder
{
return WorldAnalyticsEvent::query()
->where('world_id', (int) $world->id)
->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start));
}
private function resolveVisitorKey(Request $request, ?User $user, string $visitorToken): string
{
if ($user) {
return hash('sha256', 'user:' . $user->id);
}
$token = trim($visitorToken);
if ($token !== '') {
return hash('sha256', 'visitor:' . $token);
}
$sessionId = $request->session()->getId();
if ($sessionId !== '') {
return hash('sha256', 'session:' . $sessionId);
}
return hash('sha256', 'fallback:' . ($request->ip() ?? 'unknown') . '|' . Str::limit((string) $request->userAgent(), 120, ''));
}
private function sanitizeMeta(array $meta): ?array
{
$sanitized = collect($meta)
->map(function ($value) {
if (is_scalar($value) || $value === null) {
return $value;
}
if (is_array($value)) {
return collect($value)
->filter(fn ($entry) => is_scalar($entry) || $entry === null)
->map(fn ($entry) => is_string($entry) ? Str::limit($entry, 200, '') : $entry)
->values()
->all();
}
return null;
})
->filter(fn ($value) => $value !== null && $value !== '')
->all();
return $sanitized === [] ? null : $sanitized;
}
private function ctaLabel(string $ctaKey): string
{
return match ($ctaKey) {
'main_world_cta' => 'Main world CTA',
'badge_cta' => 'Badge CTA',
'challenge_primary' => 'Challenge primary CTA',
'challenge_story' => 'Challenge story CTA',
'challenge_recap' => 'Challenge recap CTA',
'challenge_direct' => 'Direct challenge CTA',
'linked_group' => 'Linked group CTA',
'family_route' => 'Family route CTA',
'edition_archive' => 'Edition archive CTA',
'supporting_item' => 'Supporting item CTA',
default => Str::headline(str_replace('_', ' ', $ctaKey ?: 'cta')),
};
}
private function nullableString(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
return $text === '' ? null : Str::limit($text, 180, '');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,569 @@
<?php
declare(strict_types=1);
namespace App\Services\Worlds;
use App\Enums\WorldRewardType;
use App\Models\Artwork;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeOutcome;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use App\Notifications\WorldRewardGrantedNotification;
use App\Services\Activity\UserActivityService;
use App\Services\XPService;
use App\Support\AvatarUrl;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WorldRewardService
{
private const CHALLENGE_GRANT_SOURCE = 'challenge';
public function __construct(
private readonly UserActivityService $activities,
private readonly XPService $xp,
private readonly WorldAnalyticsService $analytics,
) {
}
public function syncAutomaticRewardsForSubmission(WorldSubmission $submission): void
{
$submission->loadMissing(['world', 'artwork.user.profile']);
$world = $submission->world;
$creator = $submission->artwork?->user;
if (! $world || ! $creator) {
return;
}
$this->syncAutomaticReward($world, $creator, WorldRewardType::Participant);
$this->syncAutomaticReward($world, $creator, WorldRewardType::Featured);
}
public function grantManualReward(WorldSubmission $submission, User $editor, WorldRewardType $rewardType, ?string $note = null): WorldRewardGrant
{
if ($rewardType->isAutomatic()) {
throw new \InvalidArgumentException('Automatic world rewards cannot be granted manually.');
}
if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
throw ValidationException::withMessages([
'submission' => 'Only live world submissions can receive manual rewards.',
]);
}
$submission->loadMissing(['world', 'artwork.user.profile']);
$world = $submission->world;
$artwork = $submission->artwork;
$creator = $artwork?->user;
if (! $world || ! $artwork || ! $creator) {
throw new \RuntimeException('Submission is missing world, artwork, or creator context.');
}
$grant = WorldRewardGrant::query()->firstOrNew([
'user_id' => (int) $creator->id,
'world_id' => (int) $world->id,
'reward_type' => $rewardType->value,
]);
$wasRecentlyCreated = ! $grant->exists;
$grant->forceFill([
'artwork_id' => (int) $artwork->id,
'world_submission_id' => (int) $submission->id,
'granted_by_user_id' => (int) $editor->id,
'grant_source' => $rewardType->source(),
'note' => $this->nullableText($note),
'granted_at' => $grant->granted_at ?? now(),
])->save();
$grant->loadMissing(['world', 'artwork', 'user.profile']);
if ($wasRecentlyCreated) {
$this->dispatchGrantSideEffects($grant);
}
return $grant;
}
public function revokeManualReward(WorldSubmission $submission, WorldRewardType $rewardType): void
{
if ($rewardType->isAutomatic()) {
throw new \InvalidArgumentException('Automatic world rewards are revoked through submission state changes.');
}
$submission->loadMissing(['world', 'artwork.user']);
$world = $submission->world;
$creator = $submission->artwork?->user;
if (! $world || ! $creator) {
return;
}
WorldRewardGrant::query()
->where('user_id', (int) $creator->id)
->where('world_id', (int) $world->id)
->where('reward_type', $rewardType->value)
->where('grant_source', $rewardType->source())
->delete();
$this->activities->invalidateUserFeed((int) $creator->id);
}
public function summaryForUser(User $user, int $limit = 12): array
{
$recentGrants = WorldRewardGrant::query()
->with(['world', 'artwork', 'user.profile'])
->where('user_id', (int) $user->id)
->orderByDesc('granted_at')
->orderByDesc('id')
->get();
$grants = $recentGrants->sortBy([
fn (WorldRewardGrant $grant): int => $this->sortPriority($grant->reward_type),
fn (WorldRewardGrant $grant): int => -1 * ($grant->granted_at?->getTimestamp() ?? 0),
fn (WorldRewardGrant $grant): int => -1 * (int) $grant->id,
])->values();
return [
'count' => $grants->count(),
'counts' => [
'participant' => $grants->where('reward_type', WorldRewardType::Participant)->count(),
'featured' => $grants->where('reward_type', WorldRewardType::Featured)->count(),
'finalist' => $grants->where('reward_type', WorldRewardType::Finalist)->count(),
'winner' => $grants->where('reward_type', WorldRewardType::Winner)->count(),
'spotlight' => $grants->where('reward_type', WorldRewardType::Spotlight)->count(),
],
'recent' => $recentGrants->take($limit)->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
'items' => $grants->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
];
}
public function rewardedContributorsForWorld(World $world, int $limit = 24): array
{
$baseQuery = WorldRewardGrant::query()
->where('world_id', (int) $world->id);
$allGrants = (clone $baseQuery)
->get(['user_id', 'reward_type']);
$grants = (clone $baseQuery)
->with(['user.profile', 'artwork'])
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->orderByDesc('id')
->limit($limit)
->get();
return [
'count' => $allGrants->count(),
'creator_count' => $allGrants->pluck('user_id')->filter()->unique()->count(),
'counts' => [
'participant' => $allGrants->where('reward_type', WorldRewardType::Participant->value)->count(),
'featured' => $allGrants->where('reward_type', WorldRewardType::Featured->value)->count(),
'finalist' => $allGrants->where('reward_type', WorldRewardType::Finalist->value)->count(),
'winner' => $allGrants->where('reward_type', WorldRewardType::Winner->value)->count(),
'spotlight' => $allGrants->where('reward_type', WorldRewardType::Spotlight->value)->count(),
],
'items' => $grants->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
];
}
public function syncLinkedChallengeRewardsForWorld(World $world): void
{
$world->loadMissing(['worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes']);
if (! (bool) ($world->auto_grant_challenge_world_rewards ?? true)) {
$this->deleteChallengeOutcomeGrantsForWorld($world);
return;
}
$challengeIds = $world->worldRelations
->where('related_type', WorldRelation::TYPE_CHALLENGE)
->pluck('related_id')
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->prepend((int) ($world->linked_challenge_id ?? 0))
->unique()
->values();
if ($challengeIds->isEmpty()) {
$this->deleteChallengeOutcomeGrantsForWorld($world);
return;
}
$challenges = GroupChallenge::query()
->with(['group', 'featuredArtwork.user.profile', 'outcomes.artwork.user.profile'])
->whereIn('id', $challengeIds->all())
->get()
->filter(fn (GroupChallenge $challenge): bool => $this->challengeCanGrantWorldOutcomeReward($challenge))
->values();
$this->syncChallengeOutcomeRewardTypeForWorld($world, $challenges, WorldRewardType::Winner);
$this->syncChallengeOutcomeRewardTypeForWorld($world, $challenges, WorldRewardType::Finalist);
}
private function syncChallengeOutcomeRewardTypeForWorld(World $world, Collection $challenges, WorldRewardType $rewardType): void
{
$artworkIds = $challenges
->flatMap(fn (GroupChallenge $challenge): array => $this->challengeOutcomeArtworkIds($challenge, $rewardType)->all())
->unique()
->values();
$submissionsByArtwork = $artworkIds->isEmpty()
? collect()
: WorldSubmission::query()
->with(['artwork.user.profile'])
->where('world_id', (int) $world->id)
->where('status', WorldSubmission::STATUS_LIVE)
->whereIn('artwork_id', $artworkIds->all())
->get()
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->artwork_id);
$expected = collect();
foreach ($challenges as $challenge) {
foreach ($this->challengeOutcomeArtworkIds($challenge, $rewardType) as $artworkId) {
$submission = $submissionsByArtwork->get((int) $artworkId);
$creator = $submission?->artwork?->user;
if (! $submission || ! $creator || $expected->has((int) $creator->id)) {
continue;
}
$expected->put((int) $creator->id, [
'submission' => $submission,
'challenge' => $challenge,
]);
}
}
$existing = WorldRewardGrant::query()
->where('world_id', (int) $world->id)
->where('reward_type', $rewardType->value)
->get()
->keyBy(fn (WorldRewardGrant $grant): int => (int) $grant->user_id);
foreach ($expected as $userId => $payload) {
/** @var WorldSubmission $submission */
$submission = $payload['submission'];
/** @var GroupChallenge $challenge */
$challenge = $payload['challenge'];
$current = $existing->get((int) $userId);
if ($current && (string) $current->grant_source !== self::CHALLENGE_GRANT_SOURCE) {
continue;
}
$grant = $current ?? new WorldRewardGrant();
$wasRecentlyCreated = ! $grant->exists;
$grant->forceFill([
'user_id' => (int) $userId,
'world_id' => (int) $world->id,
'artwork_id' => (int) $submission->artwork_id,
'world_submission_id' => (int) $submission->id,
'granted_by_user_id' => null,
'reward_type' => $rewardType->value,
'grant_source' => self::CHALLENGE_GRANT_SOURCE,
'note' => sprintf('Synced from linked challenge %s: %s.', $rewardType->label(), $challenge->title),
'granted_at' => $grant->granted_at ?? now(),
])->save();
$grant->loadMissing(['world', 'artwork', 'user.profile']);
if ($wasRecentlyCreated) {
$this->dispatchGrantSideEffects($grant);
}
}
$expectedUserIds = $expected->keys()->map(fn ($id): int => (int) $id)->all();
$existing
->filter(fn (WorldRewardGrant $grant): bool => (string) $grant->grant_source === self::CHALLENGE_GRANT_SOURCE)
->reject(fn (WorldRewardGrant $grant): bool => in_array((int) $grant->user_id, $expectedUserIds, true))
->each(function (WorldRewardGrant $grant): void {
$grant->delete();
$this->activities->invalidateUserFeed((int) $grant->user_id);
});
}
public function syncLinkedChallengeRewardsForChallenge(GroupChallenge $challenge): void
{
$worldIds = WorldRelation::query()
->where('related_type', WorldRelation::TYPE_CHALLENGE)
->where('related_id', (int) $challenge->id)
->pluck('world_id')
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->merge(
World::query()
->where('linked_challenge_id', (int) $challenge->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
)
->unique()
->all();
if ($worldIds === []) {
return;
}
World::query()
->with('worldRelations')
->whereIn('id', $worldIds)
->get()
->each(fn (World $world): bool => tap(true, fn () => $this->syncLinkedChallengeRewardsForWorld($world)));
}
public function creatorRewardMapForWorld(World $world): Collection
{
return WorldRewardGrant::query()
->with(['artwork'])
->where('world_id', (int) $world->id)
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->get()
->groupBy('user_id')
->map(fn (Collection $items): array => $items->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant, false))->all());
}
public function artworkRewardBadges(Artwork $artwork): array
{
return WorldRewardGrant::query()
->with('world')
->where('artwork_id', (int) $artwork->id)
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->get()
->map(function (WorldRewardGrant $grant): array {
$world = $grant->world;
$rewardType = $grant->reward_type;
return [
'world_id' => (int) ($world?->id ?? 0),
'world_title' => (string) ($world?->title ?? 'World'),
'world_slug' => (string) ($world?->slug ?? ''),
'world_url' => $world?->publicUrl(),
'badge_label' => $this->worldRewardLabel($world, $rewardType),
'status' => $rewardType->value,
'status_label' => $rewardType->label(),
'tone' => $rewardType->tone(),
'sort_priority' => $this->sortPriority($rewardType),
];
})
->all();
}
private function syncAutomaticReward(World $world, User $creator, WorldRewardType $rewardType): void
{
$qualifyingSubmission = $this->qualifyingSubmission($world, $creator, $rewardType);
$existing = WorldRewardGrant::query()
->where('user_id', (int) $creator->id)
->where('world_id', (int) $world->id)
->where('reward_type', $rewardType->value)
->first();
if (! $qualifyingSubmission) {
if ($rewardType === WorldRewardType::Participant) {
return;
}
if ($existing && (string) $existing->grant_source === $rewardType->source()) {
$existing->delete();
$this->activities->invalidateUserFeed((int) $creator->id);
}
return;
}
if ($existing) {
$existing->forceFill([
'artwork_id' => (int) $qualifyingSubmission->artwork_id,
'world_submission_id' => (int) $qualifyingSubmission->id,
'grant_source' => $rewardType->source(),
])->save();
return;
}
$grant = WorldRewardGrant::query()->create([
'user_id' => (int) $creator->id,
'world_id' => (int) $world->id,
'artwork_id' => (int) $qualifyingSubmission->artwork_id,
'world_submission_id' => (int) $qualifyingSubmission->id,
'reward_type' => $rewardType->value,
'grant_source' => $rewardType->source(),
'granted_at' => now(),
]);
$grant->loadMissing(['world', 'artwork', 'user.profile']);
$this->dispatchGrantSideEffects($grant);
}
private function qualifyingSubmission(World $world, User $creator, WorldRewardType $rewardType): ?WorldSubmission
{
$query = WorldSubmission::query()
->with(['world', 'artwork.user.profile'])
->where('world_id', (int) $world->id)
->where('status', WorldSubmission::STATUS_LIVE)
->whereHas('artwork', fn (Builder $builder) => $builder->where('user_id', (int) $creator->id));
if ($rewardType === WorldRewardType::Featured) {
$query->where('is_featured', true)->orderByDesc('featured_at');
} else {
$query->orderByDesc('reviewed_at');
}
return $query->orderByDesc('id')->first();
}
private function dispatchGrantSideEffects(WorldRewardGrant $grant): void
{
$this->analytics->recordRewardGrant($grant);
$grant->user?->notify(new WorldRewardGrantedNotification($grant));
$this->activities->logWorldReward((int) $grant->user_id, (int) $grant->id, [
'reward_type' => $grant->reward_type->value,
'world_id' => (int) $grant->world_id,
]);
$this->xp->awardWorldReward((int) $grant->user_id, $grant->reward_type, (int) $grant->world_id);
}
private function mapGrant(WorldRewardGrant $grant, bool $includeCreator = true): array
{
$grant->loadMissing(['world', 'artwork', 'user.profile']);
$payload = [
'id' => (int) $grant->id,
'reward_type' => $grant->reward_type->value,
'reward_label' => $grant->reward_type->label(),
'badge_label' => $this->worldRewardLabel($grant->world, $grant->reward_type),
'tone' => $grant->reward_type->tone(),
'grant_source' => (string) $grant->grant_source,
'note' => (string) ($grant->note ?? ''),
'granted_at' => $grant->granted_at?->toIso8601String(),
'world' => $grant->world ? [
'id' => (int) $grant->world->id,
'title' => (string) $grant->world->title,
'slug' => (string) $grant->world->slug,
'url' => $grant->world->publicUrl(),
'edition_year' => $grant->world->edition_year,
] : null,
'artwork' => $grant->artwork ? [
'id' => (int) $grant->artwork->id,
'title' => (string) ($grant->artwork->title ?: 'Untitled artwork'),
'url' => route('art.show', ['id' => (int) $grant->artwork->id, 'slug' => $grant->artwork->slug ?: Str::slug((string) $grant->artwork->title)]),
] : null,
];
if (! $includeCreator) {
return $payload;
}
return [
...$payload,
'creator' => $grant->user ? [
'id' => (int) $grant->user->id,
'name' => (string) ($grant->user->name ?: $grant->user->username ?: 'Creator'),
'username' => (string) ($grant->user->username ?? ''),
'profile_url' => $grant->user->username ? route('profile.show', ['username' => strtolower((string) $grant->user->username)]) : null,
'avatar_url' => AvatarUrl::forUser((int) $grant->user->id, $grant->user->profile?->avatar_hash, 96),
] : null,
];
}
private function worldRewardLabel(?World $world, WorldRewardType $rewardType): string
{
return trim(($world?->title ?? 'World') . ' ' . $rewardType->label());
}
private function sortCaseSql(): string
{
return "CASE reward_type WHEN 'winner' THEN 0 WHEN 'finalist' THEN 1 WHEN 'spotlight' THEN 2 WHEN 'featured' THEN 3 ELSE 4 END";
}
private function challengeCanGrantWorldOutcomeReward(GroupChallenge $challenge): bool
{
if ((string) $challenge->status === GroupChallenge::STATUS_DRAFT) {
return false;
}
return $challenge->canBeViewedBy(null);
}
private function challengeOutcomeArtworkIds(GroupChallenge $challenge, WorldRewardType $rewardType): Collection
{
$challenge->loadMissing('outcomes');
$type = match ($rewardType) {
WorldRewardType::Winner => GroupChallengeOutcome::TYPE_WINNER,
WorldRewardType::Finalist => GroupChallengeOutcome::TYPE_FINALIST,
default => null,
};
if ($type === null) {
return collect();
}
$ids = $challenge->outcomes
->where('outcome_type', $type)
->pluck('artwork_id')
->map(fn ($id): int => (int) $id)
->filter(fn (int $id): bool => $id > 0)
->values();
if ($rewardType === WorldRewardType::Winner && $ids->isEmpty() && (int) ($challenge->featured_artwork_id ?? 0) > 0) {
return collect([(int) $challenge->featured_artwork_id]);
}
return $ids;
}
private function deleteChallengeOutcomeGrantsForWorld(World $world, ?WorldRewardType $rewardType = null): void
{
$query = WorldRewardGrant::query()
->where('world_id', (int) $world->id)
->where('grant_source', self::CHALLENGE_GRANT_SOURCE)
->when($rewardType !== null, fn ($builder) => $builder->where('reward_type', $rewardType->value));
$query
->get()
->each(function (WorldRewardGrant $grant): void {
$grant->delete();
$this->activities->invalidateUserFeed((int) $grant->user_id);
});
}
private function sortPriority(WorldRewardType $rewardType): int
{
return match ($rewardType) {
WorldRewardType::Winner => 0,
WorldRewardType::Finalist => 1,
WorldRewardType::Spotlight => 2,
WorldRewardType::Featured => 3,
WorldRewardType::Participant => 4,
};
}
private function nullableText(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\Worlds;
use App\Enums\WorldRewardType;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\User;
@@ -17,14 +18,18 @@ use Illuminate\Validation\ValidationException;
final class WorldSubmissionService
{
public function __construct(private readonly ArtworkMaturityService $maturity)
{
private array $canonicalRecurringEligibilityIds = [];
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly WorldRewardService $rewards,
private readonly WorldAnalyticsService $analytics,
) {
}
public function eligibleWorldOptions(?User $viewer = null): array
{
return $this->eligibleWorldsQuery()
->get()
return $this->eligibleWorlds()
->map(fn (World $world): array => $this->mapCreatorWorldOption($world, null, true))
->all();
}
@@ -37,7 +42,7 @@ final class WorldSubmissionService
->filter(fn (WorldSubmission $submission): bool => $submission->world !== null)
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
$eligibleWorlds = $this->eligibleWorldsQuery()->get()->keyBy(fn (World $world): int => (int) $world->id);
$eligibleWorlds = $this->eligibleWorlds()->keyBy(fn (World $world): int => (int) $world->id);
$worlds = $eligibleWorlds;
$missingWorldIds = $existing->keys()
@@ -55,7 +60,9 @@ final class WorldSubmissionService
return $worlds
->sortBy([
fn (World $world): int => $existing->has((int) $world->id) ? 0 : 1,
fn (World $world): int => $world->starts_at?->getTimestamp() ?? PHP_INT_MAX,
fn (World $world): int => $world->isActiveCampaign() ? 0 : ($world->isUpcomingCampaign() ? 1 : 2),
fn (World $world): int => -1 * (int) ($world->campaign_priority ?? 0),
fn (World $world): int => $world->effectivePromotionStartsAt()?->getTimestamp() ?? PHP_INT_MAX,
fn (World $world): string => Str::lower((string) $world->title),
])
->values()
@@ -81,6 +88,7 @@ final class WorldSubmissionService
return [
'world_id' => $worldId,
'note' => Str::limit(trim((string) ($entry['note'] ?? '')), 1000, ''),
'source_surface' => trim((string) ($entry['source_surface'] ?? '')),
];
})
->filter()
@@ -143,6 +151,7 @@ final class WorldSubmissionService
}
if ($submission) {
$wasRemoved = (string) $submission->status === WorldSubmission::STATUS_REMOVED;
$payload = [
'mode_snapshot' => $world?->participation_mode,
'note' => $note,
@@ -164,10 +173,26 @@ final class WorldSubmissionService
$submission->forceFill($payload)->save();
if ($wasRemoved) {
$eventType = self::lifecycleEventForStatus($startingStatus);
if ($eventType !== null) {
$this->analytics->recordSubmissionLifecycle(
$submission,
$eventType,
$actor,
$entry['source_surface'] !== '' ? $entry['source_surface'] : null,
['reactivated' => true]
);
}
}
$this->rewards->syncAutomaticRewardsForSubmission($submission);
continue;
}
WorldSubmission::query()->create([
$submission = WorldSubmission::query()->create([
'world_id' => $worldId,
'artwork_id' => (int) $artwork->id,
'submitted_by_user_id' => (int) $actor->id,
@@ -177,6 +202,25 @@ final class WorldSubmissionService
'note' => $note,
'reviewed_at' => $reviewedAt,
]);
$this->analytics->recordSubmissionLifecycle(
$submission,
WorldAnalyticsService::EVENT_SUBMISSION_CREATED,
$actor,
$entry['source_surface'] !== '' ? $entry['source_surface'] : null
);
if ($startingStatus === WorldSubmission::STATUS_LIVE) {
$this->analytics->recordSubmissionLifecycle(
$submission,
WorldAnalyticsService::EVENT_SUBMISSION_APPROVED,
$actor,
$entry['source_surface'] !== '' ? $entry['source_surface'] : null,
['auto_approved' => true]
);
}
$this->rewards->syncAutomaticRewardsForSubmission($submission);
}
$existing->each(function (WorldSubmission $submission, int $worldId) use ($selectedWorldIds): void {
@@ -220,6 +264,13 @@ final class WorldSubmissionService
$submission->forceFill($payload)->save();
$eventType = self::lifecycleEventForStatus($status);
if ($eventType !== null) {
$this->analytics->recordSubmissionLifecycle($submission, $eventType, $reviewer);
}
$this->rewards->syncAutomaticRewardsForSubmission($submission);
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
@@ -245,6 +296,12 @@ final class WorldSubmissionService
$submission->forceFill($payload)->save();
if ($featured) {
$this->analytics->recordSubmissionLifecycle($submission, WorldAnalyticsService::EVENT_SUBMISSION_FEATURED, $reviewer);
}
$this->rewards->syncAutomaticRewardsForSubmission($submission);
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
@@ -258,6 +315,8 @@ final class WorldSubmissionService
'worldSubmissions.reviewer.profile',
]);
$rewardMap = $this->rewards->creatorRewardMapForWorld($world);
$items = $world->worldSubmissions
->sortBy([
fn (WorldSubmission $submission): int => match ((string) $submission->status) {
@@ -279,7 +338,7 @@ final class WorldSubmissionService
'blocked' => $items->where('status', WorldSubmission::STATUS_BLOCKED)->count(),
'featured' => $items->where('is_featured', true)->count(),
],
'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission))->all(),
'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission, $rewardMap))->all(),
];
}
@@ -334,14 +393,62 @@ final class WorldSubmissionService
$builder->whereNull('submission_ends_at')
->orWhere('submission_ends_at', '>=', now());
})
->orderBy('submission_ends_at')
->orderBy('starts_at')
->orderByDesc('is_active_campaign')
->orderByDesc('is_homepage_featured')
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
->orderByRaw('COALESCE(promotion_ends_at, submission_ends_at, ends_at) ASC')
->orderByRaw('COALESCE(promotion_starts_at, starts_at, submission_starts_at) ASC')
->orderBy('title');
}
private static function lifecycleEventForStatus(string $status): ?string
{
return match ($status) {
WorldSubmission::STATUS_LIVE => WorldAnalyticsService::EVENT_SUBMISSION_APPROVED,
WorldSubmission::STATUS_REMOVED => WorldAnalyticsService::EVENT_SUBMISSION_REMOVED,
WorldSubmission::STATUS_BLOCKED => WorldAnalyticsService::EVENT_SUBMISSION_BLOCKED,
default => null,
};
}
private function eligibleWorlds()
{
return $this->eligibleWorldsQuery()
->get()
->filter(fn (World $world): bool => $this->isCanonicalRecurringEdition($world))
->values();
}
private function isEligibleWorld(World $world): bool
{
return $world->isAcceptingSubmissions();
return $world->isAcceptingSubmissions() && $this->isCanonicalRecurringEdition($world);
}
private function isCanonicalRecurringEdition(World $world): bool
{
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
if (! $world->is_recurring || $recurrenceKey === '') {
return true;
}
if (! array_key_exists($recurrenceKey, $this->canonicalRecurringEligibilityIds)) {
$canonical = $this->eligibleWorldsQuery()
->where('recurrence_key', $recurrenceKey)
->get()
->sortBy([
fn (World $edition): int => $edition->isActiveCampaign() ? 0 : ($edition->isUpcomingCampaign() ? 1 : 2),
fn (World $edition): int => -1 * (int) ($edition->campaign_priority ?? 0),
fn (World $edition): int => -1 * (int) ($edition->edition_year ?? 0),
fn (World $edition): int => -1 * ($edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0),
fn (World $edition): int => -1 * (int) $edition->id,
])
->first();
$this->canonicalRecurringEligibilityIds[$recurrenceKey] = $canonical?->id;
}
return (int) ($this->canonicalRecurringEligibilityIds[$recurrenceKey] ?? 0) === (int) $world->id;
}
private function mapCreatorWorldOption(World $world, ?WorldSubmission $submission, bool $eligible): array
@@ -374,17 +481,27 @@ final class WorldSubmissionService
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
'teaser_title' => $world->teaserTitle(),
'slug' => (string) $world->slug,
'tagline' => (string) ($world->tagline ?? ''),
'summary' => (string) ($world->summary ?? ''),
'teaser_summary' => (string) ($world->teaserSummary() ?? ''),
'cover_url' => $world->coverUrl(),
'teaser_image_url' => $world->teaserImageUrl(),
'campaign_label' => (string) ($world->campaign_label ?? ''),
'timeframe_label' => $this->timeframeLabel($world),
'promotion_window_label' => $this->promotionWindowLabel($world),
'submission_window_label' => $this->submissionWindowLabel($world),
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
'participation_mode_label' => $this->participationModeLabel((string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED)),
'submission_note_enabled' => (bool) $world->submission_note_enabled,
'is_accepting_submissions' => $eligible,
'is_active_campaign' => (bool) $world->is_active_campaign,
'is_homepage_featured' => (bool) $world->is_homepage_featured,
'campaign_priority' => $world->campaign_priority,
'campaign_state_label' => $this->campaignStateLabel($world),
'status_badges' => $this->campaignBadges($world),
'selected' => $selected,
'selection_locked' => $locked,
'selection_locked_reason' => $lockedReason,
@@ -399,15 +516,72 @@ final class WorldSubmissionService
];
}
private function mapStudioSubmission(WorldSubmission $submission): array
private function campaignStateLabel(World $world): string
{
if ($world->isActiveCampaign()) {
return 'Live now';
}
if ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) {
return 'Upcoming';
}
if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) {
return 'Archived';
}
return 'Open';
}
private function campaignBadges(World $world): array
{
$badges = [];
if ($world->isActiveCampaign()) {
$badges[] = ['label' => 'Live now', 'tone' => 'emerald'];
} elseif ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) {
$badges[] = ['label' => 'Upcoming', 'tone' => 'sky'];
}
if ($world->isEndingSoon()) {
$badges[] = ['label' => 'Ending soon', 'tone' => 'amber'];
}
if ((bool) $world->is_homepage_featured || (bool) $world->is_featured) {
$badges[] = ['label' => 'Featured', 'tone' => 'rose'];
}
return $badges;
}
private function promotionWindowLabel(World $world): ?string
{
if (! $world->promotion_starts_at && ! $world->promotion_ends_at) {
return null;
}
if ($world->promotion_starts_at && $world->promotion_ends_at) {
return 'Promotion ' . $world->promotion_starts_at->format('d M Y') . ' - ' . $world->promotion_ends_at->format('d M Y');
}
if ($world->promotion_starts_at) {
return 'Promotion starts ' . $world->promotion_starts_at->format('d M Y');
}
return 'Promoted through ' . $world->promotion_ends_at?->format('d M Y');
}
private function mapStudioSubmission(WorldSubmission $submission, \Illuminate\Support\Collection $rewardMap): array
{
$artwork = $submission->artwork;
$views = (int) ($artwork?->stats?->views ?? 0);
$creatorId = (int) ($artwork?->user?->id ?? 0);
return [
'id' => (int) $submission->id,
'status' => (string) $submission->status,
'status_label' => $this->statusLabel((string) $submission->status, (bool) $submission->is_featured),
'can_grant_manual_rewards' => (string) $submission->status === WorldSubmission::STATUS_LIVE,
'is_featured' => (bool) $submission->is_featured,
'note' => (string) ($submission->note ?? ''),
'reviewer_note' => (string) ($submission->moderation_reason ?: $submission->reviewer_note ?? ''),
@@ -439,6 +613,7 @@ final class WorldSubmissionService
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
])),
] : null,
'world_rewards' => $creatorId > 0 ? ($rewardMap->get($creatorId) ?? []) : [],
'actions' => [
'approve' => route('studio.worlds.submissions.approve', ['world' => $submission->world_id, 'submission' => $submission->id]),
'remove' => route('studio.worlds.submissions.remove', ['world' => $submission->world_id, 'submission' => $submission->id]),
@@ -448,6 +623,16 @@ final class WorldSubmissionService
'feature' => route('studio.worlds.submissions.feature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'unfeature' => route('studio.worlds.submissions.unfeature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'pending' => route('studio.worlds.submissions.pending', ['world' => $submission->world_id, 'submission' => $submission->id]),
'grant_rewards' => [
WorldRewardType::Winner->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Winner->value]),
WorldRewardType::Finalist->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Finalist->value]),
WorldRewardType::Spotlight->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Spotlight->value]),
],
'revoke_rewards' => [
WorldRewardType::Winner->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Winner->value]),
WorldRewardType::Finalist->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Finalist->value]),
WorldRewardType::Spotlight->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Spotlight->value]),
],
],
];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\UserXpUpdated;
use App\Enums\WorldRewardType;
use App\Models\User;
use App\Models\UserXpLog;
use Illuminate\Support\Arr;
@@ -123,6 +124,11 @@ class XPService
return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId);
}
public function awardWorldReward(int $userId, WorldRewardType $rewardType, int $worldId): bool
{
return $this->awardUnique($userId, $rewardType->xpReward(), 'world_reward:' . $rewardType->value, $worldId);
}
public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool
{
$viewerKey = $viewerId !== null && $viewerId > 0