import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { DB } from '../db.js'; import { businessDay, formatLocal, parseDbTime } from '../time.js'; const SESSION_COOKIE = 'wutz_admin'; // Generous ceiling for a single drink price or deposit amount — bounds a // typo (or a `2.5`/`"abc"` slipping past a missing check) from wrecking a // bar's pricing. See wutzcalc#12. const MAX_CENTS = 100_000_00; // 100,000 € function isValidCents(v: unknown): v is number { return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_CENTS; } function isAuthed(req: FastifyRequest): boolean { const expected = process.env.ADMIN_PASSWORD; if (!expected) return false; return req.cookies?.[SESSION_COOKIE] === expected; } function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean { if (isAuthed(req)) return true; reply.code(401).send({ error: 'unauthorized' }); return false; } export function registerAdminRoutes(app: FastifyInstance, db: DB) { app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => { const expected = process.env.ADMIN_PASSWORD; if (!expected) return reply.code(500).send({ error: 'ADMIN_PASSWORD not set' }); if (req.body?.password !== expected) return reply.code(401).send({ error: 'wrong password' }); reply.setCookie(SESSION_COOKIE, expected, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, }); return { ok: true }; }); app.post('/admin/logout', async (_req, reply) => { reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; }); app.get('/admin/api/me', async (req) => ({ authed: isAuthed(req) })); // ----- Drinks ----- app.get('/admin/api/drinks', async (req, reply) => { if (!requireAuth(req, reply)) return; return db .prepare('SELECT id, name, price_cents, archived FROM drinks ORDER BY archived, id') .all(); }); app.post<{ Body: { name: string; price_cents: number } }>( '/admin/api/drinks', async (req, reply) => { if (!requireAuth(req, reply)) return; const { name, price_cents } = req.body ?? ({} as any); if (!name || !isValidCents(price_cents)) return reply.code(400).send({ error: 'invalid' }); const info = db .prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)') .run(name, price_cents); return { id: Number(info.lastInsertRowid) }; } ); app.patch<{ Params: { id: string }; Body: { name?: string; price_cents?: number; archived?: boolean } }>( '/admin/api/drinks/:id', async (req, reply) => { if (!requireAuth(req, reply)) return; const id = Number(req.params.id); const { name, price_cents, archived } = req.body ?? {}; if (price_cents !== undefined && !isValidCents(price_cents)) { return reply.code(400).send({ error: 'invalid price_cents' }); } const sets: string[] = []; const args: any[] = []; if (name !== undefined) { sets.push('name = ?'); args.push(name); } if (price_cents !== undefined) { sets.push('price_cents = ?'); args.push(price_cents); } if (archived !== undefined) { sets.push('archived = ?'); args.push(archived ? 1 : 0); } if (sets.length === 0) return { ok: true }; args.push(id); db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args); return { ok: true }; } ); app.delete<{ Params: { id: string } }>('/admin/api/drinks/:id', async (req, reply) => { if (!requireAuth(req, reply)) return; const id = Number(req.params.id); const sold = db .prepare('SELECT 1 FROM transaction_items WHERE drink_id = ? LIMIT 1') .get(id); if (sold) { return reply .code(409) .send({ error: 'Getränk wurde bereits verkauft — bitte archivieren statt löschen.' }); } db.transaction(() => { db.prepare('DELETE FROM bar_drinks WHERE drink_id = ?').run(id); db.prepare('DELETE FROM drinks WHERE id = ?').run(id); })(); return { ok: true }; }); // ----- Bars ----- app.get('/admin/api/bars', async (req, reply) => { if (!requireAuth(req, reply)) return; const bars = db.prepare('SELECT id, name, pfand_cents FROM bars ORDER BY id').all() as any[]; const drinkRows = db .prepare('SELECT bar_id, drink_id, sort_order FROM bar_drinks ORDER BY sort_order, drink_id') .all() as any[]; return bars.map(b => ({ ...b, drink_ids: drinkRows.filter(d => d.bar_id === b.id).map(d => d.drink_id), })); }); app.post<{ Body: { name: string; pfand_cents?: number } }>( '/admin/api/bars', async (req, reply) => { if (!requireAuth(req, reply)) return; const name = req.body?.name?.trim(); const pfand_cents = req.body?.pfand_cents ?? 0; if (!name) return reply.code(400).send({ error: 'name required' }); if (!isValidCents(pfand_cents)) { return reply.code(400).send({ error: 'invalid pfand_cents' }); } try { const info = db .prepare('INSERT INTO bars (name, pfand_cents) VALUES (?, ?)') .run(name, pfand_cents); return { id: Number(info.lastInsertRowid) }; } catch (e: any) { if (String(e.message).includes('UNIQUE')) { return reply.code(409).send({ error: 'name already exists' }); } throw e; } } ); app.patch<{ Params: { id: string }; Body: { name?: string; pfand_cents?: number; drink_ids?: number[] } }>( '/admin/api/bars/:id', async (req, reply) => { if (!requireAuth(req, reply)) return; const id = Number(req.params.id); const { name, pfand_cents, drink_ids } = req.body ?? {}; if (pfand_cents !== undefined && !isValidCents(pfand_cents)) { return reply.code(400).send({ error: 'invalid pfand_cents' }); } try { db.transaction(() => { if (name !== undefined) { 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); } if (Array.isArray(drink_ids)) { db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id); const ins = db.prepare( 'INSERT INTO bar_drinks (bar_id, drink_id, sort_order) VALUES (?, ?, ?)' ); drink_ids.forEach((did, idx) => ins.run(id, did, idx)); } })(); } catch (e: any) { if (String(e.message).includes('UNIQUE')) { return reply.code(409).send({ error: 'name already exists' }); } if (e.message === 'name empty') return reply.code(400).send({ error: 'name empty' }); throw e; } return { ok: true }; } ); app.delete<{ Params: { id: string } }>('/admin/api/bars/:id', async (req, reply) => { if (!requireAuth(req, reply)) return; const id = Number(req.params.id); const used = db .prepare('SELECT 1 FROM transactions WHERE bar_id = ? LIMIT 1') .get(id); if (used) { return reply .code(409) .send({ error: 'Tresen hat Transaktionen — Löschen würde die Statistik verfälschen.' }); } db.transaction(() => { db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id); db.prepare('DELETE FROM bars WHERE id = ?').run(id); })(); return { ok: true }; }); // ----- Stats ----- app.get('/admin/api/stats', async (req, reply) => { if (!requireAuth(req, reply)) return; // LEFT JOIN (not JOIN) so a bar with zero sales still gets a zero row — // an inner join made a brand-new bar indistinguishable from a deleted // one until its first sale, which reads as "my new bar isn't working" // during setup. COUNT(t.id), not COUNT(*): the outer join produces one // NULL-filled row per bar-with-no-transactions, and COUNT(*) would // count that as 1 instead of 0. const totals = db .prepare( `SELECT b.id AS bar_id, b.name AS bar_name, COUNT(t.id) AS tx_count, COALESCE(SUM(CASE WHEN t.crew = 0 THEN t.total_cents ELSE 0 END), 0) AS paid_cents, COALESCE(SUM(CASE WHEN t.crew = 1 THEN 1 ELSE 0 END), 0) AS crew_count, COALESCE(SUM(t.pfand_returns), 0) AS pfand_returns FROM bars b LEFT JOIN transactions t ON t.bar_id = b.id GROUP BY b.id, b.name ORDER BY b.id` ) .all(); const perDrink = db .prepare( `SELECT d.id AS drink_id, d.name AS drink_name, COALESCE(SUM(ti.qty), 0) AS sold_qty FROM transaction_items ti JOIN drinks d ON d.id = ti.drink_id GROUP BY d.id, d.name ORDER BY sold_qty DESC, d.id` ) .all(); // Per business day (sales night runs past midnight — see time.ts). // // Deliberately still computed in JS rather than SQL, despite selecting // every transaction row on every stats load: businessDay() uses // Intl.DateTimeFormat with a named IANA zone (WUTZ_TZ), which handles // DST transitions correctly. A SQL `date(created_at, '-Nh', 'localtime')` // rewrite would use the *server process's* OS timezone (not WUTZ_TZ) and // a fixed hour offset that's wrong on the two nights a year DST changes // — a real correctness regression for a money-adjacent report, to fix a // performance concern that (per the code review that flagged this) is // "fine today" at festival scale. Not worth the trade. const txRows = db .prepare('SELECT created_at, total_cents, crew, pfand_returns FROM transactions') .all() as Array<{ created_at: string; total_cents: number; crew: number; pfand_returns: number }>; const dayMap = new Map(); for (const r of txRows) { const day = businessDay(parseDbTime(r.created_at)); let agg = dayMap.get(day); if (!agg) { agg = { day, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 }; dayMap.set(day, agg); } agg.tx_count += 1; if (r.crew) agg.crew_count += 1; else agg.paid_cents += r.total_cents; agg.pfand_returns += r.pfand_returns; } const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day)); return { totals, per_drink: perDrink, by_day: byDay }; }); app.post('/admin/api/stats/reset', async (req, reply) => { if (!requireAuth(req, reply)) return; db.transaction(() => { db.prepare('DELETE FROM transaction_items').run(); db.prepare('DELETE FROM transactions').run(); })(); return { ok: true }; }); // ----- CSV export ----- app.get<{ Querystring: { what?: string } }>('/admin/api/export.csv', async (req, reply) => { if (!requireAuth(req, reply)) return; const what = req.query.what === 'items' ? 'items' : 'transactions'; let rows: any[]; let header: string[]; if (what === 'transactions') { header = ['id', 'bar_id', 'bar_name', 'created_at', 'total_cents', 'crew', 'pfand_returns', 'pfand_cents_at_sale', 'client_ip', 'client_uuid']; rows = db .prepare( `SELECT t.id, t.bar_id, b.name AS bar_name, t.created_at, t.total_cents, t.crew, t.pfand_returns, t.pfand_cents_at_sale, t.client_ip, t.client_uuid FROM transactions t JOIN bars b ON b.id = t.bar_id ORDER BY t.id` ) .all() as any[]; } else { header = [ 'transaction_id', 'bar_id', 'created_at', 'drink_id', 'drink_name', 'qty', 'unit_price_cents', 'pfand_cents_per_unit', ]; rows = db .prepare( `SELECT ti.transaction_id, t.bar_id, t.created_at, ti.drink_id, d.name AS drink_name, ti.qty, ti.unit_price_cents, ti.pfand_cents_per_unit FROM transaction_items ti JOIN transactions t ON t.id = ti.transaction_id JOIN drinks d ON d.id = ti.drink_id ORDER BY ti.transaction_id, ti.id` ) .all() as any[]; } // created_at is stored as UTC — export it as local wall-clock time. for (const r of rows) { if (r.created_at) r.created_at = formatLocal(parseDbTime(r.created_at)); } const csv = [header.join(',')] .concat(rows.map(r => header.map(h => csvCell(r[h])).join(','))) .join('\n'); reply .header('Content-Type', 'text/csv; charset=utf-8') .header('Content-Disposition', `attachment; filename="${what}.csv"`) .send(csv); }); } function csvCell(v: unknown): string { if (v === null || v === undefined) return ''; let s = String(v); // Neutralise spreadsheet formula injection: a cell starting with one of // these characters is interpreted as a formula by Excel/LibreOffice when // the CSV is opened (e.g. a drink named =HYPERLINK("http://…")). Admin- // entered data only, so low risk, but the fix is one character. if (/^[=+\-@]/.test(s)) s = `'${s}`; if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; }