First stage of typing the query results, and the one that sets the pattern. `pg` types `rows` as `any[]`, so every row this application reads entered a strict codebase as `any` — 1 of roughly 184 query sites carried a type before this. The row types live in itemSelect.ts, beside the selects that produce them, rather than in types.ts. They describe a projection rather than a table, and the two projections differ on purpose: ADMIN_ITEM_SELECT takes `i.*` while PUBLIC_ITEM_SELECT names its columns so the storefront never sees paypal_order_id or reserved_until. Typing both as "an items row" would quietly re-admit exactly the columns that select was written to exclude, so PublicItemRow and AdminItemRow share a base and the admin one adds the three fields it is allowed. types.ts gains ItemTag, which the tags subquery has always built and nothing had named. What this buys, demonstrated rather than claimed: introducing `rows[0].price_cent` at a read site now fails the build with "Property 'price_cent' does not exist on type 'ItemRowBase'. Did you mean 'price_cents'?". Before this it compiled, returned undefined, and reached the customer as an empty price. What it does not buy is written into itemSelect.ts rather than left for the next reader to assume. `pool.query<T>` asserts a shape; it does not check the SQL, which TypeScript never reads. Dropping a column from a select without dropping it from its type compiles cleanly and every read goes on type-checking while being undefined at runtime. The selects and their types are kept in step by hand, and the integration suite is the only thing that catches them disagreeing, because it runs the real queries against a real schema. The acceptance criteria on #159 originally claimed the compiler would catch that; it will not, and the issue has been corrected. Verified: tsc clean, and the four integration suites that exercise these selects pass 87/87. Refs #159
97 lines
4.1 KiB
TypeScript
Executable File
97 lines
4.1 KiB
TypeScript
Executable File
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<string, unknown>);
|
|
} 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);
|
|
const where = [EXCLUDE_PENDING, ...clauses].join(' AND ');
|
|
const { rows } = await pool.query<PublicItemRow>(
|
|
`${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<PublicItemRow>(
|
|
`${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;
|