Files
redefined-designs/backend/src/routes/adminCategories.ts
T
bermudalambandClaude Opus 5 5f9172512c refactor: clear the 85 minutes of technical debt (#81)
Thirteen of SonarQube's fourteen code smells, with the fourteenth argued as a false positive rather than coded around.

Four of these were not stylistic. The Remove button in Cart's List.Item actions array carried no key, so React could not match it across renders and rebuilt it on every cart render. The cart, customer-auth and favorites providers each passed a freshly allocated object as their context value, which re-renders every consumer whenever the provider renders, cart or session unchanged — and these three wrap the entire storefront, so the fan-out was the whole tree. Two of them also rebuilt a Set on every render for good measure. All four now memoized on the values they actually derive from.

The four cognitive-complexity findings wanted real restructuring rather than suppression. parseItemFilters splits into one helper per field, leaving the function with the order those helpers run in and the single rule that spans two fields; the order is preserved exactly, because a query wrong in two ways reports the first field and rearranging the calls would change which error a caller sees. adminCategories' PUT extracts the parent resolution — the existence check and the cycle check — into a resolver that returns the refusal rather than sending it. App's four-way render chain becomes a Catalogue component, which also removes two of the nested ternaries. Customers' confirm dialog moves to module level, since a function's cognitive complexity counts everything declared inside it and that dialog branches on `disabling` five times.

The rest were mechanical: two more nested ternaries — a status-colour lookup and a pluralisation helper — and one type assertion that asserted the type the expression already had.

Left alone: S6478 on CategoryTreeSelect's popupRender. That is antd's render prop, called as a function and spliced in, never mounted as a component type, so the destroy-the-subtree failure the rule describes cannot happen. Marked false positive in SonarQube with that reasoning rather than contorting the component around a rule that misread it.

Verified rather than assumed. Backend unit 78 pass, integration 134 pass, end-to-end 83 pass, both workspaces build clean. ESLint warnings drop from 35 to 31 with no new file warning — the same React and SonarJS rules #60 turned on are what surfaced this backlog in the first place.

Refs #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:10:12 -05:00

183 lines
6.1 KiB
TypeScript

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<boolean> {
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<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(
`${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;