From c119f797479f3b1fd57effb479c9fe33ea579e20 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 1 Sep 2026 13:54:11 -0500 Subject: [PATCH] docs(intake): plan the review queue (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks: the price provenance rule as a pure unit, the list endpoint, publish, the three state actions, the screen, and an end-to-end pass. The price field is the reason this screen exists. Items are priced on arrival, so the schema no longer prevents a number nobody chose from reaching the storefront — that protection moves here, into presentation, where it is weaker. So the rule is a pure tested function rather than a line inside a route: editing the number is the only thing that confirms it, publishing an untouched field deliberately does not, and publishing something still unconfirmed asks first rather than reporting afterwards. 80.00 was chosen because it reads as a decision rather than as an obvious sentinel, which is exactly why it has to be called out rather than left to be noticed. Discard deletes nothing. It is one click from an inbox, and the photos are often the only copy of an item no longer in the sender's hands, so it marks the draft and returns the item to pending. Restore brings it back at the state its own contents justify rather than unconditionally ready, because a submission discarded before it was ever drafted has no copy and must not return claiming otherwise. Regenerate clears attempts along with the state. The worker only picks up rows below the attempt cap, so re-queueing a draft that already failed three times would otherwise produce a button that appears to work, does nothing, and says nothing. The end-to-end test seeds through the intake route rather than POST /api/admin/items, which writes no item_drafts row and would never appear in a queue that joins it. One deviation from the issue is recorded in the plan rather than buried: it asks for the existing admin item components to be reused, and this builds a purpose-made card instead, because the fields differ in kind rather than arrangement. The cost — two places rendering a name, description and price — is named there too. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-01-intake-review-queue.md | 1198 +++++++++++++++++ 1 file changed, 1198 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-intake-review-queue.md diff --git a/docs/superpowers/plans/2026-09-01-intake-review-queue.md b/docs/superpowers/plans/2026-09-01-intake-review-queue.md new file mode 100644 index 0000000..afb1dc1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-intake-review-queue.md @@ -0,0 +1,1198 @@ +# Intake Review Queue Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** An admin can see every submitted item with its photos, note and drafted copy, edit it, and publish it — or send it back for another draft, or discard it. + +**Architecture:** One new admin router at `/api/admin/item-drafts` exposing a list and four actions, and one new admin tab that renders it. Publishing writes the edited fields onto the item and sets it available; nothing else in the app moves an intake item to the storefront. + +**Tech Stack:** Express 4, TypeScript, `pg`, Jest + supertest, React, antd, Playwright. + +**Spec:** `docs/superpowers/specs/2026-08-29-intake-pipeline-design.md` (issue #225, slice 4 of #220) + +**Already verified against the tree, so no need to re-check:** `item_drafts` has `item_id` (UNIQUE, FK CASCADE), `upload_link_id`, `submitter_note`, `state`, `attempts`, `model`, `ai_name`, `ai_description`, `ai_category_id`, `ai_tag_names`, `ai_suggested_price_cents`, `price_source`, `ai_error`, `input_tokens`, `output_tokens`, `cost_micros`, `drafted_at`, `created_at`. `items` is `(id, name, description, price_cents NOT NULL DEFAULT 8000, status DEFAULT 'pending', …)` with no CHECK constraint on status. `item_images` is `(item_id, image_path, sort_order)`. Admin routers mount as `app.use('/api/admin/', requireAdminGate, router)` — see `app.ts:81` for upload links. `POST /api/admin/items/:id/mark-available` (`routes/admin.ts:304`) sets `status='available'` and clears `sold_at`, `reserved_until`, `paypal_order_id`. Admin tabs are registered in `frontend/src/admin/Admin.tsx:392-398`. + +## Global Constraints + +- **Nothing reaches the storefront except by publishing from this queue.** The item arrives `pending`, which every public query already excludes. This screen is the only place `mark-available` is reached for an intake item. +- **The price field is the point of this screen.** Items are priced on arrival, so the schema no longer prevents an unchosen price reaching the storefront — the protection lives here now. The field is labelled with where the number came from, anything that is not `price_source='admin'` is visibly unconfirmed, editing sets `'admin'`, and publishing an unconfirmed price is allowed but says so *before* rather than after. +- **80.00 is a plausible price, not an obvious sentinel.** `0` would render "$0.00" and read as a bug; 80.00 reads as a decision. That is the entire argument for surfacing provenance loudly. +- **Discard is recoverable.** A one-click destructive action reachable from an inbox must not be final. +- **States are `queued | drafting | ready | failed | discarded`.** Note the worker never sets `drafting` today — it goes `queued → ready|failed` — so that filter will legitimately be empty. Do not "fix" that here. +- **Every route handler wrapped in `asyncRoute`** — `tests/unit/routesAreWrapped.test.ts` enforces it. +- **antd deep imports from `antd/es/...`**, never the barrel. +- **E2E assertions scoped to the item under test**, never the whole grid — the dev database never truncates. Use `findOrFail` from `./fixtures` for collection lookups, and `exact: true` on any short accessible name (#253). +- **Playwright and the integration suite need Node 20+**; the machine default is 18.16.1. Put a newer Node first on `PATH` for the command — never `nvm use`. +- **Commit style:** Conventional Commits, subject ending `(#225)`, no hard wrapping in bodies. + +## File Structure + +**Created:** +- `backend/src/intake/priceSource.ts` — the transition rule. Pure. +- `backend/src/routes/adminItemDrafts.ts` — list plus four actions. +- `backend/tests/unit/priceSource.test.ts` +- `backend/tests/integration/adminItemDrafts.integration.test.ts` +- `frontend/src/admin/draftsApi.ts` — typed client. +- `frontend/src/admin/DraftQueue.tsx` — the screen. +- `frontend/tests/e2e/admin-draft-queue.spec.ts` + +**Modified:** +- `backend/src/app.ts` — mount the router +- `frontend/src/admin/Admin.tsx` — register the tab + +--- + +### Task 1: The price provenance rule + +The one piece of logic on this screen that must not be wrong, so it is pure and tested on its own rather than buried in a route. + +**Files:** +- Create: `backend/src/intake/priceSource.ts`, `backend/tests/unit/priceSource.test.ts` + +**Interfaces:** +- Produces: `type PriceSource = 'default' | 'ai' | 'admin'`, `nextPriceSource(current: PriceSource, submittedCents: number, storedCents: number): PriceSource`, `isUnconfirmed(source: PriceSource): boolean` + +- [ ] **Step 1: Write the failing test** + +```ts +import { nextPriceSource, isUnconfirmed } from '../../src/intake/priceSource'; + +describe('nextPriceSource', () => { + // Touching the number is the admin taking responsibility for it. That is the + // only event that can confirm a price. + it('becomes admin when the number changes', () => { + expect(nextPriceSource('default', 9500, 8000)).toBe('admin'); + expect(nextPriceSource('ai', 4000, 4500)).toBe('admin'); + }); + + // Publishing without touching the field must NOT silently confirm it. That + // is the whole failure this screen exists to prevent: an item selling at a + // number nobody chose, with nothing recording that. + it('leaves an untouched price unconfirmed', () => { + expect(nextPriceSource('default', 8000, 8000)).toBe('default'); + expect(nextPriceSource('ai', 4500, 4500)).toBe('ai'); + }); + + // Already confirmed stays confirmed, including when re-submitted unchanged. + it('keeps admin once set', () => { + expect(nextPriceSource('admin', 9500, 9500)).toBe('admin'); + expect(nextPriceSource('admin', 7000, 9500)).toBe('admin'); + }); +}); + +describe('isUnconfirmed', () => { + it('treats anything but admin as unconfirmed', () => { + expect(isUnconfirmed('default')).toBe(true); + expect(isUnconfirmed('ai')).toBe(true); + expect(isUnconfirmed('admin')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.unit.config.js priceSource +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Write it** + +```ts +/** + * Where an item's price came from, and when that changes. + * + * This is the protection that used to live in the schema. Items are priced on + * arrival — the model's suggestion, or the 80.00 default — so nothing stops a + * number nobody chose from reaching the storefront except this screen showing + * that it was never chosen. + * + * Pure and separately tested because the failure is silent. An item that sells + * at a default price looks exactly like an item that sells at a chosen one; + * 80.00 was picked precisely because it reads as a decision rather than as an + * obvious sentinel like 0.00 would. + */ +export type PriceSource = 'default' | 'ai' | 'admin'; + +/** + * Editing the number is the admin taking responsibility for it, and it is the + * only thing that can. Publishing without touching the field deliberately does + * NOT confirm it — that would turn "I did not look at this" into "I approved + * this", which is the exact misrecording this screen exists to prevent. + */ +export function nextPriceSource( + current: PriceSource, + submittedCents: number, + storedCents: number +): PriceSource { + if (current === 'admin') return 'admin'; + return submittedCents === storedCents ? current : 'admin'; +} + +/** Anything a person did not choose, which the screen marks visibly. */ +export function isUnconfirmed(source: PriceSource): boolean { + return source !== 'admin'; +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.unit.config.js priceSource +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/intake/priceSource.ts backend/tests/unit/priceSource.test.ts +git commit -m "feat(intake): record who chose an item's price (#225)" +``` + +--- + +### Task 2: Listing the queue + +**Files:** +- Create: `backend/src/routes/adminItemDrafts.ts`, `backend/tests/integration/adminItemDrafts.integration.test.ts` +- Modify: `backend/src/app.ts` + +**Interfaces:** +- Consumes: `PriceSource` from Task 1 +- Produces: `GET /api/admin/item-drafts?state=` returning `{ drafts: DraftRow[] }` + +- [ ] **Step 1: Write the failing test** + +```ts +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +const GATE = { 'x-admin-gate': process.env.ADMIN_GATE_SECRET ?? '' }; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +async function seedDraft(overrides: { state?: string; aiName?: string } = {}): Promise { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name, status) VALUES ('Submission 2026-09-01', 'pending') RETURNING id` + ); + const itemId = rows[0]!.id; + await pool.query( + `INSERT INTO item_drafts (item_id, submitter_note, state, ai_name, ai_description, price_source) + VALUES ($1, 'found in a loft', $2, $3, 'A blue vase.', 'ai')`, + [itemId, overrides.state ?? 'ready', overrides.aiName ?? 'Blue vase'] + ); + await pool.query( + `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0)`, + [itemId] + ); + return itemId; +} + +describe('GET /api/admin/item-drafts', () => { + it('returns the draft with its item, photos and note', async () => { + const itemId = await seedDraft(); + + const res = await request(app).get('/api/admin/item-drafts').set(GATE); + + expect(res.status).toBe(200); + const draft = res.body.drafts.find((d: { item_id: number }) => d.item_id === itemId); + expect(draft).toBeDefined(); + expect(draft.submitter_note).toBe('found in a loft'); + expect(draft.ai_name).toBe('Blue vase'); + expect(draft.price_cents).toBe(8000); + expect(draft.price_source).toBe('ai'); + expect(draft.images).toHaveLength(1); + }); + + it('filters by state', async () => { + const ready = await seedDraft({ state: 'ready' }); + const failed = await seedDraft({ state: 'failed' }); + + const res = await request(app).get('/api/admin/item-drafts?state=failed').set(GATE); + + const ids = res.body.drafts.map((d: { item_id: number }) => d.item_id); + expect(ids).toContain(failed); + expect(ids).not.toContain(ready); + }); + + // Discarded is recoverable, so it has to be reachable — but it must not sit + // in the default view competing with work that still needs doing. + it('hides discarded drafts unless they are asked for', async () => { + const discarded = await seedDraft({ state: 'discarded' }); + + const def = await request(app).get('/api/admin/item-drafts').set(GATE); + expect(def.body.drafts.map((d: { item_id: number }) => d.item_id)).not.toContain(discarded); + + const asked = await request(app).get('/api/admin/item-drafts?state=discarded').set(GATE); + expect(asked.body.drafts.map((d: { item_id: number }) => d.item_id)).toContain(discarded); + }); + + it('refuses without the admin gate', async () => { + await seedDraft(); + const res = await request(app).get('/api/admin/item-drafts'); + expect(res.status).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Bring up a database (see Global Constraints), then: + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand adminItemDrafts +``` + +Expected: FAIL — 404, the route does not exist. + +- [ ] **Step 3: Write the router** + +Create `backend/src/routes/adminItemDrafts.ts`: + +```ts +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; +import { asyncRoute } from '../asyncRoute'; + +const router = Router(); + +/** + * The review queue: everything waiting for a person, with what a person needs + * to decide. + * + * Columns are spelled out rather than `d.*` so a column added to item_drafts + * later — a cost, a token count, an error — does not silently start being sent + * to the browser. The images come back as an aggregate rather than a second + * round trip, matching itemSelect.ts. + */ +const DRAFT_SELECT = ` + SELECT d.item_id, d.state, d.attempts, d.submitter_note, d.ai_error, + d.ai_name, d.ai_description, d.ai_category_id, d.ai_tag_names, + d.ai_suggested_price_cents, d.price_source, d.model, d.drafted_at, + d.created_at, + i.name AS item_name, i.description AS item_description, + i.price_cents, i.status, + l.label AS upload_link_label, + COALESCE(( + SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path) + ORDER BY img.sort_order) + FROM item_images img WHERE img.item_id = d.item_id + ), '[]'::json) AS images + FROM item_drafts d + JOIN items i ON i.id = d.item_id + LEFT JOIN upload_links l ON l.id = d.upload_link_id +`; + +/** + * Discarded is excluded by default rather than deleted. Discard has to be + * recoverable — it is one click from an inbox — but a discarded row sitting in + * the default view would compete with work that still needs doing. + */ +router.get('/', asyncRoute(async (req: Request, res: Response) => { + const state = typeof req.query.state === 'string' ? req.query.state : null; + + const { rows } = state + ? await pool.query(`${DRAFT_SELECT} WHERE d.state = $1 ORDER BY d.created_at DESC`, [state]) + : await pool.query(`${DRAFT_SELECT} WHERE d.state <> 'discarded' ORDER BY d.created_at DESC`); + + res.json({ drafts: rows }); +})); + +export default router; +``` + +- [ ] **Step 4: Mount it** + +In `backend/src/app.ts`, beside the other admin routers: + +```ts +import adminItemDraftsRouter from './routes/adminItemDrafts'; +``` + +and, next to the `upload-links` line: + +```ts +app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter); +``` + +The gate goes on the mount, not inside the router — that is the pattern every other admin router follows, and it is what makes the "refuses without the admin gate" test above pass without a line of code in the handler. + +- [ ] **Step 5: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand adminItemDrafts +npx jest -c jest.unit.config.js routesAreWrapped +``` + +Expected: PASS, 4 integration tests, and the wrapper guard still green. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/routes/adminItemDrafts.ts backend/src/app.ts backend/tests/integration/adminItemDrafts.integration.test.ts +git commit -m "feat(intake): list submitted items waiting for review (#225)" +``` + +--- + +### Task 3: Publishing + +**Files:** +- Modify: `backend/src/routes/adminItemDrafts.ts`, `backend/tests/integration/adminItemDrafts.integration.test.ts` + +**Interfaces:** +- Consumes: `nextPriceSource` from Task 1 +- Produces: `POST /api/admin/item-drafts/:itemId/publish` taking `{ name, description, priceCents, categoryId?, tagNames? }` + +- [ ] **Step 1: Write the failing test** + +Append to the integration test: + +```ts +describe('POST /api/admin/item-drafts/:itemId/publish', () => { + const body = { name: 'Blue stoneware vase', description: 'Chipped base.', priceCents: 9500 }; + + it('writes the edited copy onto the item and publishes it', async () => { + const itemId = await seedDraft(); + + const res = await request(app) + .post(`/api/admin/item-drafts/${itemId}/publish`) + .set(GATE) + .send(body); + + expect(res.status).toBe(200); + const { rows } = await pool.query( + `SELECT name, description, price_cents, status FROM items WHERE id = $1`, + [itemId] + ); + expect(rows[0]).toMatchObject({ + name: 'Blue stoneware vase', + description: 'Chipped base.', + price_cents: 9500, + status: 'available' + }); + }); + + // The transition Task 1 defines, asserted end to end: a changed number is + // now the admin's. + it('records an edited price as the admin choice', async () => { + const itemId = await seedDraft(); + + await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).set(GATE).send(body); + + const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [ + itemId + ]); + expect(rows[0]?.price_source).toBe('admin'); + }); + + // And the case that matters more: publishing without touching the number + // must leave it recorded as unconfirmed rather than quietly claiming the + // admin chose it. + it('leaves an untouched price unconfirmed', async () => { + const itemId = await seedDraft(); + + await request(app) + .post(`/api/admin/item-drafts/${itemId}/publish`) + .set(GATE) + .send({ ...body, priceCents: 8000 }); + + const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [ + itemId + ]); + expect(rows[0]?.price_source).toBe('ai'); + }); + + it('refuses a publish with no name', async () => { + const itemId = await seedDraft(); + const res = await request(app) + .post(`/api/admin/item-drafts/${itemId}/publish`) + .set(GATE) + .send({ ...body, name: ' ' }); + + expect(res.status).toBe(400); + const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(rows[0]?.status).toBe('pending'); + }); + + it('refuses a negative price', async () => { + const itemId = await seedDraft(); + const res = await request(app) + .post(`/api/admin/item-drafts/${itemId}/publish`) + .set(GATE) + .send({ ...body, priceCents: -1 }); + expect(res.status).toBe(400); + }); + + it('404s for an item with no draft', async () => { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name) VALUES ('ordinary item') RETURNING id` + ); + const res = await request(app) + .post(`/api/admin/item-drafts/${rows[0]!.id}/publish`) + .set(GATE) + .send(body); + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand adminItemDrafts +``` + +Expected: FAIL — 404 on the publish route. + +- [ ] **Step 3: Write it** + +Add to `adminItemDrafts.ts`, above `export default router`: + +```ts +import { nextPriceSource, PriceSource } from '../intake/priceSource'; + +interface DraftPriceRow { + price_source: PriceSource; + price_cents: number; +} + +/** + * Publish: the edited copy goes onto the item, and the item goes live. + * + * The only path from an intake submission to the storefront. It does what + * mark-available does — status, and clearing the sale and reservation fields — + * rather than calling that route, because both halves must be one transaction: + * an item published with the previous draft's name would be worse than one not + * published at all. + */ +router.post('/:itemId/publish', asyncRoute(async (req: Request, res: Response) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const description = + typeof req.body?.description === 'string' ? req.body.description.trim() : ''; + const priceCents = Number(req.body?.priceCents); + + if (name === '') { + return res.status(400).json({ error: 'a name is required' }); + } + if (!Number.isInteger(priceCents) || priceCents < 0) { + return res.status(400).json({ error: 'a price in whole cents is required' }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Locked for the length of the transaction so two admins publishing the + // same submission cannot interleave a price decision with a name. + const { rows } = await client.query( + `SELECT d.price_source, i.price_cents + FROM item_drafts d JOIN items i ON i.id = d.item_id + WHERE d.item_id = $1 + FOR UPDATE OF d, i`, + [req.params.itemId] + ); + const existing = rows[0]; + if (!existing) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'no draft for this item' }); + } + + const priceSource = nextPriceSource(existing.price_source, priceCents, existing.price_cents); + + await client.query( + `UPDATE items + SET name = $2, description = $3, price_cents = $4, + status = 'available', sold_at = NULL, reserved_until = NULL, paypal_order_id = NULL + WHERE id = $1`, + [req.params.itemId, name, description === '' ? null : description, priceCents] + ); + + await client.query( + `UPDATE item_drafts SET price_source = $2 WHERE item_id = $1`, + [req.params.itemId, priceSource] + ); + + await client.query('COMMIT'); + res.json({ published: true, priceSource }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +})); +``` + +- [ ] **Step 4: Run it to verify it passes** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand adminItemDrafts +``` + +Expected: PASS, 10 integration tests. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/routes/adminItemDrafts.ts backend/tests/integration/adminItemDrafts.integration.test.ts +git commit -m "feat(intake): publish a reviewed item to the storefront (#225)" +``` + +--- + +### Task 4: Regenerate, discard and restore + +**Files:** +- Modify: `backend/src/routes/adminItemDrafts.ts`, `backend/tests/integration/adminItemDrafts.integration.test.ts` + +**Interfaces:** +- Produces: `POST /:itemId/regenerate`, `POST /:itemId/discard`, `POST /:itemId/restore` + +- [ ] **Step 1: Write the failing test** + +```ts +describe('the other three actions', () => { + // Back to queued, and attempts cleared — otherwise a draft that already + // failed three times is regenerated into a state the worker will not pick up, + // and the button does nothing with no way to tell. + it('regenerate re-queues a failed draft and clears its attempts', async () => { + const itemId = await seedDraft({ state: 'failed' }); + await pool.query(`UPDATE item_drafts SET attempts = 3, ai_error = 'boom' WHERE item_id = $1`, [ + itemId + ]); + + const res = await request(app).post(`/api/admin/item-drafts/${itemId}/regenerate`).set(GATE); + + expect(res.status).toBe(200); + const { rows } = await pool.query( + `SELECT state, attempts, ai_error FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]).toMatchObject({ state: 'queued', attempts: 0, ai_error: null }); + }); + + it('discard marks the draft and leaves the item unpublished', async () => { + const itemId = await seedDraft(); + + const res = await request(app).post(`/api/admin/item-drafts/${itemId}/discard`).set(GATE); + + expect(res.status).toBe(200); + const draft = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.state).toBe('discarded'); + const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + }); + + // The reason discard is allowed to be one click. + it('discard does not delete the item or its photos', async () => { + const itemId = await seedDraft(); + await request(app).post(`/api/admin/item-drafts/${itemId}/discard`).set(GATE); + + const item = await pool.query(`SELECT id FROM items WHERE id = $1`, [itemId]); + expect(item.rows).toHaveLength(1); + const images = await pool.query(`SELECT id FROM item_images WHERE item_id = $1`, [itemId]); + expect(images.rows).toHaveLength(1); + }); + + it('discard unpublishes an item that had been published', async () => { + const itemId = await seedDraft(); + await pool.query(`UPDATE items SET status = 'available' WHERE id = $1`, [itemId]); + + await request(app).post(`/api/admin/item-drafts/${itemId}/discard`).set(GATE); + + const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + }); + + it('restore brings a discarded draft back', async () => { + const itemId = await seedDraft({ state: 'discarded' }); + + const res = await request(app).post(`/api/admin/item-drafts/${itemId}/restore`).set(GATE); + + expect(res.status).toBe(200); + const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.state).toBe('ready'); + }); + + // A draft that never drafted must not come back claiming it did. + it('restore returns an undrafted submission to failed, not ready', async () => { + const itemId = await seedDraft({ state: 'discarded' }); + await pool.query(`UPDATE item_drafts SET ai_name = NULL WHERE item_id = $1`, [itemId]); + + await request(app).post(`/api/admin/item-drafts/${itemId}/restore`).set(GATE); + + const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.state).toBe('failed'); + }); + + it('404s each action for an item with no draft', async () => { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name) VALUES ('ordinary item') RETURNING id` + ); + for (const action of ['regenerate', 'discard', 'restore']) { + const res = await request(app) + .post(`/api/admin/item-drafts/${rows[0]!.id}/${action}`) + .set(GATE); + expect(res.status).toBe(404); + } + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand adminItemDrafts +``` + +Expected: FAIL — 404 on all three routes. + +- [ ] **Step 3: Write them** + +```ts +/** + * Regenerate: hand it back to the worker. + * + * attempts is reset along with the state. The worker only picks up rows below + * the attempt cap, so re-queueing a draft that already failed three times + * without clearing them produces a button that appears to work and does + * nothing — and nothing anywhere would say why. + */ +router.post('/:itemId/regenerate', asyncRoute(async (req: Request, res: Response) => { + const { rowCount } = await pool.query( + `UPDATE item_drafts + SET state = 'queued', attempts = 0, ai_error = NULL + WHERE item_id = $1`, + [req.params.itemId] + ); + if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' }); + res.json({ state: 'queued' }); +})); + +/** + * Discard: out of the queue, off the storefront, and still entirely recoverable. + * + * Nothing is deleted — not the item, not the photos. This is one click from an + * inbox, and the photos are often the only copy of something no longer in the + * sender's hands, so the destructive reading of "discard" is not available here. + * The item is returned to pending because a discarded submission must not stay + * on sale. + */ +router.post('/:itemId/discard', asyncRoute(async (req: Request, res: Response) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rowCount } = await client.query( + `UPDATE item_drafts SET state = 'discarded' WHERE item_id = $1`, + [req.params.itemId] + ); + if (rowCount === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'no draft for this item' }); + } + await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [req.params.itemId]); + await client.query('COMMIT'); + res.json({ state: 'discarded' }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +})); + +/** + * Restore: back into the queue, at the state the draft's own contents justify. + * + * Not unconditionally 'ready'. A submission discarded before it was ever + * drafted has no copy, and returning it as ready would present an empty draft + * as a finished one. Judged on whether a name was ever written rather than on + * remembered history, because the previous state is not stored. + */ +router.post('/:itemId/restore', asyncRoute(async (req: Request, res: Response) => { + const { rowCount } = await pool.query( + `UPDATE item_drafts + SET state = CASE WHEN ai_name IS NULL THEN 'failed' ELSE 'ready' END + WHERE item_id = $1`, + [req.params.itemId] + ); + if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' }); + res.json({ restored: true }); +})); +``` + +- [ ] **Step 4: Run it and the guards** + +```bash +cd backend +npx jest -c jest.integration.config.js --runInBand adminItemDrafts +npm run test:unit && npm run lint && npm run build +``` + +Expected: PASS, 17 integration tests; unit, lint and build clean. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/routes/adminItemDrafts.ts backend/tests/integration/adminItemDrafts.integration.test.ts +git commit -m "feat(intake): regenerate, discard and restore a draft (#225)" +``` + +--- + +### Task 5: The screen + +**Files:** +- Create: `frontend/src/admin/draftsApi.ts`, `frontend/src/admin/DraftQueue.tsx` +- Modify: `frontend/src/admin/Admin.tsx` + +**Interfaces:** +- Consumes: the four routes from Tasks 2-4 +- Produces: a `Review queue` tab + +- [ ] **Step 1: Write the client** + +Create `frontend/src/admin/draftsApi.ts`: + +```ts +export type PriceSource = 'default' | 'ai' | 'admin'; + +export interface DraftImage { + id: number; + image_path: string; +} + +export interface Draft { + item_id: number; + state: string; + attempts: number; + submitter_note: string | null; + ai_error: string | null; + ai_name: string | null; + ai_description: string | null; + ai_suggested_price_cents: number | null; + price_source: PriceSource; + model: string | null; + item_name: string; + item_description: string | null; + price_cents: number; + status: string; + upload_link_label: string | null; + images: DraftImage[]; +} + +async function send(path: string, init?: RequestInit): Promise { + return fetch(`/api/admin/item-drafts${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) } + }); +} + +export async function fetchDrafts(state?: string): Promise { + const query = state ? `?state=${encodeURIComponent(state)}` : ''; + const res = await send(query); + if (!res.ok) throw new Error('could not load the review queue'); + return (await res.json()).drafts; +} + +export interface PublishInput { + name: string; + description: string; + priceCents: number; +} + +export async function publishDraft(itemId: number, input: PublishInput): Promise { + const res = await send(`/${itemId}/publish`, { method: 'POST', body: JSON.stringify(input) }); + if (!res.ok) throw new Error((await res.json()).error ?? 'could not publish'); +} + +export async function actOnDraft( + itemId: number, + action: 'regenerate' | 'discard' | 'restore' +): Promise { + const res = await send(`/${itemId}/${action}`, { method: 'POST' }); + if (!res.ok) throw new Error(`could not ${action}`); +} +``` + +- [ ] **Step 2: Write the screen** + +Create `frontend/src/admin/DraftQueue.tsx`: + +```tsx +import { useCallback, useEffect, useState } from 'react'; +import Card from 'antd/es/card'; +import Button from 'antd/es/button'; +import Input from 'antd/es/input'; +import InputNumber from 'antd/es/input-number'; +import Space from 'antd/es/space'; +import Tag from 'antd/es/tag'; +import Select from 'antd/es/select'; +import Empty from 'antd/es/empty'; +import Alert from 'antd/es/alert'; +import Modal from 'antd/es/modal'; +import message from 'antd/es/message'; +import { Draft, PriceSource, actOnDraft, fetchDrafts, publishDraft } from './draftsApi'; + +const { TextArea } = Input; + +/** Mirrors isUnconfirmed on the server. Anything a person did not choose. */ +function isUnconfirmed(source: PriceSource): boolean { + return source !== 'admin'; +} + +function priceLabel(source: PriceSource): string { + if (source === 'admin') return 'you set this price'; + if (source === 'ai') return 'suggested by the model — not confirmed'; + return 'default price — nobody chose this'; +} + +/** + * One submission, with everything needed to judge it. + * + * The price is the reason this screen exists. Items are priced on arrival, so + * nothing stops a number nobody chose from reaching the storefront except this + * saying so — and 80.00 is a plausible price rather than an obvious sentinel, + * which is exactly why it has to be called out rather than left to be noticed. + */ +function DraftCard({ draft, onChanged }: { draft: Draft; onChanged: () => void }) { + const [name, setName] = useState(draft.ai_name ?? draft.item_name); + const [description, setDescription] = useState(draft.ai_description ?? draft.item_description ?? ''); + const [priceCents, setPriceCents] = useState(draft.price_cents); + const [busy, setBusy] = useState(false); + + // Unconfirmed until the number is actually changed. Opening the field and + // leaving it alone is not a decision, and must not be recorded as one. + const unconfirmed = isUnconfirmed(draft.price_source) && priceCents === draft.price_cents; + + const run = async (work: () => Promise) => { + setBusy(true); + try { + await work(); + onChanged(); + } catch (err) { + message.error(err instanceof Error ? err.message : 'that did not work'); + } finally { + setBusy(false); + } + }; + + const publish = () => { + const go = () => run(() => publishDraft(draft.item_id, { name, description, priceCents })); + if (!unconfirmed) return go(); + // Said before, not after. Publishing an unconfirmed price is allowed — + // it is a decision someone is entitled to make — but not by accident. + Modal.confirm({ + title: 'Publish at a price nobody chose?', + content: `This will go on sale at $${(priceCents / 100).toFixed(2)}, which is ${ + draft.price_source === 'ai' ? "the model's suggestion" : 'the default' + } rather than a price you set.`, + okText: 'Publish anyway', + onOk: go + }); + }; + + return ( + {draft.state}} + style={{ marginBottom: 16 }} + data-testid={`draft-${draft.item_id}`} + > + + {draft.ai_error && } + + + {draft.images.map((image) => ( + + ))} + + + {draft.submitter_note && ( + + )} + {draft.upload_link_label && via {draft.upload_link_label}} + + setName(e.target.value)} aria-label="Name" /> +