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