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,156 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Maturity\ArtworkMaturityAuditService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VisionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
final class AuditArtworkMaturityThumbnailsCommand extends Command
{
protected $signature = 'artworks:audit-thumbnail-maturity
{--id= : Audit only this artwork ID}
{--after-id=0 : Skip artworks with ID less than or equal to this value}
{--limit= : Stop after processing this many artworks}
{--chunk=25 : Number of artworks to scan per batch}
{--variant= : Thumbnail variant to analyze (defaults to vision.image_variant)}
{--refresh : Re-scan artworks that already have an open audit finding}
{--dry-run : Report candidates without writing audit findings}';
protected $description = 'Scan artwork thumbnails for possible mature content without mutating artwork maturity fields.';
public function handle(VisionService $vision, ArtworkMaturityAuditService $audit): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$afterId = max(0, (int) $this->option('after-id'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 200));
$dryRun = (bool) $this->option('dry-run');
$refresh = (bool) $this->option('refresh');
$variant = trim((string) ($this->option('variant') ?: config('vision.image_variant', 'md')));
if (! $vision->isEnabled()) {
$this->error('Vision maturity analysis is disabled.');
return self::FAILURE;
}
if (! $dryRun && ! Schema::hasTable('artwork_maturity_audit_findings')) {
$this->error('Artwork maturity audit findings table is missing. Run the latest database migrations first.');
return self::FAILURE;
}
$this->info(sprintf(
'Starting artwork maturity thumbnail audit. order=id_desc variant=%s chunk=%d limit=%s refresh=%s dry_run=%s',
$variant !== '' ? $variant : 'md',
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$refresh ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
));
$query = $audit->eligibleArtworkQuery($refresh)
->orderByDesc('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
if ($afterId > 0) {
$query->where('id', '>', $afterId);
}
$processed = 0;
$flagged = 0;
$safe = 0;
$written = 0;
$failed = 0;
$query->chunkByIdDesc($chunkSize, function ($artworks) use ($vision, $audit, $variant, $limit, $dryRun, $refresh, &$processed, &$flagged, &$safe, &$written, &$failed) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$assessment = (array) ($vision->analyzeArtworkMaturityDetailed($artwork, (string) $artwork->hash, $variant)['assessment'] ?? []);
$processed++;
if ($audit->shouldOpenFinding($assessment)) {
$flagged++;
$message = sprintf(
'Artwork %d flagged for moderator review. action=%s confidence=%s label=%s',
(int) $artwork->id,
(string) ($assessment['action_hint'] ?? 'unknown'),
is_numeric($assessment['confidence'] ?? null) ? number_format((float) $assessment['confidence'], 4, '.', '') : 'n/a',
(string) ($assessment['maturity_label'] ?? 'unknown'),
);
$this->warn($message);
Log::warning('artworks:audit-thumbnail-maturity candidate detected', [
'artwork_id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'assessment' => $assessment,
'variant' => $variant,
]);
if (! $dryRun) {
$audit->recordFinding($artwork, $assessment, $variant !== '' ? $variant : 'md');
$written++;
}
continue;
}
if (($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) === ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
$safe++;
$this->line(sprintf('Artwork %d scanned safe for audit purposes.', (int) $artwork->id));
if (! $dryRun && $refresh) {
$audit->markFindingCleared($artwork, 'Thumbnail maturity rescan no longer indicates moderator review.');
}
continue;
}
$failed++;
$this->warn(sprintf(
'Artwork %d maturity audit failed: %s',
(int) $artwork->id,
(string) ($assessment['advisory'] ?? $assessment['status'] ?? 'unknown failure'),
));
} catch (Throwable $exception) {
$processed++;
$failed++;
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
Log::warning('artworks:audit-thumbnail-maturity failed', [
'artwork_id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'variant' => $variant,
'error' => $exception->getMessage(),
]);
}
}
return true;
});
$this->info(sprintf(
'Artwork maturity thumbnail audit complete. processed=%d flagged=%d safe=%d written=%d failed=%d',
$processed,
$flagged,
$safe,
$written,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class AuditArtworkThumbnailsCommand extends Command
{
protected $signature = 'artworks:audit-thumbnails
{--id= : Audit only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to check (defaults to all configured derivatives)}
{--dry-run : Report missing thumbnails without updating the artworks table}';
protected $description = 'Check artwork thumbnails on the configured object storage disk and mark artworks with missing thumbnails.';
public function handle(UploadStorageService $storage): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$dryRun = (bool) $this->option('dry-run');
$variants = $this->resolveVariants();
if ($variants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return self::FAILURE;
}
if (! $dryRun && ! Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
])) {
$this->error('Artwork thumbnail audit columns are missing. Run the latest database migrations first.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$diskConfig = config("filesystems.disks.{$diskName}");
if (! is_array($diskConfig)) {
$this->error("Filesystem disk [{$diskName}] is not configured.");
return self::FAILURE;
}
$disk = Storage::disk($diskName);
$this->info(sprintf(
'Starting thumbnail audit. disk=%s variants=%s chunk=%d limit=%s dry_run=%s',
$diskName,
implode(',', $variants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$dryRun ? 'yes' : 'no',
));
$query = Artwork::query()
->select(['id', 'hash', 'thumb_ext'])
->orderBy('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
$processed = 0;
$healthy = 0;
$missing = 0;
$written = 0;
$failed = 0;
$query->chunkById($chunkSize, function ($artworks) use ($storage, $disk, $variants, $limit, $dryRun, &$processed, &$healthy, &$missing, &$written, &$failed) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$missingVariants = $this->resolveMissingVariants($artwork, $variants, $storage, $disk);
$hasMissing = $missingVariants !== [];
if ($hasMissing) {
$missing++;
$this->warn(sprintf(
'Artwork %d missing thumbnails: %s',
(int) $artwork->id,
implode(',', $missingVariants),
));
} else {
$healthy++;
}
if (! $dryRun) {
$this->persistAuditResult((int) $artwork->id, $hasMissing, $missingVariants);
$written++;
}
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d audit failed: %s', (int) $artwork->id, $exception->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Thumbnail audit complete. processed=%d healthy=%d missing=%d written=%d failed=%d',
$processed,
$healthy,
$missing,
$written,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return list<string>
*/
private function resolveVariants(): array
{
$configured = array_keys((array) config('uploads.derivatives', []));
$configured = array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$configured,
)));
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configured;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configured));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configured));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
if ($hash === '' || $thumbExt === '') {
return $variants;
}
$filename = $hash . '.' . $thumbExt;
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $filename);
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
/**
* @param list<string> $missingVariants
*/
private function persistAuditResult(int $artworkId, bool $hasMissing, array $missingVariants): void
{
DB::table('artworks')
->where('id', $artworkId)
->update([
'has_missing_thumbnails' => $hasMissing,
'missing_thumbnail_variants_json' => $missingVariants === []
? null
: json_encode(array_values($missingVariants), JSON_UNESCAPED_SLASHES),
'thumbnails_checked_at' => now(),
]);
}
}

View File

