// 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|call2 // settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference // 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). 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; // #89: set true once the owner has locked the taxonomy (deterministic copy built). metadata_locked: boolean; 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; 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)); } export function updateSlab(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)); } 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; 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; original: Blob; processed?: Blob | null; } export interface UploadPhotoResult { id: string; slab_id: string; seq: number; is_calibration: number; 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'); 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)); } 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; // #89: whether the metadata is locked (deterministic copy built/persisted). locked: boolean; } 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; // #89: 'lock' triggers the explicit metadata-lock + deterministic copy build. action?: 'lock'; } 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)); } // #89: explicit lock of the confirmed taxonomy. The server builds and persists // the deterministic title/short_description from the locked set (owner prose // already on the slab wins). Distinct from reviewSlab() which only reports. export function lockSlab(slabId: string, payload: ReviewInput = {}): Promise { return reviewSlab(slabId, { ...payload, action: 'lock' }); } // --------------------------------------------------------------------------- // 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', }).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; } 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)); } // --------------------------------------------------------------------------- // 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' }).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' }).then((r) => parse(r), ); } export function testInference(): Promise { return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) => parse(r), ); }