diff --git a/app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php b/app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php new file mode 100644 index 00000000..08a243ec --- /dev/null +++ b/app/Console/Commands/AuditArtworkMaturityThumbnailsCommand.php @@ -0,0 +1,156 @@ +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; + } +} \ No newline at end of file diff --git a/app/Console/Commands/AuditArtworkThumbnailsCommand.php b/app/Console/Commands/AuditArtworkThumbnailsCommand.php new file mode 100644 index 00000000..e6891385 --- /dev/null +++ b/app/Console/Commands/AuditArtworkThumbnailsCommand.php @@ -0,0 +1,203 @@ +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 + */ + 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 $variants + * @return list + */ + 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 $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(), + ]); + } +} \ No newline at end of file diff --git a/app/Console/Commands/ConfigureMeilisearchIndex.php b/app/Console/Commands/ConfigureMeilisearchIndex.php index b56fbb59..bbaab84b 100644 --- a/app/Console/Commands/ConfigureMeilisearchIndex.php +++ b/app/Console/Commands/ConfigureMeilisearchIndex.php @@ -27,14 +27,22 @@ class ConfigureMeilisearchIndex extends Command private const SORTABLE_ATTRIBUTES = [ 'created_at', 'published_at_ts', + 'missing_thumbnail_rank', 'trending_score_24h', 'trending_score_7d', 'favorites_count', 'downloads_count', 'awards_received_count', + 'awards_score_7d', + 'awards_score_30d', 'views', 'likes', 'downloads', + 'ranking_score', + 'engagement_velocity', + 'shares_count', + 'comments_count', + 'heat_score', ]; /** @@ -44,6 +52,11 @@ class ConfigureMeilisearchIndex extends Command 'id', 'is_public', 'is_approved', + 'is_mature', + 'is_mature_effective', + 'maturity_level', + 'maturity_status', + 'has_missing_thumbnails', 'category', 'content_type', 'tags', diff --git a/app/Console/Commands/ImportLegacyAwards.php b/app/Console/Commands/ImportLegacyAwards.php index 2d6b5561..a8584afa 100644 --- a/app/Console/Commands/ImportLegacyAwards.php +++ b/app/Console/Commands/ImportLegacyAwards.php @@ -5,20 +5,20 @@ declare(strict_types=1); namespace App\Console\Commands; use App\Models\ArtworkAward; -use App\Models\ArtworkAwardStat; use App\Services\ArtworkAwardService; use Illuminate\Console\Command; 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): - * 4 → gold (weight 3) - * 3 → silver (weight 2) - * 2 → bronze (weight 1) - * 1 → skipped (too low to map meaningfully) + * 5 → gold + * 4 → gold + * 3 → silver + * 2 → silver + * 1 → bronze + * 0 → bronze * * Usage: * php artisan awards:import-legacy @@ -29,22 +29,38 @@ use Illuminate\Support\Facades\Schema; class ImportLegacyAwards extends Command { 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} {--chunk=250 : Rows to process per batch} {--skip-stats : Skip per-artwork stats recalculation at the end} {--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 */ private const SCORE_MAP = [ - 4 => 'gold', + 0 => 'bronze', + 1 => 'bronze', + 2 => 'silver', 3 => 'silver', - 2 => 'bronze', + 4 => 'gold', + 5 => 'gold', ]; 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'); $chunk = max(1, (int) $this->option('chunk')); $skipStats = (bool) $this->option('skip-stats'); @@ -56,17 +72,24 @@ class ImportLegacyAwards extends Command // Verify legacy connection is reachable try { - DB::connection('legacy')->getPdo(); + DB::connection($legacyConnection)->getPdo(); } 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; } - if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) { - $this->error('Legacy table `users_opinions` not found.'); + if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable('users_opinions')) { + $this->error("Legacy table `users_opinions` not found on connection [{$legacyConnection}]."); 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 $this->info('Loading new-DB artwork and user ID sets…'); $validArtworkIds = DB::table('artworks') @@ -88,9 +111,7 @@ class ImportLegacyAwards extends Command )); // Count legacy rows for progress bar - $total = DB::connection('legacy') - ->table('users_opinions') - ->count(); + $total = (clone $legacyQuery)->count(); $this->info("Legacy rows to process: {$total}"); @@ -105,11 +126,13 @@ class ImportLegacyAwards extends Command 'skipped_artwork' => 0, 'skipped_user' => 0, 'skipped_duplicate'=> 0, + 'reported_duplicate'=> 0, 'updated_force' => 0, 'errors' => 0, ]; $affectedArtworkIds = []; + $duplicateRows = []; $bar = $this->output->createProgressBar($total); $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%'); @@ -117,24 +140,30 @@ class ImportLegacyAwards extends Command $bar->setMessage('0', 'skipped'); $bar->start(); - DB::connection('legacy') - ->table('users_opinions') + $legacyQuery ->orderBy('opinion_id') ->chunk($chunk, function ($rows) use ( &$stats, &$affectedArtworkIds, + &$duplicateRows, $validArtworkIds, $validUserIds, $dryRun, $force, + $showDuplicates, + $duplicatesLimit, $bar ) { $inserts = []; $now = now(); 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; - $userId = (int) $row->author_id; // author_id = the voter + $userId = (int) $row->user_id; $score = (int) $row->score; $postedAt = $row->post_date ?? $now; @@ -163,11 +192,11 @@ class ImportLegacyAwards extends Command if (! $dryRun) { if ($force) { // Upsert: update medal if row already exists - $affected = DB::table('artwork_awards') + $affected = DB::table('artwork_medals') ->where('artwork_id', $artworkId) ->where('user_id', $userId) ->update([ - 'medal' => $medal, + 'medal_type' => $medal, 'weight' => ArtworkAward::WEIGHTS[$medal], 'updated_at' => $now, ]); @@ -180,13 +209,26 @@ class ImportLegacyAwards extends Command } } else { // Skip if already exists - if ( - DB::table('artwork_awards') - ->where('artwork_id', $artworkId) - ->where('user_id', $userId) - ->exists() - ) { + $existingMedal = DB::table('artwork_medals') + ->where('artwork_id', $artworkId) + ->where('user_id', $userId) + ->value('medal_type'); + + if ($existingMedal !== null) { $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(); continue; } @@ -195,7 +237,7 @@ class ImportLegacyAwards extends Command $inserts[] = [ 'artwork_id' => $artworkId, 'user_id' => $userId, - 'medal' => $medal, + 'medal_type' => $medal, 'weight' => ArtworkAward::WEIGHTS[$medal], 'created_at' => $postedAt, 'updated_at' => $postedAt, @@ -212,12 +254,12 @@ class ImportLegacyAwards extends Command // stats are recalculated in bulk at the end for performance) if (! $dryRun && ! empty($inserts)) { try { - DB::table('artwork_awards')->insert($inserts); + DB::table('artwork_medals')->insert($inserts); } catch (\Throwable $e) { // Fallback: insert one-by-one to isolate constraint violations foreach ($inserts as $row) { try { - DB::table('artwork_awards')->insertOrIgnore([$row]); + DB::table('artwork_medals')->insertOrIgnore([$row]); } catch (\Throwable) { $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) { $this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.'); } else { diff --git a/app/Console/Commands/ImportLegacyNewsCommand.php b/app/Console/Commands/ImportLegacyNewsCommand.php new file mode 100644 index 00000000..4858619d --- /dev/null +++ b/app/Console/Commands/ImportLegacyNewsCommand.php @@ -0,0 +1,109 @@ +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; + } +} diff --git a/app/Console/Commands/RebuildCreatorErasCommand.php b/app/Console/Commands/RebuildCreatorErasCommand.php new file mode 100644 index 00000000..3b9a6a9b --- /dev/null +++ b/app/Console/Commands/RebuildCreatorErasCommand.php @@ -0,0 +1,90 @@ +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; + } +} diff --git a/app/Console/Commands/RebuildCreatorJourneyCommand.php b/app/Console/Commands/RebuildCreatorJourneyCommand.php new file mode 100644 index 00000000..85020203 --- /dev/null +++ b/app/Console/Commands/RebuildCreatorJourneyCommand.php @@ -0,0 +1,123 @@ +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; + } +} \ No newline at end of file diff --git a/app/Console/Commands/WarmHomepageGuestCacheCommand.php b/app/Console/Commands/WarmHomepageGuestCacheCommand.php new file mode 100644 index 00000000..cc8170fe --- /dev/null +++ b/app/Console/Commands/WarmHomepageGuestCacheCommand.php @@ -0,0 +1,31 @@ +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; + } +} \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index cdba7202..3263926b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -85,6 +85,7 @@ class Kernel extends ConsoleKernel RecalculateRankingsCommand::class, MetricsSnapshotHourlyCommand::class, RecalculateHeatCommand::class, + \App\Console\Commands\RebuildCreatorErasCommand::class, ]; /** diff --git a/app/Enums/CreatorMilestoneType.php b/app/Enums/CreatorMilestoneType.php new file mode 100644 index 00000000..a4cbe7ec --- /dev/null +++ b/app/Enums/CreatorMilestoneType.php @@ -0,0 +1,73 @@ + 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, + }; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/ArtworkAwardController.php b/app/Http/Controllers/Api/ArtworkAwardController.php index b7353b69..5294a678 100644 --- a/app/Http/Controllers/Api/ArtworkAwardController.php +++ b/app/Http/Controllers/Api/ArtworkAwardController.php @@ -7,14 +7,16 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Artwork; 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\Request; final class ArtworkAwardController extends Controller { 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'], ]); - $award = $this->service->award($artwork, $user, $data['medal']); + $this->service->award($artwork, $user, $data['medal']); // Record activity event 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 * Change an existing award medal. @@ -60,7 +88,7 @@ final class ArtworkAwardController extends Controller $user = $request->user(); $artwork = Artwork::findOrFail($id); - $existingAward = ArtworkAward::where('artwork_id', $artwork->id) + $existingAward = ArtworkMedal::where('artwork_id', $artwork->id) ->where('user_id', $user->id) ->firstOrFail(); @@ -70,7 +98,7 @@ final class ArtworkAwardController extends Controller '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)); } @@ -84,17 +112,29 @@ final class ArtworkAwardController extends Controller $user = $request->user(); $artwork = Artwork::findOrFail($id); - $existingAward = ArtworkAward::where('artwork_id', $artwork->id) + $existingAward = ArtworkMedal::where('artwork_id', $artwork->id) ->where('user_id', $user->id) ->firstOrFail(); $this->authorize('remove', $existingAward); - $this->service->removeAward($artwork, $user); + $this->service->removeMedal($artwork, $user); 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 * Return award stats + viewer's current award. @@ -111,22 +151,29 @@ final class ArtworkAwardController extends Controller private function buildPayload(int $artworkId, ?int $userId): array { - $stat = \App\Models\ArtworkAwardStat::find($artworkId); + $stat = ArtworkMedalStat::find($artworkId); $userAward = $userId - ? ArtworkAward::where('artwork_id', $artworkId) + ? ArtworkMedal::where('artwork_id', $artworkId) ->where('user_id', $userId) - ->value('medal') + ->value('medal_type') : 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 [ - 'awards' => [ - 'gold' => $stat?->gold_count ?? 0, - 'silver' => $stat?->silver_count ?? 0, - 'bronze' => $stat?->bronze_count ?? 0, - 'score' => $stat?->score_total ?? 0, - ], + 'awards' => $medals, + 'medals' => $medals, 'viewer_award' => $userAward, + 'current_user_medal' => $userAward, ]; } } diff --git a/app/Http/Controllers/Api/ArtworkDownloadController.php b/app/Http/Controllers/Api/ArtworkDownloadController.php index 3ac3e307..5e8fcade 100644 --- a/app/Http/Controllers/Api/ArtworkDownloadController.php +++ b/app/Http/Controllers/Api/ArtworkDownloadController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkStatsService; +use App\Services\Profile\CreatorJourneyService; use App\Services\ThumbnailPresenter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -31,7 +32,10 @@ use Illuminate\Support\Str; */ 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 { @@ -48,13 +52,15 @@ final class ArtworkDownloadController extends Controller // Record the download event — non-blocking, errors are swallowed. $this->recordDownload($request, $artwork); - // Increment counters — deferred via Redis when available. + // Increment counters immediately so Studio stats stay fresh. try { - $this->stats->incrementDownloads((int) $artwork->id, 1, defer: true); + $this->stats->incrementDownloads((int) $artwork->id, 1, defer: false); } catch (\Throwable) { // Stats failure must never interrupt the download. } + $this->journeys->requestRebuild((int) $artwork->user_id); + // Resolve the highest-resolution download URL available. $url = $this->resolveDownloadUrl($artwork); diff --git a/app/Http/Controllers/Api/ArtworkInteractionController.php b/app/Http/Controllers/Api/ArtworkInteractionController.php index d4f72c3f..f893d529 100644 --- a/app/Http/Controllers/Api/ArtworkInteractionController.php +++ b/app/Http/Controllers/Api/ArtworkInteractionController.php @@ -10,6 +10,7 @@ use App\Models\Artwork; use App\Notifications\ArtworkLikedNotification; use App\Services\FollowService; use App\Services\Activity\UserActivityService; +use App\Services\ArtworkStatsService; use App\Services\UserStatsService; use App\Services\XPService; use Illuminate\Http\JsonResponse; @@ -168,7 +169,7 @@ final class ArtworkInteractionController extends Controller public function share(Request $request, int $artworkId): JsonResponse { $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')) { @@ -178,6 +179,8 @@ final class ArtworkInteractionController extends Controller 'platform' => $data['platform'], 'created_at' => now(), ]); + + $this->syncArtworkStats($artworkId); } return response()->json(['ok' => true]); @@ -216,25 +219,7 @@ final class ArtworkInteractionController extends Controller private function syncArtworkStats(int $artworkId): void { - if (! Schema::hasTable('artwork_stats')) { - 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, - ] - ); + app(ArtworkStatsService::class)->syncEngagementCounts($artworkId); } private function statusPayload(int $viewerId, int $artworkId): array diff --git a/app/Http/Controllers/Api/ArtworkViewController.php b/app/Http/Controllers/Api/ArtworkViewController.php index f065ffdc..1e7dbb5a 100644 --- a/app/Http/Controllers/Api/ArtworkViewController.php +++ b/app/Http/Controllers/Api/ArtworkViewController.php @@ -16,14 +16,10 @@ use Illuminate\Http\Request; * * Fire-and-forget view tracker. * - * Deduplication strategy (layered): - * 1. Session key (`art_viewed.{id}`) — prevents double-counts within the - * same browser session (survives page reloads). - * 2. Route throttle (5 per 10 minutes per IP+artwork) — catches bots that - * don't send session cookies. - * - * The frontend should additionally guard with sessionStorage so it only - * calls this endpoint once per page load. + * Every page visit should count as a new view. + * Lightweight abuse protection is handled at the route layer via throttling, + * while the stat increment itself is applied immediately so Studio analytics + * reflect new visits without waiting for the scheduler to flush Redis deltas. */ final class ArtworkViewController extends Controller { @@ -43,18 +39,11 @@ final class ArtworkViewController extends Controller 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). $this->stats->logViewEvent((int) $artwork->id, $request->user()?->id); - // Defer to Redis when available, fall back to direct DB increment. - $this->stats->incrementViews((int) $artwork->id, 1, defer: true); + // Apply the increment immediately so counters stay fresh in Studio. + $this->stats->incrementViews((int) $artwork->id, 1, defer: false); $viewerId = $request->user()?->id; 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]); } } diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php index 97118ca5..f9c240d2 100644 --- a/app/Http/Controllers/Api/ProfileApiController.php +++ b/app/Http/Controllers/Api/ProfileApiController.php @@ -39,6 +39,8 @@ final class ProfileApiController extends Controller $query = 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') @@ -115,6 +117,8 @@ final class ProfileApiController extends Controller $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') @@ -190,6 +194,15 @@ final class ProfileApiController extends Controller $category = $art->categories->first(); $contentType = $category?->contentType; $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 [ 'id' => $art->id, @@ -198,8 +211,22 @@ final class ProfileApiController extends Controller 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'width' => $art->width, 'height' => $art->height, - 'username' => $art->user->username ?? null, - 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', + 'username' => $username, + '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_slug' => $contentType?->slug, 'category' => $category?->name, diff --git a/app/Http/Controllers/Api/ProfileJourneyController.php b/app/Http/Controllers/Api/ProfileJourneyController.php new file mode 100644 index 00000000..09509ab1 --- /dev/null +++ b/app/Http/Controllers/Api/ProfileJourneyController.php @@ -0,0 +1,37 @@ +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(), + ], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/RankController.php b/app/Http/Controllers/Api/RankController.php index 1b0d887b..934cd196 100644 --- a/app/Http/Controllers/Api/RankController.php +++ b/app/Http/Controllers/Api/RankController.php @@ -9,6 +9,7 @@ use App\Http\Resources\ArtworkListResource; use App\Models\Artwork; use App\Models\Category; use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; use App\Services\RankingService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -26,7 +27,10 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection; */ 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 @@ -65,7 +69,7 @@ class RankController extends Controller { $ct = is_numeric($contentType) ? ContentType::find((int) $contentType) - : ContentType::where('slug', $contentType)->first(); + : $this->contentTypeResolver->resolve($contentType)->contentType; if ($ct === null) { return response()->json(['message' => 'Content type not found.'], 404); diff --git a/app/Http/Controllers/Api/SuggestedCreatorsController.php b/app/Http/Controllers/Api/SuggestedCreatorsController.php index 6dbc6ec7..15aee372 100644 --- a/app/Http/Controllers/Api/SuggestedCreatorsController.php +++ b/app/Http/Controllers/Api/SuggestedCreatorsController.php @@ -71,10 +71,10 @@ final class SuggestedCreatorsController extends Controller u.username, up.avatar_hash, 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 ') - ->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') ->limit(20) ->get(); @@ -117,10 +117,10 @@ final class SuggestedCreatorsController extends Controller u.username, up.avatar_hash, 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 ') - ->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') ->limit(20) ->get(); @@ -197,7 +197,7 @@ final class SuggestedCreatorsController extends Controller u.username, up.avatar_hash, 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') ->limit($limit) diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 2969631d..f1e38512 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -33,6 +33,7 @@ use App\Uploads\Jobs\VirusScanJob; use App\Uploads\Services\PublishService; use App\Services\Activity\UserActivityService; use App\Services\ArtworkAttributionService; +use App\Services\Maturity\ArtworkMaturityService; use App\Uploads\Exceptions\UploadNotFoundException; use App\Uploads\Exceptions\UploadOwnershipException; use App\Uploads\Exceptions\UploadPublishValidationException; @@ -558,7 +559,7 @@ final class UploadController extends Controller ], 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(); @@ -566,7 +567,7 @@ final class UploadController extends Controller 'title' => ['nullable', 'string', 'max:150'], 'description' => ['nullable', 'string'], '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'], 'is_mature' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'], @@ -657,6 +658,7 @@ final class UploadController extends Controller } $artwork->save(); + $maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature); $artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated); if ($mode === 'schedule' && $publishAt) { @@ -760,7 +762,7 @@ final class UploadController extends Controller 'title' => ['nullable', 'string', 'max:150'], 'description' => ['nullable', 'string'], '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'], 'is_mature' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'], diff --git a/app/Http/Controllers/ArtworkController.php b/app/Http/Controllers/ArtworkController.php index af1ed8de..7030b40a 100644 --- a/app/Http/Controllers/ArtworkController.php +++ b/app/Http/Controllers/ArtworkController.php @@ -6,12 +6,17 @@ use App\Http\Controllers\Controller; use App\Http\Requests\ArtworkIndexRequest; use App\Models\Artwork; use App\Models\Category; -use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; use Illuminate\View\View; class ArtworkController extends Controller { + public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver) + { + } + /** * Browse artworks with optional category filtering. * 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) { + $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 // the 'artwork' parameter to an Artwork model or pass the slug string. $foundArtwork = null; @@ -67,7 +83,7 @@ class ArtworkController extends Controller $artworkSlug = $artwork->slug; } elseif ($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), @@ -75,9 +91,9 @@ class ArtworkController extends Controller // behave consistently. if (! empty($artworkSlug)) { $combinedPath = trim($categoryPath . '/' . $artworkSlug, '/'); - $resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath); + $resolvedCategory = Category::findByPath($resolvedContentTypeSlug, $combinedPath); 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) { $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()) { @@ -108,9 +124,8 @@ class ArtworkController extends Controller 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, '/')))); - $category = $contentType ? Category::findByPath($contentType->slug, $segments) : null; + $category = Category::findByPath(strtolower($contentTypeSlug), $segments); $query = Artwork::query()->where('slug', $artworkSlug); @@ -125,4 +140,17 @@ class ArtworkController extends Controller ->orderByDesc('id') ->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); + } } diff --git a/app/Http/Controllers/ArtworkDownloadController.php b/app/Http/Controllers/ArtworkDownloadController.php index c016c65c..d5efd6ea 100644 --- a/app/Http/Controllers/ArtworkDownloadController.php +++ b/app/Http/Controllers/ArtworkDownloadController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers; use App\Models\Artwork; use App\Models\ArtworkDownload; +use App\Services\ArtworkStatsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; @@ -34,6 +35,10 @@ final class ArtworkDownloadController extends Controller 'gz', ]; + public function __construct( + private readonly ArtworkStatsService $stats, + ) {} + public function __invoke(Request $request, int $id): BinaryFileResponse { $artwork = Artwork::query()->find($id); @@ -51,6 +56,15 @@ final class ArtworkDownloadController extends Controller $this->recordDownload($request, $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)) { Log::warning('Artwork original file missing for download.', [ 'artwork_id' => $artwork->id, diff --git a/app/Http/Controllers/CategoryPageController.php b/app/Http/Controllers/CategoryPageController.php index f47e1b9a..297f2519 100644 --- a/app/Http/Controllers/CategoryPageController.php +++ b/app/Http/Controllers/CategoryPageController.php @@ -3,23 +3,39 @@ namespace App\Http\Controllers; use App\Models\Category; -use App\Models\ContentType; use App\Services\ArtworkService; +use App\Services\ContentTypes\ContentTypeSlugResolver; use Illuminate\Http\Request; 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) { - $contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first(); - if (! $contentType) { + $resolution = $this->contentTypeResolver->resolve($contentTypeSlug); + if (! $resolution->found() || $resolution->contentType === null) { 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'); diff --git a/app/Http/Controllers/Legacy/PhotographyController.php b/app/Http/Controllers/Legacy/PhotographyController.php index 97316c58..10ed3915 100644 --- a/app/Http/Controllers/Legacy/PhotographyController.php +++ b/app/Http/Controllers/Legacy/PhotographyController.php @@ -7,31 +7,45 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use App\Services\ArtworkService; -use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; class PhotographyController extends Controller { protected ArtworkService $artworks; - public function __construct(ArtworkService $artworks) + public function __construct( + ArtworkService $artworks, + private readonly ContentTypeSlugResolver $contentTypeResolver, + ) { $this->artworks = $artworks; } 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'); - $contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography'; + $resolution = $this->contentTypeResolver->resolve($segment); - // Human-friendly group name (used by legacy templates) - $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); + } - // Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType $id = null; 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) @@ -47,25 +61,20 @@ class PhotographyController extends Controller $category = null; } - // Page title and description: prefer legacy category when present, otherwise use ContentType data - $ct = ContentType::where('slug', $contentSlug)->first(); - $page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug)); - $tidy = $category->description ?? ($ct->description ?? null); + $page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug)); + $tidy = $category->description ?? ($contentType->description ?? null); $perPage = 40; $sort = (string) $request->get('sort', 'latest'); - // Load artworks for the requested content type using standard pagination try { $artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort); } catch (\Throwable $e) { - // Return an empty paginator so views using ->links() / ->firstItem() work $artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [ 'path' => url()->current(), ]); } - // Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories $subcategories = collect(); try { if ($id !== null && Schema::hasTable('artworks_categories')) { @@ -79,18 +88,13 @@ class PhotographyController extends Controller } if (! $subcategories || $subcategories->count() === 0) { - if ($ct) { - $subcategories = $ct->rootCategories() - ->orderBy('sort_order') - ->orderBy('name') - ->get() - ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); - } else { - $subcategories = collect(); - } + $subcategories = $contentType->rootCategories() + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); } - // Coerce collections to a paginator so the view's pagination helpers work if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) { $page = (int) ($request->query('page', 1)); $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 - $contentType = ContentType::where('slug', $contentSlug)->first(); - $rootCategories = $contentType - ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() - : collect(); + $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); $page_meta_description = $tidy; diff --git a/app/Http/Controllers/PhotographyController.php b/app/Http/Controllers/PhotographyController.php index 63d48e14..c36fa34d 100644 --- a/app/Http/Controllers/PhotographyController.php +++ b/app/Http/Controllers/PhotographyController.php @@ -7,13 +7,16 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use App\Services\ArtworkService; -use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; class PhotographyController extends Controller { protected ArtworkService $artworks; - public function __construct(ArtworkService $artworks) + public function __construct( + ArtworkService $artworks, + private readonly ContentTypeSlugResolver $contentTypeResolver, + ) { $this->artworks = $artworks; } @@ -21,9 +24,24 @@ class PhotographyController extends Controller public function index(Request $request) { $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; if ($contentSlug === 'photography') { @@ -42,9 +60,8 @@ class PhotographyController extends Controller $category = null; } - $ct = ContentType::where('slug', $contentSlug)->first(); - $page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug)); - $tidy = $category->description ?? ($ct->description ?? null); + $page_title = $category->category_name ?? ($contentType->name ?? ucfirst($contentSlug)); + $tidy = $category->description ?? ($contentType->description ?? null); $perPage = 40; $sort = (string) $request->get('sort', 'latest'); @@ -70,15 +87,11 @@ class PhotographyController extends Controller } if (! $subcategories || $subcategories->count() === 0) { - if ($ct) { - $subcategories = $ct->rootCategories() - ->orderBy('sort_order') - ->orderBy('name') - ->get() - ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); - } else { - $subcategories = collect(); - } + $subcategories = $contentType->rootCategories() + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]); } 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 - ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() - : collect(); + $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); $page_meta_description = $tidy; diff --git a/app/Http/Controllers/RSS/ExploreFeedController.php b/app/Http/Controllers/RSS/ExploreFeedController.php index 2d884a47..53fa4467 100644 --- a/app/Http/Controllers/RSS/ExploreFeedController.php +++ b/app/Http/Controllers/RSS/ExploreFeedController.php @@ -6,8 +6,10 @@ namespace App\Http\Controllers\RSS; use App\Http\Controllers\Controller; use App\Models\Artwork; -use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; use App\Services\RSS\RSSFeedBuilder; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; @@ -30,36 +32,53 @@ final class ExploreFeedController extends Controller '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 */ - 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} */ - 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'; - $ttl = self::SORT_TTL[$mode] ?? 300; - $feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : '')); - $label = ucfirst(str_replace('-', ' ', $type)); + $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true); - $artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) { - $contentType = ContentType::where('slug', $type)->first(); + if (! $resolution->found()) { + 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() ->with(['user:id,username', 'categories:id,name,slug,content_type_id']); - if ($contentType) { + if (! $resolution->isVirtual && $contentType) { $query->whereHas('categories', fn ($q) => $q->where('content_type_id', $contentType->id) ); diff --git a/app/Http/Controllers/Settings/ArtworkMaturityAdminController.php b/app/Http/Controllers/Settings/ArtworkMaturityAdminController.php new file mode 100644 index 00000000..b12752c6 --- /dev/null +++ b/app/Http/Controllers/Settings/ArtworkMaturityAdminController.php @@ -0,0 +1,310 @@ +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> + */ + 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> + */ + 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 + */ + 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 + */ + 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 + */ + 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 $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', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Settings/FeaturedArtworkAdminController.php b/app/Http/Controllers/Settings/FeaturedArtworkAdminController.php new file mode 100644 index 00000000..4fc2c585 --- /dev/null +++ b/app/Http/Controllers/Settings/FeaturedArtworkAdminController.php @@ -0,0 +1,215 @@ +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 + */ + 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 + */ + 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'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index 81fcc5c7..a6176bb7 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -9,6 +9,7 @@ use App\Models\Artwork; use App\Models\Category; use App\Models\ContentType; use App\Models\ArtworkVersion; +use App\Services\ArtworkEvolutionService; use App\Services\Cdn\ArtworkCdnPurgeService; use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkAttributionService; @@ -122,6 +123,7 @@ final class StudioArtworksApiController extends Controller public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); + $evolution = app(ArtworkEvolutionService::class); $validated = $request->validate([ 'title' => 'sometimes|string|max:255', @@ -133,7 +135,7 @@ final class StudioArtworksApiController extends Controller 'timezone' => 'sometimes|nullable|string|max:64', 'category_id' => 'sometimes|nullable|integer|exists:categories,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', 'title_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.*.credit_role' => 'nullable|string|max:80', '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) || array_key_exists('primary_author_user_id', $validated) || array_key_exists('contributor_user_ids', $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 = [ 'group' => $validated['group'] ?? $artwork->group?->slug, @@ -190,7 +198,13 @@ final class StudioArtworksApiController extends Controller $tags = $validated['tags'] ?? null; $categoryId = $validated['category_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['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']); $validated['visibility'] = $visibility; $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); } + 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 try { 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', 'tags_source' => $artwork->tags_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()), ], ]); } diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php index 29c10202..64c70304 100644 --- a/app/Http/Controllers/Studio/StudioController.php +++ b/app/Http/Controllers/Studio/StudioController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Studio; use App\Http\Controllers\Controller; use App\Models\Group; use App\Models\ContentType; +use App\Services\ArtworkEvolutionService; use App\Services\GroupMembershipService; use App\Services\GroupService; use App\Services\Studio\CreatorStudioAnalyticsService; @@ -478,6 +479,7 @@ final class StudioController extends Controller 'description_source' => $artwork->description_source ?: 'manual', 'tags_source' => $artwork->tags_source ?: 'manual', 'category_source' => $artwork->category_source ?: 'manual', + 'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user), // Versioning 'version_count' => (int) ($artwork->version_count ?? 1), 'requires_reapproval' => (bool) $artwork->requires_reapproval, @@ -485,6 +487,7 @@ final class StudioController extends Controller 'contentTypes' => $this->getCategories(), 'groupOptions' => $availableGroups, 'contributorOptionsByGroup' => $contributorOptionsByGroup, + 'evolutionRelationTypes' => app(ArtworkEvolutionService::class)->relationTypeOptions(), ]); } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 4316fa77..cf86cdd0 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\ProfileUpdateRequest; use App\Http\Requests\Settings\RequestEmailChangeRequest; use App\Http\Requests\Settings\UpdateAccountSectionRequest; +use App\Http\Requests\Settings\UpdateContentPreferencesRequest; use App\Http\Requests\Settings\UpdateNotificationsSectionRequest; use App\Http\Requests\Settings\UpdatePersonalSectionRequest; use App\Http\Requests\Settings\UpdateProfileSectionRequest; @@ -35,10 +36,11 @@ use App\Services\FollowAnalyticsService; use App\Services\LeaderboardService; use App\Services\UserSuggestionService; use App\Services\Countries\CountryCatalogService; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\ThumbnailPresenter; -use App\Services\ThumbnailService; use App\Services\XPService; use App\Services\UsernameApprovalService; +use App\Services\Profile\CreatorJourneyService; use App\Services\UserStatsService; use App\Support\AvatarUrl; use App\Support\CoverUrl; @@ -84,6 +86,7 @@ class ProfileController extends Controller private readonly LeaderboardService $leaderboards, private readonly CountryCatalogService $countryCatalog, private readonly UserSuggestionService $userSuggestions, + private readonly CreatorJourneyService $creatorJourney, ) { } @@ -312,6 +315,10 @@ class ProfileController extends Controller $followerNotifications = (bool) ($profileData['follower_notifications'] ?? true); $commentNotifications = (bool) ($profileData['comment_notifications'] ?? true); $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', [ 'user' => [ @@ -332,6 +339,8 @@ class ProfileController extends Controller 'follower_notifications' => $followerNotifications, 'comment_notifications' => $commentNotifications, 'newsletter' => $newsletter, + 'mature_content_visibility' => $matureContentVisibility, + 'mature_content_warning_enabled' => $matureContentWarningEnabled, 'last_username_change_at' => $user->last_username_change_at, 'username_changed_at' => $user->username_changed_at, ], @@ -576,6 +585,18 @@ class ProfileController extends Controller 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 { $validated = $request->validated(); @@ -918,7 +939,7 @@ class ProfileController extends Controller $perPage = 24; // ── Artworks (cursor-paginated) ────────────────────────────────────── - $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage) + $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer) ->through(function (Artwork $art) { return (object) $this->mapArtworkCardPayload($art); }); @@ -926,34 +947,38 @@ class ProfileController extends Controller // ── Featured artworks for this user ───────────────────────────────── $featuredArtworks = collect(); if (Schema::hasTable('artwork_features')) { - $featuredArtworks = DB::table('artwork_features as af') - ->join('artworks as a', 'a.id', '=', 'af.artwork_id') - ->where('a.user_id', $user->id) + $featuredQuery = Artwork::query() + ->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(['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) ->whereNull('af.deleted_at') - ->whereNull('a.deleted_at') - ->where('a.is_public', true) - ->where('a.is_approved', true) + ->whereNull('artworks.deleted_at') + ->where('artworks.is_public', 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') - ->limit(3) - ->select([ - 'a.id', 'a.title as name', 'a.hash', 'a.thumb_ext', - 'a.width', 'a.height', 'af.label', 'af.featured_at', - ]) + ->limit(3); + + app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer); + + $featuredArtworks = $featuredQuery ->get() - ->map(function ($row) { - $thumbUrl = ($row->hash && $row->thumb_ext) - ? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'md') - : '/images/placeholder.jpg'; - return (object) [ - 'id' => $row->id, - 'name' => $row->name, - 'thumb' => $thumbUrl, - 'label' => $row->label, - 'featured_at' => $row->featured_at, - 'width' => $row->width, - 'height' => $row->height, - ]; + ->map(function (Artwork $artwork) { + return (object) array_merge($this->mapArtworkCardPayload($artwork), [ + 'label' => $artwork->featured_label, + 'featured_at' => $this->formatIsoDate($artwork->featured_slot_at), + ]); }); } @@ -972,6 +997,10 @@ class ProfileController extends Controller ->where('a.is_public', true) ->where('a.is_approved', true) ->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.artwork_id') ->limit($favouriteLimit + 1) @@ -981,7 +1010,16 @@ class ProfileController extends Controller $hasMore = $favIds->count() > $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) ->get() ->keyBy('id'); @@ -1056,18 +1094,38 @@ class ProfileController extends Controller ->count(); } - $liveAwardsReceivedCount = 0; - if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) { - $liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw') - ->join('artworks as a', 'a.id', '=', 'aw.artwork_id') + $medalTotals = [ + 'gold' => 0, + 'silver' => 0, + '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) ->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 : [], [ 'uploads_count' => $liveUploadsCount, - 'awards_received_count' => $liveAwardsReceivedCount, + 'awards_received_count' => $medalTotals['count'], + 'medal_totals' => $medalTotals, 'followers_count' => (int) $followerCount, 'following_count' => (int) $followingCount, ]); @@ -1145,7 +1203,7 @@ class ProfileController extends Controller ]); $profileCollections = $this->collections->getProfileCollections($user, $viewer); - $profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner); + $profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer); // ── Profile data ───────────────────────────────────────────────────── $profile = $user->profile; @@ -1203,6 +1261,7 @@ class ProfileController extends Controller $achievementSummary = $this->achievements->summary((int) $user->id); $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); $groupContributionHistory = $this->buildGroupContributionHistory($user); + $journey = $this->creatorJourney->publicPayloadForUser($user); $resolvedInitialTab = $this->normalizeProfileTab($initialTab); $isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null; $activeProfileUrl = $resolvedInitialTab !== null @@ -1276,6 +1335,7 @@ class ProfileController extends Controller 'collections' => $profileCollectionsPayload, 'achievements' => $achievementSummary, 'leaderboardRank' => $leaderboardRank, + 'journey' => $journey, 'groupContributionHistory' => $groupContributionHistory, 'countryName' => $countryName, 'isOwner' => $isOwner, @@ -1288,6 +1348,7 @@ class ProfileController extends Controller 'collectionsFeaturedUrl' => route('collections.featured'), 'collectionFeatureLimit' => (int) config('collections.featured_limit', 3), 'profileTabUrls' => $profileTabUrls, + 'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]), ])->withViewData([ 'page_title' => $pageTitle, 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, @@ -1435,8 +1496,17 @@ class ProfileController extends Controller $category = $art->categories->first(); $contentType = $category?->contentType; $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, 'name' => $art->title, 'picture' => $art->file_name, @@ -1444,11 +1514,22 @@ class ProfileController extends Controller 'published_at' => $this->formatIsoDate($art->published_at), 'thumb' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $art->user->name ?? 'Skinbase', - 'username' => $art->user->username ?? null, + 'uname' => $displayName, + '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, - 'author_level' => (int) ($art->user?->level ?? 1), - 'author_rank' => (string) ($art->user?->rank ?? 'Newbie'), + 'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1), + 'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'), 'content_type' => $contentType?->name, 'content_type_slug' => $contentType?->slug, 'category' => $category?->name, @@ -1458,7 +1539,7 @@ class ProfileController extends Controller 'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0), 'width' => $art->width, 'height' => $art->height, - ]; + ], $art, request()->user()); } private function formatIsoDate(mixed $value): ?string diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index d76aa32b..e66e4ba1 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -12,6 +12,7 @@ use App\Services\ContentSanitizer; use App\Services\ThumbnailPresenter; use App\Services\ErrorSuggestionService; use App\Services\GroupService; +use App\Services\Maturity\ArtworkMaturityService; use App\Support\Seo\SeoFactory; use App\Support\AvatarUrl; use Illuminate\Support\Carbon; @@ -23,7 +24,10 @@ use Illuminate\View\View; 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 { @@ -145,6 +149,7 @@ final class ArtworkPageController extends Controller ->whereKeyNot($artwork->id) ->public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user())) ->where(function ($query) use ($artwork, $categoryIds, $tagIds): void { $query->where('user_id', $artwork->user_id); @@ -176,14 +181,14 @@ final class ArtworkPageController extends Controller $md = ThumbnailPresenter::present($item, 'md'); $lg = ThumbnailPresenter::present($item, 'lg'); - return [ + return $this->maturity->decoratePayload([ 'id' => (int) $item->id, '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'), 'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]), 'thumb' => $md['url'] ?? null, 'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w', - ]; + ], $item, request()->user()); }) ->values() ->all(); diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index a706b24d..d4e0c093 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -7,7 +7,10 @@ use App\Models\ContentType; use App\Models\Artwork; use App\Services\ArtworkSearchService; use App\Services\ArtworkService; +use App\Services\ContentTypes\ContentTypeSlugResolver; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\ThumbnailPresenter; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -17,8 +20,6 @@ use Illuminate\Pagination\AbstractCursorPaginator; 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. * First element is primary sort; subsequent elements are tie-breakers. @@ -74,6 +75,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller public function __construct( private ArtworkService $artworks, 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) { - $contentSlug = strtolower($contentTypeSlug); - if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) { + $requestedSlug = strtolower($contentTypeSlug); + $resolution = $this->contentTypeResolver->resolve($requestedSlug); + + if (! $resolution->found() || $resolution->contentType === null) { abort(404); } - $contentType = ContentType::where('slug', $contentSlug)->first(); - if (! $contentType) { - abort(404); + $contentType = $resolution->contentType; + $contentSlug = strtolower((string) $contentType->slug); + + if ($resolution->requiresRedirect()) { + return $this->redirectToContentTypePath($request, $contentSlug, $path, 301); } // Default sort: trending (not chronological) @@ -265,12 +272,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller $contentTypeSlug = strtolower((string) $contentTypeSlug); $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 $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( $req, - $contentTypeSlug, + $resolvedContentTypeSlug, $categoryPath, $artworkSlug ); @@ -293,7 +313,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); - return (object) [ + return (object) $this->maturity->decoratePayload([ 'id' => $artwork->id, 'name' => $artwork->title, 'content_type_name' => $primaryCategory?->contentType?->name ?? '', @@ -317,7 +337,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'published_at' => $artwork->published_at, 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, - ]; + ], $artwork, request()->user()); } /** @@ -372,9 +392,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller private function mainCategories(): Collection { - return ContentType::ordered() - ->whereIn('slug', self::CONTENT_TYPE_SLUGS) - ->get(['name', 'slug']) + return $this->contentTypeResolver + ->publicContentTypes() ->map(function (ContentType $type) { return (object) [ '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 { $canonicalQuery = $request->query(); diff --git a/app/Http/Controllers/Web/DailyUploadsController.php b/app/Http/Controllers/Web/DailyUploadsController.php index 663e461f..8d7f9cd2 100644 --- a/app/Http/Controllers/Web/DailyUploadsController.php +++ b/app/Http/Controllers/Web/DailyUploadsController.php @@ -5,13 +5,14 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkService; +use App\Services\Maturity\ArtworkMaturityService; use Illuminate\Http\Request; class DailyUploadsController extends Controller { protected ArtworkService $artworks; - public function __construct(ArtworkService $artworks) + public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity) { $this->artworks = $artworks; } @@ -76,11 +77,11 @@ class DailyUploadsController extends Controller private function prepareArts($ars) { - return $ars->map(function (Artwork $ar) { + $items = $ars->map(function (Artwork $ar): array { $primaryCategory = $ar->categories->sortBy('sort_order')->first(); $present = \App\Services\ThumbnailPresenter::present($ar, 'md'); - return (object) [ + return $this->maturity->decoratePayload([ 'id' => $ar->id, 'name' => $ar->title, 'thumb' => $present['url'], @@ -88,7 +89,11 @@ class DailyUploadsController extends Controller 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, 'category_name' => $primaryCategory->name ?? '', '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(); } } diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 1bd9e75c..6abfa549 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -9,10 +9,12 @@ use App\Services\ArtworkSearchService; use App\Services\ArtworkService; use App\Services\EarlyGrowth\AdaptiveTimeWindow; use App\Services\EarlyGrowth\GridFiller; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\Recommendations\RecommendationFeedResolver; use App\Services\UserSuggestionService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -38,6 +40,7 @@ final class DiscoverController extends Controller private readonly GridFiller $gridFiller, private readonly CommunityActivityService $communityActivity, private readonly UserSuggestionService $userSuggestions, + private readonly ArtworkMaturityService $maturity, ) {} // ─── /discover/trending ────────────────────────────────────────────────── @@ -178,6 +181,7 @@ final class DiscoverController extends Controller ->whereRaw('MONTH(published_at) = ?', [$today->month]) ->whereRaw('DAY(published_at) = ?', [$today->day]) ->whereRaw('YEAR(published_at) < ?', [$today->year]) + ->orderMissingThumbnailsLast() ->orderByDesc('published_at') ->paginate($perPage) ->withQueryString(); @@ -270,7 +274,8 @@ final class DiscoverController extends Controller $artworks = collect($feedResult['data'] ?? [])->map( fn (array $item) => $this->presentRecommendedArtwork($item) - )->values(); + ); + $artworks = $this->reorderDiscoverItemsByThumbnailHealth($artworks)->values(); $meta = $feedResult['meta'] ?? []; $nextCursor = $meta['next_cursor'] ?? null; @@ -345,6 +350,7 @@ final class DiscoverController extends Controller ->published() ->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) ->whereIn('user_id', $followingIds) + ->orderMissingThumbnailsLast() ->orderByDesc('published_at') ->paginate($perPage) ->withQueryString(); @@ -416,6 +422,7 @@ final class DiscoverController extends Controller 'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories.contentType:id,slug,name', ]) + ->orderMissingThumbnailsLast() ->orderByDesc('published_at') ->orderByDesc('id') ->paginate($perPage) @@ -438,6 +445,7 @@ final class DiscoverController extends Controller ->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') ->where('artworks.published_at', '>=', $cutoff) + ->orderMissingThumbnailsLast() ->orderByDesc('discover_stats.ranking_score') ->orderByDesc('discover_stats.engagement_velocity') ->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.engagement_velocity, 0) as engagement_velocity') ->where('artworks.published_at', '>=', $cutoff) + ->orderMissingThumbnailsLast() ->orderByDesc('discover_stats.heat_score') ->orderByDesc('discover_stats.engagement_velocity') ->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(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h') ->where('artworks.published_at', '>=', $cutoff) + ->orderMissingThumbnailsLast() ->orderByDesc('recent_signal_24h') ->orderByDesc('artworks.published_at') ->orderByDesc('artworks.id') @@ -599,7 +609,7 @@ final class DiscoverController extends Controller $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); - return (object) [ + return (object) $this->maturity->decoratePayload([ 'id' => $artwork->id, 'name' => $artwork->title, 'content_type_name' => $primaryCategory?->contentType?->name ?? '', @@ -624,7 +634,7 @@ final class DiscoverController extends Controller 'published_at' => $artwork->published_at, 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, - ]; + ], $artwork, request()->user()); } /** @@ -676,6 +686,7 @@ final class DiscoverController extends Controller ->whereIn('user_id', $followingIds) ->where('published_at', '>=', now()->subDays(30)) ->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'artworks.id') + ->orderMissingThumbnailsLast() ->orderByDesc(DB::raw('COALESCE(ast.heat_score, 0)')) ->orderByDesc(DB::raw('COALESCE(ast.favorites, 0)')) ->orderByDesc('artworks.published_at') @@ -703,4 +714,42 @@ final class DiscoverController extends Controller ->values() ->all(); } + + /** + * @param Collection $items + * @return Collection + */ + 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(); + } } diff --git a/app/Http/Controllers/Web/ExploreController.php b/app/Http/Controllers/Web/ExploreController.php index 75c61c60..5064b6eb 100644 --- a/app/Http/Controllers/Web/ExploreController.php +++ b/app/Http/Controllers/Web/ExploreController.php @@ -6,11 +6,12 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Artwork; -use App\Models\ContentType; use App\Services\ArtworkSearchService; +use App\Services\ContentTypes\ContentTypeSlugResolver; use App\Services\EarlyGrowth\EarlyGrowth; use App\Services\EarlyGrowth\GridFiller; use App\Services\EarlyGrowth\SpotlightEngineInterface; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; use Illuminate\Pagination\AbstractCursorPaginator; @@ -27,8 +28,6 @@ use Illuminate\Support\Facades\Cache; */ final class ExploreController extends Controller { - private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other']; - /** Meilisearch sort-field arrays per sort alias. */ private const SORT_MAP = [ '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 GridFiller $gridFiller, private readonly SpotlightEngineInterface $spotlight, + private readonly ContentTypeSlugResolver $contentTypeResolver, + private readonly ArtworkMaturityService $maturity, ) {} // ── /explore (hub) ────────────────────────────────────────────────── @@ -75,13 +76,15 @@ final class ExploreController extends Controller $perPage = $this->resolvePerPage($request); $page = max(1, (int) $request->query('page', 1)); $ttl = self::SORT_TTL[$sort] ?? 300; + $cacheVersion = $this->cacheVersion(); - $artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () => - Artwork::search('')->options([ + $artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () => + $this->search->searchWithThumbnailPreference([ 'filter' => 'is_public = true AND is_approved = true', '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 $artworks = $this->gridFiller->fill($artworks, 0, $page); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); @@ -121,35 +124,43 @@ final class ExploreController extends Controller public function byType(Request $request, string $type) { - $type = strtolower($type); - if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) { + $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true); + + if (! $resolution->found()) { abort(404); } - // "artworks" is the umbrella — search all types - $isAll = $type === 'artworks'; + $isAll = $resolution->isVirtual && $resolution->virtualType === '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. if (! $isAll) { - return redirect()->to($this->canonicalTypeUrl($request, $type), 301); + return redirect()->to($this->canonicalTypeUrl($request, $resolvedTypeSlug), 301); } $sort = $this->resolveSort($request); $perPage = $this->resolvePerPage($request); $page = max(1, (int) $request->query('page', 1)); $ttl = self::SORT_TTL[$sort] ?? 300; + $cacheVersion = $this->cacheVersion(); $filter = 'is_public = true AND is_approved = true'; if (!$isAll) { $filter .= ' AND content_type = "' . $type . '"'; } - $artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () => - Artwork::search('')->options([ + $artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () => + $this->search->searchWithThumbnailPreference([ 'filter' => $filter, '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 $artworks = $this->gridFiller->fill($artworks, 0, $page); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); @@ -163,7 +174,7 @@ final class ExploreController extends Controller $contentType = null; $subcategories = $mainCategories; if (! $isAll) { - $contentType = ContentType::where('slug', $type)->first(); + $contentType = $resolution->contentType; $subcategories = $contentType ? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get() : collect(); @@ -172,10 +183,10 @@ final class ExploreController extends Controller if ($isAll) { $humanType = 'Artworks'; } 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); return view('gallery.index', [ @@ -192,11 +203,11 @@ final class ExploreController extends Controller 'hero_description' => "Browse {$humanType} on Skinbase.", 'breadcrumbs' => collect([ (object) ['name' => 'Explore', 'url' => '/explore'], - (object) ['name' => $humanType, 'url' => "/explore/{$type}"], + (object) ['name' => $humanType, 'url' => "/explore/{$resolvedTypeSlug}"], ]), 'page_title' => "{$humanType} - Explore - Skinbase", '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_rel_prev' => $seo['prev'], 'page_rel_next' => $seo['next'], @@ -208,12 +219,17 @@ final class ExploreController extends Controller public function byTypeMode(Request $request, string $type, string $mode) { - $type = strtolower($type); - if ($type !== 'artworks') { + $resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true); + + if (! $resolution->found()) { + abort(404); + } + + if (! ($resolution->isVirtual && $resolution->virtualType === 'artworks')) { $query = $request->query(); $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 @@ -225,8 +241,8 @@ final class ExploreController extends Controller private function mainCategories(): Collection { - $categories = ContentType::ordered() - ->get(['name', 'slug']) + $categories = $this->contentTypeResolver + ->publicContentTypes() ->map(fn ($ct) => (object) [ 'name' => $ct->name, 'slug' => $ct->slug, @@ -272,6 +288,26 @@ final class ExploreController extends Controller 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 { $primary = $artwork->categories->sortBy('sort_order')->first(); @@ -289,7 +325,7 @@ final class ExploreController extends Controller $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); - return (object) [ + return (object) $this->maturity->decoratePayload([ 'id' => $artwork->id, 'name' => $artwork->title, 'content_type_name' => $primary?->contentType?->name ?? '', @@ -314,7 +350,7 @@ final class ExploreController extends Controller 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, - ]; + ], $artwork, request()->user()); } private function paginationSeo(Request $request, string $base, mixed $paginator): array diff --git a/app/Http/Controllers/Web/FeaturedArtworksController.php b/app/Http/Controllers/Web/FeaturedArtworksController.php index 1bff0a26..13f9aac4 100644 --- a/app/Http/Controllers/Web/FeaturedArtworksController.php +++ b/app/Http/Controllers/Web/FeaturedArtworksController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkService; +use App\Services\Maturity\ArtworkMaturityService; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Str; @@ -13,7 +14,7 @@ class FeaturedArtworksController extends Controller { protected ArtworkService $artworks; - public function __construct(ArtworkService $artworks) + public function __construct(ArtworkService $artworks, private readonly ArtworkMaturityService $maturity) { $this->artworks = $artworks; } @@ -29,7 +30,8 @@ class FeaturedArtworksController extends Controller /** @var LengthAwarePaginator $artworks */ $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(); $categoryName = $primaryCategory->name ?? ''; $categorySlug = $primaryCategory->slug ?? ''; @@ -37,7 +39,7 @@ class FeaturedArtworksController extends Controller $present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); $username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase'; - return (object) [ + return $this->maturity->decoratePayload([ 'id' => $artwork->id, 'name' => $artwork->title, 'slug' => $artwork->slug, @@ -53,8 +55,11 @@ class FeaturedArtworksController extends Controller 'height' => $artwork->height, 'uname' => $artwork->user->name ?? 'Skinbase', 'username' => $username, - ]; - }); + ], $artwork, $request->user()); + })->values()->all(), $request->user())) + ->map(static fn (array $item): object => (object) $item) + ->values() + ); $artworkTypes = [ 1 => 'Bronze Awards', diff --git a/app/Http/Controllers/Web/RssFeedController.php b/app/Http/Controllers/Web/RssFeedController.php index 358afbe0..abf0eb8d 100644 --- a/app/Http/Controllers/Web/RssFeedController.php +++ b/app/Http/Controllers/Web/RssFeedController.php @@ -6,7 +6,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Artwork; -use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; use Illuminate\Http\Response; use Illuminate\View\View; @@ -26,52 +26,6 @@ final class RssFeedController extends Controller /** Number of items per legacy feed. */ 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). */ public const FEEDS = [ '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'], ]; + public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver) + { + } + /** Info page at /rss-feeds */ public function index(): View { @@ -94,7 +52,7 @@ final class RssFeedController extends Controller (object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'], ]), 'feeds' => self::FEEDS, - 'feed_groups' => self::FEED_GROUPS, + 'feed_groups' => $this->feedGroups(), 'center_content' => true, 'center_max' => '3xl', ]); @@ -134,7 +92,7 @@ final class RssFeedController extends Controller 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); @@ -160,4 +118,70 @@ final class RssFeedController extends Controller '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.'], + ], + ], + ]; + } } diff --git a/app/Http/Controllers/Web/SearchController.php b/app/Http/Controllers/Web/SearchController.php index d84141c7..d3fe0770 100644 --- a/app/Http/Controllers/Web/SearchController.php +++ b/app/Http/Controllers/Web/SearchController.php @@ -22,6 +22,7 @@ final class SearchController extends Controller { $q = trim((string) $request->query('q', '')); $sort = $request->query('sort', 'latest'); + $hasQuery = $q !== ''; $sortMap = [ 'popular' => 'views:desc', @@ -30,17 +31,17 @@ final class SearchController extends Controller 'downloads' => 'downloads:desc', ]; - $artworks = $q !== '' + $artworks = $hasQuery ? $this->search->search($q, [ 'sort' => ($sortMap[$sort] ?? 'created_at:desc'), ]) : $this->search->popular(24); - $groups = $q !== '' + $groups = $hasQuery ? $this->groups->searchCards($q, $request->user(), 6) : $this->groups->surfaceCards($request->user(), 'featured', 4); - $news = $q !== '' + $news = $hasQuery ? NewsArticle::query() ->with(['author:id,username,name', 'category:id,name,slug']) ->published() @@ -55,15 +56,59 @@ final class SearchController extends Controller ->get() : 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', [ 'q' => $q, + 'hasQuery' => $hasQuery, 'sort' => $sort, 'groups' => $groups, + 'groupResults' => $groupResults, + 'groupResultCount' => $groupResultCount, 'artworks' => $artworks, + 'resultCount' => $resultCount, '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_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, + ]; + } } diff --git a/app/Http/Controllers/Web/SimilarArtworksPageController.php b/app/Http/Controllers/Web/SimilarArtworksPageController.php index 4a72496f..10af6a56 100644 --- a/app/Http/Controllers/Web/SimilarArtworksPageController.php +++ b/app/Http/Controllers/Web/SimilarArtworksPageController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Artwork; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\Recommendations\HybridSimilarArtworksService; use App\Services\ThumbnailPresenter; use App\Services\Vision\VectorService; @@ -35,6 +36,7 @@ final class SimilarArtworksPageController extends Controller public function __construct( private readonly VectorService $vectors, + private readonly ArtworkMaturityService $maturity, private readonly HybridSimilarArtworksService $hybridService, ) {} @@ -70,6 +72,7 @@ final class SimilarArtworksPageController extends Controller 'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null, 'author_name' => $source->user?->name ?? 'Artist', 'author_username' => $source->user?->username ?? '', + 'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null, 'author_avatar' => AvatarUrl::forUser( (int) ($source->user_id ?? 0), $source->user?->profile?->avatar_hash ?? null, @@ -79,6 +82,7 @@ final class SimilarArtworksPageController extends Controller 'category_slug' => $primaryCat?->slug ?? '', 'content_type_name' => $primaryCat?->contentType?->name ?? '', '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(), 'width' => $source->width ?? null, 'height' => $source->height ?? null, @@ -144,8 +148,11 @@ final class SimilarArtworksPageController extends Controller 'slug' => $art->slug ?? '', 'width' => $art->width ?? null, 'height' => $art->height ?? null, + 'maturity' => $art->maturity ?? null, ])->values(); + $galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values(); + return response()->json([ 'data' => $galleryItems, 'similarity_source' => $similaritySource, @@ -303,7 +310,7 @@ final class SimilarArtworksPageController extends Controller $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); - return (object) [ + return (object) $this->maturity->decoratePayload([ 'id' => $artwork->id, 'name' => $artwork->title, 'content_type_name' => $primary?->contentType?->name ?? '', @@ -328,6 +335,6 @@ final class SimilarArtworksPageController extends Controller 'slug' => $artwork->slug ?? '', 'width' => $artwork->width ?? null, 'height' => $artwork->height ?? null, - ]; + ], $artwork, request()->user()); } } diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index 20fd0f02..afe529d6 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Models\ContentType; use App\Models\Tag; use App\Services\ArtworkSearchService; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\Tags\TagDiscoveryService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; @@ -17,6 +18,7 @@ final class TagController extends Controller { public function __construct( private readonly ArtworkSearchService $search, + private readonly ArtworkMaturityService $maturity, 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. - $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(); $present = ThumbnailPresenter::present($a, 'md'); $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, 'name' => $a->title ?? ($a->name ?? null), 'content_type_name' => $primaryCategory?->contentType?->name ?? '', @@ -82,8 +84,10 @@ final class TagController extends Controller 'width' => $a->width ?? null, 'height' => $a->height ?? null, 'slug' => $a->slug ?? null, - ]; - })->values(); + ], $a, $request->user()); + })->values()->all(), $request->user())) + ->map(static fn (array $item): object => (object) $item) + ->values(); // Replace paginator collection with the gallery-shaped collection so // the gallery.index blade will generate the expected JSON payload. diff --git a/app/Http/Middleware/ConditionalCors.php b/app/Http/Middleware/ConditionalCors.php index e3265b86..1c0ea86b 100644 --- a/app/Http/Middleware/ConditionalCors.php +++ b/app/Http/Middleware/ConditionalCors.php @@ -21,7 +21,7 @@ class ConditionalCors } // 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) { return $next($request); diff --git a/app/Http/Middleware/EnsureArtworkMaturityAccess.php b/app/Http/Middleware/EnsureArtworkMaturityAccess.php new file mode 100644 index 00000000..91e94415 --- /dev/null +++ b/app/Http/Middleware/EnsureArtworkMaturityAccess.php @@ -0,0 +1,32 @@ +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.'); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php b/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php index 762dfdaf..7d7cc577 100644 --- a/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php +++ b/app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php @@ -21,7 +21,7 @@ final class ArtworkTagsStoreRequest extends FormRequest public function rules(): array { return [ - 'tags' => 'required|array|max:15', + 'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30), 'tags.*' => 'required|string|max:64', ]; } diff --git a/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php b/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php index e97e6f29..8937f4e3 100644 --- a/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php +++ b/app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php @@ -21,7 +21,7 @@ final class ArtworkTagsUpdateRequest extends FormRequest public function rules(): array { return [ - 'tags' => 'required|array|max:15', + 'tags' => 'required|array|max:' . (int) config('tags.max_user_tags', 30), 'tags.*' => 'required|string|max:64', ]; } diff --git a/app/Http/Requests/Settings/UpdateContentPreferencesRequest.php b/app/Http/Requests/Settings/UpdateContentPreferencesRequest.php new file mode 100644 index 00000000..5d536d18 --- /dev/null +++ b/app/Http/Requests/Settings/UpdateContentPreferencesRequest.php @@ -0,0 +1,23 @@ + ['required', 'in:hide,blur,show'], + 'mature_content_warning_enabled' => ['required', 'boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php b/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php index 275c5942..e5c7ab9c 100644 --- a/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php +++ b/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php @@ -21,7 +21,7 @@ final class ApplyArtworkAiAssistRequest extends FormRequest 'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])], 'description' => ['sometimes', 'nullable', 'string', 'max:5000'], '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'], 'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])], 'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'], diff --git a/app/Http/Resources/ArtworkListResource.php b/app/Http/Resources/ArtworkListResource.php index 1aee5026..6610d6a9 100644 --- a/app/Http/Resources/ArtworkListResource.php +++ b/app/Http/Resources/ArtworkListResource.php @@ -1,6 +1,7 @@ decoratePayload([ 'id' => $artId, 'slug' => $slugVal, 'title' => $decode($get('title')), @@ -106,6 +107,6 @@ class ArtworkListResource extends JsonResource 'direct' => $directUrl, 'canonical' => $webUrl ?? $directUrl, ], - ]; + ], $this->resource, $request->user()); } } diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index 38da7b82..60f531d3 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -1,7 +1,9 @@ exists(); } - if (Schema::hasTable('artwork_awards')) { - $viewerAward = DB::table('artwork_awards') + if (Schema::hasTable('artwork_medals')) { + $viewerAward = DB::table('artwork_medals') ->where('user_id', $viewerId) ->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), '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(), '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) => [ 'id' => (int) $category->id, 'slug' => (string) $category->slug, diff --git a/app/Jobs/DetectArtworkMaturityJob.php b/app/Jobs/DetectArtworkMaturityJob.php new file mode 100644 index 00000000..244fb8a6 --- /dev/null +++ b/app/Jobs/DetectArtworkMaturityJob.php @@ -0,0 +1,82 @@ +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(), + ]); + } +} \ No newline at end of file diff --git a/app/Jobs/GenerateDerivativesJob.php b/app/Jobs/GenerateDerivativesJob.php index 4972fb85..cb16e970 100644 --- a/app/Jobs/GenerateDerivativesJob.php +++ b/app/Jobs/GenerateDerivativesJob.php @@ -7,6 +7,7 @@ namespace App\Jobs; use App\Services\Uploads\UploadPipelineService; use App\Jobs\AnalyzeArtworkAiAssistJob; use App\Jobs\AutoTagArtworkJob; +use App\Jobs\DetectArtworkMaturityJob; use App\Jobs\GenerateArtworkEmbeddingJob; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -48,6 +49,7 @@ final class GenerateDerivativesJob implements ShouldQueue // Auto-tagging is async and must never block publish. AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit(); + DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit(); AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit(); } diff --git a/app/Jobs/RebuildCreatorJourneyJob.php b/app/Jobs/RebuildCreatorJourneyJob.php new file mode 100644 index 00000000..ef19e1be --- /dev/null +++ b/app/Jobs/RebuildCreatorJourneyJob.php @@ -0,0 +1,35 @@ + $userIds + */ + public function __construct(public readonly array $userIds) + { + } + + public function handle(CreatorJourneyService $journeys): void + { + foreach ($this->userIds as $userId) { + $journeys->rebuildForUser((int) $userId); + } + } +} \ No newline at end of file diff --git a/app/Jobs/RecalculateArtworkMedalStatsJob.php b/app/Jobs/RecalculateArtworkMedalStatsJob.php new file mode 100644 index 00000000..5429cfd7 --- /dev/null +++ b/app/Jobs/RecalculateArtworkMedalStatsJob.php @@ -0,0 +1,29 @@ +refreshArtworkMedalState($this->artworkId); + } +} \ No newline at end of file diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 91033f30..fa606db4 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -52,6 +52,9 @@ class Artwork extends Model 'hash', 'file_ext', 'thumb_ext', + 'has_missing_thumbnails', + 'missing_thumbnail_variants_json', + 'thumbnails_checked_at', 'file_size', 'mime_type', 'width', @@ -60,6 +63,27 @@ class Artwork extends Model 'visibility', 'is_approved', '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', 'hash', 'thumb_ext', @@ -90,7 +114,28 @@ class Artwork extends Model 'visibility' => 'string', 'is_approved' => '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', + 'missing_thumbnail_variants_json' => 'array', + 'thumbnails_checked_at' => 'datetime', 'published_as_type' => 'string', 'published_as_id' => 'integer', 'publish_at' => 'datetime', @@ -184,6 +229,11 @@ class Artwork extends Model return $this->belongsTo(Group::class); } + public function maturityAuditFinding(): HasOne + { + return $this->hasOne(ArtworkMaturityAuditFinding::class); + } + public function uploadedBy(): BelongsTo { return $this->belongsTo(User::class, 'uploaded_by_user_id'); @@ -297,12 +347,31 @@ class Artwork extends Model return $this->hasMany(ArtworkAward::class); } + public function medals(): HasMany + { + return $this->hasMany(ArtworkMedal::class, 'artwork_id'); + } + /** All file versions for this artwork (oldest first). */ public function versions(): HasMany { 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. */ public function currentVersion(): BelongsTo { @@ -319,6 +388,11 @@ class Artwork extends Model return $this->hasOne(ArtworkAwardStat::class); } + public function medalStats(): HasOne + { + return $this->hasOne(ArtworkMedalStat::class, 'artwork_id'); + } + /** * Build the Meilisearch document for this artwork. * Includes all fields required for search, filtering, sorting, and display. @@ -385,12 +459,20 @@ class Artwork extends Model 'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0, 'is_public' => (bool) $this->is_public, '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_score_1h' => (float) ($this->trending_score_1h ?? 0), 'trending_score_24h' => (float) ($this->trending_score_24h ?? 0), 'trending_score_7d' => (float) ($this->trending_score_7d ?? 0), 'favorites_count' => (int) ($stat?->favorites ?? 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), // ── Ranking V2 fields ─────────────────────────────────────────────── 'ranking_score' => (float) ($stat?->ranking_score ?? 0), @@ -404,6 +486,8 @@ class Artwork extends Model 'silver' => $awardStat?->silver_count ?? 0, 'bronze' => $awardStat?->bronze_count ?? 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()); } + 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 { return 'slug'; diff --git a/app/Models/ArtworkAward.php b/app/Models/ArtworkAward.php index 3943540f..b25a3de3 100644 --- a/app/Models/ArtworkAward.php +++ b/app/Models/ArtworkAward.php @@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class ArtworkAward extends Model { - protected $table = 'artwork_awards'; + protected $table = 'artwork_medals'; protected $fillable = [ 'artwork_id', 'user_id', + 'medal_type', 'medal', 'weight', ]; @@ -27,11 +28,26 @@ class ArtworkAward extends Model public const MEDALS = ['gold', 'silver', 'bronze']; public const WEIGHTS = [ - 'gold' => 3, - 'silver' => 2, + 'gold' => 5, + 'silver' => 3, 'bronze' => 1, ]; + public static function weightFor(string $medal): int + { + return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0); + } + + /** + * @return array + */ + public static function weights(): array + { + return collect(self::MEDALS) + ->mapWithKeys(fn (string $medal): array => [$medal => self::weightFor($medal)]) + ->all(); + } + public function artwork(): BelongsTo { return $this->belongsTo(Artwork::class); @@ -41,4 +57,14 @@ class ArtworkAward extends Model { 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; + } } diff --git a/app/Models/ArtworkAwardStat.php b/app/Models/ArtworkAwardStat.php index b280486e..ebc46ed1 100644 --- a/app/Models/ArtworkAwardStat.php +++ b/app/Models/ArtworkAwardStat.php @@ -9,11 +9,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class ArtworkAwardStat extends Model { - protected $table = 'artwork_award_stats'; + protected $table = 'artwork_medal_stats'; public $primaryKey = 'artwork_id'; public $incrementing = false; - public $timestamps = false; + public $timestamps = true; protected $fillable = [ 'artwork_id', @@ -21,6 +21,10 @@ class ArtworkAwardStat extends Model 'silver_count', 'bronze_count', 'score_total', + 'score_7d', + 'score_30d', + 'last_medaled_at', + 'created_at', 'updated_at', ]; @@ -30,6 +34,10 @@ class ArtworkAwardStat extends Model 'silver_count' => 'integer', 'bronze_count' => 'integer', 'score_total' => 'integer', + 'score_7d' => 'integer', + 'score_30d' => 'integer', + 'last_medaled_at' => 'datetime', + 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/ArtworkFeature.php b/app/Models/ArtworkFeature.php index 8a37fc26..d57f5d8f 100644 --- a/app/Models/ArtworkFeature.php +++ b/app/Models/ArtworkFeature.php @@ -3,21 +3,33 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; class ArtworkFeature extends Model { - protected $table = 'artwork_features'; + use SoftDeletes; - public $timestamps = false; + protected $table = 'artwork_features'; protected $fillable = [ 'artwork_id', 'type', 'featured_at', + 'expires_at', + 'priority', + 'label', + 'note', + 'is_active', + 'force_hero', + 'created_by', ]; protected $casts = [ 'featured_at' => 'datetime', + 'expires_at' => 'datetime', + 'priority' => 'integer', + 'is_active' => 'boolean', + 'force_hero' => 'boolean', ]; public function artwork(): BelongsTo diff --git a/app/Models/ArtworkMaturityAuditFinding.php b/app/Models/ArtworkMaturityAuditFinding.php new file mode 100644 index 00000000..49e15dd8 --- /dev/null +++ b/app/Models/ArtworkMaturityAuditFinding.php @@ -0,0 +1,61 @@ + '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'); + } +} \ No newline at end of file diff --git a/app/Models/ArtworkMedal.php b/app/Models/ArtworkMedal.php new file mode 100644 index 00000000..cb0c4754 --- /dev/null +++ b/app/Models/ArtworkMedal.php @@ -0,0 +1,9 @@ + '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'); + } +} \ No newline at end of file diff --git a/app/Models/Category.php b/app/Models/Category.php index 342d9141..57e51b58 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -17,6 +17,27 @@ class Category extends Model 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. */ @@ -159,4 +180,25 @@ class Category extends Model 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; + } } diff --git a/app/Models/Collection.php b/app/Models/Collection.php index 4990b2b6..48b0ffdb 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -653,19 +653,26 @@ class Collection extends Model 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(); - if ($cover && (! $publicOnly || $this->artworkIsPubliclyVisible($cover))) { + if ($cover && $this->artworkMatchesCoverVisibility($cover, $publicOnly, $hideMature)) { return $cover; } $relation = $publicOnly ? 'publicArtworks' : 'artworks'; $artworks = $this->relationLoaded($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 @@ -712,4 +719,18 @@ class Collection extends Model && $artwork->published_at !== null && $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'; + } } diff --git a/app/Models/ContentType.php b/app/Models/ContentType.php index 30f29fdf..d6ee0c22 100644 --- a/app/Models/ContentType.php +++ b/app/Models/ContentType.php @@ -10,10 +10,11 @@ use App\Models\Artwork; 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 = [ 'order' => 'integer', + 'hide_from_menu' => 'boolean', ]; public function scopeOrdered(EloquentBuilder $query): EloquentBuilder @@ -21,6 +22,11 @@ class ContentType extends Model 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 { return $this->hasMany(Category::class); @@ -31,6 +37,11 @@ class ContentType extends Model 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. * 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 { 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, '/'); + } } diff --git a/app/Models/ContentTypeSlugHistory.php b/app/Models/ContentTypeSlugHistory.php new file mode 100644 index 00000000..42ef1137 --- /dev/null +++ b/app/Models/ContentTypeSlugHistory.php @@ -0,0 +1,19 @@ +belongsTo(ContentType::class); + } +} diff --git a/app/Models/CreatorEra.php b/app/Models/CreatorEra.php new file mode 100644 index 00000000..5bfa49ec --- /dev/null +++ b/app/Models/CreatorEra.php @@ -0,0 +1,45 @@ + 'datetime', + 'ends_at' => 'datetime', + 'is_current' => 'boolean', + 'metadata' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/CreatorMilestone.php b/app/Models/CreatorMilestone.php new file mode 100644 index 00000000..e2d5af0c --- /dev/null +++ b/app/Models/CreatorMilestone.php @@ -0,0 +1,46 @@ + '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'); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index d8ba8414..3be5a2e9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -124,6 +124,11 @@ class User extends Authenticatable return $this->hasMany(Artwork::class); } + public function givenArtworkMedals(): HasMany + { + return $this->hasMany(ArtworkMedal::class, 'user_id'); + } + public function collections(): HasMany { return $this->hasMany(Collection::class)->latest('updated_at'); diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php index 5c0915f4..230f2740 100644 --- a/app/Models/UserProfile.php +++ b/app/Models/UserProfile.php @@ -35,6 +35,8 @@ class UserProfile extends Model 'follower_notifications', 'comment_notifications', 'newsletter', + 'mature_content_visibility', + 'mature_content_warning_enabled', ]; protected $casts = [ @@ -46,6 +48,7 @@ class UserProfile extends Model 'follower_notifications' => 'boolean', 'comment_notifications' => 'boolean', 'newsletter' => 'boolean', + 'mature_content_warning_enabled' => 'boolean', ]; public $timestamps = true; diff --git a/app/Observers/ArtworkAwardObserver.php b/app/Observers/ArtworkAwardObserver.php index a621ffc1..68b52fa6 100644 --- a/app/Observers/ArtworkAwardObserver.php +++ b/app/Observers/ArtworkAwardObserver.php @@ -4,15 +4,14 @@ declare(strict_types=1); namespace App\Observers; +use App\Jobs\RecalculateArtworkMedalStatsJob; use App\Models\ArtworkAward; -use App\Services\ArtworkAwardService; use App\Services\UserStatsService; use Illuminate\Support\Facades\DB; class ArtworkAwardObserver { public function __construct( - private readonly ArtworkAwardService $service, private readonly UserStatsService $userStats, ) {} @@ -36,12 +35,7 @@ class ArtworkAwardObserver private function refresh(ArtworkAward $award): void { - $this->service->recalcStats($award->artwork_id); - - $artwork = $award->artwork; - if ($artwork) { - $this->service->syncToSearch($artwork); - } + RecalculateArtworkMedalStatsJob::dispatchSync((int) $award->artwork_id); } private function trackCreatorStats(ArtworkAward $award, int $delta): void diff --git a/app/Observers/ArtworkCommentObserver.php b/app/Observers/ArtworkCommentObserver.php index 31ed96f4..489a6ea0 100644 --- a/app/Observers/ArtworkCommentObserver.php +++ b/app/Observers/ArtworkCommentObserver.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Observers; use App\Models\ArtworkComment; +use App\Services\ArtworkStatsService; +use App\Services\Profile\CreatorJourneyService; use App\Services\UserStatsService; use App\Services\UserMentionSyncService; use App\Services\XPService; @@ -17,7 +19,9 @@ use Illuminate\Support\Facades\DB; class ArtworkCommentObserver { public function __construct( + private readonly ArtworkStatsService $artworkStats, private readonly UserStatsService $userStats, + private readonly CreatorJourneyService $journeys, private readonly UserMentionSyncService $mentionSync, private readonly XPService $xp, ) {} @@ -27,6 +31,7 @@ class ArtworkCommentObserver $creatorId = $this->creatorId($comment->artwork_id); if ($creatorId) { $this->userStats->incrementCommentsReceived($creatorId); + $this->journeys->requestRebuild($creatorId); } // The commenter is "active" @@ -34,6 +39,7 @@ class ArtworkCommentObserver $this->userStats->setLastActiveAt($comment->user_id); $this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork'); $this->mentionSync->syncForComment($comment); + $this->artworkStats->syncEngagementCounts((int) $comment->artwork_id); } public function updated(ArtworkComment $comment): void @@ -49,9 +55,11 @@ class ArtworkCommentObserver $creatorId = $this->creatorId($comment->artwork_id); if ($creatorId) { $this->userStats->decrementCommentsReceived($creatorId); + $this->journeys->requestRebuild($creatorId); } $this->mentionSync->deleteForComment((int) $comment->id); + $this->artworkStats->syncEngagementCounts((int) $comment->artwork_id); } /** Hard delete after soft delete — already decremented; nothing to do. */ @@ -63,15 +71,23 @@ class ArtworkCommentObserver $creatorId = $this->creatorId($comment->artwork_id); if ($creatorId) { $this->userStats->decrementCommentsReceived($creatorId); + $this->journeys->requestRebuild($creatorId); } } $this->mentionSync->deleteForComment((int) $comment->id); + $this->artworkStats->syncEngagementCounts((int) $comment->artwork_id); } public function restored(ArtworkComment $comment): void { $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 diff --git a/app/Observers/ArtworkFavouriteObserver.php b/app/Observers/ArtworkFavouriteObserver.php index 29dabff5..6f2ab751 100644 --- a/app/Observers/ArtworkFavouriteObserver.php +++ b/app/Observers/ArtworkFavouriteObserver.php @@ -7,6 +7,7 @@ namespace App\Observers; use App\Jobs\RecComputeSimilarByBehaviorJob; use App\Jobs\RecComputeSimilarHybridJob; use App\Models\ArtworkFavourite; +use App\Services\Profile\CreatorJourneyService; use App\Services\UserStatsService; use Illuminate\Support\Facades\DB; @@ -18,6 +19,7 @@ class ArtworkFavouriteObserver { public function __construct( private readonly UserStatsService $userStats, + private readonly CreatorJourneyService $journeys, ) {} public function created(ArtworkFavourite $favourite): void @@ -25,6 +27,7 @@ class ArtworkFavouriteObserver $creatorId = $this->creatorId($favourite->artwork_id); if ($creatorId) { $this->userStats->incrementFavoritesReceived($creatorId); + $this->journeys->requestRebuild($creatorId); } // §7.5 On-demand: recompute behavior similarity when artwork reaches threshold @@ -36,6 +39,7 @@ class ArtworkFavouriteObserver $creatorId = $this->creatorId($favourite->artwork_id); if ($creatorId) { $this->userStats->decrementFavoritesReceived($creatorId); + $this->journeys->requestRebuild($creatorId); } } diff --git a/app/Observers/ArtworkFeatureObserver.php b/app/Observers/ArtworkFeatureObserver.php new file mode 100644 index 00000000..297d22c1 --- /dev/null +++ b/app/Observers/ArtworkFeatureObserver.php @@ -0,0 +1,63 @@ +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); + } +} \ No newline at end of file diff --git a/app/Observers/ArtworkObserver.php b/app/Observers/ArtworkObserver.php index d4492e8c..c08715d3 100644 --- a/app/Observers/ArtworkObserver.php +++ b/app/Observers/ArtworkObserver.php @@ -10,8 +10,11 @@ use App\Jobs\RecComputeSimilarByTagsJob; use App\Jobs\RecComputeSimilarHybridJob; use App\Jobs\Posts\AutoUploadPostJob; use App\Services\ArtworkSearchIndexer; +use App\Services\HomepageService; +use App\Services\Profile\CreatorJourneyService; use App\Services\UserStatsService; use App\Services\XPService; +use Illuminate\Support\Facades\Cache; /** * Syncs artwork documents to Meilisearch on every relevant model event. @@ -25,6 +28,8 @@ class ArtworkObserver private readonly ArtworkSearchIndexer $indexer, private readonly UserStatsService $userStats, private readonly XPService $xp, + private readonly HomepageService $homepage, + private readonly CreatorJourneyService $journeys, ) {} /** New artwork created — index; bump uploadscount + last_upload_at. */ @@ -33,6 +38,11 @@ class ArtworkObserver $this->indexer->index($artwork); $this->userStats->incrementUploads($artwork->user_id); $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) { $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. */ @@ -82,12 +104,20 @@ class ArtworkObserver { $this->indexer->delete($artwork->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. */ public function forceDeleted(Artwork $artwork): void { $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; // the deleted() event did NOT fire, so we decrement here. @@ -101,5 +131,25 @@ class ArtworkObserver { $this->indexer->index($artwork); $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(); } } diff --git a/app/Observers/ContentTypeObserver.php b/app/Observers/ContentTypeObserver.php new file mode 100644 index 00000000..14086624 --- /dev/null +++ b/app/Observers/ContentTypeObserver.php @@ -0,0 +1,37 @@ +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(); + } +} diff --git a/app/Observers/GroupReleaseContributorObserver.php b/app/Observers/GroupReleaseContributorObserver.php new file mode 100644 index 00000000..b5023f40 --- /dev/null +++ b/app/Observers/GroupReleaseContributorObserver.php @@ -0,0 +1,39 @@ +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); + } +} \ No newline at end of file diff --git a/app/Observers/GroupReleaseObserver.php b/app/Observers/GroupReleaseObserver.php new file mode 100644 index 00000000..9fbb63a4 --- /dev/null +++ b/app/Observers/GroupReleaseObserver.php @@ -0,0 +1,58 @@ +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); + } + } +} \ No newline at end of file diff --git a/app/Policies/ArtworkAwardPolicy.php b/app/Policies/ArtworkAwardPolicy.php index 633c4412..3959a723 100644 --- a/app/Policies/ArtworkAwardPolicy.php +++ b/app/Policies/ArtworkAwardPolicy.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Policies; +use Illuminate\Auth\Access\Response; use App\Models\ArtworkAward; use App\Models\Artwork; use App\Models\User; @@ -27,14 +28,26 @@ class ArtworkAwardPolicy * that isn't their own. * 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) { - 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) { - return false; + return Response::deny('You cannot medal your own artwork.'); } 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) { - return true; // cannot verify — allow + if ((bool) config('artwork_medals.require_verified_email', true)) { + $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(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9e35e1c6..304ee1b3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,15 +10,26 @@ use Illuminate\Support\ServiceProvider; use App\Models\Artwork; use App\Models\ArtworkAward; use App\Models\ArtworkComment; +use App\Models\ArtworkFeature; use App\Models\ArtworkFavourite; +use App\Models\ArtworkMedal; use App\Models\ArtworkReaction; +use App\Models\ContentType; +use App\Models\GroupRelease; +use App\Models\GroupReleaseContributor; use App\Observers\ArtworkAwardObserver; use App\Observers\ArtworkCommentObserver; +use App\Observers\ArtworkFeatureObserver; use App\Observers\ArtworkFavouriteObserver; use App\Observers\ArtworkObserver; 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\UploadDraftService; +use App\Services\ContentTypes\ContentTypeSlugResolver; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; @@ -82,6 +93,12 @@ class AppServiceProvider extends ServiceProvider // after the folder was renamed from legacy/ to _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->configureUploadRateLimiters(); $this->configureMessagingRateLimiters(); @@ -94,10 +111,15 @@ class AppServiceProvider extends ServiceProvider $this->configureMailFailureLogging(); ArtworkAward::observe(ArtworkAwardObserver::class); + ArtworkMedal::observe(ArtworkAwardObserver::class); Artwork::observe(ArtworkObserver::class); + ArtworkFeature::observe(ArtworkFeatureObserver::class); ArtworkFavourite::observe(ArtworkFavouriteObserver::class); ArtworkComment::observe(ArtworkCommentObserver::class); ArtworkReaction::observe(ArtworkReactionObserver::class); + ContentType::observe(ContentTypeObserver::class); + GroupRelease::observe(GroupReleaseObserver::class); + GroupReleaseContributor::observe(GroupReleaseContributorObserver::class); // ── OAuth / SocialiteProviders ────────────────────────────────────── Event::listen( @@ -134,6 +156,17 @@ class AppServiceProvider extends ServiceProvider $avatarHash = null; $displayName = 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()) { $userId = Auth::id(); @@ -188,7 +221,7 @@ class AppServiceProvider extends ServiceProvider $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 @@ -384,13 +417,14 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('artwork-awards', function (Request $request): array { $userId = $request->user()?->id; $artworkId = (int) $request->route('id'); + $perMinute = max(1, (int) config('artwork_medals.rate_limit_per_minute', 10)); return [ // 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. - Limit::perMinute(120)->by('awards:user:' . ($userId ?? 'guest')), - Limit::perMinute(180)->by('awards:ip:' . $request->ip()), + Limit::perMinute($perMinute * 6)->by('awards:user:' . ($userId ?? 'guest')), + Limit::perMinute($perMinute * 9)->by('awards:ip:' . $request->ip()), ]; }); } @@ -472,6 +506,10 @@ class AppServiceProvider extends ServiceProvider private function registerCpadMenuItems(): void { + if (! $this->isControlPanelRequest()) { + return; + } + if (! class_exists(Menu::class)) { return; } @@ -483,4 +521,25 @@ class AppServiceProvider extends ServiceProvider // 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 . '/*'); + } } diff --git a/app/Services/ArtworkAwardService.php b/app/Services/ArtworkAwardService.php index e968ef46..66303f24 100644 --- a/app/Services/ArtworkAwardService.php +++ b/app/Services/ArtworkAwardService.php @@ -4,45 +4,24 @@ 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\User; -use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class ArtworkAwardService { + public function __construct(private readonly ArtworkMedalService $medals) + { + } + /** * Award an artwork with the given medal. * Throws ValidationException if the user already awarded this artwork. */ public function award(Artwork $artwork, User $user, string $medal): ArtworkAward { - $this->validateMedal($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; + return $this->medals->award($artwork, $user, $medal); } /** @@ -50,21 +29,7 @@ class ArtworkAwardService */ public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward { - $this->validateMedal($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(); + return $this->medals->changeMedal($artwork, $user, $medal); } /** @@ -73,17 +38,7 @@ class ArtworkAwardService */ public function removeAward(Artwork $artwork, User $user): void { - $award = ArtworkAward::where('artwork_id', $artwork->id) - ->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); - } + $this->medals->removeMedal($artwork, $user); } /** @@ -91,32 +46,7 @@ class ArtworkAwardService */ public function recalcStats(int $artworkId): ArtworkAwardStat { - $counts = DB::table('artwork_awards') - ->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; + return $this->medals->recalculateStats($artworkId); } /** @@ -124,15 +54,6 @@ class ArtworkAwardService */ public function syncToSearch(Artwork $artwork): void { - IndexArtworkJob::dispatch($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.', - ]); - } + $this->medals->syncArtworkToSearch((int) $artwork->id); } } diff --git a/app/Services/ArtworkEvolutionService.php b/app/Services/ArtworkEvolutionService.php new file mode 100644 index 00000000..6d83cc05 --- /dev/null +++ b/app/Services/ArtworkEvolutionService.php @@ -0,0 +1,644 @@ + + */ + 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> + */ + 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|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> + */ + 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|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 + */ + 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 $manageableGroupIds + * @return list> + */ + 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 $manageableGroupIds + * @param list $excludeIds + * @return list> + */ + 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 $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|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|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 + */ + 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 $card + */ + private function shouldOmitForViewer(array $card): bool + { + return (bool) data_get($card, 'maturity.should_hide', false); + } + + /** + * @param array $before + * @param array $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', + }; + } +} \ No newline at end of file diff --git a/app/Services/ArtworkMedalService.php b/app/Services/ArtworkMedalService.php new file mode 100644 index 00000000..14dadb02 --- /dev/null +++ b/app/Services/ArtworkMedalService.php @@ -0,0 +1,176 @@ +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.', + ]); + } + } +} \ No newline at end of file diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index b8730b14..58607a2b 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -7,9 +7,11 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Tag; use App\Services\EarlyGrowth\AdaptiveTimeWindow; +use App\Services\Maturity\ArtworkMaturityService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; /** * 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 CACHE_TTL = 300; // 5 minutes 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( private readonly AdaptiveTimeWindow $timeWindow, + private readonly ArtworkMaturityService $maturity, ) {} /** @@ -76,11 +81,38 @@ final class ArtworkSearchService $options['sort'] = $sort; } + $options = $this->viewerAwareOptions($options); + return Artwork::search($q ?: '') ->options($options) ->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. */ @@ -92,12 +124,13 @@ final class ArtworkSearchService } $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) { $query = Artwork::query() ->public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id)) ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') @@ -132,14 +165,14 @@ final class ArtworkSearchService */ 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 Artwork::search('') - ->options([ + ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"', 'sort' => ['created_at:desc'], - ]) + ])) ->paginate($perPage); }); } @@ -181,14 +214,14 @@ final class ArtworkSearchService $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $page = (int) request()->get('page', 1); $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 Artwork::search('') - ->options([ + ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"', 'sort' => self::CATEGORY_SORT_FIELDS[$sort], - ]) + ])) ->paginate($perPage); }); } @@ -204,14 +237,14 @@ final class ArtworkSearchService $sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending'; $page = (int) request()->get('page', 1); $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 Artwork::search('') - ->options([ + ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"', 'sort' => self::CATEGORY_SORT_FIELDS[$sort], - ]) + ])) ->paginate($perPage); }); } @@ -230,7 +263,7 @@ final class ArtworkSearchService 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) { $tagFilters = implode(' OR ', array_map( @@ -239,10 +272,10 @@ final class ArtworkSearchService )); return Artwork::search('') - ->options([ + ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')', 'sort' => ['views:desc', 'likes:desc'], - ]) + ])) ->paginate($limit); }); } @@ -252,12 +285,12 @@ final class ArtworkSearchService */ 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('') - ->options([ + ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER, 'sort' => ['views:desc', 'likes:desc'], - ]) + ])) ->paginate($perPage); }); } @@ -267,12 +300,12 @@ final class ArtworkSearchService */ 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('') - ->options([ + ->options($this->viewerAwareOptions([ 'filter' => self::BASE_FILTER, 'sort' => ['published_at_ts:desc'], - ]) + ])) ->paginate($perPage); }); } @@ -291,15 +324,13 @@ final class ArtworkSearchService $windowDays = $this->timeWindow->getTrendingWindowDays(30); $cutoff = now()->subDays($windowDays)->toDateString(); // 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 Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', - 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], - ]) - ->paginate($perPage); + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', + 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], + ], $perPage); }); } @@ -314,15 +345,13 @@ final class ArtworkSearchService $page = (int) request()->get('page', 1); $windowDays = $this->timeWindow->getTrendingWindowDays(30); $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 Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', - 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'], - ]) - ->paginate($perPage); + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', + 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'], + ], $perPage); }); } @@ -332,13 +361,11 @@ final class ArtworkSearchService public function discoverFresh(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); - return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) { - return Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER, - 'sort' => ['published_at_ts:desc'], - ]) - ->paginate($perPage); + return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) { + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER, + 'sort' => ['published_at_ts:desc'], + ], $perPage); }); } @@ -348,13 +375,11 @@ final class ArtworkSearchService public function discoverTopRated(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); - return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) { - return Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER, - 'sort' => ['likes:desc', 'views:desc'], - ]) - ->paginate($perPage); + return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) { + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER, + 'sort' => ['likes:desc', 'views:desc'], + ], $perPage); }); } @@ -364,13 +389,11 @@ final class ArtworkSearchService public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator { $page = (int) request()->get('page', 1); - return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) { - return Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER, - 'sort' => ['downloads:desc', 'views:desc'], - ]) - ->paginate($perPage); + return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) { + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER, + 'sort' => ['downloads:desc', 'views:desc'], + ], $perPage); }); } @@ -391,18 +414,28 @@ final class ArtworkSearchService 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 Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')', - 'sort' => ['trending_score_7d:desc', 'likes:desc'], - ]) - ->paginate($limit); + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')', + 'sort' => ['trending_score_7d:desc', 'likes:desc'], + ], $limit, true, 1); }); } + 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. * Used for personalized "Fresh in your favourite categories" section. @@ -420,15 +453,13 @@ final class ArtworkSearchService 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 Artwork::search('') - ->options([ - 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', - 'sort' => ['published_at_ts:desc'], - ]) - ->paginate($limit); + return $this->searchWithThumbnailPreference([ + 'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')', + 'sort' => ['published_at_ts:desc'], + ], $limit, true, 1); }); } @@ -444,6 +475,52 @@ final class ArtworkSearchService 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 { return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage); diff --git a/app/Services/ArtworkService.php b/app/Services/ArtworkService.php index c7afa5fa..69fa452c 100644 --- a/app/Services/ArtworkService.php +++ b/app/Services/ArtworkService.php @@ -4,6 +4,9 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Category; 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\CursorPaginator; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -11,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Schema; /** * ArtworkService @@ -23,6 +27,30 @@ class ArtworkService { 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 + */ + 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. * @@ -32,7 +60,7 @@ class ArtworkService { return [ 'user:id,name,username', - 'user.profile:user_id,avatar_url', + 'user.profile:user_id,avatar_hash', 'group:id,name,slug,avatar_path', 'categories' => function ($q) { $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() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->with($this->browseRelations()); $normalizedSort = strtolower(trim($sort)); @@ -122,6 +151,7 @@ class ArtworkService public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator { $query = Artwork::public()->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->with($this->browseRelations()) ->whereHas('categories', function ($q) use ($category) { $q->where('categories.id', $category->id); @@ -141,6 +171,7 @@ class ArtworkService public function getLatestArtworks(int $limit = 10): Collection { return Artwork::public()->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) ->orderByDesc('published_at') ->limit($limit) ->get(); @@ -165,13 +196,7 @@ class ArtworkService */ public function getArtworksByContentType(string $slug, int $perPage, string $sort = 'latest'): CursorPaginator { - $contentType = ContentType::where('slug', strtolower($slug))->first(); - - if (! $contentType) { - $e = new ModelNotFoundException(); - $e->setModel(ContentType::class, [$slug]); - throw $e; - } + $contentType = $this->resolveContentTypeOrFail($slug); $query = $this->browseQuery($sort) ->whereHas('categories', function ($q) use ($contentType) { @@ -198,12 +223,7 @@ class ArtworkService $parts = array_values(array_map('strtolower', $slugs)); $contentTypeSlug = array_shift($parts); - $contentType = ContentType::where('slug', $contentTypeSlug)->first(); - if (! $contentType) { - $e = new ModelNotFoundException(); - $e->setModel(ContentType::class, [$contentTypeSlug]); - throw $e; - } + $contentType = $this->resolveContentTypeOrFail((string) $contentTypeSlug); if (empty($parts)) { $e = new ModelNotFoundException(); @@ -274,30 +294,102 @@ class ArtworkService 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. * 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.*') ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') - ->public() - ->published() + ->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id') + ->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) { $q->where('af.type', $type); - }) - ->with([ - 'user:id,name,username', - 'categories' => function ($q) { - $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order'); - }, - ]) + }); + } + + private function applyFeaturedEligibilityFilters(Builder $query): void + { + $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('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 * @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) ->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 ($q) { $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) { // Apply public visibility constraints for non-owners $query->public()->published(); + $this->maturity->applyViewerFilter($query, $viewer); } else { // Owner: include all non-deleted items (do not force published/approved) $query->whereNull('deleted_at'); diff --git a/app/Services/ArtworkStatsService.php b/app/Services/ArtworkStatsService.php index 118a5a16..e5c8bb98 100644 --- a/app/Services/ArtworkStatsService.php +++ b/app/Services/ArtworkStatsService.php @@ -6,6 +6,7 @@ use App\Services\UserStatsService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; +use Illuminate\Support\Facades\Schema; use Throwable; /** @@ -91,6 +92,55 @@ class ArtworkStatsService $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. * After updating artwork-level stats, forwards view/download counts to diff --git a/app/Services/CollectionService.php b/app/Services/CollectionService.php index 5a833765..c46edc07 100644 --- a/app/Services/CollectionService.php +++ b/app/Services/CollectionService.php @@ -17,6 +17,7 @@ use App\Models\Artwork; use App\Models\Collection; use App\Models\Group; use App\Models\User; +use App\Services\Maturity\ArtworkMaturityService; use App\Support\AvatarUrl; use App\Services\ThumbnailPresenter; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -33,6 +34,7 @@ class CollectionService private readonly SmartCollectionService $smartCollections, private readonly CollectionCollaborationService $collaborators, private readonly GroupMembershipService $groupMembers, + private readonly ArtworkMaturityService $maturity, ) { } @@ -492,12 +494,14 @@ class CollectionService 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()) { return $this->smartCollections->resolveArtworks($collection, $ownerView, $perPage); } + $viewer ??= $ownerView ? null : request()->user(); + $query = $collection->artworks() ->with([ 'user:id,name,username', @@ -515,12 +519,21 @@ class CollectionService ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->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) { Collection::SORT_NEWEST => $query->orderByDesc('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'), }; @@ -843,15 +856,18 @@ class CollectionService public function mapCollectionCardPayloads(iterable $collections, bool $ownerView = false, ?User $viewer = null): array { + $viewer ??= $ownerView ? null : request()->user(); $collectionList = $collections instanceof EloquentCollection ? $collections : new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections)); $collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all(); + $hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer); $firstArtworkMap = $this->firstArtworkMapForCollections( $collectionIds, - ! $ownerView + ! $ownerView, + $hideMatureCovers, ); $savedCollectionIds = $viewer && ! $ownerView && $collectionIds !== [] @@ -866,9 +882,11 @@ class CollectionService return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) { $resolvedCover = $collection->isSmart() ? $this->smartCollections->firstArtwork($collection, $ownerView) - : $collection->resolvedCoverArtwork(! $ownerView); + : $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer)); $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; $isSaved = in_array((int) $collection->id, $savedCollectionIds, true); $canSave = ! $ownerView && $viewer && $collection->canBeSavedBy($viewer); @@ -958,6 +976,7 @@ class CollectionService 'last_recommendation_refresh_at' => optional($collection->last_recommendation_refresh_at)?->toISOString(), 'smart_summary' => $collection->isSmart() ? $this->smartCollections->smartSummary($collection->smart_rules_json) : null, 'cover_image' => $cover ? $this->mapArtworkThumb($cover) : null, + 'cover_image_maturity' => ! $ownerView && $cover ? $this->maturity->presentation($cover, $viewer) : null, 'cover_artwork_id' => $cover?->id, 'saved' => $isSaved, 'save_url' => $canSave ? route('collections.save', ['collection' => $collection->id]) : null, @@ -976,11 +995,18 @@ class CollectionService })->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() ? $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 [ 'id' => $collection->id, @@ -1074,7 +1100,8 @@ class CollectionService 'expired_at' => optional($collection->expired_at)?->toISOString(), 'history_count' => (int) $collection->history_count, '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, '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, @@ -1194,7 +1221,7 @@ class CollectionService * @param array $collectionIds * @return SupportCollection */ - private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly): SupportCollection + private function firstArtworkMapForCollections(array $collectionIds, bool $publicOnly, bool $hideMature = false): SupportCollection { if ($collectionIds === []) { return collect(); @@ -1210,6 +1237,10 @@ class CollectionService ->whereNotNull('a.published_at') ->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.order_num') ->select(['ca.collection_id', 'a.id']) @@ -1237,7 +1268,7 @@ class CollectionService $contentType = $category?->contentType; $stats = $artwork->stats; - return array_merge([ + return $this->maturity->decoratePayload(array_merge([ 'id' => $artwork->id, 'title' => $artwork->title, 'slug' => $artwork->slug, @@ -1261,7 +1292,7 @@ class CollectionService 'username' => $artwork->user->username, 'profile_url' => route('profile.show', ['username' => strtolower((string) $artwork->user->username)]), ] : null, - ], $extra); + ], $extra), $artwork, request()->user()); } 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'); } + 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 { return Collection::query() diff --git a/app/Services/ContentSanitizer.php b/app/Services/ContentSanitizer.php index e095d404..b45f7283 100644 --- a/app/Services/ContentSanitizer.php +++ b/app/Services/ContentSanitizer.php @@ -206,12 +206,18 @@ class ContentSanitizer */ private static function sanitizeHtml(string $html, bool $allowLinks = true): string { + $encodedHtml = mb_encode_numericentity( + $html, + [0x80, 0x10FFFF, 0, 0xFFFFFF], + 'UTF-8' + ); + // Parse with DOMDocument $doc = new \DOMDocument('1.0', 'UTF-8'); // Suppress warnings from malformed fragments libxml_use_internal_errors(true); $doc->loadHTML( - '' . $html . '', + '' . $encodedHtml . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); @@ -226,7 +232,7 @@ class ContentSanitizer } // Fix self-closing etc. - return trim($inner); + return trim(html_entity_decode($inner, ENT_QUOTES | ENT_HTML5, 'UTF-8')); } /** diff --git a/app/Services/ContentTypeAssetService.php b/app/Services/ContentTypeAssetService.php new file mode 100644 index 00000000..b01490e7 --- /dev/null +++ b/app/Services/ContentTypeAssetService.php @@ -0,0 +1,98 @@ +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', + }; + } +} \ No newline at end of file diff --git a/app/Services/ContentTypes/ContentTypeSlugResolution.php b/app/Services/ContentTypes/ContentTypeSlugResolution.php new file mode 100644 index 00000000..d2778323 --- /dev/null +++ b/app/Services/ContentTypes/ContentTypeSlugResolution.php @@ -0,0 +1,27 @@ +contentType !== null || $this->isVirtual; + } + + public function requiresRedirect(): bool + { + return $this->redirectSlug !== null && $this->redirectSlug !== '' && $this->redirectSlug !== $this->requestedSlug; + } +} diff --git a/app/Services/ContentTypes/ContentTypeSlugResolver.php b/app/Services/ContentTypes/ContentTypeSlugResolver.php new file mode 100644 index 00000000..de7d8914 --- /dev/null +++ b/app/Services/ContentTypes/ContentTypeSlugResolver.php @@ -0,0 +1,151 @@ +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'); + } +} diff --git a/app/Services/EarlyGrowth/GridFiller.php b/app/Services/EarlyGrowth/GridFiller.php index 2954fea3..cc1c9f63 100644 --- a/app/Services/EarlyGrowth/GridFiller.php +++ b/app/Services/EarlyGrowth/GridFiller.php @@ -112,6 +112,7 @@ final class GridFiller return Artwork::query() ->public() ->published() + ->withoutMissingThumbnails() ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', diff --git a/app/Services/ErrorSuggestionService.php b/app/Services/ErrorSuggestionService.php index 2a3fec4d..146943b3 100644 --- a/app/Services/ErrorSuggestionService.php +++ b/app/Services/ErrorSuggestionService.php @@ -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'), 'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]), 'thumb' => $md['url'] ?? null, + 'thumb_srcset' => $md['srcset'] ?? null, ]; } diff --git a/app/Services/FeaturedArtworkAdminService.php b/app/Services/FeaturedArtworkAdminService.php new file mode 100644 index 00000000..1b5b5406 --- /dev/null +++ b/app/Services/FeaturedArtworkAdminService.php @@ -0,0 +1,479 @@ + + */ + 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> + */ + 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 $duplicateCounts + * @return array + */ + 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 + */ + 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 + */ + 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> $entries + * @return SupportCollection> + */ + 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 $winner + * @param SupportCollection> $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|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); + } +} \ No newline at end of file diff --git a/app/Services/GroupService.php b/app/Services/GroupService.php index 0c57fc5c..b889556d 100644 --- a/app/Services/GroupService.php +++ b/app/Services/GroupService.php @@ -8,6 +8,7 @@ use App\Models\Artwork; use App\Models\Collection; use App\Models\Group; use App\Models\User; +use App\Services\Maturity\ArtworkMaturityService; use App\Services\ThumbnailPresenter; use Illuminate\Http\UploadedFile; use Illuminate\Pagination\LengthAwarePaginator; @@ -32,6 +33,7 @@ class GroupService private readonly GroupActivityService $activity, private readonly GroupHistoryService $history, private readonly GroupReputationService $reputation, + private readonly ArtworkMaturityService $maturity, ) { } @@ -372,6 +374,8 @@ class GroupService ->where('is_approved', true) ->whereNotNull('published_at'); + $this->maturity->applyViewerFilter($query, request()->user()); + if ((int) ($group->featured_artwork_id ?? 0) > 0) { $featuredArtwork = (clone $query) ->where('id', (int) $group->featured_artwork_id) @@ -461,14 +465,18 @@ class GroupService public function publicArtworkCards(Group $group, int $limit = 18): array { - return Artwork::query() + $query = Artwork::query() ->with(['user.profile', 'group', 'primaryAuthor.profile']) ->where('group_id', $group->id) ->whereNull('deleted_at') ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') - ->latest('published_at') + ->latest('published_at'); + + $this->maturity->applyViewerFilter($query, request()->user()); + + return $query ->limit($limit) ->get() ->map(fn (Artwork $artwork): array => $this->mapPublicArtworkCard($artwork)) @@ -493,7 +501,7 @@ class GroupService private function mapPublicArtworkCard(Artwork $artwork): array { - return [ + return $this->maturity->decoratePayload([ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, '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), 'author' => $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist', 'published_at' => $artwork->published_at?->toISOString(), - ]; + ], $artwork, request()->user()); } public function studioDashboardSummary(Group $group): array diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index 519f4fe4..6acf2ca4 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -14,6 +14,7 @@ use App\Services\Recommendations\RecommendationFeedResolver; use App\Services\UserPreferenceService; use App\Support\AvatarUrl; use App\Models\Collection as CollectionModel; +use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Facades\Cache; @@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Database\QueryException; use cPad\Plugins\News\Models\NewsArticle; +use App\Services\Maturity\ArtworkMaturityService; /** * HomepageService @@ -32,9 +34,11 @@ use cPad\Plugins\News\Models\NewsArticle; final class HomepageService { private const CACHE_TTL = 300; // 5 minutes + private const DEFAULT_ARTWORK_RAIL_LIMIT = 10; private const ARTWORK_SERIALIZATION_RELATIONS = [ 'user:id,name,username', '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.contentType:id,name,slug', ]; @@ -42,6 +46,7 @@ final class HomepageService public function __construct( private readonly ArtworkService $artworks, private readonly ArtworkSearchService $search, + private readonly ArtworkMaturityService $maturity, private readonly UserPreferenceService $prefs, private readonly RecommendationFeedResolver $feedResolver, private readonly GridFiller $gridFiller, @@ -60,9 +65,60 @@ final class HomepageService * Return all homepage section data as a single array ready to JSON-encode. */ 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 [ 'hero' => $this->getHeroArtwork(), + 'community_favorites' => $this->getCommunityFavorites(), + 'hall_of_fame' => $this->getHallOfFame(), 'rising' => $this->getRising(), 'trending' => $this->getTrending(), '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. * @@ -97,6 +168,8 @@ final class HomepageService 'is_logged_in' => true, 'user_data' => $this->getUserData($user), 'hero' => $this->getHeroArtwork(), + 'community_favorites' => $this->getCommunityFavorites(), + 'hall_of_fame' => $this->getHallOfFame(), 'for_you' => $this->getForYouPreview($user), 'from_following' => $this->getFollowingFeed($user, $prefs), 'rising' => $this->getRising(), @@ -127,52 +200,56 @@ final class HomepageService public function getForYouPreview(\App\Models\User $user, int $limit = 12): array { 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'] ?? ''); $discoveryEndpoint = route('api.discovery.events.store'); $hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork'); $dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag'); - return collect($feed['data'] ?? [])->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array { - $reason = (string) ($item['reason'] ?? 'Picked for you'); + 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'); - return [ - 'id' => (int) ($item['id'] ?? 0), - 'title' => (string) ($item['title'] ?? 'Untitled'), - 'name' => (string) ($item['title'] ?? 'Untitled'), - 'slug' => (string) ($item['slug'] ?? ''), - 'author' => (string) ($item['author'] ?? 'Artist'), - 'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null, - 'author_username' => (string) ($item['username'] ?? ''), - 'author_avatar' => $item['avatar_url'] ?? null, - 'avatar_url' => $item['avatar_url'] ?? null, - 'thumb' => $item['thumbnail_url'] ?? null, - 'thumb_url' => $item['thumbnail_url'] ?? null, - 'thumb_srcset' => $item['thumbnail_srcset'] ?? null, - 'category_name' => (string) ($item['category_name'] ?? ''), - 'category_slug' => (string) ($item['category_slug'] ?? ''), - 'content_type_name' => (string) ($item['content_type_name'] ?? ''), - 'content_type_slug' => (string) ($item['content_type_slug'] ?? ''), - 'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))), - 'width' => isset($item['width']) ? (int) $item['width'] : null, - 'height' => isset($item['height']) ? (int) $item['height'] : null, - 'published_at' => $item['published_at'] ?? null, - 'primary_tag' => $item['primary_tag'] ?? null, - 'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [], - 'recommendation_source' => (string) ($item['source'] ?? 'mixed'), - 'recommendation_reason' => $reason, - 'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null, - 'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion), - 'recommendation_surface' => 'homepage-for-you', - 'discovery_endpoint' => $discoveryEndpoint, - 'hide_artwork_endpoint' => $hideArtworkEndpoint, - 'dislike_tag_endpoint' => $dislikeTagEndpoint, - 'metric_badge' => [ - 'label' => $reason, - 'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate', - ], - ]; - })->values()->all(); + return [ + 'id' => (int) ($item['id'] ?? 0), + 'title' => (string) ($item['title'] ?? 'Untitled'), + 'name' => (string) ($item['title'] ?? 'Untitled'), + 'slug' => (string) ($item['slug'] ?? ''), + 'author' => (string) ($item['author'] ?? 'Artist'), + 'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null, + 'author_username' => (string) ($item['username'] ?? ''), + 'author_avatar' => $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_url' => $item['thumbnail_url'] ?? null, + 'thumb_srcset' => $item['thumbnail_srcset'] ?? null, + 'category_name' => (string) ($item['category_name'] ?? ''), + 'category_slug' => (string) ($item['category_slug'] ?? ''), + 'content_type_name' => (string) ($item['content_type_name'] ?? ''), + 'content_type_slug' => (string) ($item['content_type_slug'] ?? ''), + 'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))), + 'width' => isset($item['width']) ? (int) $item['width'] : null, + 'height' => isset($item['height']) ? (int) $item['height'] : null, + 'published_at' => $item['published_at'] ?? null, + 'primary_tag' => $item['primary_tag'] ?? null, + 'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [], + 'recommendation_source' => (string) ($item['source'] ?? 'mixed'), + 'recommendation_reason' => $reason, + 'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null, + 'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion), + 'recommendation_surface' => 'homepage-for-you', + 'discovery_endpoint' => $discoveryEndpoint, + 'hide_artwork_endpoint' => $hideArtworkEndpoint, + 'dislike_tag_endpoint' => $dislikeTagEndpoint, + 'metric_badge' => [ + 'label' => $reason, + 'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate', + ], + ]; + })->values()->all(); } catch (\Throwable $e) { Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]); return []; @@ -276,18 +353,18 @@ final class HomepageService */ public function getHeroArtwork(): ?array { - return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array { - $result = $this->artworks->getFeaturedArtworks(null, 1); + return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array { + $artwork = $this->artworks->getFeaturedArtworkWinner(); - /** @var \Illuminate\Database\Eloquent\Model|\null $artwork */ - if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) { - $artwork = $result->getCollection()->first(); - } elseif ($result instanceof \Illuminate\Support\Collection) { - $artwork = $result->first(); - } elseif (is_array($result)) { - $artwork = $result[0] ?? null; - } else { - $artwork = null; + if (! $artwork instanceof Artwork) { + $artwork = Artwork::query() + ->public() + ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) + ->withoutMissingThumbnails() + ->with(self::ARTWORK_SERIALIZATION_RELATIONS) + ->latest('published_at') + ->first(); } 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). * @@ -308,14 +449,12 @@ final class HomepageService { $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 { - $results = Artwork::search('') - ->options([ - 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', - 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], - ]) - ->paginate($limit, 'page', 1); + $results = $this->search->searchWithThumbnailPreference([ + 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', + 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], + ], $limit, true, 1); $items = $this->prepareArtworksForSerialization($this->searchResultCollection($results)); @@ -327,7 +466,7 @@ final class HomepageService return $this->getRisingLowSignalFromDb($limit); } - return $items + return $this->fillArtworkRailFromArchive($items, $limit) ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); @@ -346,8 +485,10 @@ final class HomepageService */ private function getRisingFromDb(int $limit): array { - return Artwork::public() + $artworks = Artwork::public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) + ->withoutMissingThumbnails() ->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') @@ -355,7 +496,9 @@ final class HomepageService ->orderByDesc('artwork_stats.heat_score') ->orderByDesc('artwork_stats.engagement_velocity') ->limit($limit) - ->get() + ->get(); + + return $this->fillArtworkRailFromArchive($artworks, $limit) ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); @@ -363,8 +506,10 @@ final class HomepageService private function getRisingLowSignalFromDb(int $limit): array { - return Artwork::public() + $artworks = Artwork::public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) + ->withoutMissingThumbnails() ->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void { $join->on('recent_rising_activity.artwork_id', '=', 'artworks.id'); @@ -375,7 +520,9 @@ final class HomepageService ->orderByDesc('artworks.published_at') ->orderByDesc('artworks.id') ->limit($limit) - ->get() + ->get(); + + return $this->fillArtworkRailFromArchive($artworks, $limit) ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); @@ -392,14 +539,12 @@ final class HomepageService { $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 { - $results = Artwork::search('') - ->options([ - 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', - 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], - ]) - ->paginate($limit, 'page', 1); + $results = $this->search->searchWithThumbnailPreference([ + 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', + 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], + ], $limit, true, 1); $items = $this->prepareArtworksForSerialization($this->searchResultCollection($results)); @@ -407,7 +552,7 @@ final class HomepageService return $this->getTrendingFromDb($limit); } - return $items + return $this->fillArtworkRailFromArchive($items, $limit) ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); @@ -427,8 +572,10 @@ final class HomepageService */ private function getTrendingFromDb(int $limit): array { - return Artwork::public() + $artworks = Artwork::public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) + ->withoutMissingThumbnails() ->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->select('artworks.*') @@ -436,7 +583,9 @@ final class HomepageService ->orderByDesc('artwork_stats.ranking_score') ->orderByDesc('artwork_stats.engagement_velocity') ->limit($limit) - ->get() + ->get(); + + return $this->fillArtworkRailFromArchive($artworks, $limit) ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); @@ -450,11 +599,13 @@ final class HomepageService { // Include EGS mode in cache key so toggling EGS updates the section within TTL $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 { $artworks = Artwork::public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) + ->withoutMissingThumbnails() ->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->orderByDesc('published_at') ->limit($limit) @@ -541,6 +692,7 @@ final class HomepageService $latestArtworkIds = Artwork::public() ->published() + ->withoutMissingThumbnails() ->whereIn('user_id', $userIds) ->whereNotNull('hash') ->whereNotNull('thumb_ext') @@ -698,7 +850,7 @@ final class HomepageService 'u.username', 'up.avatar_hash', 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) ->whereNotIn('u.id', array_merge($followingIds, [$user->id])) @@ -738,11 +890,13 @@ final class HomepageService } return Cache::remember( - "homepage.following.{$user->id}", + "homepage.following.{$user->id}.{$this->viewerCacheSegment()}", 60, // short TTL – personal data function () use ($followingIds): array { $artworks = Artwork::public() ->published() + ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user())) + ->withoutMissingThumbnails() ->with(self::ARTWORK_SERIALIZATION_RELATIONS) ->whereIn('user_id', $followingIds) ->orderByDesc('published_at') @@ -766,7 +920,13 @@ final class HomepageService try { $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 ->map(fn ($a) => $this->serializeArtwork($a)) @@ -790,7 +950,13 @@ final class HomepageService try { $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 ->map(fn ($a) => $this->serializeArtwork($a)) @@ -839,7 +1005,87 @@ final class HomepageService $artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS); - return $artworks; + 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 $artworks + * @return Collection + */ + private function fillArtworkRailFromArchive(Collection $artworks, int $limit, ?callable $fallbackConstraint = null): Collection + { + $artworks = $this->prepareArtworksForSerialization($artworks)->take($limit)->values(); + + if ($artworks->count() >= $limit) { + 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> $items + * @return Collection> + */ + 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 @@ -875,18 +1121,32 @@ final class HomepageService private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array { + $awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null; + $thumbSm = $artwork->thumbUrl('sm'); $thumbMd = $artwork->thumbUrl('md'); $thumbLg = $artwork->thumbUrl('lg'); + $thumbXl = $artwork->thumbUrl('xl'); $thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg); $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); - $authorId = $artwork->user_id; - $authorName = $artwork->user?->name ?? 'Artist'; - $authorUsername = $artwork->user?->username ?? ''; - $avatarHash = $artwork->user?->profile?->avatar_hash ?? null; - $authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64); + $thumbSrcset = collect([ + $thumbSm ? $thumbSm . ' 320w' : null, + $thumbMd ? $thumbMd . ' 640w' : null, + $thumbLg ? $thumbLg . ' 1280w' : null, + $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, 'title' => $artwork->title ?? 'Untitled', 'slug' => $artwork->slug, @@ -894,9 +1154,14 @@ final class HomepageService 'author_id' => $authorId, 'author_username' => $authorUsername, 'author_avatar' => $authorAvatar, + 'published_as_type' => $artwork->publishedAsType(), + 'publisher' => $publisher, 'thumb' => $thumb, + 'thumb_sm' => $thumbSm, 'thumb_md' => $thumbMd, 'thumb_lg' => $thumbLg, + 'thumb_xl' => $thumbXl, + 'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null, 'category_name' => $primaryCategory->name ?? '', 'category_slug' => $primaryCategory->slug ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '', @@ -905,6 +1170,65 @@ final class HomepageService 'width' => $artwork->width, 'height' => $artwork->height, '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|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']; + } } diff --git a/app/Services/Maturity/ArtworkMaturityAuditService.php b/app/Services/Maturity/ArtworkMaturityAuditService.php new file mode 100644 index 00000000..c719860a --- /dev/null +++ b/app/Services/Maturity/ArtworkMaturityAuditService.php @@ -0,0 +1,219 @@ +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 $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 $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|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; + } +} \ No newline at end of file diff --git a/app/Services/Maturity/ArtworkMaturityService.php b/app/Services/Maturity/ArtworkMaturityService.php new file mode 100644 index 00000000..21dd214e --- /dev/null +++ b/app/Services/Maturity/ArtworkMaturityService.php @@ -0,0 +1,562 @@ + + */ + 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 + */ + 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 $payload + * @return array + */ + public function decoratePayload(array $payload, mixed $artwork, ?User $viewer): array + { + $payload['maturity'] = $this->presentation($artwork, $viewer); + + return $payload; + } + + /** + * @param array> $items + * @return array> + */ + 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 $analysis + * @return array{score:float,labels:array,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 $analysis + * @return array{score:float,labels:array,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 $analysis + * @return array{status:string,maturity_label:?string,confidence:?float,labels:array,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,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 $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 $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 $rows + * @return array + */ + 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 + */ + 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 $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; + } +} \ No newline at end of file diff --git a/app/Services/Profile/CreatorComebackService.php b/app/Services/Profile/CreatorComebackService.php new file mode 100644 index 00000000..40988783 --- /dev/null +++ b/app/Services/Profile/CreatorComebackService.php @@ -0,0 +1,187 @@ + $artworks rows from publicArtworkRows() + * @param int $userId + * @param CarbonInterface $computedAt + * @param callable(int, CreatorMilestoneType, CarbonInterface, array, ?int, CarbonInterface): array $makeMilestoneRow + * @return array> + */ + 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 + */ + 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 + */ + 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; + } + } +} diff --git a/app/Services/Profile/CreatorEraService.php b/app/Services/Profile/CreatorEraService.php new file mode 100644 index 00000000..56575407 --- /dev/null +++ b/app/Services/Profile/CreatorEraService.php @@ -0,0 +1,359 @@ + $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> + */ + 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 $artworks + * @return array> + */ + 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 $artworks + * @return array> + */ + 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 $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 $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 $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 + */ + 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 ?? [], + ]; + } +} diff --git a/app/Services/Profile/CreatorJourneyService.php b/app/Services/Profile/CreatorJourneyService.php new file mode 100644 index 00000000..b1f07d04 --- /dev/null +++ b/app/Services/Profile/CreatorJourneyService.php @@ -0,0 +1,986 @@ +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> + */ + 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 $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 $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 $artworks + * @return array> + */ + 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 $artworks + * @return array 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 $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 $payload + * @return array + */ + 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 + */ + 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 + */ + 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; + } +} \ No newline at end of file diff --git a/app/Services/Profile/CreatorStreakService.php b/app/Services/Profile/CreatorStreakService.php new file mode 100644 index 00000000..3230752f --- /dev/null +++ b/app/Services/Profile/CreatorStreakService.php @@ -0,0 +1,303 @@ + $artworks + * @param int $userId + * @param CarbonInterface $computedAt + * @param callable $makeMilestoneRow + * @return array> + */ + 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 $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 $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 $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; + } + } +} diff --git a/app/Services/Recommendation/UserPreferenceBuilder.php b/app/Services/Recommendation/UserPreferenceBuilder.php index 6554f68c..d8e6edf8 100644 --- a/app/Services/Recommendation/UserPreferenceBuilder.php +++ b/app/Services/Recommendation/UserPreferenceBuilder.php @@ -177,7 +177,7 @@ class UserPreferenceBuilder */ private function tagScoresFromAwards(int $userId, float $weight): array { - $rows = DB::table('artwork_awards as aa') + $rows = DB::table('artwork_medals as aa') ->join('artwork_tag as at', 'at.artwork_id', '=', 'aa.artwork_id') ->join('tags as t', 't.id', '=', 'at.tag_id') ->where('aa.user_id', $userId) diff --git a/app/Services/Recommendations/PersonalizedFeedService.php b/app/Services/Recommendations/PersonalizedFeedService.php index 3be7ed34..7ff00198 100644 --- a/app/Services/Recommendations/PersonalizedFeedService.php +++ b/app/Services/Recommendations/PersonalizedFeedService.php @@ -386,6 +386,7 @@ final class PersonalizedFeedService ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,headline,avatar_path,followers_count', 'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories.contentType:id,name,slug', 'tags:id,name,slug', @@ -407,6 +408,8 @@ final class PersonalizedFeedService $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryTag = $artwork->tags->sortBy('name')->first(); $source = (string) ($item['source'] ?? 'mixed'); + $publisher = $this->mapPublisherPayload($artwork); + $isGroupPublisher = ($publisher['type'] ?? null) === 'group'; $responseItems[] = [ 'id' => $artwork->id, @@ -414,14 +417,18 @@ final class PersonalizedFeedService 'title' => $artwork->title, 'thumbnail_url' => $artwork->thumb_url, 'thumbnail_srcset' => $artwork->thumb_srcset, - 'author' => $artwork->user?->name, - 'username' => $artwork->user?->username, + 'author' => $isGroupPublisher ? ($publisher['name'] ?? 'Skinbase Group') : $artwork->user?->name, + 'username' => $isGroupPublisher ? null : $artwork->user?->username, 'author_id' => $artwork->user?->id, - 'avatar_url' => AvatarUrl::forUser( - (int) ($artwork->user?->id ?? 0), - $artwork->user?->profile?->avatar_hash, - 64 - ), + 'avatar_url' => $isGroupPublisher + ? ($publisher['avatar_url'] ?? null) + : AvatarUrl::forUser( + (int) ($artwork->user?->id ?? 0), + $artwork->user?->profile?->avatar_hash, + 64 + ), + 'published_as_type' => $artwork->publishedAsType(), + 'publisher' => $publisher, 'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory?->name ?? '', @@ -490,6 +497,32 @@ final class PersonalizedFeedService }; } + /** + * @return array|null + */ + private function mapPublisherPayload(Artwork $artwork): ?array + { + if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) { + return null; + } + + $group = $artwork->group; + 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 resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string { if ($algoVersion !== null && $algoVersion !== '') { diff --git a/app/Services/Recommendations/RecommendationServiceV2.php b/app/Services/Recommendations/RecommendationServiceV2.php index 7dd3b735..17e74482 100644 --- a/app/Services/Recommendations/RecommendationServiceV2.php +++ b/app/Services/Recommendations/RecommendationServiceV2.php @@ -958,6 +958,7 @@ final class RecommendationServiceV2 ->with([ 'user:id,name,username', 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,headline,avatar_path,followers_count', 'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories.contentType:id,name,slug', 'tags:id,name,slug', @@ -991,20 +992,26 @@ final class RecommendationServiceV2 $rankingSignals = (array) ($item['ranking_signals'] ?? []); $rankingSignals['local_embedding_present'] = $hasLocalEmbedding; $rankingSignals['vector_indexed_at'] = $vectorIndexedAt; + $publisher = $this->mapPublisherPayload($artwork); + $isGroupPublisher = ($publisher['type'] ?? null) === 'group'; $responseItems[] = [ 'id' => $artwork->id, 'slug' => $artwork->slug, 'title' => $artwork->title, 'thumbnail_url' => $artwork->thumb_url, 'thumbnail_srcset' => $artwork->thumb_srcset, - 'author' => $artwork->user?->name, - 'username' => $artwork->user?->username, + 'author' => $isGroupPublisher ? ($publisher['name'] ?? 'Skinbase Group') : $artwork->user?->name, + 'username' => $isGroupPublisher ? null : $artwork->user?->username, 'author_id' => $artwork->user?->id, - 'avatar_url' => AvatarUrl::forUser( - (int) ($artwork->user?->id ?? 0), - $artwork->user?->profile?->avatar_hash, - 64 - ), + 'avatar_url' => $isGroupPublisher + ? ($publisher['avatar_url'] ?? null) + : AvatarUrl::forUser( + (int) ($artwork->user?->id ?? 0), + $artwork->user?->profile?->avatar_hash, + 64 + ), + 'published_as_type' => $artwork->publishedAsType(), + 'publisher' => $publisher, 'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory?->name ?? '', @@ -1323,6 +1330,32 @@ final class RecommendationServiceV2 return $typed; } + /** + * @return array|null + */ + private function mapPublisherPayload(Artwork $artwork): ?array + { + if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) { + return null; + } + + $group = $artwork->group; + 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 v3Enabled(): bool { return (bool) config('discovery.v3.enabled', false); diff --git a/app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php b/app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php index ff864d84..1bfa570b 100644 --- a/app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php +++ b/app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php @@ -5,14 +5,17 @@ declare(strict_types=1); namespace App\Services\Sitemaps\Builders; use App\Models\Category; -use App\Models\ContentType; +use App\Services\ContentTypes\ContentTypeSlugResolver; use App\Services\Sitemaps\AbstractSitemapBuilder; use App\Services\Sitemaps\SitemapUrlBuilder; use DateTimeInterface; final class CategoriesSitemapBuilder extends AbstractSitemapBuilder { - public function __construct(private readonly SitemapUrlBuilder $urls) + public function __construct( + private readonly SitemapUrlBuilder $urls, + private readonly ContentTypeSlugResolver $contentTypeResolver, + ) { } @@ -25,19 +28,18 @@ final class CategoriesSitemapBuilder extends AbstractSitemapBuilder { $items = [$this->urls->categoryDirectory()]; - $contentTypes = ContentType::query() - ->whereIn('slug', $this->contentTypeSlugs()) - ->ordered() - ->get(); + $contentTypes = $this->contentTypeResolver->dynamicSitemapContentTypes(); foreach ($contentTypes as $contentType) { $items[] = $this->urls->contentType($contentType); } + $contentTypeIds = $contentTypes->pluck('id')->filter()->values(); + $categories = Category::query() ->with('contentType') ->active() - ->whereHas('contentType', fn ($query) => $query->whereIn('slug', $this->contentTypeSlugs())) + ->when($contentTypeIds->isNotEmpty(), fn ($query) => $query->whereIn('content_type_id', $contentTypeIds->all())) ->orderBy('content_type_id') ->orderBy('parent_id') ->orderBy('sort_order') diff --git a/app/Services/SmartCollectionService.php b/app/Services/SmartCollectionService.php index 76e007a0..b8315f96 100644 --- a/app/Services/SmartCollectionService.php +++ b/app/Services/SmartCollectionService.php @@ -7,6 +7,7 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Collection; use App\Models\User; +use App\Services\Maturity\ArtworkMaturityService; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -15,6 +16,10 @@ use Illuminate\Validation\ValidationException; class SmartCollectionService { + public function __construct(private readonly ArtworkMaturityService $maturity) + { + } + public function sanitizeRules(?array $input): ?array { if ($input === null) { @@ -278,6 +283,8 @@ class SmartCollectionService ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->where('artworks.published_at', '<=', now()); + + $this->maturity->applyViewerFilter($query, request()->user()); } $method = ($sanitized['match'] ?? 'all') === 'any' ? 'orWhere' : 'where'; @@ -382,7 +389,10 @@ class SmartCollectionService { return match ($sort) { Collection::SORT_OLDEST => $query->orderBy('artworks.published_at')->orderBy('artworks.id'), - Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByDesc('artworks.id'), + Collection::SORT_POPULAR => $query + ->leftJoin('artwork_stats as artwork_stats_sort', 'artwork_stats_sort.artwork_id', '=', 'artworks.id') + ->orderByRaw('COALESCE(artwork_stats_sort.views, 0) DESC') + ->orderByDesc('artworks.id'), default => $query->orderByDesc('artworks.published_at')->orderByDesc('artworks.id'), }; } diff --git a/app/Services/TagService.php b/app/Services/TagService.php index bf7e2a55..f5988a42 100644 --- a/app/Services/TagService.php +++ b/app/Services/TagService.php @@ -320,7 +320,7 @@ final class TagService */ private function normalizeUserTags(array $tags): array { - $max = (int) config('tags.max_user_tags', 15); + $max = (int) config('tags.max_user_tags', 30); if (count($tags) > $max) { throw ValidationException::withMessages([ 'tags' => ["Too many tags (max {$max})."], diff --git a/app/Services/Uploads/UploadStorageService.php b/app/Services/Uploads/UploadStorageService.php index 9f940c7c..859ca5d4 100644 --- a/app/Services/Uploads/UploadStorageService.php +++ b/app/Services/Uploads/UploadStorageService.php @@ -7,6 +7,7 @@ namespace App\Services\Uploads; use App\DTOs\Uploads\UploadStoredFile; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use RuntimeException; @@ -34,9 +35,10 @@ final class UploadStorageService { $path = $this->sectionPath($section); - if (! File::exists($path)) { - File::makeDirectory($path, 0755, true); - } + $this->ensureDirectoryExists($path, [ + 'section' => $section, + 'storage_root' => rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR), + ]); return $path; } @@ -75,9 +77,11 @@ final class UploadStorageService $segments = $this->hashSegments($hash); $dir = $this->sectionPath($section) . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments); - if (! File::exists($dir)) { - File::makeDirectory($dir, 0755, true); - } + $this->ensureDirectoryExists($dir, [ + 'section' => $section, + 'hash' => $hash, + 'segments' => $segments, + ]); return $dir; } @@ -87,9 +91,12 @@ final class UploadStorageService $segments = $this->hashSegments($hash); $dir = $this->localOriginalsRoot() . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments); - if (! File::exists($dir)) { - File::makeDirectory($dir, 0755, true); - } + $this->ensureDirectoryExists($dir, [ + 'section' => 'local_originals', + 'hash' => $hash, + 'segments' => $segments, + 'local_originals_root' => $this->localOriginalsRoot(), + ]); return $dir; } @@ -219,6 +226,37 @@ final class UploadStorageService return is_array($matches) && count($matches) > 0; } + /** + * @param array $context + */ + private function ensureDirectoryExists(string $path, array $context = []): void + { + if (File::exists($path)) { + return; + } + + $parent = dirname($path); + + try { + File::makeDirectory($path, 0755, true); + } catch (\Throwable $exception) { + Log::error('Upload storage directory creation failed.', array_merge($context, [ + 'path' => $path, + 'parent' => $parent, + 'path_exists' => File::exists($path), + 'parent_exists' => File::exists($parent), + 'parent_is_directory' => File::isDirectory($parent), + 'parent_is_writable' => is_writable($parent), + 'storage_root_config' => (string) config('uploads.storage_root'), + 'local_originals_root_config' => (string) config('uploads.local_originals_root'), + 'exception_class' => $exception::class, + 'exception_message' => $exception->getMessage(), + ])); + + throw $exception; + } + } + private function safeExtension(UploadedFile $file): string { $extension = (string) $file->guessExtension(); diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index 600705e8..b15197d8 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -178,7 +178,7 @@ final class UserStatsService ->whereNull('a.deleted_at') ->sum('s.views'), - 'awards_received_count' => (int) DB::table('artwork_awards as aw') + 'awards_received_count' => (int) DB::table('artwork_medals as aw') ->join('artworks as a', 'a.id', '=', 'aw.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') diff --git a/app/Services/Vision/AiArtworkVectorSearchService.php b/app/Services/Vision/AiArtworkVectorSearchService.php index c728dc54..5d09ebac 100644 --- a/app/Services/Vision/AiArtworkVectorSearchService.php +++ b/app/Services/Vision/AiArtworkVectorSearchService.php @@ -12,6 +12,8 @@ use RuntimeException; final class AiArtworkVectorSearchService { + private const MAX_SIMILAR_RESULTS = 120; + public function __construct( private readonly VectorGatewayClient $client, private readonly ArtworkVisionImageUrl $imageUrl, @@ -28,7 +30,7 @@ final class AiArtworkVectorSearchService */ public function similarToArtwork(Artwork $artwork, int $limit = 12): array { - $safeLimit = max(1, min(24, $limit)); + $safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit)); $cacheKey = sprintf('rec:artwork:%d:similar-ai:%d', $artwork->id, $safeLimit); $ttl = max(60, (int) config('recommendations.ttl.similar_artworks', 30 * 60)); @@ -49,7 +51,7 @@ final class AiArtworkVectorSearchService */ public function searchByUploadedImage(UploadedFile $file, int $limit = 12): array { - $safeLimit = max(1, min(24, $limit)); + $safeLimit = max(1, min(self::MAX_SIMILAR_RESULTS, $limit)); $path = $file->store('ai-search/tmp', 'public'); if (! is_string($path) || $path === '') { diff --git a/app/Services/Vision/VisionService.php b/app/Services/Vision/VisionService.php index cf324a44..9cb44c6a 100644 --- a/app/Services/Vision/VisionService.php +++ b/app/Services/Vision/VisionService.php @@ -21,9 +21,9 @@ final class VisionService return (bool) config('vision.enabled', true); } - public function buildImageUrl(string $hash): ?string + public function buildImageUrl(string $hash, ?string $variant = null): ?string { - $variant = (string) config('vision.image_variant', 'md'); + $variant = $variant ?? (string) config('vision.image_variant', 'md'); $variant = $variant !== '' ? $variant : 'md'; $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash)); @@ -94,6 +94,45 @@ final class VisionService ]; } + /** + * @return array{assessment: array, debug: array} + */ + public function analyzeArtworkMaturityDetailed(Artwork $artwork, string $hash, ?string $variant = null): array + { + $imageUrl = $this->buildImageUrl($hash, $variant); + $ref = (string) Str::uuid(); + + if ($imageUrl === null) { + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => 'Artwork maturity analysis could not start because no image URL was available.', + ], + 'debug' => [ + 'ref' => $ref, + 'artwork_id' => (int) $artwork->id, + 'hash' => $hash, + 'image_url' => null, + 'reason' => 'image_url_unavailable', + 'calls' => [], + ], + ]; + } + + $call = $this->callMaturityDetailed($artwork, $imageUrl, $hash, $ref); + + return [ + 'assessment' => $call['assessment'], + 'debug' => [ + 'ref' => $ref, + 'artwork_id' => (int) $artwork->id, + 'hash' => $hash, + 'image_url' => $imageUrl, + 'calls' => [$call['debug']], + ], + ]; + } + /** * @return array{tags: array, vision_enabled: bool, source?: string, reason?: string} */ @@ -658,6 +697,177 @@ final class VisionService return ['tags' => $this->extractTagList($response->json()), 'debug' => $debug]; } + /** + * @return array{assessment: array, debug: array} + */ + private function callMaturityDetailed(Artwork $artwork, string $imageUrl, string $hash, string $ref): array + { + $base = trim((string) config('vision.maturity.base_url', '')); + if ($base === '') { + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => 'Vision maturity endpoint is not configured.', + ], + 'debug' => [ + 'service' => 'maturity', + 'enabled' => false, + 'reason' => 'base_url_missing', + ], + ]; + } + + $endpoint = (string) config('vision.maturity.endpoint', '/analyze/maturity'); + $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/'); + $timeout = (int) config('vision.maturity.timeout_seconds', 20); + $connectTimeout = (int) config('vision.maturity.connect_timeout_seconds', 3); + $retries = (int) config('vision.maturity.retries', 1); + $delay = (int) config('vision.maturity.retry_delay_ms', 200); + $requestPayload = [ + 'url' => $imageUrl, + 'image_url' => $imageUrl, + 'artwork_id' => (int) $artwork->id, + 'hash' => $hash, + ]; + $debug = [ + 'service' => 'maturity', + 'endpoint' => $url, + 'request' => $requestPayload, + ]; + + try { + /** @var \Illuminate\Http\Client\Response $response */ + $response = $this->requestWithVisionAuth('maturity', $ref) + ->connectTimeout(max(1, $connectTimeout)) + ->timeout(max(1, $timeout)) + ->retry(max(0, $retries), max(0, $delay), throw: false) + ->post($url, $requestPayload); + $debug['status'] = $response->status(); + $debug['auth_header_sent'] = $this->visionApiKey('maturity') !== ''; + $debug['response'] = $response->json() ?? $this->safeBody($response->body()); + } catch (\Throwable $e) { + Log::warning('Vision maturity request failed', [ + 'ref' => $ref, + 'artwork_id' => (int) $artwork->id, + 'error' => $e->getMessage(), + ]); + + $debug['error'] = $e->getMessage(); + + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => $e->getMessage(), + ], + 'debug' => $debug, + ]; + } + + if ($response->ok()) { + return [ + 'assessment' => $this->parseMaturityAssessment($response->json()), + 'debug' => $debug, + ]; + } + + Log::warning('Vision maturity non-ok response', [ + 'ref' => $ref, + 'artwork_id' => (int) $artwork->id, + 'status' => $response->status(), + 'body' => $this->safeBody($response->body()), + ]); + + $fallback = $this->callMaturityFileDetailed($artwork, $ref); + $debug['fallback_upload'] = $fallback['debug']; + + if (($fallback['assessment']['status'] ?? null) === 'succeeded') { + return [ + 'assessment' => $fallback['assessment'], + 'debug' => $debug, + ]; + } + + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => $this->buildFailureAdvisory($response->status(), $fallback['assessment']['advisory'] ?? null), + ], + 'debug' => $debug, + ]; + } + + /** + * @return array{assessment: array, debug: array} + */ + private function callMaturityFileDetailed(Artwork $artwork, string $ref): array + { + $base = trim((string) config('vision.maturity.base_url', '')); + $endpoint = (string) config('vision.maturity.file_endpoint', '/analyze/maturity/file'); + $url = rtrim($base, '/') . '/' . ltrim($endpoint, '/'); + $debug = [ + 'endpoint' => $url, + ]; + + if ($base === '') { + $debug['reason'] = 'base_url_missing'; + + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => 'Vision maturity upload endpoint is not configured.', + ], + 'debug' => $debug, + ]; + } + + $file = $this->fetchStoredArtworkBinary((int) $artwork->id); + if ($file === null) { + $debug['reason'] = 'file_unavailable'; + + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => 'Artwork maturity analysis could not fall back to the upload endpoint because the stored file was unavailable.', + ], + 'debug' => $debug, + ]; + } + + try { + /** @var \Illuminate\Http\Client\Response $response */ + $response = $this->requestWithVisionAuth('maturity', $ref) + ->attach('file', $file['contents'], $file['filename']) + ->post($url, ['artwork_id' => (int) $artwork->id]); + $debug['status'] = $response->status(); + $debug['response'] = $response->json() ?? $this->safeBody($response->body()); + } catch (\Throwable $e) { + $debug['error'] = $e->getMessage(); + + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => $e->getMessage(), + ], + 'debug' => $debug, + ]; + } + + if (! $response->ok()) { + return [ + 'assessment' => [ + 'status' => 'failed', + 'advisory' => 'Vision maturity upload endpoint returned HTTP ' . $response->status() . '.', + ], + 'debug' => $debug, + ]; + } + + return [ + 'assessment' => $this->parseMaturityAssessment($response->json()), + 'debug' => $debug, + ]; + } + private function shouldRunYolo(Artwork $artwork): bool { if (! (bool) config('vision.yolo.enabled', true)) { @@ -696,12 +906,237 @@ final class VisionService { return match ($service) { 'gateway' => trim((string) config('vision.gateway.api_key', '')), + 'maturity' => trim((string) config('vision.maturity.api_key', '')), 'clip' => trim((string) config('vision.clip.api_key', '')), 'yolo' => trim((string) config('vision.yolo.api_key', '')), default => '', }; } + /** + * @param mixed $json + * @return array + */ + private function parseMaturityAssessment(mixed $json): array + { + if (! is_array($json)) { + return [ + 'status' => 'failed', + 'advisory' => 'Vision maturity endpoint returned an invalid response.', + ]; + } + + $label = $this->normalizeMaturityLabel( + $json['maturity_label'] + ?? $json['label'] + ?? data_get($json, 'data.maturity_label') + ?? data_get($json, 'result.maturity_label') + ); + $confidence = $this->normalizeFloat( + $json['confidence'] + ?? $json['score'] + ?? data_get($json, 'data.confidence') + ?? data_get($json, 'result.confidence') + ); + $thresholdUsed = $this->normalizeFloat( + $json['threshold_used'] + ?? $json['threshold'] + ?? data_get($json, 'data.threshold_used') + ?? data_get($json, 'result.threshold_used') + ); + $actionHint = $this->normalizeActionHint( + $json['action_hint'] + ?? data_get($json, 'data.action_hint') + ?? data_get($json, 'result.action_hint') + ); + $advisory = $this->normalizeText( + $json['advisory'] + ?? $json['message'] + ?? data_get($json, 'data.advisory') + ?? data_get($json, 'result.advisory') + ); + $status = $this->normalizeAssessmentStatus( + $json['status'] + ?? data_get($json, 'data.status') + ?? data_get($json, 'result.status') + ?? ($label !== null || $actionHint !== null ? 'succeeded' : 'failed') + ); + $model = $this->normalizeText( + $json['model'] + ?? data_get($json, 'meta.model') + ?? data_get($json, 'result.model') + ); + $analysisTimeMs = $this->normalizeInt( + $json['analysis_time_ms'] + ?? data_get($json, 'meta.analysis_time_ms') + ?? data_get($json, 'result.analysis_time_ms') + ); + + if ($status === 'succeeded' && $label === null && $actionHint === null) { + $status = 'failed'; + $advisory = $advisory ?: 'Vision maturity endpoint did not return a maturity label or action hint.'; + } + + $labels = $this->extractMaturityLabels($json, $label); + + return array_filter([ + 'status' => $status, + 'maturity_label' => $label, + 'confidence' => $confidence, + 'model' => $model, + 'threshold_used' => $thresholdUsed, + 'analysis_time_ms' => $analysisTimeMs, + 'action_hint' => $actionHint, + 'advisory' => $advisory, + 'labels' => $labels, + ], static fn (mixed $value): bool => $value !== null); + } + + /** + * @param mixed $json + * @return array + */ + private function extractMaturityLabels(mixed $json, ?string $fallbackLabel): array + { + if (! is_array($json)) { + return $fallbackLabel !== null ? [$fallbackLabel] : []; + } + + $raw = $json['labels'] + ?? $json['matched_labels'] + ?? $json['matched_terms'] + ?? data_get($json, 'data.labels') + ?? data_get($json, 'result.labels') + ?? []; + + $labels = collect(is_array($raw) ? $raw : [$raw]) + ->map(function (mixed $item): ?string { + if (is_string($item)) { + $label = trim($item); + return $label !== '' ? $label : null; + } + + if (! is_array($item)) { + return null; + } + + $label = trim((string) ($item['label'] ?? $item['tag'] ?? $item['name'] ?? '')); + + return $label !== '' ? $label : null; + }) + ->filter() + ->values() + ->all(); + + if ($labels === [] && $fallbackLabel !== null) { + $labels[] = $fallbackLabel; + } + + return array_values(array_unique($labels)); + } + + /** + * @return array{filename: string, contents: string}|null + */ + private function fetchStoredArtworkBinary(int $artworkId): ?array + { + try { + $variant = (string) config('vision.image_variant', 'md'); + $row = DB::table('artwork_files') + ->where('artwork_id', $artworkId) + ->where('variant', $variant) + ->first(); + + if (! $row || empty($row->path)) { + return null; + } + + $path = (string) $row->path; + $contents = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->get($path); + if (! is_string($contents) || $contents === '') { + return null; + } + + return [ + 'filename' => basename($path), + 'contents' => $contents, + ]; + } catch (\Throwable) { + return null; + } + } + + private function buildFailureAdvisory(int $status, ?string $fallback): string + { + if (is_string($fallback) && trim($fallback) !== '') { + return trim($fallback); + } + + return 'Vision maturity endpoint returned HTTP ' . $status . '.'; + } + + private function normalizeMaturityLabel(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + return match (Str::lower(trim((string) $value))) { + 'safe', 'clear', 'sfw' => 'safe', + 'mature', 'adult', 'nsfw', 'explicit' => 'mature', + default => null, + }; + } + + private function normalizeActionHint(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + return match (Str::lower(trim((string) $value))) { + 'allow', 'mark_safe', 'safe' => 'safe', + 'review', 'queue', 'suspect' => 'review', + 'flag_high', 'block', 'mature', 'mark_mature' => 'flag_high', + default => null, + }; + } + + private function normalizeAssessmentStatus(mixed $value): string + { + if (! is_scalar($value)) { + return 'failed'; + } + + return match (Str::lower(trim((string) $value))) { + 'ok', 'success', 'succeeded', 'complete', 'completed' => 'succeeded', + 'pending', 'queued', 'processing' => 'pending', + 'skipped', 'not_requested' => 'skipped', + default => 'failed', + }; + } + + private function normalizeFloat(mixed $value): ?float + { + return is_numeric($value) ? round((float) $value, 4) : null; + } + + private function normalizeInt(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private function normalizeText(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + $normalized = trim((string) $value); + + return $normalized !== '' ? $normalized : null; + } + /** * @param mixed $json * @return array diff --git a/app/Support/Seo/SeoDataBuilder.php b/app/Support/Seo/SeoDataBuilder.php index 47849c31..2d549496 100644 --- a/app/Support/Seo/SeoDataBuilder.php +++ b/app/Support/Seo/SeoDataBuilder.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Support\Seo; -use Illuminate\Support\Arr; use Illuminate\Support\Collection; final class SeoDataBuilder @@ -66,13 +65,13 @@ final class SeoDataBuilder $builder->breadcrumbs($attributes['breadcrumbs']); } - foreach (Arr::wrap($attributes['json_ld'] ?? []) as $schema) { + foreach (self::normalizeSchemaPayload($attributes['json_ld'] ?? []) as $schema) { $builder->addJsonLd($schema); } - foreach (Arr::wrap($attributes['structured_data'] ?? []) as $schema) { + foreach (self::normalizeSchemaPayload($attributes['structured_data'] ?? []) as $schema) { $builder->addJsonLd($schema); } - foreach (Arr::wrap($attributes['faq_schema'] ?? []) as $schema) { + foreach (self::normalizeSchemaPayload($attributes['faq_schema'] ?? []) as $schema) { $builder->addJsonLd($schema); } @@ -271,6 +270,42 @@ final class SeoDataBuilder return false; } + /** + * @return list> + */ + private static function normalizeSchemaPayload(mixed $payload): array + { + if ($payload === null || $payload === '') { + return []; + } + + if (is_string($payload)) { + $decoded = json_decode($payload, true); + + return json_last_error() === JSON_ERROR_NONE + ? self::normalizeSchemaPayload($decoded) + : []; + } + + if (! is_array($payload)) { + return []; + } + + if ($payload === []) { + return []; + } + + if (! array_is_list($payload)) { + return [$payload]; + } + + return collect($payload) + ->map(static fn (mixed $schema): array => self::normalizeSchemaPayload($schema)[0] ?? []) + ->filter(static fn (array $schema): bool => $schema !== []) + ->values() + ->all(); + } + private function clean(?string $value): ?string { $value = trim((string) $value); diff --git a/bootstrap/app.php b/bootstrap/app.php index 935e637f..1e203fb6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -28,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->alias([ + 'artwork.maturity.access' => \App\Http\Middleware\EnsureArtworkMaturityAccess::class, 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, 'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class, 'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class, diff --git a/config/artwork_medals.php b/config/artwork_medals.php new file mode 100644 index 00000000..2e0a503b --- /dev/null +++ b/config/artwork_medals.php @@ -0,0 +1,15 @@ + env('ARTWORK_MEDALS_ENABLED', true), + + 'weights' => [ + 'gold' => 5, + 'silver' => 3, + 'bronze' => 1, + ], + + 'require_verified_email' => env('ARTWORK_MEDALS_REQUIRE_VERIFIED_EMAIL', true), + 'minimum_account_age_hours' => (int) env('ARTWORK_MEDALS_MINIMUM_ACCOUNT_AGE_HOURS', 24), + 'rate_limit_per_minute' => (int) env('ARTWORK_MEDALS_RATE_LIMIT_PER_MINUTE', 10), +]; \ No newline at end of file diff --git a/config/cache.php b/config/cache.php index b32aead2..ba7a5f06 100644 --- a/config/cache.php +++ b/config/cache.php @@ -78,6 +78,15 @@ return [ 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), ], + 'homepage' => [ + 'driver' => 'failover', + 'stores' => [ + 'redis', + 'database', + 'array', + ], + ], + 'dynamodb' => [ 'driver' => 'dynamodb', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/config/content_types.php b/config/content_types.php new file mode 100644 index 00000000..5f3b34d0 --- /dev/null +++ b/config/content_types.php @@ -0,0 +1,65 @@ + [ + 'public_list_key' => 'content-types.public-list', + 'slug_map_key' => 'content-types.slug-map', + 'history_map_key' => 'content-types.slug-history-map', + ], + + 'virtual_types' => [ + 'artworks' => [ + 'name' => 'All Artworks', + ], + ], + + 'reserved_slugs' => [ + 'news', + 'help', + 'groups', + 'creators', + 'cards', + 'search', + 'upload', + 'studio', + 'dashboard', + 'settings', + 'login', + 'register', + 'password', + 'rss', + 'sitemap', + 'robots', + 'pages', + 'tag', + 'tags', + 'categories', + 'stories', + 'blog', + 'art', + 'feed', + 'messages', + 'leaderboard', + 'following', + 'about', + 'contact', + 'faq', + 'staff', + 'members', + 'discover', + 'featured', + 'downloads', + 'comments', + 'collections', + 'community', + 'creator', + 'manage', + 'home', + 'cp', + 'admin', + 'forum', + 'legal', + 'bug-report', + 'rss-feeds', + ], +]; diff --git a/config/cors.php b/config/cors.php index 3407497b..495c3964 100644 --- a/config/cors.php +++ b/config/cors.php @@ -11,7 +11,7 @@ return [ |-------------------------------------------------------------------------- */ - 'paths' => env('CP_ENABLE_CORS', true) + 'paths' => env('CP_ENABLE_CORS', false) ? [ 'api/*', 'sanctum/csrf-cookie', diff --git a/config/homepage.php b/config/homepage.php new file mode 100644 index 00000000..cbe2e0d2 --- /dev/null +++ b/config/homepage.php @@ -0,0 +1,7 @@ + env('HOMEPAGE_CACHE_STORE', 'homepage'), + 'guest_payload_key' => env('HOMEPAGE_GUEST_PAYLOAD_KEY', 'homepage.payload.guest'), + 'guest_payload_ttl_seconds' => (int) env('HOMEPAGE_GUEST_PAYLOAD_TTL_SECONDS', 1800), +]; \ No newline at end of file diff --git a/config/maturity.php b/config/maturity.php new file mode 100644 index 00000000..090db8c5 --- /dev/null +++ b/config/maturity.php @@ -0,0 +1,38 @@ + [ + 'default_mode' => env('MATURITY_DEFAULT_MODE', 'blur'), + 'default_warn_on_detail' => env('MATURITY_DEFAULT_WARN_ON_DETAIL', true), + ], + + 'ai' => [ + 'threshold' => (float) env('MATURITY_AI_THRESHOLD', 0.68), + 'queue' => env('MATURITY_AI_QUEUE', env('VISION_QUEUE', 'default')), + 'strong_keywords' => [ + 'adult', + 'bare breasts', + 'breasts', + 'explicit', + 'genitals', + 'lingerie', + 'nude', + 'nudity', + 'nsfw', + 'porn', + 'sex', + 'sexual', + 'topless', + ], + 'medium_keywords' => [ + 'bikini', + 'erotic', + 'fetish', + 'intimate', + 'sensual', + 'underwear', + ], + ], +]; \ No newline at end of file diff --git a/config/scout.php b/config/scout.php index 668d8dae..8f88ad7d 100644 --- a/config/scout.php +++ b/config/scout.php @@ -101,11 +101,13 @@ return [ 'author_id', 'is_public', 'is_approved', + 'has_missing_thumbnails', 'created_at', ], 'sortableAttributes' => [ 'created_at', 'published_at_ts', + 'missing_thumbnail_rank', 'downloads', 'likes', 'views', @@ -113,6 +115,8 @@ return [ 'trending_score_7d', 'favorites_count', 'awards_received_count', + 'awards_score_7d', + 'awards_score_30d', 'downloads_count', 'ranking_score', 'shares_count', diff --git a/config/tags.php b/config/tags.php index 6c15ce58..202788c7 100644 --- a/config/tags.php +++ b/config/tags.php @@ -14,7 +14,7 @@ return [ */ 'max_length' => 32, - 'max_user_tags' => 15, + 'max_user_tags' => (int) env('TAGS_MAX_USER_TAGS', 30), // Exact-match banned tags after normalization. 'banned' => [ diff --git a/config/vision.php b/config/vision.php index 128b30ad..7d4c4a93 100644 --- a/config/vision.php +++ b/config/vision.php @@ -47,6 +47,17 @@ return [ 'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3), ], + 'maturity' => [ + 'base_url' => env('VISION_MATURITY_URL', env('VISION_GATEWAY_URL', env('CLIP_BASE_URL', ''))), + 'endpoint' => env('VISION_MATURITY_ENDPOINT', '/analyze/maturity'), + 'file_endpoint' => env('VISION_MATURITY_FILE_ENDPOINT', '/analyze/maturity/file'), + 'api_key' => env('VISION_MATURITY_API_KEY', env('VISION_GATEWAY_API_KEY', env('VISION_API_KEY', env('VISION_VECTOR_GATEWAY_API_KEY', '')))), + 'timeout_seconds' => (int) env('VISION_MATURITY_TIMEOUT', 20), + 'connect_timeout_seconds' => (int) env('VISION_MATURITY_CONNECT_TIMEOUT', 3), + 'retries' => (int) env('VISION_MATURITY_RETRIES', 1), + 'retry_delay_ms' => (int) env('VISION_MATURITY_RETRY_DELAY_MS', 200), + ], + 'vector_gateway' => [ 'enabled' => env('VISION_VECTOR_GATEWAY_ENABLED', true), 'base_url' => env('VISION_VECTOR_GATEWAY_URL', ''), diff --git a/database/migrations/2026_04_09_000001_add_thumbnail_audit_columns_to_artworks_table.php b/database/migrations/2026_04_09_000001_add_thumbnail_audit_columns_to_artworks_table.php new file mode 100644 index 00000000..807f2cea --- /dev/null +++ b/database/migrations/2026_04_09_000001_add_thumbnail_audit_columns_to_artworks_table.php @@ -0,0 +1,68 @@ +boolean('has_missing_thumbnails') + ->default(false) + ->after('thumb_ext'); + } + + if (! Schema::hasColumn('artworks', 'missing_thumbnail_variants_json')) { + $table->json('missing_thumbnail_variants_json') + ->nullable() + ->after('has_missing_thumbnails'); + } + + if (! Schema::hasColumn('artworks', 'thumbnails_checked_at')) { + $table->timestamp('thumbnails_checked_at') + ->nullable() + ->after('missing_thumbnail_variants_json'); + } + }); + + Schema::table('artworks', function (Blueprint $table): void { + if (Schema::hasColumn('artworks', 'has_missing_thumbnails')) { + $table->index('has_missing_thumbnails', 'artworks_missing_thumbnails_idx'); + } + + if (Schema::hasColumn('artworks', 'thumbnails_checked_at')) { + $table->index('thumbnails_checked_at', 'artworks_thumbnails_checked_idx'); + } + }); + } + + public function down(): void + { + Schema::table('artworks', function (Blueprint $table): void { + try { + $table->dropIndex('artworks_missing_thumbnails_idx'); + } catch (Throwable) { + } + + try { + $table->dropIndex('artworks_thumbnails_checked_idx'); + } catch (Throwable) { + } + + $columns = []; + + foreach (['has_missing_thumbnails', 'missing_thumbnail_variants_json', 'thumbnails_checked_at'] as $column) { + if (Schema::hasColumn('artworks', $column)) { + $columns[] = $column; + } + } + + if ($columns !== []) { + $table->dropColumn($columns); + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_10_000002_upgrade_artwork_awards_to_medal_stats_v1.php b/database/migrations/2026_04_10_000002_upgrade_artwork_awards_to_medal_stats_v1.php new file mode 100644 index 00000000..e45c18db --- /dev/null +++ b/database/migrations/2026_04_10_000002_upgrade_artwork_awards_to_medal_stats_v1.php @@ -0,0 +1,154 @@ +index(['artwork_id', 'medal'], 'artwork_awards_artwork_medal_index'); + $table->index('updated_at', 'artwork_awards_updated_at_index'); + }); + + Schema::table('artwork_award_stats', function (Blueprint $table): void { + $table->unsignedInteger('score_7d')->default(0)->after('score_total'); + $table->unsignedInteger('score_30d')->default(0)->after('score_7d'); + $table->timestamp('last_medaled_at')->nullable()->after('score_30d'); + + $table->index('score_total', 'artwork_award_stats_score_total_index'); + $table->index('score_30d', 'artwork_award_stats_score_30d_index'); + $table->index('last_medaled_at', 'artwork_award_stats_last_medaled_at_index'); + }); + + $this->applyWeights([ + 'gold' => 5, + 'silver' => 3, + 'bronze' => 1, + ]); + + $this->rebuildStats([ + 'gold' => 5, + 'silver' => 3, + 'bronze' => 1, + ], true); + } + + public function down(): void + { + $this->applyWeights([ + 'gold' => 3, + 'silver' => 2, + 'bronze' => 1, + ]); + + $this->rebuildStats([ + 'gold' => 3, + 'silver' => 2, + 'bronze' => 1, + ], false); + + Schema::table('artwork_award_stats', function (Blueprint $table): void { + $table->dropIndex('artwork_award_stats_score_total_index'); + $table->dropIndex('artwork_award_stats_score_30d_index'); + $table->dropIndex('artwork_award_stats_last_medaled_at_index'); + $table->dropColumn(['score_7d', 'score_30d', 'last_medaled_at']); + }); + + Schema::table('artwork_awards', function (Blueprint $table): void { + $table->dropIndex('artwork_awards_artwork_medal_index'); + $table->dropIndex('artwork_awards_updated_at_index'); + }); + } + + /** + * @param array $weights + */ + private function applyWeights(array $weights): void + { + foreach ($weights as $medal => $weight) { + DB::table('artwork_awards') + ->where('medal', $medal) + ->update(['weight' => $weight]); + } + } + + /** + * @param array $weights + */ + private function rebuildStats(array $weights, bool $includeRecentColumns): void + { + DB::table('artwork_award_stats')->delete(); + + $rows = DB::table('artwork_awards') + ->orderBy('artwork_id') + ->get(['artwork_id', 'medal', 'updated_at']); + + $cutoff7d = Carbon::now()->subDays(7); + $cutoff30d = Carbon::now()->subDays(30); + $payload = []; + + foreach ($rows->groupBy('artwork_id') as $artworkId => $artworkRows) { + $gold = 0; + $silver = 0; + $bronze = 0; + $scoreTotal = 0; + $score7d = 0; + $score30d = 0; + $lastMedaledAt = null; + + foreach ($artworkRows as $row) { + $medal = (string) $row->medal; + $updatedAt = $row->updated_at ? Carbon::parse($row->updated_at) : null; + $weight = (int) ($weights[$medal] ?? 0); + + if ($medal === 'gold') { + $gold++; + } elseif ($medal === 'silver') { + $silver++; + } elseif ($medal === 'bronze') { + $bronze++; + } + + $scoreTotal += $weight; + + if ($updatedAt && $updatedAt->greaterThanOrEqualTo($cutoff7d)) { + $score7d += $weight; + } + + if ($updatedAt && $updatedAt->greaterThanOrEqualTo($cutoff30d)) { + $score30d += $weight; + } + + if ($updatedAt && ($lastMedaledAt === null || $updatedAt->greaterThan($lastMedaledAt))) { + $lastMedaledAt = $updatedAt; + } + } + + $rowPayload = [ + 'artwork_id' => (int) $artworkId, + 'gold_count' => $gold, + 'silver_count' => $silver, + 'bronze_count' => $bronze, + 'score_total' => $scoreTotal, + 'updated_at' => now(), + ]; + + if ($includeRecentColumns) { + $rowPayload['score_7d'] = $score7d; + $rowPayload['score_30d'] = $score30d; + $rowPayload['last_medaled_at'] = $lastMedaledAt; + } + + $payload[] = $rowPayload; + } + + foreach (array_chunk($payload, 250) as $chunk) { + DB::table('artwork_award_stats')->insert($chunk); + } + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_10_000003_create_artwork_medals_tables_v1.php b/database/migrations/2026_04_10_000003_create_artwork_medals_tables_v1.php new file mode 100644 index 00000000..7a6848b9 --- /dev/null +++ b/database/migrations/2026_04_10_000003_create_artwork_medals_tables_v1.php @@ -0,0 +1,114 @@ +id(); + $table->unsignedBigInteger('artwork_id'); + $table->unsignedBigInteger('user_id'); + $table->enum('medal_type', ['gold', 'silver', 'bronze']); + $table->unsignedTinyInteger('weight')->default(1); + $table->timestamps(); + + $table->unique(['artwork_id', 'user_id'], 'artwork_medals_artwork_user_unique'); + $table->index(['artwork_id', 'medal_type'], 'artwork_medals_artwork_medal_type_index'); + $table->index('user_id', 'artwork_medals_user_id_index'); + $table->index('updated_at', 'artwork_medals_updated_at_index'); + + $table->foreign('artwork_id') + ->references('id')->on('artworks') + ->cascadeOnDelete(); + $table->foreign('user_id') + ->references('id')->on('users') + ->cascadeOnDelete(); + }); + + Schema::create('artwork_medal_stats', function (Blueprint $table): void { + $table->unsignedBigInteger('artwork_id')->primary(); + $table->unsignedInteger('gold_count')->default(0); + $table->unsignedInteger('silver_count')->default(0); + $table->unsignedInteger('bronze_count')->default(0); + $table->unsignedInteger('score_total')->default(0); + $table->unsignedInteger('score_7d')->default(0); + $table->unsignedInteger('score_30d')->default(0); + $table->timestamp('last_medaled_at')->nullable(); + $table->timestamps(); + + $table->index('score_total', 'artwork_medal_stats_score_total_index'); + $table->index('score_30d', 'artwork_medal_stats_score_30d_index'); + $table->index('last_medaled_at', 'artwork_medal_stats_last_medaled_at_index'); + + $table->foreign('artwork_id') + ->references('id')->on('artworks') + ->cascadeOnDelete(); + }); + + $this->copyExistingMedals(); + $this->copyExistingMedalStats(); + } + + public function down(): void + { + Schema::dropIfExists('artwork_medal_stats'); + Schema::dropIfExists('artwork_medals'); + } + + private function copyExistingMedals(): void + { + if (! Schema::hasTable('artwork_awards')) { + return; + } + + $rows = DB::table('artwork_awards') + ->orderBy('id') + ->get(['artwork_id', 'user_id', 'medal', 'weight', 'created_at', 'updated_at']); + + foreach ($rows->chunk(250) as $chunk) { + DB::table('artwork_medals')->insert($chunk->map(static function ($row): array { + return [ + 'artwork_id' => (int) $row->artwork_id, + 'user_id' => (int) $row->user_id, + 'medal_type' => (string) $row->medal, + 'weight' => (int) $row->weight, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]; + })->all()); + } + } + + private function copyExistingMedalStats(): void + { + if (! Schema::hasTable('artwork_award_stats')) { + return; + } + + $rows = DB::table('artwork_award_stats')->get(); + + foreach ($rows->chunk(250) as $chunk) { + DB::table('artwork_medal_stats')->insert($chunk->map(static function ($row): array { + $updatedAt = $row->updated_at ?? now(); + + return [ + 'artwork_id' => (int) $row->artwork_id, + 'gold_count' => (int) ($row->gold_count ?? 0), + 'silver_count' => (int) ($row->silver_count ?? 0), + 'bronze_count' => (int) ($row->bronze_count ?? 0), + 'score_total' => (int) ($row->score_total ?? 0), + 'score_7d' => (int) ($row->score_7d ?? 0), + 'score_30d' => (int) ($row->score_30d ?? 0), + 'last_medaled_at' => $row->last_medaled_at ?? null, + 'created_at' => $updatedAt, + 'updated_at' => $updatedAt, + ]; + })->all()); + } + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_10_210000_add_force_hero_to_artwork_features_table.php b/database/migrations/2026_04_10_210000_add_force_hero_to_artwork_features_table.php new file mode 100644 index 00000000..8c870d86 --- /dev/null +++ b/database/migrations/2026_04_10_210000_add_force_hero_to_artwork_features_table.php @@ -0,0 +1,34 @@ +boolean('force_hero')->default(false)->after('is_active'); + $table->index(['force_hero', 'is_active'], 'idx_features_force_hero'); + }); + } + + public function down(): void + { + if (! Schema::hasColumn('artwork_features', 'force_hero')) { + return; + } + + Schema::table('artwork_features', function (Blueprint $table): void { + $table->dropIndex('idx_features_force_hero'); + $table->dropColumn('force_hero'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_000100_add_maturity_workflow_fields_to_artworks_table.php b/database/migrations/2026_04_11_000100_add_maturity_workflow_fields_to_artworks_table.php new file mode 100644 index 00000000..81eb4a2c --- /dev/null +++ b/database/migrations/2026_04_11_000100_add_maturity_workflow_fields_to_artworks_table.php @@ -0,0 +1,83 @@ +string('maturity_level', 24)->default('safe')->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_source')) { + $table->string('maturity_source', 24)->default('legacy')->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_status')) { + $table->string('maturity_status', 24)->default('clear')->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_score')) { + $table->decimal('maturity_ai_score', 5, 4)->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_labels')) { + $table->json('maturity_ai_labels')->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_detected_at')) { + $table->timestamp('maturity_ai_detected_at')->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_flagged_at')) { + $table->timestamp('maturity_flagged_at')->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_flag_reason')) { + $table->string('maturity_flag_reason', 255)->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_reviewed_by')) { + $table->unsignedBigInteger('maturity_reviewed_by')->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_reviewed_at')) { + $table->timestamp('maturity_reviewed_at')->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_reviewer_note')) { + $table->text('maturity_reviewer_note')->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_mismatch_count')) { + $table->unsignedInteger('maturity_mismatch_count')->default(0); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('artworks')) { + return; + } + + Schema::table('artworks', function (Blueprint $table): void { + foreach ([ + 'maturity_level', + 'maturity_source', + 'maturity_status', + 'maturity_ai_score', + 'maturity_ai_labels', + 'maturity_ai_detected_at', + 'maturity_flagged_at', + 'maturity_flag_reason', + 'maturity_reviewed_by', + 'maturity_reviewed_at', + 'maturity_reviewer_note', + 'maturity_mismatch_count', + ] as $column) { + if (Schema::hasColumn('artworks', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_000110_add_maturity_preferences_to_user_profiles_table.php b/database/migrations/2026_04_11_000110_add_maturity_preferences_to_user_profiles_table.php new file mode 100644 index 00000000..3f2db2f9 --- /dev/null +++ b/database/migrations/2026_04_11_000110_add_maturity_preferences_to_user_profiles_table.php @@ -0,0 +1,40 @@ +string('mature_content_visibility', 12)->default('blur'); + } + if (! Schema::hasColumn('user_profiles', 'mature_content_warning_enabled')) { + $table->boolean('mature_content_warning_enabled')->default(true); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('user_profiles')) { + return; + } + + Schema::table('user_profiles', function (Blueprint $table): void { + foreach (['mature_content_visibility', 'mature_content_warning_enabled'] as $column) { + if (Schema::hasColumn('user_profiles', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_000200_add_maturity_ai_contract_fields_to_artworks_table.php b/database/migrations/2026_04_11_000200_add_maturity_ai_contract_fields_to_artworks_table.php new file mode 100644 index 00000000..ddbbff53 --- /dev/null +++ b/database/migrations/2026_04_11_000200_add_maturity_ai_contract_fields_to_artworks_table.php @@ -0,0 +1,71 @@ +string('maturity_ai_label', 24)->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_confidence')) { + $table->decimal('maturity_ai_confidence', 5, 4)->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_model')) { + $table->string('maturity_ai_model', 120)->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_threshold_used')) { + $table->decimal('maturity_ai_threshold_used', 5, 4)->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_analysis_time_ms')) { + $table->unsignedInteger('maturity_ai_analysis_time_ms')->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_action_hint')) { + $table->string('maturity_ai_action_hint', 32)->nullable()->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_advisory')) { + $table->text('maturity_ai_advisory')->nullable(); + } + if (! Schema::hasColumn('artworks', 'maturity_ai_status')) { + $table->string('maturity_ai_status', 24)->default('not_requested')->index(); + } + if (! Schema::hasColumn('artworks', 'maturity_declared_at')) { + $table->timestamp('maturity_declared_at')->nullable()->index(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('artworks')) { + return; + } + + Schema::table('artworks', function (Blueprint $table): void { + foreach ([ + '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_declared_at', + ] as $column) { + if (Schema::hasColumn('artworks', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_090000_add_assets_to_content_types_table.php b/database/migrations/2026_04_11_090000_add_assets_to_content_types_table.php new file mode 100644 index 00000000..ee98cb9f --- /dev/null +++ b/database/migrations/2026_04_11_090000_add_assets_to_content_types_table.php @@ -0,0 +1,23 @@ +string('mascot_path')->nullable()->after('description'); + $table->string('cover_art_path')->nullable()->after('mascot_path'); + }); + } + + public function down(): void + { + Schema::table('content_types', function (Blueprint $table): void { + $table->dropColumn(['mascot_path', 'cover_art_path']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_180000_create_content_type_slug_histories_table.php b/database/migrations/2026_04_11_180000_create_content_type_slug_histories_table.php new file mode 100644 index 00000000..cbb2249b --- /dev/null +++ b/database/migrations/2026_04_11_180000_create_content_type_slug_histories_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete(); + $table->string('old_slug', 64)->unique(); + $table->timestamps(); + + $table->index('content_type_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('content_type_slug_histories'); + } +}; diff --git a/database/migrations/2026_04_11_200000_create_artwork_maturity_audit_findings_table.php b/database/migrations/2026_04_11_200000_create_artwork_maturity_audit_findings_table.php new file mode 100644 index 00000000..c3a359ae --- /dev/null +++ b/database/migrations/2026_04_11_200000_create_artwork_maturity_audit_findings_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->string('status', 24)->default('open')->index(); + $table->string('thumbnail_variant', 24)->nullable(); + $table->string('ai_label', 24)->nullable()->index(); + $table->decimal('ai_confidence', 5, 4)->nullable()->index(); + $table->decimal('ai_score', 5, 4)->nullable()->index(); + $table->json('ai_labels')->nullable(); + $table->string('ai_model', 120)->nullable(); + $table->decimal('ai_threshold_used', 5, 4)->nullable(); + $table->unsignedInteger('ai_analysis_time_ms')->nullable(); + $table->string('ai_action_hint', 32)->nullable()->index(); + $table->string('ai_status', 24)->default('not_requested')->index(); + $table->text('ai_advisory')->nullable(); + $table->timestamp('detected_at')->nullable()->index(); + $table->timestamp('last_scanned_at')->nullable()->index(); + $table->string('resolution_action', 32)->nullable(); + $table->text('resolution_note')->nullable(); + $table->foreignId('resolved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('resolved_at')->nullable()->index(); + $table->timestamps(); + + $table->unique('artwork_id', 'artwork_maturity_audit_findings_artwork_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_maturity_audit_findings'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_220000_add_hide_from_menu_to_content_types_table.php b/database/migrations/2026_04_11_220000_add_hide_from_menu_to_content_types_table.php new file mode 100644 index 00000000..512a69d8 --- /dev/null +++ b/database/migrations/2026_04_11_220000_add_hide_from_menu_to_content_types_table.php @@ -0,0 +1,22 @@ +boolean('hide_from_menu')->default(false)->after('order'); + }); + } + + public function down(): void + { + Schema::table('content_types', function (Blueprint $table): void { + $table->dropColumn('hide_from_menu'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_230000_create_artwork_relations_table.php b/database/migrations/2026_04_11_230000_create_artwork_relations_table.php new file mode 100644 index 00000000..d72bdc2d --- /dev/null +++ b/database/migrations/2026_04_11_230000_create_artwork_relations_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('source_artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->foreignId('target_artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->string('relation_type', 32)->default('remake_of')->index(); + $table->text('note')->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique([ + 'source_artwork_id', + 'target_artwork_id', + 'relation_type', + ], 'artwork_relations_unique_pair_type'); + $table->index(['source_artwork_id', 'sort_order'], 'artwork_relations_source_sort_idx'); + $table->index(['target_artwork_id', 'relation_type'], 'artwork_relations_target_type_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_relations'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_12_120000_create_creator_milestones_table.php b/database/migrations/2026_04_12_120000_create_creator_milestones_table.php new file mode 100644 index 00000000..db72a460 --- /dev/null +++ b/database/migrations/2026_04_12_120000_create_creator_milestones_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('type', 64); + $table->timestamp('occurred_at'); + $table->unsignedSmallInteger('occurred_year')->nullable(); + $table->foreignId('related_artwork_id')->nullable()->constrained('artworks')->nullOnDelete(); + $table->boolean('is_public')->default(true); + $table->unsignedTinyInteger('priority')->default(0); + $table->json('payload_json')->nullable(); + $table->timestamp('computed_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'occurred_at'], 'creator_milestones_user_occurred_idx'); + $table->index(['user_id', 'type'], 'creator_milestones_user_type_idx'); + $table->index(['user_id', 'is_public'], 'creator_milestones_user_public_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('creator_milestones'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_12_130000_create_creator_eras_table.php b/database/migrations/2026_04_12_130000_create_creator_eras_table.php new file mode 100644 index 00000000..14848623 --- /dev/null +++ b/database/migrations/2026_04_12_130000_create_creator_eras_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('era_type', 48); // early_years | breakthrough | experimental | comeback | current + $table->string('title', 100); + $table->string('description', 300)->nullable(); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->boolean('is_current')->default(false); + $table->json('metadata')->nullable(); // uploads_count, featured_count, top_categories, top_artwork_id, dominant_years + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->index(['user_id', 'starts_at']); + $table->index(['user_id', 'is_current']); + }); + } + + public function down(): void + { + Schema::dropIfExists('creator_eras'); + } +}; diff --git a/docs/deployment.md b/docs/deployment.md index 3df56c01..b4125964 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,10 +16,45 @@ This will: - build frontend assets locally with `npm run build` - rsync the application files to production -- run `composer install --no-dev` on the server -- run `php artisan migrate --force` -- clear and rebuild Laravel caches +- run `composer install --no-dev` on the server before entering maintenance mode +- bring the app down only for the short critical section that runs `php artisan migrate --force`, `php artisan optimize:clear`, and `php artisan optimize` +- bring the app back up immediately after that critical section finishes +- warm the guest homepage cache with `php artisan homepage:warm-guest-cache` +- warm the post trending cache with `php artisan posts:warm-trending` - restart queue workers with `php artisan queue:restart` +- gracefully restart Horizon with `php artisan horizon:terminate` + +This is now the low-downtime default path for normal code and feature deploys. + +## Full upgrade + +Use a full upgrade when the release also needs broad Meilisearch work or non-code service operations. + +```bash +bash sync.sh --full-upgrade +``` + +Full-upgrade mode: + +- keeps the normal deploy steps +- forces a full Meilisearch import for all searchable models unless you explicitly pass `--skip-meilisearch` +- allows optional remote upgrade hooks for service-level work + +Example with service hooks: + +```bash +bash sync.sh --full-upgrade \ + --upgrade-pre-hook='sudo systemctl stop reverb' \ + --upgrade-post-hook='sudo systemctl restart reverb meilisearch' +``` + +You can also provide those hooks through environment variables instead of CLI flags: + +```bash +FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \ +FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \ +bash sync.sh --full-upgrade +``` ## Deploy options @@ -27,6 +62,7 @@ This will: bash sync.sh --skip-build bash sync.sh --skip-migrate bash sync.sh --no-maintenance +bash sync.sh --mode=full-upgrade ``` Environment overrides: @@ -42,6 +78,13 @@ LOCAL_BUILD_COMMAND='npm run build' bash sync.sh LOCAL_BUILD_COMMAND='pnpm build' bash sync.sh ``` +Upgrade hooks can also be supplied via environment variables: + +```bash +FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash sync.sh --full-upgrade +FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash sync.sh --full-upgrade +``` + ## Replace production database from local This is intentionally separate from a normal deploy because it overwrites production data. @@ -94,5 +137,6 @@ LOCAL_MYSQLDUMP_COMMAND='mysqldump --host=10.0.0.5 --port=3306 --user=app dbname ## Safety notes - Normal deployments should use `bash sync.sh` without `--with-db`. +- Use `bash sync.sh --full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes. - Use database replacement only for first-time bootstrap, staging, or an intentional full production reset. -- This project should not use `php artisan route:cache` in deploy automation because the route file contains closure routes. \ No newline at end of file +- Route caching now runs through `php artisan optimize` in deploy automation; if that starts failing again, fix the route definitions instead of dropping route caching from deploy. \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 0a26f369..3d6b83e6 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -78,6 +78,27 @@ font-medium hover:brightness-110 transition; } +.btn-accent-solid { + color: #ffffff; + background: linear-gradient(180deg, #cc6f1d 0%, #a85412 100%); + border: 1px solid rgba(255, 196, 125, 0.24); + box-shadow: 0 12px 30px rgba(168, 84, 18, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.14); + transition: filter 160ms ease, transform 160ms ease, box-shadow 160ms ease; +} + +.btn-accent-solid:hover { + filter: brightness(1.05); + box-shadow: 0 16px 36px rgba(168, 84, 18, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.btn-accent-solid:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px rgba(245, 158, 11, 0.46), + 0 12px 30px rgba(168, 84, 18, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.14); +} + .btn-secondary { @apply bg-nova-500/30 text-white px-5 py-2 rounded-lg hover:bg-nova-500/50 transition; diff --git a/resources/js/Pages/Artwork/SimilarArtworksHeader.jsx b/resources/js/Pages/Artwork/SimilarArtworksHeader.jsx new file mode 100644 index 00000000..867760c2 --- /dev/null +++ b/resources/js/Pages/Artwork/SimilarArtworksHeader.jsx @@ -0,0 +1,124 @@ +import React from 'react' + +export default function SimilarArtworksHeader({ artwork }) { + if (!artwork) return null + + const title = artwork.title || 'Artwork' + const artworkUrl = artwork.url || '#' + const authorName = artwork.author_name || 'Artist' + const authorHref = artwork.author_profile_url || (artwork.author_username ? `/@${artwork.author_username}` : null) + const browseHref = artwork.browse_url || (artwork.content_type_slug ? `/${artwork.content_type_slug}` : '/explore') + const thumbUrl = artwork.thumb_lg || artwork.thumb_md || null + const thumbSrcSet = artwork.thumb_srcset || undefined + const tags = Array.isArray(artwork.tag_slugs) ? artwork.tag_slugs.filter(Boolean) : [] + + return ( +
+
+
+ +
+ {thumbUrl ? ( + {title} + ) : ( +
+ Preview unavailable +
+ )} +
+
+ + +
+
+ + Visual discovery +
+ +

+ Artworks similar to{' '} + + {title} + +

+ +

+ Browse visually related artworks, compare style cues, and jump back into the original piece whenever you need context. +

+ +
+ + {artwork.author_avatar ? ( + {authorName} + ) : null} + {authorHref ? ( + + {authorName} + + ) : ( + {authorName} + )} + + + {artwork.category_name ? ( + + {artwork.category_name} + + ) : null} + + {artwork.content_type_name ? ( + + {artwork.content_type_name} + + ) : null} +
+ + {tags.length > 0 ? ( +
+ {tags.map((tagSlug) => ( + + #{tagSlug} + + ))} +
+ ) : null} + + +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/ArtworkPage.jsx b/resources/js/Pages/ArtworkPage.jsx index 4f5033c8..48ed2170 100644 --- a/resources/js/Pages/ArtworkPage.jsx +++ b/resources/js/Pages/ArtworkPage.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useMemo } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import { createRoot } from 'react-dom/client' import axios from 'axios' import ArtworkHero from '../components/artwork/ArtworkHero' @@ -7,6 +7,7 @@ import ArtworkMeta from '../components/artwork/ArtworkMeta' import ArtworkAwards from '../components/artwork/ArtworkAwards' import ArtworkTags from '../components/artwork/ArtworkTags' import ArtworkDescription from '../components/artwork/ArtworkDescription' +import ArtworkEvolutionPanel from '../components/artwork/ArtworkEvolutionPanel' import ArtworkComments from '../components/artwork/ArtworkComments' import ArtworkActionBar from '../components/artwork/ArtworkActionBar' import ArtworkDetailsPanel from '../components/artwork/ArtworkDetailsPanel' @@ -42,6 +43,7 @@ function publisherToGroupSummary(publisher) { function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null }) { const [viewerOpen, setViewerOpen] = useState(false) + const [showMatureArtwork, setShowMatureArtwork] = useState(false) const openViewer = useCallback(() => setViewerOpen(true), []) const closeViewer = useCallback(() => setViewerOpen(false), []) @@ -98,47 +100,77 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher)) setSelectedMediaId('cover') setViewerOpen(false) // close viewer when navigating away + setShowMatureArtwork(false) }, []) if (!artwork) return null - const mediaItems = useMemo(() => { - const coverItem = { - id: 'cover', - label: 'Cover art', - thumbUrl: presentSq?.url || presentMd?.url || presentLg?.url || artwork?.thumbs?.sq?.url || artwork?.thumbs?.md?.url || null, - mdUrl: presentMd?.url || artwork?.thumbs?.md?.url || null, - lgUrl: presentLg?.url || artwork?.thumbs?.lg?.url || null, - xlUrl: presentXl?.url || artwork?.thumbs?.xl?.url || null, - width: Number(artwork?.dimensions?.width || artwork?.width || 0) || null, - height: Number(artwork?.dimensions?.height || artwork?.height || 0) || null, - } + const requiresInterstitial = Boolean(artwork?.maturity?.requires_interstitial) && !showMatureArtwork - const screenshotItems = Array.isArray(artwork?.screenshots) - ? artwork.screenshots.map((item, index) => ({ - id: item.id || `shot-${index + 1}`, - label: item.label || `Screenshot ${index + 1}`, - thumbUrl: item.thumb_url || item.url || null, - mdUrl: item.url || item.thumb_url || null, - lgUrl: item.url || item.thumb_url || null, - xlUrl: item.url || item.thumb_url || null, - width: null, - height: null, - })) - : [] + if (requiresInterstitial) { + return ( +
+
+
+

Content warning

+

{artwork?.maturity?.warning_title || 'Mature content warning'}

+

{artwork?.maturity?.warning_message || 'This artwork may contain mature material. Continue only if you want to view it.'}

+
+
{artwork.title}
+
by {artwork?.publisher?.name || artwork?.user?.name || 'Artist'}
+
+
+ + + + Leave this page + +
+
+
+
+ ) + } - return [coverItem, ...screenshotItems].filter((item) => Boolean(item.thumbUrl || item.lgUrl || item.xlUrl)) - }, [artwork, presentMd, presentLg, presentXl, presentSq]) + const coverItem = { + id: 'cover', + label: 'Cover art', + thumbUrl: presentSq?.url || presentMd?.url || presentLg?.url || artwork?.thumbs?.sq?.url || artwork?.thumbs?.md?.url || null, + mdUrl: presentMd?.url || artwork?.thumbs?.md?.url || null, + lgUrl: presentLg?.url || artwork?.thumbs?.lg?.url || null, + xlUrl: presentXl?.url || artwork?.thumbs?.xl?.url || null, + width: Number(artwork?.dimensions?.width || artwork?.width || 0) || null, + height: Number(artwork?.dimensions?.height || artwork?.height || 0) || null, + } + + const screenshotItems = Array.isArray(artwork?.screenshots) + ? artwork.screenshots.map((item, index) => ({ + id: item.id || `shot-${index + 1}`, + label: item.label || `Screenshot ${index + 1}`, + thumbUrl: item.thumb_url || item.url || null, + mdUrl: item.url || item.thumb_url || null, + lgUrl: item.url || item.thumb_url || null, + xlUrl: item.url || item.thumb_url || null, + width: null, + height: null, + })) + : [] + + const mediaItems = [coverItem, ...screenshotItems].filter((item) => Boolean(item.thumbUrl || item.lgUrl || item.xlUrl)) const selectedMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null - useEffect(() => { - if (!selectedMedia && mediaItems.length > 0) { - setSelectedMediaId(mediaItems[0].id) - } - }, [mediaItems, selectedMedia]) - - const initialAwards = artwork?.awards ?? null + const initialAwards = artwork?.medals ?? artwork?.awards ?? null return ( <> @@ -188,6 +220,9 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present {/* Description */} + {/* Artwork evolution */} + + {/* Artwork reactions */} {reactionTotals !== null && (
@@ -232,7 +267,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present {/* Details (collapsible) */} - {/* Awards */} + {/* Medals */} diff --git a/resources/js/Pages/Collection/CollectionManage.jsx b/resources/js/Pages/Collection/CollectionManage.jsx index d270cccc..5f69924b 100644 --- a/resources/js/Pages/Collection/CollectionManage.jsx +++ b/resources/js/Pages/Collection/CollectionManage.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import { Head, usePage } from '@inertiajs/react' +import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge' function getCsrfToken() { diff --git a/resources/js/Pages/Collection/CollectionShow.jsx b/resources/js/Pages/Collection/CollectionShow.jsx index dea76937..b27b920a 100644 --- a/resources/js/Pages/Collection/CollectionShow.jsx +++ b/resources/js/Pages/Collection/CollectionShow.jsx @@ -203,6 +203,25 @@ function EntityLinkCard({ item }) { ) } +function CollectionCover({ collection }) { + const coverImage = collection?.cover_image + const coverMaturity = collection?.cover_image_maturity || null + const shouldBlur = Boolean(coverMaturity?.should_blur) + const isMature = Boolean(coverMaturity?.is_mature_effective) + + if (!coverImage) { + return
+ } + + return ( +
+ {collection?.title} + {isMature ?
Mature cover
: null} + {shouldBlur ?
Blurred by your settings
: null} +
+ ) +} + function humanizeToken(value) { return String(value || '') .replaceAll('_', ' ') @@ -745,7 +764,7 @@ export default function CollectionShow() {
- {collection?.cover_image ? {collection.title} :
} +
diff --git a/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx b/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx new file mode 100644 index 00000000..af427c88 --- /dev/null +++ b/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx @@ -0,0 +1,723 @@ +import React from 'react' +import { Head, usePage } from '@inertiajs/react' + +function getCsrfToken() { + if (typeof document === 'undefined') return '' + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +async function requestJson(url, { method = 'POST', body } = {}) { + const response = await fetch(url, { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': getCsrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + }, + body: body ? JSON.stringify(body) : undefined, + }) + + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload?.message || payload?.errors?.artwork_id?.[0] || payload?.errors?.is_active?.[0] || payload?.errors?.force_hero?.[0] || 'Request failed.') + } + + return payload +} + +function isoToLocalInput(value) { + if (!value) return '' + + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + + const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)) + return local.toISOString().slice(0, 16) +} + +function localInputToIso(value) { + if (!value) return null + + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + return date.toISOString() +} + +function formatDateTime(value) { + if (!value) return '—' + + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '—' + + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) +} + +function Badge({ label, tone = 'slate' }) { + const toneClasses = { + slate: 'border-white/10 bg-white/10 text-slate-100', + sky: 'border-sky-300/20 bg-sky-400/15 text-sky-100', + emerald: 'border-emerald-300/20 bg-emerald-400/15 text-emerald-100', + amber: 'border-amber-300/20 bg-amber-400/15 text-amber-100', + rose: 'border-rose-300/20 bg-rose-400/15 text-rose-100', + } + + return ( + + {label} + + ) +} + +function Field({ label, help, children }) { + return ( + + ) +} + +function StatCard({ label, value, tone = 'sky' }) { + const toneClasses = { + sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100', + amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100', + emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100', + rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100', + } + + return ( +
+
{label}
+
{value}
+
+ ) +} + +function emptyForm() { + return { + artwork_id: '', + priority: 100, + featured_at: isoToLocalInput(new Date().toISOString()), + expires_at: '', + is_active: true, + } +} + +function mapEntryToCandidate(entry) { + if (!entry) return null + + return { + ...entry.artwork, + medals: entry.medals, + eligibility: entry.eligibility, + existing_feature_count: entry.duplicate_count, + already_featured: entry.duplicate_count > 0, + } +} + +function compareEntries(left, right, sortKey, direction) { + const dir = direction === 'asc' ? 1 : -1 + const value = (entry) => { + switch (sortKey) { + case 'featured_at': + return new Date(entry.featured_at || 0).getTime() || 0 + case 'expires_at': + return new Date(entry.expires_at || 0).getTime() || 0 + case 'score_30d': + return Number(entry.medals?.score_30d || 0) + default: + return Number(entry.priority || 0) + } + } + + const leftValue = value(left) + const rightValue = value(right) + if (leftValue !== rightValue) { + return (leftValue > rightValue ? 1 : -1) * dir + } + + const leftFeatured = new Date(left.featured_at || 0).getTime() || 0 + const rightFeatured = new Date(right.featured_at || 0).getTime() || 0 + if (leftFeatured !== rightFeatured) { + return (leftFeatured > rightFeatured ? 1 : -1) * dir + } + + return Number(right.id || 0) - Number(left.id || 0) +} + +export default function FeaturedArtworksAdmin() { + const { props } = usePage() + const endpoints = props.endpoints || {} + const capabilities = props.capabilities || {} + const seo = props.seo || {} + const [entries, setEntries] = React.useState(Array.isArray(props.entries) ? props.entries : []) + const [winner, setWinner] = React.useState(props.winner || null) + const [stats, setStats] = React.useState(props.stats || {}) + const [notice, setNotice] = React.useState('') + const [busy, setBusy] = React.useState('') + const [filter, setFilter] = React.useState('all') + const [sortKey, setSortKey] = React.useState('priority') + const [sortDirection, setSortDirection] = React.useState('desc') + const [listQuery, setListQuery] = React.useState('') + const [searchQuery, setSearchQuery] = React.useState('') + const [searchResults, setSearchResults] = React.useState([]) + const [selectedArtwork, setSelectedArtwork] = React.useState(null) + const [editingId, setEditingId] = React.useState(null) + const [form, setForm] = React.useState(emptyForm()) + + React.useEffect(() => { + setEntries(Array.isArray(props.entries) ? props.entries : []) + setWinner(props.winner || null) + setStats(props.stats || {}) + }, [props.entries, props.stats, props.winner]) + + function syncPayload(payload) { + setEntries(Array.isArray(payload.entries) ? payload.entries : []) + setWinner(payload.winner || null) + setStats(payload.stats || {}) + if (payload.message) { + setNotice(payload.message) + } + } + + function resetEditor() { + setEditingId(null) + setSelectedArtwork(null) + setSearchResults([]) + setSearchQuery('') + setForm(emptyForm()) + } + + async function handleArtworkSearch(event) { + event.preventDefault() + if (!searchQuery.trim()) { + setSearchResults([]) + return + } + + setBusy('search') + setNotice('') + + try { + const url = `${endpoints.search}?q=${encodeURIComponent(searchQuery.trim())}` + const payload = await requestJson(url, { method: 'GET' }) + setSearchResults(Array.isArray(payload.results) ? payload.results : []) + if ((payload.results || []).length === 0) { + setNotice('No artworks matched that search.') + } + } catch (error) { + setNotice(error.message || 'Artwork search failed.') + } finally { + setBusy('') + } + } + + function chooseArtwork(artwork) { + setSelectedArtwork(artwork) + setForm((current) => ({ + ...current, + artwork_id: artwork.id, + })) + } + + function editEntry(entry) { + setEditingId(entry.id) + setSelectedArtwork(mapEntryToCandidate(entry)) + setSearchResults([]) + setSearchQuery('') + setForm({ + artwork_id: entry.artwork_id, + priority: entry.priority, + featured_at: isoToLocalInput(entry.featured_at), + expires_at: isoToLocalInput(entry.expires_at), + is_active: Boolean(entry.is_active), + }) + + if (typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + } + + async function handleSubmit(event) { + event.preventDefault() + if (!editingId && !form.artwork_id) { + setNotice('Select an artwork first.') + return + } + + setBusy('submit') + setNotice('') + + try { + const payload = await requestJson( + editingId + ? endpoints.updatePattern.replace('__FEATURE__', String(editingId)) + : endpoints.store, + { + method: editingId ? 'PATCH' : 'POST', + body: { + artwork_id: Number(form.artwork_id), + priority: Number(form.priority || 0), + featured_at: localInputToIso(form.featured_at), + expires_at: localInputToIso(form.expires_at), + is_active: Boolean(form.is_active), + }, + }, + ) + + syncPayload(payload) + resetEditor() + } catch (error) { + setNotice(error.message || 'Failed to save this featured entry.') + } finally { + setBusy('') + } + } + + async function handleToggle(entry) { + setBusy(`toggle-${entry.id}`) + setNotice('') + + try { + const payload = await requestJson(endpoints.togglePattern.replace('__FEATURE__', String(entry.id)), { + method: 'PATCH', + }) + syncPayload(payload) + } catch (error) { + setNotice(error.message || 'Failed to change active state.') + } finally { + setBusy('') + } + } + + async function handleDelete(entry) { + if (typeof window !== 'undefined' && !window.confirm(`Delete featured entry #${entry.id}?`)) { + return + } + + setBusy(`delete-${entry.id}`) + setNotice('') + + try { + const payload = await requestJson(endpoints.destroyPattern.replace('__FEATURE__', String(entry.id)), { + method: 'DELETE', + }) + syncPayload(payload) + + if (editingId === entry.id) { + resetEditor() + } + } catch (error) { + setNotice(error.message || 'Failed to delete this featured entry.') + } finally { + setBusy('') + } + } + + async function handleForceHero(entry) { + setBusy(`force-${entry.id}`) + setNotice('') + + try { + const payload = await requestJson(endpoints.forceHeroPattern.replace('__FEATURE__', String(entry.id)), { + method: 'PATCH', + }) + syncPayload(payload) + } catch (error) { + setNotice(error.message || 'Failed to change force hero state.') + } finally { + setBusy('') + } + } + + const filteredEntries = React.useMemo(() => { + const query = listQuery.trim().toLowerCase() + + return entries + .filter((entry) => { + if (filter === 'active') return Boolean(entry.is_active) + if (filter === 'inactive') return !entry.is_active + if (filter === 'expired') return Boolean(entry.is_expired) + if (filter === 'winner') return Boolean(entry.is_winner) + if (filter === 'eligible') return Boolean(entry.eligibility?.is_eligible) + if (filter === 'ineligible') return !entry.eligibility?.is_eligible + return true + }) + .filter((entry) => { + if (!query) return true + + const haystack = [ + entry.artwork?.title, + entry.artwork?.owner?.display_name, + entry.artwork?.owner?.username, + entry.artwork?.id, + ].join(' ').toLowerCase() + + return haystack.includes(query) + }) + .sort((left, right) => compareEntries(left, right, sortKey, sortDirection)) + }, [entries, filter, listQuery, sortDirection, sortKey]) + + const duplicateSelection = !editingId && selectedArtwork?.already_featured + + return ( + <> + + {seo.title || 'Featured Artworks'} + {seo.description ? : null} + {seo.robots ? : null} + + +
+
+
+
+
+
Featured Artworks
+

Homepage hero control, with the real winner logic exposed.

+

Editors can create, update, activate, expire, and remove featured entries here. The winner summary below mirrors the public homepage selection order: priority, recent medal score, featured date, then published date.

+
+
+ + + + + + +
+
+
+ + {notice ? ( +
+ {notice} +
+ ) : null} + +
+
+
+
+
Current Homepage Hero
+

{winner ? winner.artwork?.title : 'No eligible featured artwork'}

+

+ {winner?.selection_reason || 'There is no active, non-expired, eligible featured artwork right now.'} +

+ {winner?.is_force_hero ? ( +
+ Forced by editor. This artwork bypasses the normal hero winner order until Force Hero is disabled on its featured row. +
+ ) : null} +
+
+ {winner ? : } + {winner?.is_force_hero ? : null} +
+
+ + {winner ? ( +
+ + {winner.artwork?.title + +
+
+
Artist
+
{winner.artwork?.owner?.display_name || 'Unknown'}
+
{winner.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${winner.artwork?.owner?.username || ''}`}
+
+
+
Medal Score (30d)
+
{winner.medals?.score_30d || 0}
+
+
+
Priority
+
{winner.priority}
+
+
+
Featured Since
+
{formatDateTime(winner.featured_at)}
+
+
+
Published At
+
{formatDateTime(winner.artwork?.published_at)}
+
+
+
+ ) : null} +
+ +
+
+
+
{editingId ? 'Edit Entry' : 'Create Entry'}
+

{editingId ? `Featured entry #${editingId}` : 'Add an artwork to the featured pool'}

+
+ {editingId ? ( + + ) : null} +
+ + {!editingId ? ( +
+ +
+ setSearchQuery(event.target.value)} + className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" + placeholder="Try an artwork ID, title, or creator" + /> + +
+
+ + {searchResults.length > 0 ? ( +
+ {searchResults.map((artwork) => ( + + ))} +
+ ) : null} +
+ ) : null} + + {selectedArtwork ? ( +
+ {selectedArtwork.title +
+
+
Selected Artwork
+
{selectedArtwork.title}
+
#{selectedArtwork.id} • {selectedArtwork.owner?.display_name || 'Unknown'} • Medal Score (30d): {selectedArtwork.medals?.score_30d || 0}
+
+
+ {(selectedArtwork.eligibility?.is_eligible ? [{ label: 'Currently eligible', tone: 'emerald' }] : [{ label: 'Currently ineligible', tone: 'rose' }]).concat( + (selectedArtwork.eligibility?.reasons || []).map((reason) => ({ + label: reason, + tone: reason === 'Missing preview' ? 'rose' : 'slate', + })) + ).map((badge) => ( + + ))} +
+
+
+ ) : null} + + {duplicateSelection ? ( +
+ This artwork already has a featured entry. Edit the existing row instead of creating a duplicate. +
+ ) : null} + +
+ + setForm((current) => ({ ...current, priority: event.target.value }))} + className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" + /> + + + + + + + + setForm((current) => ({ ...current, featured_at: event.target.value }))} + className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" + /> + + + + setForm((current) => ({ ...current, expires_at: event.target.value }))} + className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" + /> + + +
+ + {editingId ? ( + + ) : null} +
+
+
+
+ +
+
+
+
Featured Pool
+

Every featured row, with eligibility and winner state visible.

+
+
+ setListQuery(event.target.value)} + placeholder="Filter by title, artist, or artwork ID" + className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" + /> + +
+ + +
+
+
+ +
+
+
Artwork
+
Artist / Owner
+
Priority
+
Featured Since
+
Expires
+
Score (30d)
+
Status
+
Actions
+
+ +
+ {filteredEntries.length === 0 ? ( +
No featured entries match the current filter.
+ ) : filteredEntries.map((entry) => ( +
+
+ + {entry.artwork?.title + +
+
+ {entry.artwork?.title || 'Missing artwork'} + #{entry.artwork?.id || entry.artwork_id} +
+
Visibility: {entry.artwork?.visibility || '—'} • Published: {entry.artwork?.published_at ? 'Yes' : 'No'}
+ {entry.is_winner && entry.winner_reason ?
{entry.winner_reason}
: null} +
+
+ +
+
{entry.artwork?.owner?.display_name || 'Unknown'}
+
{entry.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${entry.artwork?.owner?.username || ''}`}
+
+ +
{entry.priority}
+
{formatDateTime(entry.featured_at)}
+
{formatDateTime(entry.expires_at)}
+
{entry.medals?.score_30d || 0}
+ +
+ {(entry.status_badges || []).map((badge, index) => ( + + ))} +
+ +
+ + {capabilities.forceHeroEnabled ? ( + + ) : null} + + +
+
+ ))} +
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/resources/js/Pages/Home/HomeBecauseYouLike.jsx b/resources/js/Pages/Home/HomeBecauseYouLike.jsx index ed3533a4..fb9d256e 100644 --- a/resources/js/Pages/Home/HomeBecauseYouLike.jsx +++ b/resources/js/Pages/Home/HomeBecauseYouLike.jsx @@ -65,7 +65,7 @@ export default function HomeBecauseYouLike({ items, preferences }) {
- {items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => ( + {items.map((item) => ( ))}
diff --git a/resources/js/Pages/Home/HomeCTA.jsx b/resources/js/Pages/Home/HomeCTA.jsx index 435d95dc..f3889da4 100644 --- a/resources/js/Pages/Home/HomeCTA.jsx +++ b/resources/js/Pages/Home/HomeCTA.jsx @@ -24,7 +24,7 @@ export default function HomeCTA({ isLoggedIn }) {
Upload your artwork diff --git a/resources/js/Pages/Home/HomeCreators.jsx b/resources/js/Pages/Home/HomeCreators.jsx index 8a9393b6..66a07c92 100644 --- a/resources/js/Pages/Home/HomeCreators.jsx +++ b/resources/js/Pages/Home/HomeCreators.jsx @@ -24,7 +24,7 @@ function CreatorCard({ creator }) { {creator.name}
diff --git a/resources/js/Pages/Home/HomeFromFollowing.jsx b/resources/js/Pages/Home/HomeFromFollowing.jsx index bea372f8..5bc85788 100644 --- a/resources/js/Pages/Home/HomeFromFollowing.jsx +++ b/resources/js/Pages/Home/HomeFromFollowing.jsx @@ -74,7 +74,7 @@ export default function HomeFromFollowing({ items }) {
- {items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => ( + {items.map((item) => ( ))}
diff --git a/resources/js/Pages/Home/HomeGroups.jsx b/resources/js/Pages/Home/HomeGroups.jsx index 48a56928..c799045f 100644 --- a/resources/js/Pages/Home/HomeGroups.jsx +++ b/resources/js/Pages/Home/HomeGroups.jsx @@ -30,7 +30,7 @@ function GroupSpotlightCard({ group }) { {group.avatar_url ? ( {group.name}
@@ -23,16 +24,20 @@ export default function HomeHero({ artwork }) { } const src = artwork.thumb_lg || artwork.thumb || FALLBACK + const srcSet = artwork.thumb_srcset || null return (
{/* Background image */} {artwork.title} { e.currentTarget.src = FALLBACK }} /> @@ -53,7 +58,7 @@ export default function HomeHero({ artwork }) {
Explore Trending diff --git a/resources/js/Pages/Home/HomeMedalHighlights.jsx b/resources/js/Pages/Home/HomeMedalHighlights.jsx new file mode 100644 index 00000000..76d6e599 --- /dev/null +++ b/resources/js/Pages/Home/HomeMedalHighlights.jsx @@ -0,0 +1,24 @@ +import React from 'react' +import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid' + +export default function HomeMedalHighlights({ title, href = null, items, description = '' }) { + if (!Array.isArray(items) || items.length === 0) return null + + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {href ? ( + + See all → + + ) : null} +
+ + +
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Home/HomePage.jsx b/resources/js/Pages/Home/HomePage.jsx index f0e579cd..dae79540 100644 --- a/resources/js/Pages/Home/HomePage.jsx +++ b/resources/js/Pages/Home/HomePage.jsx @@ -8,6 +8,7 @@ const HomeTrendingForYou = lazy(() => import('./HomeTrendingForYou')) const HomeBecauseYouLike = lazy(() => import('./HomeBecauseYouLike')) const HomeSuggestedCreators = lazy(() => import('./HomeSuggestedCreators')) const HomeTrending = lazy(() => import('./HomeTrending')) +const HomeMedalHighlights = lazy(() => import('./HomeMedalHighlights')) const HomeRising = lazy(() => import('./HomeRising')) const HomeFresh = lazy(() => import('./HomeFresh')) const HomeCollections = lazy(() => import('./HomeCollections')) @@ -18,30 +19,122 @@ const HomeCreators = lazy(() => import('./HomeCreators')) const HomeNews = lazy(() => import('./HomeNews')) const HomeCTA = lazy(() => import('./HomeCTA')) -function SectionFallback() { +function cx(...parts) { + return parts.filter(Boolean).join(' ') +} + +function SectionFallback({ variant = 'gallery' }) { + if (variant === 'welcome') { + return ( +
diff --git a/resources/js/Pages/Home/HomeTrendingForYou.jsx b/resources/js/Pages/Home/HomeTrendingForYou.jsx index 4e0eface..0192ca4e 100644 --- a/resources/js/Pages/Home/HomeTrendingForYou.jsx +++ b/resources/js/Pages/Home/HomeTrendingForYou.jsx @@ -23,7 +23,7 @@ export default function HomeTrendingForYou({ items, preferences }) { Open full feed → - + ) } diff --git a/resources/js/Pages/Home/HomeWelcomeRow.jsx b/resources/js/Pages/Home/HomeWelcomeRow.jsx index e71fa9c4..a463d676 100644 --- a/resources/js/Pages/Home/HomeWelcomeRow.jsx +++ b/resources/js/Pages/Home/HomeWelcomeRow.jsx @@ -47,7 +47,7 @@ export default function HomeWelcomeRow({ user_data }) { {notifications_unread > 0 && ( @@ -59,7 +59,7 @@ export default function HomeWelcomeRow({ user_data }) { diff --git a/resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx b/resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx new file mode 100644 index 00000000..b4c73c92 --- /dev/null +++ b/resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx @@ -0,0 +1,307 @@ +import React, { useMemo, useState } from 'react' +import { Head, usePage } from '@inertiajs/react' +import ArtworkViewer from '../../components/viewer/ArtworkViewer' + +function requestJson(url, { method = 'GET', body } = {}) { + return fetch(url, { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: body ? JSON.stringify(body) : undefined, + }).then(async (response) => { + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload?.message || 'Request failed') + } + return payload + }) +} + +function Badge({ children, tone = 'slate' }) { + const tones = { + slate: 'border-white/10 bg-white/[0.05] text-slate-200', + amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100', + rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100', + emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100', + sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100', + } + + return {children} +} + +export default function ArtworkMaturityQueue() { + const { props } = usePage() + const [items, setItems] = useState(props.initialItems || []) + const [stats, setStats] = useState(props.stats || {}) + const [status, setStatus] = useState(props.initialFilters?.status || 'suspected') + const [aiAction, setAiAction] = useState(props.initialFilters?.ai_action || 'all') + const [aiStatus, setAiStatus] = useState(props.initialFilters?.ai_status || 'all') + const [busyId, setBusyId] = useState(null) + const [noteById, setNoteById] = useState({}) + const [error, setError] = useState('') + const [previewItem, setPreviewItem] = useState(null) + + const endpoints = props.endpoints || {} + const filterOptions = props.filterOptions || {} + const reviewActions = props.reviewActions || [] + + function queueStatusKey(key) { + return key === 'mature' ? 'reviewed' : key + } + + async function load(nextStatus, nextAiAction = aiAction, nextAiStatus = aiStatus) { + setStatus(nextStatus) + setAiAction(nextAiAction) + setAiStatus(nextAiStatus) + setError('') + try { + const query = new URLSearchParams({ + status: nextStatus, + ai_action: nextAiAction, + ai_status: nextAiStatus, + }) + const payload = await requestJson(`${endpoints.list}?${query.toString()}`) + setItems(payload.data || []) + setStats(payload.meta?.stats || {}) + } catch (loadError) { + setError(loadError.message) + } + } + + async function review(itemId, action) { + setBusyId(itemId) + setError('') + + try { + const payload = await requestJson(String(endpoints.reviewPattern || '').replace('__ARTWORK__', String(itemId)), { + method: 'POST', + body: { + action, + note: noteById[itemId] || '', + }, + }) + + setStats(payload.stats || {}) + setItems((current) => current.filter((item) => item.id !== itemId).concat(status === 'reviewed' ? [payload.artwork] : [])) + + if (status !== 'reviewed') { + setItems((current) => current.filter((item) => item.id !== itemId)) + } + } catch (reviewError) { + setError(reviewError.message) + } finally { + setBusyId(null) + } + } + + const statusSummary = useMemo(() => [ + { key: 'suspected', label: 'Suspected', value: Number(stats.suspected || 0) }, + { key: 'audit', label: 'Audit candidates', value: Number(stats.audit || 0) }, + { key: 'reviewed', label: 'Reviewed', value: Number(stats.reviewed || 0) }, + { key: 'mature', label: 'Marked mature', value: Number(stats.mature || 0) }, + ], [stats]) + + return ( +
+ + +
+
+
+

Moderator surface

+

Artwork maturity review

+

Review uploads where the uploader declaration and AI suspicion do not match, plus legacy artworks detected by the non-mutating thumbnail audit. Audit candidates stay read-only until a moderator confirms the final maturity state.

+
+ +
+ {statusSummary.map((entry) => ( + (() => { + const queueKey = queueStatusKey(entry.key) + + return ( + + ) + })() + ))} +
+
+ +
+ + + +
+
+ + {error ?
{error}
: null} + +
+ {items.length === 0 ? ( +
{status === 'audit' ? 'No legacy artworks are currently flagged by the thumbnail audit.' : 'No artworks are waiting in this queue.'}
+ ) : items.map((item) => ( + (() => { + const evidence = item.audit || item.maturity || {} + + return ( +
+ + +
+ + +
+ {Array.isArray(evidence.ai_labels) && evidence.ai_labels.length > 0 ? evidence.ai_labels.map((label) => {label}) : no AI labels} +
+ +
+
+
AI score
+
{evidence.ai_score != null ? Number(evidence.ai_score).toFixed(4) : 'n/a'}
+
+
+
AI label
+
{evidence.ai_label || 'n/a'}
+
+
+
{item.audit ? 'Audit detected' : 'Published'}
+
{item.audit?.detected_at ? new Date(item.audit.detected_at).toLocaleString() : item.published_at ? new Date(item.published_at).toLocaleString() : 'Draft / unavailable'}
+
+
+ +
+
+
Confidence
+
{evidence.ai_confidence != null ? Number(evidence.ai_confidence).toFixed(4) : 'n/a'}
+
+
+
Vision model
+
{evidence.ai_model || 'n/a'}
+
+
+
Current DB state
+
{String(item.maturity?.source || 'legacy').replaceAll('_', ' ')} • {String(item.maturity?.status || 'clear').replaceAll('_', ' ')}
+
+
+ + {evidence.ai_advisory ? ( +
+
AI advisory
+
{evidence.ai_advisory}
+
+ ) : null} + +
+ +