Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
export function formatCurrency(
|
|
amount: number | string,
|
|
currency: string,
|
|
): string {
|
|
const num = Number(amount) || 0;
|
|
switch (currency) {
|
|
case "EUR":
|
|
return `${num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
|
case "USD":
|
|
return `$${num.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
case "CZK":
|
|
return `${num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`;
|
|
case "GBP":
|
|
return `£${num.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
default:
|
|
return `${num.toFixed(2)} ${currency}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Joins per-currency amounts into a single display string
|
|
* (e.g. `"1 234,00 Kč · 50,00 €"`). Typed structurally so it stays decoupled
|
|
* from the React Query layer's `CurrencyAmount`. Empty/invalid input →
|
|
* `formatCurrency(0, "CZK")`.
|
|
*/
|
|
export function formatMultiCurrency(
|
|
amounts: Array<{ amount: number; currency: string }>,
|
|
): string {
|
|
if (!Array.isArray(amounts) || amounts.length === 0)
|
|
return formatCurrency(0, "CZK");
|
|
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
|
|
}
|
|
|
|
export function formatDate(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return "—";
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString("cs-CZ");
|
|
}
|
|
|
|
/**
|
|
* Today's date as `YYYY-MM-DD` in LOCAL (Prague) time — use this for
|
|
* date-input defaults. `new Date().toISOString().slice(0, 10)` gives the UTC
|
|
* date, which is a day behind the Czech date during the post-midnight window
|
|
* (Prague is UTC+1/+2), so a "today" default could land on yesterday.
|
|
*/
|
|
export function todayLocalStr(): string {
|
|
const d = new Date();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${d.getFullYear()}-${m}-${day}`;
|
|
}
|
|
|
|
/**
|
|
* Coerce a server-returned numeric (number | decimal-string | null/undefined)
|
|
* to a number, falling back ONLY when the value is missing or not numeric —
|
|
* NEVER on a legitimate 0 (e.g. a 0% VAT rate / reverse charge). The
|
|
* `Number(x) || fallback` idiom silently turns stored zeros into the fallback.
|
|
*/
|
|
export function numberOr(raw: unknown, fallback: number): number {
|
|
if (raw == null || raw === "") return fallback;
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
export function formatKm(km: number | string): string {
|
|
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
|
|
}
|
|
|
|
export function czechPlural(
|
|
n: number,
|
|
one: string,
|
|
few: string,
|
|
many: string,
|
|
): string {
|
|
if (n === 1) return one;
|
|
if (n >= 2 && n <= 4) return few;
|
|
return many;
|
|
}
|