Files
redefined-designs/backend/src/routes/adminItemDrafts.ts
T
bermudalambandClaude Opus 5 d8a7588685 feat(intake): regenerate, discard and restore a draft (#225)
Regenerate clears attempts 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 would produce a button that appears to work, does nothing, and leaves nothing anywhere to say why.

Discard deletes nothing — not the item, not the photographs. It 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 the word is deliberately not available here. The item returns to pending, because a discarded submission must not stay on sale.

Restore returns a draft at the state its own contents justify rather than unconditionally ready. A submission discarded before it was ever drafted has no copy, and coming back as ready would present an empty draft as a finished one. Judged on whether a name was ever written, because the state held before discarding is not stored.

Backend now at 346 unit and 317 integration tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:15:22 -05:00

214 lines
7.7 KiB
TypeScript

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<DraftPriceRow>(
`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;