Files
redefined-designs/backend/src/routes/admin.ts
T
bermudalambandClaude Opus 5 306db81966 fix(db): narrow the status column instead of asserting the row (#308)
`.$castTo<AdminItemRow>()` / `.$castTo<PublicItemRow>()` at the two list-query call sites replaced the entire result type with an assertion rather than narrowing the one column that actually disagreed, which meant a projection that silently lost a column would still compile — exactly the failure mode this task exists to close, and the opposite of what the commit body claims.

Fixed at the source instead: `itemSelect.ts` now defines `ItemsWithStatus`/`ItemDB`, narrowing `items.status` from the schema mirror's `Generated<string>` (a CHECK-constrained text column, so `kysely-codegen` has no literal union to give it) to `Generated<ItemStatus>`, and builds `ItemContext`, `adminItemQuery()` and `publicItemQuery()` from an `itemDb` typed with `ItemDB` instead of `db`/`DB`. Both `$castTo` calls and their comments are gone; the `AdminItemRow[]` / `PublicItemRow[]` annotations at the two call sites now check for real. Verified by temporarily dropping a column from `adminItemQuery`'s projection: the `AdminItemRow[]` assignment failed to compile as expected, confirming the guarantee actually holds.

Also corrected two now-false statements left over from the conversion: `db-kysely/CONVENTIONS.md`'s worked-example section said `buildItemFilterSql` was "still raw `pg`" and that converting it "would put a second copy of a live function in `src/` that nothing calls" — both untrue since #308 shipped it as `itemFilterExpressions`. And two doc comments in `itemSelect.ts` still named the deleted `PUBLIC_ITEM_SELECT`/`ADMIN_ITEM_SELECT` constants instead of the functions that replaced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:08:09 -05:00

469 lines
20 KiB
TypeScript
Executable File

import { Router, Request, Response } from 'express';
import { PoolClient } from 'pg';
import { pool, requireRow } from '../db';
import { adminItemQuery, AdminItemRow, ItemRecord } from '../itemSelect';
import { ItemStatus } from '../types';
import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, itemFilterExpressions, FilterError } from '../itemFilters';
import { readId, tagColorFor } from '../utils';
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
import { removeBackgroundsForItem, restoreOriginalsForItem } from '../intake/backgroundRemoval';
import { rotateItemImage, ImageNotOnItemError } from '../imageRotation';
import { RotateDirection } from '../imageProcessing';
// The upload pipeline moved to src/imageUpload.ts when #222's public intake
// endpoint became a second caller. Mounting uploadImages gets the type
// allowlist, the magic-byte check, and the EXIF-stripping re-encode together —
// which is the point of it being one module rather than something each route
// assembles for itself.
import { uploadImages, insertItemImages } from '../imageUpload';
const router = Router();
/** The next image slot, from a COALESCE'd MAX so it is never null. */
interface MaxSortRow {
max_sort: number;
}
/** Just the status column, read before deciding whether a transition is legal. */
interface ItemStatusRow {
status: ItemStatus;
}
// The multipart body carries category_id and tags as text fields. An absent
// field means "leave as-is" on update, which is why these return undefined
// rather than null for a missing value.
function readCategoryId(value: unknown): number | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined;
return parsed;
}
function readTagNames(value: unknown): string[] | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.trim() === '') return [];
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return undefined;
return parsed.filter((name): name is string => typeof name === 'string');
} catch {
return undefined;
}
}
// Tags typed into the item form may not exist yet. Look each name up
// case-insensitively — matching the unique index — and create the missing ones
// inside the caller's transaction so a later failure rolls them back too.
async function resolveTagIds(client: PoolClient, names: string[]): Promise<number[]> {
const ids: number[] = [];
for (const raw of names) {
const name = raw.trim();
if (!name) continue;
const existing = await client.query(`SELECT id FROM tags WHERE lower(name) = lower($1)`, [name]);
if (existing.rows.length) {
if (!ids.includes(existing.rows[0].id)) ids.push(existing.rows[0].id);
continue;
}
const inserted = await client.query(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id`,
[name, tagColorFor(name)]
);
ids.push(inserted.rows[0].id);
}
return ids;
}
// Tags are replaced wholesale rather than merged — the form submits the full
// set it wants, so removing a chip has to actually remove the row.
async function setItemTags(client: PoolClient, itemId: number, tagIds: number[]): Promise<void> {
await client.query(`DELETE FROM item_tags WHERE item_id = $1`, [itemId]);
for (const tagId of tagIds) {
await client.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
}
}
/**
* The two optional fields the item form submits as multipart text.
*
* Both routes parsed them and refused them identically, eight lines each. The
* distinction being preserved is that `undefined` means "not submitted", which
* update reads as "leave as-is" — so an unparseable value has to be told apart
* from an absent one, which is what makes this more than a null check and worth
* having in one place.
*/
type ParsedItemFields =
| { ok: true; categoryId: number | null | undefined; tagNames: string[] | undefined }
| { ok: false; error: string };
function readOptionalItemFields(body: Record<string, unknown>): ParsedItemFields {
const categoryId = readCategoryId(body.category_id);
if (categoryId === undefined && body.category_id !== undefined) {
return { ok: false, error: 'invalid category_id' };
}
const tagNames = readTagNames(body.tags);
if (tagNames === undefined && body.tags !== undefined) {
return { ok: false, error: 'invalid tags' };
}
return { ok: true, categoryId, tagNames };
}
router.get('/items', asyncRoute(async (req: Request, res: Response) => {
// Same parser and query builder as the storefront, so admin filtering cannot
// drift from what customers see. The one addition is `status`, which is how
// the Inventory tab surfaces Reserved.
let filters;
try {
filters = parseItemFilters(req.query as Record<string, unknown>);
} catch (err) {
if (err instanceof FilterError) {
return res.status(400).json({ error: err.message });
}
throw err;
}
// Favorites belong to a customer, and the admin inventory view is not
// browsing as one. Refused rather than ignored so the mistake is visible.
if (filters.favoritesOnly) {
return res.status(400).json({ error: 'favorites is not a valid inventory filter' });
}
// No interpolation, and nothing to argue about. Until #308 this assembled
// `${ADMIN_ITEM_SELECT} ${where}` from clauses composed at run time, and
// sixteen lines in itemFilters.ts explained why that was safe. The clauses
// are Kysely expressions now: a value cannot reach the SQL text, because the
// types do not let it.
const rows: AdminItemRow[] = await adminItemQuery()
.where((eb) => eb.and(itemFilterExpressions(eb, filters, null)))
.orderBy('i.created_at', 'desc')
.execute();
res.json(rows);
}));
router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const parsed = readOptionalItemFields(req.body);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
const { categoryId, tagNames } = parsed;
const files = (req.files as Express.Multer.File[]) || [];
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<ItemRecord>(
`INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`,
[name, description, Math.round(parseFloat(price) * 100), categoryId ?? null]
);
const item = requireRow(rows, 'the item INSERT');
await insertItemImages(client, item.id, files, 0);
if (tagNames) {
await setItemTags(client, item.id, await resolveTagIds(client, tagNames));
}
await client.query('COMMIT');
// No interpolation here at all now: the whole query is a constant and the id
// is bound as $1. It always was bound — what changed is that a reader no
// longer has to check that the interpolated half carries no caller data,
// because there is no interpolated half. See #294.
const full = await adminItemQuery().where('i.id', '=', item.id).execute();
res.json(requireRow(full, 'the item just inserted'));
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
}));
router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
const { name, description, price } = req.body;
const parsed = readOptionalItemFields(req.body);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
const { categoryId, tagNames } = parsed;
const files = (req.files as Express.Multer.File[]) || [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`,
[name, description, Math.round(parseFloat(price) * 100), itemId]
);
// Only touch the category when the field was actually submitted, so a
// caller that omits it doesn't silently uncategorize the item.
if (categoryId !== undefined) {
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, itemId]);
}
if (tagNames) {
await setItemTags(client, itemId, await resolveTagIds(client, tagNames));
}
if (files.length) {
const { rows: existing } = await client.query<MaxSortRow>(
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
[itemId]
);
// COALESCE'd MAX, so the aggregate always returns exactly one row.
const nextSort = requireRow(existing, 'the MAX(sort_order) aggregate').max_sort + 1;
// Number(), as the setItemTags call above already does: a matched route
// always has this param, but noUncheckedIndexedAccess cannot know that,
// and the helper's typed parameter surfaces what the old inline query's
// unknown[] hid.
await insertItemImages(client, itemId, files, nextSort);
}
await client.query('COMMIT');
// The same constant as the create route above. itemId is caller-controlled
// and goes through the driver as a bound parameter; it never reaches the
// query text.
const full = await adminItemQuery().where('i.id', '=', itemId).execute();
// The create route beside this one has always used requireRow here. This
// one did not, so an UPDATE matching nothing committed happily, the SELECT
// returned nothing, and the caller got 200 with an empty body — a success
// it could do nothing with, and no record anywhere that the item was
// missing. See #207.
const updated = full[0];
if (!updated) return res.status(404).json({ error: 'not found' });
res.json(updated);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
}));
router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
// 404 rather than the 500 a raw Number() produced: 'abc' became NaN, reached
// Postgres as the text "NaN", raised 22P02 on an integer column and told the
// caller the server had broken. An item that cannot exist is not found (#207).
//
// A well-formed but absent id still answers 204. DELETE is idempotent and the
// caller's intent — that the item should not exist — is satisfied either way.
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
// Collected before the delete: favorites cascade with the item, so after it
// is gone there is no record of who was watching. Restricted to unsold items
// because anyone watching a sold one has already been told it went.
const recipients = await collectFavoriteRecipients([itemId], null, true);
await pool.query(`DELETE FROM items WHERE id = $1`, [itemId]);
// Sent only once the delete has succeeded, so nobody hears about a withdrawal
// that did not happen.
await notifyFavoritersOfRemoval(recipients);
res.status(204).end();
}));
router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: Response) => {
// Both ids, not just the first. A route carrying two of them can guard one
// and forget the other, and the forgotten one fails exactly as loudly (#207).
const itemId = readId(req.params.id);
const imageId = readId(req.params.imageId);
if (itemId === null || imageId === null) return res.status(404).json({ error: 'not found' });
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [imageId, itemId]);
res.status(204).end();
}));
router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
const { rows } = await pool.query<ItemRecord>(
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
[itemId]
);
const sold = rows[0];
if (!sold) return res.status(404).json({ error: 'not found' });
// No buyer to exclude: an admin marking an item sold has no associated
// customer, so everyone watching it hears about it. Sent only after the row
// is known to exist, so nobody is told about a sale that did not happen.
await notifyFavoritersOfSale([sold.id], null);
res.json(sold);
}));
// Publishing is the existing mark-available: it already sets status='available'
// and clears sold_at, reserved_until and paypal_order_id, all of which are
// no-ops on a pending item. A second endpoint running the same UPDATE would be
// duplication, so the admin UI labels that button "Publish" when the item is
// pending. This is the reverse, and it is not symmetrical — see the guard.
router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Response) => {
// Guarded before the lookup, so a malformed id is 404 rather than the 500 the
// raw string produced at Postgres. The absent case below was already right;
// only the unreadable one was not (#207).
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
const { rows } = await pool.query<ItemStatusRow>(`SELECT status FROM items WHERE id = $1`, [itemId]);
if (!rows.length) {
return res.status(404).json({ error: 'not found' });
}
const status = requireRow(rows, 'the item status lookup').status;
if (status === 'pending') {
return res.status(400).json({ error: 'this item is already pending' });
}
// Reserved and sold are not drafts. A reserved item is in someone's cart
// right now and hiding it would strand them mid-checkout; a sold item is a
// record of something that happened, and pulling it back would quietly
// rewrite that. Both are refused by name so the reason is on screen rather
// than left to be guessed from a generic error.
if (status === 'reserved') {
return res.status(400).json({ error: 'a customer is holding this item — it cannot be unpublished' });
}
if (status === 'sold') {
return res.status(400).json({ error: 'a sold item cannot be unpublished' });
}
const { rows: updated } = await pool.query<ItemRecord>(
`UPDATE items SET status='pending' WHERE id=$1 RETURNING *`,
[itemId]
);
res.json(updated[0]);
}));
router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.id);
if (itemId === null) return res.status(404).json({ error: 'not found' });
const { rows } = await pool.query<ItemRecord>(
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
WHERE id=$1 RETURNING *`,
[itemId]
);
const available = rows[0];
if (!available) return res.status(404).json({ error: 'not found' });
res.json(available);
}));
/**
* Whether an item with this id exists.
*
* Checked before acting so an absent item is a 404 rather than a cheerful
* summary of nothing. `removeBackgroundsForItem` would happily report
* `total: 0` for an id that was never an item, which is true and useless.
*/
async function itemExists(itemId: number): Promise<boolean> {
const { rows } = await pool.query(`SELECT 1 FROM items WHERE id = $1`, [itemId]);
return rows.length > 0;
}
/**
* Remove the background from every photo of one item.
*
* Per item rather than per photo because an upload is one item: the front, the
* back and the chipped base are three views of one thing, not three things to
* cut out separately (#293).
*
* Answers 200 once the id is valid, even when the sidecar fails. Unlike the
* per-photo endpoints in #281, this acts on several images, so "did it work"
* has no single answer — two of four is the normal shape of a bad day here.
* A 502 would throw away the count, which is the only thing that makes the
* outcome actionable. Non-200 is reserved for not being able to try at all.
*
* No status check. 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.
*/
router.post('/items/:id/remove-backgrounds', asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.id);
if (itemId === null || !(await itemExists(itemId))) {
return res.status(404).json({ error: 'not found' });
}
res.json(await removeBackgroundsForItem(itemId));
}));
/**
* Put every original back.
*
* The reason removing is safe to try. Photos that were never cut out are
* skipped rather than refused, so a half-done item — what a partial failure
* leaves behind — is restorable too.
*
* Answers 200 once the id is valid, same as remove-backgrounds and for the
* same reason: `restoreOriginalsForItem` stops at the first genuine failure
* rather than throwing, so there is always a summary to return, never a bare
* 500 that discards how far it got.
*/
router.post('/items/:id/restore-originals', asyncRoute(async (req: Request, res: Response) => {
const itemId = readId(req.params.id);
if (itemId === null || !(await itemExists(itemId))) {
return res.status(404).json({ error: 'not found' });
}
res.json(await restoreOriginalsForItem(itemId));
}));
/**
* Turn one photo a quarter turn.
*
* On the item rather than on the draft, which is the decision that makes the
* inventory editor free later: an image belongs to an item whether or not a
* draft row exists, so the second screen to want this is the same call from a
* different place, with no new backend at all.
*
* Unlike the per-item background endpoints, this acts on exactly one file, so
* it can honestly answer whether it worked. 204 rather than 200 because
* rotation changes no column — the paths are identical afterwards and only the
* bytes differ, so there is no row worth returning, which is also why
* DELETE /items/:id/images/:imageId is a 204.
*
* A factory rather than two copied handlers: the direction is the only thing
* that differs. Two paths rather than one endpoint taking a direction in the
* body matches how remove-background and restore-original are already spelled.
*/
function rotationRoute(direction: RotateDirection) {
return asyncRoute(async (req: Request, res: Response) => {
// Both ids, not just the first. A route carrying two of them can guard one
// and forget the other, and the forgotten one fails as a 500 rather than
// the 404 that "no such photo" actually means (#207).
const itemId = readId(req.params.id);
const imageId = readId(req.params.imageId);
if (itemId === null || imageId === null) {
return res.status(404).json({ error: 'no such photo on this item' });
}
try {
await rotateItemImage(itemId, imageId, direction);
} catch (err) {
// Only "not on this item" is a 404, and it is indistinguishable from an
// absent one on purpose: an image id is a serial, and confirming which
// ids exist is not something this endpoint should do. Everything else is
// a real fault and stays loud — the file is untouched in every one of
// those cases, because rotateInPlace renames over the original only once
// the new file has been written successfully.
if (err instanceof ImageNotOnItemError) {
return res.status(404).json({ error: 'no such photo on this item' });
}
console.error(`[rotation] item ${itemId}, image ${imageId}:`, err);
return res.status(500).json({
error:
err instanceof Error
? `this photo could not be rotated: ${err.message}`
: 'this photo could not be rotated'
});
}
res.status(204).end();
});
}
router.post('/items/:id/images/:imageId/rotate-left', rotationRoute('left'));
router.post('/items/:id/images/:imageId/rotate-right', rotationRoute('right'));
export default router;