// 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. import { ItemStatus } from './types'; export type { ItemStatus }; export class FilterError extends Error {} export interface ItemFilters { // Several categories, combined as OR — a customer picking Furniture and // Decor wants both, not the empty intersection. Deliberately the opposite of // how tagIds combine below, which is AND; the two controls say so in the UI // rather than leaving it to be discovered. // // Each selected id still expands to its descendants, so choosing two parents // means "anything filed under either of them". categoryIds: number[]; tagIds: number[]; minPriceCents: number | null; maxPriceCents: number | null; // Several statuses rather than one, because the control this exists to serve // is not a status filter. "Not sold" is available-or-reserved on the // storefront and available-or-reserved-or-pending in the admin, so it cannot // be expressed as equality against a single value. A single-status filter is // still expressible: it arrives as a list of one, which is how the admin's // old `?status=sold` keeps working unchanged. // // Null means the caller expressed no preference, which is distinct from // asking for every status — the storefront turns the first into its default // and the second into an explicit list. status: ItemStatus[] | null; // Storefront only: "just the items I have favorited". Which customer that // means is not part of the parsed filter — it comes from the session at build // time, so a query string can never name someone else's favorites. favoritesOnly: boolean; } // Matched exactly, not case-insensitively: `items.status` only ever holds these // lowercase values, so accepting 'Reserved' would quietly return nothing rather // than reporting that the filter was wrong. const ITEM_STATUSES: readonly string[] = ['pending', 'available', 'reserved', 'sold']; // Storefront-invalid statuses. This parser is shared with the admin routes, // where filtering by 'pending' is exactly the point, so the public routes have // to refuse it themselves rather than the parser refusing it for everyone. export const NON_PUBLIC_STATUSES: readonly ItemStatus[] = ['pending']; // What the storefront lists when the caller expressed no preference. Named here // rather than implied by the absence of a parameter, because the absence is now // meaningful: before this change no status meant every status, and afterwards it // means these two. Anything reading a shared link from before will get the new // meaning, which is the accepted cost of the default changing. export const STOREFRONT_DEFAULT_STATUSES: readonly ItemStatus[] = ['available', 'reserved']; // What "All" can mean on the storefront, which is not all of them. Pending items // are excluded from every public read unconditionally, so a filter labelled All // must not promise the fourth — a label that delivers less than it says is the // shape this codebase keeps designing against. export const STOREFRONT_ALL_STATUSES: readonly ItemStatus[] = ['available', 'reserved', 'sold']; 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+$/; // Accepts both spellings because these URLs get hand-edited and shared, but // nothing else: '?favorites=yes' is a mistake worth reporting rather than // treating as either on or off. const TRUE_VALUES: readonly string[] = ['1', 'true']; const FALSE_VALUES: readonly string[] = ['0', 'false']; 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); } /** * Comma-separated, the same shape `tags` and `status` already use. * * The parameter keeps its singular name so that every `?category=1` link, * bookmark and shared URL written before this went multi-valued still parses — * as a list of one, needing no alias and leaving no way to give the filter * twice with different meanings. */ function parseCategoryIds(value: unknown): number[] { const raw = singleValue(value, 'category'); const categoryIds: number[] = []; if (!raw) { return categoryIds; } for (const part of raw.split(',')) { const trimmed = part.trim(); if (trimmed === '') { continue; } const id = parseId(trimmed, 'category'); // Duplicates are harmless to the OR below, but they would show twice in // any caller that renders the parsed filter back. if (!categoryIds.includes(id)) { categoryIds.push(id); } } return categoryIds; } function parseTagIds(value: unknown): number[] { const raw = singleValue(value, 'tags'); const tagIds: number[] = []; if (!raw) { return tagIds; } for (const part of raw.split(',')) { const trimmed = part.trim(); if (trimmed === '') { continue; } const id = parseId(trimmed, 'tags'); // Duplicates would inflate the required-match count in buildItemFilterSql // and make the filter match nothing at all. if (!tagIds.includes(id)) { tagIds.push(id); } } return tagIds; } // Comma-separated, matching how `tags` already works, so the two multi-value // parameters in this parser read the same way in a URL. // // An unrecognised name is refused rather than dropped. Silently ignoring one // would turn `?status=available,sold_out` into "available only" — narrower than // what was asked for, and indistinguishable from a filter that worked. function parseStatus(value: unknown): ItemStatus[] | null { const raw = singleValue(value, 'status'); if (raw === null || raw === '') { return null; } const statuses: ItemStatus[] = []; for (const part of raw.split(',')) { const trimmed = part.trim(); if (trimmed === '') { continue; } if (!ITEM_STATUSES.includes(trimmed)) { throw new FilterError('invalid status'); } // Duplicates are harmless in `= ANY(...)`, but removing them keeps the // parsed filter a faithful description of what was asked for. if (!statuses.includes(trimmed as ItemStatus)) { statuses.push(trimmed as ItemStatus); } } // `?status=,,` asked for something and named nothing. Returning null would // silently mean "no status filter", which on the storefront now means the // default rather than everything — a different answer from the one requested. if (statuses.length === 0) { throw new FilterError('invalid status'); } return statuses; } function parseFavoritesOnly(value: unknown): boolean { const raw = singleValue(value, 'favorites'); if (raw === null || raw === '') { return false; } if (TRUE_VALUES.includes(raw)) { return true; } if (FALSE_VALUES.includes(raw)) { return false; } throw new FilterError('invalid favorites'); } // The per-field parsing lives in the helpers above; what stays here is the // order they run in and the one rule that spans two fields. Order is // deliberate and observable: a query wrong in two ways reports the first // field, so moving these lines around changes which error a caller sees. export function parseItemFilters(query: Record): ItemFilters { const categoryIds = parseCategoryIds(query.category); const tagIds = parseTagIds(query.tags); 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'); } const status = parseStatus(query.status); const favoritesOnly = parseFavoritesOnly(query.favorites); return { categoryIds, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly }; } // Returns WHERE fragments plus their parameters, with placeholders numbered // from `startIndex` so the caller can splice these in after its own params. // // SECURITY INVARIANT, and it is load-bearing. Both callers splice these clauses // straight into query text — admin.ts as `${ADMIN_ITEM_SELECT} ${where}`, and // items.ts as `${PUBLIC_ITEM_SELECT} WHERE ${where}`, which is reachable // without signing in. So the only thing that may ever be interpolated into a // string pushed onto `clauses` is a placeholder index: `$${next}`, or // `$${next + 1}` in the tags clause. Every value goes onto `params` and is // bound by the driver. Interpolating a filter value here would be SQL injection // at both call sites, and `parseItemFilters` refusing malformed input is not // what prevents it — these literals would be safe with no parser at all. // // Stated here rather than only at the call sites because this is where the rule // is enforced and where a seventh clause would be added. SonarQube raised S2077 // on the call sites and they are marked Reviewed/Safe (#180); that marking does // not re-raise when this file changes, so this comment and the two tests over // it are what stand between that edit and a live injection. See #202. // // `favoritesCustomerId` is required rather than optional so a caller has to say // whose favorites it means, even when it means nobody's. Both routes already // reject a favorites filter they cannot satisfy, so reaching the throw below is // a programming error — but it is here so that a future caller which forgets // the guard fails loudly instead of quietly ignoring the filter and listing the // whole catalogue. export function buildItemFilterSql( filters: ItemFilters, startIndex: number, favoritesCustomerId: number | null ): BuiltFilter { const clauses: string[] = []; const params: unknown[] = []; let next = startIndex; if (filters.categoryIds.length) { params.push(filters.categoryIds); // Selecting a category means "and everything filed beneath it", so walk the // tree down from each chosen node. A recursive CTE keeps the tree // un-denormalized: reparenting stays a single UPDATE with no stored paths // to rewrite. // // Seeded with `= ANY(...)` rather than one id, so every selected root is // walked in the same recursion. That also gives the OR for free: the union // of the subtrees is exactly "filed under any of these", and an item filed // under two selected branches appears once because IN is a set test. clauses.push(`i.category_id IN ( WITH RECURSIVE subtree AS ( SELECT id FROM categories WHERE id = ANY($${next}::int[]) 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++; } if (filters.status !== null) { params.push(filters.status); // ANY rather than equality, so one status and several use the same clause. // The ::text[] cast is explicit because `status` is a text column and the // driver would otherwise have to infer the array's element type. clauses.push(`i.status = ANY($${next}::text[])`); next++; } if (filters.favoritesOnly) { if (favoritesCustomerId === null) { throw new Error('favorites filter requires a customer id'); } params.push(favoritesCustomerId); // EXISTS rather than a join: an item is favorited by a customer at most // once, but joining would still risk multiplying rows if that ever changed, // and this reads as the membership test it is. clauses.push(`EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $${next})`); next++; } return { clauses, params }; }