diff --git a/app/Console/Commands/BackfillArtworkPrimaryCategoryCommand.php b/app/Console/Commands/BackfillArtworkPrimaryCategoryCommand.php new file mode 100644 index 00000000..720aac40 --- /dev/null +++ b/app/Console/Commands/BackfillArtworkPrimaryCategoryCommand.php @@ -0,0 +1,139 @@ +option('apply'); + $force = (bool) $this->option('force'); + $report = (bool) $this->option('report') || ! $apply; + + if ($report) { + $this->printDistribution(); + } + + $conflicts = $this->conflictArtworkIds(); + $zeros = $this->zeroCategoryArtworkIds(); + $eligible = $this->eligibleArtworkIds($force); + + $this->info('Zero-category artworks: '.$zeros->count()); + $this->info('Single-category eligible for backfill: '.$eligible->count()); + $this->info('Multi-category conflicts (not auto-resolved): '.$conflicts->count()); + + if ($conflicts->isNotEmpty()) { + $this->warn('Multi-category artwork IDs (first 50): '.$conflicts->take(50)->implode(', ')); + } + + if (! $apply) { + $this->comment('Dry run only. Re-run with --apply to write primary_category_id for single-category artworks.'); + + return self::SUCCESS; + } + + $updated = 0; + foreach ($eligible->chunk(500) as $chunk) { + $rows = DB::table('artwork_category') + ->select('artwork_id', 'category_id') + ->whereIn('artwork_id', $chunk->all()) + ->get() + ->groupBy('artwork_id'); + + foreach ($rows as $artworkId => $memberships) { + if ($memberships->count() !== 1) { + continue; + } + + $query = Artwork::query()->where('id', (int) $artworkId); + if (! $force) { + $query->whereNull('primary_category_id'); + } + + $updated += $query->update([ + 'primary_category_id' => (int) $memberships->first()->category_id, + ]); + } + } + + $this->info("Updated {$updated} artwork(s)."); + + return self::SUCCESS; + } + + private function printDistribution(): void + { + $distribution = DB::query() + ->fromSub(function ($query): void { + $query->from('artwork_category') + ->select('artwork_id', DB::raw('COUNT(*) as category_count')) + ->groupBy('artwork_id'); + }, 'x') + ->select('category_count', DB::raw('COUNT(*) as artworks')) + ->groupBy('category_count') + ->orderBy('category_count') + ->get(); + + $this->table(['category_count', 'artworks'], $distribution->map(fn ($row): array => [ + (int) $row->category_count, + (int) $row->artworks, + ])->all()); + } + + /** + * @return Collection + */ + private function eligibleArtworkIds(bool $force) + { + $query = DB::table('artwork_category') + ->select('artwork_id') + ->groupBy('artwork_id') + ->havingRaw('COUNT(*) = 1'); + + if (! $force) { + $query->whereIn('artwork_id', Artwork::query()->whereNull('primary_category_id')->select('id')); + } + + return $query->pluck('artwork_id')->map(fn ($id): int => (int) $id); + } + + /** + * @return Collection + */ + private function conflictArtworkIds() + { + return DB::table('artwork_category') + ->select('artwork_id') + ->groupBy('artwork_id') + ->havingRaw('COUNT(*) > 1') + ->orderByRaw('COUNT(*) DESC') + ->pluck('artwork_id') + ->map(fn ($id): int => (int) $id); + } + + /** + * @return Collection + */ + private function zeroCategoryArtworkIds() + { + return Artwork::query() + ->whereDoesntHave('categories') + ->pluck('id') + ->map(fn ($id): int => (int) $id); + } +} diff --git a/app/Http/Controllers/Api/ArtworkController.php b/app/Http/Controllers/Api/ArtworkController.php index 37b8a461..be85f312 100644 --- a/app/Http/Controllers/Api/ArtworkController.php +++ b/app/Http/Controllers/Api/ArtworkController.php @@ -1,13 +1,14 @@ user(); $data = $request->validated(); - $categoryId = isset($data['category']) && ctype_digit((string) $data['category']) - ? (int) $data['category'] - : null; + $categoryId = isset($data['primary_category_id']) && ctype_digit((string) $data['primary_category_id']) + ? (int) $data['primary_category_id'] + : (isset($data['category']) && ctype_digit((string) $data['category']) + ? (int) $data['category'] + : null); + $secondaryCategoryIds = array_values(array_map('intval', $data['secondary_category_ids'] ?? [])); $result = $drafts->createDraft( $user, @@ -40,6 +44,7 @@ class ArtworkController extends Controller $categoryId, (bool) ($data['is_mature'] ?? false), $data['group'] ?? null, + $secondaryCategoryIds, ); return response()->json([ diff --git a/app/Http/Controllers/Api/DiscoveryNegativeSignalController.php b/app/Http/Controllers/Api/DiscoveryNegativeSignalController.php index 3dd5cc2c..fe2a8350 100644 --- a/app/Http/Controllers/Api/DiscoveryNegativeSignalController.php +++ b/app/Http/Controllers/Api/DiscoveryNegativeSignalController.php @@ -174,7 +174,7 @@ final class DiscoveryNegativeSignalController extends Controller } /** - * @param array $meta + * @param array $meta */ private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void { @@ -182,10 +182,9 @@ final class DiscoveryNegativeSignalController extends Controller return; } - $categoryId = DB::table('artwork_category') - ->where('artwork_id', $artworkId) - ->orderBy('category_id') - ->value('category_id'); + $categoryId = DB::table('artworks') + ->where('id', $artworkId) + ->value('primary_category_id'); DB::table('user_discovery_events')->insert([ 'event_id' => (string) Str::uuid(), diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index bdbed06e..c24c1121 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -5,52 +5,58 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Uploads\UploadCancelRequest; +use App\Http\Requests\Uploads\UploadChunkRequest; use App\Http\Requests\Uploads\UploadFinishRequest; use App\Http\Requests\Uploads\UploadInitRequest; -use App\Http\Requests\Uploads\UploadChunkRequest; -use App\Http\Requests\Uploads\UploadCancelRequest; use App\Http\Requests\Uploads\UploadStatusRequest; -use App\Jobs\GenerateDerivativesJob; use App\Jobs\AnalyzeArtworkAiAssistJob; -use App\Jobs\IndexArtworkJob; use App\Jobs\AutoTagArtworkJob; use App\Jobs\DetectArtworkMaturityJob; use App\Jobs\GenerateArtworkEmbeddingJob; +use App\Jobs\GenerateDerivativesJob; +use App\Jobs\IndexArtworkJob; +use App\Models\ActivityEvent; +use App\Models\Artwork; +use App\Models\Group; +use App\Models\Tag; +use App\Notifications\NewArtworkReviewNotification; use App\Repositories\Uploads\UploadSessionRepository; -use App\Services\Uploads\UploadChunkService; -use App\Services\Uploads\UploadCancelService; -use App\Services\Uploads\UploadAuditService; -use App\Services\Uploads\UploadPipelineService; -use App\Services\Uploads\UploadQuotaService; -use App\Services\Uploads\UploadQueueService; -use App\Services\Uploads\UploadSessionStatus; -use App\Services\Uploads\UploadStatusService; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; -use Symfony\Component\HttpFoundation\Response; -use Throwable; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; -use App\Services\Upload\Contracts\UploadDraftServiceInterface; -use Carbon\Carbon; -use App\Uploads\Jobs\VirusScanJob; -use App\Uploads\Services\PublishService; use App\Services\Activity\UserActivityService; use App\Services\ArtworkAttributionService; +use App\Services\Artworks\ArtworkCategoryService; +use App\Services\GroupArtworkReviewService; use App\Services\Maturity\ArtworkMaturityService; +use App\Services\Moderation\ArtworkUploadPolicy; +use App\Services\Upload\Contracts\UploadDraftServiceInterface; +use App\Services\Uploads\UploadAuditService; +use App\Services\Uploads\UploadCancelService; +use App\Services\Uploads\UploadChunkService; +use App\Services\Uploads\UploadPipelineService; +use App\Services\Uploads\UploadQueueService; +use App\Services\Uploads\UploadQuotaService; +use App\Services\Uploads\UploadSessionStatus; +use App\Services\Uploads\UploadStatusService; +use App\Services\Worlds\WorldSubmissionService; +use App\Support\ArtworkDescriptionContentValidator; +use App\Uploads\Exceptions\DraftQuotaException; use App\Uploads\Exceptions\UploadNotFoundException; use App\Uploads\Exceptions\UploadOwnershipException; use App\Uploads\Exceptions\UploadPublishValidationException; +use App\Uploads\Jobs\VirusScanJob; use App\Uploads\Services\ArchiveInspectorService; use App\Uploads\Services\DraftQuotaService; -use App\Uploads\Exceptions\DraftQuotaException; -use App\Models\Artwork; -use App\Models\Group; -use App\Services\GroupArtworkReviewService; -use App\Support\ArtworkDescriptionContentValidator; -use App\Services\Worlds\WorldSubmissionService; -use Illuminate\Validation\ValidationException; +use App\Uploads\Services\PublishService; +use Carbon\Carbon; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\Response; +use Throwable; final class UploadController extends Controller { @@ -59,8 +65,7 @@ final class UploadController extends Controller UploadPipelineService $pipeline, UploadQuotaService $quota, UploadAuditService $audit - ) - { + ) { $user = $request->user(); try { @@ -251,6 +256,7 @@ final class UploadController extends Controller if ((bool) config('vision.upload.ai_assist.enabled', false)) { AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit(); } + return UploadSessionStatus::PROCESSED; }); @@ -305,7 +311,7 @@ final class UploadController extends Controller 'realpath' => $chunkFile->getRealPath(), ]); } - } catch (\Throwable $e) { + } catch (Throwable $e) { logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]); } @@ -333,7 +339,7 @@ final class UploadController extends Controller 'total_bytes' => $result->totalBytes, 'progress' => $result->progress, ], Response::HTTP_OK); - } catch (\Throwable $e) { + } catch (Throwable $e) { logger()->warning('Upload chunk failed', [ 'session_id' => (string) $request->input('session_id'), 'error' => $e->getMessage(), @@ -383,7 +389,7 @@ final class UploadController extends Controller 'session_id' => $result['session_id'], 'status' => $result['status'], ], Response::HTTP_OK); - } catch (\Throwable $e) { + } catch (Throwable $e) { logger()->warning('Upload cancel failed', [ 'session_id' => (string) $request->input('session_id'), 'error' => $e->getMessage(), @@ -512,6 +518,7 @@ final class UploadController extends Controller return response()->json($response, Response::HTTP_OK); } catch (Throwable $e) { logger()->error('Upload preload failed', ['error' => $e->getMessage()]); + return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR); } } @@ -538,6 +545,8 @@ final class UploadController extends Controller $validated = $request->validate([ 'title' => ['nullable', 'string', 'max:255'], 'category_id' => ['nullable', 'exists:categories,id'], + 'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)], + 'secondary_category_ids.*' => ['integer', 'exists:categories,id'], 'description' => ['nullable', 'string'], 'tags' => ['nullable', 'array'], 'license' => ['nullable', 'string'], @@ -547,7 +556,7 @@ final class UploadController extends Controller $this->ensureValidArtworkDescription($validated); $updates = []; - foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) { + foreach (['title', 'category_id', 'secondary_category_ids', 'description', 'tags', 'license', 'nsfw'] as $field) { if (array_key_exists($field, $validated)) { $updates[$field] = $validated[$field]; } @@ -557,7 +566,7 @@ final class UploadController extends Controller foreach ($updates as $field => $value) { $current = $upload->{$field} ?? null; - if ($field === 'tags') { + if ($field === 'tags' || $field === 'secondary_category_ids') { $current = $current ? json_decode((string) $current, true) : null; } @@ -575,6 +584,10 @@ final class UploadController extends Controller $dirty['tags'] = json_encode($dirty['tags']); } + if (array_key_exists('secondary_category_ids', $dirty)) { + $dirty['secondary_category_ids'] = json_encode($dirty['secondary_category_ids']); + } + if (! empty($dirty)) { $dirty['updated_at'] = now(); DB::table('uploads')->where('id', $id)->update($dirty); @@ -616,23 +629,26 @@ final class UploadController extends Controller ], Response::HTTP_OK); } - public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions) + public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy) { $user = $request->user(); $validated = $request->validate([ - 'title' => ['nullable', 'string', 'max:150'], + 'title' => ['nullable', 'string', 'max:150'], 'description' => ['nullable', 'string'], - 'category' => ['nullable', 'integer', 'exists:categories,id'], - 'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)], - 'tags.*' => ['string', 'max:64'], - 'is_mature' => ['nullable', 'boolean'], - 'nsfw' => ['nullable', 'boolean'], + 'category' => ['nullable', 'integer', 'exists:categories,id'], + 'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'], + 'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)], + 'secondary_category_ids.*' => ['integer', 'exists:categories,id'], + 'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)], + 'tags.*' => ['string', 'max:64'], + 'is_mature' => ['nullable', 'boolean'], + 'nsfw' => ['nullable', 'boolean'], // Scheduled-publishing fields - 'mode' => ['nullable', 'string', 'in:now,schedule'], - 'publish_at' => ['nullable', 'string', 'date'], - 'timezone' => ['nullable', 'string', 'max:64'], - 'visibility' => ['nullable', 'string', 'in:public,unlisted,private'], + 'mode' => ['nullable', 'string', 'in:now,schedule'], + 'publish_at' => ['nullable', 'string', 'date'], + 'timezone' => ['nullable', 'string', 'max:64'], + 'visibility' => ['nullable', 'string', 'in:public,unlisted,private'], 'group' => ['nullable', 'string', 'max:90'], 'primary_author_user_id' => ['nullable', 'integer', 'min:1'], 'contributor_user_ids' => ['nullable', 'array', 'max:20'], @@ -649,21 +665,21 @@ final class UploadController extends Controller $this->ensureValidArtworkDescription($validated); - $mode = $validated['mode'] ?? 'now'; + $mode = $validated['mode'] ?? 'now'; $visibility = $validated['visibility'] ?? 'public'; // Resolve the UTC publish_at datetime for schedule mode $publishAt = null; if ($mode === 'schedule' && ! empty($validated['publish_at'])) { try { - $publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc(); + $publishAt = Carbon::parse($validated['publish_at'])->utc(); // Must be at least 1 minute in the future (server-side guard) if ($publishAt->lte(now()->addMinute())) { return response()->json([ 'message' => 'Scheduled publish time must be at least 1 minute in the future.', ], Response::HTTP_UNPROCESSABLE_ENTITY); } - } catch (\Throwable) { + } catch (Throwable) { return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY); } } @@ -696,22 +712,25 @@ final class UploadController extends Controller if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) { $artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false); } - $artwork->slug = Str::limit($slugBase, 160, ''); + $artwork->slug = Str::limit($slugBase, 160, ''); $artwork->artwork_timezone = $validated['timezone'] ?? null; $artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id; $artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id; - // Sync category if provided - $categoryId = isset($validated['category']) ? (int) $validated['category'] : null; - if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) { - $artwork->categories()->sync([$categoryId]); + $categoryId = isset($validated['primary_category_id']) + ? (int) $validated['primary_category_id'] + : (isset($validated['category']) ? (int) $validated['category'] : null); + $secondaryCategoryIds = array_values(array_map('intval', $validated['secondary_category_ids'] ?? [])); + if ($categoryId) { + app(ArtworkCategoryService::class) + ->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false); } // Sync tags if provided - if (!empty($validated['tags']) && is_array($validated['tags'])) { + if (! empty($validated['tags']) && is_array($validated['tags'])) { $tagIds = []; foreach ($validated['tags'] as $tagSlug) { - $tag = \App\Models\Tag::firstOrCreate( + $tag = Tag::firstOrCreate( ['slug' => Str::slug($tagSlug)], ['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0] ); @@ -727,56 +746,95 @@ final class UploadController extends Controller if ($mode === 'schedule' && $publishAt) { // Scheduled: store publish_at but don't make public yet - $artwork->visibility = $visibility; - $artwork->is_public = false; - $artwork->is_approved = true; - $artwork->publish_at = $publishAt; + $artwork->visibility = $visibility; + $artwork->is_public = false; + $artwork->is_approved = true; + $artwork->publish_at = $publishAt; $artwork->artwork_status = 'scheduled'; - $artwork->published_at = null; + $artwork->published_at = null; $artwork->save(); IndexArtworkJob::dispatch((int) $artwork->id); return response()->json([ - 'success' => true, - 'artwork_id' => (int) $artwork->id, - 'status' => 'scheduled', - 'slug' => (string) $artwork->slug, - 'publish_at' => $publishAt->toISOString(), + 'success' => true, + 'artwork_id' => (int) $artwork->id, + 'status' => 'scheduled', + 'slug' => (string) $artwork->slug, + 'publish_at' => $publishAt->toISOString(), 'published_at' => null, ], Response::HTTP_OK); } - // Publish immediately - $artwork->visibility = $visibility; - $artwork->is_public = ($visibility !== 'private'); - $artwork->is_approved = true; - $artwork->published_at = now(); + // New and suspicious accounts are quarantined. Never let the client + // decide that an artwork is approved. + $policy = $uploadPolicy->assess($user, $artwork); + if ($policy['requires_review']) { + $artwork->visibility = $visibility; + $artwork->is_public = false; + $artwork->is_approved = false; + $artwork->approval_source = null; + $artwork->artwork_status = 'review'; + $artwork->published_at = null; + $artwork->publish_at = null; + $artwork->moderated_at = null; + $artwork->moderated_by = null; + $artwork->moderation_note = implode(', ', $policy['reasons']); + $artwork->save(); + + $notifyEmail = trim((string) config('uploads.moderation.notify_email', '')); + if ($notifyEmail !== '') { + try { + Notification::route('mail', $notifyEmail)->notify( + new NewArtworkReviewNotification($artwork, $user, $policy['reasons']) + ); + } catch (Throwable) { + // Email failure must not expose the artwork. + } + } + + return response()->json([ + 'success' => true, + 'artwork_id' => (int) $artwork->id, + 'status' => 'submitted_for_review', + 'message' => 'Upload received and queued for moderation.', + 'review_reasons' => $policy['reasons'], + ], Response::HTTP_OK); + } + + // Publish immediately for trusted users only. + $artwork->visibility = $visibility; + $artwork->is_public = ($visibility !== 'private'); + $artwork->is_approved = true; + $artwork->approval_source = 'trusted_auto'; + $artwork->published_at = now(); $artwork->artwork_status = 'published'; - $artwork->publish_at = null; + $artwork->publish_at = null; $artwork->save(); IndexArtworkJob::dispatch((int) $artwork->id); // Record upload activity event try { - \App\Models\ActivityEvent::record( - actorId: (int) $user->id, - type: \App\Models\ActivityEvent::TYPE_UPLOAD, - targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, - targetId: (int) $artwork->id, + ActivityEvent::record( + actorId: (int) $user->id, + type: ActivityEvent::TYPE_UPLOAD, + targetType: ActivityEvent::TARGET_ARTWORK, + targetId: (int) $artwork->id, ); - } catch (\Throwable) {} + } catch (Throwable) { + } try { app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id); - } catch (\Throwable) {} + } catch (Throwable) { + } return response()->json([ - 'success' => true, - 'artwork_id' => (int) $artwork->id, - 'status' => 'published', - 'slug' => (string) $artwork->slug, + 'success' => true, + 'artwork_id' => (int) $artwork->id, + 'status' => 'published', + 'slug' => (string) $artwork->slug, 'published_at' => optional($artwork->published_at)->toISOString(), ], Response::HTTP_OK); } @@ -785,11 +843,11 @@ final class UploadController extends Controller $upload = $publishService->publish($id, $user); return response()->json([ - 'success' => true, - 'upload_id' => (string) $upload->id, - 'status' => (string) $upload->status, + 'success' => true, + 'upload_id' => (string) $upload->id, + 'status' => (string) $upload->status, 'published_at' => optional($upload->published_at)->toISOString(), - 'final_path' => (string) ($upload->final_path ?? ''), + 'final_path' => (string) ($upload->final_path ?? ''), ], Response::HTTP_OK); } catch (UploadOwnershipException $e) { return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); @@ -808,7 +866,10 @@ final class UploadController extends Controller 'title' => ['nullable', 'string', 'max:150'], 'description' => ['nullable', 'string'], 'category' => ['nullable', 'integer', 'exists:categories,id'], - 'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)], + 'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'], + 'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)], + 'secondary_category_ids.*' => ['integer', 'exists:categories,id'], + '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/Dashboard/ManageController.php b/app/Http/Controllers/Dashboard/ManageController.php index ef58bfd0..b371aa3d 100644 --- a/app/Http/Controllers/Dashboard/ManageController.php +++ b/app/Http/Controllers/Dashboard/ManageController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers\Dashboard; use App\Http\Controllers\Controller; +use App\Http\Requests\Manage\ManageArtworkDestroyRequest; use App\Http\Requests\Manage\ManageArtworkEditRequest; use App\Http\Requests\Manage\ManageArtworkUpdateRequest; -use App\Http\Requests\Manage\ManageArtworkDestroyRequest; +use App\Models\Artwork; +use App\Services\Artworks\ArtworkCategoryService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; class ManageController extends Controller { @@ -17,14 +19,12 @@ class ManageController extends Controller $userId = $request->user()->id; $perPage = 50; - $categorySub = DB::table('artwork_category as ac') - ->join('categories as c', 'ac.category_id', '=', 'c.id') - ->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name')) - ->groupBy('ac.artwork_id'); + $categorySub = DB::table('categories as c') + ->select('c.id as category_id', 'c.name as category_name'); $query = DB::table('artworks as a') ->leftJoinSub($categorySub, 'cat', function ($join) { - $join->on('a.id', '=', 'cat.artwork_id'); + $join->on('a.primary_category_id', '=', 'cat.category_id'); }) ->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id') ->where('a.user_id', $userId) @@ -52,8 +52,8 @@ class ManageController extends Controller { $artwork = $request->artwork(); - $selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id'); - $artwork->category = $selectedCategory; + $selectedCategory = (int) ($artwork->primary_category_id ?: DB::table('artwork_category')->where('artwork_id', (int) $id)->value('category_id')); + $artwork->category = $selectedCategory ?: null; $categories = DB::table('categories') ->where('content_type_id', 0) @@ -64,7 +64,7 @@ class ManageController extends Controller return view('manage.edit', [ 'artwork' => $artwork, 'categories' => $categories, - 'page_title' => 'Edit Artwork: ' . ($artwork->title ?? ''), + 'page_title' => 'Edit Artwork: '.($artwork->title ?? ''), ]); } @@ -91,14 +91,14 @@ class ManageController extends Controller $update['fname'] = basename($attPath); } - DB::table('artworks')->where('id', (int)$id)->update($update); + DB::table('artworks')->where('id', (int) $id)->update($update); if (isset($data['section'])) { - DB::table('artwork_category')->where('artwork_id', (int)$id)->delete(); - DB::table('artwork_category')->insert([ - 'artwork_id' => (int)$id, - 'category_id' => (int)$data['section'], - ]); + $model = Artwork::query()->find((int) $id); + if ($model) { + app(ArtworkCategoryService::class) + ->sync($model, (int) $data['section'], [], 'moderator'); + } } return redirect()->route('manage')->with('status', 'Artwork was successfully updated.'); @@ -108,14 +108,14 @@ class ManageController extends Controller { $artwork = $request->artwork(); - if (!empty($artwork->fname)) { - Storage::delete('public/uploads/attachments/' . $artwork->fname); + if (! empty($artwork->fname)) { + Storage::delete('public/uploads/attachments/'.$artwork->fname); } - if (!empty($artwork->picture)) { - Storage::delete('public/uploads/artworks/' . $artwork->picture); + if (! empty($artwork->picture)) { + Storage::delete('public/uploads/artworks/'.$artwork->picture); } - DB::table('artworks')->where('id', (int)$id)->delete(); + DB::table('artworks')->where('id', (int) $id)->delete(); return redirect()->route('manage')->with('status', 'Artwork deleted.'); } diff --git a/app/Http/Controllers/Legacy/TodayDownloadsController.php b/app/Http/Controllers/Legacy/TodayDownloadsController.php index 1743fb85..cdb8deef 100644 --- a/app/Http/Controllers/Legacy/TodayDownloadsController.php +++ b/app/Http/Controllers/Legacy/TodayDownloadsController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers\Legacy; use App\Http\Controllers\Controller; -use Illuminate\Http\Request; +use App\Models\Artwork; use App\Models\ArtworkDownload; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; +use App\Services\ThumbnailPresenter; use Carbon\Carbon; +use Illuminate\Http\Request; +use Illuminate\Support\Str; class TodayDownloadsController extends Controller { @@ -35,16 +36,16 @@ class TodayDownloadsController extends Controller $art = $row->artwork ?? null; // If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it if (! $art && isset($row->artwork_id)) { - $art = \App\Models\Artwork::find($row->artwork_id); + $art = Artwork::find($row->artwork_id); } $name = $art->title ?? null; $picture = $art->file_name ?? null; $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $encoded = null; // legacy encoding unavailable; leave null - $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; + $present = $art ? ThumbnailPresenter::present($art, 'md') : null; $thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp'; - $categoryId = $art->categories->first()->id ?? null; + $categoryId = $art->resolvedPrimaryCategory()?->id; return (object) [ 'id' => $art->id ?? null, diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index b4bab332..e77b1821 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -6,27 +6,34 @@ namespace App\Http\Controllers\Studio; use App\Http\Controllers\Controller; use App\Models\Artwork; +use App\Models\ArtworkVersion; 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\Notifications\NewArtworkReviewNotification; +use App\Repositories\Uploads\ArtworkFileRepository; use App\Services\ArtworkAttributionService; +use App\Services\ArtworkEvolutionService; +use App\Services\Artworks\ArtworkCategoryService; use App\Services\Artworks\ArtworkPublicationService; -use App\Services\TagService; +use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkVersioningService; +use App\Services\Cdn\ArtworkCdnPurgeService; +use App\Services\Moderation\ArtworkUploadPolicy; use App\Services\Studio\StudioArtworkQueryService; use App\Services\Studio\StudioBulkActionService; -use App\Support\ArtworkDescriptionContentValidator; use App\Services\Tags\TagDiscoveryService; +use App\Services\TagService; +use App\Services\Uploads\UploadDerivativesService; +use App\Services\Uploads\UploadStorageService; use App\Services\Worlds\WorldSubmissionService; +use App\Support\ArtworkDescriptionContentValidator; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; @@ -45,6 +52,7 @@ final class StudioArtworksApiController extends Controller private readonly TagService $tagService, private readonly ArtworkCdnPurgeService $cdnPurge, private readonly ArtworkPublicationService $artworkPublication, + private readonly ArtworkCategoryService $artworkCategories, ) {} /** @@ -74,9 +82,9 @@ final class StudioArtworksApiController extends Controller 'data' => $items, 'meta' => [ 'current_page' => $paginator->currentPage(), - 'last_page' => $paginator->lastPage(), - 'per_page' => $paginator->perPage(), - 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), ], ]); } @@ -88,14 +96,14 @@ final class StudioArtworksApiController extends Controller public function bulk(Request $request): JsonResponse { $validator = Validator::make($request->all(), [ - 'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags', + 'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags', 'artwork_ids' => 'required|array|min:1|max:200', 'artwork_ids.*' => 'integer', - 'params' => 'sometimes|array', + 'params' => 'sometimes|array', 'params.category_id' => 'sometimes|integer|exists:categories,id', - 'params.tag_ids' => 'sometimes|array', - 'params.tag_ids.*' => 'integer|exists:tags,id', - 'confirm' => 'required_if:action,delete|string', + 'params.tag_ids' => 'sometimes|array', + 'params.tag_ids.*' => 'integer|exists:tags,id', + 'confirm' => 'required_if:action,delete|string', ]); if ($validator->fails()) { @@ -127,23 +135,26 @@ final class StudioArtworksApiController extends Controller * PUT /api/studio/artworks/{id} * Update artwork details (title, description, visibility). */ - public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse + public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); $evolution = app(ArtworkEvolutionService::class); $validated = $request->validate([ - 'title' => 'sometimes|string|max:255', + 'title' => 'sometimes|string|max:255', 'description' => 'sometimes|nullable|string|max:5000', - 'is_public' => 'sometimes|boolean', - 'visibility' => 'sometimes|string|in:public,unlisted,private', - 'mode' => 'sometimes|string|in:now,schedule', - 'publish_at' => 'sometimes|nullable|string|date', - 'timezone' => 'sometimes|nullable|string|max:64', + 'is_public' => 'sometimes|boolean', + 'visibility' => 'sometimes|string|in:public,unlisted,private', + 'mode' => 'sometimes|string|in:now,schedule', + 'publish_at' => 'sometimes|nullable|string|date', + 'timezone' => 'sometimes|nullable|string|max:64', 'category_id' => 'sometimes|nullable|integer|exists:categories,id', + 'primary_category_id' => 'sometimes|nullable|integer|exists:categories,id', + 'secondary_category_ids' => 'sometimes|array|max:'.(int) config('categories.max_secondary_categories', 5), + 'secondary_category_ids.*' => 'integer|exists:categories,id', 'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id', - 'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30), - 'tags.*' => 'string|max:64', + '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', 'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed', @@ -210,14 +221,18 @@ final class StudioArtworksApiController extends Controller // Extract tags and category before updating core fields $tags = $validated['tags'] ?? null; - $categoryId = $validated['category_id'] ?? null; + $categoryId = $validated['primary_category_id'] ?? $validated['category_id'] ?? null; + $secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated) + ? array_values(array_map('intval', $validated['secondary_category_ids'] ?? [])) + : []; + $hasSecondaryCategoryUpdate = array_key_exists('secondary_category_ids', $validated); $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'], $validated['world_submissions']); + unset($validated['tags'], $validated['category_id'], $validated['primary_category_id'], $validated['secondary_category_ids'], $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'], $validated['world_submissions']); unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']); $validated['visibility'] = $visibility; @@ -231,9 +246,17 @@ final class StudioArtworksApiController extends Controller $validated['artwork_status'] = 'scheduled'; } else { $validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE; - $validated['is_approved'] = true; + $policy = $uploadPolicy->assess($request->user(), $artwork); + $validated['is_approved'] = ! $policy['requires_review']; + $validated['approval_source'] = $policy['requires_review'] ? null : 'trusted_auto'; $validated['publish_at'] = null; - $validated['artwork_status'] = 'published'; + $validated['artwork_status'] = $policy['requires_review'] ? 'review' : 'published'; + + if ($policy['requires_review']) { + $validated['is_public'] = false; + $validated['published_at'] = null; + $validated['moderation_note'] = implode(', ', $policy['reasons']); + } if (($validated['is_public'] ?? false) && ! $artwork->published_at) { $validated['published_at'] = now(); @@ -249,10 +272,19 @@ final class StudioArtworksApiController extends Controller } $artwork->update($validated); + $requiresReview = ($policy['requires_review'] ?? false) === true; - // Sync category - if ($categoryId !== null) { - $artwork->categories()->sync([(int) $categoryId]); + if ($categoryId !== null || $hasSecondaryCategoryUpdate) { + try { + $this->artworkCategories->sync( + $artwork, + $categoryId !== null ? (int) $categoryId : (int) $artwork->primary_category_id, + $hasSecondaryCategoryUpdate ? $secondaryCategoryIds : ($categoryId !== null ? [] : null), + (string) ($validated['category_source'] ?? $artwork->category_source ?? 'manual'), + ); + } catch (ValidationException $exception) { + return response()->json(['errors' => $exception->errors()], 422); + } } // Sync tags through the shared tag service so pivot source/usage rules stay valid. @@ -291,24 +323,40 @@ final class StudioArtworksApiController extends Controller // Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop. $this->searchIndexer->update($artwork); + if ($requiresReview) { + $notifyEmail = trim((string) config('uploads.moderation.notify_email', '')); + if ($notifyEmail !== '') { + try { + Notification::route('mail', $notifyEmail)->notify( + new NewArtworkReviewNotification($artwork, $request->user(), $policy['reasons']) + ); + } catch (\Throwable) { + // Keep the artwork private if notification delivery fails. + } + } + } + // Reload relationships for response - $artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']); - $primaryCategory = $artwork->categories->first(); + $artwork->load(['categories.contentType', 'primaryCategory.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']); + $primaryCategory = $artwork->resolvedPrimaryCategory(); return response()->json([ 'success' => true, + 'status' => $requiresReview ? 'submitted_for_review' : 'published', + 'message' => $requiresReview ? 'Artwork saved and queued for moderation.' : 'Artwork updated successfully.', + 'review_reasons' => $requiresReview ? $policy['reasons'] : [], 'artwork' => [ - 'id' => $artwork->id, - 'title' => $artwork->title, - 'description' => $artwork->description, - 'is_public' => (bool) $artwork->is_public, - 'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE), - 'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now', - 'publish_at' => $artwork->publish_at?->toIso8601String(), - 'artwork_status' => $artwork->artwork_status, + 'id' => $artwork->id, + 'title' => $artwork->title, + 'description' => $artwork->description, + 'is_public' => (bool) $artwork->is_public, + 'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE), + 'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now', + 'publish_at' => $artwork->publish_at?->toIso8601String(), + 'artwork_status' => $artwork->artwork_status, 'artwork_timezone' => $artwork->artwork_timezone, - 'slug' => $artwork->slug, - 'group_slug' => $artwork->group?->slug, + 'slug' => $artwork->slug, + 'group_slug' => $artwork->group?->slug, 'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id), 'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(), 'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [ @@ -317,11 +365,18 @@ final class StudioArtworksApiController extends Controller 'is_primary' => (bool) $contributor->is_primary, ])->values()->all(), 'content_type_id' => $primaryCategory?->contentType?->id, - 'category_id' => $primaryCategory?->id, - 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), - 'title_source' => $artwork->title_source ?: 'manual', + 'category_id' => $primaryCategory?->id, + 'primary_category_id' => $primaryCategory?->id, + 'secondary_category_ids' => $artwork->categories + ->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0)) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->values() + ->all(), + 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), + 'title_source' => $artwork->title_source ?: 'manual', 'description_source' => $artwork->description_source ?: 'manual', - 'tags_source' => $artwork->tags_source ?: 'manual', + 'tags_source' => $artwork->tags_source ?: 'manual', 'category_source' => $artwork->category_source ?: 'manual', 'evolution_relation' => $evolution->editorRelation($artwork, $request->user()), ], @@ -423,18 +478,18 @@ final class StudioArtworksApiController extends Controller return response()->json([ 'artwork' => [ - 'id' => $artwork->id, + 'id' => $artwork->id, 'title' => $artwork->title, - 'slug' => $artwork->slug, + 'slug' => $artwork->slug, ], 'analytics' => [ - 'views' => (int) ($stats?->views ?? 0), - 'favourites' => (int) ($stats?->favorites ?? 0), - 'shares' => (int) ($stats?->shares_count ?? 0), - 'comments' => (int) ($stats?->comments_count ?? 0), - 'downloads' => (int) ($stats?->downloads ?? 0), - 'ranking_score' => (float) ($stats?->ranking_score ?? 0), - 'heat_score' => (float) ($stats?->heat_score ?? 0), + 'views' => (int) ($stats?->views ?? 0), + 'favourites' => (int) ($stats?->favorites ?? 0), + 'shares' => (int) ($stats?->shares_count ?? 0), + 'comments' => (int) ($stats?->comments_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'ranking_score' => (float) ($stats?->ranking_score ?? 0), + 'heat_score' => (float) ($stats?->heat_score ?? 0), 'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0), ], ]); @@ -449,28 +504,28 @@ final class StudioArtworksApiController extends Controller $stats = $artwork->stats ?? null; return [ - 'id' => $artwork->id, - 'title' => $artwork->title, - 'slug' => $artwork->slug, - 'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg', - 'is_public' => (bool) $artwork->is_public, - 'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE), - 'is_approved' => (bool) $artwork->is_approved, - 'published_at' => $artwork->published_at?->toIso8601String(), - 'publish_at' => $artwork->publish_at?->toIso8601String(), - 'artwork_status' => $artwork->artwork_status, - 'created_at' => $artwork->created_at?->toIso8601String(), - 'deleted_at' => $artwork->deleted_at?->toIso8601String(), - 'category' => $artwork->categories->first()?->name, - 'category_slug' => $artwork->categories->first()?->slug, - 'tags' => $artwork->tags->pluck('slug')->values()->all(), - 'views' => (int) ($stats?->views ?? 0), - 'favourites' => (int) ($stats?->favorites ?? 0), - 'shares' => (int) ($stats?->shares_count ?? 0), - 'comments' => (int) ($stats?->comments_count ?? 0), - 'downloads' => (int) ($stats?->downloads ?? 0), - 'ranking_score' => (float) ($stats?->ranking_score ?? 0), - 'heat_score' => (float) ($stats?->heat_score ?? 0), + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg', + 'is_public' => (bool) $artwork->is_public, + 'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE), + 'is_approved' => (bool) $artwork->is_approved, + 'published_at' => $artwork->published_at?->toIso8601String(), + 'publish_at' => $artwork->publish_at?->toIso8601String(), + 'artwork_status' => $artwork->artwork_status, + 'created_at' => $artwork->created_at?->toIso8601String(), + 'deleted_at' => $artwork->deleted_at?->toIso8601String(), + 'category' => $artwork->resolvedPrimaryCategory()?->name, + 'category_slug' => $artwork->resolvedPrimaryCategory()?->slug, + 'tags' => $artwork->tags->pluck('slug')->values()->all(), + 'views' => (int) ($stats?->views ?? 0), + 'favourites' => (int) ($stats?->favorites ?? 0), + 'shares' => (int) ($stats?->shares_count ?? 0), + 'comments' => (int) ($stats?->comments_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'ranking_score' => (float) ($stats?->ranking_score ?? 0), + 'heat_score' => (float) ($stats?->heat_score ?? 0), ]; } @@ -499,7 +554,7 @@ final class StudioArtworksApiController extends Controller $previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork); $request->validate([ - 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB + 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB 'change_note' => 'sometimes|nullable|string|max:500', ]); @@ -510,43 +565,43 @@ final class StudioArtworksApiController extends Controller return response()->json(['success' => false, 'error' => $e->getMessage()], 429); } - $file = $request->file('file'); + $file = $request->file('file'); $tempPath = $file->getRealPath(); - $hash = hash_file('sha256', $tempPath); + $hash = hash_file('sha256', $tempPath); // Reject identical files early (before any disk writes) if ($artwork->hash === $hash) { return response()->json([ 'success' => false, - 'error' => 'The uploaded file is identical to the current version.', + 'error' => 'The uploaded file is identical to the current version.', ], 422); } try { - $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); - $storage = app(\App\Services\Uploads\UploadStorageService::class); - $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); + $derivatives = app(UploadDerivativesService::class); + $storage = app(UploadStorageService::class); + $artworkFiles = app(ArtworkFileRepository::class); // 1. Store original on disk (preserve extension when possible) - $originalAsset = $derivatives->storeOriginal($tempPath, $hash); - $originalPath = $originalAsset['local_path']; - $origFilename = basename($originalPath); + $originalAsset = $derivatives->storeOriginal($tempPath, $hash); + $originalPath = $originalAsset['local_path']; + $origFilename = basename($originalPath); $originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename); - $origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream'; + $origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream'; $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath)); // 2. Generate thumbnails (xs/sm/md/lg/xl) $publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash); foreach ($publicAssets as $variant => $asset) { - $relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp'); + $relativePath = $storage->sectionRelativePath($variant, $hash, $hash.'.webp'); $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0)); } // 3. Get new dimensions - $dims = @getimagesize($tempPath); - $width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width; + $dims = @getimagesize($tempPath); + $width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width; $height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height; - $size = (int) filesize($originalPath); + $size = (int) filesize($originalPath); // 4. Update the artwork's file-serving fields (hash drives thumbnail URLs) $origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: ''); @@ -559,7 +614,7 @@ final class StudioArtworksApiController extends Controller if ($clientName !== '') { $clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION)); if ($clientExt === '' && $origExt !== '') { - $clientName .= '.' . $origExt; + $clientName .= '.'.$origExt; } $displayFileName = $clientName; @@ -570,11 +625,11 @@ final class StudioArtworksApiController extends Controller 'file_path' => '', 'file_size' => $size, 'mime_type' => $origMime, - 'hash' => $hash, - 'file_ext' => $origExt, + 'hash' => $hash, + 'file_ext' => $origExt, 'thumb_ext' => 'webp', - 'width' => max(1, $width), - 'height' => max(1, $height), + 'width' => max(1, $width), + 'height' => max(1, $height), ]); // 5. Create version record from the new full media snapshot. @@ -593,7 +648,7 @@ final class StudioArtworksApiController extends Controller } catch (\Throwable $e) { Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [ 'artwork_id' => $artwork->id, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); } @@ -601,16 +656,17 @@ final class StudioArtworksApiController extends Controller $this->purgeCdnCache($artwork, $hash); return response()->json([ - 'success' => true, + 'success' => true, 'version_number' => $version->version_number, 'requires_reapproval' => (bool) $artwork->requires_reapproval, ] + $this->mediaPayload($artwork)); } catch (\Throwable $e) { Log::error('replaceFile: processing error', [ 'artwork_id' => $artwork->id, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); - return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500); + + return response()->json(['success' => false, 'error' => 'File processing failed: '.$e->getMessage()], 500); } } @@ -678,8 +734,8 @@ final class StudioArtworksApiController extends Controller return response()->json(['success' => false, 'error' => $e->getMessage()], 429); } - $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); - $storage = app(\App\Services\Uploads\UploadStorageService::class); + $derivatives = app(UploadDerivativesService::class); + $storage = app(UploadStorageService::class); $cleanupLocalPaths = []; $cleanupObjectPaths = []; @@ -766,7 +822,7 @@ final class StudioArtworksApiController extends Controller $allScreenshotDescriptors = []; foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) { - $descriptor['variant'] = 'shot' . ($index + 1); + $descriptor['variant'] = 'shot'.($index + 1); $allScreenshotDescriptors[] = $descriptor; } @@ -862,7 +918,7 @@ final class StudioArtworksApiController extends Controller return response()->json([ 'success' => false, - 'error' => 'Revision update failed: ' . $exception->getMessage(), + 'error' => 'Revision update failed: '.$exception->getMessage(), ], 500); } } @@ -873,27 +929,27 @@ final class StudioArtworksApiController extends Controller */ public function versions(Request $request, int $id): JsonResponse { - $artwork = $request->user()->artworks()->findOrFail($id); + $artwork = $request->user()->artworks()->findOrFail($id); $versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get(); return response()->json([ 'artwork' => [ - 'id' => $artwork->id, - 'title' => $artwork->title, + 'id' => $artwork->id, + 'title' => $artwork->title, 'version_count' => (int) ($artwork->version_count ?? 1), ], 'versions' => $versions->map(fn (ArtworkVersion $v) => [ - 'id' => $v->id, + 'id' => $v->id, 'version_number' => $v->version_number, - 'file_path' => $v->file_path, - 'file_hash' => $v->file_hash, - 'width' => $v->width, - 'height' => $v->height, - 'file_size' => $v->file_size, - 'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''), - 'change_note' => $v->change_note, - 'is_current' => $v->is_current, - 'created_at' => $v->created_at?->toIso8601String(), + 'file_path' => $v->file_path, + 'file_hash' => $v->file_hash, + 'width' => $v->width, + 'height' => $v->height, + 'file_size' => $v->file_size, + 'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''), + 'change_note' => $v->change_note, + 'is_current' => $v->is_current, + 'created_at' => $v->created_at?->toIso8601String(), ])->values(), ]); } @@ -918,17 +974,18 @@ final class StudioArtworksApiController extends Controller // Reindex try { $this->searchIndexer->update($artwork); - } catch (\Throwable) {} + } catch (\Throwable) { + } return response()->json([ - 'success' => true, + 'success' => true, 'version_number' => $newVersion->version_number, - 'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.", + 'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.", ] + $this->mediaPayload($artwork)); } catch (TooManyRequestsHttpException $e) { return response()->json(['success' => false, 'error' => $e->getMessage()], 429); } catch (\Throwable $e) { - return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500); + return response()->json(['success' => false, 'error' => 'Restore failed: '.$e->getMessage()], 500); } } @@ -938,7 +995,7 @@ final class StudioArtworksApiController extends Controller * This is best-effort; failures are logged but never fatal. * Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed. */ - private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void + private function purgeCdnCache(Artwork $artwork, string $oldHash): void { try { $this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [ @@ -951,7 +1008,7 @@ final class StudioArtworksApiController extends Controller } /** - * @param array $snapshot + * @param array $snapshot * @return array> */ private function snapshotFilesByVariant(array $snapshot): array @@ -963,7 +1020,7 @@ final class StudioArtworksApiController extends Controller } /** - * @param array $snapshot + * @param array $snapshot * @return array */ private function currentPublicAssetsFromSnapshot(array $snapshot): array @@ -981,7 +1038,7 @@ final class StudioArtworksApiController extends Controller } /** - * @param array|null $file + * @param array|null $file * @return array|null */ private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array @@ -1002,7 +1059,7 @@ final class StudioArtworksApiController extends Controller } /** - * @param array $stored + * @param array $stored * @return array */ private function storedOriginalDescriptor(array $stored, string $variant): array @@ -1028,7 +1085,7 @@ final class StudioArtworksApiController extends Controller $candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION)); if ($candidateExt === '' && $ext !== '') { - $candidate .= '.' . ltrim($ext, '.'); + $candidate .= '.'.ltrim($ext, '.'); } return $candidate; @@ -1058,7 +1115,7 @@ final class StudioArtworksApiController extends Controller } /** - * @param array $snapshot + * @param array $snapshot * @return array> */ private function screenshotAssetsFromSnapshot(array $snapshot): array @@ -1070,11 +1127,11 @@ final class StudioArtworksApiController extends Controller ->values() ->map(function (array $file, int $index) use ($base): array { $path = trim((string) ($file['path'] ?? ''), '/'); - $url = $base . '/' . $path; + $url = $base.'/'.$path; return [ - 'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))), - 'label' => 'Screenshot ' . ($index + 1), + 'id' => (string) ($file['variant'] ?? ('shot'.($index + 1))), + 'label' => 'Screenshot '.($index + 1), 'url' => $url, 'thumb_url' => $url, 'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'), diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php index 5e11bf30..10bf7e5c 100644 --- a/app/Http/Controllers/Studio/StudioController.php +++ b/app/Http/Controllers/Studio/StudioController.php @@ -5,26 +5,27 @@ declare(strict_types=1); namespace App\Http\Controllers\Studio; use App\Http\Controllers\Controller; -use App\Models\Group; use App\Models\ContentType; +use App\Models\Group; use App\Services\ArtworkEvolutionService; use App\Services\Artworks\ArtworkPublicationService; use App\Services\GroupMembershipService; use App\Services\GroupService; +use App\Services\Studio\CreatorStudioActivityService; use App\Services\Studio\CreatorStudioAnalyticsService; use App\Services\Studio\CreatorStudioAssetService; use App\Services\Studio\CreatorStudioCalendarService; +use App\Services\Studio\CreatorStudioChallengeService; use App\Services\Studio\CreatorStudioCommentService; use App\Services\Studio\CreatorStudioContentService; use App\Services\Studio\CreatorStudioFollowersService; use App\Services\Studio\CreatorStudioGrowthService; -use App\Services\Studio\CreatorStudioActivityService; use App\Services\Studio\CreatorStudioInboxService; use App\Services\Studio\CreatorStudioOverviewService; use App\Services\Studio\CreatorStudioPreferenceService; -use App\Services\Studio\CreatorStudioChallengeService; -use App\Services\Studio\CreatorStudioSearchService; use App\Services\Studio\CreatorStudioScheduledService; +use App\Services\Studio\CreatorStudioSearchService; +use App\Services\Uploads\UploadQueueService; use App\Services\Worlds\WorldSubmissionService; use App\Support\CoverUrl; use Illuminate\Http\RedirectResponse; @@ -132,7 +133,7 @@ final class StudioController extends Controller public function uploadQueue(Request $request): Response { - $queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload( + $queue = app(UploadQueueService::class)->listPayload( $request->user(), $request->only(['batch_id', 'status', 'sort']) ); @@ -361,7 +362,7 @@ final class StudioController extends Controller 'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null, 'cover_position' => (int) ($user->cover_position ?? 50), 'followers' => (int) ($user->statistics?->followers_count ?? 0), - 'profile_url' => '/@' . strtolower((string) $user->username), + 'profile_url' => '/@'.strtolower((string) $user->username), 'social_links' => $socialLinks, ], 'moduleSummaries' => $this->content->moduleSummaries($user), @@ -449,13 +450,13 @@ final class StudioController extends Controller { $user = $request->user(); $artwork = $user->artworks() - ->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']) + ->with(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']) ->findOrFail($id); $artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork); - $artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']); + $artwork->loadMissing(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']); - $primaryCategory = $artwork->categories->first(); + $primaryCategory = $artwork->resolvedPrimaryCategory(); $availableGroups = app(GroupService::class)->studioOptionsForUser($user); $membershipService = app(GroupMembershipService::class); $contributorOptionsByGroup = []; @@ -470,30 +471,30 @@ final class StudioController extends Controller } return Inertia::render('Studio/StudioArtworkEdit', [ - 'artwork' => [ - 'id' => $artwork->id, - 'title' => $artwork->title, - 'slug' => $artwork->slug, - 'description' => $artwork->description, - 'is_public' => (bool) $artwork->is_public, - 'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'), - 'is_approved' => (bool) $artwork->is_approved, - 'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now', - 'publish_at' => $artwork->publish_at?->toIso8601String(), - 'artwork_status' => $artwork->artwork_status, + 'artwork' => [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'description' => $artwork->description, + 'is_public' => (bool) $artwork->is_public, + 'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'), + 'is_approved' => (bool) $artwork->is_approved, + 'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now', + 'publish_at' => $artwork->publish_at?->toIso8601String(), + 'artwork_status' => $artwork->artwork_status, 'artwork_timezone' => $artwork->artwork_timezone, - 'thumb_url' => $artwork->thumbUrl('md'), - 'thumb_url_lg' => $artwork->thumbUrl('lg'), - 'download_url' => route('art.download', ['id' => $artwork->id]), - 'file_name' => $artwork->file_name, - 'file_ext' => $artwork->file_ext, - 'file_size' => $artwork->file_size, - 'width' => $artwork->width, - 'height' => $artwork->height, - 'mime_type' => $artwork->mime_type, + 'thumb_url' => $artwork->thumbUrl('md'), + 'thumb_url_lg' => $artwork->thumbUrl('lg'), + 'download_url' => route('art.download', ['id' => $artwork->id]), + 'file_name' => $artwork->file_name, + 'file_ext' => $artwork->file_ext, + 'file_size' => $artwork->file_size, + 'width' => $artwork->width, + 'height' => $artwork->height, + 'mime_type' => $artwork->mime_type, 'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id), - 'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id), - 'group_slug' => $artwork->group?->slug, + 'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id), + 'group_slug' => $artwork->group?->slug, 'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id), 'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(), 'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [ @@ -502,19 +503,27 @@ final class StudioController extends Controller 'is_primary' => (bool) $contributor->is_primary, ])->values()->all(), 'content_type_id' => $primaryCategory?->contentType?->id, - 'category_id' => $primaryCategory?->id, + 'category_id' => $primaryCategory?->id, + 'primary_category_id' => $primaryCategory?->id, 'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id, - 'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null, - 'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(), - 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), - 'ai_status' => $artwork->ai_status, - 'title_source' => $artwork->title_source ?: 'manual', + 'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null, + 'secondary_category_ids' => $artwork->categories + ->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0)) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->values() + ->all(), + 'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5), + 'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug, 'is_primary' => (int) $c->id === (int) ($primaryCategory?->id ?? 0)])->values()->all(), + 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), + 'ai_status' => $artwork->ai_status, + 'title_source' => $artwork->title_source ?: 'manual', 'description_source' => $artwork->description_source ?: 'manual', - 'tags_source' => $artwork->tags_source ?: 'manual', + 'tags_source' => $artwork->tags_source ?: 'manual', 'category_source' => $artwork->category_source ?: 'manual', 'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user), // Versioning - 'version_count' => (int) ($artwork->version_count ?? 1), + 'version_count' => (int) ($artwork->version_count ?? 1), 'requires_reapproval' => (bool) $artwork->requires_reapproval, ], 'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user), @@ -538,19 +547,19 @@ final class StudioController extends Controller return Inertia::render('Studio/StudioArtworkAnalytics', [ 'artwork' => [ - 'id' => $artwork->id, - 'title' => $artwork->title, - 'slug' => $artwork->slug, - 'thumb_url' => $artwork->thumbUrl('md'), + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'thumb_url' => $artwork->thumbUrl('md'), ], 'analytics' => [ - 'views' => (int) ($stats?->views ?? 0), - 'favourites' => (int) ($stats?->favorites ?? 0), - 'shares' => (int) ($stats?->shares_count ?? 0), - 'comments' => (int) ($stats?->comments_count ?? 0), - 'downloads' => (int) ($stats?->downloads ?? 0), - 'ranking_score' => (float) ($stats?->ranking_score ?? 0), - 'heat_score' => (float) ($stats?->heat_score ?? 0), + 'views' => (int) ($stats?->views ?? 0), + 'favourites' => (int) ($stats?->favorites ?? 0), + 'shares' => (int) ($stats?->shares_count ?? 0), + 'comments' => (int) ($stats?->comments_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'ranking_score' => (float) ($stats?->ranking_score ?? 0), + 'heat_score' => (float) ($stats?->heat_score ?? 0), 'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0), ], ]); @@ -569,16 +578,16 @@ final class StudioController extends Controller $data = $this->analytics->overview($user, $rangeDays); return Inertia::render('Studio/StudioAnalytics', [ - 'totals' => $data['totals'], - 'topContent' => $data['top_content'], - 'moduleBreakdown' => $data['module_breakdown'], - 'viewsTrend' => $data['views_trend'], - 'engagementTrend' => $data['engagement_trend'], + 'totals' => $data['totals'], + 'topContent' => $data['top_content'], + 'moduleBreakdown' => $data['module_breakdown'], + 'viewsTrend' => $data['views_trend'], + 'engagementTrend' => $data['engagement_trend'], 'publishingTimeline' => $data['publishing_timeline'], - 'comparison' => $data['comparison'], - 'insightBlocks' => $data['insight_blocks'], - 'rangeDays' => $data['range_days'], - 'recentComments' => $this->overview->recentComments($user, 8), + 'comparison' => $data['comparison'], + 'insightBlocks' => $data['insight_blocks'], + 'rangeDays' => $data['range_days'], + 'recentComments' => $this->overview->recentComments($user, 8), ]); } @@ -586,16 +595,16 @@ final class StudioController extends Controller { return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) { return [ - 'id' => $ct->id, + 'id' => $ct->id, 'name' => $ct->name, 'slug' => $ct->slug, 'categories' => $ct->rootCategories->map(function ($c) { return [ - 'id' => $c->id, - 'name' => $c->name, - 'slug' => $c->slug, + 'id' => $c->id, + 'name' => $c->name, + 'slug' => $c->slug, 'children' => $c->children->map(fn ($ch) => [ - 'id' => $ch->id, + 'id' => $ch->id, 'name' => $ch->name, 'slug' => $ch->slug, ])->values()->all(), @@ -642,11 +651,11 @@ final class StudioController extends Controller return null; } - $url = $base . '/' . $path; + $url = $base.'/'.$path; return [ - 'id' => (string) ($row->variant ?? ('shot' . ($index + 1))), - 'label' => 'Screenshot ' . ($index + 1), + 'id' => (string) ($row->variant ?? ('shot'.($index + 1))), + 'label' => 'Screenshot '.($index + 1), 'url' => $url, 'thumb_url' => $url, 'mime_type' => (string) ($row->mime ?? 'image/jpeg'), diff --git a/app/Http/Controllers/User/TodayDownloadsController.php b/app/Http/Controllers/User/TodayDownloadsController.php index ec694fca..86562ce5 100644 --- a/app/Http/Controllers/User/TodayDownloadsController.php +++ b/app/Http/Controllers/User/TodayDownloadsController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Models\Artwork; use App\Models\ArtworkDownload; +use App\Services\ThumbnailPresenter; +use App\Support\AvatarUrl; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -54,7 +57,7 @@ class TodayDownloadsController extends Controller $paginator->getCollection()->transform(function ($row) { $art = $row->artwork ?? null; if (! $art && isset($row->artwork_id)) { - $art = \App\Models\Artwork::find($row->artwork_id); + $art = Artwork::find($row->artwork_id); } if (! $art) { @@ -75,9 +78,9 @@ class TodayDownloadsController extends Controller $picture = $art->file_name ?? null; $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $encoded = null; - $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; + $present = $art ? ThumbnailPresenter::present($art, 'md') : null; $thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp'; - $primaryCategory = $art->categories->first(); + $primaryCategory = $art->resolvedPrimaryCategory(); $categoryId = $primaryCategory->id ?? null; $categoryName = $primaryCategory->name ?? ''; $categorySlug = $primaryCategory->slug ?? ''; @@ -98,7 +101,7 @@ class TodayDownloadsController extends Controller 'category_slug' => $categorySlug, 'uname' => $art->user->name ?? 'Skinbase', 'username' => $art->user->username ?? '', - 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64), + 'avatar_url' => AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64), 'width' => $art->width, 'height' => $art->height, 'published_at' => $art->published_at, diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index 15680f30..4ce7fd63 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -1,4 +1,5 @@ $this->safeSuggestions(fn () => $suggestions->trendingArtworks()), ]), 404); @@ -73,24 +78,25 @@ final class ArtworkPageController extends Controller ->limit(6) ->get() ->map(function (Artwork $a) { - $slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id; - $md = \App\Services\ThumbnailPresenter::present($a, 'md'); + $slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id; + $md = ThumbnailPresenter::present($a, 'md'); + return [ - 'id' => $a->id, - 'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'id' => $a->id, + 'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), - 'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]), - 'thumb' => $md['url'] ?? null, + 'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]), + 'thumb' => $md['url'] ?? null, ]; }); }); } return response(view('errors.contextual.artwork-not-found', [ - 'message' => 'This artwork is not publicly available.', - 'isForbidden' => true, - 'creatorArtworks' => $creatorArtworks, - 'creatorUsername' => $creatorUsername, + 'message' => 'This artwork is not publicly available.', + 'isForbidden' => true, + 'creatorArtworks' => $creatorArtworks, + 'creatorUsername' => $creatorUsername, 'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()), ]), 403); } @@ -101,9 +107,15 @@ final class ArtworkPageController extends Controller // distinct responses. Reusing that model avoids a second identical // primary-key lookup on the public path. $artwork = $raw; - $artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']); + $artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'primaryCategory.contentType', 'primaryCategory.parent.contentType', 'tags', 'stats', 'awardStat']); - $this->loadCategoryAncestors($artwork->categories); + $this->loadCategoryAncestors( + $artwork->categories + ->concat(collect([$artwork->resolvedPrimaryCategory()])) + ->filter() + ->unique('id') + ->values() + ); $canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title)); if ($canonicalSlug === '') { @@ -152,37 +164,7 @@ final class ArtworkPageController extends Controller 'xl' => $thumbXl, ], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray(); - $categoryIds = $artwork->categories->pluck('id')->filter()->values(); - $tagIds = $artwork->tags->pluck('id')->filter()->values(); - - $related = Artwork::query() - ->with(['user', 'group', 'categories.contentType']) - ->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); - - if ($artwork->group_id) { - $query->orWhere('group_id', $artwork->group_id); - } - - if ($categoryIds->isNotEmpty()) { - $query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void { - $categoryQuery->whereIn('categories.id', $categoryIds->all()); - }); - } - - if ($tagIds->isNotEmpty()) { - $query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void { - $tagQuery->whereIn('tags.id', $tagIds->all()); - }); - } - }) - ->latest('published_at') - ->limit(12) - ->get() + $related = $this->relatedArtworks->related($artwork, $request->user(), 12) ->map(function (Artwork $item): array { $itemSlug = Str::slug((string) ($item->slug ?: $item->title)); if ($itemSlug === '') { @@ -201,7 +183,7 @@ final class ArtworkPageController extends Controller 'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0), 'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]), 'thumb' => $sm['url'] ?? null, - 'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w', + 'thumb_srcset' => ($sm['url'] ?? '').' 320w, '.($md['url'] ?? '').' 640w', ], $item, request()->user()); }) ->values() @@ -210,20 +192,19 @@ final class ArtworkPageController extends Controller $canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped'); $viewerId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null; - return Inertia::render('ArtworkPage', [ - 'artwork' => $artworkData, - 'navigation' => $navigationData, - 'presentMd' => $thumbMd, - 'presentLg' => $thumbLg, - 'presentXl' => $thumbXl, - 'presentSq' => $thumbSq, - 'related' => $related, - 'canonicalUrl' => $canonical, - 'groupSummary' => $groupSummary, + 'artwork' => $artworkData, + 'navigation' => $navigationData, + 'presentMd' => $thumbMd, + 'presentLg' => $thumbLg, + 'presentXl' => $thumbXl, + 'presentSq' => $thumbSq, + 'related' => $related, + 'canonicalUrl' => $canonical, + 'groupSummary' => $groupSummary, 'isAuthenticated' => $viewerId !== null, - 'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId), - 'seo' => $seo, + 'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId), + 'seo' => $seo, ])->rootView('artworks.show'); } @@ -245,7 +226,7 @@ final class ArtworkPageController extends Controller $totals = []; foreach (ReactionType::cases() as $type) { - $slug = $type->value; + $slug = $type->value; $count = (int) ($rows[$slug]->total ?? 0); $mine = false; @@ -261,7 +242,7 @@ final class ArtworkPageController extends Controller 'emoji' => $type->emoji(), 'label' => $type->label(), 'count' => $count, - 'mine' => $mine, + 'mine' => $mine, ]; } @@ -282,7 +263,7 @@ final class ArtworkPageController extends Controller ->values(); if ($missingParentIds->isNotEmpty()) { - $fetchedParents = \App\Models\Category::query() + $fetchedParents = Category::query() ->with('contentType') ->whereIn('id', $missingParentIds->all()) ->get() @@ -308,12 +289,7 @@ final class ArtworkPageController extends Controller */ private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array { - $primaryCategory = $artwork->categories - ->sortBy(fn ($category) => [ - (int) ($category->sort_order ?? 0), - (string) ($category->name ?? ''), - ]) - ->first(); + $primaryCategory = $artwork->resolvedPrimaryCategory(); if ($primaryCategory === null) { return [ @@ -338,7 +314,7 @@ final class ArtworkPageController extends Controller if ($contentTypeSlug !== '' && $contentTypeName !== '') { $breadcrumbs[] = [ 'name' => $contentTypeName, - 'url' => url('/' . $contentTypeSlug), + 'url' => url('/'.$contentTypeSlug), ]; } @@ -355,7 +331,7 @@ final class ArtworkPageController extends Controller $pathSegments[] = $slug; $breadcrumbs[] = [ 'name' => $name, - 'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)), + 'url' => url('/'.$contentTypeSlug.'/'.implode('/', $pathSegments)), ]; } diff --git a/app/Http/Requests/Artworks/ArtworkCreateRequest.php b/app/Http/Requests/Artworks/ArtworkCreateRequest.php index ddfe0478..e412ad82 100644 --- a/app/Http/Requests/Artworks/ArtworkCreateRequest.php +++ b/app/Http/Requests/Artworks/ArtworkCreateRequest.php @@ -27,6 +27,9 @@ final class ArtworkCreateRequest extends FormRequest 'title' => 'required|string|max:150', 'description' => 'nullable|string', 'category' => 'nullable|integer|exists:categories,id', + 'primary_category_id' => 'nullable|integer|exists:categories,id', + 'secondary_category_ids' => 'nullable|array|max:'.(int) config('categories.max_secondary_categories', 5), + 'secondary_category_ids.*' => 'integer|exists:categories,id', 'tags' => 'nullable|string|max:200', 'license' => 'nullable|boolean', 'is_mature' => 'nullable|boolean', @@ -45,7 +48,7 @@ final class ArtworkCreateRequest extends FormRequest private function denyAsNotFound(): void { - throw new NotFoundHttpException(); + throw new NotFoundHttpException; } private function logUnauthorized(string $reason): void diff --git a/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php b/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php index de16ee00..c0d06113 100644 --- a/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php +++ b/app/Http/Requests/Studio/ApplyArtworkAiAssistRequest.php @@ -23,10 +23,13 @@ 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:' . (int) config('tags.max_user_tags', 30)], + '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'], + 'primary_category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'], + 'secondary_category_ids' => ['sometimes', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)], + 'secondary_category_ids.*' => ['integer', 'exists:categories,id'], 'content_type_id' => ['sometimes', 'nullable', 'integer', 'exists:content_types,id'], 'similar_actions' => ['sometimes', 'array', 'max:10'], 'similar_actions.*.artwork_id' => ['required_with:similar_actions', 'integer'], @@ -42,4 +45,4 @@ final class ApplyArtworkAiAssistRequest extends FormRequest } }); } -} \ No newline at end of file +} diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index 91a682a2..3a1532fe 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -1,9 +1,10 @@ resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world', 'worldRewardGrants.world']); + $this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world']); $md = ThumbnailPresenter::present($this->resource, 'md'); $lg = ThumbnailPresenter::present($this->resource, 'lg'); @@ -31,18 +33,16 @@ class ArtworkResource extends JsonResource $sq = ThumbnailPresenter::present($this->resource, 'sq'); $screenshots = $this->resolveScreenshotAssets(); - $canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title)); + $canonicalSlug = Str::slug((string) ($this->slug ?: $this->title)); if ($canonicalSlug === '') { $canonicalSlug = (string) $this->id; } $followerCount = 0; - if (!empty($this->user?->id)) { - if ($this->hasTable('user_statistics')) { - $followerCount = (int) DB::table('user_statistics') - ->where('user_id', (int) $this->user->id) - ->value('followers_count'); - } + if (! empty($this->user?->id)) { + $followerCount = (int) DB::table('user_statistics') + ->where('user_id', (int) $this->user->id) + ->value('followers_count'); // Legacy fallback for environments where new tables are unavailable. if (($followerCount <= 0) && $this->hasTable('friends_list')) { @@ -61,9 +61,9 @@ class ArtworkResource extends JsonResource $viewerAward = null; $isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0); - $bookmarksCount = $this->hasTable('artwork_bookmarks') - ? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count() - : 0; + $bookmarksCount = (int) DB::table('artwork_bookmarks') + ->where('artwork_id', (int) $this->id) + ->count(); if ($viewerId > 0) { if ($this->hasTable('artwork_likes')) { @@ -73,19 +73,17 @@ class ArtworkResource extends JsonResource ->exists(); } - if ($this->hasTable('artwork_bookmarks')) { - $isBookmarked = DB::table('artwork_bookmarks') - ->where('user_id', $viewerId) - ->where('artwork_id', (int) $this->id) - ->exists(); - } + $isBookmarked = DB::table('artwork_bookmarks') + ->where('user_id', $viewerId) + ->where('artwork_id', (int) $this->id) + ->exists(); $isFavorited = DB::table('artwork_favourites') ->where('user_id', $viewerId) ->where('artwork_id', (int) $this->id) ->exists(); - if (!empty($this->user?->id)) { + if (! empty($this->user?->id)) { if ($this->hasTable('user_followers')) { $isFollowing = DB::table('user_followers') ->where('user_id', (int) $this->user->id) @@ -100,7 +98,7 @@ class ArtworkResource extends JsonResource } } - if (!empty($this->group?->id) && $this->hasTable('group_follows')) { + if (! empty($this->group?->id) && $this->hasTable('group_follows')) { $isFollowingGroup = DB::table('group_follows') ->where('group_id', (int) $this->group->id) ->where('user_id', $viewerId) @@ -125,7 +123,7 @@ class ArtworkResource extends JsonResource 'id' => (int) ($user->id ?? 0), 'name' => html_entity_decode((string) ($user->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'username' => (string) ($user->username ?? ''), - 'profile_url' => ! empty($user->username) ? '/@' . $user->username : null, + 'profile_url' => ! empty($user->username) ? '/@'.$user->username : null, 'avatar_url' => $user->profile?->avatar_url, ]; }; @@ -150,7 +148,7 @@ class ArtworkResource extends JsonResource 'slug' => (string) ($this->user?->username ?? ''), 'headline' => '', 'avatar_url' => $this->user?->profile?->avatar_url, - 'profile_url' => $this->user?->username ? '/@' . $this->user->username : null, + 'profile_url' => $this->user?->username ? '/@'.$this->user->username : null, 'followers_count' => $followerCount, 'follow_url' => null, 'unfollow_url' => null, @@ -179,6 +177,8 @@ class ArtworkResource extends JsonResource 'title' => $decode($this->title), 'description' => $decode($this->description), 'description_html' => $this->renderDescriptionHtml(), + 'download_status' => (string) ($this->download_status ?? 'unknown'), + 'download_source' => $this->download_source, 'dimensions' => [ 'width' => (int) ($this->width ?? 0), 'height' => (int) ($this->height ?? 0), @@ -201,7 +201,7 @@ class ArtworkResource extends JsonResource 'id' => (int) ($this->user?->id ?? 0), 'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'username' => (string) ($this->user?->username ?? ''), - 'profile_url' => $this->user?->username ? '/@' . $this->user->username : null, + 'profile_url' => $this->user?->username ? '/@'.$this->user->username : null, 'avatar_url' => $this->user?->profile?->avatar_url, 'level' => (int) ($this->user?->level ?? 1), 'rank' => (string) ($this->user?->rank ?? 'Newbie'), @@ -258,20 +258,40 @@ class ArtworkResource extends JsonResource 'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()), 'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()), 'world_participation' => $this->resolveWorldParticipation(), - 'categories' => $this->categories->map(fn ($category) => [ - 'id' => (int) $category->id, - 'slug' => (string) $category->slug, - 'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), - 'content_type_slug' => (string) ($category->contentType?->slug ?? ''), - 'url' => $category->contentType ? $category->url : null, - 'parent' => $category->parent ? [ - 'id' => (int) $category->parent->id, - 'slug' => (string) $category->parent->slug, - 'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), - 'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''), - 'url' => $category->parent->contentType ? $category->parent->url : null, - ] : null, - ])->values(), + 'category' => ($primary = $this->resolvedPrimaryCategory()) ? [ + 'id' => (int) $primary->id, + 'slug' => (string) $primary->slug, + 'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'content_type_slug' => (string) ($primary->contentType?->slug ?? ''), + 'url' => $primary->contentType ? $primary->url : null, + 'is_primary' => true, + ] : null, + 'primary_category' => ($primary = $this->resolvedPrimaryCategory()) ? [ + 'id' => (int) $primary->id, + 'slug' => (string) $primary->slug, + 'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'content_type_slug' => (string) ($primary->contentType?->slug ?? ''), + 'url' => $primary->contentType ? $primary->url : null, + ] : null, + 'categories' => $this->categories->map(function ($category) { + $primaryId = (int) ($this->primary_category_id ?? 0); + + return [ + 'id' => (int) $category->id, + 'slug' => (string) $category->slug, + 'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'content_type_slug' => (string) ($category->contentType?->slug ?? ''), + 'url' => $category->contentType ? $category->url : null, + 'is_primary' => $primaryId > 0 && (int) $category->id === $primaryId, + 'parent' => $category->parent ? [ + 'id' => (int) $category->parent->id, + 'slug' => (string) $category->parent->slug, + 'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''), + 'url' => $category->parent->contentType ? $category->parent->url : null, + ] : null, + ]; + })->values(), 'tags' => $this->tags->map(fn ($tag) => [ 'id' => (int) $tag->id, 'slug' => (string) $tag->slug, @@ -282,10 +302,6 @@ class ArtworkResource extends JsonResource private function resolveScreenshotAssets(): array { - if (! $this->hasTable('artwork_files')) { - return []; - } - return DB::table('artwork_files') ->where('artwork_id', (int) $this->id) ->where('variant', 'like', 'shot%') @@ -296,9 +312,9 @@ class ArtworkResource extends JsonResource $url = $this->objectUrl($path); return [ - 'id' => (string) ($row->variant ?? ('shot' . ($index + 1))), + 'id' => (string) ($row->variant ?? ('shot'.($index + 1))), 'variant' => (string) ($row->variant ?? ''), - 'label' => 'Screenshot ' . ($index + 1), + 'label' => 'Screenshot '.($index + 1), 'url' => $url, 'thumb_url' => $url, 'mime_type' => (string) ($row->mime ?? 'image/jpeg'), @@ -319,7 +335,7 @@ class ArtworkResource extends JsonResource $base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'); - return $base . '/' . $trimmedPath; + return $base.'/'.$trimmedPath; } private function renderDescriptionHtml(): string @@ -342,73 +358,75 @@ class ArtworkResource extends JsonResource $items = collect(); $participationWorlds = collect(); - if ($this->hasTable('world_relations') && $this->hasTable('worlds')) { - $relations = WorldRelation::query() - ->with('world') - ->where('related_type', WorldRelation::TYPE_ARTWORK) - ->where('related_id', (int) $this->id) - ->get() - ->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible()) - ->values(); + $relations = WorldRelation::query() + ->with('world') + ->where('related_type', WorldRelation::TYPE_ARTWORK) + ->where('related_id', (int) $this->id) + ->get() + ->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible()) + ->values(); - $participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter()); + $participationWorlds = $participationWorlds->concat( + $relations->pluck('world')->filter() + ); - $items = $items->concat( - $relations->map(function (WorldRelation $relation): array { - $world = $relation->world; + $items = $items->concat( + $relations->map(function (WorldRelation $relation): array { + $world = $relation->world; - return [ - 'world_id' => (int) $relation->world_id, - 'world_title' => (string) $world->title, - 'world_slug' => (string) $world->slug, - 'world_url' => $world->publicUrl(), - 'badge_label' => 'Part of ' . $world->title, - 'status' => 'curated', - 'status_label' => 'Curated', - 'tone' => 'curated', - 'sort_priority' => 1, - ]; - }) - ); - } + return [ + 'world_id' => (int) $relation->world_id, + 'world_title' => (string) $world->title, + 'world_slug' => (string) $world->slug, + 'world_url' => $world->publicUrl(), + 'badge_label' => 'Part of '.$world->title, + 'status' => 'curated', + 'status_label' => 'Curated', + 'tone' => 'curated', + 'sort_priority' => 1, + ]; + }) + ); - if ($this->hasTable('world_submissions')) { - $liveSubmissions = $this->worldSubmissions - ->filter(function (WorldSubmission $submission): bool { - return (string) $submission->status === WorldSubmission::STATUS_LIVE - && $submission->world !== null - && $submission->world->isPubliclyVisible(); - }) - ->values(); + $liveSubmissions = $this->worldSubmissions + ->filter(function (WorldSubmission $submission): bool { + return (string) $submission->status === WorldSubmission::STATUS_LIVE + && $submission->world !== null + && $submission->world->isPubliclyVisible(); + }) + ->values(); - $participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter()); - World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all()); + $participationWorlds = $participationWorlds->concat( + $liveSubmissions->pluck('world')->filter() + ); - $items = $items->concat( - $liveSubmissions->map(function (WorldSubmission $submission): array { - $world = $submission->world; - $isFeatured = (bool) $submission->is_featured; + World::primeCanonicalEditionIds( + $participationWorlds->pluck('recurrence_key')->all() + ); - return [ - 'world_id' => (int) $submission->world_id, - 'world_title' => (string) $world->title, - 'world_slug' => (string) $world->slug, - 'world_url' => $world->publicUrl(), - 'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title, - 'status' => (string) $submission->status, - 'status_label' => $isFeatured ? 'Featured' : 'Community submission', - 'tone' => $isFeatured ? 'featured' : 'community', - 'sort_priority' => $isFeatured ? 0 : 2, - ]; - }) - ); - } elseif ($participationWorlds->isNotEmpty()) { - World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all()); - } + $items = $items->concat( + $liveSubmissions->map(function (WorldSubmission $submission): array { + $world = $submission->world; + $isFeatured = (bool) $submission->is_featured; - if ($this->hasTable('world_reward_grants')) { - $items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource)); - } + return [ + 'world_id' => (int) $submission->world_id, + 'world_title' => (string) $world->title, + 'world_slug' => (string) $world->slug, + 'world_url' => $world->publicUrl(), + 'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ').$world->title, + 'status' => (string) $submission->status, + 'status_label' => $isFeatured ? 'Featured' : 'Community submission', + 'tone' => $isFeatured ? 'featured' : 'community', + 'sort_priority' => $isFeatured ? 0 : 2, + ]; + }) + ); + + $items = $items->concat( + app(WorldRewardService::class) + ->artworkRewardBadges($this->resource) + ); return $items ->sortBy('sort_priority') diff --git a/app/Jobs/IngestUserDiscoveryEventJob.php b/app/Jobs/IngestUserDiscoveryEventJob.php index 4b10207c..8dfe9b84 100644 --- a/app/Jobs/IngestUserDiscoveryEventJob.php +++ b/app/Jobs/IngestUserDiscoveryEventJob.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Jobs; -use App\Services\Recommendations\UserInterestProfileService; use App\Services\Recommendations\SessionRecoService; +use App\Services\Recommendations\UserInterestProfileService; use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -30,7 +30,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue public array $backoff = [5, 30, 120]; /** - * @param array $meta + * @param array $meta */ public function __construct( public readonly string $eventId, @@ -40,8 +40,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue public readonly string $algoVersion, public readonly string $occurredAt, public readonly array $meta = [] - ) { - } + ) {} public function handle(UserInterestProfileService $profileService, SessionRecoService $sessionRecoService): void { @@ -70,10 +69,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue $eventVersion = (string) config('discovery.event_version', 'event-v1'); $eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0; - $categoryId = DB::table('artwork_category') - ->where('artwork_id', $this->artworkId) - ->orderBy('category_id') - ->value('category_id'); + $categoryId = DB::table('artworks') + ->where('id', $this->artworkId) + ->value('primary_category_id'); $insertPayload = [ 'event_id' => $this->eventId, diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 14227620..761d5f87 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -66,6 +66,9 @@ class Artwork extends Model 'description', 'file_name', 'file_path', + 'download_status', + 'download_source', + 'download_checked_at', 'hash', 'file_ext', 'thumb_ext', @@ -81,6 +84,10 @@ class Artwork extends Model 'is_public', 'visibility', 'is_approved', + 'approval_source', + 'moderated_at', + 'moderated_by', + 'moderation_note', 'is_mature', 'maturity_level', 'maturity_source', @@ -117,6 +124,7 @@ class Artwork extends Model 'description_source', 'tags_source', 'category_source', + 'primary_category_id', // Versioning 'current_version_id', 'version_count', @@ -132,6 +140,7 @@ class Artwork extends Model 'is_public' => 'boolean', 'visibility' => 'string', 'is_approved' => 'boolean', + 'moderated_at' => 'datetime', 'is_mature' => 'boolean', 'maturity_level' => 'string', 'maturity_source' => 'string', @@ -171,6 +180,7 @@ class Artwork extends Model 'category_source' => 'string', 'version_updated_at' => 'datetime', 'requires_reapproval' => 'boolean', + 'download_checked_at' => 'datetime', ]; /** @@ -449,6 +459,32 @@ class Artwork extends Model return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id'); } + public function primaryCategory(): BelongsTo + { + return $this->belongsTo(Category::class, 'primary_category_id'); + } + + public function resolvedPrimaryCategory(): ?Category + { + if ($this->relationLoaded('primaryCategory') && $this->getRelation('primaryCategory') instanceof Category) { + return $this->getRelation('primaryCategory'); + } + + $primaryId = $this->primary_category_id !== null ? (int) $this->primary_category_id : 0; + if ($primaryId > 0 && $this->relationLoaded('categories')) { + $match = $this->categories->firstWhere('id', $primaryId); + if ($match instanceof Category) { + return $match; + } + } + + if ($primaryId > 0) { + return $this->primaryCategory; + } + + return null; + } + public function collections(): BelongsToMany { return $this->belongsToMany(Collection::class, 'collection_artwork', 'artwork_id', 'collection_id') @@ -559,19 +595,25 @@ class Artwork extends Model */ public function toSearchableArray(): array { - $this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']); + $this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'primaryCategory.contentType', 'stats', 'awardStat']); $stat = $this->stats; $awardStat = $this->awardStat; $publishedSortAt = $this->published_at ?? $this->created_at; - $sortedCategories = $this->categories->sortBy( - fn ($category) => sprintf( - '%010d|%s|%010d', - (int) ($category->sort_order ?? 999999999), - strtolower((string) ($category->name ?? '')), - (int) ($category->id ?? 0) - ) - )->values(); + $primaryCategory = $this->resolvedPrimaryCategory(); + $sortedCategories = $this->categories + ->sortBy(function ($category) use ($primaryCategory) { + $isPrimary = $primaryCategory && (int) $category->id === (int) $primaryCategory->id; + + return sprintf( + '%d|%010d|%s|%010d', + $isPrimary ? 0 : 1, + (int) ($category->sort_order ?? 999999999), + strtolower((string) ($category->name ?? '')), + (int) ($category->id ?? 0) + ); + }) + ->values(); // Orientation derived from pixel dimensions $orientation = 'square'; @@ -588,8 +630,6 @@ class Artwork extends Model ? $this->width.'x'.$this->height : ''; - // Primary category slug follows the same sort_order-first semantics used by page presenters. - $primaryCategory = $sortedCategories->first(); $categorySlugs = $sortedCategories ->pluck('slug') ->filter() @@ -619,6 +659,13 @@ class Artwork extends Model 'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase', 'published_as_type' => $this->publishedAsType(), 'category' => $category, + 'primary_category_id' => $primaryCategory?->id ? (int) $primaryCategory->id : null, + 'category_ids' => $this->categories + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->unique() + ->values() + ->all(), 'categories' => $categorySlugs, 'content_type' => $content_type, 'content_types' => $contentTypeSlugs, diff --git a/app/Models/Upload.php b/app/Models/Upload.php index 96fcf494..8c4d0171 100644 --- a/app/Models/Upload.php +++ b/app/Models/Upload.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class Upload extends Model { @@ -23,6 +24,7 @@ class Upload extends Model 'title', 'slug', 'category_id', + 'secondary_category_ids', 'description', 'tags', 'license', @@ -41,6 +43,7 @@ class Upload extends Model protected $casts = [ 'tags' => 'array', + 'secondary_category_ids' => 'array', 'nsfw' => 'boolean', 'is_scanned' => 'boolean', 'has_tags' => 'boolean', @@ -48,4 +51,14 @@ class Upload extends Model 'expires_at' => 'datetime', 'moderated_at' => 'datetime', ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } } diff --git a/app/Services/Artworks/ArtworkCategoryService.php b/app/Services/Artworks/ArtworkCategoryService.php new file mode 100644 index 00000000..0cc39e20 --- /dev/null +++ b/app/Services/Artworks/ArtworkCategoryService.php @@ -0,0 +1,201 @@ +|null $secondaryCategoryIds null keeps existing secondaries that remain compatible + */ + public function sync( + Artwork $artwork, + ?int $primaryCategoryId, + ?array $secondaryCategoryIds = [], + ?string $source = null, + bool $reindex = true, + ): void { + $primaryCategoryId = $primaryCategoryId !== null && $primaryCategoryId > 0 ? $primaryCategoryId : null; + $secondaryCategoryIds = $secondaryCategoryIds === null + ? $this->existingSecondaryIds($artwork, $primaryCategoryId) + : $this->normalizeIds($secondaryCategoryIds); + + if ($primaryCategoryId === null) { + if ($secondaryCategoryIds !== []) { + throw ValidationException::withMessages([ + 'primary_category_id' => ['A primary category is required when additional categories are selected.'], + ]); + } + + $this->persist($artwork, null, [], $source, $reindex); + + return; + } + + $secondaryCategoryIds = array_values(array_filter( + $secondaryCategoryIds, + static fn (int $id): bool => $id !== $primaryCategoryId, + )); + + if (count($secondaryCategoryIds) > $this->maxSecondary()) { + throw ValidationException::withMessages([ + 'secondary_category_ids' => [sprintf('You can add at most %d additional categories.', $this->maxSecondary())], + ]); + } + + $requestedIds = array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds])); + $categories = Category::query() + ->whereIn('id', $requestedIds) + ->get() + ->keyBy('id'); + + if ($categories->count() !== count($requestedIds)) { + throw ValidationException::withMessages([ + 'primary_category_id' => ['One or more selected categories do not exist.'], + ]); + } + + $primary = $categories->get($primaryCategoryId); + if (! $primary) { + throw ValidationException::withMessages([ + 'primary_category_id' => ['Choose a valid primary category.'], + ]); + } + + if (! $primary->is_active) { + throw ValidationException::withMessages([ + 'primary_category_id' => ['The primary category is not available.'], + ]); + } + + $contentTypeId = (int) $primary->content_type_id; + + foreach ($secondaryCategoryIds as $secondaryId) { + $category = $categories->get($secondaryId); + if (! $category || ! $category->is_active) { + throw ValidationException::withMessages([ + 'secondary_category_ids' => ['One or more additional categories are not available.'], + ]); + } + + if ((int) $category->content_type_id !== $contentTypeId) { + throw ValidationException::withMessages([ + 'secondary_category_ids' => ['Additional categories must belong to the same content type as the primary category.'], + ]); + } + } + + $this->persist($artwork, $primaryCategoryId, $secondaryCategoryIds, $source, $reindex); + } + + /** + * @return array + */ + public function presentCategory(Category $category, bool $isPrimary): array + { + return [ + 'id' => (int) $category->id, + 'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'slug' => (string) $category->slug, + 'content_type_slug' => (string) ($category->contentType?->slug ?? ''), + 'url' => $category->contentType ? $category->url : null, + 'is_primary' => $isPrimary, + 'parent' => $category->parent ? [ + 'id' => (int) $category->parent->id, + 'slug' => (string) $category->parent->slug, + 'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''), + 'url' => $category->parent->contentType ? $category->parent->url : null, + ] : null, + ]; + } + + /** + * @param list $ids + * @return list + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + $value = (int) $id; + if ($value > 0 && ! in_array($value, $normalized, true)) { + $normalized[] = $value; + } + } + + return $normalized; + } + + /** + * @return list + */ + private function existingSecondaryIds(Artwork $artwork, ?int $primaryCategoryId): array + { + return $artwork->categories() + ->pluck('categories.id') + ->map(static fn ($id): int => (int) $id) + ->reject(static fn (int $id): bool => $primaryCategoryId !== null && $id === $primaryCategoryId) + ->values() + ->all(); + } + + /** + * @param list $secondaryCategoryIds + */ + private function persist( + Artwork $artwork, + ?int $primaryCategoryId, + array $secondaryCategoryIds, + ?string $source, + bool $reindex, + ): void { + $membershipIds = $primaryCategoryId === null + ? [] + : array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds])); + + DB::transaction(function () use ($artwork, $primaryCategoryId, $membershipIds, $source): void { + $artwork->categories()->sync($membershipIds); + $artwork->primary_category_id = $primaryCategoryId; + + if ($source !== null) { + $artwork->category_source = $source; + } + + $artwork->save(); + }); + + $artwork->unsetRelation('categories'); + $artwork->unsetRelation('primaryCategory'); + + if (! $reindex) { + return; + } + + DB::afterCommit(function () use ($artwork): void { + $this->artworkService->clearArtworkCache($artwork->fresh() ?? $artwork); + Cache::forget('categories.directory.v1'); + $this->searchIndexer->update($artwork); + }); + } +} diff --git a/app/Services/Artworks/ArtworkDraftService.php b/app/Services/Artworks/ArtworkDraftService.php index 4496d483..441c85dd 100644 --- a/app/Services/Artworks/ArtworkDraftService.php +++ b/app/Services/Artworks/ArtworkDraftService.php @@ -17,12 +17,16 @@ final class ArtworkDraftService { public function __construct( private readonly GroupService $groups, + private readonly ArtworkCategoryService $categories, ) { } - public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null): ArtworkDraftResult + /** + * @param list $secondaryCategoryIds + */ + public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null, array $secondaryCategoryIds = []): ArtworkDraftResult { - return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier) { + return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier, $secondaryCategoryIds) { $slug = $this->makeSlug($title); $group = $this->resolveGroup($user, $groupIdentifier); @@ -50,9 +54,8 @@ final class ArtworkDraftService 'artwork_status' => 'draft', ]); - // Attach the selected category to the artwork pivot table - if ($categoryId !== null && \App\Models\Category::where('id', $categoryId)->exists()) { - $artwork->categories()->sync([$categoryId]); + if ($categoryId !== null || $secondaryCategoryIds !== []) { + $this->categories->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false); } if ($group) { diff --git a/app/Services/CollectionAiCurationService.php b/app/Services/CollectionAiCurationService.php index bc4cacee..1fdbe1a7 100644 --- a/app/Services/CollectionAiCurationService.php +++ b/app/Services/CollectionAiCurationService.php @@ -140,7 +140,7 @@ class CollectionAiCurationService ->first(); $label = $tag?->name - ?: ($artwork->categories->first()?->name) + ?: ($artwork->resolvedPrimaryCategory()?->name) ?: ($artwork->stats?->views ? 'Popular highlights' : 'Curated picks'); if (! isset($themeBuckets[$label])) { @@ -673,7 +673,7 @@ class CollectionAiCurationService $artworks = $this->candidateArtworks($collection, $draft, 36); $themes = $this->topThemes($artworks); $categories = $artworks - ->map(fn (Artwork $artwork) => $artwork->categories->first()?->name) + ->map(fn (Artwork $artwork) => $artwork->resolvedPrimaryCategory()?->name) ->filter() ->countBy() ->sortDesc(); diff --git a/app/Services/CollectionLinkService.php b/app/Services/CollectionLinkService.php index 44677a2c..1a09042b 100644 --- a/app/Services/CollectionLinkService.php +++ b/app/Services/CollectionLinkService.php @@ -95,7 +95,7 @@ class CollectionLinkService 'label' => (string) $artwork->title, 'description' => collect([ $artwork->user?->username ? '@' . strtolower((string) $artwork->user->username) : null, - $artwork->categories->first()?->contentType?->name, + $artwork->resolvedPrimaryCategory()?->contentType?->name, ])->filter()->join(' • ') ?: 'Published artwork', ]) ->values() @@ -343,7 +343,7 @@ class CollectionLinkService 'title' => (string) $entity->title, 'subtitle' => collect([ $entity->user?->username ? '@' . strtolower((string) $entity->user->username) : null, - $entity->categories->first()?->contentType?->name, + $entity->resolvedPrimaryCategory()?->contentType?->name, ])->filter()->join(' • ') ?: 'Artwork', 'description' => $link->relationship_type ?: 'Linked artwork', 'url' => route('art.show', [ diff --git a/app/Services/CollectionService.php b/app/Services/CollectionService.php index c46edc07..8bfadc2b 100644 --- a/app/Services/CollectionService.php +++ b/app/Services/CollectionService.php @@ -861,6 +861,14 @@ class CollectionService ? $collections : new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections)); + // The profile producer already eager-loads these relations. Keep the + // mapper safe for direct callers as well, so the card loop never + // triggers relation lazy-loading one collection at a time. + $collectionList->loadMissing([ + 'user:id,username,name', + 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', + ]); + $collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all(); $hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer); @@ -879,12 +887,16 @@ class CollectionService ->all() : []; - return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) { + return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds, $hideMatureCovers) { + // Manual collections have their explicit cover eager-loaded. The + // already bulk-loaded first artwork map is the exact fallback + // used by resolvedCoverArtwork() when that cover is absent or not + // visible, so no per-card relation query is needed here. $resolvedCover = $collection->isSmart() ? $this->smartCollections->firstArtwork($collection, $ownerView) - : $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer)); + : ($collection->relationLoaded('coverArtwork') ? $collection->coverArtwork : null); $fallbackCover = $firstArtworkMap->get((int) $collection->id); - $cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer)) + $cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, $hideMatureCovers) ? $resolvedCover : $fallbackCover; $summary = $collection->summary ?? $collection->description; @@ -1264,7 +1276,7 @@ class CollectionService private function mapArtworkPayload(Artwork $artwork, array $extra = []): array { - $category = $artwork->categories->first(); + $category = $artwork->resolvedPrimaryCategory(); $contentType = $category?->contentType; $stats = $artwork->stats; diff --git a/app/Services/GroupArtworkReviewService.php b/app/Services/GroupArtworkReviewService.php index 63eef26a..905ae010 100644 --- a/app/Services/GroupArtworkReviewService.php +++ b/app/Services/GroupArtworkReviewService.php @@ -10,6 +10,7 @@ use App\Models\Group; use App\Jobs\IndexArtworkJob; use App\Models\Tag; use App\Models\User; +use App\Services\Artworks\ArtworkCategoryService; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -23,6 +24,7 @@ class GroupArtworkReviewService private readonly GroupHistoryService $history, private readonly NotificationService $notifications, private readonly GroupMembershipService $memberships, + private readonly ArtworkCategoryService $artworkCategories, ) { } @@ -108,6 +110,10 @@ class GroupArtworkReviewService 'group_reviewed_at' => now(), 'group_review_notes' => $notes, 'is_approved' => true, + 'approval_source' => 'moderator', + 'moderated_at' => now(), + 'moderated_by' => $actor->id, + 'moderation_note' => $notes, 'artwork_status' => 'published', 'published_at' => now(), 'publish_at' => null, @@ -363,9 +369,20 @@ class GroupArtworkReviewService $artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id; $artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id; - $categoryId = isset($validated['category']) ? (int) $validated['category'] : null; - if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) { - $artwork->categories()->sync([$categoryId]); + $categoryId = isset($validated['primary_category_id']) + ? (int) $validated['primary_category_id'] + : (isset($validated['category']) ? (int) $validated['category'] : null); + $secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated) + ? array_values(array_map('intval', (array) $validated['secondary_category_ids'])) + : []; + if ($categoryId > 0) { + $this->artworkCategories->sync( + $artwork, + $categoryId, + $secondaryCategoryIds, + 'user', + false, + ); } if (array_key_exists('tags', $validated) && is_array($validated['tags'])) { @@ -395,4 +412,4 @@ class GroupArtworkReviewService { IndexArtworkJob::dispatch((int) $artwork->id); } -} \ No newline at end of file +} diff --git a/app/Services/RSS/RSSFeedBuilder.php b/app/Services/RSS/RSSFeedBuilder.php index df356c01..b3d80634 100644 --- a/app/Services/RSS/RSSFeedBuilder.php +++ b/app/Services/RSS/RSSFeedBuilder.php @@ -87,7 +87,7 @@ final class RSSFeedBuilder // Primary category from eagerly loaded relation (avoid N+1) $primaryCategory = ($artwork->relationLoaded('categories')) - ? $artwork->categories->first() + ? $artwork->resolvedPrimaryCategory() : null; // Build HTML description embedded in CDATA diff --git a/app/Services/Studio/Providers/ArtworkStudioProvider.php b/app/Services/Studio/Providers/ArtworkStudioProvider.php index 2f657c06..2cdf26a0 100644 --- a/app/Services/Studio/Providers/ArtworkStudioProvider.php +++ b/app/Services/Studio/Providers/ArtworkStudioProvider.php @@ -201,7 +201,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider ? 'scheduled' : ((bool) $artwork->is_public ? 'published' : 'draft')); - $category = $artwork->categories->first(); + $category = $artwork->resolvedPrimaryCategory(); $visibility = $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE); return [ diff --git a/app/Services/Studio/StudioAiAssistService.php b/app/Services/Studio/StudioAiAssistService.php index 72723199..b664d73f 100644 --- a/app/Services/Studio/StudioAiAssistService.php +++ b/app/Services/Studio/StudioAiAssistService.php @@ -211,9 +211,13 @@ final class StudioAiAssistService } $categoryId = $this->resolveCategoryId($payload); + $secondaryCategoryIds = array_key_exists('secondary_category_ids', $payload) + ? array_values(array_map('intval', (array) $payload['secondary_category_ids'])) + : null; if ($categoryId !== null) { - $artwork->categories()->sync([$categoryId]); + app(\App\Services\Artworks\ArtworkCategoryService::class) + ->sync($artwork, $categoryId, $secondaryCategoryIds, 'ai_applied', false); $artwork->category_source = 'ai_applied'; $updated = true; $applied[] = 'category'; @@ -294,10 +298,10 @@ final class StudioAiAssistService */ public function payloadFor(Artwork $artwork): array { - $artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']); + $artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType', 'primaryCategory.contentType']); $assist = $artwork->artworkAiAssist; - $primaryCategory = $artwork->categories->first(); + $primaryCategory = $artwork->resolvedPrimaryCategory(); if (! $assist) { return [ @@ -515,6 +519,13 @@ final class StudioAiAssistService 'description' => (string) ($artwork->description ?? ''), 'tags' => $artwork->tags->pluck('slug')->values()->all(), 'category_id' => $primaryCategory?->id, + 'primary_category_id' => $primaryCategory?->id, + 'secondary_category_ids' => $artwork->categories + ->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0)) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->values() + ->all(), 'content_type_id' => $primaryCategory?->contentType?->id, 'sources' => [ 'title' => $artwork->title_source ?: 'manual', @@ -530,6 +541,10 @@ final class StudioAiAssistService */ private function resolveCategoryId(array $payload): ?int { + if (isset($payload['primary_category_id']) && $payload['primary_category_id'] !== null) { + return (int) $payload['primary_category_id']; + } + if (isset($payload['category_id']) && $payload['category_id'] !== null) { return (int) $payload['category_id']; } diff --git a/app/Services/Studio/StudioAiCategoryMapper.php b/app/Services/Studio/StudioAiCategoryMapper.php index e59053d2..ca400a0b 100644 --- a/app/Services/Studio/StudioAiCategoryMapper.php +++ b/app/Services/Studio/StudioAiCategoryMapper.php @@ -49,8 +49,9 @@ final class StudioAiCategoryMapper $selectedCategory, $this->confidenceForModel($categoryScores, $selectedCategory->id), $categoryScores - ->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id) - ->take(3) + ->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id + || (int) $row['model']->content_type_id !== (int) $selectedCategory->content_type_id) + ->take(max(0, (int) config('categories.max_secondary_categories', 5))) ->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence'])) ->all() ) : null, @@ -245,6 +246,7 @@ final class StudioAiCategoryMapper 'root_category_id' => (int) $rootCategory->id, 'sub_category_id' => $category->parent_id ? (int) $category->id : null, 'alternatives' => array_values($alternatives), + 'secondary_categories' => array_values($alternatives), ]; } } \ No newline at end of file diff --git a/app/Services/Studio/StudioBulkActionService.php b/app/Services/Studio/StudioBulkActionService.php index d0f6e99c..d2dc105f 100644 --- a/app/Services/Studio/StudioBulkActionService.php +++ b/app/Services/Studio/StudioBulkActionService.php @@ -117,7 +117,8 @@ final class StudioBulkActionService throw new \InvalidArgumentException('category_id required for change_category'); } - $artwork->categories()->sync([(int) $params['category_id']]); + app(\App\Services\Artworks\ArtworkCategoryService::class) + ->sync($artwork, (int) $params['category_id'], [], 'moderator'); } private function addTags(Artwork $artwork, array $params): void diff --git a/app/Services/Worlds/WorldSubmissionService.php b/app/Services/Worlds/WorldSubmissionService.php index 1089bbc8..c0bc46a5 100644 --- a/app/Services/Worlds/WorldSubmissionService.php +++ b/app/Services/Worlds/WorldSubmissionService.php @@ -608,7 +608,7 @@ final class WorldSubmissionService 'thumbnail_url' => $artwork->thumbUrl('md'), 'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''), 'meta' => array_values(array_filter([ - $artwork->categories->first()?->name, + $artwork->resolvedPrimaryCategory()?->name, $views > 0 ? number_format($views) . ' views' : null, $artwork->visibility ? Str::headline((string) $artwork->visibility) : null, ])), diff --git a/config/categories.php b/config/categories.php new file mode 100644 index 00000000..4bc00cac --- /dev/null +++ b/config/categories.php @@ -0,0 +1,7 @@ + max(0, (int) env('CATEGORIES_MAX_SECONDARY', 5)), +]; diff --git a/database/migrations/2026_09_20_140000_add_primary_category_support_to_artworks.php b/database/migrations/2026_09_20_140000_add_primary_category_support_to_artworks.php new file mode 100644 index 00000000..f2434233 --- /dev/null +++ b/database/migrations/2026_09_20_140000_add_primary_category_support_to_artworks.php @@ -0,0 +1,46 @@ +unsignedBigInteger('primary_category_id')->nullable()->after('user_id'); + $table->index('primary_category_id'); + $table->foreign('primary_category_id') + ->references('id') + ->on('categories') + ->nullOnDelete(); + }); + } + + if (Schema::hasTable('uploads') && ! Schema::hasColumn('uploads', 'secondary_category_ids')) { + Schema::table('uploads', function (Blueprint $table): void { + $table->json('secondary_category_ids')->nullable()->after('category_id'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('artworks') && Schema::hasColumn('artworks', 'primary_category_id')) { + Schema::table('artworks', function (Blueprint $table): void { + $table->dropForeign(['primary_category_id']); + $table->dropColumn('primary_category_id'); + }); + } + + if (Schema::hasTable('uploads') && Schema::hasColumn('uploads', 'secondary_category_ids')) { + Schema::table('uploads', function (Blueprint $table): void { + $table->dropColumn('secondary_category_ids'); + }); + } + } +}; diff --git a/docs/SkinbaseNova_SN-CAT-001_Multi-Category_Artwork.md b/docs/SkinbaseNova_SN-CAT-001_Multi-Category_Artwork.md new file mode 100644 index 00000000..1b8ea8dc --- /dev/null +++ b/docs/SkinbaseNova_SN-CAT-001_Multi-Category_Artwork.md @@ -0,0 +1,1152 @@ +# SkinbaseNova — Multi-Category Artwork Classification + +**Milestone:** SN-CAT-001 +**Status:** Implemented +**Scope:** Categories, artwork classification, upload flow, Nova AI suggestions, API, SEO, migration, tests +**Date:** 2026-09-20 + +--- + +## 1. Objective + +Upgrade SkinbaseNova so that an artwork can belong to more than one **category** while preserving one unambiguous **primary category**. + +The final model must support: + +- exactly one content type per artwork; +- one primary category where a category is required; +- zero to five secondary categories by default; +- all selected categories belonging to the same content type as the artwork; +- existing canonical artwork URLs; +- deterministic breadcrumbs and SEO; +- category browsing through all memberships; +- Nova AI category suggestions; +- backward-compatible migration from the current single-category application behavior. + +This feature must not turn categories into tags. Categories remain a curated taxonomy with strong semantic meaning. Tags continue to provide fine-grained descriptors. + +--- + +## 2. Current State + +The existing database is already partly prepared for multi-category artwork classification. + +### Relevant tables + +#### `categories` + +The category taxonomy contains, among other fields: + +- `id` +- `content_type_id` +- `parent_id` +- `name` +- `slug` + +The taxonomy is scoped by content type. + +A category slug can therefore exist under different content types, for example: + +- `Wallpapers / Landscape` +- `Photography / Landscape` + +These are distinct category records. + +#### `artwork_category` + +The existing pivot table already models an artwork-to-category many-to-many relationship. + +Conceptually: + +```text +artworks + ↕ +artwork_category + ↕ +categories +``` + +The composite primary key prevents duplicate memberships: + +```sql +PRIMARY KEY (artwork_id, category_id) +``` + +The current schema contains a foreign key for `category_id -> categories.id`, but the implementation should verify whether a matching FK exists for: + +```text +artwork_category.artwork_id -> artworks.id +``` + +If missing, it must be added with `ON DELETE CASCADE`. + +#### `uploads` + +The current upload staging model contains a single: + +```text +uploads.category_id +``` + +This reflects the current application's single-category behavior and must be adapted. + +#### `artwork_ai_assists` + +The existing AI-assist structure contains: + +```text +category_suggestions_json +``` + +This is already suitable for multiple ranked category suggestions and should be reused rather than replaced unless the current code proves otherwise. + +--- + +## 3. Terminology + +Do not call categories "groups" in this implementation. + +Skinbase has separate concepts and terminology. + +Use: + +```text +Content Type +Category +Primary Category +Secondary Category +Tag +``` + +Example: + +```text +Artwork: Morning Coffee in Venice + +Content Type: + Wallpapers + +Primary Category: + Cityscape + +Secondary Categories: + Architecture + Travel + +Tags: + Venice + Italy + Grand Canal + coffee + sunrise + golden hour +``` + +--- + +## 4. Product Rules + +### 4.1 Content type + +An artwork belongs to one content type. + +Examples: + +- Wallpapers +- Photography +- Digital Art +- Skins + +Multi-category classification must not implicitly make one artwork belong to multiple content types. + +### 4.2 Primary category + +An artwork has at most one primary category. + +The primary category is used for: + +- breadcrumb generation; +- primary classification in artwork UI; +- default SEO/category context; +- legacy single-category compatibility; +- admin/editor display; +- any place that requires one deterministic category. + +### 4.3 Secondary categories + +An artwork may have multiple secondary categories. + +Default application rule: + +```text +1 primary category +0–5 secondary categories +``` + +The exact maximum should be configurable rather than hard-coded throughout the application. + +Suggested config: + +```php +'max_secondary_categories' => 5, +``` + +Per-content-type overrides may be introduced if the existing architecture already has an appropriate configuration mechanism. + +For example, the application may eventually choose stricter rules for skin formats than for wallpapers or photography. + +### 4.4 Same-content-type invariant + +Every selected category must belong to the same content type as the artwork. + +Valid: + +```text +Content Type: Wallpapers + +Primary: + Wallpapers / Cityscape + +Secondary: + Wallpapers / Architecture + Wallpapers / Sci-Fi +``` + +Invalid: + +```text +Content Type: Wallpapers + +Primary: + Wallpapers / Cityscape + +Secondary: + Photography / Landscape + Digital Art / 3D +``` + +This invariant must be enforced server-side. + +Never rely only on frontend filtering. + +### 4.5 Categories are not tags + +Do not permit arbitrary category spam. + +Categories are broad curated browse/classification signals. + +Tags remain the detailed semantic descriptors. + +A category must not be automatically created merely because an AI suggestion contains an unknown label. + +--- + +## 5. Target Data Model + +### 5.1 `artworks.primary_category_id` + +Add a nullable primary category reference: + +```sql +ALTER TABLE artworks + ADD COLUMN primary_category_id BIGINT UNSIGNED NULL; +``` + +Add an indexed FK to `categories.id`. + +Expected behavior: + +```text +artworks.primary_category_id + -> categories.id +``` + +Recommended deletion behavior: + +- prefer `ON DELETE SET NULL` for `artworks.primary_category_id`; +- category deletion logic should separately remove the pivot membership through the existing pivot FK/cascade behavior. + +The implementation must follow existing project migration conventions. + +### Why store the primary category on `artworks`? + +The pivot remains the canonical set of memberships, while `primary_category_id` gives the application a deterministic and efficient answer to: + +> Which category represents this artwork in a single-category context? + +This avoids ordering-dependent behavior such as: + +```php +$artwork->categories->first() +``` + +which is not a valid definition of "primary". + +### 5.2 `artwork_category` + +Keep the existing many-to-many table. + +Add or verify: + +```text +FK artwork_id -> artworks.id ON DELETE CASCADE +FK category_id -> categories.id ON DELETE CASCADE +``` + +Retain the composite uniqueness: + +```text +PRIMARY KEY (artwork_id, category_id) +``` + +Consider adding metadata if it is consistent with existing project conventions: + +```text +source +confidence +created_at +updated_at +``` + +Suggested semantics: + +```text +source: + user + ai + moderator + system + migration +``` + +`confidence` should be nullable and should only have meaning for machine-generated or machine-assisted classification. + +Do not add unnecessary metadata if the current application already stores equivalent provenance elsewhere. + +### 5.3 Membership invariant + +If: + +```text +artworks.primary_category_id = X +``` + +then the row: + +```text +(artwork_id, X) +``` + +must exist in `artwork_category`. + +All writes must preserve this invariant. + +### 5.4 Legacy `category_source` + +If `artworks.category_source` currently describes the single selected category, do not remove it in the first deployment unless all usages have been audited. + +Prefer a staged migration: + +1. support the new model; +2. migrate provenance to the new relation if appropriate; +3. update all readers/writers; +4. deprecate the legacy field; +5. remove it only in a later cleanup migration. + +--- + +## 6. Central Domain Service + +Do not distribute category synchronization logic across controllers, jobs and model observers. + +Create or extend one domain/service-layer entry point, for example: + +```php +ArtworkCategoryService +``` + +Suggested operation: + +```php +sync( + Artwork $artwork, + int $primaryCategoryId, + array $secondaryCategoryIds = [], + ?string $source = null +): void +``` + +The exact class/signature should follow current SkinbaseNova architecture. + +### The service must + +1. load all requested categories; +2. reject nonexistent IDs; +3. remove duplicates; +4. verify the primary category is included exactly once; +5. validate the category content type against the artwork; +6. enforce the configured secondary-category limit; +7. update the pivot and `primary_category_id` in one DB transaction; +8. preserve or update provenance metadata correctly; +9. leave the artwork in a valid state if any operation fails; +10. emit existing domain events/cache invalidation/search-index updates if required by the codebase. + +Controllers, upload finalization, admin editing, imports and AI acceptance should call this service rather than implementing their own sync behavior. + +--- + +## 7. Eloquent Model Changes + +Audit the existing models before changing them. + +The final `Artwork` model should conceptually support: + +```php +public function categories(): BelongsToMany +{ + return $this->belongsToMany(Category::class); +} + +public function primaryCategory(): BelongsTo +{ + return $this->belongsTo(Category::class, 'primary_category_id'); +} +``` + +The exact pivot configuration must match the existing schema. + +Audit for problematic patterns such as: + +```php +$artwork->categories()->first(); +$artwork->categories->first(); +sync([$categoryId]); +firstOrFail(); +``` + +where the code assumes there can only be one category. + +Replace such assumptions only where they are category-related; do not make unrelated refactors. + +--- + +## 8. Upload Flow + +### 8.1 UX + +The upload/edit UI should present: + +```text +Primary category +[ single select ] + +Additional categories +[ multi-select, max 3 ] +``` + +Requirements: + +- secondary selection excludes the primary category; +- only categories from the selected content type are available; +- changing content type must revalidate/clear incompatible selections; +- duplicate selections are impossible; +- the UI displays the configured maximum; +- server validation remains authoritative. + +Suggested copy: + +```text +Primary category +Choose the category that best represents the artwork. + +Additional categories +Optional. Add up to 5 other relevant categories. +``` + +### 8.2 Upload staging schema + +Audit how the current `uploads.category_id` is used before changing it. + +Preferred target model: + +```text +uploads.primary_category_id +``` + +plus either: + +```text +upload_category +--------------- +upload_id +category_id +``` + +or another existing staging mechanism that cleanly supports multiple category IDs. + +Do not introduce a second competing staging architecture if the project already has reusable draft metadata storage. + +### 8.3 Safe migration strategy + +A staged approach is acceptable: + +**Phase A** + +- existing `uploads.category_id` continues to mean primary category; +- add secondary-category staging support; +- update finalization to use the central category service. + +**Phase B** + +- rename/deprecate `uploads.category_id` to `primary_category_id` if appropriate; +- remove compatibility code after all writers/readers are migrated. + +Prefer deployability and rollback safety over a large destructive migration. + +--- + +## 9. Nova AI Category Suggestions + +Reuse the existing AI-assist category suggestion facility where possible. + +Suggested normalized suggestion payload: + +```json +[ + { + "category_id": 123, + "name": "Cityscape", + "confidence": 0.96 + }, + { + "category_id": 456, + "name": "Architecture", + "confidence": 0.89 + } +] +``` + +The implementation should prefer IDs from the known taxonomy rather than free-form names. + +### AI rules + +Nova may: + +- rank existing categories; +- suggest a likely primary category; +- suggest secondary categories; +- show confidence where available. + +Nova must not: + +- create new categories automatically; +- silently replace a user's confirmed primary category; +- attach categories from another content type; +- exceed the configured maximum. + +Initial rollout should require explicit user/editor confirmation for AI category changes. + +Future auto-accept behavior can be added separately after measuring accuracy. + +--- + +## 10. API Contract + +Audit current endpoints and serializers/resources. + +The API should expose both the deterministic primary category and all category memberships. + +Conceptual response: + +```json +{ + "primary_category": { + "id": 10, + "name": "Cityscape", + "slug": "cityscape" + }, + "categories": [ + { + "id": 10, + "name": "Cityscape", + "slug": "cityscape", + "is_primary": true + }, + { + "id": 12, + "name": "Architecture", + "slug": "architecture", + "is_primary": false + } + ] +} +``` + +If the current public API has a singular legacy `category` property: + +- keep it backward-compatible; +- map it to the primary category; +- add the plural structure additively; +- document deprecation only if the project intends to remove the singular field later. + +For create/update operations, prefer an explicit contract such as: + +```json +{ + "primary_category_id": 10, + "secondary_category_ids": [12, 18] +} +``` + +This is clearer than relying on array order. + +--- + +## 11. Category Pages and Browsing + +A category page such as: + +```text +/wallpapers/cityscape +``` + +must include every artwork attached to that category, regardless of whether it is the artwork's primary or secondary category. + +Conceptually query through: + +```text +artwork_category +``` + +not only: + +```text +artworks.primary_category_id +``` + +This is the main discovery benefit of the feature. + +Review: + +- pagination; +- category counts; +- sorting; +- cache keys; +- search indexing; +- sitemap generation; +- related-artwork queries; +- category statistics. + +Any category count that currently assumes one category per artwork must be audited. + +--- + +## 12. Artwork Page, Breadcrumbs and SEO + +### 12.1 Canonical artwork URL + +Do not change the canonical artwork URL. + +Keep the current model: + +```text +/art/{id}/{slug} +``` + +An artwork must not gain a different canonical URL for every category. + +### 12.2 Breadcrumb + +Use the primary category only. + +Example: + +```text +Home +→ Wallpapers +→ Cityscape +→ Morning Coffee in Venice +``` + +Do not select the breadcrumb category by pivot ordering. + +### 12.3 Category display + +Artwork UI may display: + +```text +Category +Cityscape + +Also in +Architecture +Travel +``` + +or a combined component where the primary category is visibly distinguished. + +### 12.4 Structured data + +If the project emits `BreadcrumbList` or other structured data: + +- use the primary category path; +- preserve one stable breadcrumb path; +- avoid multiple competing breadcrumb definitions for the same artwork. + +### 12.5 Internal links + +Secondary category links are beneficial discovery links, but they must all point to their normal category pages. + +All category pages must link back to the same canonical artwork URL. + +--- + +## 13. Parent Categories + +The schema supports `categories.parent_id`. + +Do not automatically duplicate membership into ancestor categories unless the existing taxonomy explicitly requires this. + +Example: + +```text +Nature +└── Flowers +``` + +If the artwork is directly classified as: + +```text +Flowers +``` + +store the direct membership once. + +If the `Nature` category page is intended to include descendants, compute or query descendant membership rather than writing redundant pivot rows. + +Audit current category hierarchy behavior before changing it. + +--- + +## 14. Existing Data Migration + +The migration must not make arbitrary semantic choices silently. + +### 14.1 Preflight report + +Before backfilling `primary_category_id`, inspect current production data. + +Required report: + +```sql +SELECT + artwork_id, + COUNT(*) AS category_count +FROM artwork_category +GROUP BY artwork_id +HAVING COUNT(*) > 1 +ORDER BY category_count DESC; +``` + +Distribution: + +```sql +SELECT + category_count, + COUNT(*) AS artworks +FROM ( + SELECT + artwork_id, + COUNT(*) AS category_count + FROM artwork_category + GROUP BY artwork_id +) x +GROUP BY category_count +ORDER BY category_count; +``` + +Also report artworks with no category if category membership is expected for their content type. + +### 14.2 Backfill rules + +For artwork with exactly one category: + +```text +primary_category_id = that category +``` + +For artwork with zero categories: + +```text +primary_category_id = NULL +``` + +and include it in an audit report if the content type normally requires a category. + +For artwork with more than one category: + +- do not silently use `MIN(category_id)`, `first()` or database row order as semantic truth; +- first inspect whether existing application data provides a deterministic primary source; +- if no reliable source exists, produce an actionable report for explicit resolution. + +An Artisan command is preferred for migration/preflight because it can support dry-run and reporting. + +Suggested capabilities: + +```text +--dry-run +--report +--apply +``` + +Follow the project's command naming conventions. + +### 14.3 Idempotency + +The migration/backfill process must be safe to run more than once. + +Never overwrite an already-confirmed primary category unless explicitly requested. + +--- + +## 15. Database Integrity + +Verify/add appropriate indexes. + +At minimum: + +```text +artworks.primary_category_id +artwork_category (artwork_id, category_id) PK/unique +artwork_category category lookup index if not already covered +``` + +Verify both pivot FKs. + +Application-level validation must enforce: + +- primary is a member of the pivot; +- same content type; +- category-count limit. + +If project conventions use database constraints or triggers for these invariants, evaluate them, but avoid introducing database-specific complexity without a strong reason. + +--- + +## 16. Search and Recommendation Systems + +Audit all consumers of artwork categories. + +Potential consumers include: + +- full-text/search indexing; +- Meilisearch/Elasticsearch/OpenSearch if present; +- recommendation scoring; +- related artworks; +- Nova embeddings/classification; +- feeds; +- filters; +- analytics; +- category statistics. + +Suggested representation in a search document: + +```json +{ + "primary_category_id": 10, + "category_ids": [10, 12, 18] +} +``` + +Recommendation systems may weight primary and secondary membership differently, for example: + +```text +primary category: strong signal +secondary category: medium signal +tags: fine-grained signal +embeddings: semantic similarity signal +``` + +Do not introduce arbitrary scoring changes in this milestone unless the existing recommendation architecture clearly needs them. Make the data available first. + +--- + +## 17. Admin / Moderation + +Admin/editor UI should clearly show: + +```text +Primary +Secondary +Source +AI confidence +``` + +where those metadata exist. + +Editors must be able to: + +- replace the primary category; +- add/remove secondary categories; +- promote a secondary category to primary; +- see incompatible selections rejected; +- save everything transactionally. + +When promoting a secondary category to primary, the previous primary may remain as a secondary category unless the editor removes it. + +--- + +## 18. Caching and Invalidation + +Audit cached data related to: + +- artwork detail; +- category pages; +- category artwork counts; +- breadcrumbs; +- related artwork; +- search documents. + +Changing category membership must invalidate/rebuild all affected representations. + +A change from: + +```text +Cityscape +``` + +to: + +```text +Primary: Architecture +Secondary: Cityscape +``` + +must invalidate both relevant category views as well as the artwork detail/breadcrumb cache. + +Use existing cache/event patterns rather than creating a parallel mechanism. + +--- + +## 19. Authorization and Security + +The new fields must use the same authorization rules as the current category edit action. + +Do not allow users to assign: + +- inaccessible/private taxonomy entries; +- disabled categories; +- categories belonging to another content type; +- more than the allowed maximum. + +Validate IDs server-side. + +Avoid mass-assignment of unvalidated category arrays. + +--- + +## 20. Tests + +Add tests at the level appropriate to the current project. + +### Database / model + +- artwork can have multiple categories; +- duplicate pivot membership is rejected/prevented; +- deleting an artwork removes pivot rows; +- deleting a category handles pivot rows and primary reference safely; +- primary category relationship works. + +### Domain service + +- one primary only; +- primary is always in the pivot; +- secondary categories sync correctly; +- duplicate IDs are normalized/rejected consistently; +- category from another content type is rejected; +- more than configured secondary categories is rejected; +- transaction rolls back on failure; +- source/confidence metadata is preserved if implemented. + +### API + +- legacy singular category still maps to primary where required; +- plural categories are returned; +- create/update accepts primary + secondaries; +- invalid cross-content-type categories return validation errors; +- maximum is enforced. + +### Upload + +- primary category selection works; +- multiple secondary categories persist through staging/finalization; +- changing content type invalidates incompatible categories; +- upload finalization uses central domain service. + +### Category pages + +- artwork appears on its primary category page; +- artwork also appears on secondary category pages; +- pagination/counts do not duplicate the same artwork; +- category URL behavior remains unchanged. + +### Artwork page / SEO + +- canonical artwork URL remains unchanged; +- breadcrumb uses the primary category; +- changing secondary categories does not change breadcrumb; +- promoting a secondary category updates breadcrumb; +- structured data uses the primary path. + +### AI assist + +- suggestions are limited to known categories; +- suggestions are filtered to matching content type; +- AI cannot exceed the maximum; +- confirmed user selection is not silently overwritten. + +### Migration + +- zero-category artwork is handled; +- one-category artwork is backfilled deterministically; +- multi-category legacy artwork is reported instead of arbitrarily resolved; +- rerunning backfill is idempotent. + +--- + +## 21. Backward Compatibility + +This implementation must not break existing public artwork/category URLs. + +Do not change: + +```text +/art/{id}/{slug} +``` + +Do not change existing category route structure merely for this feature. + +If legacy API/UI code expects a singular category, map that concept to: + +```text +primary_category +``` + +during the compatibility period. + +Prefer additive schema/API changes first, cleanup later. + +--- + +## 22. Observability / Audit + +During rollout, log or report: + +- artwork with no primary category where one is required; +- primary category missing from pivot; +- cross-content-type memberships; +- artwork exceeding the configured category limit; +- migration conflicts; +- failed AI suggestion mappings. + +If the project already has health/audit commands, integrate with them rather than creating unnecessary infrastructure. + +--- + +## 23. Rollout Plan + +### Phase 1 — Audit + +- inspect current code paths; +- inspect current data distribution; +- identify all single-category assumptions; +- confirm missing/existing foreign keys; +- confirm API compatibility requirements. + +### Phase 2 — Additive database changes + +- add `artworks.primary_category_id`; +- add/verify pivot integrity; +- add pivot metadata only if needed; +- add upload staging support. + +### Phase 3 — Domain logic + +- centralize category sync; +- update models; +- update validation; +- update events/cache/search integration. + +### Phase 4 — Backfill + +- run dry-run report; +- automatically backfill artworks with exactly one category; +- explicitly resolve any legacy multi-category conflicts; +- verify invariants. + +### Phase 5 — UI/API + +- upload editor; +- artwork editor/admin; +- API resources; +- artwork display; +- category pages; +- breadcrumb/SEO. + +### Phase 6 — Nova integration + +- reuse ranked category suggestions; +- support primary + secondary acceptance; +- preserve explicit user control. + +### Phase 7 — Cleanup + +Only after production verification: + +- remove obsolete single-category assumptions; +- deprecate/remove legacy fields where safe; +- update documentation. + +--- + +## 24. Acceptance Criteria + +The milestone is complete only when all of the following are true: + +- [ ] One artwork can belong to multiple categories. +- [ ] Exactly one selected category can be marked as primary. +- [ ] Primary category is always present in `artwork_category`. +- [ ] Secondary category limit is enforced server-side. +- [ ] Cross-content-type category assignment is impossible. +- [ ] Existing artwork URLs remain unchanged. +- [ ] Breadcrumb uses only the primary category. +- [ ] Artwork is discoverable through every assigned category page. +- [ ] Category pages do not duplicate an artwork. +- [ ] Existing single-category API behavior remains compatible where required. +- [ ] Upload/edit flow supports primary + secondary categories. +- [ ] Nova category suggestions work with the new model. +- [ ] Existing data can be migrated without arbitrary silent primary selection. +- [ ] Missing `artwork_category.artwork_id` FK is fixed if confirmed absent. +- [ ] Cache/search/index consumers are updated. +- [ ] Automated tests cover the new invariants. +- [ ] No unrelated refactors are included. +- [ ] Documentation reflects the final implementation. + +--- + +## 25. Definition of Done + +Before declaring the milestone finished: + +1. run the complete relevant automated test suite; +2. run lint/static analysis/type checks used by the repository; +3. run the migration/backfill in dry-run mode against representative data; +4. verify at least one artwork with one primary and two secondary categories; +5. verify that the artwork appears on all three category pages; +6. verify its canonical URL is unchanged; +7. verify breadcrumb follows only the primary category; +8. verify a cross-content-type assignment is rejected; +9. verify category deletion/artwork deletion preserves DB integrity; +10. document migration evidence, test results and any compatibility decisions. + +Do not mark the feature complete if the database supports multiple categories but any main write path still collapses them back to one. diff --git a/resources/js/Pages/Studio/StudioArtworkEdit.jsx b/resources/js/Pages/Studio/StudioArtworkEdit.jsx index b8a16a03..f585a6ac 100644 --- a/resources/js/Pages/Studio/StudioArtworkEdit.jsx +++ b/resources/js/Pages/Studio/StudioArtworkEdit.jsx @@ -301,6 +301,8 @@ export default function StudioArtworkEdit() { const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null) const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null) const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null) + const [secondaryCategoryIds, setSecondaryCategoryIds] = useState(() => (Array.isArray(artwork?.secondary_category_ids) ? artwork.secondary_category_ids.map(Number).filter((id) => Number.isFinite(id) && id > 0) : [])) + const maxSecondaryCategories = Number(artwork?.max_secondary_categories) > 0 ? Number(artwork.max_secondary_categories) : 5 const [title, setTitle] = useState(artwork?.title || '') const [description, setDescription] = useState(() => normalizeMarkdownLiteContent(artwork?.description || '')) const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name)) @@ -415,6 +417,22 @@ export default function StudioArtworkEdit() { const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed' const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData]) const selectedLeafCategoryId = subCategoryId || categoryId || null + const additionalCategoryOptions = useMemo(() => { + const options = [] + const seen = new Set() + for (const root of rootCategories) { + const items = [root, ...(root.children || [])] + for (const item of items) { + const id = Number(item.id) + if (!Number.isFinite(id) || id === Number(selectedLeafCategoryId) || seen.has(id)) { + continue + } + seen.add(id) + options.push(item) + } + } + return options.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' })) + }, [rootCategories, selectedLeafCategoryId]) const visibilitySummary = publishMode === 'schedule' ? `Scheduled as ${visibilityLabel(visibility)}` : visibilityLabel(visibility) @@ -469,6 +487,7 @@ export default function StudioArtworkEdit() { setContentTypeId(id) setCategoryId(null) setSubCategoryId(null) + setSecondaryCategoryIds([]) setIsCategoryChooserOpen(true) setCategorySource((current) => nextSourceForManualEdit(current)) } @@ -476,12 +495,14 @@ export default function StudioArtworkEdit() { const handleCategoryChange = (id) => { setCategoryId(id) setSubCategoryId(null) + setSecondaryCategoryIds((current) => current.filter((value) => Number(value) !== Number(id))) setIsCategoryChooserOpen(false) setCategorySource((current) => nextSourceForManualEdit(current)) } const handleSubCategoryChange = (id) => { setSubCategoryId(id) + setSecondaryCategoryIds((current) => current.filter((value) => Number(value) !== Number(id))) setCategorySource((current) => nextSourceForManualEdit(current)) } @@ -781,6 +802,8 @@ export default function StudioArtworkEdit() { : [], content_type_id: contentTypeId, category_id: selectedLeafCategoryId, + primary_category_id: selectedLeafCategoryId, + secondary_category_ids: secondaryCategoryIds.filter((id) => Number(id) !== Number(selectedLeafCategoryId)), tags: tagSlugs, title_source: titleSource, description_source: descriptionSource, @@ -834,7 +857,7 @@ export default function StudioArtworkEdit() { } finally { setSaving(false) } - }, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes]) + }, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, secondaryCategoryIds, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, worldSubmissionOptions, evolutionTarget, evolutionRelationType, evolutionNote, artwork?.id, evolutionRelationTypes]) const handleFileReplace = async (file) => { if (!file) return @@ -1448,7 +1471,45 @@ export default function StudioArtworkEdit() { )} - {errors.category_id &&

