refactor(backend): type the item query results (#159) #160
@@ -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,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');
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user