@@ -27,14 +27,22 @@ class ConfigureMeilisearchIndex extends Command
private const SORTABLE_ATTRIBUTES = [ private const SORTABLE_ATTRIBUTES = [
'created_at', 'created_at',
'published_at_ts', 'published_at_ts',
'missing_thumbnail_rank',
'trending_score_24h', 'trending_score_24h',
'trending_score_7d', 'trending_score_7d',
'favorites_count', 'favorites_count',
'downloads_count', 'downloads_count',
'awards_received_count', 'awards_received_count',
'awards_score_7d',
'awards_score_30d',
'views', 'views',
'likes', 'likes',
'downloads', 'downloads',
'ranking_score',
'engagement_velocity',
'shares_count',
'comments_count',
'heat_score',
]; ];
/** /**
@@ -44,6 +52,11 @@ class ConfigureMeilisearchIndex extends Command
'id', 'id',
'is_public', 'is_public',
'is_approved', 'is_approved',
'is_mature',
'is_mature_effective',
'maturity_level',
'maturity_status',
'has_missing_thumbnails',
'category', 'category',
'content_type', 'content_type',
'tags', 'tags',

View File

@@ -5,20 +5,20 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\ArtworkAward; use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Services\ArtworkAwardService; use App\Services\ArtworkAwardService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** /**
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`. * Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_medals`.
* *
* Score mapping (legacy score new medal): * Score mapping (legacy score new medal):
* 4 gold (weight 3) * 5 gold
* 3 silver (weight 2) * 4 gold
* 2 bronze (weight 1) * 3 silver
* 1 skipped (too low to map meaningfully) * 2 silver
* 1 bronze
* 0 bronze
* *
* Usage: * Usage:
* php artisan awards:import-legacy * php artisan awards:import-legacy
@@ -29,22 +29,38 @@ use Illuminate\Support\Facades\Schema;
class ImportLegacyAwards extends Command class ImportLegacyAwards extends Command
{ {
protected $signature = 'awards:import-legacy protected $signature = 'awards:import-legacy
{--connection=legacy : Legacy database connection name}
{--artwork-id=* : Restrict import to one or more artwork IDs}
{--show-duplicates : Output skipped duplicate artwork/user pairs at the end}
{--duplicates-limit=100 : Maximum duplicate rows to print when --show-duplicates is used}
{--dry-run : Preview only no writes to DB} {--dry-run : Preview only no writes to DB}
{--chunk=250 : Rows to process per batch} {--chunk=250 : Rows to process per batch}
{--skip-stats : Skip per-artwork stats recalculation at the end} {--skip-stats : Skip per-artwork stats recalculation at the end}
{--force : Overwrite existing awards instead of skipping duplicates}'; {--force : Overwrite existing awards instead of skipping duplicates}';
protected $description = 'Import legacy users_opinions into artwork_awards'; protected $description = 'Import legacy users_opinions into artwork_medals';
/** Maps legacy score value → medal string */ /** Maps legacy score value → medal string */
private const SCORE_MAP = [ private const SCORE_MAP = [
4 => 'gold', 0 => 'bronze',
1 => 'bronze',
2 => 'silver',
3 => 'silver', 3 => 'silver',
2 => 'bronze', 4 => 'gold',
5 => 'gold',
]; ];
public function handle(ArtworkAwardService $service): int public function handle(ArtworkAwardService $service): int
{ {
$legacyConnection = (string) $this->option('connection');
$artworkIds = collect((array) $this->option('artwork-id'))
->map(static fn (mixed $value): int => (int) $value)
->filter(static fn (int $value): bool => $value > 0)
->unique()
->values()
->all();
$showDuplicates = (bool) $this->option('show-duplicates');
$duplicatesLimit = max(1, (int) $this->option('duplicates-limit'));
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk')); $chunk = max(1, (int) $this->option('chunk'));
$skipStats = (bool) $this->option('skip-stats'); $skipStats = (bool) $this->option('skip-stats');
@@ -56,17 +72,24 @@ class ImportLegacyAwards extends Command
// Verify legacy connection is reachable // Verify legacy connection is reachable
try { try {
DB::connection('legacy')->getPdo(); DB::connection($legacyConnection)->getPdo();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage()); $this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $e->getMessage());
return self::FAILURE; return self::FAILURE;
} }
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) { if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable('users_opinions')) {
$this->error('Legacy table `users_opinions` not found.'); $this->error("Legacy table `users_opinions` not found on connection [{$legacyConnection}].");
return self::FAILURE; return self::FAILURE;
} }
$legacyQuery = DB::connection($legacyConnection)->table('users_opinions');
if ($artworkIds !== []) {
$legacyQuery->whereIn('artwork_id', $artworkIds);
$this->info('Restricting import to artwork IDs: ' . implode(', ', $artworkIds));
}
// Pre-load sets of valid artwork IDs and user IDs from the new DB // Pre-load sets of valid artwork IDs and user IDs from the new DB
$this->info('Loading new-DB artwork and user ID sets…'); $this->info('Loading new-DB artwork and user ID sets…');
$validArtworkIds = DB::table('artworks') $validArtworkIds = DB::table('artworks')
@@ -88,9 +111,7 @@ class ImportLegacyAwards extends Command
)); ));
// Count legacy rows for progress bar // Count legacy rows for progress bar
$total = DB::connection('legacy') $total = (clone $legacyQuery)->count();
->table('users_opinions')
->count();
$this->info("Legacy rows to process: {$total}"); $this->info("Legacy rows to process: {$total}");
@@ -105,11 +126,13 @@ class ImportLegacyAwards extends Command
'skipped_artwork' => 0, 'skipped_artwork' => 0,
'skipped_user' => 0, 'skipped_user' => 0,
'skipped_duplicate'=> 0, 'skipped_duplicate'=> 0,
'reported_duplicate'=> 0,
'updated_force' => 0, 'updated_force' => 0,
'errors' => 0, 'errors' => 0,
]; ];
$affectedArtworkIds = []; $affectedArtworkIds = [];
$duplicateRows = [];
$bar = $this->output->createProgressBar($total); $bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%'); $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
@@ -117,24 +140,30 @@ class ImportLegacyAwards extends Command
$bar->setMessage('0', 'skipped'); $bar->setMessage('0', 'skipped');
$bar->start(); $bar->start();
DB::connection('legacy') $legacyQuery
->table('users_opinions')
->orderBy('opinion_id') ->orderBy('opinion_id')
->chunk($chunk, function ($rows) use ( ->chunk($chunk, function ($rows) use (
&$stats, &$stats,
&$affectedArtworkIds, &$affectedArtworkIds,
&$duplicateRows,
$validArtworkIds, $validArtworkIds,
$validUserIds, $validUserIds,
$dryRun, $dryRun,
$force, $force,
$showDuplicates,
$duplicatesLimit,
$bar $bar
) { ) {
$inserts = []; $inserts = [];
$now = now(); $now = now();
foreach ($rows as $row) { foreach ($rows as $row) {
// Legacy users_opinions semantics:
// - artwork_id = the artwork being scored
// - author_id = the artwork owner / author
// - user_id = the voter who gave the score
$artworkId = (int) $row->artwork_id; $artworkId = (int) $row->artwork_id;
$userId = (int) $row->author_id; // author_id = the voter $userId = (int) $row->user_id;
$score = (int) $row->score; $score = (int) $row->score;
$postedAt = $row->post_date ?? $now; $postedAt = $row->post_date ?? $now;
@@ -163,11 +192,11 @@ class ImportLegacyAwards extends Command
if (! $dryRun) { if (! $dryRun) {
if ($force) { if ($force) {
// Upsert: update medal if row already exists // Upsert: update medal if row already exists
$affected = DB::table('artwork_awards') $affected = DB::table('artwork_medals')
->where('artwork_id', $artworkId) ->where('artwork_id', $artworkId)
->where('user_id', $userId) ->where('user_id', $userId)
->update([ ->update([
'medal' => $medal, 'medal_type' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal], 'weight' => ArtworkAward::WEIGHTS[$medal],
'updated_at' => $now, 'updated_at' => $now,
]); ]);
@@ -180,13 +209,26 @@ class ImportLegacyAwards extends Command
} }
} else { } else {
// Skip if already exists // Skip if already exists
if ( $existingMedal = DB::table('artwork_medals')
DB::table('artwork_awards')
->where('artwork_id', $artworkId) ->where('artwork_id', $artworkId)
->where('user_id', $userId) ->where('user_id', $userId)
->exists() ->value('medal_type');
) {
if ($existingMedal !== null) {
$stats['skipped_duplicate']++; $stats['skipped_duplicate']++;
if ($showDuplicates && count($duplicateRows) < $duplicatesLimit) {
$duplicateRows[] = [
'opinion_id' => (int) ($row->opinion_id ?? 0),
'artwork_id' => $artworkId,
'user_id' => $userId,
'legacy_score' => $score,
'legacy_medal' => $medal,
'existing_medal' => (string) $existingMedal,
];
$stats['reported_duplicate']++;
}
$bar->advance(); $bar->advance();
continue; continue;
} }
@@ -195,7 +237,7 @@ class ImportLegacyAwards extends Command
$inserts[] = [ $inserts[] = [
'artwork_id' => $artworkId, 'artwork_id' => $artworkId,
'user_id' => $userId, 'user_id' => $userId,
'medal' => $medal, 'medal_type' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal], 'weight' => ArtworkAward::WEIGHTS[$medal],
'created_at' => $postedAt, 'created_at' => $postedAt,
'updated_at' => $postedAt, 'updated_at' => $postedAt,
@@ -212,12 +254,12 @@ class ImportLegacyAwards extends Command
// stats are recalculated in bulk at the end for performance) // stats are recalculated in bulk at the end for performance)
if (! $dryRun && ! empty($inserts)) { if (! $dryRun && ! empty($inserts)) {
try { try {
DB::table('artwork_awards')->insert($inserts); DB::table('artwork_medals')->insert($inserts);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Fallback: insert one-by-one to isolate constraint violations // Fallback: insert one-by-one to isolate constraint violations
foreach ($inserts as $row) { foreach ($inserts as $row) {
try { try {
DB::table('artwork_awards')->insertOrIgnore([$row]); DB::table('artwork_medals')->insertOrIgnore([$row]);
} catch (\Throwable) { } catch (\Throwable) {
$stats['errors']++; $stats['errors']++;
} }
@@ -277,6 +319,30 @@ class ImportLegacyAwards extends Command
] ]
); );
if ($showDuplicates && $stats['skipped_duplicate'] > 0) {
$this->newLine();
$this->info(sprintf(
'Duplicate rows skipped: %d. Showing %d row(s)%s.',
$stats['skipped_duplicate'],
count($duplicateRows),
$stats['skipped_duplicate'] > count($duplicateRows) ? " (truncated by --duplicates-limit={$duplicatesLimit})" : ''
));
if ($duplicateRows !== []) {
$this->table(
['Legacy opinion', 'Artwork ID', 'Voter user_id', 'Legacy score', 'Legacy medal', 'Existing medal'],
array_map(static fn (array $row): array => [
$row['opinion_id'],
$row['artwork_id'],
$row['user_id'],
$row['legacy_score'],
$row['legacy_medal'],
$row['existing_medal'],
], $duplicateRows)
);
}
}
if ($dryRun) { if ($dryRun) {
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.'); $this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
} else { } else {

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use cPad\Plugins\News\Models\NewsArticle;
class ImportLegacyNewsCommand extends Command
{
protected $signature = 'news:import-legacy {--dry-run} {--limit=500} {--start=0}';
protected $description = 'Import News articles from legacy DB into the current Skinbase news_articles table.';
public function handle()
{
$dryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
$start = (int) $this->option('start');
// Verify legacy DB connection exists and is reachable
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable $e) {
$this->error('Cannot connect to legacy database via connection "legacy": ' . $e->getMessage());
Log::error('Legacy import failed - cannot connect to legacy', ['exception' => $e]);
return 2;
}
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('news')) {
$this->error('Legacy table `news` not found on legacy connection.');
return 2;
}
$this->info(sprintf('Fetching up to %d legacy rows starting at %d...', $limit, $start));
try {
$rows = DB::connection('legacy')->table('news')
->orderBy('news_id')
->skip($start)
->take($limit)
->get();
} catch (\Throwable $e) {
$this->error('Failed to query legacy DB: ' . $e->getMessage());
Log::error('Legacy import failed', ['exception' => $e]);
return 2;
}
if ($rows->isEmpty()) {
$this->info('No rows found in legacy news table.');
return 0;
}
$this->info('Processing ' . $rows->count() . ' rows...');
$created = 0;
foreach ($rows as $row) {
// Map fields conservatively — adjust mapping as needed for your legacy schema
$title = $row->headline ?? ($row->title ?? '');
$content = $row->content ?? ($row->message ?? '');
$excerpt = $row->preview ?? null;
$publishedAt = $row->create_date ?? ($row->published_at ?? null);
// Best-effort author mapping: try username/uname then fallback to user id 1
$authorId = 1;
if (!empty($row->uname)) {
$uid = DB::table('users')->where('username', $row->uname)->orWhere('uname', $row->uname)->value('id');
if ($uid) {
$authorId = $uid;
}
}
$payload = [
'title' => $title,
'slug' => NewsArticle::generateUniqueSlug($title),
'excerpt' => $excerpt,
'content' => $content,
'cover_image' => $row->picture ?? null,
'type' => 'announcement',
'author_id' => $authorId,
'category_id' => null,
'editorial_status' => isset($row->type) && (int)$row->type === 0 ? NewsArticle::EDITORIAL_STATUS_DRAFT : NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'published_at' => $publishedAt ? date('Y-m-d H:i:s', strtotime($publishedAt)) : null,
'is_featured' => ($row->frontpage ?? 0) == 1,
'is_pinned' => ($row->type ?? 0) == 2,
'views' => $row->views ?? 0,
'canonical_url' => '/legacy/news/' . ($row->news_id ?? ''),
];
if ($dryRun) {
$this->line('[dry-run] Would insert: ' . $payload['title'] . ' (' . ($payload['published_at'] ?? 'no-date') . ')');
continue;
}
try {
NewsArticle::create($payload);
$created++;
} catch (\Throwable $e) {
$this->error('Failed to insert legacy article ' . ($row->news_id ?? '?') . ': ' . $e->getMessage());
Log::error('import-legacy: insert failed', ['exception' => $e, 'row' => $row]);
}
}
$this->info(sprintf('Done. Created %d articles (dry-run=%s).', $created, $dryRun ? 'yes' : 'no'));
return 0;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Profile\CreatorEraService;
use App\Services\Profile\CreatorJourneyService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Rebuild creator eras independently of milestones.
*
* Usage:
* php artisan creator-journey:rebuild-eras (all users)
* php artisan creator-journey:rebuild-eras {user_id} (single user)
*/
class RebuildCreatorErasCommand extends Command
{
protected $signature = 'creator-journey:rebuild-eras
{user_id? : Rebuild eras for a single user}
{--chunk=500 : Chunk size when rebuilding all}';
protected $description = 'Rebuild creator era records from public artwork history (v2)';
public function handle(CreatorEraService $eraService, CreatorJourneyService $journeys): int
{
$userId = $this->argument('user_id');
if ($userId !== null) {
return $this->rebuildSingle((int) $userId, $eraService, $journeys);
}
return $this->rebuildAll($eraService, $journeys, (int) $this->option('chunk'));
}
private function rebuildSingle(int $userId, CreatorEraService $eraService, CreatorJourneyService $journeys): int
{
$user = User::query()->find($userId);
if (! $user) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
// Delegate to journeys service so eras + milestones stay in sync
$journeys->rebuildForUser($user);
$this->info("Rebuilt eras for user #{$userId}.");
return self::SUCCESS;
}
private function rebuildAll(CreatorEraService $eraService, CreatorJourneyService $journeys, int $chunk): int
{
$total = DB::table('users')->whereNull('deleted_at')->count();
$this->info("Rebuilding eras for {$total} users...");
$bar = $this->output->createProgressBar($total);
$bar->start();
$eras = 0;
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($journeys, $bar, &$eras): void {
foreach ($users as $userRow) {
try {
$user = User::query()->find($userRow->id);
if ($user) {
$journeys->rebuildForUser($user);
$eras++;
}
} catch (\Throwable $e) {
$this->newLine();
$this->warn("Failed for user #{$userRow->id}: " . $e->getMessage());
}
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$this->info("Rebuilt eras for {$eras} users.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\RebuildCreatorJourneyJob;
use App\Services\Profile\CreatorJourneyService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RebuildCreatorJourneyCommand extends Command
{
protected $signature = 'skinbase:rebuild-creator-journey
{user_id? : The ID of a single creator to rebuild}
{--all : Rebuild creator journey rows for all non-deleted users}
{--chunk=500 : Chunk size when --all is used}
{--queue : Dispatch rebuild jobs instead of rebuilding inline}';
protected $description = 'Rebuild persisted creator journey milestones from public source data';
public function handle(CreatorJourneyService $journeys): int
{
$userId = $this->argument('user_id');
$all = (bool) $this->option('all');
$chunk = max(1, (int) $this->option('chunk'));
$queue = (bool) $this->option('queue');
if ($userId !== null && $all) {
$this->error('Provide either a user_id OR --all, not both.');
return self::FAILURE;
}
if ($userId !== null) {
return $this->rebuildSingle((int) $userId, $journeys, $queue);
}
if ($all) {
return $this->rebuildAll($journeys, $chunk, $queue);
}
$this->error('Provide a user_id or use --all.');
return self::FAILURE;
}
private function rebuildSingle(int $userId, CreatorJourneyService $journeys, bool $queue): int
{
if (! DB::table('users')->where('id', $userId)->exists()) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
if ($queue) {
RebuildCreatorJourneyJob::dispatch([$userId]);
$this->info("Queued creator journey rebuild for user #{$userId}.");
return self::SUCCESS;
}
$result = $journeys->rebuildForUser($userId);
$this->table(['Metric', 'Value'], [
['user_id', $userId],
['milestones_saved', $result['milestones_saved']],
]);
return self::SUCCESS;
}
private function rebuildAll(CreatorJourneyService $journeys, int $chunk, bool $queue): int
{
$total = DB::table('users')->whereNull('deleted_at')->count();
$this->info(sprintf(
'%s Rebuilding creator journeys for %d users (chunk=%d)...',
$queue ? '[QUEUE]' : '[LIVE]',
$total,
$chunk,
));
if ($queue) {
$dispatched = 0;
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use (&$dispatched): void {
$ids = $users->pluck('id')->map(fn ($id): int => (int) $id)->all();
RebuildCreatorJourneyJob::dispatch($ids);
$dispatched += count($ids);
$this->line(' Queued chunk of ' . count($ids) . ' users (total dispatched: ' . $dispatched . ')');
});
$this->info("Done - {$dispatched} users queued for creator journey rebuild.");
return self::SUCCESS;
}
$processed = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($journeys, &$processed, $bar): void {
foreach ($users as $user) {
$journeys->rebuildForUser((int) $user->id);
$processed++;
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$this->info("Done - {$processed} creator journeys rebuilt.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\HomepageService;
use Illuminate\Console\Command;
final class WarmHomepageGuestCacheCommand extends Command
{
protected $signature = 'homepage:warm-guest-cache';
protected $description = 'Warm the guest homepage payload cache';
public function handle(HomepageService $homepage): int
{
$startedAt = microtime(true);
$payload = $homepage->warmGuestPayloadCache();
$durationMs = (microtime(true) - $startedAt) * 1000;
$this->info(sprintf(
'Warmed guest homepage cache (%d sections) in %.2fms using store [%s].',
count($payload),
$durationMs,
$homepage->guestPayloadCacheStoreName(),
));
return self::SUCCESS;
}
}

View File

@@ -85,6 +85,7 @@ class Kernel extends ConsoleKernel
RecalculateRankingsCommand::class, RecalculateRankingsCommand::class,
MetricsSnapshotHourlyCommand::class, MetricsSnapshotHourlyCommand::class,
RecalculateHeatCommand::class, RecalculateHeatCommand::class,
\App\Console\Commands\RebuildCreatorErasCommand::class,
]; ];
/** /**

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum CreatorMilestoneType: string
{
// ── v1 milestones ─────────────────────────────────────────────────────────
case FirstUpload = 'first_upload';
case FirstFeaturedArtwork = 'first_featured_artwork';
case FirstGroupRelease = 'first_group_release';
case BiggestDownloadSpike = 'biggest_download_spike';
case BestPerformingWork = 'best_performing_work';
case MostProductiveYear = 'most_productive_year';
case YearlyRecap = 'yearly_recap';
// ── v2: Comeback milestones ────────────────────────────────────────────────
case ComebackMinor = 'comeback_minor'; // 180364 days gap
case ComebackMajor = 'comeback_major'; // 3651094 days gap (13 years)
case ComebackLegendary = 'comeback_legendary'; // 1095+ days gap (3+ years)
// ── v2: Streak milestones ─────────────────────────────────────────────────
case UploadStreak3 = 'upload_streak_3';
case UploadStreak6 = 'upload_streak_6';
case UploadStreak12 = 'upload_streak_12';
case ActiveYearStreak3 = 'active_year_streak_3';
case ActiveYearStreak5 = 'active_year_streak_5';
// ── v2: Evolution / Era milestones ────────────────────────────────────────
case BeforeNow = 'before_now';
case EraStarted = 'era_started';
public function priority(): int
{
return match ($this) {
self::BestPerformingWork => 100,
self::BiggestDownloadSpike => 95,
self::FirstFeaturedArtwork => 90,
self::ComebackLegendary => 88,
self::UploadStreak12 => 87,
self::ActiveYearStreak5 => 86,
self::MostProductiveYear => 85,
self::ActiveYearStreak3 => 84,
self::UploadStreak6 => 83,
self::FirstGroupRelease => 80,
self::BeforeNow => 78,
self::ComebackMajor => 77,
self::EraStarted => 76,
self::FirstUpload => 75,
self::UploadStreak3 => 72,
self::ComebackMinor => 70,
self::YearlyRecap => 60,
};
}
public function isV2(): bool
{
return match ($this) {
self::ComebackMinor,
self::ComebackMajor,
self::ComebackLegendary,
self::UploadStreak3,
self::UploadStreak6,
self::UploadStreak12,
self::ActiveYearStreak3,
self::ActiveYearStreak5,
self::BeforeNow,
self::EraStarted => true,
default => false,
};
}
}

View File

@@ -7,14 +7,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkAward; use App\Models\ArtworkAward;
use App\Services\ArtworkAwardService; use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Services\ArtworkMedalService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final class ArtworkAwardController extends Controller final class ArtworkAwardController extends Controller
{ {
public function __construct( public function __construct(
private readonly ArtworkAwardService $service private readonly ArtworkMedalService $service
) {} ) {}
/** /**
@@ -32,7 +34,7 @@ final class ArtworkAwardController extends Controller
'medal' => ['required', 'string', 'in:gold,silver,bronze'], 'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]); ]);
$award = $this->service->award($artwork, $user, $data['medal']); $this->service->award($artwork, $user, $data['medal']);
// Record activity event // Record activity event
try { try {
@@ -51,6 +53,32 @@ final class ArtworkAwardController extends Controller
); );
} }
public function upsert(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->authorize('award', [ArtworkAward::class, $artwork]);
$data = $request->validate([
'medal_type' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$existed = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
$this->service->upsert($artwork, $user, $data['medal_type']);
return response()->json(
array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => $existed ? 'Medal updated.' : 'Medal added.',
]),
$existed ? 200 : 201,
);
}
/** /**
* PUT /api/artworks/{id}/award * PUT /api/artworks/{id}/award
* Change an existing award medal. * Change an existing award medal.
@@ -60,7 +88,7 @@ final class ArtworkAwardController extends Controller
$user = $request->user(); $user = $request->user();
$artwork = Artwork::findOrFail($id); $artwork = Artwork::findOrFail($id);
$existingAward = ArtworkAward::where('artwork_id', $artwork->id) $existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id) ->where('user_id', $user->id)
->firstOrFail(); ->firstOrFail();
@@ -70,7 +98,7 @@ final class ArtworkAwardController extends Controller
'medal' => ['required', 'string', 'in:gold,silver,bronze'], 'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]); ]);
$award = $this->service->changeAward($artwork, $user, $data['medal']); $this->service->changeMedal($artwork, $user, $data['medal']);
return response()->json($this->buildPayload($artwork->id, $user->id)); return response()->json($this->buildPayload($artwork->id, $user->id));
} }
@@ -84,17 +112,29 @@ final class ArtworkAwardController extends Controller
$user = $request->user(); $user = $request->user();
$artwork = Artwork::findOrFail($id); $artwork = Artwork::findOrFail($id);
$existingAward = ArtworkAward::where('artwork_id', $artwork->id) $existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id) ->where('user_id', $user->id)
->firstOrFail(); ->firstOrFail();
$this->authorize('remove', $existingAward); $this->authorize('remove', $existingAward);
$this->service->removeAward($artwork, $user); $this->service->removeMedal($artwork, $user);
return response()->json($this->buildPayload($artwork->id, $user->id)); return response()->json($this->buildPayload($artwork->id, $user->id));
} }
public function destroyMedal(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->service->removeMedal($artwork, $user);
return response()->json(array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => 'Medal removed.',
]));
}
/** /**
* GET /api/artworks/{id}/awards * GET /api/artworks/{id}/awards
* Return award stats + viewer's current award. * Return award stats + viewer's current award.
@@ -111,22 +151,29 @@ final class ArtworkAwardController extends Controller
private function buildPayload(int $artworkId, ?int $userId): array private function buildPayload(int $artworkId, ?int $userId): array
{ {
$stat = \App\Models\ArtworkAwardStat::find($artworkId); $stat = ArtworkMedalStat::find($artworkId);
$userAward = $userId $userAward = $userId
? ArtworkAward::where('artwork_id', $artworkId) ? ArtworkMedal::where('artwork_id', $artworkId)
->where('user_id', $userId) ->where('user_id', $userId)
->value('medal') ->value('medal_type')
: null; : null;
$medals = [
'gold' => (int) ($stat?->gold_count ?? 0),
'silver' => (int) ($stat?->silver_count ?? 0),
'bronze' => (int) ($stat?->bronze_count ?? 0),
'score' => (int) ($stat?->score_total ?? 0),
'score_7d' => (int) ($stat?->score_7d ?? 0),
'score_30d' => (int) ($stat?->score_30d ?? 0),
'last_medaled_at' => $stat?->last_medaled_at?->toIsoString(),
];
return [ return [
'awards' => [ 'awards' => $medals,
'gold' => $stat?->gold_count ?? 0, 'medals' => $medals,
'silver' => $stat?->silver_count ?? 0,
'bronze' => $stat?->bronze_count ?? 0,
'score' => $stat?->score_total ?? 0,
],
'viewer_award' => $userAward, 'viewer_award' => $userAward,
'current_user_medal' => $userAward,
]; ];
} }
} }

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkStatsService; use App\Services\ArtworkStatsService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -31,7 +32,10 @@ use Illuminate\Support\Str;
*/ */
final class ArtworkDownloadController extends Controller final class ArtworkDownloadController extends Controller
{ {
public function __construct(private readonly ArtworkStatsService $stats) {} public function __construct(
private readonly ArtworkStatsService $stats,
private readonly CreatorJourneyService $journeys,
) {}
public function __invoke(Request $request, int $id): JsonResponse public function __invoke(Request $request, int $id): JsonResponse
{ {
@@ -48,13 +52,15 @@ final class ArtworkDownloadController extends Controller
// Record the download event — non-blocking, errors are swallowed. // Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork); $this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available. // Increment counters immediately so Studio stats stay fresh.
try { try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true); $this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
} catch (\Throwable) { } catch (\Throwable) {
// Stats failure must never interrupt the download. // Stats failure must never interrupt the download.
} }
$this->journeys->requestRebuild((int) $artwork->user_id);
// Resolve the highest-resolution download URL available. // Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork); $url = $this->resolveDownloadUrl($artwork);

View File

@@ -10,6 +10,7 @@ use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification; use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService; use App\Services\FollowService;
use App\Services\Activity\UserActivityService; use App\Services\Activity\UserActivityService;
use App\Services\ArtworkStatsService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Services\XPService; use App\Services\XPService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -168,7 +169,7 @@ final class ArtworkInteractionController extends Controller
public function share(Request $request, int $artworkId): JsonResponse public function share(Request $request, int $artworkId): JsonResponse
{ {
$data = $request->validate([ $data = $request->validate([
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'], 'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed,native'],
]); ]);
if (Schema::hasTable('artwork_shares')) { if (Schema::hasTable('artwork_shares')) {
@@ -178,6 +179,8 @@ final class ArtworkInteractionController extends Controller
'platform' => $data['platform'], 'platform' => $data['platform'],
'created_at' => now(), 'created_at' => now(),
]); ]);
$this->syncArtworkStats($artworkId);
} }
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
@@ -216,25 +219,7 @@ final class ArtworkInteractionController extends Controller
private function syncArtworkStats(int $artworkId): void private function syncArtworkStats(int $artworkId): void
{ {
if (! Schema::hasTable('artwork_stats')) { app(ArtworkStatsService::class)->syncEngagementCounts($artworkId);
return;
}
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
[
'favorites' => $favorites,
'rating_count' => $likes,
]
);
} }
private function statusPayload(int $viewerId, int $artworkId): array private function statusPayload(int $viewerId, int $artworkId): array

View File

@@ -16,14 +16,10 @@ use Illuminate\Http\Request;
* *
* Fire-and-forget view tracker. * Fire-and-forget view tracker.
* *
* Deduplication strategy (layered): * Every page visit should count as a new view.
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the * Lightweight abuse protection is handled at the route layer via throttling,
* same browser session (survives page reloads). * while the stat increment itself is applied immediately so Studio analytics
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that * reflect new visits without waiting for the scheduler to flush Redis deltas.
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
*/ */
final class ArtworkViewController extends Controller final class ArtworkViewController extends Controller
{ {
@@ -43,18 +39,11 @@ final class ArtworkViewController extends Controller
return response()->json(['error' => 'Not found'], 404); return response()->json(['error' => 'Not found'], 404);
} }
$sessionKey = 'art_viewed.' . $id;
// Already counted this session — return early without touching the DB.
if ($request->hasSession() && $request->session()->has($sessionKey)) {
return response()->json(['ok' => true, 'counted' => false]);
}
// Write persistent event log (auth user_id or null for guests). // Write persistent event log (auth user_id or null for guests).
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id); $this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
// Defer to Redis when available, fall back to direct DB increment. // Apply the increment immediately so counters stay fresh in Studio.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true); $this->stats->incrementViews((int) $artwork->id, 1, defer: false);
$viewerId = $request->user()?->id; $viewerId = $request->user()?->id;
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) { if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
@@ -66,11 +55,6 @@ final class ArtworkViewController extends Controller
); );
} }
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);
}
return response()->json(['ok' => true, 'counted' => true]); return response()->json(['ok' => true, 'counted' => true]);
} }
} }

View File

@@ -39,6 +39,8 @@ final class ProfileApiController extends Controller
$query = Artwork::with([ $query = Artwork::with([
'user:id,name,username,level,rank', 'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites', 'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) { 'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -115,6 +117,8 @@ final class ProfileApiController extends Controller
$indexed = Artwork::with([ $indexed = Artwork::with([
'user:id,name,username,level,rank', 'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites', 'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) { 'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -190,6 +194,15 @@ final class ProfileApiController extends Controller
$category = $art->categories->first(); $category = $art->categories->first();
$contentType = $category?->contentType; $contentType = $category?->contentType;
$stats = $art->stats; $stats = $art->stats;
$group = $art->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [ return [
'id' => $art->id, 'id' => $art->id,
@@ -198,8 +211,22 @@ final class ProfileApiController extends Controller
'thumb_srcset' => $present['srcset'] ?? $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width, 'width' => $art->width,
'height' => $art->height, 'height' => $art->height,
'username' => $art->user->username ?? null, 'username' => $username,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', 'uname' => $displayName,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $publisherType,
'publisher' => [
'type' => $publisherType,
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
'name' => $displayName,
'username' => $username ?? '',
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'user_id' => $art->user_id,
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name, 'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug, 'content_type_slug' => $contentType?->slug,
'category' => $category?->name, 'category' => $category?->name,

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Profile\CreatorJourneyService;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
final class ProfileJourneyController extends Controller
{
public function __construct(private readonly CreatorJourneyService $journeys)
{
}
public function __invoke(string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->where('is_active', true)
->whereNull('deleted_at')
->firstOrFail();
return response()->json([
'data' => $this->journeys->publicPayloadForUser($user),
'meta' => [
'username' => (string) $user->username,
'generated_at' => now()->toIso8601String(),
],
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RankingService; use App\Services\RankingService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -26,7 +27,10 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
*/ */
class RankController extends Controller class RankController extends Controller
{ {
public function __construct(private readonly RankingService $ranking) {} public function __construct(
private readonly RankingService $ranking,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/** /**
* GET /api/rank/global * GET /api/rank/global
@@ -65,7 +69,7 @@ class RankController extends Controller
{ {
$ct = is_numeric($contentType) $ct = is_numeric($contentType)
? ContentType::find((int) $contentType) ? ContentType::find((int) $contentType)
: ContentType::where('slug', $contentType)->first(); : $this->contentTypeResolver->resolve($contentType)->contentType;
if ($ct === null) { if ($ct === null) {
return response()->json(['message' => 'Content type not found.'], 404); return response()->json(['message' => 'Content type not found.'], 404);

View File

@@ -71,10 +71,10 @@ final class SuggestedCreatorsController extends Controller
u.username, u.username,
up.avatar_hash, up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count, COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(*) as mutual_weight COUNT(*) as mutual_weight
') ')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count') ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('mutual_weight') ->orderByDesc('mutual_weight')
->limit(20) ->limit(20)
->get(); ->get();
@@ -117,10 +117,10 @@ final class SuggestedCreatorsController extends Controller
u.username, u.username,
up.avatar_hash, up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count, COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(DISTINCT t.id) as matched_tags COUNT(DISTINCT t.id) as matched_tags
') ')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count') ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('matched_tags') ->orderByDesc('matched_tags')
->limit(20) ->limit(20)
->get(); ->get();
@@ -197,7 +197,7 @@ final class SuggestedCreatorsController extends Controller
u.username, u.username,
up.avatar_hash, up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count COALESCE(us.uploads_count, 0) as artworks_count
') ')
->orderByDesc('followers_count') ->orderByDesc('followers_count')
->limit($limit) ->limit($limit)

View File

@@ -33,6 +33,7 @@ use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService; use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService; use App\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService; use App\Services\ArtworkAttributionService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Uploads\Exceptions\UploadNotFoundException; use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException; use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException; use App\Uploads\Exceptions\UploadPublishValidationException;
@@ -558,7 +559,7 @@ final class UploadController extends Controller
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution) public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
{ {
$user = $request->user(); $user = $request->user();
@@ -566,7 +567,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'], 'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'], 'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'], 'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'], 'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'], 'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
@@ -657,6 +658,7 @@ final class UploadController extends Controller
} }
$artwork->save(); $artwork->save();
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated); $artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
if ($mode === 'schedule' && $publishAt) { if ($mode === 'schedule' && $publishAt) {
@@ -760,7 +762,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'], 'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'], 'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'], 'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'], 'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'], 'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],

View File

@@ -6,12 +6,17 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest; use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
class ArtworkController extends Controller class ArtworkController extends Controller
{ {
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
{
}
/** /**
* Browse artworks with optional category filtering. * Browse artworks with optional category filtering.
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items. * Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
@@ -58,6 +63,17 @@ class ArtworkController extends Controller
*/ */
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null) public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
{ {
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
if ($resolution->requiresRedirect()) {
return $this->redirectToCanonicalArtworkPath($request, $resolvedContentTypeSlug, $categoryPath, $artwork, 301);
}
// Manually resolve artwork by slug when provided. The route may bind // Manually resolve artwork by slug when provided. The route may bind
// the 'artwork' parameter to an Artwork model or pass the slug string. // the 'artwork' parameter to an Artwork model or pass the slug string.
$foundArtwork = null; $foundArtwork = null;
@@ -67,7 +83,7 @@ class ArtworkController extends Controller
$artworkSlug = $artwork->slug; $artworkSlug = $artwork->slug;
} elseif ($artwork) { } elseif ($artwork) {
$artworkSlug = (string) $artwork; $artworkSlug = (string) $artwork;
$foundArtwork = $this->findArtworkForCategoryPath($contentTypeSlug, $categoryPath, $artworkSlug); $foundArtwork = $this->findArtworkForCategoryPath($resolvedContentTypeSlug, $categoryPath, $artworkSlug);
} }
// When the URL can represent a nested category path (e.g. /skins/audio/winamp), // When the URL can represent a nested category path (e.g. /skins/audio/winamp),
@@ -75,9 +91,9 @@ class ArtworkController extends Controller
// behave consistently. // behave consistently.
if (! empty($artworkSlug)) { if (! empty($artworkSlug)) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/'); $combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
$resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath); $resolvedCategory = Category::findByPath($resolvedContentTypeSlug, $combinedPath);
if ($resolvedCategory) { if ($resolvedCategory) {
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath); return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
} }
} }
@@ -90,7 +106,7 @@ class ArtworkController extends Controller
if ($artworkSlug) { if ($artworkSlug) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/'); $combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
} }
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath); return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
} }
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) { if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
@@ -108,9 +124,8 @@ class ArtworkController extends Controller
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
{ {
$contentType = ContentType::query()->where('slug', strtolower($contentTypeSlug))->first();
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/')))); $segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
$category = $contentType ? Category::findByPath($contentType->slug, $segments) : null; $category = Category::findByPath(strtolower($contentTypeSlug), $segments);
$query = Artwork::query()->where('slug', $artworkSlug); $query = Artwork::query()->where('slug', $artworkSlug);
@@ -125,4 +140,17 @@ class ArtworkController extends Controller
->orderByDesc('id') ->orderByDesc('id')
->first(); ->first();
} }
private function redirectToCanonicalArtworkPath(Request $request, string $contentTypeSlug, string $categoryPath, Artwork|string|null $artwork, int $status = 301): RedirectResponse
{
$artworkSlug = $artwork instanceof Artwork ? $artwork->slug : (string) $artwork;
$target = url('/' . trim($contentTypeSlug . '/' . trim($categoryPath, '/') . '/' . trim($artworkSlug, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, $status);
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkDownload; use App\Models\ArtworkDownload;
use App\Services\ArtworkStatsService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -34,6 +35,10 @@ final class ArtworkDownloadController extends Controller
'gz', 'gz',
]; ];
public function __construct(
private readonly ArtworkStatsService $stats,
) {}
public function __invoke(Request $request, int $id): BinaryFileResponse public function __invoke(Request $request, int $id): BinaryFileResponse
{ {
$artwork = Artwork::query()->find($id); $artwork = Artwork::query()->find($id);
@@ -51,6 +56,15 @@ final class ArtworkDownloadController extends Controller
$this->recordDownload($request, $artwork->id); $this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id); $this->incrementDownloadCountIfAvailable($artwork->id);
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
} catch (\Throwable $exception) {
Log::warning('Failed to increment artwork_stats download counter.', [
'artwork_id' => $artwork->id,
'error' => $exception->getMessage(),
]);
}
if (! File::isFile($filePath)) { if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [ Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id, 'artwork_id' => $artwork->id,

View File

@@ -3,23 +3,39 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class CategoryPageController extends Controller class CategoryPageController extends Controller
{ {
public function __construct(private ArtworkService $artworkService) public function __construct(
private ArtworkService $artworkService,
private ContentTypeSlugResolver $contentTypeResolver,
)
{ {
} }
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null) public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
{ {
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first(); $resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $contentType) { if (! $resolution->found() || $resolution->contentType === null) {
abort(404); abort(404);
} }
$contentType = $resolution->contentType;
if ($resolution->requiresRedirect()) {
$target = url('/' . trim($contentType->slug . '/' . trim((string) $categoryPath, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, 301);
}
$sort = (string) $request->get('sort', 'latest'); $sort = (string) $request->get('sort', 'latest');

View File

@@ -7,31 +7,45 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Models\ContentType; use App\Services\ContentTypes\ContentTypeSlugResolver;
class PhotographyController extends Controller class PhotographyController extends Controller
{ {
protected ArtworkService $artworks; protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks) public function __construct(
ArtworkService $artworks,
private readonly ContentTypeSlugResolver $contentTypeResolver,
)
{ {
$this->artworks = $artworks; $this->artworks = $artworks;
} }
public function index(Request $request) public function index(Request $request)
{ {
// Legacy group mapping: Photography => id 3
// Determine the requested content type from the first URL segment (photography|wallpapers|skins)
$segment = strtolower($request->segment(1) ?? 'photography'); $segment = strtolower($request->segment(1) ?? 'photography');
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography'; $resolution = $this->contentTypeResolver->resolve($segment);
// Human-friendly group name (used by legacy templates) if (! $resolution->found() || $resolution->contentType === null) {
$group = ucfirst($contentSlug); abort(404);
}
$contentType = $resolution->contentType;
$contentSlug = strtolower((string) $contentType->slug);
if ($resolution->requiresRedirect()) {
$target = url('/' . $contentSlug);
if ($request->getQueryString()) {
$target .= '?' . $request->getQueryString();
}
return redirect()->to($target, 301);
}
// Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType
$id = null; $id = null;
if ($contentSlug === 'photography') { if ($contentSlug === 'photography') {
$id = 3; // legacy root id for photography in oldSite (kept for backward compatibility) $id = 3;
} }
// Fetch legacy category info if available (only when we have an id) // Fetch legacy category info if available (only when we have an id)
@@ -47,25 +61,20 @@ class PhotographyController extends Controller
$category = null; $category = null;
} }
// Page title and description: prefer legacy category when present, otherwise use ContentType data $page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug));
$ct = ContentType::where('slug', $contentSlug)->first(); $tidy = $category->description ?? ($contentType->description ?? null);
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
$tidy = $category->description ?? ($ct->description ?? null);
$perPage = 40; $perPage = 40;
$sort = (string) $request->get('sort', 'latest'); $sort = (string) $request->get('sort', 'latest');
// Load artworks for the requested content type using standard pagination
try { try {
$artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort); $artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Return an empty paginator so views using ->links() / ->firstItem() work
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [ $artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
'path' => url()->current(), 'path' => url()->current(),
]); ]);
} }
// Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories
$subcategories = collect(); $subcategories = collect();
try { try {
if ($id !== null && Schema::hasTable('artworks_categories')) { if ($id !== null && Schema::hasTable('artworks_categories')) {
@@ -79,18 +88,13 @@ class PhotographyController extends Controller
} }
if (! $subcategories || $subcategories->count() === 0) { if (! $subcategories || $subcategories->count() === 0) {
if ($ct) { $subcategories = $contentType->rootCategories()
$subcategories = $ct->rootCategories()
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('name') ->orderBy('name')
->get() ->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
} else {
$subcategories = collect();
}
} }
// Coerce collections to a paginator so the view's pagination helpers work
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) { if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
$page = (int) ($request->query('page', 1)); $page = (int) ($request->query('page', 1));
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [ $artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
@@ -99,11 +103,7 @@ class PhotographyController extends Controller
]); ]);
} }
// Prepare variables for the modern content-type view $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$contentType = ContentType::where('slug', $contentSlug)->first();
$rootCategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
$page_meta_description = $tidy; $page_meta_description = $tidy;

View File

@@ -7,13 +7,16 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Models\ContentType; use App\Services\ContentTypes\ContentTypeSlugResolver;
class PhotographyController extends Controller class PhotographyController extends Controller
{ {
protected ArtworkService $artworks; protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks) public function __construct(
ArtworkService $artworks,
private readonly ContentTypeSlugResolver $contentTypeResolver,
)
{ {
$this->artworks = $artworks; $this->artworks = $artworks;
} }
@@ -21,9 +24,24 @@ class PhotographyController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
$segment = strtolower($request->segment(1) ?? 'photography'); $segment = strtolower($request->segment(1) ?? 'photography');
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography'; $resolution = $this->contentTypeResolver->resolve($segment);
$group = ucfirst($contentSlug); if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$contentType = $resolution->contentType;
$contentSlug = strtolower((string) $contentType->slug);
if ($resolution->requiresRedirect()) {
$target = url('/' . $contentSlug);
if ($request->getQueryString()) {
$target .= '?' . $request->getQueryString();
}
return redirect()->to($target, 301);
}
$id = null; $id = null;
if ($contentSlug === 'photography') { if ($contentSlug === 'photography') {
@@ -42,9 +60,8 @@ class PhotographyController extends Controller
$category = null; $category = null;
} }
$ct = ContentType::where('slug', $contentSlug)->first(); $page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug));
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug)); $tidy = $category->description ?? ($contentType->description ?? null);
$tidy = $category->description ?? ($ct->description ?? null);
$perPage = 40; $perPage = 40;
$sort = (string) $request->get('sort', 'latest'); $sort = (string) $request->get('sort', 'latest');
@@ -70,15 +87,11 @@ class PhotographyController extends Controller
} }
if (! $subcategories || $subcategories->count() === 0) { if (! $subcategories || $subcategories->count() === 0) {
if ($ct) { $subcategories = $contentType->rootCategories()
$subcategories = $ct->rootCategories()
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('name') ->orderBy('name')
->get() ->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
} else {
$subcategories = collect();
}
} }
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) { if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
@@ -89,10 +102,7 @@ class PhotographyController extends Controller
]); ]);
} }
$contentType = ContentType::where('slug', $contentSlug)->first(); $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
$page_meta_description = $tidy; $page_meta_description = $tidy;

View File

@@ -6,8 +6,10 @@ namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ContentType; use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RSS\RSSFeedBuilder; use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -30,36 +32,53 @@ final class ExploreFeedController extends Controller
'latest' => 300, 'latest' => 300,
]; ];
public function __construct(private readonly RSSFeedBuilder $builder) {} public function __construct(
private readonly RSSFeedBuilder $builder,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/** /rss/explore/{type} — defaults to latest */ /** /rss/explore/{type} — defaults to latest */
public function byType(string $type): Response public function byType(Request $request, string $type): Response|RedirectResponse
{ {
return $this->feed($type, 'latest'); return $this->feed($request, $type, 'latest');
} }
/** /rss/explore/{type}/{mode} */ /** /rss/explore/{type}/{mode} */
public function byTypeMode(string $type, string $mode): Response public function byTypeMode(Request $request, string $type, string $mode): Response|RedirectResponse
{ {
return $this->feed($type, $mode); return $this->feed($request, $type, $mode);
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
private function feed(string $type, string $mode): Response private function feed(Request $request, string $type, string $mode): Response|RedirectResponse
{ {
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest'; $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
$label = ucfirst(str_replace('-', ' ', $type));
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) { if (! $resolution->found()) {
$contentType = ContentType::where('slug', $type)->first(); abort(404);
}
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
$resolvedType = $resolution->isVirtual ? 'artworks' : strtolower((string) $resolution->contentType?->slug);
if ($resolution->requiresRedirect()) {
return redirect()->to(url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : '')) . ($request->getQueryString() ? ('?' . $request->getQueryString()) : ''), 301);
}
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : ''));
$label = $resolution->isVirtual
? 'All Artworks'
: ($resolution->contentType?->name ?? ucfirst(str_replace('-', ' ', $resolvedType)));
$artworks = Cache::remember("rss:explore:{$resolvedType}:{$mode}", $ttl, function () use ($resolution, $mode) {
$contentType = $resolution->contentType;
$query = Artwork::public()->published() $query = Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id']); ->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
if ($contentType) { if (! $resolution->isVirtual && $contentType) {
$query->whereHas('categories', fn ($q) => $query->whereHas('categories', fn ($q) =>
$q->where('content_type_id', $contentType->id) $q->where('content_type_id', $contentType->id)
); );

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkMaturityAuditFinding;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityAuditService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class ArtworkMaturityAdminController extends Controller
{
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly ArtworkMaturityAuditService $audit,
)
{
}
public function index(Request $request): Response
{
$stats = $this->queueStats();
$status = $this->initialStatus($request, $stats);
$routes = $this->routeNamesForRequest($request);
return Inertia::render('Moderation/ArtworkMaturityQueue', [
'title' => 'Artwork Maturity Queue',
'initialItems' => $this->queueItems($status),
'initialFilters' => [
'status' => $status,
'ai_action' => 'all',
'ai_status' => 'all',
],
'stats' => $stats,
'endpoints' => [
'list' => route($routes['list']),
'reviewPattern' => route($routes['review'], ['artwork' => '__ARTWORK__']),
],
'filterOptions' => [
'aiAction' => [
['value' => 'all', 'label' => 'All actions'],
['value' => ArtworkMaturityService::AI_ACTION_SAFE, 'label' => 'Safe'],
['value' => ArtworkMaturityService::AI_ACTION_REVIEW, 'label' => 'Review'],
['value' => ArtworkMaturityService::AI_ACTION_FLAG_HIGH, 'label' => 'Flag high'],
],
'aiStatus' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, 'label' => 'Succeeded'],
['value' => ArtworkMaturityService::AI_STATUS_PENDING, 'label' => 'Pending'],
['value' => ArtworkMaturityService::AI_STATUS_FAILED, 'label' => 'Failed'],
['value' => ArtworkMaturityService::AI_STATUS_SKIPPED, 'label' => 'Skipped'],
],
],
'reviewActions' => [
['value' => 'mark_safe', 'label' => 'Mark safe'],
['value' => 'mark_mature', 'label' => 'Mark mature'],
['value' => 'confirm_current', 'label' => 'Confirm current state'],
],
])->rootView('moderation');
}
public function list(Request $request): JsonResponse
{
$status = $this->normalizeStatus((string) $request->query('status', 'suspected'));
$aiAction = strtolower((string) $request->query('ai_action', 'all'));
$aiStatus = strtolower((string) $request->query('ai_status', 'all'));
return response()->json([
'data' => $this->queueItems($status, $aiAction, $aiStatus),
'meta' => [
'stats' => $this->queueStats(),
'status' => $status,
'filters' => [
'ai_action' => $aiAction,
'ai_status' => $aiStatus,
],
],
]);
}
public function review(Request $request, Artwork $artwork): JsonResponse
{
$validated = $request->validate([
'action' => ['required', 'in:mark_safe,mark_mature,confirm_current'],
'note' => ['nullable', 'string', 'max:2000'],
]);
/** @var User $moderator */
$moderator = $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
$artwork = $this->maturity->review($artwork, (string) $validated['action'], $moderator, $validated['note'] ?? null);
$this->audit->resolveFindingForReview($artwork, $moderator, (string) $validated['action'], $validated['note'] ?? null);
return response()->json([
'success' => true,
'artwork' => $this->mapQueueItem($artwork->loadMissing(['user.profile', 'group', 'categories.contentType'])),
'stats' => $this->queueStats(),
]);
}
/**
* @return array<int, array<string, mixed>>
*/
private function queueItems(string $status, string $aiAction = 'all', string $aiStatus = 'all'): array
{
if ($status === 'audit') {
return $this->auditQueueItems($aiAction, $aiStatus);
}
$query = Artwork::query()
->with(['user.profile', 'group', 'categories.contentType'])
->where(function ($builder): void {
$builder->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)
->orWhere(function ($reviewed): void {
$reviewed->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)
->whereNotNull('maturity_reviewed_at');
});
})
->latest('maturity_flagged_at')
->latest('published_at')
->limit(100);
if ($status === 'reviewed') {
$query->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED);
} else {
$query->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED);
}
if (in_array($aiAction, [
ArtworkMaturityService::AI_ACTION_SAFE,
ArtworkMaturityService::AI_ACTION_REVIEW,
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
], true)) {
$query->where('maturity_ai_action_hint', $aiAction);
}
if (in_array($aiStatus, [
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_FAILED,
ArtworkMaturityService::AI_STATUS_SKIPPED,
], true)) {
$query->where('maturity_ai_status', $aiStatus);
}
return $query->get()->map(fn (Artwork $artwork): array => $this->mapQueueItem($artwork))->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function auditQueueItems(string $aiAction = 'all', string $aiStatus = 'all'): array
{
$query = $this->audit->openFindingsQuery()
->latest('detected_at')
->latest('updated_at')
->limit(100);
if (in_array($aiAction, [
ArtworkMaturityService::AI_ACTION_SAFE,
ArtworkMaturityService::AI_ACTION_REVIEW,
ArtworkMaturityService::AI_ACTION_FLAG_HIGH,
], true)) {
$query->where('ai_action_hint', $aiAction);
}
if (in_array($aiStatus, [
ArtworkMaturityService::AI_STATUS_SUCCEEDED,
ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_FAILED,
ArtworkMaturityService::AI_STATUS_SKIPPED,
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
], true)) {
$query->where('ai_status', $aiStatus);
}
return $query->get()->map(fn (ArtworkMaturityAuditFinding $finding): array => $this->mapAuditQueueItem($finding))->all();
}
/**
* @return array<string, int>
*/
private function queueStats(): array
{
return [
'suspected' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_SUSPECTED)->count(),
'audit' => $this->audit->openFindingsCount(),
'reviewed' => (int) Artwork::query()->where('maturity_status', ArtworkMaturityService::STATUS_REVIEWED)->count(),
'mature' => (int) Artwork::query()->where('is_mature', true)->count(),
];
}
/**
* @return array<string, mixed>
*/
private function mapAuditQueueItem(ArtworkMaturityAuditFinding $finding): array
{
$artwork = $finding->artwork;
return $this->mapQueueItem($artwork, [
'status' => (string) $finding->status,
'thumbnail_variant' => $finding->thumbnail_variant,
'detected_at' => optional($finding->detected_at)->toIsoString(),
'last_scanned_at' => optional($finding->last_scanned_at)->toIsoString(),
'ai_label' => $finding->ai_label,
'ai_confidence' => $finding->ai_confidence,
'ai_score' => $finding->ai_score,
'ai_labels' => $finding->ai_labels,
'ai_model' => $finding->ai_model,
'ai_threshold_used' => $finding->ai_threshold_used,
'ai_analysis_time_ms' => $finding->ai_analysis_time_ms,
'ai_action_hint' => $finding->ai_action_hint,
'ai_status' => $finding->ai_status,
'ai_advisory' => $finding->ai_advisory,
'legacy_unset' => $this->audit->isArtworkEligible($artwork),
]);
}
/**
* @return array<string, mixed>
*/
private function mapQueueItem(Artwork $artwork, ?array $audit = null): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$publisherName = $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
$thumb = ThumbnailPresenter::present($artwork, 'md');
$preview = ThumbnailPresenter::present($artwork, 'xl');
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id]),
'admin_url' => route('admin.cp.artworks.edit', ['id' => $artwork->id]),
'thumbnail' => $thumb['url'] ?? null,
'preview_image' => $preview['url'] ?? ($thumb['url'] ?? null),
'publisher' => $publisherName,
'published_at' => optional($artwork->published_at)->toIsoString(),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
'maturity' => $this->maturity->presentation($artwork, null),
'audit' => $audit,
'review' => [
'reviewed_at' => optional($artwork->maturity_reviewed_at)->toIsoString(),
'reviewed_by' => $artwork->maturity_reviewed_by,
'reviewer_note' => $artwork->maturity_reviewer_note,
],
];
}
private function normalizeStatus(string $status): string
{
$normalized = Str::lower(trim($status));
return in_array($normalized, ['suspected', 'reviewed', 'audit'], true)
? $normalized
: 'suspected';
}
/**
* @param array<string, int> $stats
*/
private function initialStatus(Request $request, array $stats): string
{
if ($request->query->has('status')) {
return $this->normalizeStatus((string) $request->query('status'));
}
if (($stats['suspected'] ?? 0) > 0) {
return 'suspected';
}
if (($stats['audit'] ?? 0) > 0) {
return 'audit';
}
if (($stats['reviewed'] ?? 0) > 0) {
return 'reviewed';
}
return 'suspected';
}
/**
* @return array{list: string, review: string}
*/
private function routeNamesForRequest(Request $request): array
{
$routeName = (string) $request->route()?->getName();
if (Str::startsWith($routeName, 'admin.cp.artworks.maturity.')) {
return [
'list' => 'admin.cp.artworks.maturity.queue',
'review' => 'admin.cp.artworks.maturity.review',
];
}
return [
'list' => 'cp.maturity.list',
'review' => 'cp.maturity.review',
];
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\ArtworkFeature;
use App\Services\FeaturedArtworkAdminService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class FeaturedArtworkAdminController extends Controller
{
public function __construct(private readonly FeaturedArtworkAdminService $featuredArtworks)
{
}
public function index(): Response
{
return Inertia::render('Collection/FeaturedArtworksAdmin', array_merge(
$this->featuredArtworks->pageProps(),
[
'endpoints' => [
'search' => route('admin.cp.artworks.featured.search'),
'store' => route('admin.cp.artworks.featured.store'),
'updatePattern' => route('admin.cp.artworks.featured.update', ['feature' => '__FEATURE__']),
'togglePattern' => route('admin.cp.artworks.featured.toggle', ['feature' => '__FEATURE__']),
'forceHeroPattern' => route('admin.cp.artworks.featured.force-hero', ['feature' => '__FEATURE__']),
'destroyPattern' => route('admin.cp.artworks.featured.delete', ['feature' => '__FEATURE__']),
],
'capabilities' => [
'forceHeroEnabled' => $this->hasForceHeroColumn(),
],
'seo' => [
'title' => 'Featured Artworks — Skinbase Nova',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route('admin.cp.artworks.featured.main'),
'robots' => 'noindex,follow',
],
],
))->rootView('collections');
}
public function search(Request $request): JsonResponse
{
$validated = $request->validate([
'q' => ['required', 'string', 'min:1', 'max:120'],
]);
return response()->json([
'ok' => true,
'results' => $this->featuredArtworks->searchArtworks((string) $validated['q']),
]);
}
public function store(Request $request): JsonResponse
{
$validated = $this->validateStore($request);
$actor = $this->currentActor($request);
ArtworkFeature::query()->create([
'artwork_id' => (int) $validated['artwork_id'],
'priority' => (int) $validated['priority'],
'featured_at' => Carbon::parse((string) $validated['featured_at']),
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
'is_active' => (bool) $validated['is_active'],
'created_by' => (int) $actor->id,
]);
return $this->mutationResponse('Featured artwork added.');
}
public function update(Request $request, ArtworkFeature $feature): JsonResponse
{
$validated = $this->validateUpdate($request);
$this->ensureStateAvailable($feature, (bool) $validated['is_active']);
$feature->fill([
'priority' => (int) $validated['priority'],
'featured_at' => Carbon::parse((string) $validated['featured_at']),
'expires_at' => filled($validated['expires_at'] ?? null) ? Carbon::parse((string) $validated['expires_at']) : null,
'is_active' => (bool) $validated['is_active'],
]);
$feature->save();
return $this->mutationResponse('Featured artwork updated.');
}
public function toggle(ArtworkFeature $feature): JsonResponse
{
$nextState = ! (bool) $feature->is_active;
$this->ensureStateAvailable($feature, $nextState);
$feature->forceFill([
'is_active' => $nextState,
])->save();
return $this->mutationResponse($nextState ? 'Featured artwork activated.' : 'Featured artwork deactivated.');
}
public function toggleForceHero(ArtworkFeature $feature): JsonResponse
{
$this->ensureForceHeroAvailable();
$nextState = ! (bool) $feature->force_hero;
DB::transaction(function () use ($feature, $nextState): void {
if ($nextState) {
ArtworkFeature::query()
->where('force_hero', true)
->whereNull('deleted_at')
->whereKeyNot($feature->id)
->update(['force_hero' => false]);
}
$feature->forceFill([
'force_hero' => $nextState,
])->save();
});
return $this->mutationResponse($nextState ? 'Force hero enabled.' : 'Force hero disabled.');
}
public function destroy(ArtworkFeature $feature): JsonResponse
{
$feature->delete();
return $this->mutationResponse('Featured artwork entry deleted.');
}
/**
* @return array<string, mixed>
*/
private function validateStore(Request $request): array
{
return $request->validate([
'artwork_id' => [
'required',
'integer',
Rule::exists('artworks', 'id'),
Rule::unique('artwork_features', 'artwork_id')->where(fn ($query) => $query->whereNull('deleted_at')),
],
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
'featured_at' => ['required', 'date'],
'expires_at' => ['nullable', 'date', 'after:featured_at'],
'is_active' => ['required', 'boolean'],
], [
'artwork_id.unique' => 'This artwork already has a featured entry. Edit the existing row instead.',
]);
}
/**
* @return array<string, mixed>
*/
private function validateUpdate(Request $request): array
{
return $request->validate([
'priority' => ['required', 'integer', 'min:0', 'max:65535'],
'featured_at' => ['required', 'date'],
'expires_at' => ['nullable', 'date', 'after:featured_at'],
'is_active' => ['required', 'boolean'],
]);
}
private function ensureStateAvailable(ArtworkFeature $feature, bool $isActive): void
{
$conflictExists = ArtworkFeature::query()
->where('artwork_id', $feature->artwork_id)
->where('is_active', $isActive)
->whereNull('deleted_at')
->whereKeyNot($feature->id)
->exists();
if ($conflictExists) {
throw ValidationException::withMessages([
'is_active' => 'Another featured entry for this artwork already uses that active state.',
]);
}
}
private function mutationResponse(string $message): JsonResponse
{
return response()->json(array_merge([
'ok' => true,
'message' => $message,
], $this->featuredArtworks->pageProps()));
}
private function currentActor(Request $request): object
{
return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.');
}
private function ensureForceHeroAvailable(): void
{
if (! $this->hasForceHeroColumn()) {
throw ValidationException::withMessages([
'force_hero' => 'Run php artisan migrate to enable force hero controls.',
]);
}
}
private function hasForceHeroColumn(): bool
{
return Schema::hasColumn('artwork_features', 'force_hero');
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\ArtworkVersion; use App\Models\ArtworkVersion;
use App\Services\ArtworkEvolutionService;
use App\Services\Cdn\ArtworkCdnPurgeService; use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkAttributionService; use App\Services\ArtworkAttributionService;
@@ -122,6 +123,7 @@ final class StudioArtworksApiController extends Controller
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
{ {
$artwork = $request->user()->artworks()->findOrFail($id); $artwork = $request->user()->artworks()->findOrFail($id);
$evolution = app(ArtworkEvolutionService::class);
$validated = $request->validate([ $validated = $request->validate([
'title' => 'sometimes|string|max:255', 'title' => 'sometimes|string|max:255',
@@ -133,7 +135,7 @@ final class StudioArtworksApiController extends Controller
'timezone' => 'sometimes|nullable|string|max:64', 'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id', 'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id', 'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:15', 'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'string|max:64', 'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed', 'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed', 'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
@@ -147,12 +149,18 @@ final class StudioArtworksApiController extends Controller
'contributor_credits.*.user_id' => 'required|integer|min:1', 'contributor_credits.*.user_id' => 'required|integer|min:1',
'contributor_credits.*.credit_role' => 'nullable|string|max:80', 'contributor_credits.*.credit_role' => 'nullable|string|max:80',
'contributor_credits.*.is_primary' => 'nullable|boolean', 'contributor_credits.*.is_primary' => 'nullable|boolean',
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
'evolution_note' => 'sometimes|nullable|string|max:1200',
]); ]);
$hasAttributionUpdates = array_key_exists('group', $validated) $hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated) || array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $validated) || array_key_exists('contributor_user_ids', $validated)
|| array_key_exists('contributor_credits', $validated); || array_key_exists('contributor_credits', $validated);
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|| array_key_exists('evolution_relation_type', $validated)
|| array_key_exists('evolution_note', $validated);
$attributionPayload = [ $attributionPayload = [
'group' => $validated['group'] ?? $artwork->group?->slug, 'group' => $validated['group'] ?? $artwork->group?->slug,
@@ -190,7 +198,13 @@ final class StudioArtworksApiController extends Controller
$tags = $validated['tags'] ?? null; $tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null; $categoryId = $validated['category_id'] ?? null;
$contentTypeId = $validated['content_type_id'] ?? null; $contentTypeId = $validated['content_type_id'] ?? null;
$evolutionPayload = [
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
'relation_type' => $validated['evolution_relation_type'] ?? null,
'note' => $validated['evolution_note'] ?? null,
];
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']); unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']);
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
$validated['visibility'] = $visibility; $validated['visibility'] = $visibility;
$validated['artwork_timezone'] = $timezone; $validated['artwork_timezone'] = $timezone;
@@ -244,6 +258,14 @@ final class StudioArtworksApiController extends Controller
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload); $artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
} }
if ($hasEvolutionUpdates) {
try {
$evolution->syncPrimaryRelation($artwork->fresh(['group.members']), $request->user(), $evolutionPayload);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
// Reindex in Meilisearch // Reindex in Meilisearch
try { try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) { if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
@@ -287,6 +309,25 @@ final class StudioArtworksApiController extends Controller
'description_source' => $artwork->description_source ?: 'manual', 'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual', 'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual', 'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
],
]);
}
public function evolutionOptions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:120'],
]);
$evolution = app(ArtworkEvolutionService::class);
return response()->json([
'data' => $evolution->manageableSearchOptions($artwork, $request->user(), (string) ($validated['search'] ?? '')),
'meta' => [
'selected' => $evolution->editorRelation($artwork, $request->user()),
], ],
]); ]);
} }

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Group; use App\Models\Group;
use App\Models\ContentType; use App\Models\ContentType;
use App\Services\ArtworkEvolutionService;
use App\Services\GroupMembershipService; use App\Services\GroupMembershipService;
use App\Services\GroupService; use App\Services\GroupService;
use App\Services\Studio\CreatorStudioAnalyticsService; use App\Services\Studio\CreatorStudioAnalyticsService;
@@ -478,6 +479,7 @@ final class StudioController extends Controller
'description_source' => $artwork->description_source ?: 'manual', 'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual', 'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual', 'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
// Versioning // Versioning
'version_count' => (int) ($artwork->version_count ?? 1), 'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval, 'requires_reapproval' => (bool) $artwork->requires_reapproval,
@@ -485,6 +487,7 @@ final class StudioController extends Controller
'contentTypes' => $this->getCategories(), 'contentTypes' => $this->getCategories(),
'groupOptions' => $availableGroups, 'groupOptions' => $availableGroups,
'contributorOptionsByGroup' => $contributorOptionsByGroup, 'contributorOptionsByGroup' => $contributorOptionsByGroup,
'evolutionRelationTypes' => app(ArtworkEvolutionService::class)->relationTypeOptions(),
]); ]);
} }

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest; use App\Http\Requests\ProfileUpdateRequest;
use App\Http\Requests\Settings\RequestEmailChangeRequest; use App\Http\Requests\Settings\RequestEmailChangeRequest;
use App\Http\Requests\Settings\UpdateAccountSectionRequest; use App\Http\Requests\Settings\UpdateAccountSectionRequest;
use App\Http\Requests\Settings\UpdateContentPreferencesRequest;
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest; use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
use App\Http\Requests\Settings\UpdatePersonalSectionRequest; use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
use App\Http\Requests\Settings\UpdateProfileSectionRequest; use App\Http\Requests\Settings\UpdateProfileSectionRequest;
@@ -35,10 +36,11 @@ use App\Services\FollowAnalyticsService;
use App\Services\LeaderboardService; use App\Services\LeaderboardService;
use App\Services\UserSuggestionService; use App\Services\UserSuggestionService;
use App\Services\Countries\CountryCatalogService; use App\Services\Countries\CountryCatalogService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Services\XPService; use App\Services\XPService;
use App\Services\UsernameApprovalService; use App\Services\UsernameApprovalService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use App\Support\CoverUrl; use App\Support\CoverUrl;
@@ -84,6 +86,7 @@ class ProfileController extends Controller
private readonly LeaderboardService $leaderboards, private readonly LeaderboardService $leaderboards,
private readonly CountryCatalogService $countryCatalog, private readonly CountryCatalogService $countryCatalog,
private readonly UserSuggestionService $userSuggestions, private readonly UserSuggestionService $userSuggestions,
private readonly CreatorJourneyService $creatorJourney,
) )
{ {
} }
@@ -312,6 +315,10 @@ class ProfileController extends Controller
$followerNotifications = (bool) ($profileData['follower_notifications'] ?? true); $followerNotifications = (bool) ($profileData['follower_notifications'] ?? true);
$commentNotifications = (bool) ($profileData['comment_notifications'] ?? true); $commentNotifications = (bool) ($profileData['comment_notifications'] ?? true);
$newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false); $newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false);
$matureContentVisibility = (string) ($profileData['mature_content_visibility'] ?? config('maturity.viewer.default_mode', 'blur'));
$matureContentWarningEnabled = array_key_exists('mature_content_warning_enabled', $profileData)
? (bool) $profileData['mature_content_warning_enabled']
: (bool) config('maturity.viewer.default_warn_on_detail', true);
return Inertia::render('Settings/ProfileEdit', [ return Inertia::render('Settings/ProfileEdit', [
'user' => [ 'user' => [
@@ -332,6 +339,8 @@ class ProfileController extends Controller
'follower_notifications' => $followerNotifications, 'follower_notifications' => $followerNotifications,
'comment_notifications' => $commentNotifications, 'comment_notifications' => $commentNotifications,
'newsletter' => $newsletter, 'newsletter' => $newsletter,
'mature_content_visibility' => $matureContentVisibility,
'mature_content_warning_enabled' => $matureContentWarningEnabled,
'last_username_change_at' => $user->last_username_change_at, 'last_username_change_at' => $user->last_username_change_at,
'username_changed_at' => $user->username_changed_at, 'username_changed_at' => $user->username_changed_at,
], ],
@@ -576,6 +585,18 @@ class ProfileController extends Controller
return $this->settingsResponse($request, 'Notification settings saved successfully.'); return $this->settingsResponse($request, 'Notification settings saved successfully.');
} }
public function updateContentPreferencesSection(UpdateContentPreferencesRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$this->persistProfileUpdates((int) $request->user()->id, [
'mature_content_visibility' => (string) $validated['mature_content_visibility'],
'mature_content_warning_enabled' => (bool) $validated['mature_content_warning_enabled'],
]);
return $this->settingsResponse($request, 'Content preferences saved successfully.');
}
public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse
{ {
$validated = $request->validated(); $validated = $request->validated();
@@ -918,7 +939,7 @@ class ProfileController extends Controller
$perPage = 24; $perPage = 24;
// ── Artworks (cursor-paginated) ────────────────────────────────────── // ── Artworks (cursor-paginated) ──────────────────────────────────────
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage) $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer)
->through(function (Artwork $art) { ->through(function (Artwork $art) {
return (object) $this->mapArtworkCardPayload($art); return (object) $this->mapArtworkCardPayload($art);
}); });
@@ -926,34 +947,38 @@ class ProfileController extends Controller
// ── Featured artworks for this user ───────────────────────────────── // ── Featured artworks for this user ─────────────────────────────────
$featuredArtworks = collect(); $featuredArtworks = collect();
if (Schema::hasTable('artwork_features')) { if (Schema::hasTable('artwork_features')) {
$featuredArtworks = DB::table('artwork_features as af') $featuredQuery = Artwork::query()
->join('artworks as a', 'a.id', '=', 'af.artwork_id') ->with([
->where('a.user_id', $user->id) 'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->where('artworks.user_id', $user->id)
->where('af.is_active', true) ->where('af.is_active', true)
->whereNull('af.deleted_at') ->whereNull('af.deleted_at')
->whereNull('a.deleted_at') ->whereNull('artworks.deleted_at')
->where('a.is_public', true) ->where('artworks.is_public', true)
->where('a.is_approved', true) ->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at'])
->orderByDesc('af.featured_at') ->orderByDesc('af.featured_at')
->limit(3) ->limit(3);
->select([
'a.id', 'a.title as name', 'a.hash', 'a.thumb_ext', app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer);
'a.width', 'a.height', 'af.label', 'af.featured_at',
]) $featuredArtworks = $featuredQuery
->get() ->get()
->map(function ($row) { ->map(function (Artwork $artwork) {
$thumbUrl = ($row->hash && $row->thumb_ext) return (object) array_merge($this->mapArtworkCardPayload($artwork), [
? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'md') 'label' => $artwork->featured_label,
: '/images/placeholder.jpg'; 'featured_at' => $this->formatIsoDate($artwork->featured_slot_at),
return (object) [ ]);
'id' => $row->id,
'name' => $row->name,
'thumb' => $thumbUrl,
'label' => $row->label,
'featured_at' => $row->featured_at,
'width' => $row->width,
'height' => $row->height,
];
}); });
} }
@@ -972,6 +997,10 @@ class ProfileController extends Controller
->where('a.is_public', true) ->where('a.is_public', true)
->where('a.is_approved', true) ->where('a.is_approved', true)
->whereNotNull('a.published_at') ->whereNotNull('a.published_at')
->when(app(ArtworkMaturityService::class)->viewerPreferences($viewer)['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
})
->orderByDesc('af.created_at') ->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id') ->orderByDesc('af.artwork_id')
->limit($favouriteLimit + 1) ->limit($favouriteLimit + 1)
@@ -981,7 +1010,16 @@ class ProfileController extends Controller
$hasMore = $favIds->count() > $favouriteLimit; $hasMore = $favIds->count() > $favouriteLimit;
$favIds = $favIds->take($favouriteLimit); $favIds = $favIds->take($favouriteLimit);
$indexed = Artwork::with('user:id,name,username') $indexed = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->whereIn('id', $favIds) ->whereIn('id', $favIds)
->get() ->get()
->keyBy('id'); ->keyBy('id');
@@ -1056,18 +1094,38 @@ class ProfileController extends Controller
->count(); ->count();
} }
$liveAwardsReceivedCount = 0; $medalTotals = [
if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) { 'gold' => 0,
$liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw') 'silver' => 0,
->join('artworks as a', 'a.id', '=', 'aw.artwork_id') 'bronze' => 0,
'count' => 0,
'score_total' => 0,
];
if (Schema::hasTable('artwork_medal_stats') && Schema::hasTable('artworks')) {
$totals = DB::table('artwork_medal_stats as aas')
->join('artworks as a', 'a.id', '=', 'aas.artwork_id')
->where('a.user_id', $user->id) ->where('a.user_id', $user->id)
->whereNull('a.deleted_at') ->whereNull('a.deleted_at')
->count(); ->selectRaw('COALESCE(SUM(aas.gold_count), 0) as gold_count')
->selectRaw('COALESCE(SUM(aas.silver_count), 0) as silver_count')
->selectRaw('COALESCE(SUM(aas.bronze_count), 0) as bronze_count')
->selectRaw('COALESCE(SUM(aas.score_total), 0) as score_total')
->first();
$medalTotals = [
'gold' => (int) ($totals->gold_count ?? 0),
'silver' => (int) ($totals->silver_count ?? 0),
'bronze' => (int) ($totals->bronze_count ?? 0),
'count' => (int) (($totals->gold_count ?? 0) + ($totals->silver_count ?? 0) + ($totals->bronze_count ?? 0)),
'score_total' => (int) ($totals->score_total ?? 0),
];
} }
$statsPayload = array_merge($stats ? (array) $stats : [], [ $statsPayload = array_merge($stats ? (array) $stats : [], [
'uploads_count' => $liveUploadsCount, 'uploads_count' => $liveUploadsCount,
'awards_received_count' => $liveAwardsReceivedCount, 'awards_received_count' => $medalTotals['count'],
'medal_totals' => $medalTotals,
'followers_count' => (int) $followerCount, 'followers_count' => (int) $followerCount,
'following_count' => (int) $followingCount, 'following_count' => (int) $followingCount,
]); ]);
@@ -1145,7 +1203,7 @@ class ProfileController extends Controller
]); ]);
$profileCollections = $this->collections->getProfileCollections($user, $viewer); $profileCollections = $this->collections->getProfileCollections($user, $viewer);
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner); $profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer);
// ── Profile data ───────────────────────────────────────────────────── // ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile; $profile = $user->profile;
@@ -1203,6 +1261,7 @@ class ProfileController extends Controller
$achievementSummary = $this->achievements->summary((int) $user->id); $achievementSummary = $this->achievements->summary((int) $user->id);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
$groupContributionHistory = $this->buildGroupContributionHistory($user); $groupContributionHistory = $this->buildGroupContributionHistory($user);
$journey = $this->creatorJourney->publicPayloadForUser($user);
$resolvedInitialTab = $this->normalizeProfileTab($initialTab); $resolvedInitialTab = $this->normalizeProfileTab($initialTab);
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null; $isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
$activeProfileUrl = $resolvedInitialTab !== null $activeProfileUrl = $resolvedInitialTab !== null
@@ -1276,6 +1335,7 @@ class ProfileController extends Controller
'collections' => $profileCollectionsPayload, 'collections' => $profileCollectionsPayload,
'achievements' => $achievementSummary, 'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank, 'leaderboardRank' => $leaderboardRank,
'journey' => $journey,
'groupContributionHistory' => $groupContributionHistory, 'groupContributionHistory' => $groupContributionHistory,
'countryName' => $countryName, 'countryName' => $countryName,
'isOwner' => $isOwner, 'isOwner' => $isOwner,
@@ -1288,6 +1348,7 @@ class ProfileController extends Controller
'collectionsFeaturedUrl' => route('collections.featured'), 'collectionsFeaturedUrl' => route('collections.featured'),
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3), 'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
'profileTabUrls' => $profileTabUrls, 'profileTabUrls' => $profileTabUrls,
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
])->withViewData([ ])->withViewData([
'page_title' => $pageTitle, 'page_title' => $pageTitle,
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
@@ -1435,8 +1496,17 @@ class ProfileController extends Controller
$category = $art->categories->first(); $category = $art->categories->first();
$contentType = $category?->contentType; $contentType = $category?->contentType;
$stats = $art->stats; $stats = $art->stats;
$group = $art->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [ return app(ArtworkMaturityService::class)->decoratePayload([
'id' => $art->id, 'id' => $art->id,
'name' => $art->title, 'name' => $art->title,
'picture' => $art->file_name, 'picture' => $art->file_name,
@@ -1444,11 +1514,22 @@ class ProfileController extends Controller
'published_at' => $this->formatIsoDate($art->published_at), 'published_at' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'], 'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase', 'uname' => $displayName,
'username' => $art->user->username ?? null, 'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $publisherType,
'publisher' => [
'type' => $publisherType,
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
'name' => $displayName,
'username' => $username ?? '',
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'user_id' => $art->user_id, 'user_id' => $art->user_id,
'author_level' => (int) ($art->user?->level ?? 1), 'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'), 'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name, 'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug, 'content_type_slug' => $contentType?->slug,
'category' => $category?->name, 'category' => $category?->name,
@@ -1458,7 +1539,7 @@ class ProfileController extends Controller
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0), 'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
'width' => $art->width, 'width' => $art->width,
'height' => $art->height, 'height' => $art->height,
]; ], $art, request()->user());
} }
private function formatIsoDate(mixed $value): ?string private function formatIsoDate(mixed $value): ?string

