Build world campaigns rewards and recaps
This commit is contained in:
@@ -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]),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user