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