feat(api): categories, tags, and storefront item filters (#23)

Adds a self-referencing categories tree, a tag registry with
deterministic colours, and item_tags, plus admin CRUD for both.

GET /api/items now accepts category, tags, min_price and max_price.
Category matching walks the subtree with a recursive CTE so selecting a
parent includes everything filed beneath it; tags match with AND via a
count check, since ANY() alone would return items carrying only one of
them. Malformed filter params return 400 rather than being ignored, so a
broken link doesn't quietly list the whole catalogue.

GET /api/filters serves the drawer its tree, tags, and price bounds in
one request.

Item image/tag aggregation moves from LEFT JOIN + GROUP BY to scalar
subqueries. Joining two one-to-many relations multiplies their rows, so
an item with 2 images and 3 tags would have repeated every image three
times once tags were added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:13:51 -05:00
co-authored by Claude Opus 5
parent 766358a9fe
commit 9222e97deb
14 changed files with 1288 additions and 29 deletions
+18 -13
View File
@@ -1,26 +1,31 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
const router = Router();
const SELECT_WITH_IMAGES = `
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at,
COALESCE(
json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL),
'[]'
) AS images
FROM items i
LEFT JOIN item_images img ON img.item_id = i.id
`;
router.get('/', async (req: Request, res: Response) => {
let filters;
try {
filters = parseItemFilters(req.query as Record<string, unknown>);
} catch (err) {
// A malformed filter is returned as an error rather than ignored, so a
// broken link shows itself instead of quietly listing the whole catalogue.
if (err instanceof FilterError) {
return res.status(400).json({ error: err.message });
}
throw err;
}
router.get('/', async (_req: Request, res: Response) => {
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`);
const { clauses, params } = buildItemFilterSql(filters, 1);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows);
});
router.get('/:id', async (req: Request, res: Response) => {
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]);
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});