4e97770373
Rewrite PRD, tech spec, data model, and OpenAPI around the 2026-08-27 decisions: client-side mask/measure/crop, U2Net on demand, Woo as product source of truth, draft-only local state. Architecture records topology C through the status machine. Drop the old CONTENT-WOO spec until the Call 2 prompt review finishes.
221 lines
6.6 KiB
Markdown
221 lines
6.6 KiB
Markdown
# Data Model
|
||
|
||
Status: SPEC · 2026-08-27 · SlabUploader
|
||
Storage: single SQLite file. Schema portable to Postgres.
|
||
|
||
Conventions:
|
||
- IDs: client-generated UUIDv4 for slabs so the PWA can reference a slab before it exists server-side.
|
||
- Timestamps: UTC ISO-8601 TEXT.
|
||
- Soft state via status column; no hard deletes for slabs (audit).
|
||
- Money: float64 USD, rounded half-up to cents at write.
|
||
- All rounded values are what gets stored. No full-precision internal storage.
|
||
- Inches stored as 1/8" increments. Sqft/bdft to 2 decimals.
|
||
- Multi-select fields stored as JSON arrays of Woo IDs.
|
||
|
||
---
|
||
|
||
## slabs
|
||
|
||
Draft-only. Deleted on successful publish. WooCommerce is the source of truth for product data.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | TEXT PK | client-generated UUID |
|
||
| `sku` | TEXT UNIQUE NOT NULL | 3-24 [A-Z0-9-] |
|
||
| `status` | TEXT NOT NULL | draft \| calibrated \| ready \| publishing \| published \| failed |
|
||
| `length_in` | REAL NOT NULL | 1/8" step |
|
||
| `thickness_in` | REAL NOT NULL | 1/8" step |
|
||
| `sqft` | REAL | 2 decimals |
|
||
| `bdft` | REAL | 2 decimals |
|
||
| `width_min_in` | REAL | min width at 6" stations, 2 decimals |
|
||
| `width_max_in` | REAL | max width at 6" stations, 2 decimals |
|
||
| `width_avg_in` | REAL | avg width at 6" stations, 2 decimals |
|
||
| `species_id` | INTEGER | → woo_categories (leaf only, 1 required) |
|
||
| `wood_category_ids` | INTEGER[] JSON | → woo_categories (leaf, 1+) |
|
||
| `edge_type_term_id` | INTEGER | → woo_attribute_terms (1 required) |
|
||
| `figure_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
|
||
| `grade_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
|
||
| `thickness_term_id` | INTEGER | → woo_attribute_terms (round-up) |
|
||
| `moisture_term_id` | INTEGER | → woo_attribute_terms (1 required, defaults to kiln-dried term; user overrides; not inferred) |
|
||
| `fig_tag_ids` | INTEGER[] JSON | → woo_tags (fig-*, 1+) |
|
||
| `price_per_bdft` | REAL | from pricing_rules for species |
|
||
| `price` | REAL NOT NULL | 2 decimals |
|
||
| `price_source` | TEXT | 'recommendation' \| 'override' |
|
||
| `title` | TEXT | |
|
||
| `short_title` | TEXT | |
|
||
| `description` | TEXT | |
|
||
| `content_source` | TEXT | 'manual' \| 'llm' |
|
||
| `client_rev` | INTEGER NOT NULL DEFAULT 1 | |
|
||
| `server_rev` | INTEGER NOT NULL DEFAULT 1 | |
|
||
| `created_at` | TEXT NOT NULL | |
|
||
| `updated_at` | TEXT NOT NULL | |
|
||
|
||
Indexes: `sku` (unique), `status`, `updated_at`.
|
||
|
||
### Status state machine
|
||
|
||
```
|
||
draft → calibrated → ready → publishing → published
|
||
│ │ ↑
|
||
└→ failed ────┴────────────────┘ (woo_product_id in sync_log)
|
||
any → failed (with error)
|
||
```
|
||
|
||
1. **draft** — photos taken, no mask confirmed.
|
||
2. **calibrated** — BG edge confirmed, length axis confirmed, length/thickness/SKU entered. Client computed sqft/bdft/widths. Draft uploaded to server.
|
||
3. **ready** — all mandatory fields populated. Values from Call 1, manual, or mix. Inference is optional. Publishable.
|
||
4. **publishing** — Woo create in flight. Poll for result.
|
||
5. **published** — Woo product created. This row + all photos deleted. sync_log retained.
|
||
6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available.
|
||
|
||
---
|
||
|
||
## slab_photos
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | TEXT PK | |
|
||
| `slab_id` | TEXT FK → slabs.id ON DELETE CASCADE | |
|
||
| `kind` | TEXT NOT NULL | 'inventory' |
|
||
| `role` | TEXT | 'topdown' \| 'extra' |
|
||
| `seq` | INTEGER | display order |
|
||
| `original_path` | TEXT | relative to images volume |
|
||
| `processed_path` | TEXT | relative to images volume |
|
||
| `created_at` | TEXT | |
|
||
|
||
Rule: only inventory photos. Calibration photos do not exist in this design.
|
||
|
||
---
|
||
|
||
## pricing_rules
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | TEXT PK UUID | |
|
||
| `species` | TEXT NOT NULL | matches woo_categories.name (leaf) |
|
||
| `price_per_bdft` | REAL NOT NULL | flat, no tiers |
|
||
| `created_at` | TEXT | |
|
||
| `updated_at` | TEXT | |
|
||
|
||
`UNIQUE(species)`. Seeded from synced Woo categories. User sets price per species.
|
||
|
||
---
|
||
|
||
## woo_categories
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `woo_id` | INTEGER PK | |
|
||
| `name` | TEXT | |
|
||
| `slug` | TEXT | |
|
||
| `parent_id` | INTEGER | NULL for root |
|
||
| `synced_at` | TEXT | |
|
||
|
||
Only species + wood category. Sync pulls all Woo categories; the app filters to the two we use.
|
||
|
||
`UNIQUE(woo_id)`.
|
||
|
||
---
|
||
|
||
## woo_attributes
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `woo_id` | INTEGER PK | |
|
||
| `name` | TEXT | |
|
||
| `slug` | TEXT | |
|
||
| `type` | TEXT | 'select' |
|
||
| `synced_at` | TEXT | |
|
||
|
||
Only the five first-class attributes: Edge Type, Figure, Grade, Thickness, Moisture.
|
||
|
||
`UNIQUE(woo_id)`.
|
||
|
||
---
|
||
|
||
## woo_attribute_terms
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `woo_id` | INTEGER PK | |
|
||
| `attribute_id` | INTEGER FK → woo_attributes.woo_id | |
|
||
| `name` | TEXT | |
|
||
| `slug` | TEXT | |
|
||
| `synced_at` | TEXT | |
|
||
|
||
The actual selectable values. E.g., "Cathedral" under Figure, "1\"–1 1/2\"" under Thickness.
|
||
|
||
`UNIQUE(woo_id)`.
|
||
|
||
---
|
||
|
||
## woo_tags
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `woo_id` | INTEGER PK | |
|
||
| `name` | TEXT | |
|
||
| `slug` | TEXT | |
|
||
| `synced_at` | TEXT | |
|
||
|
||
Only `fig-*` tags. Other Woo tags ignored.
|
||
|
||
`UNIQUE(woo_id)`.
|
||
|
||
---
|
||
|
||
## settings
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `key` | TEXT PK | |
|
||
| `value_enc` | BLOB | AES-GCM ciphertext |
|
||
| `updated_at` | TEXT | |
|
||
|
||
Keys:
|
||
- `woo_base_url`, `woo_consumer_key`, `woo_consumer_secret`
|
||
- `inference_base_url`, `inference_api_key`, `inference_model`
|
||
- `inference_enabled`, `content_llm_enabled`
|
||
- `brand_voice`, `geo_context`
|
||
- `prompts` (JSON: call1_prompt, call2_prompt, reset-to-default)
|
||
- `publish_status` ('published' | 'draft')
|
||
- `aspect_ratio` ('3:4'), `output_px` (1600), `output_px_min` (1600), `fill_target` (0.80)
|
||
- `user_sensitivity`, `user_edge_offset`, `user_feather`, `user_sheet_mode`
|
||
|
||
AES master key is an env var (`SLAB_AES_KEY`, 32 bytes base64). Never in the DB.
|
||
Each `value_enc` = `nonce(12) || ciphertext`.
|
||
|
||
---
|
||
|
||
## sync_log
|
||
|
||
Append-only history. Not deleted on publish.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||
| `slab_id` | TEXT FK | |
|
||
| `sku` | TEXT | |
|
||
| `woo_product_id` | INTEGER | |
|
||
| `status` | TEXT | 'success' \| 'failed' |
|
||
| `http_status` | INTEGER | |
|
||
| `error` | TEXT | machine-readable code |
|
||
| `detail` | TEXT | Woo error body |
|
||
| `payload_hash` | TEXT | sha256 of Woo payload sent |
|
||
| `created_at` | TEXT | |
|
||
|
||
---
|
||
|
||
## inference_log
|
||
|
||
Append-only audit.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||
| `slab_id` | TEXT FK | |
|
||
| `call` | TEXT | '1' \| '2' |
|
||
| `request_payload` | TEXT | JSON of what was sent |
|
||
| `response_payload` | TEXT | JSON of what came back |
|
||
| `created_at` | TEXT | |
|