feat: upload wizard refactor + vision AI tags + artwork versioning

Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar

Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env

Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
This commit is contained in:
2026-03-01 14:56:46 +01:00
parent a875203482
commit 1266f81d35
33 changed files with 3710 additions and 1298 deletions

View File

@@ -5,11 +5,16 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\ArtworkVersion;
use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
* JSON API endpoints for the Studio artwork manager.
@@ -19,6 +24,8 @@ final class StudioArtworksApiController extends Controller
public function __construct(
private readonly StudioArtworkQueryService $queryService,
private readonly StudioBulkActionService $bulkService,
private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer,
) {}
/**
@@ -274,50 +281,66 @@ final class StudioArtworksApiController extends Controller
/**
* POST /api/studio/artworks/{id}/replace-file
* Replace the artwork's primary image file and regenerate derivatives.
* 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', // 50MB
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
'change_note' => 'sometimes|nullable|string|max:500',
]);
$file = $request->file('file');
$tempPath = $file->getRealPath();
// ── 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);
}
// Compute SHA-256 hash
$hash = hash_file('sha256', $tempPath);
$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);
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// Store original
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
// 1. Store original on disk
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
// Generate public derivatives
// 2. Generate public derivatives (thumbnails)
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAbsolute as $variant => $absolutePath) {
$filename = $variant . '.webp';
$relativePath = $storage->publicRelativePath($hash, $filename);
$relativePath = $storage->publicRelativePath($hash, $variant . '.webp');
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
}
// Get dimensions
$dimensions = @getimagesize($tempPath);
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width;
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height;
// 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);
// Update artwork record
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$artwork->update([
'file_name' => 'orig.webp',
'file_path' => '',
'file_size' => (int) filesize($originalPath),
'file_size' => $size,
'mime_type' => 'image/webp',
'hash' => $hash,
'file_ext' => 'webp',
@@ -326,24 +349,145 @@ final class StudioArtworksApiController extends Controller
'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 {
$artwork->searchable();
$this->searchIndexer->update($artwork);
} catch (\Throwable) {}
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,
'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' => 'File processing failed: ' . $e->getMessage(),
], 500);
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()]);
}
}
}