360 lines
13 KiB
PHP
360 lines
13 KiB
PHP
<?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 ?? [],
|
||
];
|
||
}
|
||
}
|