Files
app/src/schemas/common.ts
BOHA e11765bf0e fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)
Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
  failed deficit line rolls back the surplus corrective receipt — retries
  no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
  of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
  (in-transaction, clamped at 0)

High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
  the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
  when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
  (Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
  hours against its own balance (mirrors createLeave)

Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
  500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
  Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
  (recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
  @db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
  /items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
  finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
  settings.company|settings.system; Settings System tab no longer clobbers
  Firma numbering patterns; draft invoices hide the dead PDF button;
  dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
  audit-log + invoice month buckets day-shift fixes

Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.

~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:19 +02:00

199 lines
7.3 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 → non-negative integer (e.g. list positions). */
export const nonNegativeIntFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isInteger(n) && n >= 0, {
message: "Číslo musí být nezáporné celé číslo",
});
/** 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"),
);
/**
* Nullable/optional variant of isoDateString for edit-form date fields:
* "" and null normalize to null (the forms clear date inputs with empty
* strings), undefined stays undefined (update semantics: field untouched).
* Same lenient trailing-time stripping — a local-midnight datetime written
* to a @db.Date column would land a day early (Prisma truncates to the UTC
* date part).
*/
export const nullableIsoDateString = z.preprocess(
(v) => {
if (v === undefined) return undefined;
if (v === null || v === "") return null;
return 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")
.nullish(),
);
/** 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"),
]);
/**
* Shared rich-text document section — offers' scope_sections and issued
* orders' issued_order_sections are 1:1 mirror tables, so both schemas use
* this. Limits are DB-aligned: title/title_cz are VarChar(500) (the old
* offers-local copy under-shot at 255), content is Text with the app-level
* 8000 cap used by the other rich-text fields.
*/
export const DocumentSectionSchema = z.object({
title: z.string().max(500).nullish(),
title_cz: z.string().max(500).nullish(),
content: z.string().max(8000).nullish(),
position: nonNegativeIntFromForm.optional(),
});