Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user