- Stats: bars table is now the LEFT side of the join to transactions, so a bar with zero sales still gets a zero row instead of vanishing from the totals table until its first sale (indistinguishable from a deleted bar). by_day stays JS-computed on purpose — a SQL rewrite would trade DST-aware timezone handling for a fixed-hour-offset 'localtime' expression that's wrong on DST transition nights, to fix a cost the original review noted is 'fine today'. Not worth that trade for a money-adjacent report; left a comment explaining why. - CSV export: cells starting with =/+/-/@ are now prefixed with ' before quoting, closing a formula-injection path (an admin-entered drink/bar name like =HYPERLINK(...) would otherwise execute when the export is opened in Excel/LibreOffice). - server/index.ts: PORT is now parsed and range-checked instead of a bare Number(...) (an unparseable value silently became NaN, and Fastify listens on a random free port for that); ADMIN_PASSWORD missing now warns at boot instead of only surfacing as a 500 at the first login attempt; new WUTZ_TRUST_PROXY env flag (off by default) so req.ip can actually reflect the real client behind a reverse proxy, documented in the README alongside the other env vars. - time.ts: WUTZ_DAY_CUTOFF_HOUR gets the same parse+range-check treatment, for the same reason (a typo used to silently disable the business-day rollback with no error). - shared/src/index.ts: Drink.archived is now typed 0 | 1, matching what SQLite actually returns (was boolean, which only worked by accident since 0 is falsy); removed TransactionRecord/ TransactionItemRecord, declared but never returned by any route — leftovers from a planned endpoint that was never built. Verified: pnpm --filter server|client typecheck/build all clean; also ran the built server with a bad PORT and no ADMIN_PASSWORD to confirm both warnings fire and the port falls back correctly.
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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<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.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;
|
|
}
|