wutzcalc/server/src/routes/admin.ts
iris a7ec25997f admin: reuse the deduped drink_ids in the bar_drinks insert
argus's review on this PR noted the insert loop recomputed new
Set(drink_ids) instead of reusing uniqueIds from the existence check
above — same result (deterministic dedup), just needless duplication.
Hoisted uniqueIds out of the existence-check block so both call sites
share it.

Verified: tsc --noEmit and server build clean; re-ran the live smoke
test (dup drink_ids dedup to the right set, nonexistent drink_id still
400s).
2026-07-31 11:12:26 +02:00

643 lines
27 KiB
TypeScript

import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { DB } from '../db.js';
import { businessDay, formatLocal, localHourBucket, localHourOfDay, parseDbTime } from '../time.js';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { sendProblem } from '../problem-details.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.
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;
}
// Random per-login session tokens, not the password itself — so a sniffed
// cookie only yields one revocable session (not the shared secret), and
// logout actually invalidates it. In-memory: a restart naturally logs
// everyone out too, which is fine here (single-process deploy).
//
// Maps each token to its own expiry (ms epoch) so the 7-day cookie maxAge
// is honoured server-side too, not just as a hint the browser can ignore —
// previously a token stayed valid indefinitely until an explicit
// /admin/logout or a process restart.
const SESSION_MAXAGE_SECONDS = 60 * 60 * 24 * 7;
const validSessions = new Map<string, number>();
function isAuthed(req: FastifyRequest): boolean {
const token = req.cookies?.[SESSION_COOKIE];
if (!token) return false;
const expires = validSessions.get(token);
if (expires === undefined) return false;
if (Date.now() > expires) {
validSessions.delete(token);
return false;
}
return true;
}
// Opportunistic sweep of expired tokens — called on login (a rare path
// relative to isAuthed, which runs on every authed request) so an
// abandoned session's memory doesn't linger forever between lookups.
function pruneExpiredSessions(): void {
const now = Date.now();
for (const [token, expires] of validSessions) {
if (now > expires) validSessions.delete(token);
}
}
// Constant-time comparison for the admin password — hashed first so a
// length mismatch doesn't throw (timingSafeEqual requires equal-length
// buffers).
function safeEqual(a: string, b: string): boolean {
const ah = createHash('sha256').update(a).digest();
const bh = createHash('sha256').update(b).digest();
return timingSafeEqual(ah, bh);
}
function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean {
if (isAuthed(req)) return true;
sendProblem(req, reply, 401, 'Unauthorized', 'unauthorized');
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
// missing id does. Reject it explicitly instead.
function parseId(raw: string, req: FastifyRequest, reply: FastifyReply): number | null {
const id = Number(raw);
if (!Number.isInteger(id)) {
sendProblem(req, reply, 400, 'Bad Request', 'invalid id');
return null;
}
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));
// Two hourly views for #45 ("why not both" — the continuous timeline and
// the hour-of-day pattern answer different questions, see the issue
// comment). Same shape as dayMap's aggregates, same JS-bucketing
// reasoning, just keyed differently: localHourBucket() is literal clock
// time (no business-day rollover — the point is "when did this happen"),
// localHourOfDay() collapses every day onto a 0-23 axis.
type HourAgg = { tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number };
function bumpHourAgg(agg: HourAgg, r: { total_cents: number; crew: number; pfand_returns: number }) {
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 hourMap = new Map<string, { hour: string } & HourAgg>();
const hourOfDayMap = new Map<number, { hour_of_day: number } & HourAgg>();
for (const r of txRows) {
const d = parseDbTime(r.created_at);
const hourKey = localHourBucket(d);
let hourAgg = hourMap.get(hourKey);
if (!hourAgg) {
hourAgg = { hour: hourKey, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 };
hourMap.set(hourKey, hourAgg);
}
bumpHourAgg(hourAgg, r);
const hod = localHourOfDay(d);
let hodAgg = hourOfDayMap.get(hod);
if (!hodAgg) {
hodAgg = { hour_of_day: hod, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 };
hourOfDayMap.set(hod, hodAgg);
}
bumpHourAgg(hodAgg, r);
}
// Oldest first, same reasoning as perDrinkByDay below — this feeds a
// left-to-right timeline chart. Zero-filled between the first and last
// bucket (issue #52) for the same reason byHourOfDay is zero-filled
// below: the client renders this on a categorical axis, so a hidden gap
// (closed hours between two festival nights) would sit flush against
// real data and read as "quiet", not "closed". The fill walks the
// bucket *strings* as if they were UTC wall-clock (Date.UTC, +1h steps)
// rather than real local time — deliberately: this only needs a
// monotonic hour sequence with correct calendar rollover, not real DST
// handling, and reusing localHourBucket()'s actual local-time path here
// would double-apply the DST question this file already takes a
// documented, deliberate simplification on (see localHourBucket's own
// comment in time.ts).
const hourKeyToDate = (key: string): Date => {
const [datePart, timePart] = key.split(' ');
const [y, m, d] = datePart!.split('-').map(Number);
const h = Number(timePart!.split(':')[0]);
return new Date(Date.UTC(y!, m! - 1, d!, h));
};
const dateToHourKey = (d: Date): string => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:00`;
};
const sortedHourKeys = [...hourMap.keys()].sort();
const byHour: Array<{ hour: string } & HourAgg> = [];
if (sortedHourKeys.length > 0) {
let cursor = hourKeyToDate(sortedHourKeys[0]!);
const end = hourKeyToDate(sortedHourKeys[sortedHourKeys.length - 1]!);
while (cursor <= end) {
const key = dateToHourKey(cursor);
byHour.push(hourMap.get(key) ?? { hour: key, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 });
cursor = new Date(cursor.getTime() + 60 * 60 * 1000);
}
}
// Full 0-23 axis, zero-filled — a bar chart with silently missing hours
// (e.g. no sales at 6am) reads as a data gap, not "zero", if the bucket
// is just absent.
const byHourOfDay = Array.from({ length: 24 }, (_, h) => hourOfDayMap.get(h) ?? {
hour_of_day: h, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0,
});
// 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,
by_hour: byHour,
by_hour_of_day: byHourOfDay,
};
}
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) {
// Masked like every other 500 (see index.ts's error handler) — the
// literal "ADMIN_PASSWORD not set" used to go straight to an
// unauthenticated caller, confirming a misconfigured deploy (no
// password will ever work) to anyone probing.
req.log.error('ADMIN_PASSWORD not set');
return sendProblem(req, reply, 500, 'Internal Server Error', 'internal error');
}
if (!safeEqual(req.body?.password ?? '', expected)) {
return sendProblem(req, reply, 401, 'Unauthorized', 'wrong password');
}
pruneExpiredSessions();
const token = randomBytes(32).toString('hex');
validSessions.set(token, Date.now() + SESSION_MAXAGE_SECONDS * 1000);
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: SESSION_MAXAGE_SECONDS,
});
return { ok: true };
});
app.post('/admin/logout', async (req, reply) => {
const token = req.cookies?.[SESSION_COOKIE];
if (token) validSessions.delete(token);
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: 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() : '';
if (!name || !isValidCents(price_cents)) return sendProblem(req, reply, 400, 'Bad Request', 'invalid');
try {
const info = db
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
.run(name, price_cents);
return { id: Number(info.lastInsertRowid) };
} catch (e: any) {
if (String(e.message).includes('UNIQUE') && String(e.message).includes('drinks.name')) {
return sendProblem(req, reply, 409, 'Conflict', 'name already exists');
}
throw e;
}
}
);
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 = parseId(req.params.id, req, reply);
if (id === null) return;
const { name: rawName, 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); }
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);
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;
}
}
);
app.delete<{ Params: { id: string } }>('/admin/api/drinks/:id', async (req, reply) => {
if (!requireAuth(req, reply)) return;
const id = parseId(req.params.id, req, reply);
if (id === null) return;
const sold = db
.prepare('SELECT 1 FROM transaction_items WHERE drink_id = ? LIMIT 1')
.get(id);
if (sold) {
return sendProblem(
req,
reply,
409,
'Conflict',
'Getränk wurde bereits verkauft — bitte archivieren statt löschen.'
);
}
const changes = db.transaction(() => {
db.prepare('DELETE FROM bar_drinks WHERE drink_id = ?').run(id);
return db.prepare('DELETE FROM drinks WHERE id = ?').run(id).changes;
})();
if (changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'drink not found');
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 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 pfand_cents = req.body?.pfand_cents ?? 0;
if (!name) return sendProblem(req, reply, 400, 'Bad Request', 'name required');
if (!isValidCents(pfand_cents)) {
return sendProblem(req, reply, 400, 'Bad Request', '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 sendProblem(req, reply, 409, 'Conflict', '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 = 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');
}
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);
}
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 (?, ?, ?)'
);
uniqueIds.forEach((did, idx) => ins.run(id, did, idx));
}
})();
} catch (e: any) {
if (String(e.message).includes('UNIQUE') && String(e.message).includes('bars.name')) {
return sendProblem(req, reply, 409, 'Conflict', 'name already exists');
}
throw e;
}
return { ok: true };
}
);
app.delete<{ Params: { id: string } }>('/admin/api/bars/:id', async (req, reply) => {
if (!requireAuth(req, reply)) return;
const id = parseId(req.params.id, req, reply);
if (id === null) return;
const used = db
.prepare('SELECT 1 FROM transactions WHERE bar_id = ? LIMIT 1')
.get(id);
if (used) {
return sendProblem(
req,
reply,
409,
'Conflict',
'Tresen hat Transaktionen — Löschen würde die Statistik verfälschen.'
);
}
const changes = db.transaction(() => {
db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id);
return db.prepare('DELETE FROM bars WHERE id = ?').run(id).changes;
})();
if (changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'bar not found');
return { ok: true };
});
// ----- 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);
});
app.get('/api/stats', async (req, reply) => {
if (!requireStatsAuth(req, reply)) return;
return computeStats(db);
});
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 '';
// Numbers (e.g. a negative total_cents from a net-Pfand-refund
// transaction) skip both the formula-injection guard and the quote
// escaping below — the guard exists for free-text columns that could
// contain a formula-injection payload; a real negative number should
// stay a real number, not get coerced into text a spreadsheet can no
// longer SUM(). Numbers also never contain `,`/`"`/`\n`, so escaping is
// moot for them anyway.
if (typeof v === 'number') return String(v);
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;
}