Follow-up from #25's review feedback — that PR converted admin.ts + the global
error handler to application/problem+json (sendProblem/ProblemDetails), left
public.ts (the tablet-facing /api/* routes) on the old ad hoc { error } shape to
stay scoped to what #25 was actually fixing. Same conversion here, no functional
changes — every reply.code(N).send({ error }) becomes sendProblem(req, reply, N,
title, detail). Client already degrades gracefully either way (errText() in
Admin.tsx checks detail/title first, falls back to .error), so this was purely
consistency cleanup, not urgent.
typecheck+build clean.
All admin API error responses (and the global error handler) now emit
RFC 7807 application/problem+json bodies (type/title/status/detail)
instead of the ad hoc { error: string } shape, per review feedback on
this PR. Scoped to admin.ts + the global handler in index.ts, since
that's what this PR already touches; public.ts's routes still use the
old shape pending a follow-up.
The session cookie value was the admin password, replayed on every
request — one sniffed request on the LAN yields the actual shared
secret, not just a session, and logout only cleared the browser's
copy since the value (the password) stays valid forever.
Mint a random token on successful login, hold valid tokens in an
in-memory Set, set that as the cookie, and delete it from the set on
logout — logout now actually revokes the session. A server restart
naturally invalidates all sessions too (fine for this single-process
deploy).
Also compare the login password with a constant-time digest
comparison instead of ===, hygiene rather than a practical fix given
the existing shared-password/no-rate-limit threat model, but a small
change while touching this code.
- 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.
Line items correctly snapshot both unit_price_cents and
pfand_cents_per_unit, so a later price change doesn't rewrite history.
Returns didn't get the same treatment: transactions.pfand_returns
stored a bare count, and the euro value came from the bar's
pfand_cents at that moment but was never recorded — change a bar's
deposit mid-event and no historical refund amount could be
recomputed from the database.
Writing returns as transaction_items rows (the schema's is_return
column) doesn't fit cleanly: a Pfand return isn't tied to a specific
drink, but transaction_items.drink_id is NOT NULL. Add
transactions.pfand_cents_at_sale instead — same snapshot idea, at
the transaction level where pfand_returns already lives. Also added
to the transactions CSV export so the recovered value is actually
visible somewhere in the app, not just reachable via raw SQL.
- POST /api/transactions: pfand_returns is now rejected with 400 if
non-integer or negative instead of silently coerced via
Math.max(0, Math.floor(x)) (which turned a non-numeric value into
NaN and slipped past the empty-transaction guard). Both
pfand_returns and per-line qty are capped at a generous but bounded
999; items.length capped at 100.
- Admin routes: price_cents/pfand_cents are validated (integer,
0..100000 EUR) on all four write paths — POST/PATCH drinks and
POST/PATCH bars. Previously only POST drinks checked
Number.isInteger with no bound; the other three had no check at
all, so a bad value (float, string, negative) could reach SQLite
directly.
The server dedupes on client_uuid, but the tablet minted a fresh uuid
on every confirm() call — including retries after a timeout/dropped
connection, exactly the case the idempotency key exists to guard
against. The dedup check never fired on a real retry, so a flaky-Wi-Fi
resend could book the same sale twice.
Client: generate one uuid per pending cart (a ref, lazily created),
reuse it across retries of the same submission, reset it only when
the cart is cleared (success or cancel) so the next cart gets its own
id.
Server: the existing-row dedup check and the insert straddled the
db.transaction() boundary, so a genuine UNIQUE-violation race would
have surfaced as a raw 500 instead of the idempotent response. Catch
that specific violation and fall back to re-reading the row.
- admin: maroon buttons use .danger class (white text); replace hardcoded
#333/#ff8a8a/drop-target colors with theme vars so light mode is legible
- time.ts: force hourCycle h23 (no 24:00 artifact), drop dead 24-guard
- Makefile/README: rsync with excludes instead of `cp -a .` so .git and the
dev DB don't ship to /opt; add rsync to deps
- store created_at as UTC ISO; display/group in Europe/Berlin
- stats grouped by business day (sales night past midnight, 5am cutoff)
- add "Statistik zurücksetzen"
- allow deleting drinks/tresen (refused if referenced by sales)
- CSV exports local wall-clock time