import { z } from "zod"; // ---- shared -------------------------------------------------------------- const NonEmpty = z.string().trim().min(1, "Required").max(200); const Code = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9._\-/]+$/, "Use letters, digits, . _ - /"); const OptionalText = z .string() .trim() .max(5000) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(); const JsonString = z .string() .max(10_000) .refine( (s) => { if (s.length === 0) return true; try { JSON.parse(s); return true; } catch { return false; } }, { message: "Must be valid JSON" }, ) .transform((s) => (s.length === 0 ? null : s)) .nullable() .optional(); const Pin = z.string().regex(/^\d{4}$/, "PIN must be exactly 4 digits"); // ---- users --------------------------------------------------------------- export const CreateAdminSchema = z.object({ role: z.literal("admin"), name: NonEmpty, email: z.string().email().max(200), password: z.string().min(8).max(200), }); export const CreateOperatorSchema = z.object({ role: z.literal("operator"), name: NonEmpty, pin: Pin, }); export const CreateUserSchema = z.discriminatedUnion("role", [ CreateAdminSchema, CreateOperatorSchema, ]); export const UpdateUserSchema = z .object({ name: NonEmpty.optional(), active: z.boolean().optional(), email: z.string().email().max(200).optional(), password: z.string().min(8).max(200).optional(), pin: Pin.optional(), }) .strict(); // ---- machines ------------------------------------------------------------ export const MachineKinds = [ "NCT_PUNCH", "PRESS_BRAKE", "RIVET", "WELD", "LASER", "SHEAR", "ASSEMBLY", "OTHER", ] as const; export const CreateMachineSchema = z.object({ name: NonEmpty, kind: z.enum(MachineKinds), location: OptionalText, notes: OptionalText, }); export const UpdateMachineSchema = z .object({ name: NonEmpty.optional(), kind: z.enum(MachineKinds).optional(), location: OptionalText, notes: OptionalText, active: z.boolean().optional(), }) .strict(); // ---- operation templates ------------------------------------------------- export const CreateTemplateSchema = z.object({ name: NonEmpty, machineId: z.string().min(1).nullable().optional(), defaultSettings: JsonString, defaultInstructions: OptionalText, qcRequired: z.boolean().default(false), }); export const UpdateTemplateSchema = z .object({ name: NonEmpty.optional(), machineId: z.string().min(1).nullable().optional(), defaultSettings: JsonString, defaultInstructions: OptionalText, qcRequired: z.boolean().optional(), active: z.boolean().optional(), }) .strict(); // ---- projects ------------------------------------------------------------ export const ProjectStatuses = ["planning", "in_progress", "completed", "cancelled"] as const; export const CreateProjectSchema = z.object({ code: Code, name: NonEmpty, customerCode: z .string() .trim() .max(64) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), dueDate: z.coerce.date().nullable().optional(), notes: OptionalText, }); export const UpdateProjectSchema = z .object({ code: Code.optional(), name: NonEmpty.optional(), customerCode: z .string() .trim() .max(64) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), dueDate: z.coerce.date().nullable().optional(), status: z.enum(ProjectStatuses).optional(), notes: OptionalText, }) .strict(); // ---- assemblies / parts -------------------------------------------------- export const CreateAssemblySchema = z.object({ code: Code, name: NonEmpty, qty: z.coerce.number().int().positive().max(100000).default(1), notes: OptionalText, }); export const UpdateAssemblySchema = z .object({ code: Code.optional(), name: NonEmpty.optional(), qty: z.coerce.number().int().positive().max(100000).optional(), notes: OptionalText, }) .strict(); export const CreatePartSchema = z.object({ code: Code, name: NonEmpty, material: z .string() .trim() .max(120) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), qty: z.coerce.number().int().positive().max(100000).default(1), notes: OptionalText, }); export const UpdatePartSchema = z .object({ code: Code.optional(), name: NonEmpty.optional(), material: z .string() .trim() .max(120) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), qty: z.coerce.number().int().positive().max(100000).optional(), notes: OptionalText, stepFileId: z.string().min(1).nullable().optional(), drawingFileId: z.string().min(1).nullable().optional(), cutFileId: z.string().min(1).nullable().optional(), thumbnailFileId: z.string().min(1).nullable().optional(), }) .strict(); // ---- operations --------------------------------------------------------- export const OperationStatuses = ["pending", "in_progress", "completed"] as const; export const CreateOperationSchema = z.object({ templateId: z.string().min(1).nullable().optional(), name: NonEmpty, machineId: z.string().min(1).nullable().optional(), settings: JsonString, materialNotes: OptionalText, instructions: OptionalText, qcRequired: z.boolean().default(false), plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(), plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(), sequence: z.coerce.number().int().positive().max(10000).optional(), }); export const UpdateOperationSchema = z .object({ templateId: z.string().min(1).nullable().optional(), name: NonEmpty.optional(), machineId: z.string().min(1).nullable().optional(), settings: JsonString, materialNotes: OptionalText, instructions: OptionalText, qcRequired: z.boolean().optional(), plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(), plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(), sequence: z.coerce.number().int().positive().max(10000).optional(), status: z.enum(OperationStatuses).optional(), }) .strict(); export const ReorderOperationsSchema = z.object({ order: z.array(z.string().min(1)).min(1), }); // ---- operator scan actions ---------------------------------------------- // A scan-page "Pause" — stops the clock but does not complete the step. The // operator can enter a partial unit count before dropping the claim. // ---- fasteners ---------------------------------------------------------- export const CreateFastenerSchema = z.object({ partNumber: z.string().trim().min(1).max(120), description: NonEmpty, qty: z.coerce.number().int().positive().max(1_000_000), supplier: z .string() .trim() .max(200) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), unitCost: z.coerce.number().min(0).max(1_000_000).nullable().optional(), notes: OptionalText, }); export const UpdateFastenerSchema = z .object({ partNumber: z.string().trim().min(1).max(120).optional(), description: NonEmpty.optional(), qty: z.coerce.number().int().positive().max(1_000_000).optional(), supplier: z .string() .trim() .max(200) .transform((v) => (v.length === 0 ? null : v)) .nullable() .optional(), unitCost: z.coerce.number().min(0).max(1_000_000).nullable().optional(), notes: OptionalText, }) .strict(); // ---- purchase orders ---------------------------------------------------- export const PoStatuses = ["draft", "sent", "partial", "received", "cancelled"] as const; // A single line on a draft PO. Fastener must belong to the same project (the // route handler verifies that; we keep the schema pure). const PoLineInput = z.object({ fastenerId: z.string().min(1), qty: z.coerce.number().int().positive().max(1_000_000), unitCost: z.coerce.number().min(0).max(1_000_000).nullable().optional(), }); export const CreatePOSchema = z.object({ vendor: NonEmpty, notes: OptionalText, lines: z.array(PoLineInput).min(1, "Add at least one line"), }); // PATCH: vendor + notes are free to change any time. Lines can only be // rewritten while the PO is in draft — the route enforces that. export const UpdatePOSchema = z .object({ vendor: NonEmpty.optional(), notes: OptionalText, lines: z.array(PoLineInput).min(1).optional(), }) .strict(); // Status transitions are validated in the route (see PO_TRANSITIONS). export const UpdatePOStatusSchema = z.object({ status: z.enum(PoStatuses), }); // Receipt: bump receivedQty by `qty` for each listed line. If every line on // the PO is fully received the route auto-moves status to "received"; // otherwise it moves to "partial". export const ReceivePOSchema = z.object({ receipts: z .array( z.object({ lineId: z.string().min(1), qty: z.coerce.number().int().positive().max(1_000_000), }), ) .min(1), }); export const ReleaseOperationSchema = z.object({ unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(), note: OptionalText, }); // A scan-page "Done". If the operation was flagged qcRequired the operator // must stamp an inline pass/fail before we allow the close. export const CloseOperationSchema = z.object({ unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(), note: OptionalText, qc: z .object({ passed: z.boolean(), notes: OptionalText, measurements: JsonString, }) .optional(), });