986 lines
40 KiB
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;
|
|
}
|
|
} |