`.$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>
469 lines
20 KiB
TypeScript
Executable File
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;
|