- 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.
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
import Fastify from 'fastify';
|
|
import fastifyStatic from '@fastify/static';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { existsSync } from 'node:fs';
|
|
|
|
import { openDb } from './db.js';
|
|
import { registerPublicRoutes } from './routes/public.js';
|
|
import { registerAdminRoutes } from './routes/admin.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
function parsePortEnv(name: string, fallback: number): number {
|
|
const raw = process.env[name];
|
|
if (raw === undefined) return fallback;
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
// A bare Number(...) turned an unparseable PORT into NaN, and
|
|
// Fastify listens on a random free port for a NaN — silently wrong,
|
|
// not an error anyone would see.
|
|
console.warn(`${name}=${JSON.stringify(raw)} is not a valid port — using default ${fallback}`);
|
|
return fallback;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
const PORT = parsePortEnv('PORT', 3000);
|
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
|
const DB_PATH = process.env.DB_PATH ?? join(process.cwd(), 'wutz.db');
|
|
|
|
if (!process.env.ADMIN_PASSWORD) {
|
|
// Not fatal — public routes work fine without it — but previously this
|
|
// only surfaced as a 500 at the first login attempt, which in practice
|
|
// is the middle of the event. Warn at boot instead.
|
|
console.warn('ADMIN_PASSWORD is not set — the admin backoffice login will fail until it is.');
|
|
}
|
|
|
|
const app = Fastify({
|
|
logger: true,
|
|
// Off by default: only trust X-Forwarded-* when the deployer has
|
|
// actually put a reverse proxy in front (the README recommends one for
|
|
// TLS termination). Without this, req.ip behind such a proxy is always
|
|
// the proxy's own address, making the stored client_ip column and its
|
|
// CSV export uniformly useless.
|
|
trustProxy: process.env.WUTZ_TRUST_PROXY === '1',
|
|
});
|
|
|
|
await app.register(fastifyCookie);
|
|
|
|
const db = openDb(DB_PATH);
|
|
|
|
registerPublicRoutes(app, db);
|
|
registerAdminRoutes(app, db);
|
|
|
|
app.get('/healthz', async () => ({ ok: true }));
|
|
|
|
// Static client (built by Vite into client/dist)
|
|
const clientDist = join(__dirname, '..', '..', 'client', 'dist');
|
|
if (existsSync(clientDist)) {
|
|
await app.register(fastifyStatic, {
|
|
root: clientDist,
|
|
prefix: '/',
|
|
wildcard: false,
|
|
});
|
|
app.setNotFoundHandler((req, reply) => {
|
|
if (req.url.startsWith('/api') || req.url.startsWith('/admin/api')) {
|
|
reply.code(404).send({ error: 'not found' });
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/admin')) {
|
|
reply.sendFile('admin.html');
|
|
return;
|
|
}
|
|
reply.sendFile('index.html');
|
|
});
|
|
} else {
|
|
app.log.warn(`client dist not found at ${clientDist} — run \`pnpm --filter client build\``);
|
|
}
|
|
|
|
try {
|
|
await app.listen({ port: PORT, host: HOST });
|
|
app.log.info(`listening on http://${HOST}:${PORT}`);
|
|
} catch (err) {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
}
|