From e5afc7524b8840f5814cb5e7a6ce81acee37ed18 Mon Sep 17 00:00:00 2001 From: Ty Balascio Date: Fri, 25 Sep 2026 12:14:35 -0700 Subject: [PATCH] fix(fe): upload slab photos to the server before publish ensureServerSlab sent calibration_photo_id at create, but slab_photos rows are only created by POST /slabs/{id}/photos, which the frontend never called - so the INSERT hit FOREIGN KEY (calibration_photo_id REFERENCES slab_photos(id)) and every create failed with a generic 400, blocking the whole create->publish walk. - stop sending calibration_photo_id at create - upload every draft photo (original + processed PNG) right after the slab row exists, via new review/photoUpload.ts (multipart FormData, same error envelope so failures surface through fail(e)) - link calibration_photo_id in the existing review PUT, once the row exists - extract the role rule into lineup.photoRole() so the review strip and the uploader cannot disagree --- frontend/src/App.svelte | 14 ++++++-- frontend/src/review/ReviewForm.svelte | 4 +-- frontend/src/review/lineup.test.ts | 12 +++++++ frontend/src/review/lineup.ts | 13 ++++++++ frontend/src/review/photoUpload.ts | 48 +++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 frontend/src/review/photoUpload.ts diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7983fbf..bfbb610 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { get, writable } from 'svelte/store'; import { apiFetch } from './lib/api'; + import { uploadSlabPhotos } from './review/photoUpload'; import type { SlabDraft, Photo, Taxonomy } from './lib/types'; import { draft } from './lib/types'; import PhotoImport from './capture/PhotoImport.svelte'; @@ -52,7 +53,9 @@ } // Register the capture result on the server so review/publish have a real - // slab id (POST /api/v1/slabs). Returns the stored slab. + // slab id (POST /api/v1/slabs). calibration_photo_id is NOT sent at create: + // the FK target row (slab_photos) does not exist yet — it is linked by PUT + // after the photos upload. Returns the stored slab. async function ensureServerSlab(d: SlabDraft): Promise { if (d.serverCreated) return d; const created = await apiFetch('/slabs', { @@ -67,9 +70,13 @@ width_min_in: d.widthMinIn, width_max_in: d.widthMaxIn, width_avg_in: d.widthAvgIn, - calibration_photo_id: d.calibrationPhotoId || null, }), }); + // Photos must be on the server before publish (PUBLISH-40) and before the + // calibration link (FK). A photo failure surfaces through fail(e). + if (d.photos.length) { + await uploadSlabPhotos(d.id, d.photos, d.calibrationPhotoId); + } return { ...d, serverCreated: true, clientRev: created.server_rev ?? d.clientRev + 1 }; } @@ -189,6 +196,9 @@ short_description: next.shortDescription, description: next.description, content_source: next.description ? 'manual' : 'server', + // The calibration photo row now exists (uploaded in ensureServerSlab) + // — link it here so the FK target is in place before publish. + calibration_photo_id: next.calibrationPhotoId || null, }), }); next = { ...next, clientRev: (stored.server_rev as number) ?? next.clientRev + 1 }; diff --git a/frontend/src/review/ReviewForm.svelte b/frontend/src/review/ReviewForm.svelte index 3feb599..163b319 100644 --- a/frontend/src/review/ReviewForm.svelte +++ b/frontend/src/review/ReviewForm.svelte @@ -3,7 +3,7 @@ import { apiFetch, ApiError } from '../lib/api'; import type { SlabDraft, Taxonomy, TaxonomyCategory, TaxonomyTerm } from '../lib/types'; import { deriveThicknessBandId } from './thicknessBand'; - import { keptInOrder, missingMinimums, moveItem, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup'; + import { keptInOrder, missingMinimums, moveItem, photoRole, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup'; import { nextPrimary, selectedSummary } from './woodPrimary'; import { buildShortDescription, buildTitle } from './copy'; @@ -110,7 +110,7 @@ // ── Photo strip (REVIEW-5): kept order, leftmost = primary (PUBLISH-45) ── $: lineup = slab.photos.map((photo, index): LineupItem => ({ id: photo.id, - role: photo.id === slab.calibrationPhotoId ? 'calibration' : photo.processedBlob ? 'overlay' : 'batch', + role: photoRole(photo.id, slab.calibrationPhotoId, Boolean(photo.processedBlob)), isCalibration: photo.id === slab.calibrationPhotoId, kept: index < slab.photos.length, })); diff --git a/frontend/src/review/lineup.test.ts b/frontend/src/review/lineup.test.ts index 8e7d0d3..89ee158 100644 --- a/frontend/src/review/lineup.test.ts +++ b/frontend/src/review/lineup.test.ts @@ -5,6 +5,7 @@ import { moveItem, missingMinimums, lineupValid, + photoRole, roleLabel, resolvePhotoUrl, type LineupItem, @@ -63,6 +64,17 @@ test('lineup: missingMinimums only counts KEPT photos', () => { assert.deepEqual(missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]), ['calibration']); }); +test('lineup: photoRole is the single role rule (cal > overlay > batch)', () => { + assert.equal(photoRole('cal', 'cal', true), 'calibration'); + assert.equal(photoRole('ov', 'cal', true), 'overlay'); + assert.equal(photoRole('b1', 'cal', false), 'batch'); + // Calibration wins even if it carries a processed copy. + assert.equal(photoRole('cal', 'cal', true), 'calibration'); + // No calibration selected: processed photos are overlays. + assert.equal(photoRole('ov', '', true), 'overlay'); + assert.equal(photoRole('b1', '', false), 'batch'); +}); + test('lineup: roleLabel is owner-facing', () => { assert.match(roleLabel('calibration'), /clean/); assert.match(roleLabel('overlay'), /overlay/); diff --git a/frontend/src/review/lineup.ts b/frontend/src/review/lineup.ts index a19420b..e618ecd 100644 --- a/frontend/src/review/lineup.ts +++ b/frontend/src/review/lineup.ts @@ -18,6 +18,19 @@ export interface LineupItem { kept: boolean; } +/** + * The submission role of a photo: the calibration photo is `calibration`; a + * photo carrying its dimension-overlay copy (`processedBlob`) is `overlay`; + * everything else is `batch`. Single definition of the role rule — the review + * strip (ReviewForm) and the server photo upload (photoUpload) both derive from + * this so they can never disagree. + */ +export function photoRole(id: string, calibrationPhotoId: string, hasProcessed: boolean): PhotoRole { + if (id === calibrationPhotoId) return 'calibration'; + if (hasProcessed) return 'overlay'; + return 'batch'; +} + /** The kept photos in their current (left→right) order; the first is the primary. */ export function keptInOrder(items: LineupItem[]): LineupItem[] { return items.filter((it) => it.kept); diff --git a/frontend/src/review/photoUpload.ts b/frontend/src/review/photoUpload.ts new file mode 100644 index 0000000..3f970bd --- /dev/null +++ b/frontend/src/review/photoUpload.ts @@ -0,0 +1,48 @@ +// Multipart photo upload to the server (POST /api/v1/slabs/{id}/photos, +// backend/photos.py). Kept out of App.svelte so the create→publish path stays +// readable. Reuses the single role definition from lineup.ts (photoRole) and +// the apiFetch conventions (same /api/v1 base, same ApiError envelope) so +// failures surface through the existing fail(e) path. + +import { ApiError, type ApiErrorBody } from '../lib/api'; +import type { Photo } from '../lib/types'; +import { photoRole } from './lineup'; + +export interface UploadedPhoto { + id: string; + seq: number; + is_calibration: number; + role: string; +} + +function blobFile(blob: Blob, name: string, type?: string): File { + return blob instanceof File ? blob : new File([blob], name, { type }); +} + +/** + * Upload every draft photo (original + processed where present) to the server, + * in seq order. Throws an ApiError on the first photo that fails — the slab + * row must exist first (its id is the path segment), so the caller runs this + * after POST /slabs. + */ +export async function uploadSlabPhotos(slabId: string, photos: Photo[], calibrationPhotoId: string): Promise { + const uploaded: UploadedPhoto[] = []; + for (const photo of photos) { + const form = new FormData(); + form.append('photo_id', photo.id); + form.append('seq', String(photo.seq)); + form.append('is_calibration', photo.id === calibrationPhotoId ? '1' : '0'); + form.append('role', photoRole(photo.id, calibrationPhotoId, Boolean(photo.processedBlob))); + form.append('original', blobFile(photo.blob, `${photo.id}.png`, photo.blob.type || 'image/png')); + if (photo.processedBlob) { + form.append('processed', blobFile(photo.processedBlob, `${photo.id}-processed.png`, photo.processedBlob.type || 'image/png')); + } + const res = await fetch(`/api/v1/slabs/${slabId}/photos`, { method: 'POST', body: form }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: ApiErrorBody } | null; + throw new ApiError(res.status, body?.error ?? { code: String(res.status), message: `HTTP ${res.status}` }); + } + uploaded.push((await res.json()) as UploadedPhoto); + } + return uploaded; +}