Express 4 does not forward a rejected promise from an async handler, so an unwrapped async route never responds at all — the request hangs until the client gives up, nothing reaches the error middleware, and monitoring sees an open connection rather than a 500. That silence is the shape of the 2026-08-17 incident, where an unhandled rejection left every item query hanging and the storefront rendered it as an empty shop. `asyncRoute` was written in response, but it was only applied to some routes: 30 handlers added afterwards were still bare, including register, login, the whole cart, and PayPal checkout. Wraps all 30, plus two the issue's inventory missed. `attachCustomer` is a bare async middleware mounted globally in app.ts, so a rejection in its session lookup would hang every request in the application — including the 25 handlers that were already wrapped correctly, which meant the guarantee did not actually hold anywhere. The PayPal webhook registers on a second router named `webhookRouter`, so an audit grepping for `router.` walked straight past it. Adds a unit test that scans the route sources and fails on any registration whose handler is not wrapped. A convention already half-forgotten once will be forgotten again, and enforcement is what the issue asked for; ESLint would be the better home for it but there is no ESLint in this repo yet (#60). The test walks parens rather than lines, so it also catches a handler whose `async` sits on its own line, and it matches any `*Router` name rather than just `router` — the two ways the existing bare handlers escaped notice. It is deleted along with `asyncRoute` if the project moves to Express 5, which forwards rejections natively. No behaviour changes on the success path; the failure path turns a hung request into a logged 500. Closes #59 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
Executable File
280 lines
11 KiB
TypeScript
Executable File
import { Router, Request, Response, NextFunction } from 'express';
|
|
import multer from 'multer';
|
|
import path from 'path';
|
|
import { randomUUID } from 'crypto';
|
|
import { PoolClient } from 'pg';
|
|
import { pool } from '../db';
|
|
import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
|
import { asyncRoute } from '../asyncRoute';
|
|
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
|
import { tagColorFor } from '../utils';
|
|
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
|
|
|
|
const router = Router();
|
|
|
|
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
|
|
|
|
// Multer writes to disk with no size cap unless one is given, so a single
|
|
// request could fill the uploads volume. Bound every dimension of the
|
|
// multipart body: image count, bytes per image, and the small text fields
|
|
// (name/description/price) that accompany them.
|
|
const MAX_IMAGES_PER_REQUEST = 6;
|
|
// 8 MB, not 8 MiB — this is the ceiling S5693 treats as safe, and 8 * 1024 *
|
|
// 1024 sits just over it. Plenty for a product photo either way.
|
|
const MAX_IMAGE_BYTES = 8_000_000;
|
|
const MAX_TEXT_FIELDS = 8;
|
|
const MAX_TEXT_FIELD_BYTES = 64 * 1024;
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: UPLOADS_DIR,
|
|
// Stored names come from a CSPRNG rather than a timestamp plus Math.random,
|
|
// which is predictable enough that a caller could guess (or collide with)
|
|
// another upload's path.
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, `${randomUUID()}${ext}`);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: {
|
|
fileSize: MAX_IMAGE_BYTES,
|
|
files: MAX_IMAGES_PER_REQUEST,
|
|
fields: MAX_TEXT_FIELDS,
|
|
fieldSize: MAX_TEXT_FIELD_BYTES
|
|
}
|
|
});
|
|
|
|
// No error-handling middleware is mounted on the app, so translate multer's
|
|
// limit errors here instead of letting them surface as a generic 500.
|
|
const uploadImages = (req: Request, res: Response, next: NextFunction) => {
|
|
upload.array('images', MAX_IMAGES_PER_REQUEST)(req, res, (err: unknown) => {
|
|
if (err instanceof multer.MulterError) {
|
|
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
|
return res.status(status).json({ error: err.message });
|
|
}
|
|
return next(err);
|
|
});
|
|
};
|
|
|
|
// 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]);
|
|
}
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
const { clauses, params } = buildItemFilterSql(filters, 1, null);
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
|
res.json(rows);
|
|
}));
|
|
|
|
router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Response) => {
|
|
const { name, description, price } = req.body;
|
|
|
|
const categoryId = readCategoryId(req.body.category_id);
|
|
if (categoryId === undefined && req.body.category_id !== undefined) {
|
|
return res.status(400).json({ error: 'invalid category_id' });
|
|
}
|
|
const tagNames = readTagNames(req.body.tags);
|
|
if (tagNames === undefined && req.body.tags !== undefined) {
|
|
return res.status(400).json({ error: 'invalid tags' });
|
|
}
|
|
|
|
const files = (req.files as Express.Multer.File[]) || [];
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows } = await client.query(
|
|
`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 = rows[0];
|
|
for (let i = 0; i < files.length; i++) {
|
|
await client.query(
|
|
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
|
[item.id, `/uploads/${files[i].filename}`, i]
|
|
);
|
|
}
|
|
if (tagNames) {
|
|
await setItemTags(client, item.id, await resolveTagIds(client, tagNames));
|
|
}
|
|
await client.query('COMMIT');
|
|
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
|
|
res.json(full[0]);
|
|
} 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 { name, description, price } = req.body;
|
|
|
|
const categoryId = readCategoryId(req.body.category_id);
|
|
if (categoryId === undefined && req.body.category_id !== undefined) {
|
|
return res.status(400).json({ error: 'invalid category_id' });
|
|
}
|
|
const tagNames = readTagNames(req.body.tags);
|
|
if (tagNames === undefined && req.body.tags !== undefined) {
|
|
return res.status(400).json({ error: 'invalid tags' });
|
|
}
|
|
|
|
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), req.params.id]
|
|
);
|
|
// 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, req.params.id]);
|
|
}
|
|
if (tagNames) {
|
|
await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames));
|
|
}
|
|
if (files.length) {
|
|
const { rows: existing } = await client.query(
|
|
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
|
|
[req.params.id]
|
|
);
|
|
let nextSort = existing[0].max_sort + 1;
|
|
for (const file of files) {
|
|
await client.query(
|
|
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
|
[req.params.id, `/uploads/${file.filename}`, nextSort++]
|
|
);
|
|
}
|
|
}
|
|
await client.query('COMMIT');
|
|
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
|
res.json(full[0]);
|
|
} 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) => {
|
|
const itemId = Number(req.params.id);
|
|
|
|
// 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.
|
|
notifyFavoritersOfRemoval(recipients);
|
|
res.status(204).end();
|
|
}));
|
|
|
|
router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: Response) => {
|
|
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
|
|
res.status(204).end();
|
|
}));
|
|
|
|
router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Response) => {
|
|
const { rows } = await pool.query(
|
|
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
|
|
[req.params.id]
|
|
);
|
|
// No buyer to exclude: an admin marking an item sold has no associated
|
|
// customer, so everyone watching it hears about it.
|
|
await notifyFavoritersOfSale([Number(req.params.id)], null);
|
|
res.json(rows[0]);
|
|
}));
|
|
|
|
router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => {
|
|
const { rows } = await pool.query(
|
|
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
|
|
WHERE id=$1 RETURNING *`,
|
|
[req.params.id]
|
|
);
|
|
res.json(rows[0]);
|
|
}));
|
|
|
|
export default router;
|