feat(intake): list submitted items waiting for review (#225)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 14:10:38 -05:00
co-authored by Claude Opus 5
parent 7522826bd0
commit 81886f84c2
3 changed files with 191 additions and 0 deletions
+2
View File
@@ -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);
+57
View File
@@ -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;