refactor(backend): type the item query results (#159) #160

Merged
bermudalamb merged 2 commits from feature/159-type-query-results into main 2026-08-24 13:01:48 -05:00
4 changed files with 60 additions and 8 deletions
+47 -1
View File
@@ -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<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 + // 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 // 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 // rows, silently repeating every image three times. Subqueries keep each
// aggregate independent and drop the GROUP BY entirely. // aggregate independent and drop the GROUP BY entirely.
import { ItemStatus, ItemImage, ItemTag } from './types';
const IMAGES_SUBQUERY = ` const IMAGES_SUBQUERY = `
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) 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}, ${IMAGES_SUBQUERY},
${TAGS_SUBQUERY} ${TAGS_SUBQUERY}
${FROM_CLAUSE}`; ${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;
}
+4 -4
View File
@@ -4,7 +4,7 @@ import { promises as fs } from 'fs';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { PoolClient } from 'pg'; import { PoolClient } from 'pg';
import { pool } from '../db'; import { pool } from '../db';
import { ADMIN_ITEM_SELECT } from '../itemSelect'; import { ADMIN_ITEM_SELECT, AdminItemRow } from '../itemSelect';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils'; 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 { clauses, params } = buildItemFilterSql(filters, 1, null);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; 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<AdminItemRow>(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows); 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 setItemTags(client, item.id, await resolveTagIds(client, tagNames));
} }
await client.query('COMMIT'); 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<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
res.json(full[0]); res.json(full[0]);
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -323,7 +323,7 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
} }
} }
await client.query('COMMIT'); 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<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
res.json(full[0]); res.json(full[0]);
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
+3 -3
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { pool } from '../db'; import { pool } from '../db';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { PUBLIC_ITEM_SELECT } from '../itemSelect'; import { PUBLIC_ITEM_SELECT, PublicItemRow } from '../itemSelect';
import { import {
parseItemFilters, parseItemFilters,
buildItemFilterSql, buildItemFilterSql,
@@ -74,7 +74,7 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
const { clauses, params } = buildItemFilterSql(effectiveFilters, 1, req.customerId ?? null); const { clauses, params } = buildItemFilterSql(effectiveFilters, 1, req.customerId ?? null);
const where = [EXCLUDE_PENDING, ...clauses].join(' AND '); const where = [EXCLUDE_PENDING, ...clauses].join(' AND ');
const { rows } = await pool.query( const { rows } = await pool.query<PublicItemRow>(
`${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`, `${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`,
params 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 // 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 // fetchable by id would be hidden from the catalogue and still reachable by
// anyone who guessed or kept a link. // anyone who guessed or kept a link.
const { rows } = await pool.query( const { rows } = await pool.query<PublicItemRow>(
`${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`, `${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`,
[req.params.id] [req.params.id]
); );
+6
View File
@@ -6,6 +6,12 @@ export interface ItemImage {
sort_order: number; sort_order: number;
} }
export interface ItemTag {
id: number;
name: string;
color: string;
}
export interface Item { export interface Item {
id: number; id: number;
name: string; name: string;