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

360 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 ?? [],
];
}
}