diff --git a/backend/src/routes/cart.ts b/backend/src/routes/cart.ts index cc5be3a..df7bbee 100644 --- a/backend/src/routes/cart.ts +++ b/backend/src/routes/cart.ts @@ -3,9 +3,41 @@ import { pool } 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, @@ -24,9 +56,9 @@ const CART_ITEM_SELECT = ` `; 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 { rows: cartRows } = await pool.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); if (!cartRows.length) return res.json({ items: [] }); - const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]); + const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]); res.json({ items }); })); @@ -35,7 +67,7 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r 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 { 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') { @@ -43,13 +75,13 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r return res.status(409).json({ error: 'item is no longer available' }); } - let { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); + let { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); let cartId: number; if (cartRows.length) { cartId = cartRows[0].id; await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]); } else { - const { rows: newCart } = await client.query( + const { rows: newCart } = await client.query( `INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`, [req.customerId] ); @@ -79,7 +111,7 @@ router.delete('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, const client = await pool.connect(); try { await client.query('BEGIN'); - const { rows } = await client.query( + 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 diff --git a/backend/src/routes/cartCheckout.ts b/backend/src/routes/cartCheckout.ts index dd63a7a..2648d63 100644 --- a/backend/src/routes/cartCheckout.ts +++ b/backend/src/routes/cartCheckout.ts @@ -25,6 +25,35 @@ async function getAccessToken(): Promise { return data.access_token; } +/** + * Row shapes for the reads in this file. + * + * Only queries whose rows are actually read carry a type. The INSERTs, UPDATEs, + * DELETEs and the BEGIN/COMMIT/ROLLBACK calls return nothing anyone looks at, + * and annotating them would be ceremony that makes the ones that matter harder + * to pick out. + * + * Kept in step with their SQL by hand: `client.query` asserts a shape rather + * than checking it, because TypeScript never reads the query string. The + * integration suite is what catches a select and its type disagreeing. + */ +interface IdRow { + id: number; +} + +interface CheckoutItemRow { + item_id: number; + price_cents: number; +} + +interface CheckoutOwnerRow { + customer_id: number | null; +} + +interface CheckoutStatusRow { + status: string; +} + interface CartItem { id: number; name: string; @@ -40,10 +69,10 @@ interface LockedCart { // Locks the customer's cart, verifies every item is still reserved to them, // and returns { cartId, items: [{id, name, price_cents}], totalCents }. async function loadLockedCart(client: PoolClient, customerId: number): Promise { - const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]); + const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]); if (!cartRows.length) return null; const cartId = cartRows[0].id; - const { rows: items } = await client.query( + const { rows: items } = await client.query( `SELECT i.id, i.name, i.price_cents FROM cart_items ci JOIN items i ON i.id = ci.item_id @@ -52,7 +81,7 @@ async function loadLockedCart(client: PoolClient, customerId: number): Promise sum + it.price_cents, 0); + const totalCents = items.reduce((sum, it) => sum + it.price_cents, 0); return { cartId, items, totalCents }; } @@ -71,7 +100,7 @@ async function openCheckout( processor: string, processorOrderId: string | null ): Promise { - const { rows: addrRows } = await client.query( + const { rows: addrRows } = await client.query( `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, [shippingAddressId, customerId] ); @@ -80,7 +109,7 @@ async function openCheckout( const cart = await loadLockedCart(client, customerId); if (!cart || !cart.items.length) return { ok: false, error: 'cart is empty' }; - const { rows: checkoutRows } = await client.query( + const { rows: checkoutRows } = await client.query( `INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status) VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`, [customerId, shippingAddressId, processor, processorOrderId, cart.totalCents] @@ -147,11 +176,11 @@ router.post('/paypal/create', requireCustomer, asyncRoute(async (req: Request, r // *after* COMMIT. Sending inside the transaction would email people about a // sale that then rolled back, and would hold the transaction open for SMTP. async function completeCheckout(client: PoolClient, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> { - const { rows: checkoutItems } = await client.query( + const { rows: checkoutItems } = await client.query( `SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`, [checkoutId] ); - const { rows: checkoutRows } = await client.query(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]); + const { rows: checkoutRows } = await client.query(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]); const customerId = checkoutRows[0]?.customer_id; for (const ci of checkoutItems) { @@ -166,7 +195,7 @@ async function completeCheckout(client: PoolClient, checkoutId: number, processo await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]); return { - itemIds: checkoutItems.map((ci: { item_id: number }) => ci.item_id), + itemIds: checkoutItems.map((ci) => ci.item_id), buyerId: customerId ?? null }; } @@ -185,7 +214,7 @@ router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request, return res.status(502).json({ error: 'capture failed', detail: capture }); } - const { rows } = await pool.query( + const { rows } = await pool.query( `SELECT id FROM checkouts WHERE processor_order_id = $1 AND customer_id = $2`, [orderID, req.customerId] ); @@ -252,7 +281,7 @@ webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => { if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { const checkoutId = event.resource?.custom_id; if (checkoutId) { - const { rows } = await pool.query(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]); + const { rows } = await pool.query(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]); if (rows.length && rows[0].status !== 'completed') { const client = await pool.connect(); try {