import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; import { nextPriceSource, PriceSource } from '../intake/priceSource'; 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 }); }) ); 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 performs what * mark-available performs — the status, and clearing the sale and reservation * fields — rather than calling that route, because both halves have to be one * transaction. An item published carrying the previous draft's name would be a * worse outcome 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' }); } // Integer because the column is cents. A fractional value would round // somewhere nobody is looking and sell the item at a price no one entered. 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 one's price decision with another's // 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(); } }) ); /** * 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 has already failed three times * without clearing them produces a button that appears to work, does nothing, * and leaves nothing anywhere to 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 entirely recoverable. * * Nothing is deleted — not the item, not the photographs. This is one click * away in what amounts to 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 deliberately not available here. The item returns 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, because the * state it held before being discarded is not stored anywhere. */ 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 }); }) ); export default router;