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>
This commit is contained in:
@@ -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;
|
export default router;
|
||||||
|
|||||||
@@ -218,3 +218,89 @@ describe('POST /api/admin/item-drafts/:itemId/publish', () => {
|
|||||||
expect(res.status).toBe(404);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user