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:
+12
-2
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user