{errors.category_id[0]}

} + {(errors.category_id || errors.primary_category_id) &&

{(errors.category_id || errors.primary_category_id)[0]}

} + {errors.secondary_category_ids &&

{errors.secondary_category_ids[0]}

} + + {selectedLeafCategoryId && additionalCategoryOptions.length > 0 && ( +
+
Additional categories
+

Optional. Add up to {maxSecondaryCategories} other categories from this content type. They appear on those category pages but do not change the artwork URL or breadcrumb.

+
+ {additionalCategoryOptions.map((option) => { + const selected = secondaryCategoryIds.includes(Number(option.id)) + const atLimit = !selected && secondaryCategoryIds.length >= maxSecondaryCategories + return ( + + ) + })} +
+
+ )} diff --git a/resources/js/Pages/Upload/Index.jsx b/resources/js/Pages/Upload/Index.jsx index 58c087f4..378b2c0b 100644 --- a/resources/js/Pages/Upload/Index.jsx +++ b/resources/js/Pages/Upload/Index.jsx @@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react' import SeoHead from '../../components/seo/SeoHead' import TagInput from '../../components/tags/TagInput' import UploadWizard from '../../components/upload/UploadWizard' +import UploadReviewNotice from '../../components/upload/UploadReviewNotice' import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor' import Checkbox from '../../Components/ui/Checkbox' import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices' @@ -52,6 +53,7 @@ const initialState = { title: '', type: '', category: '', + additionalCategories: [], tags: '', description: '', isMature: false, @@ -320,6 +322,8 @@ function useUploadMachine({ draftId = null, filesCdnUrl = '', chunkSize, chunkRe const res = await window.axios.post('/api/artworks', { title: state.metadata.title, category: state.metadata.category, + primary_category_id: state.metadata.category ? Number(state.metadata.category) : null, + secondary_category_ids: (state.metadata.additionalCategories || []).map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0), tags: state.metadata.tags, description: state.metadata.description, is_mature: state.metadata.isMature, @@ -671,6 +675,8 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize contributorOptionsByGroup={props?.contributor_options_by_group && typeof props.contributor_options_by_group === 'object' ? props.contributor_options_by_group : {}} initialGroupSlug={props?.initial_group || ''} currentUserId={props?.auth?.user?.id ?? null} + maxSecondaryCategories={Number(props?.max_secondary_categories) > 0 ? Number(props.max_secondary_categories) : 5} + requiresUploadReview={Boolean(props?.requires_upload_review)} /> @@ -841,6 +847,7 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
+

