backend polish: stats LEFT JOIN, CSV formula injection, trustProxy, validated env vars, dead 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.
This commit is contained in:
iris 2026-07-29 20:35:16 +02:00 committed by mara
commit 9b82cd54c4
5 changed files with 85 additions and 31 deletions

View file

@ -9,7 +9,24 @@ export const TZ = process.env.WUTZ_TZ ?? 'Europe/Berlin';
// A sale at e.g. 03:00 still belongs to the previous night's business day.
// Anything before this local hour counts towards the day before.
export const BUSINESS_DAY_CUTOFF_HOUR = Number(process.env.WUTZ_DAY_CUTOFF_HOUR ?? 5);
//
// Parsed and range-checked rather than a bare Number(...) — an unparseable
// value (a typo like "5am" instead of "5") used to silently become NaN,
// and `hour < NaN` is always false, so the business-day rollback would
// just stop happening with no error anywhere: every after-midnight sale
// would land on the wrong day in the stats table.
export const BUSINESS_DAY_CUTOFF_HOUR = parseHourEnv('WUTZ_DAY_CUTOFF_HOUR', 5);
function parseHourEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n < 0 || n > 23) {
console.warn(`${name}=${JSON.stringify(raw)} is not a valid hour (0-23) — using default ${fallback}`);
return fallback;
}
return n;
}
/**
* Parse a value stored in `created_at`. New rows are UTC ISO strings (with `Z`),