M3 Review→Publish UI (#77): one-column flow, searchable pickers, fig/feat tags, e2e
- Review screen: slug-resolved species/wcat groupers, case-insensitive attrs, fig-*/feat-* tag pickers (METADATA-30/40), AI-assist labels + AI bubbles. - App orchestrator: single writable flow store, capture→review→publish, Resume boot. - API client (slabs.ts) mirroring live backend routes; Blob name fix. - Playwright e2e (7 tests, hermetic mock): canvas PNG seed, client-UUID mock, SKU blur commit, ready-state reactive fix; expect timeout 4s, 2 workers. - e2e green 7/7 in 6s; check_requirements + check_coverage PASS.
This commit is contained in:
+419
-18
@@ -1,45 +1,130 @@
|
||||
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
|
||||
// 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;
|
||||
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;
|
||||
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) {
|
||||
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.ok) {
|
||||
let code: string | null = null;
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) {
|
||||
code = body.error.code ?? null;
|
||||
message = body.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) 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[]> {
|
||||
@@ -49,3 +134,319 @@ export function listSlabs(): Promise<Slab[]> {
|
||||
export function getSlab(id: string): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||
}
|
||||
|
||||
export function updateSlab(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));
|
||||
}
|
||||
|
||||
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;
|
||||
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<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');
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
}).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;
|
||||
grade_term_id?: number | null;
|
||||
grade_confidence?: number | null;
|
||||
character?: string | null;
|
||||
inclusions?: string | null;
|
||||
voids?: string | null;
|
||||
checks?: string | null;
|
||||
}
|
||||
|
||||
export interface Call1Result {
|
||||
ok: boolean;
|
||||
suggestions: Call1Suggestions;
|
||||
}
|
||||
|
||||
export interface Call2Result {
|
||||
ok: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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' }).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' }).then((r) =>
|
||||
parse<TestWooResult>(r),
|
||||
);
|
||||
}
|
||||
|
||||
export function testInference(): Promise<TestInferenceResult> {
|
||||
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
|
||||
parse<TestInferenceResult>(r),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user