Files
app/src/schemas/common.ts
BOHA 1826fc7976 fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
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>
2026-06-10 09:59:47 +02:00

157 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
export function parseBody<T>(
schema: z.ZodType<T>,
body: unknown,
): { data: T } | { error: string } {
try {
return { data: schema.parse(body) };
} catch (e) {
if (e instanceof z.ZodError) {
return {
error: e.issues.map((err: z.ZodIssue) => err.message).join(", "),
};
}
return { error: "Neplatný požadavek" };
}
}
// ── Shared form-coercion helpers ──────────────────────────────────────────
// HTTP bodies arrive as strings (multipart) or numbers (JSON). These helpers
// coerce number|string → number with a NaN/Infinity guard, replacing the
// `z.union([z.number(), z.string()]).transform(Number)` idiom that was
// duplicated ~150× across schemas and silently produced NaN on bad input
// (which then flowed into Prisma / business math). User-facing messages Czech.
const coerceNum = (v: unknown): number =>
typeof v === "number"
? v
: typeof v === "string" && v.trim() !== ""
? Number(v)
: NaN;
/** number|string → number; rejects NaN/Infinity/empty. */
export const numberFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n), { message: "Neplatné číslo" });
/** number|string → number within [min,max]; rejects NaN/out-of-range. */
export const numberInRange = (min: number, max: number, message?: string) =>
z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n) && n >= min && n <= max, {
message: message ?? `Číslo musí být mezi ${min} a ${max}`,
});
/** number|string → non-negative number (e.g. quantities, prices, odometers). */
export const nonNegativeNumberFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n) && n >= 0, {
message: "Číslo nesmí být záporné",
});
/** number|string → positive number ( > 0 ). */
export const positiveNumberFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n) && n > 0, {
message: "Číslo musí být kladné",
});
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
export const intIdFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isInteger(n) && n > 0, { message: "Neplatné ID" });
/** number|string|null → number|null; rejects NaN when a value is present. */
export const nullableNumberFromForm = z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null || v === "" ? null : coerceNum(v)))
.refine((n) => n === null || Number.isFinite(n), {
message: "Neplatné číslo",
});
/** number|string|null → positive-int id | null; rejects NaN/≤0 when present. */
export const nullableIntIdFromForm = z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null || v === "" ? null : coerceNum(v)))
.refine((n) => n === null || (Number.isInteger(n) && n > 0), {
message: "Neplatné ID",
});
/** Truthy form flag → boolean: true | 1 | "1" | "true" → true. */
export const booleanFromForm = z.preprocess(
(v) => v === true || v === 1 || v === "1" || v === "true",
z.boolean(),
);
/**
* YYYY-MM-DD date string. Tolerant of a trailing time component: an edit form
* round-trips a `@db.Date` that the `Date.toJSON` override serialises as
* "YYYY-MM-DDT00:00:00", so we strip anything after the date before validating
* (and the validated output is always the date-only `YYYY-MM-DD`).
*/
export const isoDateString = z.preprocess(
(v) => (typeof v === "string" ? v.slice(0, 10) : v),
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD"),
);
/** HH:MM (24h) time string. Tolerant of a trailing ":ss" seconds component. */
export const timeString = z.preprocess(
(v) =>
typeof v === "string" && /^\d{2}:\d{2}:\d{2}/.test(v) ? v.slice(0, 5) : v,
z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
);
/**
* Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the
* attendance forms produce by joining a date input and a time input
* (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" —
* stripped before validating), because the client appends ":00" and an edit
* form round-trips a value the `Date.toJSON` override serialised as
* "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)`
* as LOCAL time (Europe/Prague) — no timezone suffix is part of the format.
*/
export const dateTimeString = z.preprocess(
(v) =>
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)
? v.slice(0, 16)
: v,
z
.string()
.regex(
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/,
"Datum a čas musí být ve formátu YYYY-MM-DD HH:MM",
),
);
/**
* Optional-datetime variant: ""/null mean "not set" → null (the attendance
* forms historically submitted empty strings for unfilled time fields, and a
* hard reject would 400 the whole form — including leave records that have no
* times at all). Pair with `.optional()` at the call site for fields that may
* be absent entirely (absent stays `undefined`, e.g. "don't change" on update).
*/
export const nullableDateTimeString = z.preprocess(
(v) => (v === "" || v === null ? null : v),
z.union([z.null(), dateTimeString]),
);
/**
* An email field that also accepts an empty string (meaning "not set"). Many
* settings/supplier forms submit "" for un-filled optional emails; a bare
* `.email()` would 400 the whole form. Pair with `.nullish()` at the call site.
*/
export const emailOrEmpty = z.union([
z.literal(""),
z.string().max(255).email("Neplatný email"),
]);