View File

@@ -12,6 +12,7 @@ use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService; use App\Services\ErrorSuggestionService;
use App\Services\GroupService; use App\Services\GroupService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Support\Seo\SeoFactory; use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -23,7 +24,10 @@ use Illuminate\View\View;
final class ArtworkPageController extends Controller final class ArtworkPageController extends Controller
{ {
public function __construct(private readonly GroupService $groups) {} public function __construct(
private readonly GroupService $groups,
private readonly ArtworkMaturityService $maturity,
) {}
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
{ {
@@ -145,6 +149,7 @@ final class ArtworkPageController extends Controller
->whereKeyNot($artwork->id) ->whereKeyNot($artwork->id)
->public() ->public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user()))
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void { ->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id); $query->where('user_id', $artwork->user_id);
@@ -176,14 +181,14 @@ final class ArtworkPageController extends Controller
$md = ThumbnailPresenter::present($item, 'md'); $md = ThumbnailPresenter::present($item, 'md');
$lg = ThumbnailPresenter::present($item, 'lg'); $lg = ThumbnailPresenter::present($item, 'lg');
return [ return $this->maturity->decoratePayload([
'id' => (int) $item->id, 'id' => (int) $item->id,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]), 'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null, 'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w', 'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
]; ], $item, request()->user());
}) })
->values() ->values()
->all(); ->all();

View File

@@ -7,7 +7,10 @@ use App\Models\ContentType;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkSearchService; use App\Services\ArtworkSearchService;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -17,8 +20,6 @@ use Illuminate\Pagination\AbstractCursorPaginator;
class BrowseGalleryController extends \App\Http\Controllers\Controller class BrowseGalleryController extends \App\Http\Controllers\Controller
{ {
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other', 'digital-art'];
/** /**
* Meilisearch sort-field arrays per sort alias. * Meilisearch sort-field arrays per sort alias.
* First element is primary sort; subsequent elements are tie-breakers. * First element is primary sort; subsequent elements are tie-breakers.
@@ -74,6 +75,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
public function __construct( public function __construct(
private ArtworkService $artworks, private ArtworkService $artworks,
private ArtworkSearchService $search, private ArtworkSearchService $search,
private ContentTypeSlugResolver $contentTypeResolver,
private ArtworkMaturityService $maturity,
) { ) {
} }
@@ -121,14 +124,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
public function content(Request $request, string $contentTypeSlug, ?string $path = null) public function content(Request $request, string $contentTypeSlug, ?string $path = null)
{ {
$contentSlug = strtolower($contentTypeSlug); $requestedSlug = strtolower($contentTypeSlug);
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) { $resolution = $this->contentTypeResolver->resolve($requestedSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404); abort(404);
} }
$contentType = ContentType::where('slug', $contentSlug)->first(); $contentType = $resolution->contentType;
if (! $contentType) { $contentSlug = strtolower((string) $contentType->slug);
abort(404);
if ($resolution->requiresRedirect()) {
return $this->redirectToContentTypePath($request, $contentSlug, $path, 301);
} }
// Default sort: trending (not chronological) // Default sort: trending (not chronological)
@@ -265,12 +272,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$contentTypeSlug = strtolower((string) $contentTypeSlug); $contentTypeSlug = strtolower((string) $contentTypeSlug);
$categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : ''); $categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : '');
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
// Normalize artwork param if route-model binding returned an Artwork model // Normalize artwork param if route-model binding returned an Artwork model
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork; $artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
if ($resolution->requiresRedirect()) {
$path = trim($categoryPath . '/' . $artworkSlug, '/');
return $this->redirectToContentTypePath($req, $resolvedContentTypeSlug, $path, 301);
}
return app(\App\Http\Controllers\ArtworkController::class)->show( return app(\App\Http\Controllers\ArtworkController::class)->show(
$req, $req,
$contentTypeSlug, $resolvedContentTypeSlug,
$categoryPath, $categoryPath,
$artworkSlug $artworkSlug
); );
@@ -293,7 +313,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [ return (object) $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'name' => $artwork->title, 'name' => $artwork->title,
'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '',
@@ -317,7 +337,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'published_at' => $artwork->published_at, 'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null, 'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null, 'height' => $artwork->height ?? null,
]; ], $artwork, request()->user());
} }
/** /**
@@ -372,9 +392,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
private function mainCategories(): Collection private function mainCategories(): Collection
{ {
return ContentType::ordered() return $this->contentTypeResolver
->whereIn('slug', self::CONTENT_TYPE_SLUGS) ->publicContentTypes()
->get(['name', 'slug'])
->map(function (ContentType $type) { ->map(function (ContentType $type) {
return (object) [ return (object) [
'id' => $type->id, 'id' => $type->id,
@@ -385,6 +404,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
}); });
} }
private function redirectToContentTypePath(Request $request, string $contentTypeSlug, ?string $path = null, int $status = 301): RedirectResponse
{
$target = url('/' . trim($contentTypeSlug . '/' . trim((string) $path, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, $status);
}
private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array
{ {
$canonicalQuery = $request->query(); $canonicalQuery = $request->query();

View File

@@ -5,13 +5,14 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class DailyUploadsController extends Controller class DailyUploadsController extends Controller
{ {
protected ArtworkService $artworks; protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks) public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity)
{ {
$this->artworks = $artworks; $this->artworks = $artworks;
} }
@@ -76,11 +77,11 @@ class DailyUploadsController extends Controller
private function prepareArts($ars) private function prepareArts($ars)
{ {
return $ars->map(function (Artwork $ar) { $items = $ars->map(function (Artwork $ar): array {
$primaryCategory = $ar->categories->sortBy('sort_order')->first(); $primaryCategory = $ar->categories->sortBy('sort_order')->first();
$present = \App\Services\ThumbnailPresenter::present($ar, 'md'); $present = \App\Services\ThumbnailPresenter::present($ar, 'md');
return (object) [ return $this->maturity->decoratePayload([
'id' => $ar->id, 'id' => $ar->id,
'name' => $ar->title, 'name' => $ar->title,
'thumb' => $present['url'], 'thumb' => $present['url'],
@@ -88,7 +89,11 @@ class DailyUploadsController extends Controller
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'category_name' => $primaryCategory->name ?? '', 'category_name' => $primaryCategory->name ?? '',
'uname' => $ar->user->name ?? 'Skinbase', 'uname' => $ar->user->name ?? 'Skinbase',
]; ], $ar, request()->user());
}); })->values()->all();
return collect($this->maturity->filterPayloadItems($items, request()->user()))
->map(static fn (array $item): object => (object) $item)
->values();
} }
} }

View File

@@ -9,10 +9,12 @@ use App\Services\ArtworkSearchService;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\EarlyGrowth\AdaptiveTimeWindow; use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\GridFiller; use App\Services\EarlyGrowth\GridFiller;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Recommendations\RecommendationFeedResolver; use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserSuggestionService; use App\Services\UserSuggestionService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -38,6 +40,7 @@ final class DiscoverController extends Controller
private readonly GridFiller $gridFiller, private readonly GridFiller $gridFiller,
private readonly CommunityActivityService $communityActivity, private readonly CommunityActivityService $communityActivity,
private readonly UserSuggestionService $userSuggestions, private readonly UserSuggestionService $userSuggestions,
private readonly ArtworkMaturityService $maturity,
) {} ) {}
// ─── /discover/trending ────────────────────────────────────────────────── // ─── /discover/trending ──────────────────────────────────────────────────
@@ -178,6 +181,7 @@ final class DiscoverController extends Controller
->whereRaw('MONTH(published_at) = ?', [$today->month]) ->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day]) ->whereRaw('DAY(published_at) = ?', [$today->day])
->whereRaw('YEAR(published_at) < ?', [$today->year]) ->whereRaw('YEAR(published_at) < ?', [$today->year])
->orderMissingThumbnailsLast()
->orderByDesc('published_at') ->orderByDesc('published_at')
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();
@@ -270,7 +274,8 @@ final class DiscoverController extends Controller
$artworks = collect($feedResult['data'] ?? [])->map( $artworks = collect($feedResult['data'] ?? [])->map(
fn (array $item) => $this->presentRecommendedArtwork($item) fn (array $item) => $this->presentRecommendedArtwork($item)
)->values(); );
$artworks = $this->reorderDiscoverItemsByThumbnailHealth($artworks)->values();
$meta = $feedResult['meta'] ?? []; $meta = $feedResult['meta'] ?? [];
$nextCursor = $meta['next_cursor'] ?? null; $nextCursor = $meta['next_cursor'] ?? null;
@@ -345,6 +350,7 @@ final class DiscoverController extends Controller
->published() ->published()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) ->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereIn('user_id', $followingIds) ->whereIn('user_id', $followingIds)
->orderMissingThumbnailsLast()
->orderByDesc('published_at') ->orderByDesc('published_at')
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();
@@ -416,6 +422,7 @@ final class DiscoverController extends Controller
'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name', 'categories.contentType:id,slug,name',
]) ])
->orderMissingThumbnailsLast()
->orderByDesc('published_at') ->orderByDesc('published_at')
->orderByDesc('id') ->orderByDesc('id')
->paginate($perPage) ->paginate($perPage)
@@ -438,6 +445,7 @@ final class DiscoverController extends Controller
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*') ->select('artworks.*')
->where('artworks.published_at', '>=', $cutoff) ->where('artworks.published_at', '>=', $cutoff)
->orderMissingThumbnailsLast()
->orderByDesc('discover_stats.ranking_score') ->orderByDesc('discover_stats.ranking_score')
->orderByDesc('discover_stats.engagement_velocity') ->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('discover_stats.views') ->orderByDesc('discover_stats.views')
@@ -465,6 +473,7 @@ final class DiscoverController extends Controller
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score') ->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity') ->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->where('artworks.published_at', '>=', $cutoff) ->where('artworks.published_at', '>=', $cutoff)
->orderMissingThumbnailsLast()
->orderByDesc('discover_stats.heat_score') ->orderByDesc('discover_stats.heat_score')
->orderByDesc('discover_stats.engagement_velocity') ->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('artworks.published_at') ->orderByDesc('artworks.published_at')
@@ -496,6 +505,7 @@ final class DiscoverController extends Controller
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity') ->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h') ->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
->where('artworks.published_at', '>=', $cutoff) ->where('artworks.published_at', '>=', $cutoff)
->orderMissingThumbnailsLast()
->orderByDesc('recent_signal_24h') ->orderByDesc('recent_signal_24h')
->orderByDesc('artworks.published_at') ->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id') ->orderByDesc('artworks.id')
@@ -599,7 +609,7 @@ final class DiscoverController extends Controller
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [ return (object) $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'name' => $artwork->title, 'name' => $artwork->title,
'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '',
@@ -624,7 +634,7 @@ final class DiscoverController extends Controller
'published_at' => $artwork->published_at, 'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null, 'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null, 'height' => $artwork->height ?? null,
]; ], $artwork, request()->user());
} }
/** /**
@@ -676,6 +686,7 @@ final class DiscoverController extends Controller
->whereIn('user_id', $followingIds) ->whereIn('user_id', $followingIds)
->where('published_at', '>=', now()->subDays(30)) ->where('published_at', '>=', now()->subDays(30))
->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id')
->orderMissingThumbnailsLast()
->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)')) ->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)'))
->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)')) ->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)'))
->orderByDesc('artworks.published_at') ->orderByDesc('artworks.published_at')
@@ -703,4 +714,42 @@ final class DiscoverController extends Controller
->values() ->values()
->all(); ->all();
} }
/**
* @param Collection<int, object> $items
* @return Collection<int, object>
*/
private function reorderDiscoverItemsByThumbnailHealth(Collection $items): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items;
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items;
}
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
return $healthy
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
->values();
}
} }

View File

@@ -6,11 +6,12 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\ArtworkSearchService; use App\Services\ArtworkSearchService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\EarlyGrowth\EarlyGrowth; use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller; use App\Services\EarlyGrowth\GridFiller;
use App\Services\EarlyGrowth\SpotlightEngineInterface; use App\Services\EarlyGrowth\SpotlightEngineInterface;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\AbstractCursorPaginator; use Illuminate\Pagination\AbstractCursorPaginator;
@@ -27,8 +28,6 @@ use Illuminate\Support\Facades\Cache;
*/ */
final class ExploreController extends Controller final class ExploreController extends Controller
{ {
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
/** Meilisearch sort-field arrays per sort alias. */ /** Meilisearch sort-field arrays per sort alias. */
private const SORT_MAP = [ private const SORT_MAP = [
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'], 'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
@@ -65,6 +64,8 @@ final class ExploreController extends Controller
private readonly ArtworkSearchService $search, private readonly ArtworkSearchService $search,
private readonly GridFiller $gridFiller, private readonly GridFiller $gridFiller,
private readonly SpotlightEngineInterface $spotlight, private readonly SpotlightEngineInterface $spotlight,
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
) {} ) {}
// ── /explore (hub) ────────────────────────────────────────────────── // ── /explore (hub) ──────────────────────────────────────────────────
@@ -75,13 +76,15 @@ final class ExploreController extends Controller
$perPage = $this->resolvePerPage($request); $perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1)); $page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300; $ttl = self::SORT_TTL[$sort] ?? 300;
$cacheVersion = $this->cacheVersion();
$artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () => $artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
Artwork::search('')->options([ $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true', 'filter' => 'is_public = true AND is_approved = true',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage) ], $perPage, false, $page)
); );
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse // EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page); $artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -121,35 +124,43 @@ final class ExploreController extends Controller
public function byType(Request $request, string $type) public function byType(Request $request, string $type)
{ {
$type = strtolower($type); $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
if (! $resolution->found()) {
abort(404); abort(404);
} }
// "artworks" is the umbrella — search all types $isAll = $resolution->isVirtual && $resolution->virtualType === 'artworks';
$isAll = $type === 'artworks';
if (! $isAll && $resolution->contentType === null) {
abort(404);
}
$resolvedTypeSlug = $isAll ? 'artworks' : strtolower((string) $resolution->contentType->slug);
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other. // Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
if (! $isAll) { if (! $isAll) {
return redirect()->to($this->canonicalTypeUrl($request, $type), 301); return redirect()->to($this->canonicalTypeUrl($request, $resolvedTypeSlug), 301);
} }
$sort = $this->resolveSort($request); $sort = $this->resolveSort($request);
$perPage = $this->resolvePerPage($request); $perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1)); $page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300; $ttl = self::SORT_TTL[$sort] ?? 300;
$cacheVersion = $this->cacheVersion();
$filter = 'is_public = true AND is_approved = true'; $filter = 'is_public = true AND is_approved = true';
if (!$isAll) { if (!$isAll) {
$filter .= ' AND content_type = "' . $type . '"'; $filter .= ' AND content_type = "' . $type . '"';
} }
$artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () => $artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
Artwork::search('')->options([ $this->search->searchWithThumbnailPreference([
'filter' => $filter, 'filter' => $filter,
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage) ], $perPage, false, $page)
); );
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse // EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page); $artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
@@ -163,7 +174,7 @@ final class ExploreController extends Controller
$contentType = null; $contentType = null;
$subcategories = $mainCategories; $subcategories = $mainCategories;
if (! $isAll) { if (! $isAll) {
$contentType = ContentType::where('slug', $type)->first(); $contentType = $resolution->contentType;
$subcategories = $contentType $subcategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect(); : collect();
@@ -172,10 +183,10 @@ final class ExploreController extends Controller
if ($isAll) { if ($isAll) {
$humanType = 'Artworks'; $humanType = 'Artworks';
} else { } else {
$humanType = $contentType?->name ?? ucfirst($type); $humanType = $contentType?->name ?? ucfirst($resolvedTypeSlug);
} }
$baseUrl = url('/explore/' . $type); $baseUrl = url('/explore/' . $resolvedTypeSlug);
$seo = $this->paginationSeo($request, $baseUrl, $artworks); $seo = $this->paginationSeo($request, $baseUrl, $artworks);
return view('gallery.index', [ return view('gallery.index', [
@@ -192,11 +203,11 @@ final class ExploreController extends Controller
'hero_description' => "Browse {$humanType} on Skinbase.", 'hero_description' => "Browse {$humanType} on Skinbase.",
'breadcrumbs' => collect([ 'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $humanType, 'url' => "/explore/{$type}"], (object) ['name' => $humanType, 'url' => "/explore/{$resolvedTypeSlug}"],
]), ]),
'page_title' => "{$humanType} - Explore - Skinbase", 'page_title' => "{$humanType} - Explore - Skinbase",
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.", 'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography', 'page_meta_keywords' => strtolower($resolvedTypeSlug) . ', explore, skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'], 'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'], 'page_rel_next' => $seo['next'],
@@ -208,12 +219,17 @@ final class ExploreController extends Controller
public function byTypeMode(Request $request, string $type, string $mode) public function byTypeMode(Request $request, string $type, string $mode)
{ {
$type = strtolower($type); $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
if ($type !== 'artworks') {
if (! $resolution->found()) {
abort(404);
}
if (! ($resolution->isVirtual && $resolution->virtualType === 'artworks')) {
$query = $request->query(); $query = $request->query();
$query['sort'] = $this->normalizeSort((string) $mode); $query['sort'] = $this->normalizeSort((string) $mode);
return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301); return redirect()->to($this->canonicalTypeUrl($request, strtolower((string) $resolution->contentType?->slug), $query), 301);
} }
// Rewrite the sort via the URL segment and delegate // Rewrite the sort via the URL segment and delegate
@@ -225,8 +241,8 @@ final class ExploreController extends Controller
private function mainCategories(): Collection private function mainCategories(): Collection
{ {
$categories = ContentType::ordered() $categories = $this->contentTypeResolver
->get(['name', 'slug']) ->publicContentTypes()
->map(fn ($ct) => (object) [ ->map(fn ($ct) => (object) [
'name' => $ct->name, 'name' => $ct->name,
'slug' => $ct->slug, 'slug' => $ct->slug,
@@ -272,6 +288,26 @@ final class ExploreController extends Controller
return max(12, min($v, 80)); return max(12, min($v, 80));
} }
private function cacheVersion(): int
{
return max(1, (int) Cache::get('explore.cache.version', 1));
}
private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator
{
$paginator->setCollection(
$paginator->getCollection()
->filter(fn ($artwork) => $artwork instanceof Artwork
&& $artwork->deleted_at === null
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null)
->values()
);
return $paginator;
}
private function presentArtwork(Artwork $artwork): object private function presentArtwork(Artwork $artwork): object
{ {
$primary = $artwork->categories->sortBy('sort_order')->first(); $primary = $artwork->categories->sortBy('sort_order')->first();
@@ -289,7 +325,7 @@ final class ExploreController extends Controller
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [ return (object) $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'name' => $artwork->title, 'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '', 'content_type_name' => $primary?->contentType?->name ?? '',
@@ -314,7 +350,7 @@ final class ExploreController extends Controller
'slug' => $artwork->slug ?? '', 'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null, 'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null, 'height' => $artwork->height ?? null,
]; ], $artwork, request()->user());
} }
private function paginationSeo(Request $request, string $base, mixed $paginator): array private function paginationSeo(Request $request, string $base, mixed $paginator): array

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -13,7 +14,7 @@ class FeaturedArtworksController extends Controller
{ {
protected ArtworkService $artworks; protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks) public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity)
{ {
$this->artworks = $artworks; $this->artworks = $artworks;
} }
@@ -29,7 +30,8 @@ class FeaturedArtworksController extends Controller
/** @var LengthAwarePaginator $artworks */ /** @var LengthAwarePaginator $artworks */
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage); $artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
$artworks->getCollection()->transform(function (Artwork $artwork) { $artworks->setCollection(
collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function (Artwork $artwork): array {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = $primaryCategory->name ?? ''; $categoryName = $primaryCategory->name ?? '';
$categorySlug = $primaryCategory->slug ?? ''; $categorySlug = $primaryCategory->slug ?? '';
@@ -37,7 +39,7 @@ class FeaturedArtworksController extends Controller
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); $present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
$username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase'; $username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase';
return (object) [ return $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'name' => $artwork->title, 'name' => $artwork->title,
'slug' => $artwork->slug, 'slug' => $artwork->slug,
@@ -53,8 +55,11 @@ class FeaturedArtworksController extends Controller
'height' => $artwork->height, 'height' => $artwork->height,
'uname' => $artwork->user->name ?? 'Skinbase', 'uname' => $artwork->user->name ?? 'Skinbase',
'username' => $username, 'username' => $username,
]; ], $artwork, $request->user());
}); })->values()->all(), $request->user()))
->map(static fn (array $item): object => (object) $item)
->values()
);
$artworkTypes = [ $artworkTypes = [
1 => 'Bronze Awards', 1 => 'Bronze Awards',

View File

@@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ContentType; use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\View\View; use Illuminate\View\View;
@@ -26,52 +26,6 @@ final class RssFeedController extends Controller
/** Number of items per legacy feed. */ /** Number of items per legacy feed. */
private const FEED_LIMIT = 25; private const FEED_LIMIT = 25;
/**
* Grouped feed definitions shown on the /rss-feeds info page.
* Each group has a 'label' and an array of 'feeds' with title + url.
*/
public const FEED_GROUPS = [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => [
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
],
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
/** Flat feed list kept for backward-compatibility (old view logic). */ /** Flat feed list kept for backward-compatibility (old view logic). */
public const FEEDS = [ public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'], 'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
@@ -80,6 +34,10 @@ final class RssFeedController extends Controller
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'], 'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
]; ];
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
{
}
/** Info page at /rss-feeds */ /** Info page at /rss-feeds */
public function index(): View public function index(): View
{ {
@@ -94,7 +52,7 @@ final class RssFeedController extends Controller
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'], (object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]), ]),
'feeds' => self::FEEDS, 'feeds' => self::FEEDS,
'feed_groups' => self::FEED_GROUPS, 'feed_groups' => $this->feedGroups(),
'center_content' => true, 'center_content' => true,
'center_max' => '3xl', 'center_max' => '3xl',
]); ]);
@@ -134,7 +92,7 @@ final class RssFeedController extends Controller
private function feedByContentType(string $slug, string $title, string $feedPath): Response private function feedByContentType(string $slug, string $title, string $feedPath): Response
{ {
$contentType = ContentType::where('slug', $slug)->first(); $contentType = $this->contentTypeResolver->resolve($slug)->contentType;
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT); $query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
@@ -160,4 +118,70 @@ final class RssFeedController extends Controller
'Content-Type' => 'application/rss+xml; charset=utf-8', 'Content-Type' => 'application/rss+xml; charset=utf-8',
]); ]);
} }
private function feedGroups(): array
{
$exploreFeeds = [[
'title' => 'All Artworks',
'url' => '/rss/explore/artworks',
'description' => 'Latest artworks of all types.',
]];
foreach ($this->contentTypeResolver->publicContentTypes() as $contentType) {
$name = (string) $contentType->name;
$slug = (string) $contentType->slug;
$exploreFeeds[] = [
'title' => $name,
'url' => '/rss/explore/' . $slug,
'description' => 'Latest ' . strtolower($name) . '.',
];
}
if ($this->contentTypeResolver->publicContentTypes()->isNotEmpty()) {
$firstType = $this->contentTypeResolver->publicContentTypes()->first();
$exploreFeeds[] = [
'title' => 'Trending ' . $firstType->name,
'url' => '/rss/explore/' . $firstType->slug . '/trending',
'description' => 'Trending ' . strtolower((string) $firstType->name) . ' this week.',
];
}
return [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => $exploreFeeds,
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
}
} }

