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(); 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(); 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(); const hourOfDayMap = new Map(); 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>(); 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; }