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
+145
View File
@@ -0,0 +1,145 @@
// Parsing and SQL construction for the storefront's category / tag / price
// filters. Kept apart from the route so the rules can be unit-tested without a
// database, and so items.ts stays a thin handler.
export class FilterError extends Error {}
export interface ItemFilters {
categoryId: number | null;
tagIds: number[];
minPriceCents: number | null;
maxPriceCents: number | null;
}
export interface BuiltFilter {
clauses: string[];
params: unknown[];
}
// Deliberately excludes a leading sign and any decimal point: every filter
// value is a non-negative integer (an id, or a price in cents), so '-1' and
// '10.5' are caller mistakes worth surfacing rather than silently coercing.
const NON_NEGATIVE_INTEGER = /^\d+$/;
function singleValue(value: unknown, name: string): string | null {
if (value === undefined || value === null) {
return null;
}
// Express parses `?category=1&category=2` into an array. Picking one silently
// would make a malformed link look like it worked, so refuse it instead.
if (Array.isArray(value)) {
throw new FilterError(`${name} may only be given once`);
}
if (typeof value !== 'string') {
throw new FilterError(`invalid ${name}`);
}
return value;
}
function parseNonNegativeInteger(raw: string, name: string): number {
if (!NON_NEGATIVE_INTEGER.test(raw)) {
throw new FilterError(`invalid ${name}`);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
throw new FilterError(`invalid ${name}`);
}
return parsed;
}
function parseId(raw: string, name: string): number {
const parsed = parseNonNegativeInteger(raw, name);
if (parsed < 1) {
throw new FilterError(`invalid ${name}`);
}
return parsed;
}
function parsePrice(value: unknown, name: string): number | null {
const raw = singleValue(value, name);
if (raw === null || raw === '') {
return null;
}
return parseNonNegativeInteger(raw, name);
}
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
const categoryRaw = singleValue(query.category, 'category');
const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category');
const tagsRaw = singleValue(query.tags, 'tags');
const tagIds: number[] = [];
if (tagsRaw) {
for (const part of tagsRaw.split(',')) {
const trimmed = part.trim();
if (trimmed === '') {
continue;
}
const id = parseId(trimmed, 'tags');
// Duplicates would inflate the required-match count below and make the
// filter match nothing at all.
if (!tagIds.includes(id)) {
tagIds.push(id);
}
}
}
const minPriceCents = parsePrice(query.min_price, 'min_price');
const maxPriceCents = parsePrice(query.max_price, 'max_price');
if (minPriceCents !== null && maxPriceCents !== null && minPriceCents > maxPriceCents) {
throw new FilterError('min_price may not exceed max_price');
}
return { categoryId, tagIds, minPriceCents, maxPriceCents };
}
// Returns WHERE fragments plus their parameters, with placeholders numbered
// from `startIndex` so the caller can splice these in after its own params.
export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter {
const clauses: string[] = [];
const params: unknown[] = [];
let next = startIndex;
if (filters.categoryId !== null) {
params.push(filters.categoryId);
// Selecting a category means "and everything filed beneath it", so walk the
// tree down from the chosen node. A recursive CTE keeps the tree
// un-denormalized: reparenting stays a single UPDATE with no stored paths
// to rewrite.
clauses.push(`i.category_id IN (
WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $${next}
UNION ALL
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
)
SELECT id FROM subtree
)`);
next++;
}
if (filters.tagIds.length) {
params.push(filters.tagIds, filters.tagIds.length);
// AND, not OR: the item must carry every selected tag. Matching with
// `tag_id = ANY(...)` alone would return items holding just one of them, so
// the count of matched rows has to equal the number requested.
clauses.push(
`(SELECT COUNT(*) FROM item_tags it
WHERE it.item_id = i.id AND it.tag_id = ANY($${next}::int[])) = $${next + 1}`
);
next += 2;
}
if (filters.minPriceCents !== null) {
params.push(filters.minPriceCents);
clauses.push(`i.price_cents >= $${next}`);
next++;
}
if (filters.maxPriceCents !== null) {
params.push(filters.maxPriceCents);
clauses.push(`i.price_cents <= $${next}`);
next++;
}
return { clauses, params };
}