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:
Ty
2026-09-20 14:34:24 -07:00
parent 2e2be48154
commit 1133201f30
12 changed files with 1996 additions and 275 deletions
+419 -18
View File
@@ -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),
);
}