847 lines
37 KiB
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, '');
|
|
}
|
|
} |