feat: ship creator journey v2 and profile updates
This commit is contained in:
986
app/Services/Profile/CreatorJourneyService.php
Normal file
986
app/Services/Profile/CreatorJourneyService.php
Normal file
@@ -0,0 +1,986 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user