View File

@@ -22,6 +22,7 @@ final class SearchController extends Controller
{ {
$q = trim((string) $request->query('q', '')); $q = trim((string) $request->query('q', ''));
$sort = $request->query('sort', 'latest'); $sort = $request->query('sort', 'latest');
$hasQuery = $q !== '';
$sortMap = [ $sortMap = [
'popular' => 'views:desc', 'popular' => 'views:desc',
@@ -30,17 +31,17 @@ final class SearchController extends Controller
'downloads' => 'downloads:desc', 'downloads' => 'downloads:desc',
]; ];
$artworks = $q !== '' $artworks = $hasQuery
? $this->search->search($q, [ ? $this->search->search($q, [
'sort' => ($sortMap[$sort] ?? 'created_at:desc'), 'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
]) ])
: $this->search->popular(24); : $this->search->popular(24);
$groups = $q !== '' $groups = $hasQuery
? $this->groups->searchCards($q, $request->user(), 6) ? $this->groups->searchCards($q, $request->user(), 6)
: $this->groups->surfaceCards($request->user(), 'featured', 4); : $this->groups->surfaceCards($request->user(), 'featured', 4);
$news = $q !== '' $news = $hasQuery
? NewsArticle::query() ? NewsArticle::query()
->with(['author:id,username,name', 'category:id,name,slug']) ->with(['author:id,username,name', 'category:id,name,slug'])
->published() ->published()
@@ -55,15 +56,59 @@ final class SearchController extends Controller
->get() ->get()
: collect(); : collect();
$groupResults = collect($groups ?? []);
$newsResults = collect($news ?? []);
$resultCount = method_exists($artworks, 'total') ? (int) $artworks->total() : 0;
$groupResultCount = $groupResults->count();
$newsResultCount = $newsResults->count();
$hasAnyResults = $resultCount > 0 || $groupResultCount > 0 || $newsResultCount > 0;
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : $artworks)
->map(fn ($art) => $this->mapArtworkCard($art))
->values();
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
return view('search.index', [ return view('search.index', [
'q' => $q, 'q' => $q,
'hasQuery' => $hasQuery,
'sort' => $sort, 'sort' => $sort,
'groups' => $groups, 'groups' => $groups,
'groupResults' => $groupResults,
'groupResultCount' => $groupResultCount,
'artworks' => $artworks, 'artworks' => $artworks,
'resultCount' => $resultCount,
'news' => $news, 'news' => $news,
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase', 'newsResults' => $newsResults,
'newsResultCount' => $newsResultCount,
'hasAnyResults' => $hasAnyResults,
'galleryArtworks' => $galleryArtworks,
'galleryNextPageUrl' => $galleryNextPageUrl,
'page_title' => $hasQuery ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.', 'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.',
'page_robots' => 'noindex,follow', 'page_robots' => 'noindex,follow',
]); ]);
} }
private function mapArtworkCard(mixed $artwork): array
{
return [
'id' => $artwork->id ?? null,
'name' => $artwork->name ?? null,
'thumb' => $artwork->thumb_url ?? $artwork->thumb ?? null,
'thumb_srcset' => $artwork->thumb_srcset ?? null,
'uname' => $artwork->uname ?? '',
'username' => $artwork->username ?? '',
'avatar_url' => $artwork->avatar_url ?? null,
'profile_url' => $artwork->profile_url ?? null,
'published_as_type' => $artwork->published_as_type ?? null,
'publisher' => $artwork->publisher ?? null,
'category_name' => $artwork->category_name ?? '',
'category_slug' => $artwork->category_slug ?? '',
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
'views' => $artwork->views ?? null,
'likes' => $artwork->likes ?? null,
'downloads' => $artwork->downloads ?? null,
];
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Recommendations\HybridSimilarArtworksService; use App\Services\Recommendations\HybridSimilarArtworksService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\Vision\VectorService; use App\Services\Vision\VectorService;
@@ -35,6 +36,7 @@ final class SimilarArtworksPageController extends Controller
public function __construct( public function __construct(
private readonly VectorService $vectors, private readonly VectorService $vectors,
private readonly ArtworkMaturityService $maturity,
private readonly HybridSimilarArtworksService $hybridService, private readonly HybridSimilarArtworksService $hybridService,
) {} ) {}
@@ -70,6 +72,7 @@ final class SimilarArtworksPageController extends Controller
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null, 'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist', 'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '', 'author_username' => $source->user?->username ?? '',
'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null,
'author_avatar' => AvatarUrl::forUser( 'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0), (int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null, $source->user?->profile?->avatar_hash ?? null,
@@ -79,6 +82,7 @@ final class SimilarArtworksPageController extends Controller
'category_slug' => $primaryCat?->slug ?? '', 'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '', 'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '', 'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'),
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(), 'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null, 'width' => $source->width ?? null,
'height' => $source->height ?? null, 'height' => $source->height ?? null,
@@ -144,8 +148,11 @@ final class SimilarArtworksPageController extends Controller
'slug' => $art->slug ?? '', 'slug' => $art->slug ?? '',
'width' => $art->width ?? null, 'width' => $art->width ?? null,
'height' => $art->height ?? null, 'height' => $art->height ?? null,
'maturity' => $art->maturity ?? null,
])->values(); ])->values();
$galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values();
return response()->json([ return response()->json([
'data' => $galleryItems, 'data' => $galleryItems,
'similarity_source' => $similaritySource, 'similarity_source' => $similaritySource,
@@ -303,7 +310,7 @@ final class SimilarArtworksPageController extends Controller
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [ return (object) $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'name' => $artwork->title, 'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '', 'content_type_name' => $primary?->contentType?->name ?? '',
@@ -328,6 +335,6 @@ final class SimilarArtworksPageController extends Controller
'slug' => $artwork->slug ?? '', 'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null, 'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null, 'height' => $artwork->height ?? null,
]; ], $artwork, request()->user());
} }
} }

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\Tag; use App\Models\Tag;
use App\Services\ArtworkSearchService; use App\Services\ArtworkSearchService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Tags\TagDiscoveryService; use App\Services\Tags\TagDiscoveryService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ final class TagController extends Controller
{ {
public function __construct( public function __construct(
private readonly ArtworkSearchService $search, private readonly ArtworkSearchService $search,
private readonly ArtworkMaturityService $maturity,
private readonly TagDiscoveryService $tagDiscovery, private readonly TagDiscoveryService $tagDiscovery,
) {} ) {}
@@ -61,12 +63,12 @@ final class TagController extends Controller
]); ]);
// Map artworks into the lightweight shape expected by the gallery React component. // Map artworks into the lightweight shape expected by the gallery React component.
$galleryCollection = $artworks->getCollection()->map(function ($a) { $galleryCollection = collect($this->maturity->filterPayloadItems($artworks->getCollection()->map(function ($a) use ($request): array {
$primaryCategory = $a->categories->sortBy('sort_order')->first(); $primaryCategory = $a->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($a, 'md'); $present = ThumbnailPresenter::present($a, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64); $avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
return (object) [ return $this->maturity->decoratePayload([
'id' => $a->id, 'id' => $a->id,
'name' => $a->title ?? ($a->name ?? null), 'name' => $a->title ?? ($a->name ?? null),
'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '',
@@ -82,8 +84,10 @@ final class TagController extends Controller
'width' => $a->width ?? null, 'width' => $a->width ?? null,
'height' => $a->height ?? null, 'height' => $a->height ?? null,
'slug' => $a->slug ?? null, 'slug' => $a->slug ?? null,
]; ], $a, $request->user());
})->values(); })->values()->all(), $request->user()))
->map(static fn (array $item): object => (object) $item)
->values();
// Replace paginator collection with the gallery-shaped collection so // Replace paginator collection with the gallery-shaped collection so
// the gallery.index blade will generate the expected JSON payload. // the gallery.index blade will generate the expected JSON payload.

View File

@@ -21,7 +21,7 @@ class ConditionalCors
} }
// Fallback to env if config wasn't populated for some reason. // Fallback to env if config wasn't populated for some reason.
$enabled = env('CP_ENABLE_CORS', true); $enabled = env('CP_ENABLE_CORS', false);
if (! $enabled) { if (! $enabled) {
return $next($request); return $next($request);

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureArtworkMaturityAccess
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user('controlpanel') !== null) {
return $next($request);
}
$user = $request->user();
$role = strtolower((string) ($user?->role ?? ''));
if (in_array($role, ['admin', 'moderator'], true)) {
return $next($request);
}
if (! $request->expectsJson() && route('cp.login', absolute: false) !== null) {
return redirect()->route('cp.login');
}
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
}

View File

@@ -21,7 +21,7 @@ final class ArtworkTagsStoreRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'tags' => 'required|array|max:15', 'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'required|string|max:64', 'tags.*' => 'required|string|max:64',
]; ];
} }

View File

@@ -21,7 +21,7 @@ final class ArtworkTagsUpdateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'tags' => 'required|array|max:15', 'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30),
'tags.*' => 'required|string|max:64', 'tags.*' => 'required|string|max:64',
]; ];
} }

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateContentPreferencesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'mature_content_visibility' => ['required', 'in:hide,blur,show'],
'mature_content_warning_enabled' => ['required', 'boolean'],
];
}
}

View File

@@ -21,7 +21,7 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])], 'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
'description' => ['sometimes', 'nullable', 'string', 'max:5000'], 'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])], 'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
'tags' => ['sometimes', 'array', 'max:15'], 'tags' => ['sometimes', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'], 'tags.*' => ['string', 'max:64'],
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])], 'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'], 'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],

View File

@@ -1,6 +1,7 @@
<?php <?php
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
use App\Services\ThumbnailService; use App\Services\ThumbnailService;
@@ -77,7 +78,7 @@ class ArtworkListResource extends JsonResource
] ]
: null; : null;
return [ return app(ArtworkMaturityService::class)->decoratePayload([
'id' => $artId, 'id' => $artId,
'slug' => $slugVal, 'slug' => $slugVal,
'title' => $decode($get('title')), 'title' => $decode($get('title')),
@@ -106,6 +107,6 @@ class ArtworkListResource extends JsonResource
'direct' => $directUrl, 'direct' => $directUrl,
'canonical' => $webUrl ?? $directUrl, 'canonical' => $webUrl ?? $directUrl,
], ],
]; ], $this->resource, $request->user());
} }
} }

View File

@@ -1,7 +1,9 @@
<?php <?php
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Services\ArtworkEvolutionService;
use App\Services\ContentSanitizer; use App\Services\ContentSanitizer;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -97,11 +99,11 @@ class ArtworkResource extends JsonResource
->exists(); ->exists();
} }
if (Schema::hasTable('artwork_awards')) { if (Schema::hasTable('artwork_medals')) {
$viewerAward = DB::table('artwork_awards') $viewerAward = DB::table('artwork_medals')
->where('user_id', $viewerId) ->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id) ->where('artwork_id', (int) $this->id)
->value('medal'); ->value('medal_type');
} }
} }
@@ -225,8 +227,24 @@ class ArtworkResource extends JsonResource
'silver' => (int) ($this->awardStat?->silver_count ?? 0), 'silver' => (int) ($this->awardStat?->silver_count ?? 0),
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0), 'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
'score' => (int) ($this->awardStat?->score_total ?? 0), 'score' => (int) ($this->awardStat?->score_total ?? 0),
'score_7d' => (int) ($this->awardStat?->score_7d ?? 0),
'score_30d' => (int) ($this->awardStat?->score_30d ?? 0),
'last_medaled_at' => optional($this->awardStat?->last_medaled_at)->toIsoString(),
'viewer_award' => $viewerAward, 'viewer_award' => $viewerAward,
], ],
'medals' => [
'gold' => (int) ($this->awardStat?->gold_count ?? 0),
'silver' => (int) ($this->awardStat?->silver_count ?? 0),
'bronze' => (int) ($this->awardStat?->bronze_count ?? 0),
'score' => (int) ($this->awardStat?->score_total ?? 0),
'score_7d' => (int) ($this->awardStat?->score_7d ?? 0),
'score_30d' => (int) ($this->awardStat?->score_30d ?? 0),
'last_medaled_at' => optional($this->awardStat?->last_medaled_at)->toIsoString(),
'current_user_medal' => $viewerAward,
'viewer_award' => $viewerAward,
],
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
'categories' => $this->categories->map(fn ($category) => [ 'categories' => $this->categories->map(fn ($category) => [
'id' => (int) $category->id, 'id' => (int) $category->id,
'slug' => (string) $category->slug, 'slug' => (string) $category->slug,

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VisionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
final class DetectArtworkMaturityJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 20;
public function __construct(
private readonly int $artworkId,
private readonly string $hash,
) {
$queue = (string) config('maturity.ai.queue', config('vision.queue', 'default'));
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function backoff(): array
{
return [5, 30, 120];
}
public function handle(VisionService $vision, ArtworkMaturityService $maturity): void
{
if (! $vision->isEnabled()) {
return;
}
$artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId);
if (! $artwork) {
return;
}
$detailed = $vision->analyzeArtworkMaturityDetailed($artwork, $this->hash);
$assessment = (array) ($detailed['assessment'] ?? []);
if ($assessment === []) {
$assessment = [
'status' => ArtworkMaturityService::AI_STATUS_FAILED,
'advisory' => 'Vision maturity analysis returned no assessment payload.',
];
}
$maturity->applyAiAssessment($artwork->fresh(), $assessment);
}
public function failed(\Throwable $exception): void
{
$artwork = Artwork::query()->find($this->artworkId);
if ($artwork) {
app(ArtworkMaturityService::class)->applyAiAssessment($artwork, [
'status' => ArtworkMaturityService::AI_STATUS_FAILED,
'advisory' => $exception->getMessage(),
]);
}
Log::warning('DetectArtworkMaturityJob failed', [
'artwork_id' => $this->artworkId,
'hash' => $this->hash,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Jobs;
use App\Services\Uploads\UploadPipelineService; use App\Services\Uploads\UploadPipelineService;
use App\Jobs\AnalyzeArtworkAiAssistJob; use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\AutoTagArtworkJob; use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob; use App\Jobs\GenerateArtworkEmbeddingJob;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -48,6 +49,7 @@ final class GenerateDerivativesJob implements ShouldQueue
// Auto-tagging is async and must never block publish. // Auto-tagging is async and must never block publish.
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit(); AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit(); AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
} }

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\Profile\CreatorJourneyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RebuildCreatorJourneyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 300;
/**
* @param array<int> $userIds
*/
public function __construct(public readonly array $userIds)
{
}
public function handle(CreatorJourneyService $journeys): void
{
foreach ($this->userIds as $userId) {
$journeys->rebuildForUser((int) $userId);
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\ArtworkMedalService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateArtworkMedalStatsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public readonly int $artworkId)
{
}
public function handle(ArtworkMedalService $medals): void
{
$medals->refreshArtworkMedalState($this->artworkId);
}
}

View File

@@ -52,6 +52,9 @@ class Artwork extends Model
'hash', 'hash',
'file_ext', 'file_ext',
'thumb_ext', 'thumb_ext',
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
'file_size', 'file_size',
'mime_type', 'mime_type',
'width', 'width',
@@ -60,6 +63,27 @@ class Artwork extends Model
'visibility', 'visibility',
'is_approved', 'is_approved',
'is_mature', 'is_mature',
'maturity_level',
'maturity_source',
'maturity_status',
'maturity_ai_score',
'maturity_ai_labels',
'maturity_ai_label',
'maturity_ai_confidence',
'maturity_ai_model',
'maturity_ai_threshold_used',
'maturity_ai_analysis_time_ms',
'maturity_ai_action_hint',
'maturity_ai_advisory',
'maturity_ai_status',
'maturity_ai_detected_at',
'maturity_declared_at',
'maturity_flagged_at',
'maturity_flag_reason',
'maturity_reviewed_by',
'maturity_reviewed_at',
'maturity_reviewer_note',
'maturity_mismatch_count',
'published_at', 'published_at',
'hash', 'hash',
'thumb_ext', 'thumb_ext',
@@ -90,7 +114,28 @@ class Artwork extends Model
'visibility' => 'string', 'visibility' => 'string',
'is_approved' => 'boolean', 'is_approved' => 'boolean',
'is_mature' => 'boolean', 'is_mature' => 'boolean',
'maturity_level' => 'string',
'maturity_source' => 'string',
'maturity_status' => 'string',
'maturity_ai_score' => 'float',
'maturity_ai_labels' => 'array',
'maturity_ai_label' => 'string',
'maturity_ai_confidence' => 'float',
'maturity_ai_model' => 'string',
'maturity_ai_threshold_used' => 'float',
'maturity_ai_analysis_time_ms' => 'integer',
'maturity_ai_action_hint' => 'string',
'maturity_ai_advisory' => 'string',
'maturity_ai_status' => 'string',
'maturity_ai_detected_at' => 'datetime',
'maturity_declared_at' => 'datetime',
'maturity_flagged_at' => 'datetime',
'maturity_reviewed_at' => 'datetime',
'maturity_mismatch_count' => 'integer',
'has_missing_thumbnails' => 'boolean',
'published_at' => 'datetime', 'published_at' => 'datetime',
'missing_thumbnail_variants_json' => 'array',
'thumbnails_checked_at' => 'datetime',
'published_as_type' => 'string', 'published_as_type' => 'string',
'published_as_id' => 'integer', 'published_as_id' => 'integer',
'publish_at' => 'datetime', 'publish_at' => 'datetime',
@@ -184,6 +229,11 @@ class Artwork extends Model
return $this->belongsTo(Group::class); return $this->belongsTo(Group::class);
} }
public function maturityAuditFinding(): HasOne
{
return $this->hasOne(ArtworkMaturityAuditFinding::class);
}
public function uploadedBy(): BelongsTo public function uploadedBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'uploaded_by_user_id'); return $this->belongsTo(User::class, 'uploaded_by_user_id');
@@ -297,12 +347,31 @@ class Artwork extends Model
return $this->hasMany(ArtworkAward::class); return $this->hasMany(ArtworkAward::class);
} }
public function medals(): HasMany
{
return $this->hasMany(ArtworkMedal::class, 'artwork_id');
}
/** All file versions for this artwork (oldest first). */ /** All file versions for this artwork (oldest first). */
public function versions(): HasMany public function versions(): HasMany
{ {
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number'); return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
} }
public function outgoingEvolutionRelations(): HasMany
{
return $this->hasMany(ArtworkRelation::class, 'source_artwork_id')
->orderBy('sort_order')
->orderBy('id');
}
public function incomingEvolutionRelations(): HasMany
{
return $this->hasMany(ArtworkRelation::class, 'target_artwork_id')
->orderByDesc('updated_at')
->orderByDesc('id');
}
/** The currently active version record. */ /** The currently active version record. */
public function currentVersion(): BelongsTo public function currentVersion(): BelongsTo
{ {
@@ -319,6 +388,11 @@ class Artwork extends Model
return $this->hasOne(ArtworkAwardStat::class); return $this->hasOne(ArtworkAwardStat::class);
} }
public function medalStats(): HasOne
{
return $this->hasOne(ArtworkMedalStat::class, 'artwork_id');
}
/** /**
* Build the Meilisearch document for this artwork. * Build the Meilisearch document for this artwork.
* Includes all fields required for search, filtering, sorting, and display. * Includes all fields required for search, filtering, sorting, and display.
@@ -385,12 +459,20 @@ class Artwork extends Model
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0, 'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
'is_public' => (bool) $this->is_public, 'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved, 'is_approved' => (bool) $this->is_approved,
'is_mature' => (bool) $this->is_mature,
'is_mature_effective' => (bool) ($this->is_mature || $this->maturity_level === 'mature' || $this->maturity_status === 'suspected'),
'maturity_level' => (string) ($this->maturity_level ?? 'safe'),
'maturity_status' => (string) ($this->maturity_status ?? 'clear'),
'has_missing_thumbnails' => (bool) ($this->has_missing_thumbnails ?? false),
'missing_thumbnail_rank' => (int) (($this->has_missing_thumbnails ?? false) ? 1 : 0),
// ── Trending / discovery fields ──────────────────────────────────── // ── Trending / discovery fields ────────────────────────────────────
'trending_score_1h' => (float) ($this->trending_score_1h ?? 0), 'trending_score_1h' => (float) ($this->trending_score_1h ?? 0),
'trending_score_24h' => (float) ($this->trending_score_24h ?? 0), 'trending_score_24h' => (float) ($this->trending_score_24h ?? 0),
'trending_score_7d' => (float) ($this->trending_score_7d ?? 0), 'trending_score_7d' => (float) ($this->trending_score_7d ?? 0),
'favorites_count' => (int) ($stat?->favorites ?? 0), 'favorites_count' => (int) ($stat?->favorites ?? 0),
'awards_received_count' => (int) ($awardStat?->score_total ?? 0), 'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
'awards_score_7d' => (int) ($awardStat?->score_7d ?? 0),
'awards_score_30d' => (int) ($awardStat?->score_30d ?? 0),
'downloads_count' => (int) ($stat?->downloads ?? 0), 'downloads_count' => (int) ($stat?->downloads ?? 0),
// ── Ranking V2 fields ─────────────────────────────────────────────── // ── Ranking V2 fields ───────────────────────────────────────────────
'ranking_score' => (float) ($stat?->ranking_score ?? 0), 'ranking_score' => (float) ($stat?->ranking_score ?? 0),
@@ -404,6 +486,8 @@ class Artwork extends Model
'silver' => $awardStat?->silver_count ?? 0, 'silver' => $awardStat?->silver_count ?? 0,
'bronze' => $awardStat?->bronze_count ?? 0, 'bronze' => $awardStat?->bronze_count ?? 0,
'score' => $awardStat?->score_total ?? 0, 'score' => $awardStat?->score_total ?? 0,
'score_7d' => $awardStat?->score_7d ?? 0,
'score_30d' => $awardStat?->score_30d ?? 0,
], ],
]; ];
} }
@@ -432,6 +516,32 @@ class Artwork extends Model
->where("{$table}.published_at", '<=', now()); ->where("{$table}.published_at", '<=', now());
} }
public function scopeSafeForGeneralAudience(Builder $query): Builder
{
$table = $this->getTable();
return $query
->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0')
->whereRaw("COALESCE(" . $table . ".maturity_status, 'clear') != ?", ['suspected']);
}
public function scopeWithoutMissingThumbnails(Builder $query): Builder
{
$table = $this->getTable();
return $query->where(function (Builder $thumbnailQuery) use ($table): void {
$thumbnailQuery->whereNull("{$table}.has_missing_thumbnails")
->orWhere("{$table}.has_missing_thumbnails", false);
});
}
public function scopeOrderMissingThumbnailsLast(Builder $query): Builder
{
$table = $this->getTable();
return $query->orderByRaw("CASE WHEN {$table}.has_missing_thumbnails = 1 THEN 1 ELSE 0 END ASC");
}
public function getRouteKeyName(): string public function getRouteKeyName(): string
{ {
return 'slug'; return 'slug';

View File

@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkAward extends Model class ArtworkAward extends Model
{ {
protected $table = 'artwork_awards'; protected $table = 'artwork_medals';
protected $fillable = [ protected $fillable = [
'artwork_id', 'artwork_id',
'user_id', 'user_id',
'medal_type',
'medal', 'medal',
'weight', 'weight',
]; ];
@@ -27,11 +28,26 @@ class ArtworkAward extends Model
public const MEDALS = ['gold', 'silver', 'bronze']; public const MEDALS = ['gold', 'silver', 'bronze'];
public const WEIGHTS = [ public const WEIGHTS = [
'gold' => 3, 'gold' => 5,
'silver' => 2, 'silver' => 3,
'bronze' => 1, 'bronze' => 1,
]; ];
public static function weightFor(string $medal): int
{
return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0);
}
/**
* @return array<string, int>
*/
public static function weights(): array
{
return collect(self::MEDALS)
->mapWithKeys(fn (string $medal): array => [$medal => self::weightFor($medal)])
->all();
}
public function artwork(): BelongsTo public function artwork(): BelongsTo
{ {
return $this->belongsTo(Artwork::class); return $this->belongsTo(Artwork::class);
@@ -41,4 +57,14 @@ class ArtworkAward extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function getMedalAttribute(): ?string
{
return $this->attributes['medal_type'] ?? null;
}
public function setMedalAttribute(?string $value): void
{
$this->attributes['medal_type'] = $value;
}
} }

View File

@@ -9,11 +9,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkAwardStat extends Model class ArtworkAwardStat extends Model
{ {
protected $table = 'artwork_award_stats'; protected $table = 'artwork_medal_stats';
public $primaryKey = 'artwork_id'; public $primaryKey = 'artwork_id';
public $incrementing = false; public $incrementing = false;
public $timestamps = false; public $timestamps = true;
protected $fillable = [ protected $fillable = [
'artwork_id', 'artwork_id',
@@ -21,6 +21,10 @@ class ArtworkAwardStat extends Model
'silver_count', 'silver_count',
'bronze_count', 'bronze_count',
'score_total', 'score_total',
'score_7d',
'score_30d',
'last_medaled_at',
'created_at',
'updated_at', 'updated_at',
]; ];
@@ -30,6 +34,10 @@ class ArtworkAwardStat extends Model
'silver_count' => 'integer', 'silver_count' => 'integer',
'bronze_count' => 'integer', 'bronze_count' => 'integer',
'score_total' => 'integer', 'score_total' => 'integer',
'score_7d' => 'integer',
'score_30d' => 'integer',
'last_medaled_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];

View File

@@ -3,21 +3,33 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ArtworkFeature extends Model class ArtworkFeature extends Model
{ {
protected $table = 'artwork_features'; use SoftDeletes;
public $timestamps = false; protected $table = 'artwork_features';
protected $fillable = [ protected $fillable = [
'artwork_id', 'artwork_id',
'type', 'type',
'featured_at', 'featured_at',
'expires_at',
'priority',
'label',
'note',
'is_active',
'force_hero',
'created_by',
]; ];
protected $casts = [ protected $casts = [
'featured_at' => 'datetime', 'featured_at' => 'datetime',
'expires_at' => 'datetime',
'priority' => 'integer',
'is_active' => 'boolean',
'force_hero' => 'boolean',
]; ];
public function artwork(): BelongsTo public function artwork(): BelongsTo

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkMaturityAuditFinding extends Model
{
use HasFactory;
public const STATUS_OPEN = 'open';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_CLEARED = 'cleared';
protected $fillable = [
'artwork_id',
'status',
'thumbnail_variant',
'ai_label',
'ai_confidence',
'ai_score',
'ai_labels',
'ai_model',
'ai_threshold_used',
'ai_analysis_time_ms',
'ai_action_hint',
'ai_status',
'ai_advisory',
'detected_at',
'last_scanned_at',
'resolution_action',
'resolution_note',
'resolved_by',
'resolved_at',
];
protected $casts = [
'ai_confidence' => 'float',
'ai_score' => 'float',
'ai_labels' => 'array',
'ai_threshold_used' => 'float',
'ai_analysis_time_ms' => 'integer',
'detected_at' => 'datetime',
'last_scanned_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function resolver(): BelongsTo
{
return $this->belongsTo(User::class, 'resolved_by');
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models;
class ArtworkMedal extends ArtworkAward
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models;
class ArtworkMedalStat extends ArtworkAwardStat
{
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkRelation extends Model
{
use HasFactory;
public const TYPE_REMAKE_OF = 'remake_of';
public const TYPE_REMASTER_OF = 'remaster_of';
public const TYPE_REVISION_OF = 'revision_of';
public const TYPE_INSPIRED_BY = 'inspired_by';
public const TYPE_VARIATION_OF = 'variation_of';
protected $fillable = [
'source_artwork_id',
'target_artwork_id',
'relation_type',
'note',
'sort_order',
'created_by_user_id',
];
protected $casts = [
'sort_order' => 'integer',
];
public function sourceArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'source_artwork_id');
}
public function targetArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'target_artwork_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

View File

@@ -17,6 +17,27 @@ class Category extends Model
protected $casts = ['is_active' => 'boolean']; protected $casts = ['is_active' => 'boolean'];
public function getNameAttribute(?string $value): ?string
{
return self::decodeHtmlEntities($value);
}
public function setNameAttribute(?string $value): void
{
$normalized = self::decodeHtmlEntities($value);
$this->attributes['name'] = $normalized !== null ? trim($normalized) : null;
}
public function getDescriptionAttribute(?string $value): ?string
{
return self::decodeHtmlEntities($value);
}
public function setDescriptionAttribute(?string $value): void
{
$this->attributes['description'] = self::decodeHtmlEntities($value);
}
/** /**
* Ensure slug is always lowercase and valid before saving. * Ensure slug is always lowercase and valid before saving.
*/ */
@@ -159,4 +180,25 @@ class Category extends Model
return $category; return $category;
} }
private static function decodeHtmlEntities(?string $value): ?string
{
if ($value === null) {
return null;
}
$decoded = $value;
for ($index = 0; $index < 5; $index++) {
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($next === $decoded) {
break;
}
$decoded = $next;
}
return $decoded;
}
} }

View File

@@ -653,19 +653,26 @@ class Collection extends Model
return $this->isPubliclyAccessible(); return $this->isPubliclyAccessible();
} }
public function resolvedCoverArtwork(bool $publicOnly = false): ?Artwork public function resolvedCoverArtwork(bool $publicOnly = false, bool $hideMature = false): ?Artwork
{ {
$cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first(); $cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first();
if ($cover && (! $publicOnly || $this->artworkIsPubliclyVisible($cover))) { if ($cover && $this->artworkMatchesCoverVisibility($cover, $publicOnly, $hideMature)) {
return $cover; return $cover;
} }
$relation = $publicOnly ? 'publicArtworks' : 'artworks'; $relation = $publicOnly ? 'publicArtworks' : 'artworks';
$artworks = $this->relationLoaded($relation) $artworks = $this->relationLoaded($relation)
? $this->getRelation($relation) ? $this->getRelation($relation)
: $this->{$relation}()->limit(1)->get(); : $this->{$relation}()
->when($hideMature, function ($query): void {
$query->whereRaw('COALESCE(artworks.is_mature, 0) = 0')
->whereRaw("COALESCE(artworks.maturity_status, 'clear') != ?", ['suspected']);
})
->limit(1)
->get();
return $artworks->first(); return $artworks
->first(fn (Artwork $artwork): bool => $this->artworkMatchesCoverVisibility($artwork, $publicOnly, $hideMature));
} }
public function syncArtworksCount(): void public function syncArtworksCount(): void
@@ -712,4 +719,18 @@ class Collection extends Model
&& $artwork->published_at !== null && $artwork->published_at !== null
&& $artwork->published_at->lte(now()); && $artwork->published_at->lte(now());
} }
private function artworkMatchesCoverVisibility(Artwork $artwork, bool $publicOnly, bool $hideMature): bool
{
if ($publicOnly && ! $this->artworkIsPubliclyVisible($artwork)) {
return false;
}
if (! $hideMature) {
return true;
}
return ! (bool) $artwork->is_mature
&& (string) ($artwork->maturity_status ?? 'clear') !== 'suspected';
}
} }

View File

@@ -10,10 +10,11 @@ use App\Models\Artwork;
class ContentType extends Model class ContentType extends Model
{ {
protected $fillable = ['name','slug','description','order']; protected $fillable = ['name','slug','description','order','hide_from_menu','mascot_path','cover_art_path'];
protected $casts = [ protected $casts = [
'order' => 'integer', 'order' => 'integer',
'hide_from_menu' => 'boolean',
]; ];
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
@@ -21,6 +22,11 @@ class ContentType extends Model
return $query->orderBy('order')->orderBy('name')->orderBy('id'); return $query->orderBy('order')->orderBy('name')->orderBy('id');
} }
public function scopeVisibleInToolbar(EloquentBuilder $query): EloquentBuilder
{
return $query->where('hide_from_menu', false);
}
public function categories(): HasMany public function categories(): HasMany
{ {
return $this->hasMany(Category::class); return $this->hasMany(Category::class);
@@ -31,6 +37,11 @@ class ContentType extends Model
return $this->categories()->whereNull('parent_id'); return $this->categories()->whereNull('parent_id');
} }
public function slugHistories(): HasMany
{
return $this->hasMany(ContentTypeSlugHistory::class);
}
/** /**
* Return an Eloquent builder for Artworks that belong to this content type. * Return an Eloquent builder for Artworks that belong to this content type.
* This traverses the pivot `artwork_category` via the `categories` relation. * This traverses the pivot `artwork_category` via the `categories` relation.
@@ -43,8 +54,33 @@ class ContentType extends Model
}); });
} }
public function getMascotUrlAttribute(): ?string
{
return $this->resolveAssetUrl($this->mascot_path);
}
public function getCoverArtUrlAttribute(): ?string
{
return $this->resolveAssetUrl($this->cover_art_path);
}
public function getRouteKeyName(): string public function getRouteKeyName(): string
{ {
return 'slug'; return 'slug';
} }
private function resolveAssetUrl(?string $path): ?string
{
$path = trim((string) $path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
} }

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentTypeSlugHistory extends Model
{
protected $fillable = [
'content_type_id',
'old_slug',
];
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
}

45
app/Models/CreatorEra.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $user_id
* @property string $era_type
* @property string $title
* @property string|null $description
* @property \Carbon\Carbon $starts_at
* @property \Carbon\Carbon|null $ends_at
* @property bool $is_current
* @property array|null $metadata
*/
final class CreatorEra extends Model
{
protected $fillable = [
'user_id',
'era_type',
'title',
'description',
'starts_at',
'ends_at',
'is_current',
'metadata',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'is_current' => 'boolean',
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CreatorMilestone extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'type',
'occurred_at',
'occurred_year',
'related_artwork_id',
'is_public',
'priority',
'payload_json',
'computed_at',
];
protected $casts = [
'occurred_at' => 'datetime',
'occurred_year' => 'integer',
'related_artwork_id' => 'integer',
'is_public' => 'boolean',
'priority' => 'integer',
'payload_json' => 'array',
'computed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'related_artwork_id');
}
}

View File

@@ -124,6 +124,11 @@ class User extends Authenticatable
return $this->hasMany(Artwork::class); return $this->hasMany(Artwork::class);
} }
public function givenArtworkMedals(): HasMany
{
return $this->hasMany(ArtworkMedal::class, 'user_id');
}
public function collections(): HasMany public function collections(): HasMany
{ {
return $this->hasMany(Collection::class)->latest('updated_at'); return $this->hasMany(Collection::class)->latest('updated_at');

View File

@@ -35,6 +35,8 @@ class UserProfile extends Model
'follower_notifications', 'follower_notifications',
'comment_notifications', 'comment_notifications',
'newsletter', 'newsletter',
'mature_content_visibility',
'mature_content_warning_enabled',
]; ];
protected $casts = [ protected $casts = [
@@ -46,6 +48,7 @@ class UserProfile extends Model
'follower_notifications' => 'boolean', 'follower_notifications' => 'boolean',
'comment_notifications' => 'boolean', 'comment_notifications' => 'boolean',
'newsletter' => 'boolean', 'newsletter' => 'boolean',
'mature_content_warning_enabled' => 'boolean',
]; ];
public $timestamps = true; public $timestamps = true;

View File

