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" /> +