// Thin client for the SlabUploader app API. Svelte-agnostic. // // Mirrors the LIVE backend routes (backend/*.py), which are the source of truth: // slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}] // photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed] // review.py POST /api/v1/slabs/{id}/review // publish.py POST /api/v1/slabs/{id}/publish // inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2 // settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference // Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage (required on those routes). // taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync] // pricing.py CRUD /api/v1/admin/pricing[/{id}] // // Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain // owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner). import { withWooHeaders } from './wooDeviceCreds'; export interface Slab { id: string; sku: string; status: string; price: number | null; price_source: string | null; length_in: number; thickness_in: number; sqft: number; bdft: number; width_min_in: number; width_max_in: number; width_avg_in: number; species_id: number | null; wood_category_ids: number[]; edge_type_term_id: number | null; figure_term_ids: number[]; grade_term_id: number | null; thickness_term_id: number | null; moisture_term_id: number | null; fig_tag_ids: number[]; feat_tag_ids: number[]; title: string | null; short_description: string | null; description: string | null; content_source: string | null; species_confidence: number | null; figure_confidence: number | null; grade_confidence: number | null; inference_status: string | null; calibration_photo_id: string | null; woo_product_id: number | null; /** Create-time optimistic token; updates compare against server_rev, not this. */ client_rev: number; /** Optimistic concurrency token; send as client_rev on PUT. */ server_rev: number; created_at: string; updated_at: string; } // Fields a client may send on create/update (subset of the row). export type SlabInput = Partial< Omit< Slab, | 'id' | 'status' | 'woo_product_id' | 'server_rev' | 'created_at' | 'updated_at' | 'inference_status' | 'species_confidence' | 'figure_confidence' | 'grade_confidence' | 'calibration_photo_id' > >; export interface SlabCreateInput extends SlabInput { id: string; // client-generated UUIDv4 (APP-20) client_rev: number; // 0 on create (APP-20) } export interface ApiErrorBody { code: string | null; message: string; detail?: Record | null; } export class ApiError extends Error { constructor( public status: number, public code: string | null, message: string, public detail?: Record | null, ) { super(message); this.name = 'ApiError'; } } async function parse(res: Response): Promise { if (res.status === 204) return undefined as T; let body: unknown = null; try { body = await res.json(); } catch { /* non-JSON body (e.g. 410 Gone) */ } if (!res.ok) { const e = (body as { error?: ApiErrorBody } | null)?.error; throw new ApiError( res.status, e?.code ?? null, e?.message ?? res.statusText, e?.detail ?? null, ); } return body as T; } function jsonBody(v: unknown): BodyInit { return JSON.stringify(v); } // --------------------------------------------------------------------------- // Slabs // --------------------------------------------------------------------------- export function createSlab(input: SlabCreateInput): Promise { return fetch('/api/v1/slabs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: jsonBody(input), }).then((r) => parse(r)); } export function listSlabs(): Promise { return fetch('/api/v1/slabs').then((r) => parse(r)); } export function getSlab(id: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse(r)); } function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: jsonBody(patch), }).then((r) => parse(r)); } /** * PUT a slab patch. `client_rev` must equal the slab's current `server_rev`. * On 409 revision_conflict (stale rev — e.g. review POST bumped server_rev, or * a concurrent save), refetch once, adopt the latest server_rev, and retry. * A second 409 surfaces a clear reload message. */ export async function updateSlab( id: string, patch: SlabInput & { client_rev: number }, ): Promise { try { return await putSlab(id, patch); } catch (e) { if (!(e instanceof ApiError) || e.status !== 409) throw e; const latest = await getSlab(id); try { return await putSlab(id, { ...patch, client_rev: latest.server_rev }); } catch (e2) { if (e2 instanceof ApiError && e2.status === 409) { throw new ApiError( 409, e2.code ?? 'revision_conflict', 'This slab changed elsewhere. Reload the page and try again.', e2.detail, ); } throw e2; } } } export function deleteSlab(id: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) => parse(r), ); } // --------------------------------------------------------------------------- // Photos (lineup order = seq; calibration is is_calibration=1) // --------------------------------------------------------------------------- export interface PhotoRow { id: string; seq: number; is_calibration: number; /** #90: lineup kind (clean cal / dimension-overlay copy / batch). */ role: 'calibration' | 'overlay' | 'batch'; width_px: number | null; height_px: number | null; has_original: number; has_processed: number; } export interface UploadPhotoInput { photoId: string; // client-generated UUIDv4 seq?: number; isCalibration: boolean; /** #90: lineup kind; defaults to batch. */ role?: 'calibration' | 'overlay' | 'batch'; original: Blob; processed?: Blob | null; } export interface UploadPhotoResult { id: string; slab_id: string; seq: number; is_calibration: number; role: 'calibration' | 'overlay' | 'batch'; original_retained: boolean; processed_retained: boolean; } export function listPhotos(slabId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) => parse(r), ); } export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise { const form = new FormData(); form.append('photo_id', input.photoId); if (input.seq != null) form.append('seq', String(input.seq)); form.append('is_calibration', input.isCalibration ? '1' : '0'); form.append('role', input.role ?? 'batch'); const origName = input.original instanceof File ? input.original.name : 'original.png'; form.append('original', input.original, origName); if (input.processed) form.append('processed', input.processed, 'processed.png'); return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, { method: 'POST', body: form, }).then((r) => parse(r)); } // #90: drop a photo the owner removed in Review (keep/remove edit). export function deletePhoto(slabId: string, photoId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, { method: 'DELETE', }).then((r) => parse(r)); } /** * #90: persist an edited Review photo lineup. `kept` is the owner's final * order (leftmost = primary); each entry re-uploads with a fresh seq so the * stored `seq` reflects the keep order. `removed` photos are dropped. Every * kept photo's blob is re-sent (original + processed) because the server * stores only the BLOB — there is no separate re-seq endpoint. */ export interface LineupPhoto { id: string; role: 'calibration' | 'overlay' | 'batch'; isCalibration: boolean; original: Blob; processed: Blob | null; } export async function persistLineup( slabId: string, kept: LineupPhoto[], removed: string[], ): Promise { for (const id of removed) { await deletePhoto(slabId, id); } for (let i = 0; i < kept.length; i++) { const p = kept[i]; await uploadPhoto(slabId, { photoId: p.id, seq: i + 1, isCalibration: p.isCalibration, role: p.role, original: p.original, processed: p.processed, }); } } export function photoUrl( slabId: string, photoId: string, kind: 'original' | 'processed', ): string { return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`; } // --------------------------------------------------------------------------- // Review (REVIEW-*) // --------------------------------------------------------------------------- export interface ReviewResult { ready: boolean; missing: string[]; title: string | null; short_description: string | null; price: number | null; price_suggestion: number | null; primary_wood_category_id: number | null; /** True when deterministic title/short_desc were built from taxonomy. */ title_built?: boolean; title_build_missing?: string[]; } export interface ReviewInput { primary_wood_category_id?: number; price?: number; sku?: string; title?: string; short_description?: string; description?: string; price_source?: string; content_source?: string; } export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: jsonBody(payload), }).then((r) => parse(r)); } // --------------------------------------------------------------------------- // Publish (PUBLISH-*) // --------------------------------------------------------------------------- export interface PublishResult { ok: true; status: string; woo_product_id: number; draft: boolean; } export function publishSlab(slabId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST', headers: withWooHeaders(), }).then((r) => parse(r)); } // --------------------------------------------------------------------------- // Inference (METADATA-*) // --------------------------------------------------------------------------- export interface Call1Suggestions { species_id?: number | null; species_confidence?: number | null; wood_category_ids?: number[]; edge_type_term_id?: number | null; figure_term_ids?: number[]; figure_confidence?: number | null; feature_tag_ids?: number[]; feature_tag_confidence?: number | null; } export interface Call1Result { ok: boolean; suggestions: Call1Suggestions; } export interface Call2Result { ok: boolean; description: string; /** Present when Call 2 returned a parsed short_description. */ short_description?: string; } /** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */ export function inferTaxon(slabId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { method: 'POST', }).then((r) => parse(r)); } export function inferContent(slabId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST', }).then((r) => parse(r)); } /** View prompt: assembled Call 2 text + photo slots (no base64, no model call). */ export interface Call2PromptPhoto { seq: number; id: string; bytes: number; long_edge_px: number | null; } export interface Call2Prompt { text: string; photos: Call2PromptPhoto[]; prompt_revision_id: number | null; } export function getCall2Prompt(slabId: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) => parse(r), ); } // --------------------------------------------------------------------------- // Taxonomy (METADATA-5/15/30; only legal picker options) // --------------------------------------------------------------------------- export interface TaxCategory { id: number; name: string; slug: string; parent_id: number | null; woo_id: number | null; } export interface TaxTerm { id: number; name: string; slug: string; } export interface TaxAttribute { id: number; name: string; slug: string; terms: TaxTerm[]; } export interface TaxTag { id: number; name: string; slug: string; } export interface Taxonomy { categories: TaxCategory[]; attributes: TaxAttribute[]; tags: TaxTag[]; taxonomy_anchor: string | null; taxonomy_last_sync_at: string | null; } export function getTaxonomy(): Promise { return fetch('/api/v1/admin/taxonomy').then((r) => parse(r)); } export function syncTaxonomy(): Promise<{ taxonomy_anchor: string; taxonomy_last_sync_at: string; }> { return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST', headers: withWooHeaders(), }).then((r) => parse(r)); } // --------------------------------------------------------------------------- // Pricing (SETTINGS-35) // --------------------------------------------------------------------------- export interface PriceRule { id: number; species_id: number; price_per_bdft: number; created_at: string; updated_at: string; } export function listPricing(): Promise { return fetch('/api/v1/admin/pricing').then((r) => parse(r)); } export function createPricingRule(rule: { species_id: number; price_per_bdft: number; }): Promise { return fetch('/api/v1/admin/pricing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: jsonBody(rule), }).then((r) => parse(r)); } export function updatePricingRule( id: number, patch: { price_per_bdft: number; species_id?: number }, ): Promise { return fetch(`/api/v1/admin/pricing/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: jsonBody(patch), }).then((r) => parse(r)); } export function deletePricingRule(id: number): Promise { return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse(r)); } // --------------------------------------------------------------------------- // Settings (SETTINGS-*) // --------------------------------------------------------------------------- export interface SettingsView { woo_base_url: string; woo_wp_username: string | null; woo_app_password: { configured: boolean } | string; inference_base_url: { configured: boolean } | string; inference_api_key: { configured: boolean } | string; inference_model: string | null; inference_enabled: boolean; content_llm_enabled: boolean; brand_voice: string | null; geo_context: string | null; woo_create_status: string; taxonomy_last_sync_at: string | null; taxonomy_anchor: string | null; } export function getSettings(): Promise { return fetch('/api/v1/settings').then((r) => parse(r)); } export function updateSettings( patch: Partial>, ): Promise { return fetch('/api/v1/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: jsonBody(patch), }).then((r) => parse(r)); } export interface TestWooResult { ok: boolean; detail: string; taxonomy_anchor: string | null; taxonomy_last_sync_at: string | null; } export interface TestInferenceResult { ok: boolean; mode: 'stateful' | 'stateless' | 'fail'; vision_capable: boolean; detail: string; } export function testWoo(): Promise { return fetch('/api/v1/settings/test-woo', { method: 'POST', headers: withWooHeaders(), }).then((r) => parse(r)); } export function testInference(): Promise { return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) => parse(r), ); }