diff --git a/backend/src/itemSelect.ts b/backend/src/itemSelect.ts index 3b490ad..253d8e2 100644 --- a/backend/src/itemSelect.ts +++ b/backend/src/itemSelect.ts @@ -1,4 +1,18 @@ -// Shared item SELECT shapes for the public and admin routes. +// 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 @@ -6,6 +20,8 @@ // 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) @@ -41,3 +57,33 @@ export const ADMIN_ITEM_SELECT = ` ${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; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 48261fe..d8a8431 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -4,7 +4,7 @@ import { promises as fs } from 'fs'; import { randomUUID } from 'crypto'; import { PoolClient } from 'pg'; import { pool } from '../db'; -import { ADMIN_ITEM_SELECT } from '../itemSelect'; +import { ADMIN_ITEM_SELECT, AdminItemRow } from '../itemSelect'; import { asyncRoute } from '../asyncRoute'; import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; import { tagColorFor } from '../utils'; @@ -235,7 +235,7 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => { const { clauses, params } = buildItemFilterSql(filters, 1, null); const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; - const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); + const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); res.json(rows); })); @@ -270,7 +270,7 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons await setItemTags(client, item.id, await resolveTagIds(client, tagNames)); } await client.query('COMMIT'); - const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]); res.json(full[0]); } catch (err) { await client.query('ROLLBACK'); @@ -323,7 +323,7 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp } } await client.query('COMMIT'); - const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); res.json(full[0]); } catch (err) { await client.query('ROLLBACK'); diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts index bfbab42..290c9ff 100755 --- a/backend/src/routes/items.ts +++ b/backend/src/routes/items.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; -import { PUBLIC_ITEM_SELECT } from '../itemSelect'; +import { PUBLIC_ITEM_SELECT, PublicItemRow } from '../itemSelect'; import { parseItemFilters, buildItemFilterSql, @@ -74,7 +74,7 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => { const { clauses, params } = buildItemFilterSql(effectiveFilters, 1, req.customerId ?? null); const where = [EXCLUDE_PENDING, ...clauses].join(' AND '); - const { rows } = await pool.query( + const { rows } = await pool.query( `${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`, params ); @@ -85,7 +85,7 @@ 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( + const { rows } = await pool.query( `${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`, [req.params.id] ); diff --git a/backend/src/types.ts b/backend/src/types.ts index 98b17bc..545c7c2 100755 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -6,6 +6,12 @@ export interface ItemImage { sort_order: number; } +export interface ItemTag { + id: number; + name: string; + color: string; +} + export interface Item { id: number; name: string;