From 6df32af784d285e6b1569638d038e6e6bd40acc7 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 14:51:25 -0500 Subject: [PATCH] feat(intake): add upload_links and item_drafts, and default an item's price (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema for the intake pipeline. A submission becomes an `items` row at status 'pending' — already invisible to every public and storefront query since #90 — with an `item_drafts` row beside it holding the submitter's note, which link it arrived through, and the fields the drafting worker will fill in later. `upload_links` stores a digest rather than a token, so a leaked database is not also a leaked set of working links, and the admin screen can show a token exactly once. `max_submissions` is nullable for "no cap", but the route will default it to a finite number: an unbounded link should be something asked for, not something that happens when nobody thought about it. `item_drafts.upload_link_id` is ON DELETE SET NULL rather than CASCADE. Deleting a link must not delete the items that arrived through it — provenance is lost, the goods are not. `items.price_cents` keeps NOT NULL and gains a default of 80.00, so an arriving item is always priced. That is the decision taken in the design review over making the column nullable: it costs the schema-level guarantee that nothing can publish at a price nobody chose, and buys not having to teach the cart, the checkout and thirteen other files about an item without a price. The protection moves into the review queue, and `price_source` exists so that queue can say whether a number came from a model, the default, or a person. The number lives in the migration rather than in configuration. Changing a default price is a rare, deliberate act that deserves a record; an environment variable would let it drift silently between environments, and a wrong default is invisible until something has already sold at it. Verified up, down and up again rather than only forwards — an irreversible migration is one that cannot be tested. Then verified by inspection rather than assumption: the default reads 8000, both tables and the state index exist, and an item inserted with no price comes back at 8000. Backend: 263 integration, 302 unit, all passing against the new schema. Ref #222 --- .../1787500000000_add-intake-pipeline.js | 80 +++++++++++++++++++ backend/tests/integration/setup/testDb.ts | 6 +- 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/1787500000000_add-intake-pipeline.js diff --git a/backend/migrations/1787500000000_add-intake-pipeline.js b/backend/migrations/1787500000000_add-intake-pipeline.js new file mode 100644 index 0000000..407a236 --- /dev/null +++ b/backend/migrations/1787500000000_add-intake-pipeline.js @@ -0,0 +1,80 @@ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS upload_links ( + id SERIAL PRIMARY KEY, + label TEXT NOT NULL, + -- The token itself is never stored, only its digest. A leaked database + -- is then not also a leaked set of working upload links, and the admin + -- screen can show a token exactly once — at creation — for the same + -- reason a password reset link is not re-readable. + token_hash TEXT NOT NULL UNIQUE, + revoked_at TIMESTAMPTZ, + submission_count INTEGER NOT NULL DEFAULT 0, + -- Null means no cap. A link handed to a regular contributor is + -- open-ended; one handed out for a single box of stock is not. The + -- route defaults this to a finite number rather than null, so an + -- unbounded link is something asked for rather than something that + -- happens when nobody thought about it. + max_submissions INTEGER, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS item_drafts ( + id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE, + -- SET NULL rather than CASCADE: deleting a link must not delete the + -- items that arrived through it. Provenance is lost; the goods are not. + upload_link_id INTEGER REFERENCES upload_links(id) ON DELETE SET NULL, + submitter_note TEXT, + state TEXT NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + model TEXT, + ai_name TEXT, + ai_description TEXT, + ai_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + ai_tag_names TEXT[], + -- Kept even though a suggestion is also copied onto the item, so what + -- the model proposed stays readable after the admin has edited the + -- item's price. Without it there is no way to ask later whether the + -- model's numbers were any good. + ai_suggested_price_cents INTEGER, + -- ai | default | admin. Where the item's current price came from. + -- Recorded rather than inferred: a model that happens to suggest exactly + -- 8000, or an admin who deliberately types the model's number, both + -- collapse any comparison-based guess. + price_source TEXT NOT NULL DEFAULT 'default', + ai_error TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cost_micros INTEGER, + drafted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- The review queue reads by state; everything else reads by item, which + -- the UNIQUE constraint on item_id already indexes. + CREATE INDEX IF NOT EXISTS item_drafts_state_idx ON item_drafts (state); + + -- A submitted item is priced on arrival rather than left unpriced, so the + -- column keeps NOT NULL and only gains a fallback. 80.00 applies when + -- nothing else supplies a price; the drafting worker in #223 writes a + -- model's suggestion over it when there is one. + -- + -- The number lives here rather than in configuration deliberately. + -- Changing a default price is a rare, deliberate act that deserves a + -- record; an environment variable would let it drift silently between + -- environments, and a wrong default is invisible until something has + -- already sold at it. + ALTER TABLE items ALTER COLUMN price_cents SET DEFAULT 8000; + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + ALTER TABLE items ALTER COLUMN price_cents DROP DEFAULT; + DROP TABLE IF EXISTS item_drafts; + DROP TABLE IF EXISTS upload_links; + `); +}; diff --git a/backend/tests/integration/setup/testDb.ts b/backend/tests/integration/setup/testDb.ts index 2f6280c..e5b191a 100755 --- a/backend/tests/integration/setup/testDb.ts +++ b/backend/tests/integration/setup/testDb.ts @@ -28,9 +28,9 @@ export async function migrate(): Promise { export async function resetDb(): Promise { await testPool.query(` - TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts, - customer_tokens, customer_sessions, favorites, customers, item_tags, item_images, items, - tags, categories + TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts, + shipping_addresses, cart_items, carts, customer_tokens, customer_sessions, favorites, + customers, item_tags, item_images, items, tags, categories RESTART IDENTITY CASCADE `);