Add project listing selector, confirmed revision and private 3MF renders

This commit is contained in:
2026-11-08 18:49:32 +00:00
parent e62b6800a6
commit 4e675510fd
29 changed files with 3394 additions and 14 deletions
+99 -3
View File
@@ -2,17 +2,30 @@
namespace App\Http\Controllers;
use App\Models\EtsyConnection;
use App\Models\Project;
use App\Models\ProjectEvent;
use App\Services\Etsy;
use App\Services\ProjectCosting;
use App\Support\ProjectLifecycle;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use RuntimeException;
use Throwable;
class ProjectController extends Controller
{
/** @var list<string> */
private const LISTING_OPTION_STATES = ['active', 'draft'];
/** Etsy's own maximum page size for the listings endpoint. */
private const LISTING_OPTIONS_PAGE_SIZE = 100;
/** Bounds each state to at most 2000 listings (20 pages * 100); exceeding this fails explicitly rather than silently truncating. */
private const LISTING_OPTIONS_MAX_PAGES_PER_STATE = 20;
public function index(Request $request): View
{
abort_unless($request->user()->can('viewAny', Project::class), 403);
@@ -55,12 +68,13 @@ class ProjectController extends Controller
return redirect()->route('projects.show', $project)->with('status', 'Project created.');
}
public function show(Request $request, Project $project): View
public function show(Request $request, Project $project, Etsy $etsy): View
{
abort_unless($request->user()->can('view', $project), 403);
$project->load([
'revisions' => fn ($q) => $q->orderByDesc('revision_number'),
'revisions.files', 'revisions.creator', 'revisions.approver',
'revisions.files', 'revisions.files.render', 'revisions.creator', 'revisions.approver',
'currentRevision',
'listingLinks' => fn ($q) => $q->orderByDesc('created_at'),
'listingLinks.liveRelease.revision', 'listingLinks.liveRelease.costSnapshot',
'costSnapshots' => fn ($q) => $q->orderByDesc('recorded_at'),
@@ -68,7 +82,89 @@ class ProjectController extends Controller
'events.actor',
]);
return view('projects.show', ['project' => $project]);
return view('projects.show', ['project' => $project] + $this->listingOptions($etsy));
}
/**
* Options for the "link an Etsy listing" dropdown: the complete set of
* this shop's Live AND Draft listings together (not one state, not one
* page), read-only via the existing Etsy service — which already caches
* each page per shop/state/generation, so a warm cache typically serves
* this without any new network call. Each state is bounded to at most
* LISTING_OPTIONS_MAX_PAGES_PER_STATE pages; if the shop has more
* listings than that bound, or any page fetch fails, the whole dropdown
* fails explicitly (listingOptionsError) rather than silently showing a
* truncated or partial list.
*
* @return array{
* listingOptionsByState: array<string, list<array<string, mixed>>>,
* listingOptionsError: ?string,
* listingOptionsConnected: bool,
* }
*/
private function listingOptions(Etsy $etsy): array
{
$connection = EtsyConnection::first();
$connected = $connection !== null && $etsy->configured();
$optionsByState = [];
$error = null;
if ($connected) {
try {
foreach (self::LISTING_OPTION_STATES as $state) {
$optionsByState[$state] = $this->fetchAllListingsForState($etsy, $connection, $state);
}
} catch (Throwable $exception) {
report($exception);
$error = 'Listing data is temporarily unavailable or incomplete. Reconnect the shop if this continues.';
$optionsByState = [];
}
}
return [
'listingOptionsByState' => $optionsByState,
'listingOptionsError' => $error,
'listingOptionsConnected' => $connected,
];
}
/**
* Pages through every listing in one state up to the bound, stopping
* early once the API's own reported count is reached (the common case
* for a shop with under a page's worth of listings). Throws rather than
* returning a partial list if the bound is exceeded or a page comes
* back short of the reported total.
*
* @return list<array<string, mixed>>
*/
private function fetchAllListingsForState(Etsy $etsy, EtsyConnection $connection, string $state): array
{
$results = [];
$page = 0;
$reportedCount = null;
do {
if ($page >= self::LISTING_OPTIONS_MAX_PAGES_PER_STATE) {
$max = self::LISTING_OPTIONS_MAX_PAGES_PER_STATE * self::LISTING_OPTIONS_PAGE_SIZE;
throw new RuntimeException("This shop has more than {$max} {$state} listings, which exceeds what can be safely listed here.");
}
$listingPage = $etsy->listListings($connection, $state, self::LISTING_OPTIONS_PAGE_SIZE, $page * self::LISTING_OPTIONS_PAGE_SIZE);
$reportedCount = $listingPage['count'];
$results = array_merge($results, $listingPage['results']);
$page++;
} while (count($results) < $reportedCount && $listingPage['results'] !== []);
if (count($results) < $reportedCount) {
throw new RuntimeException("Could not load the complete list of {$state} listings (received ".count($results)." of {$reportedCount}).");
}
if (count($results) !== $reportedCount || count(array_unique(array_column($results, 'listing_id'))) !== count($results)) {
throw new RuntimeException('Listing pages changed or overlapped while loading; retry to load a complete selector.');
}
return $results;
}
public function edit(Request $request, Project $project): View
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\OptimisticLockConflictException;
use App\Models\Project;
use App\Models\ProjectEvent;
use App\Models\ProjectRevision;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* The project's "confirmed current revision" is a separate pointer from the
* per-listing liveRelease pointers on project_listing_links: it marks which
* revision is the project's current source of truth independent of what is
* released to any particular Etsy listing. Uploading a new revision never
* moves this pointer; only an explicit confirm or clear does, under an
* optimistic concurrency check on the project's own lock_version. Nothing
* here ever mutates a revision row — it is purely a pointer update, so an
* older revision can always be re-confirmed ("rollback") without overwriting
* any immutable history.
*/
class ProjectCurrentRevisionController extends Controller
{
public function update(Request $request, Project $project, ProjectRevision $revision): RedirectResponse
{
abort_unless($revision->project_id === $project->id, 404);
abort_unless($request->user()->can('confirmCurrentRevision', $project), 403);
$data = $request->validate([
'lock_version' => ['required', 'integer', 'min:0'],
]);
try {
DB::transaction(function () use ($request, $project, $revision, $data): void {
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
throw new OptimisticLockConflictException('The project changed since you loaded it.');
}
$lockedRevision = ProjectRevision::query()->whereKey($revision->id)->firstOrFail();
if ($lockedRevision->project_id !== $locked->id) {
abort(404);
}
if (! $lockedRevision->isApproved()) {
throw ValidationException::withMessages(['revision_id' => 'Only an approved revision can be confirmed as current.']);
}
if ($lockedRevision->files()->count() === 0) {
throw ValidationException::withMessages(['revision_id' => 'An approved revision must have at least one file before it can be confirmed as current.']);
}
$previousId = $locked->current_revision_id;
$locked->update([
'current_revision_id' => $lockedRevision->id,
'lock_version' => $locked->lock_version + 1,
]);
ProjectEvent::record($locked->id, 'current_revision_confirmed', $request->user()->id, ProjectRevision::class, $lockedRevision->id, [
'previous_current_revision_id' => $previousId,
'new_current_revision_id' => $lockedRevision->id,
]);
});
} catch (OptimisticLockConflictException $exception) {
return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409);
}
return redirect()->route('projects.show', $project)->with('status', 'Current revision confirmed.');
}
public function destroy(Request $request, Project $project): RedirectResponse
{
abort_unless($request->user()->can('confirmCurrentRevision', $project), 403);
$data = $request->validate([
'lock_version' => ['required', 'integer', 'min:0'],
]);
try {
DB::transaction(function () use ($request, $project, $data): void {
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
throw new OptimisticLockConflictException('The project changed since you loaded it.');
}
$previousId = $locked->current_revision_id;
$locked->update(['current_revision_id' => null, 'lock_version' => $locked->lock_version + 1]);
ProjectEvent::record($locked->id, 'current_revision_cleared', $request->user()->id, metadata: [
'previous_current_revision_id' => $previousId,
'new_current_revision_id' => null,
]);
});
} catch (OptimisticLockConflictException $exception) {
return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409);
}
return redirect()->route('projects.show', $project)->with('status', 'Current revision cleared.');
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\ProjectEvent;
use App\Models\ProjectFile;
use App\Models\ProjectFileRender;
use App\Services\ProductImageRenderer;
use App\Services\ThreeMfGeometryExtractor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* Generates a real, numeric product preview image from an uploaded 3MF
* mesh's actual geometry — never a placeholder thumbnail or a fabricated
* photo. Generation is explicit (an operator-triggered POST) and never
* happens as a side effect of viewing a project page. The result is cached
* per immutable source file: once a render exists for a ProjectFile, calling
* store() again is a no-op (idempotent) rather than re-rendering, and
* concurrent requests for the same file are serialised by locking the
* project row so only one render row is ever created.
*/
class ProjectFileRenderController extends Controller
{
public function store(Request $request, Project $project, ProjectFile $file): RedirectResponse
{
abort_unless($file->revision->project_id === $project->id, 404);
abort_unless($request->user()->can('render', [$file, $project]), 403);
if ($file->extension !== '3mf') {
return back()->withErrors(['file' => 'Product image generation is only supported for 3MF files.'])->setStatusCode(422);
}
if ($file->render !== null) {
return redirect()->route('projects.show', $project)->with('status', 'Product image already generated for this file.');
}
$sourceDisk = Storage::disk('project_files');
if (! $sourceDisk->exists($file->storage_key)) {
return back()->withErrors(['file' => 'The original file could not be found.'])->setStatusCode(404);
}
$renderDisk = Storage::disk('project_renders');
$storedKey = null;
try {
return DB::transaction(function () use ($project, $file, $request, $sourceDisk, $renderDisk, &$storedKey): RedirectResponse {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if (ProjectFileRender::where('project_file_id', $file->id)->exists()) {
return redirect()->route('projects.show', $project)->with('status', 'Product image already generated for this file.');
}
$scene = app(ThreeMfGeometryExtractor::class)->extract($sourceDisk->path($file->storage_key));
$rendered = app(ProductImageRenderer::class)->render($scene['triangles']);
$extension = $rendered['renderer'] === ProductImageRenderer::RENDERER_GD ? 'png' : 'svg';
$key = bin2hex(random_bytes(20)).'.'.$extension;
$storedKey = $key;
if (! $renderDisk->put($key, $rendered['bytes'])) {
throw new \RuntimeException('Unable to store the generated product image.');
}
$altText = sprintf(
'Numeric isometric render of %s: %d triangle%s across %d instance%s, rendered as %s.',
$file->original_filename,
$scene['triangle_count'], $scene['triangle_count'] === 1 ? '' : 's',
$scene['instance_count'], $scene['instance_count'] === 1 ? '' : 's',
strtoupper($extension),
);
$render = ProjectFileRender::create([
'project_file_id' => $file->id,
'renderer' => $rendered['renderer'],
'mime_type' => $rendered['mime'],
'storage_key' => $key,
'width' => $rendered['width'],
'height' => $rendered['height'],
'triangle_count' => $scene['triangle_count'],
'instance_count' => $scene['instance_count'],
'alt_text' => $altText,
'byte_size' => strlen($rendered['bytes']),
'sha256' => hash('sha256', $rendered['bytes']),
'generated_by' => $request->user()->id,
'generated_at' => now(),
]);
ProjectEvent::record($project->id, 'product_image_generated', $request->user()->id, ProjectFile::class, $file->id, [
'renderer' => $render->renderer,
'triangle_count' => $render->triangle_count,
'instance_count' => $render->instance_count,
]);
return redirect()->route('projects.show', $project)->with('status', 'Product image generated.');
});
} catch (Throwable $exception) {
if ($storedKey !== null) {
$renderDisk->delete($storedKey);
}
report($exception);
return back()->withErrors(['file' => 'Unable to generate a product image: '.$exception->getMessage()])->setStatusCode(422);
}
}
public function preview(Request $request, Project $project, ProjectFile $file): Response
{
abort_unless($file->revision->project_id === $project->id, 404);
abort_unless($request->user()->can('render', [$file, $project]), 403);
$render = $file->render;
abort_if($render === null, 404);
return response(Storage::disk('project_renders')->get($render->storage_key))
->header('Content-Type', $render->mime_type)
->header('Content-Disposition', 'inline');
}
public function download(Request $request, Project $project, ProjectFile $file): StreamedResponse
{
abort_unless($file->revision->project_id === $project->id, 404);
abort_unless($request->user()->can('render', [$file, $project]), 403);
$render = $file->render;
abort_if($render === null, 404);
$extension = $render->mime_type === 'image/png' ? 'png' : 'svg';
return Storage::disk('project_renders')->download($render->storage_key, $file->original_filename.'.'.$extension);
}
}
@@ -36,6 +36,13 @@ class ProjectListingLinkController extends Controller
return back()->withErrors(['listing_id' => $exception->getMessage()]);
}
// Re-checked against a fresh (non-cached) fetch above, so this also catches
// a listing that was active/draft when the dropdown was rendered but has
// since gone inactive, expired or sold out.
if (! in_array($listing['state'] ?? null, ['active', 'draft'], true)) {
return back()->withErrors(['listing_id' => 'That listing is no longer Live or Draft and cannot be linked.']);
}
try {
$link = DB::transaction(function () use ($request, $project, $connection, $data, $listing, $variationKey): ProjectListingLink {
$locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
@@ -57,7 +57,7 @@ class ProjectReleaseController extends Controller
$release = DB::transaction(function () use ($request, $project, $listingLink, $revision, $costSnapshot, $data): ProjectRelease {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail();
if ($locked->lock_version !== $data['lock_version']) {
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.');
}
@@ -113,7 +113,7 @@ class ProjectReleaseController extends Controller
$release = DB::transaction(function () use ($request, $project, $listingLink, $target, $data): ProjectRelease {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail();
if ($locked->lock_version !== $data['lock_version']) {
if ((int) $locked->lock_version !== (int) $data['lock_version']) {
throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.');
}