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

@@ -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]),
],
],
];
}