// 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): 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 }; }