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>
199 lines
7.3 KiB
TypeScript
199 lines
7.3 KiB
TypeScript
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(),
|
||
});
|