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