server: /api/stats endpoint + per-day-per-drink trend data

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.
This commit is contained in:
iris 2026-07-30 10:19:18 +02:00 committed by mara
commit 9c75ce5243
2 changed files with 129 additions and 62 deletions

View file

@ -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<string, { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }>();
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<string, Map<number, { drink_id: number; drink_name: string; sold_qty: number }>>();
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<string, { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }>();
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) => {