diff --git a/backend/src/routes/adminItemDrafts.ts b/backend/src/routes/adminItemDrafts.ts index 2c80dc0..635882a 100644 --- a/backend/src/routes/adminItemDrafts.ts +++ b/backend/src/routes/adminItemDrafts.ts @@ -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( + `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; diff --git a/backend/tests/integration/adminItemDrafts.integration.test.ts b/backend/tests/integration/adminItemDrafts.integration.test.ts index d787f62..8f956d6 100644 --- a/backend/tests/integration/adminItemDrafts.integration.test.ts +++ b/backend/tests/integration/adminItemDrafts.integration.test.ts @@ -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); + }); +});