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:
+18
-13
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user