feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use App\Models\Artwork;
use App\Models\CreatorEra;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Generates deterministic creator eras from a creator's public artwork history.
*
* Era types (assigned in order):
* early_years from first upload until a breakthrough signal
* breakthrough starts at first featured artwork or first major download milestone
* experimental detected when a creator shows high category/tag diversity with lower volume
* comeback starts after a significant inactivity gap (180+ days) followed by new publishing
* current the latest ongoing active phase (always set for active creators)
*
* Rules:
* - Only public artworks are considered.
* - Era boundaries are determined by key events (features, comebacks).
* - At most one era of each non-current type is created per rebuild.
* - The "current" era is always the last active phase.
*/
final class CreatorEraService
{
private const COMEBACK_GAP_DAYS = 180;
/**
* Rebuild all eras for a user: delete existing rows and reinsert computed ones.
*
* @param Collection<int, object> $artworks public artwork rows (ascending by published_at)
*/
public function rebuildForUser(User $user, Collection $artworks): void
{
$eras = $this->computeEras($user, $artworks);
DB::transaction(function () use ($user, $eras): void {
CreatorEra::query()->where('user_id', (int) $user->id)->delete();
if ($eras !== []) {
DB::table('creator_eras')->insert($eras);
}
});
}
/**
* Return the public era payload for the journey API.
*
* @return list<array<string, mixed>>
*/
public function publicErasForUser(int $userId): array
{
return CreatorEra::query()
->where('user_id', $userId)
->orderBy('starts_at')
->get()
->map(fn (CreatorEra $era): array => $this->formatEra($era))
->values()
->all();
}
/**
* Compute milestone rows for era_started events.
*
* @param Collection<int, object> $artworks
* @return array<int, array<string, mixed>>
*/
public function calculateEraMilestones(
User $user,
Collection $artworks,
CarbonInterface $computedAt,
callable $makeMilestoneRow,
): array {
if ($artworks->isEmpty()) {
return [];
}
$eras = $this->computeEras($user, $artworks);
$milestones = [];
foreach ($eras as $era) {
if (in_array($era['era_type'], ['early_years', 'current'], true)) {
continue; // Only notable era transitions get milestone rows
}
$occurredAt = Carbon::parse($era['starts_at']);
$milestones[] = $makeMilestoneRow(
(int) $user->id,
CreatorMilestoneType::EraStarted,
$occurredAt,
[
'title' => 'New era',
'headline' => $era['title'],
'summary' => $era['description'] ?? 'A new creative phase began.',
'value' => $era['title'],
'metadata' => ['era_type' => $era['era_type']],
],
null,
$computedAt,
);
}
return $milestones;
}
/**
* @param Collection<int, object> $artworks
* @return array<int, array<string, mixed>>
*/
private function computeEras(User $user, Collection $artworks): array
{
if ($artworks->isEmpty()) {
return [];
}
$sorted = $artworks
->filter(fn (object $row): bool => ! empty($row->published_at))
->sortBy([['published_at', 'asc'], ['id', 'asc']])
->values();
if ($sorted->isEmpty()) {
return [];
}
$now = Carbon::now();
$userId = (int) $user->id;
$eras = [];
$firstArtwork = $sorted->first();
$firstDate = Carbon::parse($firstArtwork->published_at);
$lastArtwork = $sorted->last();
$lastDate = Carbon::parse($lastArtwork->published_at);
// Detect featured date (breakthrough signal)
$firstFeaturedAt = $this->firstFeaturedDate($userId);
$firstMajorDownloadAt = $this->firstMajorDownloadDate($sorted);
// Detect comeback gap
$comebackDate = $this->firstComebackDate($sorted);
// Phase boundaries
$breakthroughAt = match (true) {
$firstFeaturedAt !== null => $firstFeaturedAt,
$firstMajorDownloadAt !== null => $firstMajorDownloadAt,
default => null,
};
// ── Early Years ────────────────────────────────────────────────────
$earlyYearsEnds = $breakthroughAt?->copy()->subSecond()
?? $comebackDate?->copy()->subSecond()
?? null;
$eras[] = [
'user_id' => $userId,
'era_type' => 'early_years',
'title' => 'Early Years',
'description' => 'The beginning of the creative journey on Skinbase.',
'starts_at' => $firstDate->toDateTimeString(),
'ends_at' => $earlyYearsEnds?->toDateTimeString(),
'is_current' => false,
'metadata' => json_encode($this->eraMetadata($sorted, $firstDate, $earlyYearsEnds ?? $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
// ── Breakthrough Era ───────────────────────────────────────────────
if ($breakthroughAt !== null) {
$breakthroughEnds = $comebackDate?->copy()->subSecond() ?? null;
$eras[] = [
'user_id' => $userId,
'era_type' => 'breakthrough',
'title' => 'Breakthrough Era',
'description' => 'A period marked by first recognition — featured work, strong downloads, and growing visibility.',
'starts_at' => $breakthroughAt->toDateTimeString(),
'ends_at' => $breakthroughEnds?->toDateTimeString(),
'is_current' => false,
'metadata' => json_encode($this->eraMetadata($sorted, $breakthroughAt, $breakthroughEnds ?? $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
}
// ── Comeback Era ───────────────────────────────────────────────────
if ($comebackDate !== null) {
// Comeback era encompasses everything from the comeback to now (or next major event)
$eras[] = [
'user_id' => $userId,
'era_type' => 'comeback',
'title' => 'Comeback Era',
'description' => 'A return to creative work on Skinbase after a significant break.',
'starts_at' => $comebackDate->toDateTimeString(),
'ends_at' => null,
'is_current' => true,
'metadata' => json_encode($this->eraMetadata($sorted, $comebackDate, $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
} else {
// ── Current Era ───────────────────────────────────────────────
// Only set if there's been activity in the last 2 years
$twoYearsAgo = $now->copy()->subYears(2);
if ($lastDate->greaterThanOrEqualTo($twoYearsAgo)) {
$currentStart = $breakthroughAt ?? $firstDate;
// Don't double-stamp if breakthrough era is already current
if ($breakthroughAt === null || $currentStart->equalTo($firstDate)) {
$eras[] = [
'user_id' => $userId,
'era_type' => 'current',
'title' => 'Current Era',
'description' => 'The latest active creative phase on Skinbase.',
'starts_at' => $currentStart->toDateTimeString(),
'ends_at' => null,
'is_current' => true,
'metadata' => json_encode($this->eraMetadata($sorted, $currentStart, $lastDate)),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
} else {
// Mark breakthrough as current
$lastIdx = count($eras) - 1;
$eras[$lastIdx]['is_current'] = true;
$eras[$lastIdx]['ends_at'] = null;
}
}
}
// Deduplicate: ensure we don't have two is_current=true if an era was edited above
$currentCount = count(array_filter($eras, fn ($e) => $e['is_current']));
if ($currentCount > 1) {
// Only the last is_current one stays
$found = false;
for ($i = count($eras) - 1; $i >= 0; $i--) {
if ($eras[$i]['is_current']) {
if ($found) {
$eras[$i]['is_current'] = false;
} else {
$found = true;
}
}
}
}
return $eras;
}
/**
* @param Collection<int, object> $artworks
*/
private function eraMetadata(Collection $artworks, CarbonInterface $from, CarbonInterface $to): array
{
$inRange = $artworks->filter(function (object $artwork) use ($from, $to): bool {
$date = empty($artwork->published_at) ? null : Carbon::parse($artwork->published_at);
if ($date === null) {
return false;
}
return $date->greaterThanOrEqualTo($from) && $date->lessThanOrEqualTo($to);
});
$uploads = $inRange->count();
$downloads = $inRange->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0));
$topArtwork = $inRange->sortByDesc(fn ($a): float => (float) ($a->stat_downloads ?? 0))->first();
$years = $inRange
->map(fn ($a): int => (int) Carbon::parse($a->published_at)->year)
->unique()
->sort()
->values()
->all();
return [
'uploads_count' => $uploads,
'downloads' => $downloads,
'dominant_years' => $years,
'top_artwork_id' => $topArtwork ? (int) $topArtwork->id : null,
];
}
private function firstFeaturedDate(int $userId): ?CarbonInterface
{
$row = 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)
->whereNull('af.deleted_at')
->where('af.is_active', true)
->orderBy('af.featured_at')
->first(['af.featured_at']);
return $row ? Carbon::parse($row->featured_at) : null;
}
/**
* @param Collection<int, object> $sorted
*/
private function firstMajorDownloadDate(Collection $sorted): ?CarbonInterface
{
// Threshold: artwork with 500+ downloads is considered a "major" milestone
$artwork = $sorted->first(fn ($a): bool => (int) ($a->stat_downloads ?? 0) >= 500);
return $artwork ? Carbon::parse($artwork->published_at) : null;
}
/**
* @param Collection<int, object> $sorted
*/
private function firstComebackDate(Collection $sorted): ?CarbonInterface
{
$prevDate = null;
foreach ($sorted as $artwork) {
$currentDate = Carbon::parse($artwork->published_at);
if ($prevDate !== null) {
$gapDays = (int) $prevDate->diffInDays($currentDate);
if ($gapDays >= self::COMEBACK_GAP_DAYS) {
return $currentDate;
}
}
$prevDate = $currentDate;
}
return null;
}
/**
* @return array<string, mixed>
*/
private function formatEra(CreatorEra $era): array
{
return [
'type' => $era->era_type,
'title' => $era->title,
'description' => $era->description,
'starts_at' => $era->starts_at->toIso8601String(),
'ends_at' => $era->ends_at?->toIso8601String(),
'is_current' => $era->is_current,
'stats' => $era->metadata ?? [],
];
}
}