import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; 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 { const { rows } = await pool.query(`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( `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( `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 { 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( `${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( `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(`${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( `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;