From 9c75ce52439148cdaaf46dec7605e0c608a030ae Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 30 Jul 2026 10:19:18 +0200 Subject: [PATCH] server: /api/stats endpoint + per-day-per-drink trend data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New GET /api/stats — same computeStats() shared with the existing /admin/api/stats, so the numbers stay identical. Auth is gated by STATS_PUBLIC (default: requires the admin session, same as every other /admin/api endpoint) rather than always-open, so a bare deploy doesn't expose revenue by default. Also adds per_drink_by_day (day -> per-drink sold qty) alongside the existing all-time per_drink totals — the trend-over-time data mara asked for on #40. Same businessDay() JS bucketing as by_day for DST correctness, same reasoning as the existing comment on that. Client page for /stats itself is a follow-up commit. --- deploy/wutzcalc.env.example | 5 + server/src/routes/admin.ts | 186 ++++++++++++++++++++++++------------ 2 files changed, 129 insertions(+), 62 deletions(-) diff --git a/deploy/wutzcalc.env.example b/deploy/wutzcalc.env.example index 6eed85d..5898470 100644 --- a/deploy/wutzcalc.env.example +++ b/deploy/wutzcalc.env.example @@ -16,3 +16,8 @@ HOST=0.0.0.0 # previous business day, so a 03:00 sale lands on the night before. #WUTZ_TZ=Europe/Berlin #WUTZ_DAY_CUTOFF_HOUR=5 + +# /stats page: by default it requires the same admin login as /admin. Set to +# 1 to make it viewable without logging in (e.g. a screen permanently +# mounted at a festival infopoint). +#STATS_PUBLIC=0 diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 21c0374..113c785 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -40,6 +40,17 @@ function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean { return false; } +// The standalone /stats page (issue #40) reuses the admin login/session — +// there's only one password/cookie in this app, no separate stats-only +// credential. STATS_PUBLIC opts out of that gate entirely (e.g. a screen +// permanently mounted at a festival infopoint) — default is auth required, +// same as every other /admin/api endpoint, so a bare deploy doesn't +// accidentally expose revenue numbers. +function requireStatsAuth(req: FastifyRequest, reply: FastifyReply): boolean { + if (process.env.STATS_PUBLIC === '1') return true; + return requireAuth(req, reply); +} + // `Number(nonNumericString)` is NaN, and NaN binds as SQL NULL — an // `UPDATE ... WHERE id = NULL` silently matches zero rows instead of // throwing, so a bad :id param used to "succeed" the same way a genuinely @@ -53,6 +64,111 @@ function parseId(raw: string, req: FastifyRequest, reply: FastifyReply): number return id; } +// Shared by GET /admin/api/stats (legacy, still used by the admin login +// flow's session check) and GET /api/stats (the standalone /stats page, +// issue #40). 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. +function computeStats(db: DB) { + 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; + } + // Newest first — this order feeds the existing admin table (and the + // /stats "recent days" table), where the most recent day belongs on top. + const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day)); + + // Per-day-per-drink sold quantity — the "trend over time" data #40 asked + // for, on top of the all-time perDrink totals above. Same businessDay() + // JS bucketing as byDay, for the same DST-correctness reason; joined + // against transaction_items rather than reusing txRows since the qty + // lives one table over. + const itemRows = db + .prepare( + `SELECT t.created_at, ti.drink_id, d.name AS drink_name, ti.qty + FROM transaction_items ti + JOIN transactions t ON t.id = ti.transaction_id + JOIN drinks d ON d.id = ti.drink_id` + ) + .all() as Array<{ created_at: string; drink_id: number; drink_name: string; qty: number }>; + + const dayDrinkMap = new Map>(); + for (const r of itemRows) { + const day = businessDay(parseDbTime(r.created_at)); + let drinks = dayDrinkMap.get(day); + if (!drinks) { + drinks = new Map(); + dayDrinkMap.set(day, drinks); + } + let agg = drinks.get(r.drink_id); + if (!agg) { + agg = { drink_id: r.drink_id, drink_name: r.drink_name, sold_qty: 0 }; + drinks.set(r.drink_id, agg); + } + agg.sold_qty += r.qty; + } + // Oldest first — this feeds a trend chart, where left-to-right = time + // moving forward is the expected reading direction (opposite of byDay's + // "most recent on top" table order above). + const perDrinkByDay = [...dayDrinkMap.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([day, drinks]) => ({ day, drinks: [...drinks.values()].sort((a, b) => b.sold_qty - a.sold_qty) })); + + return { totals, per_drink: perDrink, by_day: byDay, per_drink_by_day: perDrinkByDay }; +} + export function registerAdminRoutes(app: FastifyInstance, db: DB) { app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => { const expected = process.env.ADMIN_PASSWORD; @@ -265,71 +381,17 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { }); // ----- Stats ----- + // Kept at the old /admin/api/stats path too (the admin dashboard's own + // "Statistik" link into /stats reuses the session, no separate call + // needed there) — computeStats() is the single source both paths share. app.get('/admin/api/stats', async (req, reply) => { if (!requireAuth(req, reply)) return; + return computeStats(db); + }); - // 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.get('/api/stats', async (req, reply) => { + if (!requireStatsAuth(req, reply)) return; + return computeStats(db); }); app.post('/admin/api/stats/reset', async (req, reply) => {