Files
SkinbaseNova/app/Services/Worlds/WorldAnalyticsService.php

847 lines
37 KiB
PHP

<?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, '');
}
}