feat(intake): publish a reviewed item to the storefront (#225)
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 the copy and the publish have to be one transaction: an item published carrying the previous draft's name would be worse than one not published at all. The price rule is applied here rather than trusted from the client. A changed number becomes the admin's; an unchanged one keeps whatever it was, so publishing without touching the field records that nobody chose it. The row is locked for the transaction so two admins publishing the same submission cannot interleave one's price decision with another's name. Whole cents only. A fractional value would round somewhere nobody is looking and put the item on sale at a price no one entered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { nextPriceSource, PriceSource } from '../intake/priceSource';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -54,4 +55,81 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -130,3 +130,91 @@ describe('GET /api/admin/item-drafts', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/item-drafts/:itemId/publish', () => {
|
||||
const body = { name: 'Blue stoneware vase', description: 'Chipped base.', priceCents: 9500 };
|
||||
|
||||
it('writes the edited copy onto the item and publishes it', async () => {
|
||||
const itemId = await seedDraft();
|
||||
|
||||
const res = await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).send(body);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT name, description, price_cents, status FROM items WHERE id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: 'Blue stoneware vase',
|
||||
description: 'Chipped base.',
|
||||
price_cents: 9500,
|
||||
status: 'available'
|
||||
});
|
||||
});
|
||||
|
||||
// The transition priceSource.ts defines, asserted end to end: a changed
|
||||
// number is now the admin's responsibility.
|
||||
it('records an edited price as the admin choice', async () => {
|
||||
const itemId = await seedDraft();
|
||||
|
||||
await request(app).post(`/api/admin/item-drafts/${itemId}/publish`).send(body);
|
||||
|
||||
const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [
|
||||
itemId
|
||||
]);
|
||||
expect(rows[0]?.price_source).toBe('admin');
|
||||
});
|
||||
|
||||
// And the case that matters more: publishing without touching the number must
|
||||
// leave it recorded as unconfirmed rather than quietly claiming it was chosen.
|
||||
it('leaves an untouched price unconfirmed', async () => {
|
||||
const itemId = await seedDraft();
|
||||
|
||||
await request(app)
|
||||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||||
.send({ ...body, priceCents: 8000 });
|
||||
|
||||
const { rows } = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [
|
||||
itemId
|
||||
]);
|
||||
expect(rows[0]?.price_source).toBe('ai');
|
||||
});
|
||||
|
||||
it('refuses a publish with no name, and leaves the item unpublished', async () => {
|
||||
const itemId = await seedDraft();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||||
.send({ ...body, name: ' ' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||||
expect(rows[0]?.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('refuses a negative price', async () => {
|
||||
const itemId = await seedDraft();
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||||
.send({ ...body, priceCents: -1 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('refuses a fractional price', async () => {
|
||||
const itemId = await seedDraft();
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/item-drafts/${itemId}/publish`)
|
||||
.send({ ...body, priceCents: 95.5 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('404s for an item with no draft', async () => {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO items (name) VALUES ('ordinary item') RETURNING id`
|
||||
);
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/item-drafts/${rows[0]!.id}/publish`)
|
||||
.send(body);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user