// 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`; 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; }