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 { 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]); } } /** * 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): 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); } 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( `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( `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( `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(`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( `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( `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 { 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;