import { z } from "zod"; export function parseBody( schema: z.ZodType, 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"), ); /** 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(), });