import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; import { PUBLIC_ITEM_SELECT, PublicItemRow } from '../itemSelect'; import { parseItemFilters, buildItemFilterSql, FilterError, NON_PUBLIC_STATUSES, STOREFRONT_DEFAULT_STATUSES, STOREFRONT_ALL_STATUSES } from '../itemFilters'; // Applied to every public read, unconditionally. This route has never had a // status filter of its own — sold items are listed and rendered with a Sold // badge on purpose — so hiding pending items cannot be expressed as one more // optional filter. It has to be a clause the caller cannot opt out of. const EXCLUDE_PENDING = `i.status <> 'pending'`; const router = Router(); router.get('/', asyncRoute(async (req: Request, res: Response) => { let filters; try { filters = parseItemFilters(req.query as Record); } catch (err) { // A malformed filter is returned as an error rather than ignored, so a // broken link shows itself instead of quietly listing the whole catalogue. if (err instanceof FilterError) { return res.status(400).json({ error: err.message }); } throw err; } // 401 rather than an empty list: a signed-out visitor asking for "my // favorites" has no favorites to be empty of, and answering with [] would // render as "no items match these filters" — a plausible-looking lie. The // storefront prompts for sign-in instead of sending this, so reaching here // means a bookmarked link outlived its session. if (filters.favoritesOnly && !req.customerId) { return res.status(401).json({ error: 'sign in to filter by favorites' }); } // Refused rather than quietly answered. The filter parser is shared with the // admin routes, where 'pending' is valid, so it parses here too — and with // the exclusion below it would return an empty list, which reads as "no items // match" rather than "you may not ask that". // // Checked across every requested status, not just a single one: `?status= // available,pending` must be refused for naming pending at all, rather than // quietly answered because the first name in the list happened to be allowed. if (filters.status?.some((status) => NON_PUBLIC_STATUSES.includes(status))) { return res.status(400).json({ error: 'invalid status' }); } // No preference means Not Sold rather than everything. Applied here rather // than in the parser, which is shared with the admin, where the same absence // has to go on meaning "every status including pending". // // Except when the customer asked for their own favorites, where the default // stays everything. A favorite that has just sold is often exactly what the // customer came to look at — they were emailed to say so — and hiding it // would make an item they curated vanish without explanation. That was a // deliberate decision before this filter existed, and defaulting favorites to // Not Sold would have quietly reversed it. An explicit ?status= still wins, // so the choice remains theirs. const defaultStatuses = filters.favoritesOnly ? STOREFRONT_ALL_STATUSES : STOREFRONT_DEFAULT_STATUSES; const effectiveFilters = { ...filters, status: filters.status ?? [...defaultStatuses] }; const { clauses, params } = buildItemFilterSql(effectiveFilters, 1, req.customerId ?? null); // The same construct SonarQube flagged as S2077 in admin.ts and which is // marked Reviewed/Safe there (#180) — and this is the copy reachable without // signing in, so it is worth saying here too rather than relying on the // reader having seen the other one. It holds for the same reason: the clauses // are literals from buildItemFilterSql carrying only placeholder indices, and // EXCLUDE_PENDING is a module constant. Joining with AND cannot weaken // EXCLUDE_PENDING either, because no fragment contains a top-level OR for the // join to re-associate against. const where = [EXCLUDE_PENDING, ...clauses].join(' AND '); const { rows } = await pool.query( `${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`, params ); res.json(rows); })); router.get('/:id', asyncRoute(async (req: Request, res: Response) => { // Excluded here too, not only from the list. A pending item that stayed // fetchable by id would be hidden from the catalogue and still reachable by // anyone who guessed or kept a link. const { rows } = await pool.query( `${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`, [req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'not found' }); res.json(rows[0]); })); export default router;