user()->id; $filters = $request->only([ 'q', 'status', 'category', 'tags', 'date_from', 'date_to', 'performance', 'sort', ]); $perPage = (int) $request->get('per_page', 24); $perPage = min(max($perPage, 12), 100); $paginator = $this->queryService->list($userId, $filters, $perPage); // Transform the paginator items to a clean DTO $items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork)); return response()->json([ 'data' => $items, 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], ]); } /** * POST /api/studio/artworks/bulk * Execute bulk operations. */ 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', 'artwork_ids' => 'required|array|min:1|max:200', 'artwork_ids.*' => 'integer', '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', ]); if ($validator->fails()) { return response()->json(['errors' => $validator->errors()], 422); } $data = $validator->validated(); // Require explicit DELETE confirmation if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') { return response()->json([ 'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']], ], 422); } $result = $this->bulkService->execute( $request->user()->id, $data['action'], $data['artwork_ids'], $data['params'] ?? [], ); $statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200; return response()->json($result, $statusCode); } /** * PUT /api/studio/artworks/{id} * Update artwork details (title, description, visibility). */ public function update(Request $request, int $id): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); $validated = $request->validate([ 'title' => 'sometimes|string|max:255', 'description' => 'sometimes|nullable|string|max:5000', 'is_public' => 'sometimes|boolean', 'category_id' => 'sometimes|nullable|integer|exists:categories,id', 'tags' => 'sometimes|array|max:15', 'tags.*' => 'string|max:64', ]); if (isset($validated['is_public'])) { if ($validated['is_public'] && !$artwork->is_public) { $validated['published_at'] = $artwork->published_at ?? now(); } } // Extract tags and category before updating core fields $tags = $validated['tags'] ?? null; $categoryId = $validated['category_id'] ?? null; unset($validated['tags'], $validated['category_id']); $artwork->update($validated); // Sync category if ($categoryId !== null) { $artwork->categories()->sync([(int) $categoryId]); } // Sync tags (by slug/name) if ($tags !== null) { $tagIds = []; foreach ($tags as $tagSlug) { $tag = \App\Models\Tag::firstOrCreate( ['slug' => \Illuminate\Support\Str::slug($tagSlug)], ['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0] ); $tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0]; } $artwork->tags()->sync($tagIds); } // Reindex in Meilisearch try { $artwork->searchable(); } catch (\Throwable $e) { // Meilisearch may be unavailable } // Reload relationships for response $artwork->load(['categories.contentType', 'tags']); $primaryCategory = $artwork->categories->first(); return response()->json([ 'success' => true, 'artwork' => [ 'id' => $artwork->id, 'title' => $artwork->title, 'description' => $artwork->description, 'is_public' => (bool) $artwork->is_public, 'slug' => $artwork->slug, '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(), ], ]); } /** * POST /api/studio/artworks/{id}/toggle * Toggle publish/unpublish/archive for a single artwork. */ public function toggle(Request $request, int $id): JsonResponse { $validator = Validator::make($request->all(), [ 'action' => 'required|string|in:publish,unpublish,archive,unarchive', ]); if ($validator->fails()) { return response()->json(['errors' => $validator->errors()], 422); } $result = $this->bulkService->execute( $request->user()->id, $validator->validated()['action'], [$id], ); if ($result['success'] === 0) { return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404); } return response()->json(['success' => true]); } /** * GET /api/studio/artworks/{id}/analytics * Analytics data for a single artwork. */ public function analytics(Request $request, int $id): JsonResponse { $artwork = $request->user()->artworks() ->with(['stats', 'awardStat']) ->findOrFail($id); $stats = $artwork->stats; return response()->json([ 'artwork' => [ 'id' => $artwork->id, 'title' => $artwork->title, '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), 'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0), ], ]); } private function transformArtwork($artwork): array { $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, 'is_approved' => (bool) $artwork->is_approved, 'published_at' => $artwork->published_at?->toIso8601String(), '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), ]; } /** * GET /api/studio/tags/search?q=... * Search active tags by name for the bulk tag picker. */ public function searchTags(Request $request): JsonResponse { $query = trim((string) $request->input('q')); $tags = \App\Models\Tag::query() ->where('is_active', true) ->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%")) ->orderByDesc('usage_count') ->limit(30) ->get(['id', 'name', 'slug', 'usage_count']); return response()->json($tags); } /** * POST /api/studio/artworks/{id}/replace-file * Replace the artwork's primary image file — creates a new immutable version. * * Accepts an optional `change_note` text field alongside the file. */ public function replaceFile(Request $request, int $id): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); $request->validate([ 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB 'change_note' => 'sometimes|nullable|string|max:500', ]); // ── Rate-limit gate (before expensive file processing) ──────────── try { $this->versioningService->rateLimitCheck($request->user()->id, $artwork->id); } catch (TooManyRequestsHttpException $e) { return response()->json(['success' => false, 'error' => $e->getMessage()], 429); } $file = $request->file('file'); $tempPath = $file->getRealPath(); $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.', ], 422); } try { $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); $storage = app(\App\Services\Uploads\UploadStorageService::class); $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); // 1. Store original on disk (preserve extension when possible) $originalPath = $derivatives->storeOriginal($tempPath, $hash); $origFilename = basename($originalPath); $originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename); $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) $publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash); foreach ($publicAbsolute as $variant => $absolutePath) { $relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp'); $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); } // 3. Get new dimensions $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); // 4. Update the artwork's file-serving fields (hash drives thumbnail URLs) $origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: ''); $displayFileName = $origFilename; $clientName = basename(str_replace('\\', '/', (string) $file->getClientOriginalName())); $clientName = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $clientName) ?? ''; $clientName = trim((string) $clientName); if ($clientName !== '') { $clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION)); if ($clientExt === '' && $origExt !== '') { $clientName .= '.' . $origExt; } $displayFileName = $clientName; } $artwork->update([ 'file_name' => $displayFileName, 'file_path' => '', 'file_size' => $size, 'mime_type' => $origMime, 'hash' => $hash, 'file_ext' => $origExt, 'thumb_ext' => 'webp', 'width' => max(1, $width), 'height' => max(1, $height), ]); // 5. Create version record, apply ranking protection, audit log $version = $this->versioningService->createNewVersion( $artwork, $originalRelative, $hash, max(1, $width), max(1, $height), $size, $request->user()->id, $request->input('change_note'), ); // 6. Reindex in Meilisearch (non-blocking) try { $this->searchIndexer->update($artwork); } catch (\Throwable $e) { Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [ 'artwork_id' => $artwork->id, 'error' => $e->getMessage(), ]); } // 7. CDN cache bust — purge thumbnail paths for the old hash $this->purgeCdnCache($artwork, $hash); return response()->json([ 'success' => true, 'thumb_url' => $artwork->thumbUrl('md'), 'thumb_url_lg' => $artwork->thumbUrl('lg'), 'width' => $artwork->width, 'height' => $artwork->height, 'file_size' => $artwork->file_size, 'version_number' => $version->version_number, 'requires_reapproval' => (bool) $artwork->requires_reapproval, ]); } catch (\Throwable $e) { Log::error('replaceFile: processing error', [ 'artwork_id' => $artwork->id, 'error' => $e->getMessage(), ]); return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500); } } /** * GET /api/studio/artworks/{id}/versions * Return version history for an artwork (newest first). */ public function versions(Request $request, int $id): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); $versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get(); return response()->json([ 'artwork' => [ 'id' => $artwork->id, 'title' => $artwork->title, 'version_count' => (int) ($artwork->version_count ?? 1), ], 'versions' => $versions->map(fn (ArtworkVersion $v) => [ '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, 'change_note' => $v->change_note, 'is_current' => $v->is_current, 'created_at' => $v->created_at?->toIso8601String(), ])->values(), ]); } /** * POST /api/studio/artworks/{id}/restore/{version_id} * Restore an earlier version (cloned as a new current version). */ public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse { $artwork = $request->user()->artworks()->findOrFail($id); $version = ArtworkVersion::where('artwork_id', $artwork->id)->findOrFail($versionId); if ($version->is_current) { return response()->json(['success' => false, 'error' => 'This version is already the current version.'], 422); } try { $newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id); // Sync artwork file fields back to restored version dimensions $artwork->update([ 'width' => max(1, (int) $version->width), 'height' => max(1, (int) $version->height), 'file_size' => (int) $version->file_size, ]); $artwork->refresh(); // Reindex try { $this->searchIndexer->update($artwork); } catch (\Throwable) {} return response()->json([ 'success' => true, 'version_number' => $newVersion->version_number, 'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.", ]); } 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); } } /** * Purge CDN thumbnail cache for the artwork. * * 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 { try { $purgeUrl = config('cdn.purge_url'); if (empty($purgeUrl)) { Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]); return; } $paths = array_map( fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp", ['sm', 'md', 'lg', 'xl'] ); \Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]); } catch (\Throwable $e) { Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]); } } }