Files
redefined-designs/backend/src/routes/cart.ts
T
bermudalamb f32913ef51
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m3s
refactor: turn on noUncheckedIndexedAccess in both workspaces (#101)
Indexing an array now yields `T | undefined`, which is what it always did — the compiler simply says so. Enabled in backend/tsconfig.json and frontend/tsconfig.json, and in tsconfig.sonar.json alongside it so the drift guard stays satisfied.

The sequencing this issue insisted on was right, and the numbers show why. Before #159 typed the query results, `rows[0]` was `any` and `any` indexes to `any`: the flag would have found close to nothing and the count would have changed completely afterwards. With the rows typed it finds 57 — 50 in the backend, 4 in the frontend, 3 in the Playwright suite — and they fall into three kinds.

Thirty are `rows[0]` after a `rows.length` guard. TypeScript cannot connect the two, and rewriting them as `const [row] = rows; if (!row) …` makes the guard and the use the same check, which is better code independently of the flag.

Ten are rows a statement guarantees — `INSERT … RETURNING`, or a lookup for an id the session middleware has already matched. These get `requireRow(rows, what)`, a new helper in db.ts that throws naming the query. A thrown error rather than a non-null assertion: if the assumption is ever wrong, an assertion hands `undefined` to the next line and fails somewhere unrelated, whereas this fails at the query and says which. asyncRoute turns it into a 500, which is the right answer for "the database did not do what the statement says it does". It also states the assumption once instead of ten times.

The rest is ordinary indexing the compiler cannot prove: a regex capture group that the pattern guarantees, `split('+')[0]`, a modulo kept in range, `hasOwnProperty` failing to narrow an index signature, and Express typing route params as an index signature so `req.params.itemId` is `string | undefined` on a route that cannot match without it.

One correction to this issue's premise, which matters for what it was expected to find. The body says "in a handful it does not guard at all", and the unguarded-500 risk it describes was not found. Every `rows[0]` either sits behind a length check or behind a statement that guarantees a row. What the flag actually bought was the ten places where that guarantee was real but unstated, and those now say so.

Two changes worth calling out because they are not mechanical. TAG_COLORS is typed `[string, ...string[]]` in both copies rather than `as const` — the first attempt used `as const`, which narrowed the elements to literals and broke adminTags, so the annotation keeps `string` while telling the compiler index 0 exists. And the filter drawer's slider falls back to the bounds it was given rather than to null, because null there reads as "no filter" and would widen the results rather than leave them unchanged.

Test files needed changes too, since ts-jest compiles them against the same config: a regex destructure in the compose guard, and ten `mock.calls[0][0]` reads where the surrounding assertions already establish the call happened.

Verified: tsc clean over backend, frontend src and the Playwright suite; unit 254/254; integration 238/238; frontend build clean; lint unchanged in both workspaces.

Closes #101
2026-08-24 15:25:09 -05:00

146 lines
5.2 KiB
TypeScript

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<ItemImage, 'id' | 'image_path'>[];
}
/** 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<IdRow>(`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<CartRow>(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<LockedItemRow>(`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<IdRow>(`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<IdRow>(
`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<RemovedItemRow>(
`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;