// Request validation schemas, shared so the web app can validate forms with the same rules as the API. import { z } from 'zod' import { actionKinds, actionStatuses, areaKinds, employmentTypes, incidentStatuses, incidentTypes, oshaCaseTypes, oshaSpecialCases, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts' const email = z.string().trim().toLowerCase().max(254).pipe(z.email()) const password = z.string().min(10, 'Use at least 10 characters').max(256) const name = z.string().trim().min(1).max(200) const optionalText = (max: number) => z.string().trim().max(max).nullish() const isoDateTime = z.iso.datetime({ offset: true }) export const setupSchema = z.object({ organizationName: name, name, email, password }) export const loginSchema = z.object({ email, password: z.string().min(1).max(256) }) export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(256), newPassword: password }) /** Without a password, the user is emailed an invitation to set one. */ export const createUserSchema = z.object({ name, email, role: z.enum(roles), password: password.optional() }) /** `password` lets an admin set a new password when email isn't available. */ export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() }) const digits = (count: number[], label: string) => z .string() .trim() .transform((value) => value.replace(/[\s-]/g, '')) .pipe(z.string().refine((value) => value === '' || (count.includes(value.length) && /^\d+$/.test(value)), `${label} must be ${count.join(' or ')} digits`)) .transform((value) => value || null) .nullish() /** OSHA establishment details, as required by Form 300A and the ITA upload. */ const establishmentFields = { streetAddress: optionalText(100), city: optionalText(100), state: z .string() .trim() .toUpperCase() .refine((value) => value === '' || /^[A-Z]{2}$/.test(value), 'Use the two-letter state code') .transform((value) => value || null) .nullish(), zip: digits([5, 9], 'ZIP code'), naicsCode: digits([6], 'NAICS code'), industryDescription: optionalText(300), ein: digits([9], 'EIN'), establishmentType: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), } export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional(), ...establishmentFields }) export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() }) export const hoursWorkedSchema = z.object({ year: z.number().int().min(2000).max(2100), months: z .array( z.object({ month: z.number().int().min(1).max(12), hours: z.number().min(0).max(100_000_000).nullable(), averageEmployees: z.number().int().min(0).max(1_000_000).nullable(), }), ) .max(12), }) const dayCount = z.number().int().min(0).max(180) const isoDateOnly = z.iso.date() /** An injured or ill person on an incident, with the OSHA recordability answers and Form 301 details. */ const oshaCaseFields = { name, jobTitle: optionalText(255), employmentType: z.enum(employmentTypes), bodyPart: optionalText(255), natureOfInjury: optionalText(500), caseType: z.enum(oshaCaseTypes), workRelated: z.boolean().nullable(), newCase: z.boolean().nullable(), died: z.boolean(), dateOfDeath: isoDateOnly.nullish(), daysAwayFromWork: z.boolean(), daysAway: dayCount, restrictedOrTransfer: z.boolean(), daysRestricted: dayCount, medicalTreatment: z.boolean(), lossOfConsciousness: z.boolean(), significantDiagnosis: z.boolean(), specialCase: z.enum(oshaSpecialCases), privacyCase: z.boolean(), inpatientHospitalization: z.boolean(), amputation: z.boolean(), lossOfEye: z.boolean(), reportedToOshaAt: isoDateTime.nullish(), employeeAddress: optionalText(500), dateOfBirth: isoDateOnly.nullish(), dateOfHire: isoDateOnly.nullish(), sex: z.enum(['M', 'F']).nullish(), physicianName: optionalText(200), treatmentFacility: optionalText(500), treatedInEmergencyRoom: z.boolean(), timeStartedWork: z .string() .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use 24-hour time, e.g. 07:30') .nullish(), activityBefore: optionalText(2_000), whatHappened: optionalText(5_000), objectSubstance: optionalText(500), } export const createOshaCaseSchema = z.object(oshaCaseFields).partial().required({ name: true }) export const updateOshaCaseSchema = z.object(oshaCaseFields).partial() export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() }) export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() }) export const createIncidentSchema = z.object({ title: name, type: z.enum(incidentTypes), severity: z.enum(severities), occurredAt: isoDateTime, siteId: z.uuid(), areaId: z.uuid().nullish(), locationDetail: optionalText(500), description: optionalText(10_000), immediateAction: optionalText(10_000), anonymous: z.boolean().default(false), /** Random id from the reporting device; resubmitting the same id returns the existing report. */ clientId: z.uuid().optional(), }) /** Status changes go through transitions, not updates. */ export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true, clientId: true }).partial() export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) }) export const commentSchema = z.object({ body: z.string().trim().min(1).max(5_000) }) export const investigationSchema = z.object({ leadId: z.uuid().nullish(), method: z.enum(rcaMethods).optional(), summary: optionalText(20_000), completed: z.boolean().optional(), }) const rootCauseFields = { category: z.enum(rootCauseCategories), description: z.string().trim().min(1).max(2_000), whys: z.array(z.string().trim().min(1).max(1_000)).max(10), } export const rootCauseSchema = z.object({ ...rootCauseFields, whys: rootCauseFields.whys.default([]) }) // Update schemas are written out rather than derived with .partial(): in Zod 4, .partial() keeps // defaults, so a partial update would silently reset omitted fields. export const updateRootCauseSchema = z.object(rootCauseFields).partial() const isoDate = z.iso.date() const actionFields = { title: name, description: optionalText(5_000), kind: z.enum(actionKinds), assigneeId: z.uuid().nullish(), dueDate: isoDate.nullish(), rootCauseId: z.uuid().nullish(), } export const createActionSchema = z.object({ ...actionFields, kind: actionFields.kind.default('corrective') }) export const updateActionSchema = z.object({ ...actionFields, status: z.enum(actionStatuses) }).partial() export const actionListQuery = z.object({ scope: z.enum(['mine', 'all']).default('mine'), filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'), }) export const createKioskSchema = z.object({ name, siteId: z.uuid().nullish() }) export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uuid().nullish(), revoked: z.boolean().optional() }) /** Report filed through a reporting link (kiosk tablet or QR code), without an account. */ export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) }) export const analyticsQuery = z.object({ from: z.iso.date(), to: z.iso.date(), siteId: z.uuid().optional(), type: z.enum(incidentTypes).optional(), }) export const savedViewSchema = z.object({ page: z.enum(['analytics', 'incidents']), name, filters: z.record(z.string().max(50), z.string().max(200)).refine((value) => Object.keys(value).length <= 20, 'Too many filters'), shared: z.boolean().default(false), }) export const emailOnlySchema = z.object({ email }) export const tokenSchema = z.object({ token: z.string().min(20).max(200) }) export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), newPassword: password }) export const incidentListQuery = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(25), q: z.string().trim().max(200).optional(), type: z.enum(incidentTypes).optional(), severity: z.enum(severities).optional(), status: z.enum(incidentStatuses).optional(), siteId: z.uuid().optional(), }) export type SetupInput = z.infer export type CreateIncidentInput = z.input export type OshaCaseInput = z.input export type SiteInput = z.input export type KioskReportInput = z.input export type UpdateIncidentInput = z.infer export type CreateActionInput = z.input export type UpdateActionInput = z.input export type RootCauseInput = z.input export type UpdateRootCauseInput = z.input export type InvestigationInput = z.input export type IncidentListQuery = z.infer