diff --git a/backend/src/routes/adminItemDrafts.ts b/backend/src/routes/adminItemDrafts.ts index 635882a..833c4a2 100644 --- a/backend/src/routes/adminItemDrafts.ts +++ b/backend/src/routes/adminItemDrafts.ts @@ -132,4 +132,82 @@ router.post( }) ); +/** + * 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; diff --git a/backend/tests/integration/adminItemDrafts.integration.test.ts b/backend/tests/integration/adminItemDrafts.integration.test.ts index 8f956d6..3c76836 100644 --- a/backend/tests/integration/adminItemDrafts.integration.test.ts +++ b/backend/tests/integration/adminItemDrafts.integration.test.ts @@ -218,3 +218,89 @@ describe('POST /api/admin/item-drafts/:itemId/publish', () => { expect(res.status).toBe(404); }); }); + +describe('the other three actions', () => { + // Back to queued, and attempts cleared — otherwise a draft that already failed + // three times is re-queued into a state the worker will not pick up, and the + // button does nothing with nothing anywhere to say why. + it('regenerate re-queues a failed draft and clears its attempts', async () => { + const itemId = await seedDraft({ state: 'failed' }); + await pool.query(`UPDATE item_drafts SET attempts = 3, ai_error = 'boom' WHERE item_id = $1`, [ + itemId + ]); + + const res = await request(app).post(`/api/admin/item-drafts/${itemId}/regenerate`); + + expect(res.status).toBe(200); + const { rows } = await pool.query( + `SELECT state, attempts, ai_error FROM item_drafts WHERE item_id = $1`, + [itemId] + ); + expect(rows[0]).toMatchObject({ state: 'queued', attempts: 0, ai_error: null }); + }); + + it('discard marks the draft and leaves the item unpublished', async () => { + const itemId = await seedDraft(); + + const res = await request(app).post(`/api/admin/item-drafts/${itemId}/discard`); + + expect(res.status).toBe(200); + const draft = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(draft.rows[0]?.state).toBe('discarded'); + const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + }); + + // The reason discard is allowed to be a single click. + it('discard does not delete the item or its photos', async () => { + const itemId = await seedDraft(); + + await request(app).post(`/api/admin/item-drafts/${itemId}/discard`); + + const item = await pool.query(`SELECT id FROM items WHERE id = $1`, [itemId]); + expect(item.rows).toHaveLength(1); + const images = await pool.query(`SELECT id FROM item_images WHERE item_id = $1`, [itemId]); + expect(images.rows).toHaveLength(1); + }); + + it('discard unpublishes an item that had already been published', async () => { + const itemId = await seedDraft(); + await pool.query(`UPDATE items SET status = 'available' WHERE id = $1`, [itemId]); + + await request(app).post(`/api/admin/item-drafts/${itemId}/discard`); + + const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(item.rows[0]?.status).toBe('pending'); + }); + + it('restore brings a discarded draft back as ready', async () => { + const itemId = await seedDraft({ state: 'discarded' }); + + const res = await request(app).post(`/api/admin/item-drafts/${itemId}/restore`); + + expect(res.status).toBe(200); + const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.state).toBe('ready'); + }); + + // A submission discarded before it was ever drafted has no copy, and must not + // return claiming to have one. + it('restore returns an undrafted submission to failed, not ready', async () => { + const itemId = await seedDraft({ state: 'discarded', aiName: null }); + + await request(app).post(`/api/admin/item-drafts/${itemId}/restore`); + + const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]); + expect(rows[0]?.state).toBe('failed'); + }); + + it('404s each action for an item with no draft', async () => { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name) VALUES ('ordinary item') RETURNING id` + ); + for (const action of ['regenerate', 'discard', 'restore']) { + const res = await request(app).post(`/api/admin/item-drafts/${rows[0]!.id}/${action}`); + expect(res.status).toBe(404); + } + }); +});