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:
@@ -0,0 +1,43 @@
|
||||
// Shared item SELECT shapes for the public and admin routes.
|
||||
//
|
||||
// Images and tags are pulled as scalar subqueries rather than LEFT JOIN +
|
||||
// GROUP BY. Joining two one-to-many relations in the same query multiplies
|
||||
// their rows together — an item with 2 images and 3 tags would aggregate 6
|
||||
// rows, silently repeating every image three times. Subqueries keep each
|
||||
// aggregate independent and drop the GROUP BY entirely.
|
||||
|
||||
const IMAGES_SUBQUERY = `
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
||||
ORDER BY img.sort_order)
|
||||
FROM item_images img
|
||||
WHERE img.item_id = i.id
|
||||
), '[]') AS images`;
|
||||
|
||||
const TAGS_SUBQUERY = `
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name)
|
||||
FROM item_tags it
|
||||
JOIN tags t ON t.id = it.tag_id
|
||||
WHERE it.item_id = i.id
|
||||
), '[]') AS tags`;
|
||||
|
||||
const FROM_CLAUSE = `
|
||||
FROM items i
|
||||
LEFT JOIN categories c ON c.id = i.category_id`;
|
||||
|
||||
// The storefront gets an explicit column list — it has no business seeing
|
||||
// paypal_order_id or reserved_until.
|
||||
export const PUBLIC_ITEM_SELECT = `
|
||||
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id,
|
||||
c.name AS category_name,
|
||||
${IMAGES_SUBQUERY},
|
||||
${TAGS_SUBQUERY}
|
||||
${FROM_CLAUSE}`;
|
||||
|
||||
export const ADMIN_ITEM_SELECT = `
|
||||
SELECT i.*,
|
||||
c.name AS category_name,
|
||||
${IMAGES_SUBQUERY},
|
||||
${TAGS_SUBQUERY}
|
||||
${FROM_CLAUSE}`;
|
||||
Reference in New Issue
Block a user