Feature/224 intake notification #263
@@ -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