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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user