Upload artwork

@@ -911,7 +918,7 @@ export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize
+ {selectedType && state.metadata.category ? ( +
+
Additional categories
+
Optional. Add up to 5 other relevant categories from the same type. They will not change the artwork URL or breadcrumb.
+
+ {categoryOptions.flatMap((cat) => [cat, ...(Array.isArray(cat.children) ? cat.children : [])]) + .filter((cat, index, all) => all.findIndex((item) => String(item.id) === String(cat.id)) === index) + .filter((cat) => String(cat.id) !== String(state.metadata.category)) + .sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' })) + .map((cat) => { + const selected = (state.metadata.additionalCategories || []).map(String).includes(String(cat.id)) + const atLimit = !selected && (state.metadata.additionalCategories || []).length >= 5 + return ( + + ) + })} +
+
+ ) : null} +
Tags
diff --git a/resources/js/components/artwork/ArtworkTags.jsx b/resources/js/components/artwork/ArtworkTags.jsx index c7845b68..81bfa51e 100644 --- a/resources/js/components/artwork/ArtworkTags.jsx +++ b/resources/js/components/artwork/ArtworkTags.jsx @@ -35,9 +35,10 @@ export default function ArtworkTags({ artwork }) { seen.add(category.id) categoryPills.push({ key: `cat-${category.id}`, - label: category.name, + label: category.is_primary ? category.name : category.name, href: category.url || `/${category.content_type_slug}/${category.slug}`, isCategory: true, + isPrimary: Boolean(category.is_primary), }) } } diff --git a/resources/js/components/upload/UploadWizard.jsx b/resources/js/components/upload/UploadWizard.jsx index 486ca201..9880d8e6 100644 --- a/resources/js/components/upload/UploadWizard.jsx +++ b/resources/js/components/upload/UploadWizard.jsx @@ -28,6 +28,7 @@ import { getContentTypeValue, getProcessingTransparencyLabel, } from '../../lib/uploadUtils' +import UploadReviewNotice from './UploadReviewNotice' import { validateMarkdownLiteContent } from '../../utils/contentValidation' // ─── Wizard step config ─────────────────────────────────────────────────────── @@ -55,6 +56,7 @@ function createInitialMetadata(initialGroupSlug = '', currentUserId = null, cont isMature: false, rightsAccepted: false, contentType: '', + additionalCategoryIds: [], group: normalizedGroupSlug, primaryAuthorUserId: defaultPrimaryAuthor, contributorUserIds: [], @@ -116,6 +118,8 @@ export default function UploadWizard({ contributorOptionsByGroup = {}, initialGroupSlug = '', currentUserId = null, + maxSecondaryCategories = 5, + requiresUploadReview = false, }) { const [notices, setNotices] = useState([]) // ── UI state ────────────────────────────────────────────────────────────── @@ -589,9 +593,18 @@ export default function UploadWizard({ filteredCategoryTree={filteredCategoryTree} allRootCategoryOptions={allRootCategoryOptions} requiresSubCategory={requiresSubCategory} - onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '' })} - onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })} - onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })} + maxSecondaryCategories={maxSecondaryCategories} + onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '', additionalCategoryIds: [] })} + onRootCategoryChange={(rootId) => setMeta({ + rootCategoryId: rootId, + subCategoryId: '', + additionalCategoryIds: (metadata.additionalCategoryIds || []).filter((id) => String(id) !== String(rootId)), + })} + onSubCategoryChange={(subId) => setMeta({ + subCategoryId: subId, + additionalCategoryIds: (metadata.additionalCategoryIds || []).filter((id) => String(id) !== String(subId)), + })} + onAdditionalCategoriesChange={(ids) => setMeta({ additionalCategoryIds: ids })} groupOptions={groupOptions} currentContributorOptions={currentContributorOptions} onGroupChange={(groupSlug) => setMeta({ group: groupSlug })} @@ -743,6 +756,8 @@ export default function UploadWizard({
)} + + {/* Restored draft banner */} {showRestoredBanner && (
diff --git a/resources/js/components/upload/__tests__/UploadWizard.test.jsx b/resources/js/components/upload/__tests__/UploadWizard.test.jsx index f1a4e46a..30640dab 100644 --- a/resources/js/components/upload/__tests__/UploadWizard.test.jsx +++ b/resources/js/components/upload/__tests__/UploadWizard.test.jsx @@ -347,6 +347,30 @@ describe('UploadWizard step flow', () => { expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything()) }) + it('shows additional categories alphabetically after the primary path is chosen', async () => { + installAxiosStubs({ statusValue: 'ready' }) + await renderWizard({ + initialDraftId: 318, + contentTypes: [{ + id: 1, + name: 'Art', + categories: [ + { id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }, { id: 14, name: 'Zebra' }] }, + { id: 12, name: 'Apple', children: [{ id: 13, name: 'City' }] }, + ], + }], + }) + + await completeStep1ToReady() + expect(await screen.findByText(/artwork details/i)).not.toBeNull() + await completeRequiredDetails({ title: 'My Art' }) + + const extraHeading = await screen.findByText(/additional categories/i) + const extraSection = extraHeading.closest('div')?.parentElement + const extraNames = Array.from(extraSection?.querySelectorAll('button') || []).map((button) => button.textContent?.trim()) + expect(extraNames).toEqual(['Apple', 'City', 'Root', 'Zebra']) + }) + it('hides the mature content checkbox in the details step', async () => { installAxiosStubs({ statusValue: 'ready' }) await renderWizard({ initialDraftId: 311, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] }) diff --git a/resources/js/components/upload/steps/Step2Details.jsx b/resources/js/components/upload/steps/Step2Details.jsx index ffc4c9d3..b67e7665 100644 --- a/resources/js/components/upload/steps/Step2Details.jsx +++ b/resources/js/components/upload/steps/Step2Details.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react' import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker' import UploadSidebar from '../UploadSidebar' import { NovaSelect } from '../../ui' -import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils' +import { additionalCategoryOptions, getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils' /** * Step2Details @@ -31,6 +31,8 @@ export default function Step2Details({ onContentTypeChange, onRootCategoryChange, onSubCategoryChange, + onAdditionalCategoriesChange, + maxSecondaryCategories = 5, groupOptions, currentContributorOptions, onGroupChange, @@ -100,6 +102,16 @@ export default function Step2Details({ const q = subCategorySearch.trim().toLowerCase() return q ? sorted.filter((s) => s.name.toLowerCase().includes(q)) : sorted }, [subCategories, subCategorySearch]) + + const selectedLeafCategoryId = metadata.subCategoryId || (!requiresSubCategory ? metadata.rootCategoryId : '') + const extraCategoryOptions = useMemo( + () => additionalCategoryOptions(filteredCategoryTree, selectedLeafCategoryId), + [filteredCategoryTree, selectedLeafCategoryId] + ) + const selectedAdditionalIds = useMemo( + () => (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds.map(String) : []), + [metadata.additionalCategoryIds] + ) const contributorCredits = metadata.contributorCredits || {} useEffect(() => { @@ -475,6 +487,50 @@ export default function Step2Details({ {selectedRoot.name} has no subcategories — selecting it is enough.
)} + + {selectedLeafCategoryId && extraCategoryOptions.length > 0 && ( + <> +
+
+
+

Additional categories

+ {selectedAdditionalIds.length}/{maxSecondaryCategories} +
+

Optional. Add up to {maxSecondaryCategories} other categories from this content type. They appear on those category pages but do not change the artwork URL or breadcrumb.

+
+ {extraCategoryOptions.map((option) => { + const selected = selectedAdditionalIds.includes(String(option.id)) + const atLimit = !selected && selectedAdditionalIds.length >= maxSecondaryCategories + return ( + + ) + })} +
+ {metadataErrors.secondary_category_ids && ( +

{metadataErrors.secondary_category_ids}

+ )} +
+ + )} {Array.isArray(groupOptions) && groupOptions.length > 0 && ( diff --git a/resources/js/components/upload/steps/Step3Publish.jsx b/resources/js/components/upload/steps/Step3Publish.jsx index 259c5181..ab063ae8 100644 --- a/resources/js/components/upload/steps/Step3Publish.jsx +++ b/resources/js/components/upload/steps/Step3Publish.jsx @@ -169,6 +169,9 @@ export default function Step3Publish({ {subLabel && ( Sub: {subLabel} )} + {(metadata.additionalCategoryIds || []).length > 0 && ( + Also in: {metadata.additionalCategoryIds.length} + )}
diff --git a/resources/js/hooks/upload/useUploadMachine.js b/resources/js/hooks/upload/useUploadMachine.js index 4474eea0..1fecd317 100644 --- a/resources/js/hooks/upload/useUploadMachine.js +++ b/resources/js/hooks/upload/useUploadMachine.js @@ -374,10 +374,17 @@ export default function useUploadMachine({ String(titleSourceFile?.name || '').replace(/\.[^.]+$/, '') || 'Untitled upload' + const draftCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null + const draftSecondaryIds = (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds : []) + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id) && id > 0 && String(id) !== String(draftCategoryId || '')) + const draftResponse = await window.axios.post('/api/artworks', { title: derivedTitle, description: String(metadata.description || '').trim() || null, - category: metadata.subCategoryId || metadata.rootCategoryId || null, + category: draftCategoryId, + primary_category_id: draftCategoryId ? Number(draftCategoryId) : null, + secondary_category_ids: draftSecondaryIds, tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '', license: Boolean(metadata.rightsAccepted), is_mature: Boolean(metadata.isMature), @@ -560,11 +567,16 @@ export default function useUploadMachine({ const { mode = 'now', publishAt = null, timezone = null, visibility = 'public', action = 'publish' } = opts const resolvedCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null + const secondaryCategoryIds = (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds : []) + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id) && id > 0 && String(id) !== String(resolvedCategoryId || '')) const buildPayload = () => ({ title: String(metadata.title || '').trim() || undefined, description: String(metadata.description || '').trim() || null, category: resolvedCategoryId ? String(resolvedCategoryId) : null, + primary_category_id: resolvedCategoryId ? Number(resolvedCategoryId) : null, + secondary_category_ids: secondaryCategoryIds, tags: Array.isArray(metadata.tags) ? metadata.tags : [], is_mature: Boolean(metadata.isMature), group: String(metadata.group || '').trim() || null, diff --git a/resources/js/lib/uploadUtils.additionalCategories.test.js b/resources/js/lib/uploadUtils.additionalCategories.test.js new file mode 100644 index 00000000..614fc795 --- /dev/null +++ b/resources/js/lib/uploadUtils.additionalCategories.test.js @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { additionalCategoryOptions } from './uploadUtils' + +describe('additionalCategoryOptions', () => { + it('excludes the primary leaf and sorts remaining names alphabetically', () => { + const options = additionalCategoryOptions([ + { id: '10', name: 'Zebra', children: [{ id: '11', name: 'Moon' }] }, + { id: '12', name: 'Apple', children: [{ id: '13', name: 'City' }] }, + ], '11') + + expect(options.map((option) => option.name)).toEqual(['Apple', 'City', 'Zebra']) + expect(options.map((option) => option.id)).not.toContain('11') + }) +}) diff --git a/resources/js/lib/uploadUtils.js b/resources/js/lib/uploadUtils.js index 5f5f10d8..140f39e0 100644 --- a/resources/js/lib/uploadUtils.js +++ b/resources/js/lib/uploadUtils.js @@ -103,6 +103,33 @@ export function buildCategoryTree(contentTypes = []) { return Array.from(rootsById.values()) } +export function compareCategoryNames(a, b) { + return String(a?.name || '').localeCompare(String(b?.name || ''), undefined, { + numeric: true, + sensitivity: 'base', + }) +} + +export function additionalCategoryOptions(roots = [], primaryId = '') { + const options = [] + const seen = new Set() + const primary = String(primaryId || '') + + roots.forEach((root) => { + const items = [root, ...(Array.isArray(root.children) ? root.children : [])] + items.forEach((item) => { + const id = String(item?.id || '') + if (!id || id === primary || seen.has(id)) { + return + } + seen.add(id) + options.push({ id, name: item.name || `Category ${id}` }) + }) + }) + + return options.sort(compareCategoryNames) +} + // ─── Content type helpers ───────────────────────────────────────────────────── export function getContentTypeValue(type) { if (!type) return '' diff --git a/tests/Feature/ArtworkSearchDocumentTest.php b/tests/Feature/ArtworkSearchDocumentTest.php index 4d513094..55796f7c 100644 --- a/tests/Feature/ArtworkSearchDocumentTest.php +++ b/tests/Feature/ArtworkSearchDocumentTest.php @@ -13,7 +13,6 @@ function createSearchDocumentContentType(string $name, int $sortOrder = 0): int 'description' => null, 'created_at' => now(), 'updated_at' => now(), - 'sort_order' => $sortOrder, ]); } @@ -48,14 +47,17 @@ it('indexes all attached categories and content types while preserving a primary $mattePaintingId = createSearchDocumentCategory($digitalArtId, 'Matte Painting', 20); $artwork->categories()->sync([$mattePaintingId, $fantasyId]); + $artwork->forceFill(['primary_category_id' => $fantasyId])->save(); - $payload = $artwork->fresh(['categories.contentType'])->toSearchableArray(); + $payload = $artwork->fresh(['categories.contentType', 'primaryCategory.contentType'])->toSearchableArray(); $fantasySlug = (string) DB::table('categories')->where('id', $fantasyId)->value('slug'); $mattePaintingSlug = (string) DB::table('categories')->where('id', $mattePaintingId)->value('slug'); $wallpapersSlug = (string) DB::table('content_types')->where('id', $wallpapersId)->value('slug'); $digitalArtSlug = (string) DB::table('content_types')->where('id', $digitalArtId)->value('slug'); expect($payload['category'])->toBe($fantasySlug) + ->and($payload['primary_category_id'])->toBe($fantasyId) + ->and($payload['category_ids'])->toEqualCanonicalizing([$fantasyId, $mattePaintingId]) ->and($payload['content_type'])->toBe($wallpapersSlug) ->and($payload['categories'])->toBe([$fantasySlug, $mattePaintingSlug]) ->and($payload['content_types'])->toBe([$wallpapersSlug, $digitalArtSlug]); diff --git a/tests/Feature/Artworks/ArtworkCategoryClassificationTest.php b/tests/Feature/Artworks/ArtworkCategoryClassificationTest.php new file mode 100644 index 00000000..edd98867 --- /dev/null +++ b/tests/Feature/Artworks/ArtworkCategoryClassificationTest.php @@ -0,0 +1,284 @@ +create([ + 'name' => $name, + 'slug' => strtolower($name).'-'.uniqid(), + 'description' => $name, + ]); +} + +function snCatCategory(ContentType $type, string $name, ?Category $parent = null): Category +{ + return Category::query()->create([ + 'content_type_id' => $type->id, + 'parent_id' => $parent?->id, + 'name' => $name, + 'slug' => strtolower($name).'-'.uniqid(), + 'description' => $name, + 'is_active' => true, + 'sort_order' => 0, + ]); +} + +it('syncs a deterministic primary category into the pivot and rejects invalid combinations', function (): void { + $user = User::factory()->create(); + $wallpapers = snCatContentType('Wallpapers'); + $photography = snCatContentType('Photography'); + $cityscape = snCatCategory($wallpapers, 'Cityscape'); + $architecture = snCatCategory($wallpapers, 'Architecture'); + $travel = snCatCategory($wallpapers, 'Travel'); + $night = snCatCategory($wallpapers, 'Night'); + $weather = snCatCategory($wallpapers, 'Weather'); + $interior = snCatCategory($wallpapers, 'Interior'); + $abstract = snCatCategory($wallpapers, 'Abstract'); + $landscape = snCatCategory($photography, 'Landscape'); + $artwork = Artwork::factory()->for($user)->create(); + + $service = app(ArtworkCategoryService::class); + $service->sync($artwork, $cityscape->id, [$architecture->id, $travel->id, $cityscape->id], 'user', false); + + $artwork->refresh()->load('categories'); + + expect($artwork->primary_category_id)->toBe($cityscape->id) + ->and($artwork->categories->pluck('id')->sort()->values()->all()) + ->toEqualCanonicalizing([$cityscape->id, $architecture->id, $travel->id]) + ->and($artwork->resolvedPrimaryCategory()?->id)->toBe($cityscape->id); + + $service->sync($artwork, $cityscape->id, [$architecture->id, $travel->id, $night->id, $weather->id, $interior->id], 'user', false); + expect($artwork->fresh()->categories()->count())->toBe(6); + + expect(fn () => $service->sync($artwork, $cityscape->id, [$architecture->id, $travel->id, $night->id, $weather->id, $interior->id, $abstract->id], 'user', false)) + ->toThrow(ValidationException::class); + + expect(fn () => $service->sync($artwork, $cityscape->id, [$landscape->id], 'user', false)) + ->toThrow(ValidationException::class); + + $artwork->refresh(); + expect($artwork->primary_category_id)->toBe($cityscape->id) + ->and($artwork->categories()->count())->toBe(6); +}); + +it('rolls back invalid category writes and keeps the previous membership', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $primary = snCatCategory($type, 'Primary'); + $secondary = snCatCategory($type, 'Secondary'); + $otherType = snCatContentType('Photography'); + $invalid = snCatCategory($otherType, 'Invalid'); + $artwork = Artwork::factory()->for($user)->create(); + $service = app(ArtworkCategoryService::class); + + $service->sync($artwork, $primary->id, [$secondary->id], 'user', false); + + try { + $service->sync($artwork, $primary->id, [$invalid->id], 'user', false); + expect(false)->toBeTrue(); + } catch (ValidationException) { + // expected + } + + $artwork->refresh()->load('categories'); + expect($artwork->primary_category_id)->toBe($primary->id) + ->and($artwork->categories->pluck('id')->all())->toEqualCanonicalizing([$primary->id, $secondary->id]); +}); + +it('promotes a secondary category to primary without dropping the previous primary', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $cityscape = snCatCategory($type, 'Cityscape'); + $architecture = snCatCategory($type, 'Architecture'); + $artwork = Artwork::factory()->for($user)->create(); + $service = app(ArtworkCategoryService::class); + + $service->sync($artwork, $cityscape->id, [$architecture->id], 'user', false); + $service->sync($artwork, $architecture->id, [$cityscape->id], 'user', false); + + $artwork->refresh(); + expect($artwork->primary_category_id)->toBe($architecture->id) + ->and($artwork->categories()->pluck('categories.id')->all())->toEqualCanonicalizing([$architecture->id, $cityscape->id]); +}); + +it('exposes primary category compatibly on the artwork API and category pages', function (): void { + $user = User::factory()->create(); + $type = snCatContentType('Wallpapers'); + $cityscape = snCatCategory($type, 'Cityscape'); + $architecture = snCatCategory($type, 'Architecture'); + $artwork = Artwork::factory()->for($user)->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subMinute(), + 'slug' => 'morning-coffee', + 'title' => 'Morning Coffee', + ]); + app(ArtworkCategoryService::class)->sync($artwork, $cityscape->id, [$architecture->id], 'user', false); + + $this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('ArtworkPage') + ->where('artwork.canonical_url', route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])) + ->where('artwork.category.id', $cityscape->id) + ->where('artwork.primary_category.id', $cityscape->id) + ->where('artwork.categories.0.is_primary', true) + ->where('seo.json_ld', function ($schemas): bool { + $json = json_encode($schemas, JSON_UNESCAPED_SLASHES); + + return str_contains($json, '"@type":"BreadcrumbList"') + && str_contains($json, '"name":"Cityscape"') + && ! str_contains($json, '"name":"Architecture"'); + })); + + $cityPage = app(ArtworkService::class)->getArtworksByCategoryPath([$type->slug, $cityscape->slug], 24); + $archPage = app(ArtworkService::class)->getArtworksByCategoryPath([$type->slug, $architecture->slug], 24); + + expect($cityPage->pluck('id')->all())->toContain($artwork->id) + ->and($archPage->pluck('id')->all())->toContain($artwork->id) + ->and($cityPage->pluck('id')->filter(fn ($id) => (int) $id === (int) $artwork->id)->count())->toBe(1); +}); + +it('finalizes upload staging through the central category service', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $primary = snCatCategory($type, 'Cityscape'); + $secondary = snCatCategory($type, 'Architecture'); + + $this->actingAs($user) + ->postJson('/api/artworks', [ + 'title' => 'Staged artwork', + 'category' => $primary->id, + 'secondary_category_ids' => [$secondary->id], + 'description' => 'A valid description for the draft.', + ]) + ->assertCreated(); + + $artwork = Artwork::query()->latest('id')->first(); + expect($artwork?->primary_category_id)->toBe($primary->id) + ->and($artwork?->categories()->pluck('categories.id')->all())->toEqualCanonicalizing([$primary->id, $secondary->id]); +}); + +it('updates studio artwork categories with an explicit primary and secondary contract', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $primary = snCatCategory($type, 'Cityscape'); + $secondary = snCatCategory($type, 'Architecture'); + $promoted = snCatCategory($type, 'Travel'); + $artwork = Artwork::factory()->for($user)->create([ + 'user_id' => $user->id, + 'is_public' => false, + 'visibility' => Artwork::VISIBILITY_PRIVATE, + ]); + app(ArtworkCategoryService::class)->sync($artwork, $primary->id, [$secondary->id], 'user', false); + + $this->actingAs($user) + ->putJson('/api/studio/artworks/'.$artwork->id, [ + 'primary_category_id' => $promoted->id, + 'secondary_category_ids' => [$primary->id], + ]) + ->assertOk() + ->assertJsonPath('artwork.primary_category_id', $promoted->id) + ->assertJsonPath('artwork.category_id', $promoted->id); + + $artwork->refresh(); + expect($artwork->primary_category_id)->toBe($promoted->id) + ->and($artwork->categories()->pluck('categories.id')->all())->toEqualCanonicalizing([$promoted->id, $primary->id]); +}); + +it('does not let nova overwrite a confirmed primary unless the editor applies a category', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $confirmed = snCatCategory($type, 'Cityscape'); + $suggested = snCatCategory($type, 'Architecture'); + $artwork = Artwork::factory()->for($user)->create(); + app(ArtworkCategoryService::class)->sync($artwork, $confirmed->id, [], 'manual', false); + + $mapper = app(StudioAiCategoryMapper::class); + $payload = $mapper->map(['architecture', 'building'], $confirmed); + + expect(count($payload['category']['secondary_categories'] ?? []))->toBeLessThanOrEqual((int) config('categories.max_secondary_categories', 5)); + if (! empty($payload['category']['content_type_id'])) { + expect($payload['category']['content_type_id'])->toBe($type->id); + } + + app(StudioAiAssistService::class)->applySuggestions($artwork->fresh(['tags', 'categories.contentType']), [ + 'title' => 'Keep my category', + ]); + + expect($artwork->fresh()->primary_category_id)->toBe($confirmed->id); + + app(StudioAiAssistService::class)->applySuggestions($artwork->fresh(['tags', 'categories.contentType']), [ + 'category_id' => $suggested->id, + 'secondary_category_ids' => [$confirmed->id], + ]); + + expect($artwork->fresh()->primary_category_id)->toBe($suggested->id); +}); + +it('backfills only unique memberships and stays idempotent', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $one = snCatCategory($type, 'One'); + $two = snCatCategory($type, 'Two'); + $single = Artwork::factory()->for($user)->create(); + $conflict = Artwork::factory()->for($user)->create(); + $empty = Artwork::factory()->for($user)->create(); + + $single->categories()->sync([$one->id]); + $conflict->categories()->sync([$one->id, $two->id]); + + artisan('artworks:backfill-primary-category', ['--report' => true]) + ->assertSuccessful() + ->expectsOutputToContain('Dry run only'); + + expect($single->fresh()->primary_category_id)->toBeNull() + ->and($conflict->fresh()->primary_category_id)->toBeNull(); + + artisan('artworks:backfill-primary-category', ['--apply' => true, '--report' => true]) + ->assertSuccessful(); + + expect($single->fresh()->primary_category_id)->toBe($one->id) + ->and($conflict->fresh()->primary_category_id)->toBeNull() + ->and($empty->fresh()->primary_category_id)->toBeNull(); + + $single->forceFill(['primary_category_id' => $two->id])->save(); + artisan('artworks:backfill-primary-category', ['--apply' => true]) + ->assertSuccessful(); + expect($single->fresh()->primary_category_id)->toBe($two->id); +}); + +it('clears the primary category reference when a category is deleted and cascades artwork deletion', function (): void { + $user = User::factory()->create(); + $type = snCatContentType(); + $category = snCatCategory($type, 'Temporary'); + $artwork = Artwork::factory()->for($user)->create(); + app(ArtworkCategoryService::class)->sync($artwork, $category->id, [], 'user', false); + + $category->forceDelete(); + expect($artwork->fresh()->primary_category_id)->toBeNull() + ->and(DB::table('artwork_category')->where('artwork_id', $artwork->id)->count())->toBe(0); + + $other = snCatCategory($type, 'Kept'); + app(ArtworkCategoryService::class)->sync($artwork->fresh(), $other->id, [], 'user', false); + $artwork->forceDelete(); + expect(DB::table('artwork_category')->where('artwork_id', $artwork->id)->count())->toBe(0); +});