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
This commit is contained in:
2026-09-25 12:14:35 -07:00
parent 5c7e5c663e
commit e5afc7524b
5 changed files with 87 additions and 4 deletions
+12 -2
View File
@@ -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<SlabDraft> {
if (d.serverCreated) return d;
const created = await apiFetch<SlabDraft>('/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 };
+2 -2
View File
@@ -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,
}));
+12
View File
@@ -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/);
+13
View File
@@ -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);
+48
View File
@@ -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<UploadedPhoto[]> {
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;
}