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:
parent
d2742751ba
commit
9c75ce5243
2 changed files with 129 additions and 62 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue