Files
SkinbaseNova/app/Services/Profile/CreatorJourneyService.php

986 lines
40 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use App\Jobs\RebuildCreatorJourneyJob;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\CreatorMilestone;
use App\Models\Group;
use App\Models\GroupRelease;
use App\Models\User;
use App\Services\Profile\CreatorComebackService;
use App\Services\Profile\CreatorEraService;
use App\Services\Profile\CreatorStreakService;
use App\Services\Ranking\ArtworkRankingService;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class CreatorJourneyService
{
private const PUBLIC_CACHE_TTL_SECONDS = 900;
private const REBUILD_DEBOUNCE_SECONDS = 300;
public function __construct(
private readonly ArtworkRankingService $ranking,
private readonly CreatorComebackService $comebacks,
private readonly CreatorStreakService $streaks,
private readonly CreatorEraService $eras,
) {
}
public function publicPayloadForUser(User|int $user): array
{
$resolvedUser = $this->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<int, array<string, mixed>>
*/
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<int, object> $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<int, object> $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<int, object> $artworks
* @return array<int, array<string, mixed>>
*/
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<int, object> $artworks
* @return array<int, int> 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<int, object> $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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, int>
*/
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;
}
}