Files
redefined-designs/backend/src/routes/adminCategories.ts
T
bermudalamb 179cbad225 refactor(backend): type the remaining query results (#159)
Completes the typing. Every `.query(...)` in backend/src whose rows are read now carries a row type: adminCustomers, adminCategories, shippingAddresses, adminTags, adminEmailTemplates, adminSettings, public, server and the auth middleware. Typed sites go from 49 to 78, and there are no untyped reads left anywhere.

Writes and transaction control stay untyped, which is the exemption #159's criteria allow for and the reason is stated in each file: they return nothing anyone reads, and annotating them would bury the ones that matter.

The aggregates needed checking rather than guessing, and the answer was not what the shapes suggest. Postgres returns COUNT as bigint and SUM as numeric, and node-postgres hands both back as strings — only an explicit ::int cast arrives as a number. Probed against the real database: COUNT(*) is a string, COUNT(*)::int is a number, SUM() is a string, MAX(timestamptz) is a Date.

That makes the admin customer list a mixture. order_count and total_spent_cents are strings; reserved_count, which the query casts, is a number. They are typed as what they are.

Which surfaces a mismatch worth knowing about and not fixed here. frontend/src/admin/adminCustomersApi.ts declares both as `number`, and Customers.tsx sorts with `a.order_count - b.order_count` and renders with `(v / 100).toFixed(2)`. Those work, because `-` and `/` coerce a numeric string. The first `+` written against either — a column total, say — will concatenate instead. Nothing is broken today; the types on both sides simply disagree about reality, and one of them is now right. Changing the API to cast would alter the response shape, which is a behaviour change and belongs in its own issue.

Two smaller shapes worth a note. shipping_addresses.usps_standardized is jsonb that is only ever handed to the client, so it is `unknown` rather than a guessed object. And `SELECT 1 ... ` used purely for `.length` has no column name of its own — Postgres calls it `?column?` — so it is an index signature with nothing read out of it rather than a fabricated field.

Verified: tsc clean, unit 254/254, integration 238/238, backend lint unchanged from main.

Closes #159
2026-08-24 15:03:48 -05:00

212 lines
6.8 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
interface CategoryRow {
id: number;
name: string;
parent_id: number | null;
sort_order: number;
}
/** The tree adds a usage count, cast to int so it arrives as a number. */
interface CategoryListRow extends CategoryRow {
item_count: number;
}
interface IdRow {
id: number;
}
interface CountRow {
n: number;
}
/**
* `SELECT 1 ...`, used only for `.length`. The column has no name of its own —
* Postgres calls it `?column?` — so the shape is an index signature rather than
* a field, and nothing reads a value out of it.
*/
interface ExistsProbe {
[column: string]: number;
}
const router = Router();
// Postgres unique-violation SQLSTATE — raised by the two partial indexes that
// stop siblings sharing a name.
const UNIQUE_VIOLATION = '23505';
// Walks down from a node, collecting it and every descendant. Used both for
// cycle detection on reparent and for reporting the blast radius of a delete.
const SUBTREE_CTE = `
WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $1
UNION ALL
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
)`;
function readName(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
// Distinguishes "not supplied" from "explicitly cleared to root".
function readParentId(value: unknown): number | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined;
return parsed;
}
async function parentExists(id: number): Promise<boolean> {
const { rows } = await pool.query<ExistsProbe>(`SELECT 1 FROM categories WHERE id = $1`, [id]);
return rows.length > 0;
}
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query<CategoryListRow>(
`SELECT c.id, c.name, c.parent_id, c.sort_order,
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
FROM categories c
ORDER BY c.sort_order, lower(c.name)`
);
res.json(rows);
}));
router.post('/', asyncRoute(async (req: Request, res: Response) => {
const name = readName(req.body.name);
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const parentId = readParentId(req.body.parent_id);
if (parentId === undefined && req.body.parent_id !== undefined) {
return res.status(400).json({ error: 'invalid parent_id' });
}
const parent = parentId ?? null;
if (parent !== null && !(await parentExists(parent))) {
return res.status(400).json({ error: 'parent category does not exist' });
}
const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0;
try {
const { rows } = await pool.query<CategoryRow>(
`INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3)
RETURNING id, name, parent_id, sort_order`,
[name, parent, sortOrder]
);
res.status(201).json({ ...rows[0], item_count: 0 });
} catch (err) {
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
return res.status(409).json({ error: 'a category with that name already exists here' });
}
throw err;
}
}));
// Works out what parent_id an update should land on. Absent means "leave it
// alone", so the current value is echoed back rather than treated as a clear.
// Returns the refusal instead of sending it, keeping the response the
// handler's business and the two ways a parent can be invalid out of its body.
type ParentResolution = { error: string } | { parent: number | null };
async function resolveParentId(
submitted: unknown,
id: number,
current: number | null
): Promise<ParentResolution> {
if (submitted === undefined) {
return { parent: current };
}
const parsed = readParentId(submitted);
if (parsed === undefined) {
return { error: 'invalid parent_id' };
}
if (parsed === null) {
return { parent: null };
}
if (!(await parentExists(parsed))) {
return { error: 'parent category does not exist' };
}
// Moving a node beneath itself or one of its own descendants would detach
// that whole branch from the tree into an unreachable cycle.
const { rows: cycle } = await pool.query<ExistsProbe>(
`${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`,
[id, parsed]
);
if (cycle.length) {
return { error: 'a category cannot be moved beneath itself' };
}
return { parent: parsed };
}
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
if (!existing.rows.length) {
return res.status(404).json({ error: 'not found' });
}
let name = existing.rows[0].name;
if (req.body.name !== undefined) {
const parsed = readName(req.body.name);
if (!parsed) {
return res.status(400).json({ error: 'name is required' });
}
name = parsed;
}
const resolved = await resolveParentId(req.body.parent_id, id, existing.rows[0].parent_id);
if ('error' in resolved) {
return res.status(400).json({ error: resolved.error });
}
const parent = resolved.parent;
const sortOrder = Number.isSafeInteger(req.body.sort_order)
? req.body.sort_order
: existing.rows[0].sort_order;
try {
const { rows } = await pool.query<CategoryRow>(
`UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4
RETURNING id, name, parent_id, sort_order`,
[name, parent, sortOrder, id]
);
res.json(rows[0]);
} catch (err) {
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
return res.status(409).json({ error: 'a category with that name already exists here' });
}
throw err;
}
}));
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const { rows: subtree } = await pool.query<IdRow>(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
if (!subtree.length) {
return res.status(404).json({ error: 'not found' });
}
const ids = subtree.map((row: { id: number }) => row.id);
const { rows: affected } = await pool.query<CountRow>(
`SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`,
[ids]
);
// The FK cascade takes the descendants; items fall back to NULL rather than
// being deleted along with their category.
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
}));
export default router;