feat(intake): add upload_links and item_drafts, and default an item's price (#222)

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
This commit is contained in:
2026-08-31 15:42:25 -05:00
parent 7d8ac15ef8
commit 6df32af784
2 changed files with 83 additions and 3 deletions
@@ -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;
`);
};
+3 -3
View File
@@ -28,9 +28,9 @@ export async function migrate(): Promise<void> {
export async function resetDb(): Promise<void> {
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
`);