Files
redefined-designs/backend/src/routes/admin.ts
T
bermudalamb f32913ef51
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m3s
refactor: turn on noUncheckedIndexedAccess in both workspaces (#101)
Indexing an array now yields `T | undefined`, which is what it always did — the compiler simply says so. Enabled in backend/tsconfig.json and frontend/tsconfig.json, and in tsconfig.sonar.json alongside it so the drift guard stays satisfied.

The sequencing this issue insisted on was right, and the numbers show why. Before #159 typed the query results, `rows[0]` was `any` and `any` indexes to `any`: the flag would have found close to nothing and the count would have changed completely afterwards. With the rows typed it finds 57 — 50 in the backend, 4 in the frontend, 3 in the Playwright suite — and they fall into three kinds.

Thirty are `rows[0]` after a `rows.length` guard. TypeScript cannot connect the two, and rewriting them as `const [row] = rows; if (!row) …` makes the guard and the use the same check, which is better code independently of the flag.

Ten are rows a statement guarantees — `INSERT … RETURNING`, or a lookup for an id the session middleware has already matched. These get `requireRow(rows, what)`, a new helper in db.ts that throws naming the query. A thrown error rather than a non-null assertion: if the assumption is ever wrong, an assertion hands `undefined` to the next line and fails somewhere unrelated, whereas this fails at the query and says which. asyncRoute turns it into a 500, which is the right answer for "the database did not do what the statement says it does". It also states the assumption once instead of ten times.

The rest is ordinary indexing the compiler cannot prove: a regex capture group that the pattern guarantees, `split('+')[0]`, a modulo kept in range, `hasOwnProperty` failing to narrow an index signature, and Express typing route params as an index signature so `req.params.itemId` is `string | undefined` on a route that cannot match without it.

One correction to this issue's premise, which matters for what it was expected to find. The body says "in a handful it does not guard at all", and the unguarded-500 risk it describes was not found. Every `rows[0]` either sits behind a length check or behind a statement that guarantees a row. What the flag actually bought was the ten places where that guarantee was real but unstated, and those now say so.

Two changes worth calling out because they are not mechanical. TAG_COLORS is typed `[string, ...string[]]` in both copies rather than `as const` — the first attempt used `as const`, which narrowed the elements to literals and broke adminTags, so the annotation keeps `string` while telling the compiler index 0 exists. And the filter drawer's slider falls back to the bounds it was given rather than to null, because null there reads as "no filter" and would widen the results rather than leave them unchanged.

Test files needed changes too, since ts-jest compiles them against the same config: a regex destructure in the compose guard, and ten `mock.calls[0][0]` reads where the surrounding assertions already establish the call happened.

Verified: tsc clean over backend, frontend src and the Playwright suite; unit 254/254; integration 238/238; frontend build clean; lint unchanged in both workspaces.

Closes #101
2026-08-24 15:25:09 -05:00

425 lines
16 KiB
TypeScript
Executable File

import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import { promises as fs } from 'fs';
import { randomUUID } from 'crypto';
import { PoolClient } from 'pg';
import { pool, requireRow } from '../db';
import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect';
import { ItemStatus } from '../types';
import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils';
import {
ALLOWED_IMAGE_TYPES,
SIGNATURE_BYTES,
extensionFor,
isAllowedImageType,
signatureMatches
} from '../uploadTypes';
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
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;
}
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;
// Refused before a byte is written. This catches the honest mistake — picking a
// PDF by accident — and nothing more, because file.mimetype is whatever the
// caller wrote in the multipart headers. The bytes are checked after the write;
// see verifyUploadedImages.
class UnsupportedImageTypeError extends Error {}
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.
//
// The extension comes from the validated content type rather than from
// path.extname(file.originalname), so the name on disk cannot disagree with
// what the file claims to be — a caller cannot get `.html` onto the uploads
// volume by naming their file that way.
filename: (_req, file, cb) => {
const ext = extensionFor(file.mimetype);
if (!ext) {
// Unreachable while fileFilter runs first, and here so that it stays
// unreachable rather than silently writing a file with no extension.
cb(new UnsupportedImageTypeError(`unsupported image type ${file.mimetype}`), '');
return;
}
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
},
fileFilter: (_req, file, cb) => {
if (!isAllowedImageType(file.mimetype)) {
cb(new UnsupportedImageTypeError(
`${file.mimetype} is not an accepted image type — allowed: ${ALLOWED_IMAGE_TYPES.join(', ')}`
));
return;
}
cb(null, true);
}
});
// Reads only the leading bytes — enough to identify a format, not enough to
// care how large the file is. The handle is closed before anything is unlinked,
// because an open handle makes the unlink fail on Windows.
async function readHead(filePath: string): Promise<Buffer> {
const handle = await fs.open(filePath, 'r');
try {
const buffer = Buffer.alloc(SIGNATURE_BYTES);
const { bytesRead } = await handle.read(buffer, 0, SIGNATURE_BYTES, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}
// Best effort: a file that cannot be removed should not turn a 400 into a 500,
// but it must not be left behind quietly either.
async function discardUploads(files: Express.Multer.File[]): Promise<void> {
await Promise.all(
files.map((file) =>
fs.unlink(file.path).catch((err: unknown) => {
console.error(`[upload] could not remove rejected file ${file.path}:`, err);
})
)
);
}
/**
* Confirms each stored file actually is what it was declared to be.
*
* This cannot happen in multer's fileFilter, which runs before the stream has
* been read — there are no bytes to look at yet. So the check runs after the
* write, and a failure removes *every* file from the request rather than only
* the offending one: a half-accepted upload would leave files on the volume
* that the request was refused for.
*
* Returns the message to refuse with, or null when everything checks out.
*/
async function verifyUploadedImages(req: Request): Promise<string | null> {
const files = (req.files as Express.Multer.File[]) || [];
for (const file of files) {
const head = await readHead(file.path);
if (!signatureMatches(file.mimetype, head)) {
await discardUploads(files);
return `${file.originalname} does not contain ${file.mimetype} data`;
}
}
return null;
}
// 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 UnsupportedImageTypeError) {
return res.status(400).json({ error: err.message });
}
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
if (err) {
return next(err);
}
verifyUploadedImages(req)
.then((problem) => {
if (problem) {
res.status(400).json({ error: problem });
return;
}
next();
})
.catch(next);
});
};
// 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<AdminItemRow>(`${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<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');
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<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
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 { 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<MaxSortRow>(
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
[req.params.id]
);
// COALESCE'd MAX, so the aggregate always returns exactly one row.
let nextSort = requireRow(existing, 'the MAX(sort_order) aggregate').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<AdminItemRow>(`${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.
await 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<ItemRecord>(
`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]);
}));
// 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) => {
const { rows } = await pool.query<ItemStatusRow>(`SELECT status FROM items WHERE id = $1`, [req.params.id]);
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 *`,
[req.params.id]
);
res.json(updated[0]);
}));
router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => {
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 *`,
[req.params.id]
);
res.json(rows[0]);
}));
export default router;