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).
643 lines
27 KiB
TypeScript
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;
|
|
}
|