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 { 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 { 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); } 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;