diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index b388d8d..d6b52e7 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -322,12 +322,7 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { '/admin/api/drinks', async (req, reply) => { if (!requireAuth(req, reply)) return; - const { name: rawName, price_cents } = req.body ?? ({} as any); - // Trim + reject non-strings/blank, same as bars already do — an - // untrimmed or empty name shows as a blank tile on the tablet grid, - // and a trailing-space variant of an existing name bypasses the - // UNIQUE index and splits that drink's stats across two rows. - const name = typeof rawName === 'string' ? rawName.trim() : ''; + const { name, price_cents } = req.body ?? ({} as any); if (!name || !isValidCents(price_cents)) return sendProblem(req, reply, 400, 'Bad Request', 'invalid'); try { const info = db @@ -349,18 +344,10 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { if (!requireAuth(req, reply)) return; const id = parseId(req.params.id, req, reply); if (id === null) return; - const { name: rawName, price_cents, archived } = req.body ?? {}; + const { name, price_cents, archived } = req.body ?? {}; if (price_cents !== undefined && !isValidCents(price_cents)) { return sendProblem(req, reply, 400, 'Bad Request', 'invalid price_cents'); } - let name: string | undefined; - if (rawName !== undefined) { - if (typeof rawName !== 'string') { - return sendProblem(req, reply, 400, 'Bad Request', 'invalid name'); - } - name = rawName.trim(); - if (!name) return sendProblem(req, reply, 400, 'Bad Request', 'name empty'); - } const sets: string[] = []; const args: any[] = []; if (name !== undefined) { sets.push('name = ?'); args.push(name); } @@ -368,18 +355,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { if (archived !== undefined) { sets.push('archived = ?'); args.push(archived ? 1 : 0); } if (sets.length === 0) return { ok: true }; args.push(id); - try { - const info = db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args); - if (info.changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'drink not found'); - return { ok: true }; - } catch (e: any) { - // Matches the POST /admin/api/drinks UNIQUE-name handling below — - // this endpoint was the one place that gap hadn't been closed yet. - if (String(e.message).includes('UNIQUE') && String(e.message).includes('drinks.name')) { - return sendProblem(req, reply, 409, 'Conflict', 'name already exists'); - } - throw e; - } + const info = db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args); + if (info.changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'drink not found'); + return { ok: true }; } ); @@ -424,14 +402,7 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { '/admin/api/bars', async (req, reply) => { if (!requireAuth(req, reply)) return; - const rawName = req.body?.name; - // typeof-guard before .trim() — a non-string name (number/object) - // used to reach `.trim()` and throw an uncaught TypeError, surfacing - // as a raw 500 instead of a 400. - if (typeof rawName !== 'string') { - return sendProblem(req, reply, 400, 'Bad Request', 'name required'); - } - const name = rawName.trim(); + const name = req.body?.name?.trim(); const pfand_cents = req.body?.pfand_cents ?? 0; if (!name) return sendProblem(req, reply, 400, 'Bad Request', 'name required'); if (!isValidCents(pfand_cents)) { @@ -457,52 +428,21 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { if (!requireAuth(req, reply)) return; const id = parseId(req.params.id, req, reply); if (id === null) return; - // The drinks-PATCH equivalent checks info.changes === 0 and 404s; a - // PATCH to this multi-statement transaction had no such check at all, - // so a stale admin tab "fixing" a Pfand rate on an already-deleted bar - // got a silent 200 {ok:true} with nothing actually changed. - const bar = db.prepare('SELECT 1 FROM bars WHERE id = ?').get(id); - if (!bar) return sendProblem(req, reply, 404, 'Not Found', 'bar not found'); - - const { name: rawName, pfand_cents, drink_ids } = req.body ?? {}; - let name: string | undefined; - if (rawName !== undefined) { - if (typeof rawName !== 'string') { - return sendProblem(req, reply, 400, 'Bad Request', 'invalid name'); - } - name = rawName.trim(); - if (!name) return sendProblem(req, reply, 400, 'Bad Request', 'name empty'); - } + const { name, pfand_cents, drink_ids } = req.body ?? {}; if (pfand_cents !== undefined && !isValidCents(pfand_cents)) { return sendProblem(req, reply, 400, 'Bad Request', 'invalid pfand_cents'); } - // Deduped once here (existence-checked below, and reused as the - // insert order in the transaction) — a repeated id would otherwise - // trip bar_drinks' own (bar_id, drink_id) primary key. - let uniqueIds: number[] = []; if (drink_ids !== undefined) { if (!Array.isArray(drink_ids) || !drink_ids.every(did => Number.isInteger(did))) { return sendProblem(req, reply, 400, 'Bad Request', 'invalid drink_ids'); } - // drink_ids was only checked for integer-ness, not existence — a - // bogus id threw an uncaught FK-constraint error inside the - // transaction below (rollback held correctly, but surfaced as a - // raw 500 instead of a 400). - uniqueIds = [...new Set(drink_ids)]; - if (uniqueIds.length > 0) { - const placeholders = uniqueIds.map(() => '?').join(','); - const found = db - .prepare(`SELECT id FROM drinks WHERE id IN (${placeholders})`) - .all(...uniqueIds) as { id: number }[]; - if (found.length !== uniqueIds.length) { - return sendProblem(req, reply, 400, 'Bad Request', 'unknown drink_id'); - } - } } try { db.transaction(() => { if (name !== undefined) { - db.prepare('UPDATE bars SET name = ? WHERE id = ?').run(name, id); + const trimmed = name.trim(); + if (!trimmed) throw new Error('name empty'); + db.prepare('UPDATE bars SET name = ? WHERE id = ?').run(trimmed, id); } if (pfand_cents !== undefined) { db.prepare('UPDATE bars SET pfand_cents = ? WHERE id = ?').run(pfand_cents, id); @@ -512,13 +452,18 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { const ins = db.prepare( 'INSERT INTO bar_drinks (bar_id, drink_id, sort_order) VALUES (?, ?, ?)' ); - uniqueIds.forEach((did, idx) => ins.run(id, did, idx)); + // Dedupe: a repeated id would otherwise trip bar_drinks' own + // (bar_id, drink_id) primary key and get mis-reported below as + // "name already exists" via the UNIQUE substring match. + [...new Set(drink_ids)].forEach((did, idx) => ins.run(id, did, idx)); } })(); } catch (e: any) { - if (String(e.message).includes('UNIQUE') && String(e.message).includes('bars.name')) { + const msg = String(e.message); + if (msg.includes('UNIQUE') && msg.includes('bars.name')) { return sendProblem(req, reply, 409, 'Conflict', 'name already exists'); } + if (msg === 'name empty') return sendProblem(req, reply, 400, 'Bad Request', 'name empty'); throw e; } return { ok: true };