Two routes on the item, and a small admin config route so the inventory screen can know whether to offer them. Both answer 200 once the id is valid, even when the sidecar fails, and that is a deliberate departure from the per-photo endpoints in #281. Those act on one image, so the request either worked or it did not and 502 says which. These act on several, so "did it work" has no single answer — two of four is the normal shape of a bad day here, not an exception — and a 502 would throw away the count that is the only thing making the outcome actionable. Non-200 is reserved for not being able to try at all, which here means an unreadable or absent id. No status check on either. A sold item's photos are still the shop's photos and improving them changes nothing about the sale; the guards on unpublish protect a checkout in progress and a completed sale, neither of which is at stake in a photograph's background. The config route follows adminVersion's precedent rather than extending the public /api/config: admin-only, one purpose, and the reason written down. The inventory screen had no other way to learn the feature exists, because GET /api/admin/items answers a bare array with several consumers and reshaping it for one boolean is the worse trade. Also extends the admin item select to carry original_image_path on each image, behind a new ADMIN_IMAGES_SUBQUERY kept separate from the shared IMAGES_SUBQUERY the public select uses. Task 3 needs to derive its restore-button label from that field, and the server never sent it for items before this — only the drafts endpoint carried it, added by #281 for the review queue. It stays admin-only for the same reason itemSelect.ts already names PUBLIC_ITEM_SELECT's columns explicitly: an internal original filename is nobody's business on the storefront, and sharing one subquery would put it in every public item response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
// 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 +
|
|
// 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;
|
|
}
|