Files
redefined-designs/backend/src/itemSelect.ts
T
bermudalamb d43e2d5871
Linting / lint (pull_request) Successful in 2m11s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m2s
refactor(backend): type the admin item queries, and fix the stale status union (#159)
admin.ts has no untyped reads left. Typed sites go from 43 to 49.

New ItemRecord in itemSelect.ts for the bare `items` row that `RETURNING *` gives back. Deliberately not AdminItemRow: that describes a select which joins the category and adds images and tags as subqueries, so typing a RETURNING * as it would promise three fields the result does not contain. Three shapes for one table, because three different queries return three different things.

The typing found a real defect on its first run, which is the case for doing this at all.

`ItemStatus` in types.ts was `'available' | 'reserved' | 'sold'`. The database has four values and defaults to 'pending' — items have arrived pending since #90. itemFilters.ts declared its own copy that had all four and was correct. Two declarations of one union with nothing connecting them: one went stale and nothing said so.

It was invisible while query rows were `any`. Typing them turned `if (status === 'pending')` in admin.ts into TS2367, "this comparison appears to be unintentional because the types 'ItemStatus' and '\"pending\"' have no overlap" — a compiler telling us the unpublish route's guard could never be true, against a type that was simply wrong.

Confirmed against the database rather than by picking the more plausible of the two declarations: `SELECT DISTINCT status FROM items` returns pending, available, reserved and sold.

Fixed by removing the duplication rather than by patching both copies. types.ts now holds the only declaration and itemFilters.ts imports it, re-exporting so its existing importers are unaffected. Patching both would have left the next drift free to happen the same way.

Verified: tsc clean, unit 254/254, integration 238/238, and backend lint unchanged — the four warnings it reports are identical to those on main with these changes stashed, so none of them are new.

Refs #159
2026-08-24 14:15:27 -05:00

111 lines
3.9 KiB
TypeScript

// Shared item SELECT shapes for the public and admin routes, and the row types
// they return.
//
// The types live here rather than in types.ts because they describe a
// projection, not a table. ADMIN_ITEM_SELECT takes `i.*` and 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.
//
// KEPT IN STEP BY HAND. `pool.query<T>` asserts a shape; it does not check the
// SQL, which TypeScript never reads. Dropping a column from a select below
// without dropping it from its type compiles cleanly and every read of it goes
// on type-checking while being undefined at runtime. The integration suite is
// the only thing that catches that, because it runs these queries against a
// real schema. Change a select and its type together.
//
// Images and tags are pulled as scalar subqueries rather than LEFT JOIN +
// GROUP BY. Joining two one-to-many relations in the same query multiplies
// their rows together — an item with 2 images and 3 tags would aggregate 6
// rows, silently repeating every image three times. Subqueries keep each
// aggregate independent and drop the GROUP BY entirely.
import { ItemStatus, ItemImage, ItemTag } from './types';
const IMAGES_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
ORDER BY img.sort_order)
FROM item_images img
WHERE img.item_id = i.id
), '[]') AS images`;
const TAGS_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name)
FROM item_tags it
JOIN tags t ON t.id = it.tag_id
WHERE it.item_id = i.id
), '[]') AS tags`;
const FROM_CLAUSE = `
FROM items i
LEFT JOIN categories c ON c.id = i.category_id`;
// The storefront gets an explicit column list — it has no business seeing
// paypal_order_id or reserved_until.
export const PUBLIC_ITEM_SELECT = `
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id,
c.name AS category_name,
${IMAGES_SUBQUERY},
${TAGS_SUBQUERY}
${FROM_CLAUSE}`;
export const ADMIN_ITEM_SELECT = `
SELECT i.*,
c.name AS category_name,
${IMAGES_SUBQUERY},
${TAGS_SUBQUERY}
${FROM_CLAUSE}`;
/** The columns every item select returns, whichever of the two it is. */
interface ItemRowBase {
id: number;
name: string;
description: string | null;
price_cents: number;
status: ItemStatus;
created_at: Date;
category_id: number | null;
category_name: string | null;
// json_agg with a COALESCE fallback, so these are always arrays and never null.
images: ItemImage[];
tags: ItemTag[];
}
/** What PUBLIC_ITEM_SELECT returns. Deliberately no payment or reservation columns. */
export type PublicItemRow = ItemRowBase;
/**
* What ADMIN_ITEM_SELECT returns: `i.*`, so every column on the table.
*
* The extra fields are the ones the storefront is not allowed to see, which is
* the whole reason the two selects differ.
*/
export interface AdminItemRow extends ItemRowBase {
reserved_until: Date | null;
sold_at: Date | null;
paypal_order_id: string | null;
}
/**
* A bare `items` row, as `RETURNING *` gives it back.
*
* Distinct from the two select rows above and not interchangeable with them:
* this is the table, so it has no category_name, no images and no tags. Those
* come from the joins and subqueries the selects add, and typing a RETURNING *
* as AdminItemRow would promise three fields that are not in the result.
*/
export interface ItemRecord {
id: number;
name: string;
description: string | null;
price_cents: number;
status: ItemStatus;
reserved_until: Date | null;
sold_at: Date | null;
paypal_order_id: string | null;
created_at: Date;
category_id: number | null;
}