Build world campaigns rewards and recaps
This commit is contained in:
455
app/Services/Profile/WorldProfileHistoryService.php
Normal file
455
app/Services/Profile/WorldProfileHistoryService.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
847
app/Services/Worlds/WorldAnalyticsService.php
Normal file
847
app/Services/Worlds/WorldAnalyticsService.php
Normal 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, '');
|
||||
}
|
||||
}
|
||||
1421
app/Services/Worlds/WorldEditorialSuggestionService.php
Normal file
1421
app/Services/Worlds/WorldEditorialSuggestionService.php
Normal file
File diff suppressed because it is too large
Load Diff
569
app/Services/Worlds/WorldRewardService.php
Normal file
569
app/Services/Worlds/WorldRewardService.php
Normal 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
@@ -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]),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user