@@ -4,15 +4,14 @@ declare(strict_types=1);
namespace App\Observers; namespace App\Observers;
use App\Jobs\RecalculateArtworkMedalStatsJob;
use App\Models\ArtworkAward; use App\Models\ArtworkAward;
use App\Services\ArtworkAwardService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class ArtworkAwardObserver class ArtworkAwardObserver
{ {
public function __construct( public function __construct(
private readonly ArtworkAwardService $service,
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
) {} ) {}
@@ -36,12 +35,7 @@ class ArtworkAwardObserver
private function refresh(ArtworkAward $award): void private function refresh(ArtworkAward $award): void
{ {
$this->service->recalcStats($award->artwork_id); RecalculateArtworkMedalStatsJob::dispatchSync((int) $award->artwork_id);
$artwork = $award->artwork;
if ($artwork) {
$this->service->syncToSearch($artwork);
}
} }
private function trackCreatorStats(ArtworkAward $award, int $delta): void private function trackCreatorStats(ArtworkAward $award, int $delta): void

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Observers; namespace App\Observers;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use App\Services\ArtworkStatsService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Services\UserMentionSyncService; use App\Services\UserMentionSyncService;
use App\Services\XPService; use App\Services\XPService;
@@ -17,7 +19,9 @@ use Illuminate\Support\Facades\DB;
class ArtworkCommentObserver class ArtworkCommentObserver
{ {
public function __construct( public function __construct(
private readonly ArtworkStatsService $artworkStats,
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly CreatorJourneyService $journeys,
private readonly UserMentionSyncService $mentionSync, private readonly UserMentionSyncService $mentionSync,
private readonly XPService $xp, private readonly XPService $xp,
) {} ) {}
@@ -27,6 +31,7 @@ class ArtworkCommentObserver
$creatorId = $this->creatorId($comment->artwork_id); $creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) { if ($creatorId) {
$this->userStats->incrementCommentsReceived($creatorId); $this->userStats->incrementCommentsReceived($creatorId);
$this->journeys->requestRebuild($creatorId);
} }
// The commenter is "active" // The commenter is "active"
@@ -34,6 +39,7 @@ class ArtworkCommentObserver
$this->userStats->setLastActiveAt($comment->user_id); $this->userStats->setLastActiveAt($comment->user_id);
$this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork'); $this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork');
$this->mentionSync->syncForComment($comment); $this->mentionSync->syncForComment($comment);
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
} }
public function updated(ArtworkComment $comment): void public function updated(ArtworkComment $comment): void
@@ -49,9 +55,11 @@ class ArtworkCommentObserver
$creatorId = $this->creatorId($comment->artwork_id); $creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) { if ($creatorId) {
$this->userStats->decrementCommentsReceived($creatorId); $this->userStats->decrementCommentsReceived($creatorId);
$this->journeys->requestRebuild($creatorId);
} }
$this->mentionSync->deleteForComment((int) $comment->id); $this->mentionSync->deleteForComment((int) $comment->id);
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
} }
/** Hard delete after soft delete — already decremented; nothing to do. */ /** Hard delete after soft delete — already decremented; nothing to do. */
@@ -63,15 +71,23 @@ class ArtworkCommentObserver
$creatorId = $this->creatorId($comment->artwork_id); $creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) { if ($creatorId) {
$this->userStats->decrementCommentsReceived($creatorId); $this->userStats->decrementCommentsReceived($creatorId);
$this->journeys->requestRebuild($creatorId);
} }
} }
$this->mentionSync->deleteForComment((int) $comment->id); $this->mentionSync->deleteForComment((int) $comment->id);
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
} }
public function restored(ArtworkComment $comment): void public function restored(ArtworkComment $comment): void
{ {
$this->mentionSync->syncForComment($comment); $this->mentionSync->syncForComment($comment);
$this->artworkStats->syncEngagementCounts((int) $comment->artwork_id);
$creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) {
$this->journeys->requestRebuild($creatorId);
}
} }
private function creatorId(int $artworkId): ?int private function creatorId(int $artworkId): ?int

View File

@@ -7,6 +7,7 @@ namespace App\Observers;
use App\Jobs\RecComputeSimilarByBehaviorJob; use App\Jobs\RecComputeSimilarByBehaviorJob;
use App\Jobs\RecComputeSimilarHybridJob; use App\Jobs\RecComputeSimilarHybridJob;
use App\Models\ArtworkFavourite; use App\Models\ArtworkFavourite;
use App\Services\Profile\CreatorJourneyService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -18,6 +19,7 @@ class ArtworkFavouriteObserver
{ {
public function __construct( public function __construct(
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly CreatorJourneyService $journeys,
) {} ) {}
public function created(ArtworkFavourite $favourite): void public function created(ArtworkFavourite $favourite): void
@@ -25,6 +27,7 @@ class ArtworkFavouriteObserver
$creatorId = $this->creatorId($favourite->artwork_id); $creatorId = $this->creatorId($favourite->artwork_id);
if ($creatorId) { if ($creatorId) {
$this->userStats->incrementFavoritesReceived($creatorId); $this->userStats->incrementFavoritesReceived($creatorId);
$this->journeys->requestRebuild($creatorId);
} }
// §7.5 On-demand: recompute behavior similarity when artwork reaches threshold // §7.5 On-demand: recompute behavior similarity when artwork reaches threshold
@@ -36,6 +39,7 @@ class ArtworkFavouriteObserver
$creatorId = $this->creatorId($favourite->artwork_id); $creatorId = $this->creatorId($favourite->artwork_id);
if ($creatorId) { if ($creatorId) {
$this->userStats->decrementFavoritesReceived($creatorId); $this->userStats->decrementFavoritesReceived($creatorId);
$this->journeys->requestRebuild($creatorId);
} }
} }

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Artwork;
use App\Models\ArtworkFeature;
use App\Services\HomepageService;
use App\Services\Profile\CreatorJourneyService;
final class ArtworkFeatureObserver
{
public function __construct(
private readonly HomepageService $homepage,
private readonly CreatorJourneyService $journeys,
)
{
}
public function created(ArtworkFeature $feature): void
{
$this->homepage->clearFeaturedAndMedalCaches();
$this->queueCreatorRebuild($feature);
}
public function updated(ArtworkFeature $feature): void
{
$this->homepage->clearFeaturedAndMedalCaches();
$this->queueCreatorRebuild($feature);
}
public function deleted(ArtworkFeature $feature): void
{
$this->homepage->clearFeaturedAndMedalCaches();
$this->queueCreatorRebuild($feature);
}
public function restored(ArtworkFeature $feature): void
{
$this->homepage->clearFeaturedAndMedalCaches();
$this->queueCreatorRebuild($feature);
}
public function forceDeleted(ArtworkFeature $feature): void
{
$this->homepage->clearFeaturedAndMedalCaches();
$this->queueCreatorRebuild($feature);
}
private function queueCreatorRebuild(ArtworkFeature $feature): void
{
$artwork = $feature->relationLoaded('artwork')
? $feature->artwork
: Artwork::withTrashed()->find($feature->artwork_id);
if (! $artwork) {
return;
}
$this->journeys->requestRebuild((int) $artwork->user_id);
}
}

View File

@@ -10,8 +10,11 @@ use App\Jobs\RecComputeSimilarByTagsJob;
use App\Jobs\RecComputeSimilarHybridJob; use App\Jobs\RecComputeSimilarHybridJob;
use App\Jobs\Posts\AutoUploadPostJob; use App\Jobs\Posts\AutoUploadPostJob;
use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkSearchIndexer;
use App\Services\HomepageService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Services\XPService; use App\Services\XPService;
use Illuminate\Support\Facades\Cache;
/** /**
* Syncs artwork documents to Meilisearch on every relevant model event. * Syncs artwork documents to Meilisearch on every relevant model event.
@@ -25,6 +28,8 @@ class ArtworkObserver
private readonly ArtworkSearchIndexer $indexer, private readonly ArtworkSearchIndexer $indexer,
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly XPService $xp, private readonly XPService $xp,
private readonly HomepageService $homepage,
private readonly CreatorJourneyService $journeys,
) {} ) {}
/** New artwork created — index; bump uploadscount + last_upload_at. */ /** New artwork created — index; bump uploadscount + last_upload_at. */
@@ -33,6 +38,11 @@ class ArtworkObserver
$this->indexer->index($artwork); $this->indexer->index($artwork);
$this->userStats->incrementUploads($artwork->user_id); $this->userStats->incrementUploads($artwork->user_id);
$this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at); $this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at);
$this->journeys->requestRebuild((int) $artwork->user_id);
if ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null) {
$this->bumpExploreCacheVersion();
}
if ($artwork->published_at !== null) { if ($artwork->published_at !== null) {
$this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id); $this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id);
@@ -75,6 +85,18 @@ class ArtworkObserver
} }
} }
} }
if ($this->shouldClearFeaturedCaches($artwork)) {
$this->homepage->clearFeaturedAndMedalCaches();
}
if ($artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'deleted_at'])) {
$this->bumpExploreCacheVersion();
}
if ($artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'visibility', 'deleted_at', 'published_as_type', 'published_as_id'])) {
$this->journeys->requestRebuild((int) $artwork->user_id);
}
} }
/** Soft delete — remove from search and decrement uploads_count. */ /** Soft delete — remove from search and decrement uploads_count. */
@@ -82,12 +104,20 @@ class ArtworkObserver
{ {
$this->indexer->delete($artwork->id); $this->indexer->delete($artwork->id);
$this->userStats->decrementUploads($artwork->user_id); $this->userStats->decrementUploads($artwork->user_id);
$this->journeys->requestRebuild((int) $artwork->user_id);
$this->bumpExploreCacheVersion();
if ($artwork->features()->exists()) {
$this->homepage->clearFeaturedAndMedalCaches();
}
} }
/** Force delete — ensure removal from index; only decrement if NOT already soft-deleted. */ /** Force delete — ensure removal from index; only decrement if NOT already soft-deleted. */
public function forceDeleted(Artwork $artwork): void public function forceDeleted(Artwork $artwork): void
{ {
$this->indexer->delete($artwork->id); $this->indexer->delete($artwork->id);
$this->journeys->requestRebuild((int) $artwork->user_id);
$this->bumpExploreCacheVersion();
// If deleted_at was null the artwork was not soft-deleted before; // If deleted_at was null the artwork was not soft-deleted before;
// the deleted() event did NOT fire, so we decrement here. // the deleted() event did NOT fire, so we decrement here.
@@ -101,5 +131,25 @@ class ArtworkObserver
{ {
$this->indexer->index($artwork); $this->indexer->index($artwork);
$this->userStats->incrementUploads($artwork->user_id); $this->userStats->incrementUploads($artwork->user_id);
$this->journeys->requestRebuild((int) $artwork->user_id);
$this->bumpExploreCacheVersion();
if ($artwork->features()->exists()) {
$this->homepage->clearFeaturedAndMedalCaches();
}
}
private function bumpExploreCacheVersion(): void
{
Cache::forever('explore.cache.version', ((int) Cache::get('explore.cache.version', 1)) + 1);
}
private function shouldClearFeaturedCaches(Artwork $artwork): bool
{
if (! $artwork->wasChanged(['published_at', 'is_public', 'is_approved', 'deleted_at', 'has_missing_thumbnails'])) {
return false;
}
return $artwork->features()->exists();
} }
} }

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Observers;
use App\Models\ContentType;
use App\Models\ContentTypeSlugHistory;
use App\Services\ContentTypes\ContentTypeSlugResolver;
class ContentTypeObserver
{
public function created(ContentType $contentType): void
{
app(ContentTypeSlugResolver::class)->flushCaches();
}
public function updated(ContentType $contentType): void
{
if ($contentType->wasChanged('slug')) {
$oldSlug = strtolower(trim((string) $contentType->getOriginal('slug')));
$newSlug = strtolower(trim((string) $contentType->slug));
if ($oldSlug !== '' && $oldSlug !== $newSlug) {
ContentTypeSlugHistory::query()->updateOrCreate(
['old_slug' => $oldSlug],
['content_type_id' => $contentType->id],
);
}
}
app(ContentTypeSlugResolver::class)->flushCaches();
}
public function deleted(ContentType $contentType): void
{
app(ContentTypeSlugResolver::class)->flushCaches();
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\GroupReleaseContributor;
use App\Services\Profile\CreatorJourneyService;
final class GroupReleaseContributorObserver
{
public function __construct(private readonly CreatorJourneyService $journeys)
{
}
public function created(GroupReleaseContributor $contributor): void
{
$this->journeys->requestRebuild((int) $contributor->user_id);
}
public function updated(GroupReleaseContributor $contributor): void
{
$this->journeys->requestRebuild((int) $contributor->user_id);
if ($contributor->wasChanged('user_id')) {
$this->journeys->requestRebuild((int) $contributor->getOriginal('user_id'));
}
}
public function deleted(GroupReleaseContributor $contributor): void
{
$this->journeys->requestRebuild((int) $contributor->user_id);
}
public function forceDeleted(GroupReleaseContributor $contributor): void
{
$this->journeys->requestRebuild((int) $contributor->user_id);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\GroupRelease;
use App\Services\Profile\CreatorJourneyService;
final class GroupReleaseObserver
{
public function __construct(private readonly CreatorJourneyService $journeys)
{
}
public function created(GroupRelease $release): void
{
$this->queueAffectedUsers($release);
}
public function updated(GroupRelease $release): void
{
if (! $release->wasChanged(['status', 'visibility', 'released_at', 'published_at', 'deleted_at', 'group_id'])) {
return;
}
$this->queueAffectedUsers($release);
}
public function deleted(GroupRelease $release): void
{
$this->queueAffectedUsers($release);
}
public function restored(GroupRelease $release): void
{
$this->queueAffectedUsers($release);
}
public function forceDeleted(GroupRelease $release): void
{
$this->queueAffectedUsers($release);
}
private function queueAffectedUsers(GroupRelease $release): void
{
$userIds = $release->contributorLinks()
->pluck('user_id')
->filter()
->map(fn ($userId): int => (int) $userId)
->unique()
->values();
foreach ($userIds as $userId) {
$this->journeys->requestRebuild((int) $userId);
}
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Policies; namespace App\Policies;
use Illuminate\Auth\Access\Response;
use App\Models\ArtworkAward; use App\Models\ArtworkAward;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\User; use App\Models\User;
@@ -27,14 +28,26 @@ class ArtworkAwardPolicy
* that isn't their own. * that isn't their own.
* Returns false ( 403 or 404 based on caller) when the check fails. * Returns false ( 403 or 404 based on caller) when the check fails.
*/ */
public function award(User $user, Artwork $artwork): bool public function award(User $user, Artwork $artwork): Response
{ {
if (! config('artwork_medals.enabled', true)) {
return Response::deny('Artwork medals are currently disabled.');
}
if (! $artwork->is_public || ! $artwork->is_approved) { if (! $artwork->is_public || ! $artwork->is_approved) {
return false; return Response::deny('This artwork is not eligible for medals.');
}
if ($artwork->deleted_at !== null) {
return Response::deny('This artwork is no longer available for medals.');
}
if ($artwork->published_at === null || $artwork->published_at->isFuture()) {
return Response::deny('This artwork is not published yet.');
} }
if ($artwork->user_id === $user->id) { if ($artwork->user_id === $user->id) {
return false; return Response::deny('You cannot medal your own artwork.');
} }
return $this->accountIsMature($user); return $this->accountIsMature($user);
@@ -58,12 +71,28 @@ class ArtworkAwardPolicy
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private function accountIsMature(User $user): bool private function accountIsMature(User $user): Response
{ {
if (! $user->created_at) { if ((bool) config('artwork_medals.require_verified_email', true)) {
return true; // cannot verify — allow $isVerified = method_exists($user, 'hasVerifiedEmail')
? $user->hasVerifiedEmail()
: ! empty($user->email_verified_at);
if (! $isVerified) {
return Response::deny('Verify your email address before giving medals.');
}
} }
return $user->created_at->diffInDays(now()) >= 7; if (! $user->created_at) {
return Response::allow(); // cannot verify — allow
}
$minimumAgeHours = (int) config('artwork_medals.minimum_account_age_hours', 24);
if ($user->created_at->diffInHours(now()) < $minimumAgeHours) {
return Response::deny("Your account must be at least {$minimumAgeHours} hours old before giving medals.");
}
return Response::allow();
} }
} }

View File

@@ -10,15 +10,26 @@ use Illuminate\Support\ServiceProvider;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkAward; use App\Models\ArtworkAward;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use App\Models\ArtworkFeature;
use App\Models\ArtworkFavourite; use App\Models\ArtworkFavourite;
use App\Models\ArtworkMedal;
use App\Models\ArtworkReaction; use App\Models\ArtworkReaction;
use App\Models\ContentType;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Observers\ArtworkAwardObserver; use App\Observers\ArtworkAwardObserver;
use App\Observers\ArtworkCommentObserver; use App\Observers\ArtworkCommentObserver;
use App\Observers\ArtworkFeatureObserver;
use App\Observers\ArtworkFavouriteObserver; use App\Observers\ArtworkFavouriteObserver;
use App\Observers\ArtworkObserver; use App\Observers\ArtworkObserver;
use App\Observers\ArtworkReactionObserver; use App\Observers\ArtworkReactionObserver;
use App\Observers\ContentTypeObserver;
use App\Observers\GroupReleaseContributorObserver;
use App\Observers\GroupReleaseObserver;
use App\Services\Upload\Contracts\UploadDraftServiceInterface; use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use App\Services\Upload\UploadDraftService; use App\Services\Upload\UploadDraftService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -82,6 +93,12 @@ class AppServiceProvider extends ServiceProvider
// after the folder was renamed from legacy/ to _legacy/. // after the folder was renamed from legacy/ to _legacy/.
View::addNamespace('legacy', resource_path('views/_legacy')); View::addNamespace('legacy', resource_path('views/_legacy'));
$exceptionRendererComponentsPath = base_path('vendor/laravel/framework/src/Illuminate/Foundation/resources/exceptions/renderer/components');
if (is_dir($exceptionRendererComponentsPath)) {
Blade::anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer');
}
$this->configureAuthRateLimiters(); $this->configureAuthRateLimiters();
$this->configureUploadRateLimiters(); $this->configureUploadRateLimiters();
$this->configureMessagingRateLimiters(); $this->configureMessagingRateLimiters();
@@ -94,10 +111,15 @@ class AppServiceProvider extends ServiceProvider
$this->configureMailFailureLogging(); $this->configureMailFailureLogging();
ArtworkAward::observe(ArtworkAwardObserver::class); ArtworkAward::observe(ArtworkAwardObserver::class);
ArtworkMedal::observe(ArtworkAwardObserver::class);
Artwork::observe(ArtworkObserver::class); Artwork::observe(ArtworkObserver::class);
ArtworkFeature::observe(ArtworkFeatureObserver::class);
ArtworkFavourite::observe(ArtworkFavouriteObserver::class); ArtworkFavourite::observe(ArtworkFavouriteObserver::class);
ArtworkComment::observe(ArtworkCommentObserver::class); ArtworkComment::observe(ArtworkCommentObserver::class);
ArtworkReaction::observe(ArtworkReactionObserver::class); ArtworkReaction::observe(ArtworkReactionObserver::class);
ContentType::observe(ContentTypeObserver::class);
GroupRelease::observe(GroupReleaseObserver::class);
GroupReleaseContributor::observe(GroupReleaseContributorObserver::class);
// ── OAuth / SocialiteProviders ────────────────────────────────────── // ── OAuth / SocialiteProviders ──────────────────────────────────────
Event::listen( Event::listen(
@@ -134,6 +156,17 @@ class AppServiceProvider extends ServiceProvider
$avatarHash = null; $avatarHash = null;
$displayName = null; $displayName = null;
$userId = null; $userId = null;
$toolbarContentTypes = collect();
try {
$toolbarContentTypes = $this->app
->make(ContentTypeSlugResolver::class)
->toolbarContentTypes()
->map(fn (ContentType $contentType) => $contentType->only(['id', 'name', 'slug']))
->map(fn (array $attributes) => new ContentType($attributes));
} catch (\Throwable $e) {
$toolbarContentTypes = collect();
}
if (Auth::check()) { if (Auth::check()) {
$userId = Auth::id(); $userId = Auth::id();
@@ -188,7 +221,7 @@ class AppServiceProvider extends ServiceProvider
$displayName = Auth::user()->name ?: (Auth::user()->username ?? ''); $displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
} }
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName')); $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes'));
}); });
// Replace the framework HandleCors with our ConditionalCors so the // Replace the framework HandleCors with our ConditionalCors so the
@@ -384,13 +417,14 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('artwork-awards', function (Request $request): array { RateLimiter::for('artwork-awards', function (Request $request): array {
$userId = $request->user()?->id; $userId = $request->user()?->id;
$artworkId = (int) $request->route('id'); $artworkId = (int) $request->route('id');
$perMinute = max(1, (int) config('artwork_medals.rate_limit_per_minute', 10));
return [ return [
// Prevent burst spam on a single artwork while allowing normal exploration. // Prevent burst spam on a single artwork while allowing normal exploration.
Limit::perMinute(20)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId), Limit::perMinute($perMinute)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId),
// Global safety net for user/IP across all artworks. // Global safety net for user/IP across all artworks.
Limit::perMinute(120)->by('awards:user:' . ($userId ?? 'guest')), Limit::perMinute($perMinute * 6)->by('awards:user:' . ($userId ?? 'guest')),
Limit::perMinute(180)->by('awards:ip:' . $request->ip()), Limit::perMinute($perMinute * 9)->by('awards:ip:' . $request->ip()),
]; ];
}); });
} }
@@ -472,6 +506,10 @@ class AppServiceProvider extends ServiceProvider
private function registerCpadMenuItems(): void private function registerCpadMenuItems(): void
{ {
if (! $this->isControlPanelRequest()) {
return;
}
if (! class_exists(Menu::class)) { if (! class_exists(Menu::class)) {
return; return;
} }
@@ -483,4 +521,25 @@ class AppServiceProvider extends ServiceProvider
// Control panel menu registration should never block the app boot. // Control panel menu registration should never block the app boot.
} }
} }
private function isControlPanelRequest(): bool
{
if ($this->app->runningInConsole()) {
return true;
}
if (! $this->app->bound('request')) {
return false;
}
$prefix = trim((string) config('cpad.webroot', config('cp.webroot', '/cp')), '/');
if ($prefix === '') {
return false;
}
$request = $this->app->make('request');
return $request->is($prefix) || $request->is($prefix . '/*');
}
} }

View File

@@ -4,45 +4,24 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkAward; use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat; use App\Models\ArtworkAwardStat;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArtworkAwardService class ArtworkAwardService
{ {
public function __construct(private readonly ArtworkMedalService $medals)
{
}
/** /**
* Award an artwork with the given medal. * Award an artwork with the given medal.
* Throws ValidationException if the user already awarded this artwork. * Throws ValidationException if the user already awarded this artwork.
*/ */
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
{ {
$this->validateMedal($medal); return $this->medals->award($artwork, $user, $medal);
$existing = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if ($existing) {
throw ValidationException::withMessages([
'medal' => 'You have already awarded this artwork. Use change to update.',
]);
}
$award = ArtworkAward::create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
]);
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
return $award;
} }
/** /**
@@ -50,21 +29,7 @@ class ArtworkAwardService
*/ */
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
{ {
$this->validateMedal($medal); return $this->medals->changeMedal($artwork, $user, $medal);
$award = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
$award->update([
'medal' => $medal,
'weight' => ArtworkAward::WEIGHTS[$medal],
]);
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
return $award->fresh();
} }
/** /**
@@ -73,17 +38,7 @@ class ArtworkAwardService
*/ */
public function removeAward(Artwork $artwork, User $user): void public function removeAward(Artwork $artwork, User $user): void
{ {
$award = ArtworkAward::where('artwork_id', $artwork->id) $this->medals->removeMedal($artwork, $user);
->where('user_id', $user->id)
->first();
if ($award) {
$award->delete(); // fires ArtworkAwardObserver::deleted
} else {
// Nothing to remove, but still sync stats to be safe.
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
}
} }
/** /**
@@ -91,32 +46,7 @@ class ArtworkAwardService
*/ */
public function recalcStats(int $artworkId): ArtworkAwardStat public function recalcStats(int $artworkId): ArtworkAwardStat
{ {
$counts = DB::table('artwork_awards') return $this->medals->recalculateStats($artworkId);
->where('artwork_id', $artworkId)
->selectRaw('
SUM(medal = \'gold\') AS gold_count,
SUM(medal = \'silver\') AS silver_count,
SUM(medal = \'bronze\') AS bronze_count
')
->first();
$gold = (int) ($counts->gold_count ?? 0);
$silver = (int) ($counts->silver_count ?? 0);
$bronze = (int) ($counts->bronze_count ?? 0);
$score = ($gold * 3) + ($silver * 2) + ($bronze * 1);
$stat = ArtworkAwardStat::updateOrCreate(
['artwork_id' => $artworkId],
[
'gold_count' => $gold,
'silver_count' => $silver,
'bronze_count' => $bronze,
'score_total' => $score,
'updated_at' => now(),
]
);
return $stat;
} }
/** /**
@@ -124,15 +54,6 @@ class ArtworkAwardService
*/ */
public function syncToSearch(Artwork $artwork): void public function syncToSearch(Artwork $artwork): void
{ {
IndexArtworkJob::dispatch($artwork->id); $this->medals->syncArtworkToSearch((int) $artwork->id);
}
private function validateMedal(string $medal): void
{
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
throw ValidationException::withMessages([
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
]);
}
} }
} }

View File

@@ -0,0 +1,644 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Vision\VectorService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ArtworkEvolutionService
{
/**
* @return list<string>
*/
public static function relationTypes(): array
{
return [
ArtworkRelation::TYPE_REMAKE_OF,
ArtworkRelation::TYPE_REMASTER_OF,
ArtworkRelation::TYPE_REVISION_OF,
ArtworkRelation::TYPE_INSPIRED_BY,
ArtworkRelation::TYPE_VARIATION_OF,
];
}
public function __construct(
private readonly ArtworkMaturityService $maturity,
private readonly GroupService $groups,
private readonly VectorService $vectors,
) {
}
/**
* @return array<int, array<string, string>>
*/
public function relationTypeOptions(): array
{
return array_map(fn (string $type): array => [
'value' => $type,
'label' => $this->relationTypeLabel($type),
'short_label' => $this->relationTypeShortLabel($type),
], self::relationTypes());
}
/**
* @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload
*/
public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.');
$targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0);
$relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF));
$note = $this->normalizeNote($payload['note'] ?? null);
if ($targetArtworkId <= 0) {
ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete();
return null;
}
if ($targetArtworkId === (int) $sourceArtwork->id) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.',
]);
}
$targetArtwork = Artwork::query()
->with(['group.members'])
->find($targetArtworkId);
if (! $targetArtwork) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.',
]);
}
$this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.');
if (! $this->isPubliclyVisible($targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.',
]);
}
if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.',
]);
}
return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation {
ArtworkRelation::query()
->where('source_artwork_id', (int) $sourceArtwork->id)
->delete();
return ArtworkRelation::query()->create([
'source_artwork_id' => (int) $sourceArtwork->id,
'target_artwork_id' => (int) $targetArtwork->id,
'relation_type' => $relationType,
'note' => $note,
'sort_order' => 0,
'created_by_user_id' => (int) $actor->id,
])->load([
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
]);
});
}
/**
* @return array<string, mixed>|null
*/
public function editorRelation(Artwork $artwork, User $actor): ?array
{
$relation = ArtworkRelation::query()
->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType'])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
if (! $relation || ! $relation->targetArtwork) {
return null;
}
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type),
'note' => $relation->note,
'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array
{
$this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.');
$manageableGroupIds = collect($this->groups->studioOptionsForUser($actor))
->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false))
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter()
->values();
$term = trim($search);
$safeLimit = max(1, min($limit, 36));
$rankedOptions = [];
$rankedIds = [];
if ($this->vectors->isConfigured()) {
$rankedOptions = $this->similarityRankedOptions($sourceArtwork, $actor, $manageableGroupIds->all(), $term, $safeLimit);
$rankedIds = array_map(static fn (array $option): int => (int) ($option['id'] ?? 0), $rankedOptions);
$rankedIds = array_values(array_filter($rankedIds));
}
if (count($rankedOptions) >= $safeLimit) {
return array_slice($rankedOptions, 0, $safeLimit);
}
$fallbackOptions = $this->fallbackSearchOptions(
$sourceArtwork,
$actor,
$manageableGroupIds->all(),
$term,
$safeLimit,
$rankedIds,
);
return collect(array_merge($rankedOptions, $fallbackOptions))
->unique('id')
->take($safeLimit)
->values()
->all();
}
/**
* @return array<string, mixed>|null
*/
public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array
{
$primaryRelation = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('source_artwork_id', (int) $artwork->id)
->orderBy('sort_order')
->orderBy('id')
->first();
$incomingRelations = ArtworkRelation::query()
->with([
'sourceArtwork.user.profile',
'sourceArtwork.group',
'sourceArtwork.categories.contentType',
'targetArtwork.user.profile',
'targetArtwork.group',
'targetArtwork.categories.contentType',
])
->where('target_artwork_id', (int) $artwork->id)
->orderByDesc('updated_at')
->orderByDesc('id')
->limit(4)
->get();
$primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null;
$updates = $incomingRelations
->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer))
->filter()
->values()
->all();
if ($primary === null && $updates === []) {
return null;
}
return [
'eyebrow' => 'Artwork Evolution',
'primary' => $primary,
'updates' => $updates,
];
}
private function ensureManageable(User $actor, Artwork $artwork, string $message): void
{
if (! Gate::forUser($actor)->allows('update', $artwork)) {
throw ValidationException::withMessages([
'evolution_target_artwork_id' => $message,
]);
}
}
private function isPubliclyVisible(Artwork $artwork): bool
{
return ! $artwork->trashed()
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null
&& $artwork->published_at->lte(now());
}
private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool
{
$sourceTimestamp = $this->comparisonTimestamp($sourceArtwork);
$targetTimestamp = $this->comparisonTimestamp($targetArtwork);
if ($sourceTimestamp === null || $targetTimestamp === null) {
return true;
}
return $targetTimestamp->lt($sourceTimestamp);
}
private function comparisonTimestamp(Artwork $artwork): ?Carbon
{
$value = $artwork->published_at ?: $artwork->created_at;
return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null);
}
private function normalizeRelationType(string $type): string
{
$normalized = Str::lower(trim($type));
return in_array($normalized, self::relationTypes(), true)
? $normalized
: ArtworkRelation::TYPE_REMAKE_OF;
}
private function normalizeNote(mixed $note): ?string
{
$resolved = trim((string) $note);
return $resolved !== '' ? $resolved : null;
}
/**
* @return array<string, mixed>
*/
private function mapStudioOption(Artwork $artwork, User $actor, array $context = []): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$publishedAt = $artwork->published_at;
$year = $publishedAt?->year ?: $artwork->created_at?->year;
$similarityScore = array_key_exists('similarity_score', $context) && is_numeric($context['similarity_score'])
? round((float) $context['similarity_score'], 5)
: null;
return [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'year' => $year,
'published_at' => optional($publishedAt)->toIsoString(),
'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null,
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
'is_manageable' => Gate::forUser($actor)->allows('update', $artwork),
'similarity_score' => $similarityScore,
'sort_source' => (string) ($context['sort_source'] ?? 'fallback'),
];
}
/**
* @param list<int> $manageableGroupIds
* @return list<array<string, mixed>>
*/
private function similarityRankedOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit): array
{
try {
$matches = $this->vectors->similarToArtwork($sourceArtwork, min(120, max($limit * 4, 48)));
} catch (\Throwable) {
return [];
}
$orderedIds = [];
$scores = [];
foreach ($matches as $match) {
$candidateId = (int) ($match['id'] ?? 0);
if ($candidateId <= 0 || isset($scores[$candidateId])) {
continue;
}
$orderedIds[] = $candidateId;
$scores[$candidateId] = (float) ($match['score'] ?? 0.0);
}
if ($orderedIds === []) {
return [];
}
$candidates = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term)
->whereIn('id', $orderedIds)
->get()
->keyBy('id');
$options = [];
foreach ($orderedIds as $candidateId) {
/** @var Artwork|null $candidate */
$candidate = $candidates->get($candidateId);
if (! $candidate) {
continue;
}
$options[] = $this->mapStudioOption($candidate, $actor, [
'similarity_score' => $scores[$candidateId] ?? null,
'sort_source' => 'vector_similarity',
]);
if (count($options) >= $limit) {
break;
}
}
return $options;
}
/**
* @param list<int> $manageableGroupIds
* @param list<int> $excludeIds
* @return list<array<string, mixed>>
*/
private function fallbackSearchOptions(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term, int $limit, array $excludeIds = []): array
{
$query = $this->manageableCandidatesQuery($sourceArtwork, $actor, $manageableGroupIds, $term);
if ($excludeIds !== []) {
$query->whereNotIn('id', $excludeIds);
}
return $query
->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id])
->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END')
->orderByDesc('published_at')
->limit(max($limit * 2, 36))
->get()
->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor))
->values()
->all();
}
/**
* @param list<int> $manageableGroupIds
*/
private function manageableCandidatesQuery(Artwork $sourceArtwork, User $actor, array $manageableGroupIds, string $term): Builder
{
$query = Artwork::query()
->with(['user.profile', 'group', 'categories.contentType'])
->whereKeyNot((int) $sourceArtwork->id)
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where(function ($builder) use ($actor, $manageableGroupIds): void {
$builder->where('user_id', (int) $actor->id);
if ($manageableGroupIds !== []) {
$builder->orWhereIn('group_id', $manageableGroupIds);
}
});
$referenceTimestamp = $this->comparisonTimestamp($sourceArtwork);
if ($referenceTimestamp !== null) {
$query->where('published_at', '<=', $referenceTimestamp);
}
if ($term !== '') {
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like))
->orWhereHas('user', fn ($userQuery) => $userQuery
->where('name', 'like', $like)
->orWhere('username', 'like', $like));
});
}
return $query;
}
/**
* @return array<string, mixed>|null
*/
private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$beforeYear = $before['year'] ?? null;
$afterYear = $after['year'] ?? null;
$yearsApart = $this->yearsApart($beforeYear, $afterYear);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Then & Now',
'summary' => $this->primarySummary($beforeYear, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Then & Now comparison',
],
];
}
/**
* @return array<string, mixed>|null
*/
private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array
{
$beforeArtwork = $relation->targetArtwork;
$afterArtwork = $relation->sourceArtwork;
if (! $beforeArtwork || ! $afterArtwork) {
return null;
}
if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) {
return null;
}
$before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original');
$after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type));
if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) {
return null;
}
$yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null);
return [
'id' => (int) $relation->id,
'relation_type' => (string) $relation->relation_type,
'relation_label' => $this->relationTypeLabel((string) $relation->relation_type),
'heading' => 'Updated Version',
'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart),
'years_apart' => $yearsApart,
'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null,
'note' => $relation->note,
'before' => $before,
'after' => $after,
'compare' => [
'available' => $this->compareAvailable($before, $after),
'title' => 'Compare versions',
],
];
}
/**
* @return array<string, mixed>
*/
private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array
{
$category = $artwork->categories->sortBy('sort_order')->first();
$md = ThumbnailPresenter::present($artwork, 'md');
$lg = ThumbnailPresenter::present($artwork, 'lg');
$xl = ThumbnailPresenter::present($artwork, 'xl');
$publishedAt = $artwork->published_at;
return $this->maturity->decoratePayload([
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', [
'id' => (int) $artwork->id,
'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id,
]),
'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => optional($publishedAt)->toIsoString(),
'year' => $publishedAt?->year ?: $artwork->created_at?->year,
'role_label' => $roleLabel,
'thumbnail' => $md['url'] ?? null,
'image_md' => $md['url'] ?? null,
'image_lg' => $lg['url'] ?? null,
'image_xl' => $xl['url'] ?? null,
'width' => (int) ($artwork->width ?? 0),
'height' => (int) ($artwork->height ?? 0),
'content_type' => $category?->contentType?->name,
'category' => $category?->name,
], $artwork, $viewer);
}
/**
* @param array<string, mixed> $card
*/
private function shouldOmitForViewer(array $card): bool
{
return (bool) data_get($card, 'maturity.should_hide', false);
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
private function compareAvailable(array $before, array $after): bool
{
return ! empty($before['image_lg']) && ! empty($after['image_lg']);
}
private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int
{
if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) {
return null;
}
return max(0, (int) $afterYear - (int) $beforeYear);
}
private function primarySummary(mixed $beforeYear, ?int $yearsApart): string
{
if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart);
}
if (is_numeric($beforeYear)) {
return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear);
}
return 'This artwork revisits an earlier version from the creator archive.';
}
private function incomingSummary(mixed $afterYear, ?int $yearsApart): string
{
if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) {
return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart);
}
if (is_numeric($afterYear)) {
return sprintf('This artwork was later revisited in %d.', (int) $afterYear);
}
return 'This artwork later received an updated version from the same creator.';
}
private function relationTypeLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of',
ArtworkRelation::TYPE_REVISION_OF => 'Revision of',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation of',
default => 'Remake of',
};
}
private function relationTypeShortLabel(string $type): string
{
return match ($type) {
ArtworkRelation::TYPE_REMASTER_OF => 'Remaster',
ArtworkRelation::TYPE_REVISION_OF => 'Update',
ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take',
ArtworkRelation::TYPE_VARIATION_OF => 'Variation',
default => 'Remake',
};
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
final class ArtworkMedalService
{
public function __construct(private readonly HomepageService $homepage)
{
}
public function upsert(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$existing = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
return $existing
? $this->changeMedal($artwork, $user, $medal)
: $this->award($artwork, $user, $medal);
}
public function award(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$this->validateMedal($medal);
$exists = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
if ($exists) {
throw ValidationException::withMessages([
'medal' => 'You have already awarded this artwork. Use change to update.',
]);
}
return ArtworkMedal::query()->create([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'medal_type' => $medal,
'weight' => ArtworkAward::weightFor($medal),
]);
}
public function changeMedal(Artwork $artwork, User $user, string $medal): ArtworkMedal
{
$this->validateMedal($medal);
$award = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if (! $award) {
throw ValidationException::withMessages([
'medal' => 'No existing medal found for this artwork.',
]);
}
$award->update([
'medal_type' => $medal,
'weight' => ArtworkAward::weightFor($medal),
]);
return $award->fresh();
}
public function removeMedal(Artwork $artwork, User $user): void
{
$award = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->first();
if ($award) {
$award->delete();
}
}
public function recalculateStats(int $artworkId): ArtworkMedalStat
{
$rows = ArtworkMedal::query()
->where('artwork_id', $artworkId)
->get(['medal_type', 'weight', 'updated_at']);
$cutoff7d = now()->subDays(7);
$cutoff30d = now()->subDays(30);
$goldCount = 0;
$silverCount = 0;
$bronzeCount = 0;
$scoreTotal = 0;
$score7d = 0;
$score30d = 0;
$lastMedaledAt = null;
foreach ($rows as $row) {
$medal = (string) $row->medal;
$weight = (int) ($row->weight ?? ArtworkAward::weightFor($medal));
$updatedAt = $row->updated_at instanceof Carbon ? $row->updated_at : Carbon::parse($row->updated_at);
if ($medal === 'gold') {
$goldCount++;
} elseif ($medal === 'silver') {
$silverCount++;
} elseif ($medal === 'bronze') {
$bronzeCount++;
}
$scoreTotal += $weight;
if ($updatedAt->greaterThanOrEqualTo($cutoff7d)) {
$score7d += $weight;
}
if ($updatedAt->greaterThanOrEqualTo($cutoff30d)) {
$score30d += $weight;
}
if ($lastMedaledAt === null || $updatedAt->greaterThan($lastMedaledAt)) {
$lastMedaledAt = $updatedAt;
}
}
$stat = ArtworkAwardStat::query()->updateOrCreate(
['artwork_id' => $artworkId],
[
'gold_count' => $goldCount,
'silver_count' => $silverCount,
'bronze_count' => $bronzeCount,
'score_total' => $scoreTotal,
'score_7d' => $score7d,
'score_30d' => $score30d,
'last_medaled_at' => $lastMedaledAt,
]
);
return ArtworkMedalStat::query()->findOrFail($stat->artwork_id);
}
public function refreshArtworkMedalState(int $artworkId): ArtworkMedalStat
{
$stat = $this->recalculateStats($artworkId);
$this->syncArtworkToSearch($artworkId);
$this->homepage->clearFeaturedAndMedalCaches();
return $stat;
}
public function syncArtworkToSearch(int $artworkId): void
{
IndexArtworkJob::dispatch($artworkId);
}
private function validateMedal(string $medal): void
{
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
throw ValidationException::withMessages([
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
]);
}
}
}

