resolveUser($user); $userId = (int) $resolvedUser->id; $version = $this->cacheVersion($userId); return Cache::remember( sprintf('creator_journey:public:%d:v%d', $userId, $version), now()->addSeconds(self::PUBLIC_CACHE_TTL_SECONDS), function () use ($resolvedUser, $userId): array { $rows = CreatorMilestone::query() ->where('user_id', $userId) ->where('is_public', true) ->orderByDesc('occurred_at') ->orderByDesc('priority') ->orderByDesc('id') ->get(); if ($rows->isEmpty()) { $this->rebuildForUser($resolvedUser); $rows = CreatorMilestone::query() ->where('user_id', $userId) ->where('is_public', true) ->orderByDesc('occurred_at') ->orderByDesc('priority') ->orderByDesc('id') ->get(); } // v2: gather eras, evolution, and streak stats $eraData = Schema::hasTable('creator_eras') ? $this->eras->publicErasForUser($userId) : []; $evolutionData = Schema::hasTable('artwork_relations') ? $this->evolutionPayloadForUser($userId) : []; $artworks = $this->publicArtworkRows($userId); $streakStats = $this->streaks->computeStreakStats($artworks); return $this->formatPublicPayload($resolvedUser, $rows, $eraData, $evolutionData, $streakStats); } ); } /** * @return array{milestones_saved:int} */ public function rebuildForUser(User|int $user): array { $resolvedUser = $this->resolveUser($user); $userId = (int) $resolvedUser->id; $computedAt = now(); $rows = $this->calculateMilestones($resolvedUser, $computedAt); DB::transaction(function () use ($userId, $rows): void { CreatorMilestone::query()->where('user_id', $userId)->delete(); if ($rows !== []) { DB::table('creator_milestones')->insert($rows); } }); // Rebuild eras in the same pass (separate table, transactional independently) $artworks = $this->publicArtworkRows($userId); $this->eras->rebuildForUser($resolvedUser, $artworks); Cache::forget($this->rebuildDebounceKey($userId)); $this->bumpCacheVersion($userId); return ['milestones_saved' => count($rows)]; } public function requestRebuild(int $userId, bool $force = false): void { if ($userId <= 0) { return; } if (! $force && ! Cache::add($this->rebuildDebounceKey($userId), true, now()->addSeconds(self::REBUILD_DEBOUNCE_SECONDS))) { return; } RebuildCreatorJourneyJob::dispatch([$userId]); } public function invalidateUser(int $userId): void { if ($userId <= 0) { return; } $this->bumpCacheVersion($userId); } /** * @return array> */ private function calculateMilestones(User $user, CarbonInterface $computedAt): array { $artworks = $this->publicArtworkRows((int) $user->id); $milestones = []; if ($firstUpload = $artworks->sortBy([['published_at', 'asc'], ['id', 'asc']])->first()) { $occurredAt = $this->parseDate($firstUpload->published_at); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::FirstUpload, $occurredAt, [ 'title' => 'First upload', 'headline' => (string) $firstUpload->title, 'summary' => 'Started the public journey with the first published work on Skinbase.', 'value' => $this->displayDate($occurredAt), 'artwork' => $this->artworkSnapshot($firstUpload), ], (int) $firstUpload->id, $computedAt, ); } if ($firstFeatured = $this->firstFeaturedArtwork((int) $user->id)) { $occurredAt = $this->parseDate($firstFeatured->featured_at); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::FirstFeaturedArtwork, $occurredAt, [ 'title' => 'First featured artwork', 'headline' => (string) $firstFeatured->title, 'summary' => 'Earned a first featured slot on the public artwork lineup.', 'value' => $this->displayDate($occurredAt), 'artwork' => $this->artworkSnapshot($firstFeatured), ], (int) $firstFeatured->id, $computedAt, ); } if ($firstGroupRelease = $this->firstGroupRelease((int) $user->id)) { $occurredAt = $this->parseDate($firstGroupRelease->released_on); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::FirstGroupRelease, $occurredAt, [ 'title' => 'First group release', 'headline' => (string) $firstGroupRelease->release_title, 'summary' => 'Joined the first public group release as a credited contributor.', 'value' => (string) $firstGroupRelease->group_name, 'release' => [ 'id' => (int) $firstGroupRelease->release_id, 'title' => (string) $firstGroupRelease->release_title, 'group_name' => (string) $firstGroupRelease->group_name, 'url' => url('/groups/' . $firstGroupRelease->group_slug . '/releases/' . $firstGroupRelease->release_slug), ], ], null, $computedAt, ); } if ($bestSpike = $this->biggestDownloadSpike($artworks)) { $occurredAt = $this->parseDate($bestSpike['occurred_at']); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::BiggestDownloadSpike, $occurredAt, [ 'title' => 'Biggest download spike', 'headline' => (string) $bestSpike['artwork']->title, 'summary' => 'Captured the strongest one-hour download burst recorded for a public artwork.', 'value' => (int) $bestSpike['downloads_in_hour'] . ' downloads in 1 hour', 'artwork' => $this->artworkSnapshot($bestSpike['artwork']), 'metrics' => [ 'downloads_in_hour' => (int) $bestSpike['downloads_in_hour'], ], ], (int) $bestSpike['artwork']->id, $computedAt, ); } if ($bestPerforming = $this->bestPerformingArtwork($artworks)) { $occurredAt = $this->parseDate($bestPerforming->published_at); $score = $this->basePerformanceScore($bestPerforming); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::BestPerformingWork, $occurredAt, [ 'title' => 'Best-performing work', 'headline' => (string) $bestPerforming->title, 'summary' => 'Leads the public catalog on total engagement across views, downloads, favourites, comments, and shares.', 'value' => number_format($score, 1) . ' performance points', 'artwork' => $this->artworkSnapshot($bestPerforming), 'metrics' => $this->artworkMetricSnapshot($bestPerforming) + ['performance_score' => round($score, 2)], ], (int) $bestPerforming->id, $computedAt, ); } if ($mostProductiveYear = $this->mostProductiveYear($artworks)) { $occurredAt = $this->parseDate($mostProductiveYear['last_published_at']); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::MostProductiveYear, $occurredAt, [ 'title' => 'Most productive year', 'headline' => (string) $mostProductiveYear['year'], 'summary' => 'Published the highest number of public artworks in a single year.', 'value' => (int) $mostProductiveYear['uploads_count'] . ' public uploads', 'metrics' => [ 'year' => (int) $mostProductiveYear['year'], 'uploads_count' => (int) $mostProductiveYear['uploads_count'], ], ], null, $computedAt, ); } // ── v2: Comeback milestones ──────────────────────────────────────── foreach ($this->comebacks->calculateComebacks($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) { $milestones[] = $row; } // ── v2: Streak milestones ───────────────────────────────────────── foreach ($this->streaks->calculateStreakMilestones($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) { $milestones[] = $row; } // ── v2: Era milestones ──────────────────────────────────────────── foreach ($this->eras->calculateEraMilestones($user, $artworks, $computedAt, $this->makeMilestoneRow(...)) as $row) { $milestones[] = $row; } // ── v2: Evolution / Before-Now milestones ───────────────────────── foreach ($this->evolutionMilestonesForUser((int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) { $milestones[] = $row; } foreach ($this->yearlyRecaps($artworks) as $recap) { $occurredAt = $this->parseDate($recap['last_published_at']); $milestones[] = $this->makeMilestoneRow( (int) $user->id, CreatorMilestoneType::YearlyRecap, $occurredAt, [ 'title' => $recap['year'] . ' recap', 'headline' => $recap['uploads_count'] . ' public uploads', 'summary' => $recap['downloads'] . ' downloads, ' . number_format((int) $recap['views']) . ' views, and ' . $recap['favorites'] . ' favourites across the year.', 'value' => (string) $recap['year'], 'artwork' => $recap['top_artwork'] !== null ? $this->artworkSnapshot($recap['top_artwork']) : null, 'metrics' => [ 'year' => (int) $recap['year'], 'uploads_count' => (int) $recap['uploads_count'], 'views' => (int) $recap['views'], 'downloads' => (int) $recap['downloads'], 'favorites' => (int) $recap['favorites'], 'comments_count' => (int) $recap['comments_count'], 'shares_count' => (int) $recap['shares_count'], 'featured_count' => (int) $recap['featured_count'], 'performance_score' => round((float) $recap['performance_score'], 2), 'top_category' => $recap['top_category'] ?? null, 'best_month' => $recap['best_month'] ?? null, 'year_status' => $recap['year_status'] ?? 'steady', ], 'shareable_recap' => [ 'type' => 'yearly_recap', 'year' => (int) $recap['year'], 'title' => 'My ' . $recap['year'] . ' on Skinbase', 'stats' => [ 'uploads' => (int) $recap['uploads_count'], 'downloads' => (int) $recap['downloads'], 'featured' => (int) $recap['featured_count'], ], 'top_artwork' => $recap['top_artwork'] !== null ? [ 'id' => (int) $recap['top_artwork']->id, 'title' => (string) $recap['top_artwork']->title, ] : null, ], ], $recap['top_artwork'] !== null ? (int) $recap['top_artwork']->id : null, $computedAt, ); } return collect($milestones) ->sortBy([ ['occurred_at', 'desc'], ['priority', 'desc'], ]) ->values() ->all(); } private function publicArtworkRows(int $userId): Collection { return DB::table('artworks as a') ->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'a.id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->where(function ($query): void { $query->whereNull('a.visibility') ->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC); }) ->whereNotNull('a.published_at') ->where('a.published_at', '<=', now()) ->orderBy('a.published_at') ->orderBy('a.id') ->get([ 'a.id', 'a.title', 'a.slug', 'a.published_at', 'a.created_at', 's.views as stat_views', 's.downloads as stat_downloads', 's.favorites as stat_favorites', 's.comments_count as stat_comments_count', 's.shares_count as stat_shares_count', 's.downloads_1h as stat_downloads_1h', 's.heat_score_updated_at as stat_heat_score_updated_at', ]); } private function firstFeaturedArtwork(int $userId): ?object { return DB::table('artwork_features as af') ->join('artworks as a', 'a.id', '=', 'af.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->where(function ($query): void { $query->whereNull('a.visibility') ->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC); }) ->whereNotNull('a.published_at') ->whereNull('af.deleted_at') ->where('af.is_active', true) ->orderBy('af.featured_at') ->orderBy('a.id') ->first([ 'a.id', 'a.title', 'a.slug', 'a.published_at', 'af.featured_at', ]); } private function firstGroupRelease(int $userId): ?object { return DB::table('group_release_contributors as grc') ->join('group_releases as gr', 'gr.id', '=', 'grc.group_release_id') ->join('groups as g', 'g.id', '=', 'gr.group_id') ->where('grc.user_id', $userId) ->whereNull('gr.deleted_at') ->where('gr.visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('gr.status', GroupRelease::STATUS_RELEASED) ->where('g.visibility', Group::VISIBILITY_PUBLIC) ->where('g.status', Group::LIFECYCLE_ACTIVE) ->whereNotNull('gr.released_at') ->where('gr.released_at', '<=', now()) ->orderBy('gr.released_at') ->orderBy('gr.id') ->first([ 'gr.id as release_id', 'gr.title as release_title', 'gr.slug as release_slug', 'gr.released_at as released_on', 'g.name as group_name', 'g.slug as group_slug', ]); } /** * @param Collection $artworks * @return array{artwork:object,downloads_in_hour:int,occurred_at:string}|null */ private function biggestDownloadSpike(Collection $artworks): ?array { $best = null; $publicArtworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all(); if ($publicArtworkIds !== [] && DB::getSchemaBuilder()->hasTable('artwork_metric_snapshots_hourly')) { $snapshots = DB::table('artwork_metric_snapshots_hourly as ms') ->whereIn('ms.artwork_id', $publicArtworkIds) ->orderBy('ms.artwork_id') ->orderBy('ms.bucket_hour') ->get([ 'ms.artwork_id', 'ms.bucket_hour', 'ms.downloads_count', ]); $byArtwork = $artworks->keyBy('id'); $previous = []; foreach ($snapshots as $snapshot) { $artwork = $byArtwork->get((int) $snapshot->artwork_id); if (! $artwork) { continue; } $priorCount = $previous[(int) $snapshot->artwork_id] ?? null; if ($priorCount !== null) { $delta = max(0, (int) $snapshot->downloads_count - $priorCount['downloads_count']); if ($delta > 0 && ($best === null || $delta > $best['downloads_in_hour'] || ($delta === $best['downloads_in_hour'] && $snapshot->bucket_hour > $best['occurred_at']))) { $best = [ 'artwork' => $artwork, 'downloads_in_hour' => $delta, 'occurred_at' => (string) $snapshot->bucket_hour, ]; } } $previous[(int) $snapshot->artwork_id] = [ 'downloads_count' => (int) $snapshot->downloads_count, ]; } } if ($best !== null) { return $best; } $fallback = $artworks ->filter(fn ($artwork): bool => (int) ($artwork->stat_downloads_1h ?? 0) > 0) ->sortBy([ fn ($artwork): int => -1 * (int) ($artwork->stat_downloads_1h ?? 0), fn ($artwork): string => (string) ($artwork->stat_heat_score_updated_at ?? $artwork->published_at ?? ''), ]) ->first(); if (! $fallback) { return null; } return [ 'artwork' => $fallback, 'downloads_in_hour' => (int) ($fallback->stat_downloads_1h ?? 0), 'occurred_at' => (string) ($fallback->stat_heat_score_updated_at ?? $fallback->published_at), ]; } private function bestPerformingArtwork(Collection $artworks): ?object { return $artworks ->filter(fn ($artwork): bool => $this->basePerformanceScore($artwork) > 0) ->sortBy([ fn ($artwork): float => -1 * $this->basePerformanceScore($artwork), fn ($artwork): string => (string) ($artwork->published_at ?? ''), ]) ->first(); } /** * @param Collection $artworks * @return array{year:int,uploads_count:int,last_published_at:string}|null */ private function mostProductiveYear(Collection $artworks): ?array { return $artworks ->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at))) ->map(function (Collection $items, int $year): array { $lastPublishedAt = $items ->sortByDesc('published_at') ->first()?->published_at; return [ 'year' => $year, 'uploads_count' => $items->count(), 'last_published_at' => (string) $lastPublishedAt, ]; }) ->sortBy([ fn (array $row): int => -1 * (int) $row['uploads_count'], fn (array $row): int => -1 * (int) $row['year'], ]) ->first(); } /** * @param Collection $artworks * @return array> */ private function yearlyRecaps(Collection $artworks): array { // Fetch featured counts per year once (keyed by year) $featuredByYear = $this->featuredCountsByYear($artworks); return $artworks ->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at))) ->map(function (Collection $items, int $year) use ($featuredByYear): array { $topArtwork = $items ->sortByDesc(fn ($artwork): float => $this->basePerformanceScore($artwork)) ->first(); $downloads = $items->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0)); $uploads = $items->count(); $featured = (int) ($featuredByYear[$year] ?? 0); $perfScore = $items->sum(fn ($a): float => $this->basePerformanceScore($a)); // Best month: which calendar month had the most uploads $bestMonth = $items ->groupBy(fn ($a): string => date('Y-m', strtotime((string) $a->published_at))) ->map(fn (Collection $g): int => $g->count()) ->sortDesc() ->keys() ->first(); // Top category from artwork pivot (best effort — requires subquery or separate call) $topCategory = $this->topCategoryForYear($items); // Year status label $yearStatus = $this->classifyYear($uploads, $featured, $perfScore); return [ 'year' => $year, 'uploads_count' => $uploads, 'views' => $items->sum(fn ($a): int => (int) ($a->stat_views ?? 0)), 'downloads' => $downloads, 'favorites' => $items->sum(fn ($a): int => (int) ($a->stat_favorites ?? 0)), 'comments_count' => $items->sum(fn ($a): int => (int) ($a->stat_comments_count ?? 0)), 'shares_count' => $items->sum(fn ($a): int => (int) ($a->stat_shares_count ?? 0)), 'featured_count' => $featured, 'performance_score' => $perfScore, 'last_published_at' => (string) $items->sortByDesc('published_at')->first()?->published_at, 'top_artwork' => $topArtwork, 'best_month' => $bestMonth, 'top_category' => $topCategory, 'year_status' => $yearStatus, ]; }) ->sortByDesc('year') ->values() ->all(); } /** * @param Collection $artworks * @return array year => featured_count */ private function featuredCountsByYear(Collection $artworks): array { $artworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all(); if ($artworkIds === [] || ! Schema::hasTable('artwork_features')) { return []; } $yearExpr = DB::connection()->getDriverName() === 'sqlite' ? "CAST(strftime('%Y', af.featured_at) AS INTEGER)" : 'YEAR(af.featured_at)'; return DB::table('artwork_features as af') ->join('artworks as a', 'a.id', '=', 'af.artwork_id') ->whereIn('af.artwork_id', $artworkIds) ->whereNull('af.deleted_at') ->where('af.is_active', true) ->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt") ->groupBy('yr') ->pluck('cnt', 'yr') ->map(fn ($cnt): int => (int) $cnt) ->toArray(); } /** * @param Collection $items */ private function topCategoryForYear(Collection $items): ?string { $artworkIds = $items->pluck('id')->map(fn ($id): int => (int) $id)->all(); if ($artworkIds === [] || ! Schema::hasTable('artwork_category')) { return null; } $row = DB::table('artwork_category as ac') ->join('categories as c', 'c.id', '=', 'ac.category_id') ->whereIn('ac.artwork_id', $artworkIds) ->selectRaw('c.name, COUNT(*) as cnt') ->groupBy('c.id', 'c.name') ->orderByDesc('cnt') ->first(['c.name']); return $row ? (string) $row->name : null; } private function classifyYear(int $uploads, int $featured, float $perfScore): string { if ($uploads >= 10 && $featured >= 2) { return 'breakout'; } if ($featured >= 1 && $uploads >= 5) { return 'steady'; } if ($uploads >= 6 && $featured === 0) { return 'experimental'; } if ($uploads <= 2) { return 'quiet'; } return 'steady'; } private function formatPublicPayload( User $user, Collection $rows, array $eras = [], array $evolution = [], array $streakStats = [], ): array { $items = $rows->map(function (CreatorMilestone $milestone): array { $payload = $milestone->payload_json ?? []; return [ 'id' => (int) $milestone->id, 'type' => (string) $milestone->type, 'occurred_at' => $milestone->occurred_at?->toIso8601String(), 'occurred_year' => $milestone->occurred_year, 'priority' => (int) $milestone->priority, 'title' => (string) ($payload['title'] ?? Str::headline((string) $milestone->type)), 'headline' => $payload['headline'] ?? null, 'summary' => $payload['summary'] ?? null, 'value' => $payload['value'] ?? null, 'artwork' => $payload['artwork'] ?? null, 'release' => $payload['release'] ?? null, 'metrics' => $payload['metrics'] ?? [], 'metadata' => $payload['metadata'] ?? null, 'shareable_recap' => $payload['shareable_recap'] ?? null, ]; })->values(); $timeline = $items ->reject(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value) ->values() ->all(); $yearlyRecaps = $items ->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value) ->sortByDesc('occurred_year') ->values() ->all(); // Build shareable recap payloads from yearly recap milestone payloads $shareableRecaps = $items ->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value) ->sortByDesc('occurred_year') ->map(fn (array $item): ?array => $item['shareable_recap']) ->filter() ->values() ->all(); $highlightTypes = [ CreatorMilestoneType::BestPerformingWork->value, CreatorMilestoneType::BiggestDownloadSpike->value, CreatorMilestoneType::MostProductiveYear->value, CreatorMilestoneType::FirstFeaturedArtwork->value, CreatorMilestoneType::ComebackLegendary->value, CreatorMilestoneType::UploadStreak12->value, CreatorMilestoneType::ActiveYearStreak5->value, ]; $highlights = $items ->filter(fn (array $item): bool => in_array($item['type'], $highlightTypes, true)) ->sortByDesc('priority') ->values() ->take(4) ->all(); $latestMilestone = collect($timeline)->first(); // Streak summary for API $streakSummary = [ 'current_monthly_upload_streak' => (int) ($streakStats['current_monthly_streak'] ?? 0), 'best_monthly_upload_streak' => (int) ($streakStats['best_monthly_streak'] ?? 0), 'current_active_year_streak' => (int) ($streakStats['current_year_streak'] ?? 0), 'best_active_year_streak' => (int) ($streakStats['best_year_streak'] ?? 0), ]; return [ 'summary' => [ 'available' => $items->isNotEmpty(), 'member_since_year' => $user->created_at?->year, 'years_on_skinbase' => $user->created_at?->diffInYears(now()), 'milestone_count' => $items->count(), 'latest_milestone' => $latestMilestone, 'latest_yearly_recap' => $yearlyRecaps[0] ?? null, 'generated_at' => $rows->max(fn (CreatorMilestone $milestone) => $milestone->computed_at?->toIso8601String()), ], 'highlights' => $highlights, 'timeline' => $timeline, 'yearly_recaps' => $yearlyRecaps, // ── v2 sections ──────────────────────────────────────────────── 'eras' => $eras, 'evolution' => $evolution, 'streaks' => $streakSummary, 'shareable_recaps' => $shareableRecaps, ]; } private function evolutionPayloadForUser(int $userId): array { // Fetch public artwork_relations where the source artwork belongs to this creator. // Both source and target must be public for public display. $rows = DB::table('artwork_relations as ar') ->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id') ->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id') ->leftJoin('artwork_stats as ss', 'ss.artwork_id', '=', 'ar.source_artwork_id') ->leftJoin('artwork_stats as ts', 'ts.artwork_id', '=', 'ar.target_artwork_id') ->where('src.user_id', $userId) ->whereNull('src.deleted_at') ->whereNull('tgt.deleted_at') ->where('src.is_public', true) ->where('src.is_approved', true) ->where('tgt.is_public', true) ->where('tgt.is_approved', true) ->whereNotNull('src.published_at') ->whereNotNull('tgt.published_at') ->orderBy('ar.sort_order') ->orderBy('ar.id') ->get([ 'ar.id', 'ar.relation_type', 'ar.note', 'src.id as src_id', 'src.title as src_title', 'src.slug as src_slug', 'src.published_at as src_published_at', 'tgt.id as tgt_id', 'tgt.title as tgt_title', 'tgt.slug as tgt_slug', 'tgt.published_at as tgt_published_at', ]); return $rows->map(function (object $row): array { $srcDate = Carbon::parse($row->src_published_at); $tgtDate = Carbon::parse($row->tgt_published_at); $yearsBetween = (int) abs($tgtDate->diffInYears($srcDate)); return [ 'id' => (int) $row->id, 'relation_type' => (string) $row->relation_type, 'years_between' => $yearsBetween, 'note' => $row->note, 'source_artwork' => [ 'id' => (int) $row->src_id, 'title' => (string) $row->src_title, 'slug' => (string) $row->src_slug, 'url' => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]), 'published_at' => $srcDate->toIso8601String(), ], 'target_artwork' => [ 'id' => (int) $row->tgt_id, 'title' => (string) $row->tgt_title, 'slug' => (string) $row->tgt_slug, 'url' => route('art.show', ['id' => (int) $row->tgt_id, 'slug' => $row->tgt_slug]), 'published_at' => $tgtDate->toIso8601String(), ], ]; })->values()->all(); } private function evolutionMilestonesForUser(int $userId, CarbonInterface $computedAt, callable $makeMilestoneRow): array { if (! Schema::hasTable('artwork_relations')) { return []; } $rows = DB::table('artwork_relations as ar') ->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id') ->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id') ->where('src.user_id', $userId) ->whereNull('src.deleted_at') ->whereNull('tgt.deleted_at') ->where('src.is_public', true) ->where('src.is_approved', true) ->where('tgt.is_public', true) ->where('tgt.is_approved', true) ->whereNotNull('src.published_at') ->whereNotNull('tgt.published_at') ->get(['ar.id', 'ar.relation_type', 'ar.note', 'src.id as src_id', 'src.title as src_title', 'src.slug as src_slug', 'src.published_at as src_pub', 'tgt.id as tgt_id', 'tgt.title as tgt_title', 'tgt.published_at as tgt_pub']); $milestones = []; foreach ($rows as $row) { $srcDate = Carbon::parse($row->src_pub); $tgtDate = Carbon::parse($row->tgt_pub); $years = max(0, (int) abs($tgtDate->diffInYears($srcDate))); $yearStr = $years >= 1 ? "{$years} " . ($years === 1 ? 'year' : 'years') . ' later' : 'recently'; $milestones[] = $makeMilestoneRow( $userId, CreatorMilestoneType::BeforeNow, $srcDate->max($tgtDate), // milestone at the newer artwork [ 'title' => 'Then & Now', 'headline' => (string) $row->src_title, 'summary' => "Revisited and {$row->relation_type} \"{$row->tgt_title}\" — {$yearStr}.", 'value' => $yearStr, 'artwork' => [ 'id' => (int) $row->src_id, 'title' => (string) $row->src_title, 'slug' => (string) $row->src_slug, 'url' => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]), ], 'metadata' => [ 'relation_type' => $row->relation_type, 'years_between' => $years, 'source_artwork_id' => (int) $row->src_id, 'target_artwork_id' => (int) $row->tgt_id, ], ], (int) $row->src_id, $computedAt, ); } return $milestones; } /** * @param array $payload * @return array */ private function makeMilestoneRow( int $userId, CreatorMilestoneType $type, ?CarbonInterface $occurredAt, array $payload, ?int $relatedArtworkId, CarbonInterface $computedAt, ): array { $occurredAt = $occurredAt ?? $computedAt; return [ 'user_id' => $userId, 'type' => $type->value, 'occurred_at' => $occurredAt->toDateTimeString(), 'occurred_year' => (int) $occurredAt->year, 'related_artwork_id' => $relatedArtworkId, 'is_public' => true, 'priority' => $type->priority(), 'payload_json' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 'computed_at' => $computedAt->toDateTimeString(), 'created_at' => $computedAt->toDateTimeString(), 'updated_at' => $computedAt->toDateTimeString(), ]; } /** * @return array */ private function artworkSnapshot(object $artwork): array { $slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id; return [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'slug' => (string) $slug, 'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]), 'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(), ]; } /** * @return array */ private function artworkMetricSnapshot(object $artwork): array { return [ 'views' => (int) ($artwork->stat_views ?? 0), 'downloads' => (int) ($artwork->stat_downloads ?? 0), 'favorites' => (int) ($artwork->stat_favorites ?? 0), 'comments_count' => (int) ($artwork->stat_comments_count ?? 0), 'shares_count' => (int) ($artwork->stat_shares_count ?? 0), ]; } private function basePerformanceScore(object $artwork): float { return $this->ranking->calculateBaseScore((object) [ 'views_all' => (float) ($artwork->stat_views ?? 0), 'downloads_all' => (float) ($artwork->stat_downloads ?? 0), 'favourites_all' => (float) ($artwork->stat_favorites ?? 0), 'comments_count' => (float) ($artwork->stat_comments_count ?? 0), 'shares_count' => (float) ($artwork->stat_shares_count ?? 0), ]); } private function displayDate(?CarbonInterface $date): ?string { return $date?->format('M j, Y'); } private function parseDate(mixed $value): ?CarbonInterface { if ($value instanceof CarbonInterface) { return $value; } if (! is_string($value) || trim($value) === '') { return null; } return Carbon::parse($value); } private function resolveUser(User|int $user): User { return $user instanceof User ? $user : User::query()->findOrFail($user); } private function cacheVersion(int $userId): int { return (int) Cache::get($this->cacheVersionKey($userId), 1); } private function bumpCacheVersion(int $userId): void { Cache::forever($this->cacheVersionKey($userId), $this->cacheVersion($userId) + 1); } private function cacheVersionKey(int $userId): string { return 'creator_journey:version:' . $userId; } private function rebuildDebounceKey(int $userId): string { return 'creator_journey:rebuild:debounce:' . $userId; } }