import { Router, Request, Response } from 'express'; import { pool, requireRow } from '../db'; import { asyncRoute } from '../asyncRoute'; import { requireCustomer } from '../middleware/customerAuth'; import { getSettings } from '../adminSettings'; import { ItemStatus, ItemImage } from '../types'; const router = Router(); /** * Row shapes for the reads here. As in cartCheckout.ts, only queries whose rows * are read carry a type, and each is kept in step with its SQL by hand. */ interface IdRow { id: number; } /** What CART_ITEM_SELECT returns — a held item as the cart page renders it. */ interface CartRow { item_id: number; added_at: Date; expires_at: Date; name: string; price_cents: number; status: ItemStatus; // COALESCE'd json_agg, so always an array. Only id and image_path are // selected; the cart does not need sort_order. images: Pick[]; } /** The row locked FOR UPDATE before an item is reserved. */ interface LockedItemRow { id: number; status: ItemStatus; } interface RemovedItemRow { item_id: number; } const CART_ITEM_SELECT = ` SELECT ci.item_id, ci.added_at, ci.expires_at, i.name, i.price_cents, i.status, COALESCE( json_agg(json_build_object('id', img.id, 'image_path', img.image_path) ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), '[]' ) AS images FROM cart_items ci JOIN items i ON i.id = ci.item_id LEFT JOIN item_images img ON img.item_id = i.id WHERE ci.cart_id = $1 GROUP BY ci.item_id, ci.added_at, ci.expires_at, i.name, i.price_cents, i.status ORDER BY ci.added_at DESC `; router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows: cartRows } = await pool.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); const [cart] = cartRows; if (!cart) return res.json({ items: [] }); const { rows: items } = await pool.query(CART_ITEM_SELECT, [cart.id]); res.json({ items }); })); router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => { // Express types route params as an index signature, so this is // `string | undefined` even though the route cannot match without it. const itemId = req.params.itemId; if (!itemId) return res.status(400).json({ error: 'itemId is required' }); const client = await pool.connect(); try { await client.query('BEGIN'); const { rows: itemRows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); const item = itemRows[0]; if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } if (item.status !== 'available') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'item is no longer available' }); } const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); const [existingCart] = cartRows; let cartId: number; if (existingCart) { cartId = existingCart.id; await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]); } else { const { rows: newCart } = await client.query( `INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`, [req.customerId] ); cartId = requireRow(newCart, 'the cart INSERT').id; } const { cartExpiryHours } = await getSettings(); const expiresAt = new Date(Date.now() + cartExpiryHours * 60 * 60 * 1000); await client.query( `INSERT INTO cart_items (cart_id, item_id, expires_at) VALUES ($1, $2, $3)`, [cartId, itemId, expiresAt] ); await client.query(`UPDATE items SET status = 'reserved' WHERE id = $1`, [itemId]); await client.query('COMMIT'); res.status(201).json({ itemId: parseInt(itemId, 10), expiresAt }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); } })); router.delete('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => { // Express types route params as an index signature, so this is // `string | undefined` even though the route cannot match without it. const itemId = req.params.itemId; if (!itemId) return res.status(400).json({ error: 'itemId is required' }); const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `DELETE FROM cart_items ci USING carts c WHERE ci.cart_id = c.id AND c.customer_id = $1 AND ci.item_id = $2 RETURNING ci.item_id`, [req.customerId, itemId] ); if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not in your cart' }); } await client.query( `UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [itemId] ); await client.query('COMMIT'); res.status(204).end(); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); } })); export default router;