View File

@@ -7,9 +7,11 @@ namespace App\Services;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Tag; use App\Models\Tag;
use App\Services\EarlyGrowth\AdaptiveTimeWindow; use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/** /**
* High-level search API powered by Meilisearch via Laravel Scout. * High-level search API powered by Meilisearch via Laravel Scout.
@@ -21,9 +23,12 @@ final class ArtworkSearchService
private const BASE_FILTER = 'is_public = true AND is_approved = true'; private const BASE_FILTER = 'is_public = true AND is_approved = true';
private const CACHE_TTL = 300; // 5 minutes private const CACHE_TTL = 300; // 5 minutes
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads']; private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
private const SEARCH_CANDIDATE_POOL_MULTIPLIER = 4;
private const SEARCH_CANDIDATE_POOL_MAX = 240;
public function __construct( public function __construct(
private readonly AdaptiveTimeWindow $timeWindow, private readonly AdaptiveTimeWindow $timeWindow,
private readonly ArtworkMaturityService $maturity,
) {} ) {}
/** /**
@@ -76,11 +81,38 @@ final class ArtworkSearchService
$options['sort'] = $sort; $options['sort'] = $sort;
} }
$options = $this->viewerAwareOptions($options);
return Artwork::search($q ?: '') return Artwork::search($q ?: '')
->options($options) ->options($options)
->paginate($perPage); ->paginate($perPage);
} }
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
{
$page = max(1, $page ?? (int) request()->get('page', 1));
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$results = Artwork::search('')
->options($this->viewerAwareOptions($options))
->paginate($candidateCount, 'page', 1);
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
$offset = max(0, ($page - 1) * $perPage);
$slice = $ordered->slice($offset, $perPage)->values();
return new PaginationLengthAwarePaginator(
$slice->all(),
(int) $results->total(),
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->query(),
'pageName' => 'page',
]
);
}
/** /**
* Load artworks for a tag page, sorted by views + likes descending. * Load artworks for a tag page, sorted by views + likes descending.
*/ */
@@ -92,12 +124,13 @@ final class ArtworkSearchService
} }
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular'; $sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1); $cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
$query = Artwork::query() $query = Artwork::query()
->public() ->public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id)) ->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*') ->select('artworks.*')
@@ -132,14 +165,14 @@ final class ArtworkSearchService
*/ */
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{ {
$cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1); $cacheKey = "search.cat.{$cat}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
return Artwork::search('') return Artwork::search('')
->options([ ->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"', 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'], 'sort' => ['created_at:desc'],
]) ]))
->paginate($perPage); ->paginate($perPage);
}); });
} }
@@ -181,14 +214,14 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1); $page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "category.{$categorySlug}.{$sort}.{$page}"; $cacheKey = "category.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) { return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return Artwork::search('') return Artwork::search('')
->options([ ->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"', 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort], 'sort' => self::CATEGORY_SORT_FIELDS[$sort],
]) ]))
->paginate($perPage); ->paginate($perPage);
}); });
} }
@@ -204,14 +237,14 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1); $page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL; $ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$page}"; $cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) { return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return Artwork::search('') return Artwork::search('')
->options([ ->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"', 'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort], 'sort' => self::CATEGORY_SORT_FIELDS[$sort],
]) ]))
->paginate($perPage); ->paginate($perPage);
}); });
} }
@@ -230,7 +263,7 @@ final class ArtworkSearchService
return $this->popular($limit); return $this->popular($limit);
} }
$cacheKey = "search.related.{$artwork->id}"; $cacheKey = "search.related.{$artwork->id}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
$tagFilters = implode(' OR ', array_map( $tagFilters = implode(' OR ', array_map(
@@ -239,10 +272,10 @@ final class ArtworkSearchService
)); ));
return Artwork::search('') return Artwork::search('')
->options([ ->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')', 'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
'sort' => ['views:desc', 'likes:desc'], 'sort' => ['views:desc', 'likes:desc'],
]) ]))
->paginate($limit); ->paginate($limit);
}); });
} }
@@ -252,12 +285,12 @@ final class ArtworkSearchService
*/ */
public function popular(int $perPage = 24): LengthAwarePaginator public function popular(int $perPage = 24): LengthAwarePaginator
{ {
return Cache::remember('search.popular.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) { return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('') return Artwork::search('')
->options([ ->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER, 'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'likes:desc'], 'sort' => ['views:desc', 'likes:desc'],
]) ]))
->paginate($perPage); ->paginate($perPage);
}); });
} }
@@ -267,12 +300,12 @@ final class ArtworkSearchService
*/ */
public function recent(int $perPage = 24): LengthAwarePaginator public function recent(int $perPage = 24): LengthAwarePaginator
{ {
return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) { return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('') return Artwork::search('')
->options([ ->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER, 'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'], 'sort' => ['published_at_ts:desc'],
]) ]))
->paginate($perPage); ->paginate($perPage);
}); });
} }
@@ -291,15 +324,13 @@ final class ArtworkSearchService
$windowDays = $this->timeWindow->getTrendingWindowDays(30); $windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString(); $cutoff = now()->subDays($windowDays)->toDateString();
// Include window in cache key so adaptive expansions surface immediately // Include window in cache key so adaptive expansions surface immediately
$cacheKey = "discover.trending.{$windowDays}d.{$page}"; $cacheKey = "discover.trending.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
]) ], $perPage);
->paginate($perPage);
}); });
} }
@@ -314,15 +345,13 @@ final class ArtworkSearchService
$page = (int) request()->get('page', 1); $page = (int) request()->get('page', 1);
$windowDays = $this->timeWindow->getTrendingWindowDays(30); $windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString(); $cutoff = now()->subDays($windowDays)->toDateString();
$cacheKey = "discover.rising.{$windowDays}d.{$page}"; $cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) { return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'], 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
]) ], $perPage);
->paginate($perPage);
}); });
} }
@@ -332,13 +361,11 @@ final class ArtworkSearchService
public function discoverFresh(int $perPage = 24): LengthAwarePaginator public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{ {
$page = (int) request()->get('page', 1); $page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) { return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER, 'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'], 'sort' => ['published_at_ts:desc'],
]) ], $perPage);
->paginate($perPage);
}); });
} }
@@ -348,13 +375,11 @@ final class ArtworkSearchService
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{ {
$page = (int) request()->get('page', 1); $page = (int) request()->get('page', 1);
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) { return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER, 'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'], 'sort' => ['likes:desc', 'views:desc'],
]) ], $perPage);
->paginate($perPage);
}); });
} }
@@ -364,13 +389,11 @@ final class ArtworkSearchService
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{ {
$page = (int) request()->get('page', 1); $page = (int) request()->get('page', 1);
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) { return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER, 'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'], 'sort' => ['downloads:desc', 'views:desc'],
]) ], $perPage);
->paginate($perPage);
}); });
} }
@@ -391,18 +414,28 @@ final class ArtworkSearchService
array_slice($tagSlugs, 0, 5) array_slice($tagSlugs, 0, 5)
)); ));
$cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs)); $cacheKey = 'discover.by-tags.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $tagSlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')', 'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'], 'sort' => ['trending_score_7d:desc', 'likes:desc'],
]) ], $limit, true, 1);
->paginate($limit);
}); });
} }
private function viewerAwareOptions(array $options): array
{
$options['filter'] = $this->maturity->appendSearchFilter((string) ($options['filter'] ?? self::BASE_FILTER), request()->user());
return $options;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
/** /**
* Fresh artworks in given categories, sorted by publish timestamp desc. * Fresh artworks in given categories, sorted by publish timestamp desc.
* Used for personalized "Fresh in your favourite categories" section. * Used for personalized "Fresh in your favourite categories" section.
@@ -420,15 +453,13 @@ final class ArtworkSearchService
array_slice($categorySlugs, 0, 3) array_slice($categorySlugs, 0, 3)
)); ));
$cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs)); $cacheKey = 'discover.by-cats.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $categorySlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
return Artwork::search('') return $this->searchWithThumbnailPreference([
->options([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['published_at_ts:desc'], 'sort' => ['published_at_ts:desc'],
]) ], $limit, true, 1);
->paginate($limit);
}); });
} }
@@ -444,6 +475,52 @@ final class ArtworkSearchService
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc']; return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
} }
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items->values();
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items->values();
}
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
if ($excludeMissing) {
return $healthy->values();
}
return $healthy
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
->values();
}
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
{
return min(
self::SEARCH_CANDIDATE_POOL_MAX,
max($perPage, $perPage * max(self::SEARCH_CANDIDATE_POOL_MULTIPLIER, $page + 2))
);
}
private function emptyPaginator(int $perPage): LengthAwarePaginator private function emptyPaginator(int $perPage): LengthAwarePaginator
{ {
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage); return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);

View File

@@ -4,6 +4,9 @@ namespace App\Services;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\User;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -11,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
/** /**
* ArtworkService * ArtworkService
@@ -23,6 +27,30 @@ class ArtworkService
{ {
protected int $cacheTtl = 3600; // seconds protected int $cacheTtl = 3600; // seconds
public function __construct(
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
)
{
}
/**
* Relations used by the featured artwork surfaces.
*
* @return array<int|string, mixed>
*/
private function featuredRelations(): array
{
return [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
},
];
}
/** /**
* Lightweight relations needed to render browse/list cards. * Lightweight relations needed to render browse/list cards.
* *
@@ -32,7 +60,7 @@ class ArtworkService
{ {
return [ return [
'user:id,name,username', 'user:id,name,username',
'user.profile:user_id,avatar_url', 'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path', 'group:id,name,slug,avatar_path',
'categories' => function ($q) { 'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -48,6 +76,7 @@ class ArtworkService
{ {
$query = Artwork::public() $query = Artwork::public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->with($this->browseRelations()); ->with($this->browseRelations());
$normalizedSort = strtolower(trim($sort)); $normalizedSort = strtolower(trim($sort));
@@ -122,6 +151,7 @@ class ArtworkService
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
{ {
$query = Artwork::public()->published() $query = Artwork::public()->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->with($this->browseRelations()) ->with($this->browseRelations())
->whereHas('categories', function ($q) use ($category) { ->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id); $q->where('categories.id', $category->id);
@@ -141,6 +171,7 @@ class ArtworkService
public function getLatestArtworks(int $limit = 10): Collection public function getLatestArtworks(int $limit = 10): Collection
{ {
return Artwork::public()->published() return Artwork::public()->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->orderByDesc('published_at') ->orderByDesc('published_at')
->limit($limit) ->limit($limit)
->get(); ->get();
@@ -165,13 +196,7 @@ class ArtworkService
*/ */
public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator
{ {
$contentType = ContentType::where('slug', strtolower($slug))->first(); $contentType = $this->resolveContentTypeOrFail($slug);
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
$query = $this->browseQuery($sort) $query = $this->browseQuery($sort)
->whereHas('categories', function ($q) use ($contentType) { ->whereHas('categories', function ($q) use ($contentType) {
@@ -198,12 +223,7 @@ class ArtworkService
$parts = array_values(array_map('strtolower', $slugs)); $parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts); $contentTypeSlug = array_shift($parts);
$contentType = ContentType::where('slug', $contentTypeSlug)->first(); $contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug);
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$contentTypeSlug]);
throw $e;
}
if (empty($parts)) { if (empty($parts)) {
$e = new ModelNotFoundException(); $e = new ModelNotFoundException();
@@ -274,30 +294,102 @@ class ArtworkService
return $allIds; return $allIds;
} }
private function resolveContentTypeOrFail(string $slug): ContentType
{
$resolution = $this->contentTypeResolver->resolve($slug);
if (! $resolution->found() || $resolution->contentType === null) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
return $resolution->contentType;
}
/** /**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type. * Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters. * Uses artwork_features table and applies public/approved/published filters.
*/ */
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator private function featuredBaseQuery(?int $type): Builder
{ {
$query = Artwork::query() return Artwork::query()
->select('artworks.*') ->select('artworks.*')
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->public() ->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->published() ->where('af.is_active', true)
->whereNull('af.deleted_at')
->where(function ($query): void {
$query->whereNull('af.expires_at')
->orWhere('af.expires_at', '>', now());
})
->when($type !== null, function ($q) use ($type) { ->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type); $q->where('af.type', $type);
}) });
->with([ }
'user:id,name,username',
'categories' => function ($q) { private function applyFeaturedEligibilityFilters(Builder $query): void
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order'); {
}, $query->public()
]) ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails();
}
private function applyFeaturedOrdering(Builder $query): Builder
{
if (Schema::hasColumn('artwork_features', 'force_hero')) {
$query->orderByDesc('af.force_hero');
}
return $query
->orderByDesc('af.priority')
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
->orderByDesc('af.featured_at') ->orderByDesc('af.featured_at')
->orderByDesc('artworks.published_at'); ->orderByDesc('artworks.published_at');
}
return $query->paginate($perPage)->withQueryString(); private function featuredSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);
$this->applyFeaturedEligibilityFilters($query);
return $this->applyFeaturedOrdering($query);
}
private function featuredHeroSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);
if (Schema::hasColumn('artwork_features', 'force_hero')) {
$query->where(function (Builder $selection): void {
$selection->where('af.force_hero', true)
->orWhere(function (Builder $eligible): void {
$this->applyFeaturedEligibilityFilters($eligible);
});
});
} else {
$this->applyFeaturedEligibilityFilters($query);
}
return $this->applyFeaturedOrdering($query);
}
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
{
return $this->featuredSelectionQuery($type)
->with($this->featuredRelations())
->paginate($perPage)
->withQueryString();
}
public function getFeaturedArtworkWinner(?int $type = null): ?Artwork
{
$artwork = $this->featuredHeroSelectionQuery($type)
->with($this->featuredRelations())
->first();
return $artwork instanceof Artwork ? $artwork : null;
} }
/** /**
@@ -310,11 +402,13 @@ class ArtworkService
* @param int $perPage * @param int $perPage
* @return CursorPaginator * @return CursorPaginator
*/ */
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24): CursorPaginator public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24, ?User $viewer = null): CursorPaginator
{ {
$query = Artwork::where('user_id', $userId) $query = Artwork::where('user_id', $userId)
->with([ ->with([
'user:id,name,username,level,rank', 'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites', 'stats:artwork_id,views,downloads,favorites',
'categories' => function ($q) { 'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
@@ -326,6 +420,7 @@ class ArtworkService
if (! $isOwner) { if (! $isOwner) {
// Apply public visibility constraints for non-owners // Apply public visibility constraints for non-owners
$query->public()->published(); $query->public()->published();
$this->maturity->applyViewerFilter($query, $viewer);
} else { } else {
// Owner: include all non-deleted items (do not force published/approved) // Owner: include all non-deleted items (do not force published/approved)
$query->whereNull('deleted_at'); $query->whereNull('deleted_at');

View File

@@ -6,6 +6,7 @@ use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Schema;
use Throwable; use Throwable;
/** /**
@@ -91,6 +92,55 @@ class ArtworkStatsService
$this->incrementDownloads((int) $artwork->id, $by, $defer); $this->incrementDownloads((int) $artwork->id, $by, $defer);
} }
/**
* Recompute denormalized engagement counters from their source tables.
*
* This keeps single-artwork analytics fresh after favourites, likes,
* comments, and shares without waiting for scheduled ranking jobs.
*/
public function syncEngagementCounts(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
try {
$payload = [
'favorites' => Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0,
'rating_count' => Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0,
];
if (Schema::hasColumn('artwork_stats', 'comments_count')) {
$payload['comments_count'] = Schema::hasTable('artwork_comments')
? (int) DB::table('artwork_comments')
->where('artwork_id', $artworkId)
->whereNull('deleted_at')
->count()
: 0;
}
if (Schema::hasColumn('artwork_stats', 'shares_count')) {
$payload['shares_count'] = Schema::hasTable('artwork_shares')
? (int) DB::table('artwork_shares')->where('artwork_id', $artworkId)->count()
: 0;
}
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
$payload
);
} catch (Throwable $e) {
Log::warning('Failed to sync artwork engagement counts', [
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
}
}
/** /**
* Apply a set of deltas to the artwork_stats row inside a transaction. * Apply a set of deltas to the artwork_stats row inside a transaction.
* After updating artwork-level stats, forwards view/download counts to * After updating artwork-level stats, forwards view/download counts to

View File

@@ -17,6 +17,7 @@ use App\Models\Artwork;
use App\Models\Collection; use App\Models\Collection;
use App\Models\Group; use App\Models\Group;
use App\Models\User; use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -33,6 +34,7 @@ class CollectionService
private readonly SmartCollectionService $smartCollections, private readonly SmartCollectionService $smartCollections,
private readonly CollectionCollaborationService $collaborators, private readonly CollectionCollaborationService $collaborators,
private readonly GroupMembershipService $groupMembers, private readonly GroupMembershipService $groupMembers,
private readonly ArtworkMaturityService $maturity,
) { ) {
} }
@@ -492,12 +494,14 @@ class CollectionService
return $query->get(); return $query->get();
} }
public function getCollectionDetailArtworks(Collection $collection, bool $ownerView, int $perPage = 24): LengthAwarePaginator public function getCollectionDetailArtworks(Collection $collection, bool $ownerView, int $perPage = 24, ?User $viewer = null): LengthAwarePaginator
{ {
if ($collection->isSmart()) { if ($collection->isSmart()) {
return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage); return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage);
} }
$viewer ??= $ownerView ? null : request()->user();
$query = $collection->artworks() $query = $collection->artworks()
->with([ ->with([
'user:id,name,username', 'user:id,name,username',
@@ -515,12 +519,21 @@ class CollectionService
->where('artworks.is_approved', true) ->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at') ->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now()); ->where('artworks.published_at', '<=', now());
if ($this->viewerShouldHideMature($viewer)) {
$query->whereRaw('COALESCE(artworks.is_mature, 0) = 0')
->whereRaw("COALESCE(artworks.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
}
} }
$query = match ($collection->sort_mode) { $query = match ($collection->sort_mode) {
Collection::SORT_NEWEST => $query->orderByDesc('artworks.published_at'), Collection::SORT_NEWEST => $query->orderByDesc('artworks.published_at'),
Collection::SORT_OLDEST => $query->orderBy('artworks.published_at'), Collection::SORT_OLDEST => $query->orderBy('artworks.published_at'),
Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByPivot('order_num'), Collection::SORT_POPULAR => $query
->leftJoin('artwork_stats as artwork_stats_sort', 'artwork_stats_sort.artwork_id', '=', 'artworks.id')
->reorder()
->orderByRaw('COALESCE(artwork_stats_sort.views, 0) DESC')
->orderBy('collection_artwork.order_num'),
default => $query->orderByPivot('order_num'), default => $query->orderByPivot('order_num'),
}; };
@@ -843,15 +856,18 @@ class CollectionService
public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array
{ {
$viewer ??= $ownerView ? null : request()->user();
$collectionList = $collections instanceof EloquentCollection $collectionList = $collections instanceof EloquentCollection
? $collections ? $collections
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections)); : new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all(); $collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
$firstArtworkMap = $this->firstArtworkMapForCollections( $firstArtworkMap = $this->firstArtworkMapForCollections(
$collectionIds, $collectionIds,
! $ownerView ! $ownerView,
$hideMatureCovers,
); );
$savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== [] $savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== []
@@ -866,9 +882,11 @@ class CollectionService
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) { return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
$resolvedCover = $collection->isSmart() $resolvedCover = $collection->isSmart()
? $this->smartCollections->firstArtwork($collection, $ownerView) ? $this->smartCollections->firstArtwork($collection, $ownerView)
: $collection->resolvedCoverArtwork(! $ownerView); : $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer));
$fallbackCover = $firstArtworkMap->get((int) $collection->id); $fallbackCover = $firstArtworkMap->get((int) $collection->id);
$cover = $resolvedCover ?? $fallbackCover; $cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer))
? $resolvedCover
: $fallbackCover;
$summary = $collection->summary ?? $collection->description; $summary = $collection->summary ?? $collection->description;
$isSaved = in_array((int) $collection->id, $savedCollectionIds, true); $isSaved = in_array((int) $collection->id, $savedCollectionIds, true);
$canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer); $canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer);
@@ -958,6 +976,7 @@ class CollectionService
'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(), 'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(),
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null, 'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null, 'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
'cover_image_maturity' => ! $ownerView && $cover ? $this->maturity->presentation($cover, $viewer) : null,
'cover_artwork_id' => $cover?->id, 'cover_artwork_id' => $cover?->id,
'saved' => $isSaved, 'saved' => $isSaved,
'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null, 'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null,
@@ -976,11 +995,18 @@ class CollectionService
})->all(); })->all();
} }
public function mapCollectionDetailPayload(Collection $collection, bool $ownerView = false): array public function mapCollectionDetailPayload(Collection $collection, bool $ownerView = false, ?User $viewer = null): array
{ {
$viewer ??= $ownerView ? null : request()->user();
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
$cover = $collection->isSmart() $cover = $collection->isSmart()
? $this->smartCollections->firstArtwork($collection, $ownerView) ? $this->smartCollections->firstArtwork($collection, $ownerView)
: $collection->resolvedCoverArtwork(! $ownerView); : $collection->resolvedCoverArtwork(! $ownerView, $hideMatureCovers);
if (! $this->eligibleCoverArtwork($cover, ! $ownerView, $hideMatureCovers)) {
$cover = $this->firstArtworkMapForCollections([(int) $collection->id], ! $ownerView, $hideMatureCovers)
->get((int) $collection->id);
}
return [ return [
'id' => $collection->id, 'id' => $collection->id,
@@ -1074,7 +1100,8 @@ class CollectionService
'expired_at' => optional($collection->expired_at)?->toISOString(), 'expired_at' => optional($collection->expired_at)?->toISOString(),
'history_count' => (int) $collection->history_count, 'history_count' => (int) $collection->history_count,
'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null, 'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null,
'cover_artwork_id' => $collection->cover_artwork_id, 'cover_image_maturity' => ! $ownerView && $cover ? $this->maturity->presentation($cover, $viewer) : null,
'cover_artwork_id' => $cover?->id,
'smart_rules_json' => $collection->smart_rules_json, 'smart_rules_json' => $collection->smart_rules_json,
'layout_modules' => $this->normalizeLayoutModules($collection->layout_modules_json, $collection->type, (bool) $collection->allow_comments, (bool) $collection->allow_submissions), 'layout_modules' => $this->normalizeLayoutModules($collection->layout_modules_json, $collection->type, (bool) $collection->allow_comments, (bool) $collection->allow_submissions),
'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null, 'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null,
@@ -1194,7 +1221,7 @@ class CollectionService
* @param array<int, int> $collectionIds * @param array<int, int> $collectionIds
* @return SupportCollection<int, Artwork> * @return SupportCollection<int, Artwork>
*/ */
private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly): SupportCollection private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly, bool $hideMature = false): SupportCollection
{ {
if ($collectionIds === []) { if ($collectionIds === []) {
return collect(); return collect();
@@ -1210,6 +1237,10 @@ class CollectionService
->whereNotNull('a.published_at') ->whereNotNull('a.published_at')
->where('a.published_at', '<=', now()); ->where('a.published_at', '<=', now());
}) })
->when($hideMature, function ($query): void {
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", ['suspected']);
})
->orderBy('ca.collection_id') ->orderBy('ca.collection_id')
->orderBy('ca.order_num') ->orderBy('ca.order_num')
->select(['ca.collection_id', 'a.id']) ->select(['ca.collection_id', 'a.id'])
@@ -1237,7 +1268,7 @@ class CollectionService
$contentType = $category?->contentType; $contentType = $category?->contentType;
$stats = $artwork->stats; $stats = $artwork->stats;
return array_merge([ return $this->maturity->decoratePayload(array_merge([
'id' => $artwork->id, 'id' => $artwork->id,
'title' => $artwork->title, 'title' => $artwork->title,
'slug' => $artwork->slug, 'slug' => $artwork->slug,
@@ -1261,7 +1292,7 @@ class CollectionService
'username' => $artwork->user->username, 'username' => $artwork->user->username,
'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]), 'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]),
] : null, ] : null,
], $extra); ], $extra), $artwork, request()->user());
} }
private function normalizeLayoutModules(?array $modules, string $type, bool $allowComments, bool $allowSubmissions, bool $includePresentation = true): array private function normalizeLayoutModules(?array $modules, string $type, bool $allowComments, bool $allowSubmissions, bool $includePresentation = true): array
@@ -1458,6 +1489,29 @@ class CollectionService
return $presented['url'] ?? $artwork->thumbUrl('md'); return $presented['url'] ?? $artwork->thumbUrl('md');
} }
private function eligibleCoverArtwork(?Artwork $artwork, bool $publicOnly, bool $hideMature): bool
{
if (! $artwork) {
return false;
}
if ($publicOnly && (! (bool) $artwork->is_public || ! (bool) $artwork->is_approved || $artwork->published_at === null || $artwork->published_at->gt(now()))) {
return false;
}
if (! $hideMature) {
return true;
}
return ! (bool) $artwork->is_mature
&& (string) ($artwork->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR) !== ArtworkMaturityService::STATUS_SUSPECTED;
}
private function viewerShouldHideMature(?User $viewer): bool
{
return $this->maturity->viewerPreferences($viewer)['visibility'] === ArtworkMaturityService::VIEW_HIDE;
}
private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null, ?Group $group = null): bool private function slugExistsForUser(User $user, string $slug, ?int $ignoreCollectionId = null, ?Group $group = null): bool
{ {
return Collection::query() return Collection::query()

View File

@@ -206,12 +206,18 @@ class ContentSanitizer
*/ */
private static function sanitizeHtml(string $html, bool $allowLinks = true): string private static function sanitizeHtml(string $html, bool $allowLinks = true): string
{ {
$encodedHtml = mb_encode_numericentity(
$html,
[0x80, 0x10FFFF, 0, 0xFFFFFF],
'UTF-8'
);
// Parse with DOMDocument // Parse with DOMDocument
$doc = new \DOMDocument('1.0', 'UTF-8'); $doc = new \DOMDocument('1.0', 'UTF-8');
// Suppress warnings from malformed fragments // Suppress warnings from malformed fragments
libxml_use_internal_errors(true); libxml_use_internal_errors(true);
$doc->loadHTML( $doc->loadHTML(
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>', '<?xml encoding="UTF-8"><html><body>' . $encodedHtml . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
); );
libxml_clear_errors(); libxml_clear_errors();
@@ -226,7 +232,7 @@ class ContentSanitizer
} }
// Fix self-closing <a></a> etc. // Fix self-closing <a></a> etc.
return trim($inner); return trim(html_entity_decode($inner, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
} }
/** /**

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ContentType;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
final class ContentTypeAssetService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public function storeUploadedAsset(ContentType $contentType, UploadedFile $file, string $kind): string
{
$mime = strtolower((string) ($file->getMimeType() ?: ''));
$extension = $this->safeExtension($file, $mime);
$path = sprintf(
'content-types/%d/%s-%s.%s',
(int) $contentType->id,
trim($kind) !== '' ? trim($kind) : 'asset',
(string) Str::uuid(),
$extension,
);
$stream = fopen((string) ($file->getRealPath() ?: $file->getPathname()), 'rb');
if ($stream === false) {
throw new RuntimeException('Unable to open uploaded content type asset.');
}
try {
$written = Storage::disk($this->diskName())->put($path, $stream, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $mime !== '' ? $mime : $this->mimeTypeForExtension($extension),
]);
} finally {
fclose($stream);
}
if ($written !== true) {
throw new RuntimeException('Unable to store content type asset.');
}
return $path;
}
public function deleteIfManaged(?string $path): void
{
$trimmed = trim((string) $path);
if ($trimmed === '' || str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://') || str_starts_with($trimmed, '/')) {
return;
}
if (! str_starts_with($trimmed, 'content-types/')) {
return;
}
Storage::disk($this->diskName())->delete($trimmed);
}
private function diskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function safeExtension(UploadedFile $file, string $mime): string
{
$extension = strtolower((string) $file->getClientOriginalExtension());
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported content type asset upload type.');
}
return match ($extension) {
'jpg', 'jpeg' => 'jpg',
'png' => 'png',
default => 'webp',
};
}
private function mimeTypeForExtension(string $extension): string
{
return match ($extension) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\ContentTypes;
use App\Models\ContentType;
class ContentTypeSlugResolution
{
public function __construct(
public readonly string $requestedSlug,
public readonly ?ContentType $contentType = null,
public readonly ?string $redirectSlug = null,
public readonly bool $isVirtual = false,
public readonly ?string $virtualType = null,
) {
}
public function found(): bool
{
return $this->contentType !== null || $this->isVirtual;
}
public function requiresRedirect(): bool
{
return $this->redirectSlug !== null && $this->redirectSlug !== '' && $this->redirectSlug !== $this->requestedSlug;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\ContentTypes;
use App\Models\ContentType;
use App\Models\ContentTypeSlugHistory;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class ContentTypeSlugResolver
{
public function publicContentTypes(): Collection
{
return Cache::rememberForever($this->publicListCacheKey(), function () {
return ContentType::query()
->ordered()
->get(['id', 'name', 'slug', 'description', 'order', 'hide_from_menu']);
});
}
public function toolbarContentTypes(): Collection
{
return $this->publicContentTypes()
->reject(static fn (ContentType $contentType): bool => (bool) $contentType->hide_from_menu)
->values();
}
public function resolve(string $slug, bool $allowVirtual = false): ContentTypeSlugResolution
{
$normalizedSlug = strtolower(trim($slug));
if ($allowVirtual && $this->isVirtualSlug($normalizedSlug)) {
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
isVirtual: true,
virtualType: $normalizedSlug,
);
}
$slugMap = $this->currentSlugMap();
if (isset($slugMap[$normalizedSlug])) {
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
contentType: $this->publicContentTypes()->firstWhere('id', $slugMap[$normalizedSlug]),
);
}
$historyMap = $this->historySlugMap();
$redirectSlug = $historyMap[$normalizedSlug] ?? null;
if ($redirectSlug !== null) {
$contentTypeId = $slugMap[$redirectSlug] ?? null;
return new ContentTypeSlugResolution(
requestedSlug: $normalizedSlug,
contentType: $contentTypeId !== null ? $this->publicContentTypes()->firstWhere('id', $contentTypeId) : null,
redirectSlug: $redirectSlug,
);
}
return new ContentTypeSlugResolution(requestedSlug: $normalizedSlug);
}
public function reservedSlugs(): array
{
return array_values(array_unique(array_map(
static fn (string $slug): string => strtolower(trim($slug)),
(array) config('content_types.reserved_slugs', [])
)));
}
public function isReservedSlug(string $slug): bool
{
return in_array(strtolower(trim($slug)), $this->reservedSlugs(), true);
}
public function historicalSlugExists(string $slug, ?int $ignoreContentTypeId = null): bool
{
$query = ContentTypeSlugHistory::query()->where('old_slug', strtolower(trim($slug)));
if ($ignoreContentTypeId !== null) {
$query->where('content_type_id', '!=', $ignoreContentTypeId);
}
return $query->exists();
}
public function flushCaches(): void
{
Cache::forget($this->publicListCacheKey());
Cache::forget($this->slugMapCacheKey());
Cache::forget($this->historyMapCacheKey());
}
public function dynamicSitemapContentTypes(): Collection
{
return $this->publicContentTypes();
}
private function currentSlugMap(): array
{
return Cache::rememberForever($this->slugMapCacheKey(), function () {
return ContentType::query()
->ordered()
->pluck('id', 'slug')
->mapWithKeys(static fn ($id, $slug) => [strtolower((string) $slug) => (int) $id])
->all();
});
}
private function historySlugMap(): array
{
return Cache::rememberForever($this->historyMapCacheKey(), function () {
$currentSlugById = ContentType::query()
->pluck('slug', 'id')
->mapWithKeys(static fn ($slug, $id) => [(int) $id => strtolower((string) $slug)])
->all();
return ContentTypeSlugHistory::query()
->orderByDesc('id')
->get(['content_type_id', 'old_slug'])
->mapWithKeys(function (ContentTypeSlugHistory $history) use ($currentSlugById) {
$currentSlug = $currentSlugById[(int) $history->content_type_id] ?? null;
return $currentSlug !== null
? [strtolower((string) $history->old_slug) => $currentSlug]
: [];
})
->all();
});
}
private function isVirtualSlug(string $slug): bool
{
return array_key_exists($slug, (array) config('content_types.virtual_types', []));
}
private function publicListCacheKey(): string
{
return (string) config('content_types.cache.public_list_key', 'content-types.public-list');
}
private function slugMapCacheKey(): string
{
return (string) config('content_types.cache.slug_map_key', 'content-types.slug-map');
}
private function historyMapCacheKey(): string
{
return (string) config('content_types.cache.history_map_key', 'content-types.slug-history-map');
}
}

View File

@@ -112,6 +112,7 @@ final class GridFiller
return Artwork::query() return Artwork::query()
->public() ->public()
->published() ->published()
->withoutMissingThumbnails()
->with([ ->with([
'user:id,name,username', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',

View File

@@ -147,6 +147,7 @@ final class ErrorSuggestionService
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]), 'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
'thumb' => $md['url'] ?? null, 'thumb' => $md['url'] ?? null,
'thumb_srcset' => $md['srcset'] ?? null,
]; ];
} }

View File

@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkFeature;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
class FeaturedArtworkAdminService
{
public function __construct(private readonly ArtworkService $artworks)
{
}
/**
* @return array<string, mixed>
*/
public function pageProps(): array
{
$now = Carbon::now();
$features = ArtworkFeature::query()
->with([
'artwork' => fn ($query) => $query->withTrashed()->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]),
])
->orderByDesc('priority')
->orderByDesc('featured_at')
->orderByDesc('id')
->get();
$duplicateCounts = $features->countBy(fn (ArtworkFeature $feature): int => (int) $feature->artwork_id);
$entries = $features
->map(fn (ArtworkFeature $feature): array => $this->mapFeature($feature, $duplicateCounts, $now))
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['eligibility']['is_eligible'] <=> (int) $left['eligibility']['is_eligible'],
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['is_active'] <=> (int) $left['is_active'],
(int) $left['is_expired'] <=> (int) $right['is_expired'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
$eligibleEntries = $this->sortForHeroSelection(
$entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->values()
);
$sharedWinnerArtworkId = $this->artworks->getFeaturedArtworkWinner()?->id;
$winner = $sharedWinnerArtworkId
? $entries->first(fn (array $entry): bool => (int) $entry['artwork_id'] === (int) $sharedWinnerArtworkId)
: null;
if (! is_array($winner) && $eligibleEntries->isNotEmpty()) {
$winner = $eligibleEntries->first();
}
$winnerReason = is_array($winner) ? $this->buildWinnerReason($winner, $eligibleEntries) : null;
$winnerId = is_array($winner) ? (int) $winner['id'] : null;
$entries = $entries
->map(function (array $entry) use ($winnerId, $winnerReason): array {
$isWinner = $winnerId !== null && (int) $entry['id'] === $winnerId;
if ($isWinner) {
array_unshift($entry['status_badges'], [
'label' => 'Winner',
'tone' => 'amber',
]);
}
$entry['is_winner'] = $isWinner;
$entry['winner_reason'] = $isWinner ? $winnerReason : null;
return $entry;
})
->values();
$winner = is_array($winner)
? array_merge($winner, ['selection_reason' => $winnerReason])
: null;
return [
'entries' => $entries->all(),
'winner' => $winner,
'stats' => [
'total' => $entries->count(),
'active' => $entries->where('is_active', true)->count(),
'inactive' => $entries->where('is_active', false)->count(),
'expired' => $entries->where('is_expired', true)->count(),
'eligible' => $entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->count(),
'ineligible' => $entries->filter(fn (array $entry): bool => ! $entry['eligibility']['is_eligible'])->count(),
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function searchArtworks(string $term, int $limit = 12): array
{
$term = trim($term);
if ($term === '') {
return [];
}
$now = Carbon::now();
$artworks = Artwork::query()
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
])
->where(function ($query) use ($term): void {
if (ctype_digit($term)) {
$query->where('artworks.id', (int) $term);
}
$query->orWhere('artworks.title', 'like', '%' . $term . '%')
->orWhere('artworks.slug', 'like', '%' . $term . '%')
->orWhereHas('user', function ($userQuery) use ($term): void {
$userQuery->where('username', 'like', '%' . $term . '%')
->orWhere('name', 'like', '%' . $term . '%');
})
->orWhereHas('group', function ($groupQuery) use ($term): void {
$groupQuery->where('name', 'like', '%' . $term . '%')
->orWhere('slug', 'like', '%' . $term . '%');
});
});
if (ctype_digit($term)) {
$artworks->orderByRaw('CASE WHEN artworks.id = ? THEN 0 ELSE 1 END', [(int) $term]);
}
$artworks = $artworks
->orderByDesc('published_at')
->limit($limit)
->get();
$featureCounts = ArtworkFeature::query()
->whereNull('deleted_at')
->whereIn('artwork_id', $artworks->pluck('id'))
->selectRaw('artwork_id, COUNT(*) as aggregate')
->groupBy('artwork_id')
->pluck('aggregate', 'artwork_id');
return $artworks
->map(fn (Artwork $artwork): array => $this->mapArtworkCandidate($artwork, (int) ($featureCounts[(int) $artwork->id] ?? 0), $now))
->values()
->all();
}
/**
* @param SupportCollection<int, int> $duplicateCounts
* @return array<string, mixed>
*/
private function mapFeature(ArtworkFeature $feature, SupportCollection $duplicateCounts, Carbon $now): array
{
$context = $this->mapArtworkContext($feature->artwork, $now);
$isExpired = $feature->expires_at !== null && $feature->expires_at->lte($now);
$isEligible = (bool) $feature->is_active && ! $isExpired && (bool) $context['eligibility']['is_eligible'];
$eligibilityReasons = $context['eligibility']['reasons'];
if (! $feature->is_active) {
$eligibilityReasons[] = 'Inactive';
}
if ($isExpired) {
$eligibilityReasons[] = 'Expired';
}
$statusBadges = [];
if ($feature->is_active && ! $isExpired) {
$statusBadges[] = ['label' => 'Active', 'tone' => 'emerald'];
}
if ((bool) $feature->force_hero) {
$statusBadges[] = ['label' => 'Force Hero', 'tone' => 'amber'];
}
if (! $feature->is_active) {
$statusBadges[] = ['label' => 'Inactive', 'tone' => 'slate'];
}
if ($isExpired) {
$statusBadges[] = ['label' => 'Expired', 'tone' => 'amber'];
}
$statusBadges[] = $isEligible
? ['label' => 'Eligible', 'tone' => 'sky']
: ['label' => 'Not eligible', 'tone' => 'rose'];
if ((bool) $context['flags']['is_private']) {
$statusBadges[] = ['label' => 'Private', 'tone' => 'slate'];
}
if ((bool) $context['flags']['is_unpublished']) {
$statusBadges[] = ['label' => 'Unpublished', 'tone' => 'slate'];
}
if ((bool) $context['flags']['missing_preview']) {
$statusBadges[] = ['label' => 'Missing preview', 'tone' => 'rose'];
}
if ((bool) $context['flags']['is_deleted']) {
$statusBadges[] = ['label' => 'Deleted', 'tone' => 'slate'];
}
if ((int) $duplicateCounts->get((int) $feature->artwork_id, 0) > 1) {
$statusBadges[] = ['label' => 'Duplicate', 'tone' => 'sky'];
}
return [
'id' => (int) $feature->id,
'artwork_id' => (int) $feature->artwork_id,
'priority' => (int) $feature->priority,
'featured_at' => $feature->featured_at?->toIsoString(),
'expires_at' => $feature->expires_at?->toIsoString(),
'created_at' => $feature->created_at?->toIsoString(),
'updated_at' => $feature->updated_at?->toIsoString(),
'is_active' => (bool) $feature->is_active,
'is_force_hero' => (bool) $feature->force_hero,
'is_expired' => $isExpired,
'duplicate_count' => (int) $duplicateCounts->get((int) $feature->artwork_id, 0),
'artwork' => $context['artwork'],
'medals' => $context['medals'],
'eligibility' => [
'is_eligible' => $isEligible,
'reasons' => array_values(array_unique($eligibilityReasons)),
],
'status_badges' => $statusBadges,
'is_winner' => false,
'winner_reason' => null,
];
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCandidate(Artwork $artwork, int $existingFeatureCount, Carbon $now): array
{
$context = $this->mapArtworkContext($artwork, $now);
return array_merge($context['artwork'], [
'medals' => $context['medals'],
'eligibility' => $context['eligibility'],
'existing_feature_count' => $existingFeatureCount,
'already_featured' => $existingFeatureCount > 0,
]);
}
/**
* @return array<string, mixed>
*/
private function mapArtworkContext(?Artwork $artwork, Carbon $now): array
{
if (! $artwork instanceof Artwork) {
return [
'artwork' => [
'id' => null,
'title' => 'Missing artwork',
'slug' => null,
'canonical_url' => null,
'thumbnail' => ThumbnailPresenter::present(['id' => null, 'name' => 'Missing artwork'], 'sm'),
'published_at' => null,
'visibility' => null,
'is_public' => false,
'is_approved' => false,
'has_missing_preview' => true,
'is_deleted' => true,
'owner' => null,
],
'medals' => [
'score_30d' => 0,
],
'eligibility' => [
'is_eligible' => false,
'reasons' => ['Deleted'],
],
'flags' => [
'is_private' => false,
'is_unpublished' => true,
'missing_preview' => true,
'is_deleted' => true,
],
];
}
$isDeleted = $artwork->deleted_at !== null;
$isPublic = ! $isDeleted && (bool) $artwork->is_public;
$isApproved = ! $isDeleted && (bool) $artwork->is_approved;
$isPublished = ! $isDeleted && $artwork->published_at !== null && $artwork->published_at->lte($now);
$hasPreview = ! (bool) $artwork->has_missing_thumbnails;
$owner = $this->mapOwner($artwork);
$reasons = [];
if ($isDeleted) {
$reasons[] = 'Deleted';
}
if (! $isPublic) {
$reasons[] = 'Private';
}
if (! $isApproved) {
$reasons[] = 'Not approved';
}
if (! $isPublished) {
$reasons[] = 'Unpublished';
}
if (! $hasPreview) {
$reasons[] = 'Missing preview';
}
return [
'artwork' => [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'slug' => (string) $artwork->slug,
'canonical_url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $this->artworkSlug($artwork)]),
'thumbnail' => ThumbnailPresenter::present($artwork, 'sm'),
'published_at' => $artwork->published_at?->toIsoString(),
'visibility' => (string) ($artwork->visibility ?? ''),
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'has_missing_preview' => (bool) $artwork->has_missing_thumbnails,
'is_deleted' => $isDeleted,
'owner' => $owner,
],
'medals' => [
'score_30d' => (int) ($artwork->awardStat?->score_30d ?? 0),
],
'eligibility' => [
'is_eligible' => $isPublic && $isApproved && $isPublished && $hasPreview,
'reasons' => $reasons,
],
'flags' => [
'is_private' => ! $isPublic,
'is_unpublished' => ! $isPublished,
'missing_preview' => ! $hasPreview,
'is_deleted' => $isDeleted,
],
];
}
/**
* @param SupportCollection<int, array<string, mixed>> $entries
* @return SupportCollection<int, array<string, mixed>>
*/
private function sortForHeroSelection(SupportCollection $entries): SupportCollection
{
return $entries
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
}
/**
* @param array<string, mixed> $winner
* @param SupportCollection<int, array<string, mixed>> $eligibleEntries
*/
private function buildWinnerReason(array $winner, SupportCollection $eligibleEntries): string
{
if ((bool) ($winner['is_force_hero'] ?? false)) {
return 'Forced hero override is enabled for this featured artwork.';
}
$runnerUp = $eligibleEntries->skip(1)->first();
if (! is_array($runnerUp)) {
return 'Only eligible featured artwork right now.';
}
if ((int) $winner['priority'] > (int) $runnerUp['priority']) {
return 'Highest priority among active, eligible featured artworks.';
}
if ((int) $winner['medals']['score_30d'] > (int) $runnerUp['medals']['score_30d']) {
return 'Tied on priority, won on higher 30-day medal score.';
}
if ($this->timestamp($winner['featured_at']) > $this->timestamp($runnerUp['featured_at'])) {
return 'Tied on priority and medal score, won on newer featured date.';
}
if ($this->timestamp($winner['artwork']['published_at']) > $this->timestamp($runnerUp['artwork']['published_at'])) {
return 'Tied on priority, medal score, and featured date, won on newer published date.';
}
return 'Selected by the shared homepage hero ordering.';
}
/**
* @return array<string, mixed>|null
*/
private function mapOwner(Artwork $artwork): ?array
{
if ($artwork->group) {
return [
'type' => 'group',
'display_name' => (string) $artwork->group->name,
'username' => (string) $artwork->group->slug,
'profile_url' => $artwork->group->publicUrl(),
];
}
if (! $artwork->user) {
return null;
}
return [
'type' => 'user',
'display_name' => (string) ($artwork->user->name ?: '@' . $artwork->user->username),
'username' => (string) $artwork->user->username,
'profile_url' => $artwork->user->username !== '' ? '/@' . $artwork->user->username : null,
];
}
private function artworkSlug(Artwork $artwork): string
{
$slug = trim((string) $artwork->slug);
if ($slug !== '') {
return $slug;
}
$titleSlug = Str::slug((string) $artwork->title);
return $titleSlug !== '' ? $titleSlug : (string) $artwork->id;
}
private function timestamp(?string $value): int
{
if (! is_string($value) || trim($value) === '') {
return 0;
}
return (int) (strtotime($value) ?: 0);
}
}

View File

@@ -8,6 +8,7 @@ use App\Models\Artwork;
use App\Models\Collection; use App\Models\Collection;
use App\Models\Group; use App\Models\Group;
use App\Models\User; use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
@@ -32,6 +33,7 @@ class GroupService
private readonly GroupActivityService $activity, private readonly GroupActivityService $activity,
private readonly GroupHistoryService $history, private readonly GroupHistoryService $history,
private readonly GroupReputationService $reputation, private readonly GroupReputationService $reputation,
private readonly ArtworkMaturityService $maturity,
) { ) {
} }
@@ -372,6 +374,8 @@ class GroupService
->where('is_approved', true) ->where('is_approved', true)
->whereNotNull('published_at'); ->whereNotNull('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
if ((int) ($group->featured_artwork_id ?? 0) > 0) { if ((int) ($group->featured_artwork_id ?? 0) > 0) {
$featuredArtwork = (clone $query) $featuredArtwork = (clone $query)
->where('id', (int) $group->featured_artwork_id) ->where('id', (int) $group->featured_artwork_id)
@@ -461,14 +465,18 @@ class GroupService
public function publicArtworkCards(Group $group, int $limit = 18): array public function publicArtworkCards(Group $group, int $limit = 18): array
{ {
return Artwork::query() $query = Artwork::query()
->with(['user.profile', 'group', 'primaryAuthor.profile']) ->with(['user.profile', 'group', 'primaryAuthor.profile'])
->where('group_id', $group->id) ->where('group_id', $group->id)
->whereNull('deleted_at') ->whereNull('deleted_at')
->where('is_public', true) ->where('is_public', true)
->where('is_approved', true) ->where('is_approved', true)
->whereNotNull('published_at') ->whereNotNull('published_at')
->latest('published_at') ->latest('published_at');
$this->maturity->applyViewerFilter($query, request()->user());
return $query
->limit($limit) ->limit($limit)
->get() ->get()
->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork)) ->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork))
@@ -493,7 +501,7 @@ class GroupService
private function mapPublicArtworkCard(Artwork $artwork): array private function mapPublicArtworkCard(Artwork $artwork): array
{ {
return [ return $this->maturity->decoratePayload([
'id' => (int) $artwork->id, 'id' => (int) $artwork->id,
'title' => (string) $artwork->title, 'title' => (string) $artwork->title,
'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]), 'url' => route('art.show', ['id' => $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: $artwork->id]),
@@ -501,7 +509,7 @@ class GroupService
'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork), 'thumb_srcset' => ThumbnailPresenter::srcsetForArtwork($artwork),
'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist', 'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist',
'published_at' => $artwork->published_at?->toISOString(), 'published_at' => $artwork->published_at?->toISOString(),
]; ], $artwork, request()->user());
} }
public function studioDashboardSummary(Group $group): array public function studioDashboardSummary(Group $group): array

View File

@@ -14,6 +14,7 @@ use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService; use App\Services\UserPreferenceService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel; use App\Models\Collection as CollectionModel;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
use App\Services\Maturity\ArtworkMaturityService;
/** /**
* HomepageService * HomepageService
@@ -32,9 +34,11 @@ use cPad\Plugins\News\Models\NewsArticle;
final class HomepageService final class HomepageService
{ {
private const CACHE_TTL = 300; // 5 minutes private const CACHE_TTL = 300; // 5 minutes
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
private const ARTWORK_SERIALIZATION_RELATIONS = [ private const ARTWORK_SERIALIZATION_RELATIONS = [
'user:id,name,username', 'user:id,name,username',
'user.profile:user_id,avatar_hash', 'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,sort_order', 'categories:id,name,slug,content_type_id,sort_order',
'categories.contentType:id,name,slug', 'categories.contentType:id,name,slug',
]; ];
@@ -42,6 +46,7 @@ final class HomepageService
public function __construct( public function __construct(
private readonly ArtworkService $artworks, private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search, private readonly ArtworkSearchService $search,
private readonly ArtworkMaturityService $maturity,
private readonly UserPreferenceService $prefs, private readonly UserPreferenceService $prefs,
private readonly RecommendationFeedResolver $feedResolver, private readonly RecommendationFeedResolver $feedResolver,
private readonly GridFiller $gridFiller, private readonly GridFiller $gridFiller,
@@ -60,9 +65,60 @@ final class HomepageService
* Return all homepage section data as a single array ready to JSON-encode. * Return all homepage section data as a single array ready to JSON-encode.
*/ */
public function all(): array public function all(): array
{
return $this->guestPayloadCache()->remember(
$this->guestPayloadCacheKey(),
$this->guestPayloadCacheTtl(),
fn (): array => $this->buildGuestPayload(),
);
}
public function warmGuestPayloadCache(): array
{
$payload = $this->buildGuestPayload();
$this->guestPayloadCache()->put(
$this->guestPayloadCacheKey(),
$payload,
$this->guestPayloadCacheTtl(),
);
return $payload;
}
public function clearGuestPayloadCache(): void
{
$this->guestPayloadCache()->forget($this->guestPayloadCacheKey());
}
public function clearFeaturedAndMedalCaches(): void
{
$this->clearGuestPayloadCache();
foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
Cache::forget("homepage.hero.{$segment}");
Cache::forget("homepage.community-favorites.8.{$segment}");
Cache::forget("homepage.hall-of-fame.8.{$segment}");
}
}
public function guestPayloadCacheStoreName(): string
{
$configuredStore = (string) config('homepage.cache_store', 'homepage');
if (is_array(config('cache.stores.' . $configuredStore))) {
return $configuredStore;
}
return (string) config('cache.default', 'database');
}
private function buildGuestPayload(): array
{ {
return [ return [
'hero' => $this->getHeroArtwork(), 'hero' => $this->getHeroArtwork(),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'rising' => $this->getRising(), 'rising' => $this->getRising(),
'trending' => $this->getTrending(), 'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(), 'fresh' => $this->getFreshUploads(),
@@ -77,6 +133,21 @@ final class HomepageService
]; ];
} }
private function guestPayloadCache(): CacheRepository
{
return Cache::store($this->guestPayloadCacheStoreName());
}
private function guestPayloadCacheKey(): string
{
return (string) config('homepage.guest_payload_key', 'homepage.payload.guest');
}
private function guestPayloadCacheTtl(): int
{
return max(60, (int) config('homepage.guest_payload_ttl_seconds', 1800));
}
/** /**
* Personalized homepage data for an authenticated user. * Personalized homepage data for an authenticated user.
* *
@@ -97,6 +168,8 @@ final class HomepageService
'is_logged_in' => true, 'is_logged_in' => true,
'user_data' => $this->getUserData($user), 'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(), 'hero' => $this->getHeroArtwork(),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'for_you' => $this->getForYouPreview($user), 'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs), 'from_following' => $this->getFollowingFeed($user, $prefs),
'rising' => $this->getRising(), 'rising' => $this->getRising(),
@@ -127,13 +200,15 @@ final class HomepageService
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{ {
try { try {
$feed = $this->feedResolver->getFeed((int) $user->id, $limit); $feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
$algoVersion = (string) ($feed['meta']['algo_version'] ?? ''); $algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
$discoveryEndpoint = route('api.discovery.events.store'); $discoveryEndpoint = route('api.discovery.events.store');
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork'); $hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag'); $dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array { return $this->filterMissingThumbnailPayloadItems(collect($feed['data'] ?? []))
->take($limit)
->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you'); $reason = (string) ($item['reason'] ?? 'Picked for you');
return [ return [
@@ -146,6 +221,8 @@ final class HomepageService
'author_username' => (string) ($item['username'] ?? ''), 'author_username' => (string) ($item['username'] ?? ''),
'author_avatar' => $item['avatar_url'] ?? null, 'author_avatar' => $item['avatar_url'] ?? null,
'avatar_url' => $item['avatar_url'] ?? null, 'avatar_url' => $item['avatar_url'] ?? null,
'published_as_type' => (string) ($item['published_as_type'] ?? ''),
'publisher' => is_array($item['publisher'] ?? null) ? $item['publisher'] : null,
'thumb' => $item['thumbnail_url'] ?? null, 'thumb' => $item['thumbnail_url'] ?? null,
'thumb_url' => $item['thumbnail_url'] ?? null, 'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null, 'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
@@ -276,18 +353,18 @@ final class HomepageService
*/ */
public function getHeroArtwork(): ?array public function getHeroArtwork(): ?array
{ {
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array { return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
$result = $this->artworks->getFeaturedArtworks(null, 1); $artwork = $this->artworks->getFeaturedArtworkWinner();
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */ if (! $artwork instanceof Artwork) {
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) { $artwork = Artwork::query()
$artwork = $result->getCollection()->first(); ->public()
} elseif ($result instanceof \Illuminate\Support\Collection) { ->published()
$artwork = $result->first(); ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
} elseif (is_array($result)) { ->withoutMissingThumbnails()
$artwork = $result[0] ?? null; ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
} else { ->latest('published_at')
$artwork = null; ->first();
} }
if ($artwork instanceof Artwork) { if ($artwork instanceof Artwork) {
@@ -298,6 +375,70 @@ final class HomepageService
}); });
} }
public function getCommunityFavorites(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
{
return Cache::remember("homepage.community-favorites.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
try {
$artworks = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]))
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->whereRaw('COALESCE(aas.score_30d, 0) > 0')
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
->orderByDesc('artworks.published_at')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'community_favorites'))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getCommunityFavorites failed', ['error' => $e->getMessage()]);
return [];
}
});
}
public function getHallOfFame(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
{
return Cache::remember("homepage.hall-of-fame.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
try {
$artworks = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]))
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->whereRaw('COALESCE(aas.score_total, 0) > 0')
->orderByRaw('COALESCE(aas.score_total, 0) DESC')
->orderByRaw('COALESCE(aas.last_medaled_at, artworks.published_at) DESC')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'hall_of_fame'))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getHallOfFame failed', ['error' => $e->getMessage()]);
return [];
}
});
}
/** /**
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min). * Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
* *
@@ -308,14 +449,12 @@ final class HomepageService
{ {
$cutoff = now()->subDays(30)->toDateString(); $cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array { return Cache::remember("homepage.rising.{$limit}.{$this->viewerCacheSegment()}", 120, function () use ($limit, $cutoff): array {
try { try {
$results = Artwork::search('') $results = $this->search->searchWithThumbnailPreference([
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
]) ], $limit, true, 1);
->paginate($limit, 'page', 1);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results)); $items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
@@ -327,7 +466,7 @@ final class HomepageService
return $this->getRisingLowSignalFromDb($limit); return $this->getRisingLowSignalFromDb($limit);
} }
return $items return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
->values() ->values()
->all(); ->all();
@@ -346,8 +485,10 @@ final class HomepageService
*/ */
private function getRisingFromDb(int $limit): array private function getRisingFromDb(int $limit): array
{ {
return Artwork::public() $artworks = Artwork::public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*') ->select('artworks.*')
@@ -355,7 +496,9 @@ final class HomepageService
->orderByDesc('artwork_stats.heat_score') ->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity') ->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit) ->limit($limit)
->get() ->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
->values() ->values()
->all(); ->all();
@@ -363,8 +506,10 @@ final class HomepageService
private function getRisingLowSignalFromDb(int $limit): array private function getRisingLowSignalFromDb(int $limit): array
{ {
return Artwork::public() $artworks = Artwork::public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void { ->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id'); $join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
@@ -375,7 +520,9 @@ final class HomepageService
->orderByDesc('artworks.published_at') ->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id') ->orderByDesc('artworks.id')
->limit($limit) ->limit($limit)
->get() ->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
->values() ->values()
->all(); ->all();
@@ -392,14 +539,12 @@ final class HomepageService
{ {
$cutoff = now()->subDays(30)->toDateString(); $cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit, $cutoff): array { return Cache::remember("homepage.trending.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
try { try {
$results = Artwork::search('') $results = $this->search->searchWithThumbnailPreference([
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
]) ], $limit, true, 1);
->paginate($limit, 'page', 1);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results)); $items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
@@ -407,7 +552,7 @@ final class HomepageService
return $this->getTrendingFromDb($limit); return $this->getTrendingFromDb($limit);
} }
return $items return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
->values() ->values()
->all(); ->all();
@@ -427,8 +572,10 @@ final class HomepageService
*/ */
private function getTrendingFromDb(int $limit): array private function getTrendingFromDb(int $limit): array
{ {
return Artwork::public() $artworks = Artwork::public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*') ->select('artworks.*')
@@ -436,7 +583,9 @@ final class HomepageService
->orderByDesc('artwork_stats.ranking_score') ->orderByDesc('artwork_stats.ranking_score')
->orderByDesc('artwork_stats.engagement_velocity') ->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit) ->limit($limit)
->get() ->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
->values() ->values()
->all(); ->all();
@@ -450,11 +599,13 @@ final class HomepageService
{ {
// Include EGS mode in cache key so toggling EGS updates the section within TTL // Include EGS mode in cache key so toggling EGS updates the section within TTL
$egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std'; $egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std';
$cacheKey = "homepage.fresh.{$limit}.{$egsKey}"; $cacheKey = "homepage.fresh.{$limit}.{$egsKey}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array { return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public() $artworks = Artwork::public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->orderByDesc('published_at') ->orderByDesc('published_at')
->limit($limit) ->limit($limit)
@@ -541,6 +692,7 @@ final class HomepageService
$latestArtworkIds = Artwork::public() $latestArtworkIds = Artwork::public()
->published() ->published()
->withoutMissingThumbnails()
->whereIn('user_id', $userIds) ->whereIn('user_id', $userIds)
->whereNotNull('hash') ->whereNotNull('hash')
->whereNotNull('thumb_ext') ->whereNotNull('thumb_ext')
@@ -698,7 +850,7 @@ final class HomepageService
'u.username', 'u.username',
'up.avatar_hash', 'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'), DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.artworks_count, 0) as artworks_count'), DB::raw('COALESCE(us.uploads_count, 0) as artworks_count'),
) )
->where('u.id', '!=', $user->id) ->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id])) ->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
@@ -738,11 +890,13 @@ final class HomepageService
} }
return Cache::remember( return Cache::remember(
"homepage.following.{$user->id}", "homepage.following.{$user->id}.{$this->viewerCacheSegment()}",
60, // short TTL personal data 60, // short TTL personal data
function () use ($followingIds): array { function () use ($followingIds): array {
$artworks = Artwork::public() $artworks = Artwork::public()
->published() ->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->whereIn('user_id', $followingIds) ->whereIn('user_id', $followingIds)
->orderByDesc('published_at') ->orderByDesc('published_at')
@@ -766,7 +920,13 @@ final class HomepageService
try { try {
$results = $this->search->discoverByTags($tagSlugs, 12); $results = $this->search->discoverByTags($tagSlugs, 12);
$items = $this->searchResultCollection($results); $items = $this->fillArtworkRailFromArchive(
$this->searchResultCollection($results),
12,
static fn ($query) => $query->whereHas('tags', function ($tagQuery) use ($tagSlugs): void {
$tagQuery->whereIn('slug', array_slice($tagSlugs, 0, 5));
}),
);
return $items return $items
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
@@ -790,7 +950,13 @@ final class HomepageService
try { try {
$results = $this->search->discoverByCategories($categorySlugs, 12); $results = $this->search->discoverByCategories($categorySlugs, 12);
$items = $this->searchResultCollection($results); $items = $this->fillArtworkRailFromArchive(
$this->searchResultCollection($results),
12,
static fn ($query) => $query->whereHas('categories', function ($categoryQuery) use ($categorySlugs): void {
$categoryQuery->whereIn('slug', array_slice($categorySlugs, 0, 3));
}),
);
return $items return $items
->map(fn ($a) => $this->serializeArtwork($a)) ->map(fn ($a) => $this->serializeArtwork($a))
@@ -839,9 +1005,89 @@ final class HomepageService
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS); $artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
return $artworks
->reject(fn ($artwork) => (bool) ($artwork->has_missing_thumbnails ?? false))
->values();
}
/**
* Backfill sparse homepage rails with recent archive artworks while preserving lead ordering.
*
* @param Collection<int, Artwork> $artworks
* @return Collection<int, Artwork>
*/
private function fillArtworkRailFromArchive(Collection $artworks, int $limit, ?callable $fallbackConstraint = null): Collection
{
$artworks = $this->prepareArtworksForSerialization($artworks)->take($limit)->values();
if ($artworks->count() >= $limit) {
return $artworks; return $artworks;
} }
$needed = $limit - $artworks->count();
$excludeIds = $artworks
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values()
->all();
$fallback = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->when($fallbackConstraint !== null, fn ($query) => $fallbackConstraint($query))
->when(! empty($excludeIds), fn ($query) => $query->whereNotIn('artworks.id', $excludeIds))
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($needed)
->get();
return $artworks
->concat($fallback)
->unique('id')
->take($limit)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $items
* @return Collection<int, array<string, mixed>>
*/
private function filterMissingThumbnailPayloadItems(Collection $items): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items;
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items;
}
return $items
->reject(fn (array $item) => $missingIds->has((int) ($item['id'] ?? 0)))
->values();
}
private function collectionHasNoRisingMomentum(Collection $items): bool private function collectionHasNoRisingMomentum(Collection $items): bool
{ {
if ($items->isEmpty()) { if ($items->isEmpty()) {
@@ -875,18 +1121,32 @@ final class HomepageService
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{ {
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
$thumbSm = $artwork->thumbUrl('sm');
$thumbMd = $artwork->thumbUrl('md'); $thumbMd = $artwork->thumbUrl('md');
$thumbLg = $artwork->thumbUrl('lg'); $thumbLg = $artwork->thumbUrl('lg');
$thumbXl = $artwork->thumbUrl('xl');
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg); $thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
$primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$authorId = $artwork->user_id; $thumbSrcset = collect([
$authorName = $artwork->user?->name ?? 'Artist'; $thumbSm ? $thumbSm . ' 320w' : null,
$authorUsername = $artwork->user?->username ?? ''; $thumbMd ? $thumbMd . ' 640w' : null,
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null; $thumbLg ? $thumbLg . ' 1280w' : null,
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64); $thumbXl ? $thumbXl . ' 1920w' : null,
])->filter()->implode(', ');
return [ $publisher = $this->mapArtworkPublisherPayload($artwork);
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
$authorId = $artwork->user_id;
$authorName = $isGroupPublisher ? ((string) ($publisher['name'] ?? 'Skinbase Group')) : ($artwork->user?->name ?? 'Artist');
$authorUsername = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = $isGroupPublisher
? ($publisher['avatar_url'] ?? null)
: AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
return $this->maturity->decoratePayload([
'id' => $artwork->id, 'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled', 'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug, 'slug' => $artwork->slug,
@@ -894,9 +1154,14 @@ final class HomepageService
'author_id' => $authorId, 'author_id' => $authorId,
'author_username' => $authorUsername, 'author_username' => $authorUsername,
'author_avatar' => $authorAvatar, 'author_avatar' => $authorAvatar,
'published_as_type' => $artwork->publishedAsType(),
'publisher' => $publisher,
'thumb' => $thumb, 'thumb' => $thumb,
'thumb_sm' => $thumbSm,
'thumb_md' => $thumbMd, 'thumb_md' => $thumbMd,
'thumb_lg' => $thumbLg, 'thumb_lg' => $thumbLg,
'thumb_xl' => $thumbXl,
'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null,
'category_name' => $primaryCategory->name ?? '', 'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '', 'category_slug' => $primaryCategory->slug ?? '',
'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '',
@@ -905,6 +1170,65 @@ final class HomepageService
'width' => $artwork->width, 'width' => $artwork->width,
'height' => $artwork->height, 'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(), 'published_at' => $artwork->published_at?->toIso8601String(),
'medals' => [
'gold' => (int) ($awardStat?->gold_count ?? 0),
'silver' => (int) ($awardStat?->silver_count ?? 0),
'bronze' => (int) ($awardStat?->bronze_count ?? 0),
'score' => (int) ($awardStat?->score_total ?? 0),
'score_7d' => (int) ($awardStat?->score_7d ?? 0),
'score_30d' => (int) ($awardStat?->score_30d ?? 0),
],
], $artwork, request()->user());
}
/**
* @return array<string, mixed>|null
*/
private function mapArtworkPublisherPayload(Artwork $artwork): ?array
{
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
return null;
}
$group = $artwork->relationLoaded('group') ? $artwork->group : $artwork->group()->first();
if (! $group) {
return null;
}
return [
'id' => (int) $group->id,
'type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => (string) ($group->headline ?? ''),
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
]; ];
} }
private function serializeArtworkWithMedalBadge(Artwork $artwork, string $surface): array
{
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
$payload = $this->serializeArtwork($artwork);
$score = $surface === 'community_favorites'
? (int) ($awardStat?->score_30d ?? 0)
: (int) ($awardStat?->score_total ?? 0);
$payload['metric_badge'] = [
'label' => $surface === 'community_favorites'
? '30d medals: ' . $score
: 'All-time medals: ' . $score,
'className' => $surface === 'community_favorites'
? 'bg-amber-500/14 text-amber-100 ring-amber-300/30'
: 'bg-cyan-500/14 text-cyan-100 ring-cyan-300/30',
];
return $payload;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
} }

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Services\Maturity;
use App\Models\Artwork;
use App\Models\ArtworkMaturityAuditFinding;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class ArtworkMaturityAuditService
{
public function eligibleArtworkQuery(bool $includeExistingOpenFindings = false): Builder
{
$query = Artwork::query()
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->whereRaw('TRIM(hash) != ?',[ '' ])
->whereRaw('TRIM(thumb_ext) != ?',[ '' ]);
$this->applyLegacyUnsetFilter($query);
if (! $includeExistingOpenFindings && Schema::hasTable('artwork_maturity_audit_findings')) {
$query->whereDoesntHave('maturityAuditFinding', function (Builder $finding): void {
$finding->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN);
});
}
return $query;
}
public function openFindingsQuery(): Builder
{
return ArtworkMaturityAuditFinding::query()
->with(['artwork.user.profile', 'artwork.group', 'artwork.categories.contentType'])
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
->whereHas('artwork', function (Builder $query): void {
$this->applyLegacyUnsetFilter($query);
});
}
public function openFindingsCount(): int
{
if (! Schema::hasTable('artwork_maturity_audit_findings')) {
return 0;
}
return (int) $this->openFindingsQuery()->count();
}
public function isArtworkEligible(Artwork $artwork): bool
{
return ! (bool) $artwork->is_mature
&& in_array((string) ($artwork->maturity_level ?? ArtworkMaturityService::LEVEL_SAFE), ['', ArtworkMaturityService::LEVEL_SAFE], true)
&& in_array((string) ($artwork->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR), ['', ArtworkMaturityService::STATUS_CLEAR], true)
&& in_array((string) ($artwork->maturity_source ?? ArtworkMaturityService::SOURCE_LEGACY), ['', ArtworkMaturityService::SOURCE_LEGACY], true)
&& $artwork->maturity_declared_at === null
&& $artwork->maturity_reviewed_at === null;
}
/**
* @param array<string, mixed> $assessment
*/
public function shouldOpenFinding(array $assessment): bool
{
$status = Str::lower(trim((string) ($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED)));
if ($status !== ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
return false;
}
$actionHint = Str::lower(trim((string) ($assessment['action_hint'] ?? '')));
if (in_array($actionHint, [ArtworkMaturityService::AI_ACTION_REVIEW, ArtworkMaturityService::AI_ACTION_FLAG_HIGH], true)) {
return true;
}
$label = Str::lower(trim((string) ($assessment['maturity_label'] ?? '')));
$confidence = is_numeric($assessment['confidence'] ?? null) ? (float) $assessment['confidence'] : 0.0;
return $label === ArtworkMaturityService::LEVEL_MATURE
&& $confidence >= (float) config('maturity.ai.threshold', 0.68);
}
/**
* @param array<string, mixed> $assessment
*/
public function recordFinding(Artwork $artwork, array $assessment, string $thumbnailVariant): ArtworkMaturityAuditFinding
{
$finding = ArtworkMaturityAuditFinding::query()->updateOrCreate(
['artwork_id' => (int) $artwork->id],
[
'status' => ArtworkMaturityAuditFinding::STATUS_OPEN,
'thumbnail_variant' => $thumbnailVariant,
'ai_label' => $this->nullableLowerString($assessment['maturity_label'] ?? null),
'ai_confidence' => $this->nullableFloat($assessment['confidence'] ?? null),
'ai_score' => $this->nullableFloat($assessment['score'] ?? ($assessment['confidence'] ?? null)),
'ai_labels' => $this->normalizeLabels($assessment['labels'] ?? []),
'ai_model' => $this->nullableString($assessment['model'] ?? null),
'ai_threshold_used' => $this->nullableFloat($assessment['threshold_used'] ?? null),
'ai_analysis_time_ms' => is_numeric($assessment['analysis_time_ms'] ?? null) ? (int) $assessment['analysis_time_ms'] : null,
'ai_action_hint' => $this->nullableLowerString($assessment['action_hint'] ?? null),
'ai_status' => $this->nullableLowerString($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) ?? ArtworkMaturityService::AI_STATUS_FAILED,
'ai_advisory' => $this->nullableString($assessment['advisory'] ?? null),
'detected_at' => now(),
'last_scanned_at' => now(),
'resolution_action' => null,
'resolution_note' => null,
'resolved_by' => null,
'resolved_at' => null,
],
);
return $finding->fresh(['artwork']);
}
public function markFindingCleared(Artwork $artwork, ?string $note = null): void
{
ArtworkMaturityAuditFinding::query()
->where('artwork_id', (int) $artwork->id)
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
->update([
'status' => ArtworkMaturityAuditFinding::STATUS_CLEARED,
'resolution_action' => 'auto_cleared',
'resolution_note' => $note,
'resolved_at' => now(),
'last_scanned_at' => now(),
]);
}
public function resolveFindingForReview(Artwork $artwork, Authenticatable $moderator, string $action, ?string $note = null): void
{
$moderatorId = (int) $moderator->getAuthIdentifier();
ArtworkMaturityAuditFinding::query()
->where('artwork_id', (int) $artwork->id)
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
->update([
'status' => ArtworkMaturityAuditFinding::STATUS_REVIEWED,
'resolution_action' => Str::lower(trim($action)),
'resolution_note' => $note,
'resolved_by' => $moderatorId,
'resolved_at' => now(),
'last_scanned_at' => now(),
]);
}
private function applyLegacyUnsetFilter(Builder $query): Builder
{
return $query
->where(function (Builder $builder): void {
$builder->whereNull('maturity_declared_at')
->whereNull('maturity_reviewed_at')
->where(function (Builder $state): void {
$state->whereNull('maturity_source')
->orWhere('maturity_source', ArtworkMaturityService::SOURCE_LEGACY);
})
->where(function (Builder $state): void {
$state->whereNull('maturity_status')
->orWhere('maturity_status', ArtworkMaturityService::STATUS_CLEAR);
})
->where(function (Builder $state): void {
$state->whereNull('maturity_level')
->orWhere('maturity_level', ArtworkMaturityService::LEVEL_SAFE);
})
->where(function (Builder $state): void {
$state->whereNull('is_mature')
->orWhere('is_mature', false);
});
});
}
/**
* @param mixed $value
*/
private function nullableFloat(mixed $value): ?float
{
return is_numeric($value) ? (float) $value : null;
}
/**
* @param mixed $value
*/
private function nullableString(mixed $value): ?string
{
$resolved = trim((string) $value);
return $resolved !== '' ? $resolved : null;
}
/**
* @param mixed $value
*/
private function nullableLowerString(mixed $value): ?string
{
$resolved = $this->nullableString($value);
return $resolved !== null ? Str::lower($resolved) : null;
}
/**
* @param mixed $value
* @return list<string>|null
*/
private function normalizeLabels(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$labels = array_values(array_filter(array_map(
static fn (mixed $label): string => Str::lower(trim((string) $label)),
$value,
)));
return $labels !== [] ? array_values(array_unique($labels)) : null;
}
}

View File

@@ -0,0 +1,562 @@
<?php
declare(strict_types=1);
namespace App\Services\Maturity;
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkSearchIndexer;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class ArtworkMaturityService
{
public const LEVEL_SAFE = 'safe';
public const LEVEL_MATURE = 'mature';
public const SOURCE_AI = 'ai';
public const SOURCE_LEGACY = 'legacy';
public const SOURCE_MODERATOR = 'moderator';
public const SOURCE_USER = 'user';
public const STATUS_CLEAR = 'clear';
public const STATUS_DECLARED = 'declared';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_SUSPECTED = 'suspected';
public const AI_ACTION_SAFE = 'safe';
public const AI_ACTION_ALLOW = self::AI_ACTION_SAFE;
public const AI_ACTION_REVIEW = 'review';
public const AI_ACTION_FLAG_HIGH = 'flag_high';
public const AI_STATUS_FAILED = 'failed';
public const AI_STATUS_NOT_REQUESTED = 'not_requested';
public const AI_STATUS_PENDING = 'pending';
public const AI_STATUS_SKIPPED = 'skipped';
public const AI_STATUS_SUCCEEDED = 'succeeded';
public const VIEW_BLUR = 'blur';
public const VIEW_HIDE = 'hide';
public const VIEW_SHOW = 'show';
/**
* @var array<int, array{visibility:string,warn_on_detail:bool,is_guest:bool}>
*/
private array $viewerPreferenceCache = [];
public function __construct(private readonly ArtworkSearchIndexer $searchIndexer)
{
}
/**
* @return array{visibility:string,warn_on_detail:bool,is_guest:bool}
*/
public function viewerPreferences(?User $viewer): array
{
$defaultMode = $this->normalizeVisibilityPreference((string) config('maturity.viewer.default_mode', self::VIEW_BLUR));
$defaultWarnOnDetail = (bool) config('maturity.viewer.default_warn_on_detail', true);
if (! $viewer) {
return [
'visibility' => $defaultMode,
'warn_on_detail' => $defaultWarnOnDetail,
'is_guest' => true,
];
}
$viewerId = (int) $viewer->id;
if (isset($this->viewerPreferenceCache[$viewerId])) {
return $this->viewerPreferenceCache[$viewerId];
}
$resolved = [
'visibility' => $defaultMode,
'warn_on_detail' => $defaultWarnOnDetail,
'is_guest' => false,
];
if (Schema::hasTable('user_profiles')) {
$row = DB::table('user_profiles')
->where('user_id', $viewerId)
->first(['mature_content_visibility', 'mature_content_warning_enabled']);
if ($row !== null) {
$resolved['visibility'] = $this->normalizeVisibilityPreference((string) ($row->mature_content_visibility ?? $defaultMode));
$resolved['warn_on_detail'] = array_key_exists('mature_content_warning_enabled', (array) $row)
? (bool) $row->mature_content_warning_enabled
: $defaultWarnOnDetail;
}
}
return $this->viewerPreferenceCache[$viewerId] = $resolved;
}
public function applyViewerFilter(Builder $query, ?User $viewer): Builder
{
if ($this->viewerPreferences($viewer)['visibility'] !== self::VIEW_HIDE) {
return $query;
}
$table = $query->getModel()->getTable();
return $query
->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0')
->whereRaw("COALESCE(" . $table . ".maturity_status, '" . self::STATUS_CLEAR . "') != ?", [self::STATUS_SUSPECTED]);
}
public function appendSearchFilter(string $filter, ?User $viewer): string
{
$filter = trim($filter);
if ($this->viewerPreferences($viewer)['visibility'] !== self::VIEW_HIDE) {
return $filter;
}
$hideClause = 'is_mature_effective = false';
if ($filter === '') {
return $hideClause;
}
return $filter . ' AND ' . $hideClause;
}
public function effectiveIsMature(mixed $artwork): bool
{
$level = Str::lower((string) $this->value($artwork, 'maturity_level', self::LEVEL_SAFE));
$status = Str::lower((string) $this->value($artwork, 'maturity_status', self::STATUS_CLEAR));
$isMature = (bool) $this->value($artwork, 'is_mature', false);
return $isMature || $level === self::LEVEL_MATURE || $status === self::STATUS_SUSPECTED;
}
/**
* @return array<string, mixed>
*/
public function presentation(mixed $artwork, ?User $viewer): array
{
$preferences = $this->viewerPreferences($viewer);
$effectiveIsMature = $this->effectiveIsMature($artwork);
$visibilityMode = $preferences['visibility'];
$shouldHide = $effectiveIsMature && $visibilityMode === self::VIEW_HIDE;
$shouldBlur = $effectiveIsMature && ! $shouldHide && $visibilityMode !== self::VIEW_SHOW;
$requiresInterstitial = $effectiveIsMature && (bool) $preferences['warn_on_detail'];
$status = Str::lower((string) $this->value($artwork, 'maturity_status', self::STATUS_CLEAR));
$labels = $this->normalizeLabels($this->value($artwork, 'maturity_ai_labels', []));
return [
'effective_level' => $effectiveIsMature ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'level' => Str::lower((string) $this->value($artwork, 'maturity_level', self::LEVEL_SAFE)),
'source' => Str::lower((string) $this->value($artwork, 'maturity_source', self::SOURCE_LEGACY)),
'status' => $status,
'is_mature' => (bool) $this->value($artwork, 'is_mature', false),
'is_mature_effective' => $effectiveIsMature,
'ai_score' => $this->normalizeScore($this->value($artwork, 'maturity_ai_score')),
'ai_confidence' => $this->normalizeScore($this->value($artwork, 'maturity_ai_confidence', $this->value($artwork, 'maturity_ai_score'))),
'ai_label' => Str::lower((string) $this->value($artwork, 'maturity_ai_label', '')) ?: null,
'ai_labels' => $labels,
'ai_status' => Str::lower((string) $this->value($artwork, 'maturity_ai_status', self::AI_STATUS_NOT_REQUESTED)),
'ai_action_hint' => Str::lower((string) $this->value($artwork, 'maturity_ai_action_hint', '')) ?: null,
'ai_model' => $this->value($artwork, 'maturity_ai_model'),
'ai_threshold_used' => $this->normalizeScore($this->value($artwork, 'maturity_ai_threshold_used')),
'ai_analysis_time_ms' => is_numeric($this->value($artwork, 'maturity_ai_analysis_time_ms')) ? (int) $this->value($artwork, 'maturity_ai_analysis_time_ms') : null,
'ai_advisory' => $this->value($artwork, 'maturity_ai_advisory'),
'flag_reason' => $this->value($artwork, 'maturity_flag_reason'),
'is_flagged' => $status === self::STATUS_SUSPECTED,
'should_hide' => $shouldHide,
'should_blur' => $shouldBlur,
'requires_interstitial' => $requiresInterstitial,
'viewer_preference' => $visibilityMode,
'warning_title' => $effectiveIsMature ? 'Mature content warning' : null,
'warning_message' => $effectiveIsMature
? 'This artwork may contain mature material. Continue only if you want to view it.'
: null,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function decoratePayload(array $payload, mixed $artwork, ?User $viewer): array
{
$payload['maturity'] = $this->presentation($artwork, $viewer);
return $payload;
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
public function filterPayloadItems(array $items, ?User $viewer): array
{
return array_values(array_filter($items, function (array $item) use ($viewer): bool {
$maturity = Arr::get($item, 'maturity');
if (! is_array($maturity)) {
return true;
}
return ! (bool) ($maturity['should_hide'] ?? false);
}));
}
public function applyUploaderDeclaration(Artwork $artwork, bool $isMature): Artwork
{
$artwork->forceFill([
'is_mature' => $isMature,
'maturity_level' => $isMature ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'maturity_source' => self::SOURCE_USER,
'maturity_status' => $isMature ? self::STATUS_DECLARED : self::STATUS_CLEAR,
'maturity_declared_at' => now(),
'maturity_flagged_at' => $isMature ? $artwork->maturity_flagged_at : null,
'maturity_flag_reason' => $isMature ? $artwork->maturity_flag_reason : null,
])->saveQuietly();
$this->searchIndexer->update($artwork);
return $artwork;
}
/**
* @param array<string, mixed> $analysis
* @return array{score:float,labels:array<int,string>,flagged:bool}
*/
public function applyAiAssessment(Artwork $artwork, array $analysis): array
{
$assessment = $this->normalizeAiAssessment($analysis);
$labels = $assessment['labels'];
$aiStatus = $assessment['status'];
$flagged = $this->shouldFlagAssessment($artwork, $assessment);
$existingMismatchCount = (int) ($artwork->maturity_mismatch_count ?? 0);
$payload = [
'maturity_ai_score' => $assessment['confidence'],
'maturity_ai_labels' => $labels === [] ? null : $labels,
'maturity_ai_label' => $assessment['maturity_label'],
'maturity_ai_confidence' => $assessment['confidence'],
'maturity_ai_model' => $assessment['model'],
'maturity_ai_threshold_used' => $assessment['threshold_used'],
'maturity_ai_analysis_time_ms' => $assessment['analysis_time_ms'],
'maturity_ai_action_hint' => $assessment['action_hint'],
'maturity_ai_advisory' => $assessment['advisory'],
'maturity_ai_status' => $aiStatus,
'maturity_ai_detected_at' => now(),
];
if ($aiStatus !== self::AI_STATUS_SUCCEEDED) {
$artwork->forceFill($payload)->saveQuietly();
$this->searchIndexer->update($artwork);
return $assessment;
}
$artwork->forceFill(array_merge($payload, [
'maturity_status' => $flagged ? self::STATUS_SUSPECTED : ($artwork->is_mature ? self::STATUS_DECLARED : ($artwork->maturity_status ?: self::STATUS_CLEAR)),
'maturity_flagged_at' => $flagged ? now() : $artwork->maturity_flagged_at,
'maturity_flag_reason' => $flagged
? $this->buildAiFlagReason($assessment)
: $artwork->maturity_flag_reason,
'maturity_mismatch_count' => $flagged ? $existingMismatchCount + 1 : $existingMismatchCount,
]))->saveQuietly();
$this->searchIndexer->update($artwork);
return $assessment;
}
public function review(Artwork $artwork, string $action, Authenticatable $moderator, ?string $note = null): Artwork
{
$normalizedAction = Str::lower(trim($action));
$isMature = $this->effectiveIsMature($artwork);
$moderatorId = (int) $moderator->getAuthIdentifier();
if ($normalizedAction === 'mark_safe') {
$isMature = false;
}
if (in_array($normalizedAction, ['mark_mature', 'confirm'], true)) {
$isMature = true;
}
if ($normalizedAction === 'confirm_current') {
$isMature = $this->effectiveIsMature($artwork);
}
$artwork->forceFill([
'is_mature' => $isMature,
'maturity_level' => $isMature ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'maturity_source' => self::SOURCE_MODERATOR,
'maturity_status' => self::STATUS_REVIEWED,
'maturity_declared_at' => $isMature ? ($artwork->maturity_declared_at ?: now()) : $artwork->maturity_declared_at,
'maturity_reviewed_by' => $moderatorId,
'maturity_reviewed_at' => now(),
'maturity_reviewer_note' => $note,
'maturity_flag_reason' => $note ?: $artwork->maturity_flag_reason,
])->saveQuietly();
$this->searchIndexer->update($artwork);
return $artwork->fresh();
}
/**
* @param array<string, mixed> $analysis
* @return array{score:float,labels:array<int,string>,flagged:bool}
*/
public function assessAnalysis(array $analysis): array
{
$labels = [];
$score = 0.0;
$strong = collect((array) config('maturity.ai.strong_keywords', []))
->map(static fn (mixed $keyword): string => Str::lower(trim((string) $keyword)))
->filter()
->values()
->all();
$medium = collect((array) config('maturity.ai.medium_keywords', []))
->map(static fn (mixed $keyword): string => Str::lower(trim((string) $keyword)))
->filter()
->values()
->all();
$fragments = collect(array_merge(
$this->analysisTextFragments($analysis['clip_tags'] ?? []),
$this->analysisTextFragments($analysis['yolo_objects'] ?? []),
[Str::lower(trim((string) ($analysis['blip_caption'] ?? '')))]
))
->filter()
->unique()
->values();
foreach ($fragments as $fragment) {
foreach ($strong as $keyword) {
if (Str::contains($fragment, $keyword)) {
$score += 0.42;
$labels[] = $keyword;
}
}
foreach ($medium as $keyword) {
if (Str::contains($fragment, $keyword)) {
$score += 0.18;
$labels[] = $keyword;
}
}
}
$labels = array_values(array_unique($labels));
$score = min(1.0, round($score, 4));
return [
'confidence' => $score,
'score' => $score,
'labels' => $labels,
'flagged' => $score >= (float) config('maturity.ai.threshold', 0.68),
'status' => self::AI_STATUS_SUCCEEDED,
'maturity_label' => $score >= (float) config('maturity.ai.threshold', 0.68) ? self::LEVEL_MATURE : self::LEVEL_SAFE,
'action_hint' => $score >= (float) config('maturity.ai.threshold', 0.68) ? self::AI_ACTION_REVIEW : self::AI_ACTION_SAFE,
'model' => null,
'threshold_used' => (float) config('maturity.ai.threshold', 0.68),
'analysis_time_ms' => null,
'advisory' => null,
];
}
/**
* @param array<string, mixed> $analysis
* @return array{status:string,maturity_label:?string,confidence:?float,labels:array<int,string>,action_hint:?string,model:?string,threshold_used:?float,analysis_time_ms:?int,advisory:?string,flagged:bool,score:?float}
*/
private function normalizeAiAssessment(array $analysis): array
{
if (! $this->looksLikeNormalizedAssessment($analysis)) {
/** @var array{status:string,maturity_label:?string,confidence:?float,labels:array<int,string>,action_hint:?string,model:?string,threshold_used:?float,analysis_time_ms:?int,advisory:?string,flagged:bool,score:?float} $legacy */
$legacy = $this->assessAnalysis($analysis);
return $legacy;
}
$status = $this->normalizeAiStatus($analysis['status'] ?? null);
$label = $this->normalizeAiLabel($analysis['maturity_label'] ?? ($analysis['label'] ?? null));
$confidence = $this->normalizeScore($analysis['confidence'] ?? ($analysis['score'] ?? null));
$labels = $this->normalizeLabels($analysis['labels'] ?? ($analysis['maturity_ai_labels'] ?? []));
$actionHint = $this->normalizeAiActionHint($analysis['action_hint'] ?? null);
$model = is_scalar($analysis['model'] ?? null) ? trim((string) $analysis['model']) : null;
$thresholdUsed = $this->normalizeScore($analysis['threshold_used'] ?? null);
$analysisTime = is_numeric($analysis['analysis_time_ms'] ?? null) ? (int) $analysis['analysis_time_ms'] : null;
$advisory = is_scalar($analysis['advisory'] ?? null) ? trim((string) $analysis['advisory']) : null;
if ($labels === [] && $label !== null) {
$labels[] = $label;
}
$flagged = $status === self::AI_STATUS_SUCCEEDED
&& in_array($actionHint, [self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH], true);
return [
'status' => $status,
'maturity_label' => $label,
'confidence' => $confidence,
'labels' => $labels,
'action_hint' => $actionHint,
'model' => $model !== '' ? $model : null,
'threshold_used' => $thresholdUsed,
'analysis_time_ms' => $analysisTime,
'advisory' => $advisory !== '' ? $advisory : null,
'flagged' => $flagged,
'score' => $confidence,
];
}
/**
* @param array<string, mixed> $assessment
*/
private function shouldFlagAssessment(Artwork $artwork, array $assessment): bool
{
if (($assessment['status'] ?? self::AI_STATUS_FAILED) !== self::AI_STATUS_SUCCEEDED) {
return false;
}
if ((bool) $artwork->is_mature) {
return false;
}
return (bool) ($assessment['flagged'] ?? false)
|| in_array($assessment['action_hint'] ?? null, [self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH], true)
|| (($assessment['maturity_label'] ?? null) === self::LEVEL_MATURE);
}
/**
* @param array<string, mixed> $assessment
*/
private function buildAiFlagReason(array $assessment): string
{
$labels = array_slice($this->normalizeLabels($assessment['labels'] ?? []), 0, 5);
$action = $this->normalizeAiActionHint($assessment['action_hint'] ?? null);
$prefix = match ($action) {
self::AI_ACTION_FLAG_HIGH => 'AI flagged high-confidence mature content',
self::AI_ACTION_REVIEW => 'AI requested moderation review for mature content',
default => 'AI suspected mature content',
};
if ($labels === []) {
return $prefix . '.';
}
return $prefix . ' from: ' . implode(', ', $labels);
}
/**
* @param array<int, mixed> $rows
* @return array<int, string>
*/
private function analysisTextFragments(array $rows): array
{
return collect($rows)
->map(function (mixed $row): string {
if (is_array($row)) {
return Str::lower(trim((string) ($row['tag'] ?? $row['label'] ?? $row['name'] ?? '')));
}
return Str::lower(trim((string) $row));
})
->filter()
->values()
->all();
}
private function normalizeVisibilityPreference(string $value): string
{
return match (Str::lower(trim($value))) {
self::VIEW_HIDE => self::VIEW_HIDE,
self::VIEW_SHOW => self::VIEW_SHOW,
default => self::VIEW_BLUR,
};
}
/**
* @return array<int, string>
*/
private function normalizeLabels(mixed $labels): array
{
if (! is_array($labels)) {
return [];
}
return collect($labels)
->map(static fn (mixed $label): string => trim((string) $label))
->filter()
->values()
->all();
}
private function normalizeScore(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
/**
* @param array<string, mixed> $analysis
*/
private function looksLikeNormalizedAssessment(array $analysis): bool
{
return array_key_exists('maturity_label', $analysis)
|| array_key_exists('action_hint', $analysis)
|| array_key_exists('status', $analysis)
|| array_key_exists('threshold_used', $analysis)
|| array_key_exists('analysis_time_ms', $analysis);
}
private function normalizeAiStatus(mixed $value): string
{
return match (Str::lower(trim((string) $value))) {
self::AI_STATUS_PENDING => self::AI_STATUS_PENDING,
self::AI_STATUS_SKIPPED => self::AI_STATUS_SKIPPED,
self::AI_STATUS_SUCCEEDED => self::AI_STATUS_SUCCEEDED,
self::AI_STATUS_NOT_REQUESTED => self::AI_STATUS_NOT_REQUESTED,
default => self::AI_STATUS_FAILED,
};
}
private function normalizeAiLabel(mixed $value): ?string
{
return match (Str::lower(trim((string) $value))) {
self::LEVEL_SAFE => self::LEVEL_SAFE,
self::LEVEL_MATURE => self::LEVEL_MATURE,
'adult', 'explicit', 'nsfw' => self::LEVEL_MATURE,
'clear', 'sfw' => self::LEVEL_SAFE,
default => null,
};
}
private function normalizeAiActionHint(mixed $value): ?string
{
return match (Str::lower(trim((string) $value))) {
self::AI_ACTION_SAFE, self::AI_ACTION_ALLOW, 'mark_safe', 'allow' => self::AI_ACTION_SAFE,
self::AI_ACTION_REVIEW, 'queue', 'suspect' => self::AI_ACTION_REVIEW,
self::AI_ACTION_FLAG_HIGH, 'block', 'mark_mature', 'mature' => self::AI_ACTION_FLAG_HIGH,
default => null,
};
}
private function value(mixed $artwork, string $key, mixed $default = null): mixed
{
if ($artwork instanceof Artwork) {
return $artwork->getAttribute($key) ?? $default;
}
if (is_array($artwork)) {
return $artwork[$key] ?? $default;
}
if (! is_object($artwork)) {
return $default;
}
return $artwork->{$key} ?? $default;
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
/**
* Detects inactivity gaps in a creator's public artwork history and
* returns milestone rows for any comeback events.
*
* Thresholds:
* Minor: 180364 days gap
* Major: 3651094 days gap (13 years)
* Legendary: 1095+ days gap (3+ years)
*/
final class CreatorComebackService
{
private const MINOR_DAYS = 180;
private const MAJOR_DAYS = 365;
private const LEGENDARY_DAYS = 1095;
/**
* Given the ordered collection of public artwork rows (ascending by published_at),
* detect all comeback events and return milestone row arrays.
*
* @param Collection<int, object> $artworks rows from publicArtworkRows()
* @param int $userId
* @param CarbonInterface $computedAt
* @param callable(int, CreatorMilestoneType, CarbonInterface, array, ?int, CarbonInterface): array $makeMilestoneRow
* @return array<int, array<string, mixed>>
*/
public function calculateComebacks(
Collection $artworks,
int $userId,
CarbonInterface $computedAt,
callable $makeMilestoneRow,
): array {
if ($artworks->count() < 2) {
return [];
}
$sorted = $artworks
->filter(fn (object $row): bool => ! empty($row->published_at))
->sortBy([['published_at', 'asc'], ['id', 'asc']])
->values();
$milestones = [];
$prevDate = null;
foreach ($sorted as $artwork) {
$currentDate = $this->parseDate($artwork->published_at);
if ($prevDate !== null && $currentDate !== null) {
$gapDays = (int) $prevDate->diffInDays($currentDate);
$type = $this->comebackTypeForGap($gapDays);
if ($type !== null) {
$milestones[] = $makeMilestoneRow(
$userId,
$type,
$currentDate,
$this->buildPayload($type, $gapDays, $prevDate, $artwork),
(int) $artwork->id,
$computedAt,
);
// Only record one comeback per gap: if we match legendary, skip major/minor for same gap.
// prevDate resets after each comeback so consecutive short-gap uploads won't double-count.
}
}
// Only advance prevDate when the gap did NOT trigger a comeback.
// After a comeback, the "chain" resets from the new return date.
$prevDate = $currentDate;
}
return $milestones;
}
private function comebackTypeForGap(int $gapDays): ?CreatorMilestoneType
{
if ($gapDays >= self::LEGENDARY_DAYS) {
return CreatorMilestoneType::ComebackLegendary;
}
if ($gapDays >= self::MAJOR_DAYS) {
return CreatorMilestoneType::ComebackMajor;
}
if ($gapDays >= self::MINOR_DAYS) {
return CreatorMilestoneType::ComebackMinor;
}
return null;
}
/**
* @return array<string, mixed>
*/
private function buildPayload(
CreatorMilestoneType $type,
int $gapDays,
CarbonInterface $previousUploadAt,
object $artwork,
): array {
$years = (int) round($gapDays / 365);
$months = (int) round($gapDays / 30);
$durationLabel = match (true) {
$years >= 3 => $years . ' years',
$years >= 1 => $years === 1 ? 'a year' : $years . ' years',
$months >= 2 => $months . ' months',
default => 'several months',
};
$summaryMap = [
CreatorMilestoneType::ComebackMinor->value => "Returned to Skinbase after {$durationLabel} away with a new public upload.",
CreatorMilestoneType::ComebackMajor->value => "Major comeback after {$durationLabel} away — new work published again on Skinbase.",
CreatorMilestoneType::ComebackLegendary->value => "Returned to Skinbase after {$durationLabel} away, picking up where the journey left off.",
];
$titleMap = [
CreatorMilestoneType::ComebackMinor->value => 'Comeback',
CreatorMilestoneType::ComebackMajor->value => 'Major comeback',
CreatorMilestoneType::ComebackLegendary->value => 'Legendary comeback',
];
return [
'title' => $titleMap[$type->value] ?? 'Comeback',
'headline' => (string) $artwork->title,
'summary' => $summaryMap[$type->value] ?? "Returned after {$durationLabel}.",
'value' => "After {$durationLabel}",
'artwork' => $this->artworkSnapshot($artwork),
'metadata' => [
'previous_upload_at' => $previousUploadAt->toIso8601String(),
'gap_days' => $gapDays,
'comeback_level' => $this->levelLabel($type),
],
];
}
private function levelLabel(CreatorMilestoneType $type): string
{
return match ($type) {
CreatorMilestoneType::ComebackMinor => 'minor',
CreatorMilestoneType::ComebackMajor => 'major',
CreatorMilestoneType::ComebackLegendary => 'legendary',
default => 'minor',
};
}
/**
* @return array<string, mixed>
*/
private function artworkSnapshot(object $artwork): array
{
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'slug' => (string) ($artwork->slug ?? $artwork->id),
'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(),
];
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value instanceof CarbonInterface) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}

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 ?? [],
];
}
}

View 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;
}
}

View File

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Services\Profile;
use App\Enums\CreatorMilestoneType;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
/**
* Calculates upload streaks (consecutive calendar months with at least one public upload)
* and active-year streaks (consecutive years with at least one public upload).
*
* Returns:
* - milestone rows for notable streak achievements
* - a streaks summary array for the API payload
*/
final class CreatorStreakService
{
/**
* Compute streak milestones from a creator's public artwork collection.
*
* @param Collection<int, object> $artworks
* @param int $userId
* @param CarbonInterface $computedAt
* @param callable $makeMilestoneRow
* @return array<int, array<string, mixed>>
*/
public function calculateStreakMilestones(
Collection $artworks,
int $userId,
CarbonInterface $computedAt,
callable $makeMilestoneRow,
): array {
if ($artworks->isEmpty()) {
return [];
}
$milestones = [];
$stats = $this->computeStreakStats($artworks);
// Monthly upload streak milestones
foreach ([12, 6, 3] as $months) {
if ($stats['best_monthly_streak'] >= $months) {
$type = match ($months) {
12 => CreatorMilestoneType::UploadStreak12,
6 => CreatorMilestoneType::UploadStreak6,
3 => CreatorMilestoneType::UploadStreak3,
};
$occurredAt = $stats['best_monthly_streak_end'] ?? $computedAt;
$milestones[] = $makeMilestoneRow(
$userId,
$type,
$occurredAt,
[
'title' => $months . '-month upload streak',
'headline' => "Published in {$months} consecutive months.",
'summary' => "Maintained a public upload in every calendar month for {$months} consecutive months.",
'value' => "{$months} months",
'metrics' => [
'months' => $months,
'best_monthly_streak' => $stats['best_monthly_streak'],
'current_monthly_streak' => $stats['current_monthly_streak'],
],
],
null,
$computedAt,
);
break; // Only insert the best monthly streak milestone (e.g. if best=12, skip 6 and 3)
}
}
// Active-year streak milestones
foreach ([5, 3] as $years) {
if ($stats['best_year_streak'] >= $years) {
$type = match ($years) {
5 => CreatorMilestoneType::ActiveYearStreak5,
3 => CreatorMilestoneType::ActiveYearStreak3,
};
$occurredAt = $stats['best_year_streak_end'] ?? $computedAt;
$milestones[] = $makeMilestoneRow(
$userId,
$type,
$occurredAt,
[
'title' => "{$years}-year active streak",
'headline' => "Stayed active for {$years} consecutive years.",
'summary' => "Published at least one public artwork every year for {$years} consecutive years.",
'value' => "{$years} years",
'metrics' => [
'years' => $years,
'best_year_streak' => $stats['best_year_streak'],
'current_year_streak' => $stats['current_year_streak'],
],
],
null,
$computedAt,
);
break; // Only insert the best year streak milestone
}
}
return $milestones;
}
/**
* Compute raw streak statistics for use in the API streaks payload.
*
* @param Collection<int, object> $artworks
* @return array{
* current_monthly_streak: int,
* best_monthly_streak: int,
* best_monthly_streak_end: ?CarbonInterface,
* current_year_streak: int,
* best_year_streak: int,
* best_year_streak_end: ?CarbonInterface,
* }
*/
public function computeStreakStats(Collection $artworks): array
{
if ($artworks->isEmpty()) {
return $this->emptyStats();
}
// Build sets of active months (YYYY-MM) and active years
$activeMonths = [];
$activeYears = [];
foreach ($artworks as $artwork) {
$date = $this->parseDate($artwork->published_at);
if ($date === null) {
continue;
}
$activeMonths[$date->format('Y-m')] = $date;
$activeYears[(int) $date->format('Y')] = $date;
}
if ($activeMonths === []) {
return $this->emptyStats();
}
ksort($activeMonths);
ksort($activeYears);
return [
...$this->computeMonthlyStreaks($activeMonths),
...$this->computeYearlyStreaks($activeYears),
];
}
/**
* @param array<string, CarbonInterface> $activeMonths sorted ascending by key (YYYY-MM)
* @return array{current_monthly_streak: int, best_monthly_streak: int, best_monthly_streak_end: ?CarbonInterface}
*/
private function computeMonthlyStreaks(array $activeMonths): array
{
$now = Carbon::now();
$currentMonth = $now->format('Y-m');
$streak = 1;
$best = 1;
$bestEndDate = null;
$prevKey = null;
$lastKey = null;
foreach ($activeMonths as $key => $date) {
if ($prevKey !== null) {
$expected = Carbon::parse($prevKey . '-01')->addMonth()->format('Y-m');
if ($key === $expected) {
$streak++;
} else {
$streak = 1;
}
}
if ($streak > $best) {
$best = $streak;
$bestEndDate = $date;
}
$prevKey = $key;
$lastKey = $key;
}
// Current streak: walk backwards from current/last month
$currentStreak = 0;
$checkMonth = $lastKey !== null ? Carbon::parse($lastKey . '-01') : $now->startOfMonth();
// If the last active month is current or previous month, count the streak
$diff = $now->startOfMonth()->diffInMonths($checkMonth);
if ($diff <= 1) {
$currentStreak = 1;
$checkBack = $checkMonth->copy()->subMonth();
while (isset($activeMonths[$checkBack->format('Y-m')])) {
$currentStreak++;
$checkBack->subMonth();
}
}
return [
'current_monthly_streak' => $currentStreak,
'best_monthly_streak' => $best,
'best_monthly_streak_end' => $bestEndDate,
];
}
/**
* @param array<int, CarbonInterface> $activeYears sorted ascending by key (int year)
* @return array{current_year_streak: int, best_year_streak: int, best_year_streak_end: ?CarbonInterface}
*/
private function computeYearlyStreaks(array $activeYears): array
{
$currentYear = (int) Carbon::now()->year;
$streak = 1;
$best = 1;
$bestEndDate = null;
$prevYear = null;
$lastYear = null;
foreach ($activeYears as $year => $date) {
if ($prevYear !== null) {
if ($year === $prevYear + 1) {
$streak++;
} else {
$streak = 1;
}
}
if ($streak > $best) {
$best = $streak;
$bestEndDate = $date;
}
$prevYear = $year;
$lastYear = $year;
}
// Current year streak
$currentStreak = 0;
if ($lastYear !== null && ($lastYear === $currentYear || $lastYear === $currentYear - 1)) {
$currentStreak = 1;
$checkYear = $lastYear - 1;
while (isset($activeYears[$checkYear])) {
$currentStreak++;
$checkYear--;
}
}
return [
'current_year_streak' => $currentStreak,
'best_year_streak' => $best,
'best_year_streak_end' => $bestEndDate,
];
}
/**
* @return array{current_monthly_streak: int, best_monthly_streak: int, best_monthly_streak_end: null, current_year_streak: int, best_year_streak: int, best_year_streak_end: null}
*/
private function emptyStats(): array
{
return [
'current_monthly_streak' => 0,
'best_monthly_streak' => 0,
'best_monthly_streak_end' => null,
'current_year_streak' => 0,
'best_year_streak' => 0,
'best_year_streak_end' => null,
];
}
private function parseDate(mixed $value): ?CarbonInterface
{
if ($value instanceof CarbonInterface) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More