Files
app/src/schemas/common.ts
BOHA d1533ffc4d feat(documents)!: unify offers and issued orders end to end
One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).

Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
  the PO template's red style (shared DocumentSectionSchema, one-transaction
  create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
  3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
  readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
  /file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
  number previews (also invoice previews), PUT returns assigned po_number

Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
  follows the table; order creation only from active offers) +
  valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
  issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
  linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
  DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
  dates, ints for positions); audit old/new values + koncept fallbacks;
  list id tiebreaks; stats include trimmed; NAS delete dedupe

Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
  document number (form field removed), one form layout/readonly convention,
  view-permission opens read-only everywhere (issued's editable-for-viewers
  hole closed), server-driven transition buttons, dirty guard + Enter submit
  on both, useApiMutation everywhere (new opt-in envelope mode), fixed
  infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
  numbers on offers, shared hardened per-row PDF flow (spinner, double-click
  guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
  states, query-lib cleanups (["offers","customers"] key, typed list rows,
  shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
  formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
  monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
  PDF language now comes from the document column

+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:39 +02:00

179 lines
6.6 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"),
);
/** 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(),
});