Files
redefined-designs/backend/src/routes/cart.ts
T
bermudalambandClaude Opus 5 1d7aba2d60 fix(backend): route every async handler through the error middleware (#59)
Express 4 does not forward a rejected promise from an async handler, so an unwrapped async route never responds at all — the request hangs until the client gives up, nothing reaches the error middleware, and monitoring sees an open connection rather than a 500. That silence is the shape of the 2026-08-17 incident, where an unhandled rejection left every item query hanging and the storefront rendered it as an empty shop. `asyncRoute` was written in response, but it was only applied to some routes: 30 handlers added afterwards were still bare, including register, login, the whole cart, and PayPal checkout.

Wraps all 30, plus two the issue's inventory missed. `attachCustomer` is a bare async middleware mounted globally in app.ts, so a rejection in its session lookup would hang every request in the application — including the 25 handlers that were already wrapped correctly, which meant the guarantee did not actually hold anywhere. The PayPal webhook registers on a second router named `webhookRouter`, so an audit grepping for `router.` walked straight past it.

Adds a unit test that scans the route sources and fails on any registration whose handler is not wrapped. A convention already half-forgotten once will be forgotten again, and enforcement is what the issue asked for; ESLint would be the better home for it but there is no ESLint in this repo yet (#60). The test walks parens rather than lines, so it also catches a handler whose `async` sits on its own line, and it matches any `*Router` name rather than just `router` — the two ways the existing bare handlers escaped notice. It is deleted along with `asyncRoute` if the project moves to Express 5, which forwards rejections natively.

No behaviour changes on the success path; the failure path turns a hung request into a logged 500.

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:12:25 -05:00

110 lines
4.0 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
const router = Router();
async function getCartExpiryHours(): Promise<number> {
const { rows } = await pool.query(`SELECT value FROM admin_settings WHERE key = 'cart_expiry_hours'`);
return rows.length ? parseFloat(rows[0].value) : 24;
}
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]);
if (!cartRows.length) return res.json({ items: [] });
const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]);
res.json({ items });
}));
router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const itemId = req.params.itemId;
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' });
}
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(
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
[req.customerId]
);
cartId = newCart[0].id;
}
const hours = await getCartExpiryHours();
const expiresAt = new Date(Date.now() + hours * 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) => {
const itemId = req.params.itemId;
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;