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 { $grants = WorldRewardGrant::query() ->with('world') ->where('artwork_id', (int) $artwork->id) ->orderByRaw($this->sortCaseSql()) ->orderByDesc('granted_at') ->get() ->values(); World::primeCanonicalEditionIds( $grants->pluck('world') ->filter() ->pluck('recurrence_key') ->all() ); return $grants ->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; } }