// 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` 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`; /** * Admin-only images, carrying `original_image_path` alongside the public * fields — the field the inventory screen needs to know whether a photo has a * cut-out to restore (#293). * * A separate subquery rather than adding the column to `IMAGES_SUBQUERY` * itself, for the same reason `PUBLIC_ITEM_SELECT` names its columns instead * of using `i.*`: an original filename is internal — nobody's business on the * storefront — and folding it into the one subquery both selects share would * put it in every public item response too. */ const ADMIN_IMAGES_SUBQUERY = ` COALESCE(( SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order, 'original_image_path', img.original_image_path) 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, ${ADMIN_IMAGES_SUBQUERY}, ${TAGS_SUBQUERY} ${FROM_CLAUSE}`; /** * One admin item, by id — the whole query, not a fragment. * * A named constant rather than `${ADMIN_ITEM_SELECT} WHERE i.id = $1` written * at each call, so no query call site interpolates anything at all. The id was * always bound as $1 and never reached the query text, but S2077 fires on the * template literal rather than on the value, because the rule cannot tell a * module constant from a request field — and neither, at a glance, can a * reader. Hoisting it makes the property structural instead of an assertion * somebody has to re-make every time the line moves. See #294. */ export const ADMIN_ITEM_BY_ID = `${ADMIN_ITEM_SELECT} WHERE i.id = $1`; /** 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; /** * An admin item's image: everything `ItemImage` has, plus where the * background-removed photo's original went. `null` for a photo that was never * cut out. */ export interface AdminItemImage extends ItemImage { original_image_path: string | null; } /** * 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. `images` is narrowed rather than * inherited as-is, to match `ADMIN_IMAGES_SUBQUERY` carrying * `original_image_path` where the public select's images do not. */ export interface AdminItemRow extends ItemRowBase { reserved_until: Date | null; sold_at: Date | null; paypal_order_id: string | null; images: AdminItemImage[]; } /** * 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; }