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); + }); + }); +});