From 81886f84c207f0583273f8cca9a0d0f7edfd7df9 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 1 Sep 2026 14:10:38 -0500 Subject: [PATCH] feat(intake): list submitted items waiting for review (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns are spelled out rather than selected with a wildcard, so a column added to item_drafts later does not silently start reaching the browser. That matters most for the join to upload_links, which carries the token digest — only the label is taken, and a test asserts the digest never appears in a response. Discarded rows are excluded by default rather than deleted. Discard has to be recoverable because it is one click away in what amounts to an inbox, but a discarded row left in the default view would compete for attention with work that still needs doing. The gate goes on the mount in app.ts rather than inside the router, matching every other admin router. Since ADMIN_GATE_SECRET is unset for integration runs the gate is disabled there, so the test that asserts the mount is actually gated sets the secret for its own duration — leaving requireAdminGate off a new mount is otherwise a silent hole. Co-Authored-By: Claude Opus 5 --- backend/src/app.ts | 2 + backend/src/routes/adminItemDrafts.ts | 57 ++++++++ .../adminItemDrafts.integration.test.ts | 132 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 backend/src/routes/adminItemDrafts.ts create mode 100644 backend/tests/integration/adminItemDrafts.integration.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index bf9da94..c254692 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,6 +10,7 @@ import adminEmailTemplatesRouter from './routes/adminEmailTemplates'; import adminCategoriesRouter from './routes/adminCategories'; import adminTagsRouter from './routes/adminTags'; import adminUploadLinksRouter from './routes/adminUploadLinks'; +import adminItemDraftsRouter from './routes/adminItemDrafts'; import intakeRouter from './routes/intake'; import adminVersionRouter from './routes/adminVersion'; import filtersRouter from './routes/filters'; @@ -79,6 +80,7 @@ app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRoute app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter); app.use('/api/admin/tags', requireAdminGate, adminTagsRouter); app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter); +app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter); app.use('/api/admin/version', requireAdminGate, adminVersionRouter); app.use('/api/admin', requireAdminGate, adminRouter); app.use('/api/customers/me/addresses', shippingAddressesRouter); diff --git a/backend/src/routes/adminItemDrafts.ts b/backend/src/routes/adminItemDrafts.ts new file mode 100644 index 0000000..2c80dc0 --- /dev/null +++ b/backend/src/routes/adminItemDrafts.ts @@ -0,0 +1,57 @@ +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 + * in order to decide. + * + * Columns are spelled out rather than `d.*, i.*` so that a column added later — + * a cost, a token count, an internal error — does not silently start being sent + * to the browser. That matters most for the join to upload_links, which carries + * the token digest: only the label is taken. + * + * Images come back as an aggregate rather than a second round trip, matching + * how itemSelect.ts builds them. + */ +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 rows are excluded by default rather than deleted. + * + * Discard has to be recoverable, because it is one click away in what amounts + * to an inbox — but a discarded row left in the default view would compete for + * attention 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; diff --git a/backend/tests/integration/adminItemDrafts.integration.test.ts b/backend/tests/integration/adminItemDrafts.integration.test.ts new file mode 100644 index 0000000..d787f62 --- /dev/null +++ b/backend/tests/integration/adminItemDrafts.integration.test.ts @@ -0,0 +1,132 @@ +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +interface SeedOptions { + state?: string; + aiName?: string | null; + priceSource?: string; +} + +/** + * A submission as the intake route leaves it: a pending item priced at the + * migration's 8000 default, a draft beside it, and one photo. + */ +async function seedDraft(overrides: SeedOptions = {}): 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.', $4)`, + [ + itemId, + overrides.state ?? 'ready', + overrides.aiName === undefined ? 'Blue vase' : overrides.aiName, + overrides.priceSource ?? 'ai' + ] + ); + await pool.query( + `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0)`, + [itemId] + ); + return itemId; +} + +const itemIds = (body: { drafts: { item_id: number }[] }): number[] => + body.drafts.map((draft) => draft.item_id); + +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'); + + 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); + }); + + // The token digest lives on upload_links and must never be selected into a + // response. Spelling the columns is what prevents that; this is the assertion + // that keeps it spelled. + it('never sends the upload link token digest', async () => { + await seedDraft(); + + const res = await request(app).get('/api/admin/item-drafts'); + + expect(JSON.stringify(res.body)).not.toContain('token_hash'); + }); + + 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'); + + expect(itemIds(res.body)).toContain(failed); + expect(itemIds(res.body)).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'); + expect(itemIds(def.body)).not.toContain(discarded); + + const asked = await request(app).get('/api/admin/item-drafts?state=discarded'); + expect(itemIds(asked.body)).toContain(discarded); + }); + + /** + * The gate is disabled when ADMIN_GATE_SECRET is unset, which is how the rest + * of this suite runs, so it is set here for the length of this test alone. + * Worth asserting: the gate goes on the mount in app.ts rather than inside the + * router, and leaving it off a new mount is a silent hole. + */ + describe('with the admin gate configured', () => { + const original = process.env.ADMIN_GATE_SECRET; + + beforeAll(() => { + process.env.ADMIN_GATE_SECRET = 'integration-secret'; + }); + + afterAll(() => { + if (original === undefined) delete process.env.ADMIN_GATE_SECRET; + else process.env.ADMIN_GATE_SECRET = original; + }); + + it('refuses a request with no gate header', async () => { + await seedDraft(); + const res = await request(app).get('/api/admin/item-drafts'); + expect(res.status).toBe(403); + }); + + it('allows a request carrying the secret', async () => { + await seedDraft(); + const res = await request(app) + .get('/api/admin/item-drafts') + .set('X-Admin-Gate', 'integration-secret'); + expect(res.status).toBe(200); + }); + }); +});