/** * Zod schemas for case mutations (create / update). * Shapes mirror the FastAPI Pydantic models in web/app.py: * - CaseCreateRequest * - CaseUpdateRequest * * Validation rules are stricter on the UI than on the backend so the user * gets Hebrew-localized error messages at the field level instead of a * 422 blob from FastAPI. */ import { z } from "zod"; import type { PracticeArea, AppealSubtype } from "@/lib/practice-area"; /* Appeal numbers follow the 1xxx / 8xxx / 9xxx domain convention (leading * digit) from CLAUDE.md, plus the post-reform serial-length rule: * 4-digit serial → ערר (appeal) e.g. "1230-04-26" * 5-digit serial → בל"מ (extension-of-time) e.g. "85074-09-24" * * New cases MUST carry the month — the canonical serial-month-year form * NNNN[N]-MM-YY (X1-identifiers spec §1; chair procedure 2026-06-11). The * legacy NNNN-YY (no-month) form is still tolerated by backend lookup for * historical records, but is no longer accepted when opening a new case. * * Slashes are deliberately forbidden: FastAPI path routing can't capture * a `/` inside a {case_number} segment even when URL-encoded as %2F, so * any case with a slash becomes unreachable at * GET /api/cases/{case_number}/details. */ const caseNumberRe = /^[1-9]\d{3,4}-\d{2}-\d{2}$/; /* Serial digit-count: leading numeric group before month/year (4 = ערר, * 5 = בל"מ). Returns 0 when no serial is present. */ const caseSerialLen = (n: string): number => n.trim().match(/^(\d{4,5})/)?.[1].length ?? 0; const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/; const dateString = z .string() .trim() .refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), { message: "תאריך חייב להיות בפורמט YYYY-MM-DD", }); // GAP-51: outcome = 3 canonical values only. "betterment_levy" is a // practice_area (selected in its own field), not an outcome. export const expectedOutcomes = [ { value: "", label: "— לא נקבע —" }, { value: "rejection", label: "דחייה" }, { value: "partial_acceptance", label: "קבלה חלקית" }, { value: "full_acceptance", label: "קבלה מלאה" }, ] as const; /* proceeding_type — distinguishes a regular appeal (ערר) from an * extension-of-time request (בל"מ). The same case_number can exist as * both, so this is a separate axis from appeal_subtype/practice_area. */ export const proceedingTypes = [ { value: "ערר", label: "ערר" }, { value: 'בל"מ', label: 'בל"מ' }, ] as const; export type ProceedingType = (typeof proceedingTypes)[number]["value"]; export const caseCreateSchema = z.object({ case_number: z .string() .trim() .min(1, "שדה חובה") .regex( caseNumberRe, 'פורמט: מספר-חודש-שנה. 4 ספרות = ערר (1230-04-26), 5 ספרות = בל"מ (85074-09-24)', ), title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"), appellants: z .array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין")) .min(1, "חייב להיות לפחות עורר אחד"), respondents: z .array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין")) .min(1, "חייב להיות לפחות משיב אחד"), subject: z.string().trim().max(500), property_address: z.string().trim().max(200), permit_number: z.string().trim().max(100), hearing_date: dateString, notes: z.string().trim().max(2000), expected_outcome: z.enum( expectedOutcomes.map((o) => o.value) as [string, ...string[]], ), practice_area: z.enum([ "appeals_committee", "national_insurance", "labor_law", ] as const satisfies readonly PracticeArea[]), appeal_subtype: z.enum([ "building_permit", "betterment_levy", "compensation_197", "extension_request_building_permit", "extension_request_betterment_levy", "extension_request_compensation", "unknown", ] as const satisfies readonly AppealSubtype[]), proceeding_type: z.enum(["ערר", 'בל"מ'] as const), }).superRefine((d, ctx) => { /* Post-reform rule: a 5-digit serial IS a בל"מ. One-directional — a * 4-digit serial may still be a legacy בל"מ, so we don't force ערר. */ if (caseSerialLen(d.case_number) === 5 && d.proceeding_type !== 'בל"מ') { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["proceeding_type"], message: 'מספר בן 5 ספרות הוא תיק בל"מ — סוג התיק חייב להיות בל"מ', }); } }); export type CaseCreateInput = z.infer; /* Update schema — all fields optional so the PUT can be used for a * single-field edit. Empty strings are tolerated (they mean "no change" * in the Pydantic model). */ export const caseUpdateSchema = z.object({ title: z.string().trim().max(200).optional(), subject: z.string().trim().max(500).optional(), notes: z.string().trim().max(2000).optional(), hearing_date: dateString.optional(), decision_date: dateString.optional(), expected_outcome: z .enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]]) .optional(), status: z.string().optional(), appellants: z .array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין")) .optional(), respondents: z .array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין")) .optional(), property_address: z.string().trim().max(200).optional(), permit_number: z.string().trim().max(100).optional(), proceeding_type: z.enum(["ערר", 'בל"מ'] as const).optional(), }); export type CaseUpdateInput = z.infer;