fca32396c0
Create title used to look enabled while review echoed a pre-typed title when species/primary were missing from taxonomy (no error). Persist wood cats before the deterministic build, return title_built / title_build_missing from review, and surface a Create-title nudge like Create desc. WooSetupGate now turns on content_llm_enabled when inference URL+key are set (Call 2 / Create desc). Call 1 stays out. Create-desc gate also requires inference URL+key so empty Settings show a clear missing list.
566 lines
16 KiB
TypeScript
566 lines
16 KiB
TypeScript
// 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<string, unknown> | null;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string | null,
|
|
message: string,
|
|
public detail?: Record<string, unknown> | null,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
async function parse<T>(res: Response): Promise<T> {
|
|
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<Slab> {
|
|
return fetch('/api/v1/slabs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: jsonBody(input),
|
|
}).then((r) => parse<Slab>(r));
|
|
}
|
|
|
|
export function listSlabs(): Promise<Slab[]> {
|
|
return fetch('/api/v1/slabs').then((r) => parse<Slab[]>(r));
|
|
}
|
|
|
|
export function getSlab(id: string): Promise<Slab> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
|
}
|
|
|
|
function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: jsonBody(patch),
|
|
}).then((r) => parse<Slab>(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<Slab> {
|
|
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<void> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
|
|
parse<void>(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<PhotoRow[]> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) =>
|
|
parse<PhotoRow[]>(r),
|
|
);
|
|
}
|
|
|
|
export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<UploadPhotoResult> {
|
|
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<UploadPhotoResult>(r));
|
|
}
|
|
|
|
// #90: drop a photo the owner removed in Review (keep/remove edit).
|
|
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
|
|
method: 'DELETE',
|
|
}).then((r) => parse<void>(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<void> {
|
|
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<ReviewResult> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: jsonBody(payload),
|
|
}).then((r) => parse<ReviewResult>(r));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Publish (PUBLISH-*)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PublishResult {
|
|
ok: true;
|
|
status: string;
|
|
woo_product_id: number;
|
|
draft: boolean;
|
|
}
|
|
|
|
export function publishSlab(slabId: string): Promise<PublishResult> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
|
|
method: 'POST',
|
|
headers: withWooHeaders(),
|
|
}).then((r) => parse<PublishResult>(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<Call1Result> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
|
|
method: 'POST',
|
|
}).then((r) => parse<Call1Result>(r));
|
|
}
|
|
|
|
export function inferContent(slabId: string): Promise<Call2Result> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, {
|
|
method: 'POST',
|
|
}).then((r) => parse<Call2Result>(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<Call2Prompt> {
|
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) =>
|
|
parse<Call2Prompt>(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<Taxonomy> {
|
|
return fetch('/api/v1/admin/taxonomy').then((r) => parse<Taxonomy>(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<PriceRule[]> {
|
|
return fetch('/api/v1/admin/pricing').then((r) => parse<PriceRule[]>(r));
|
|
}
|
|
|
|
export function createPricingRule(rule: {
|
|
species_id: number;
|
|
price_per_bdft: number;
|
|
}): Promise<PriceRule> {
|
|
return fetch('/api/v1/admin/pricing', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: jsonBody(rule),
|
|
}).then((r) => parse<PriceRule>(r));
|
|
}
|
|
|
|
export function updatePricingRule(
|
|
id: number,
|
|
patch: { price_per_bdft: number; species_id?: number },
|
|
): Promise<PriceRule> {
|
|
return fetch(`/api/v1/admin/pricing/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: jsonBody(patch),
|
|
}).then((r) => parse<PriceRule>(r));
|
|
}
|
|
|
|
export function deletePricingRule(id: number): Promise<void> {
|
|
return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse<void>(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<SettingsView> {
|
|
return fetch('/api/v1/settings').then((r) => parse<SettingsView>(r));
|
|
}
|
|
|
|
export function updateSettings(
|
|
patch: Partial<Record<keyof SettingsView, unknown>>,
|
|
): Promise<SettingsView> {
|
|
return fetch('/api/v1/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: jsonBody(patch),
|
|
}).then((r) => parse<SettingsView>(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<TestWooResult> {
|
|
return fetch('/api/v1/settings/test-woo', {
|
|
method: 'POST',
|
|
headers: withWooHeaders(),
|
|
}).then((r) => parse<TestWooResult>(r));
|
|
}
|
|
|
|
export function testInference(): Promise<TestInferenceResult> {
|
|
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
|
|
parse<TestInferenceResult>(r),
|
|
);
|
|
}
|