Files
open-safetylog-ehs/shared/schemas.ts
T
jayjennings 3e964d1176
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
2026-10-06 11:38:40 -07:00

125 lines
5.6 KiB
TypeScript

// 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, incidentStatuses, incidentTypes, 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() })
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() })
export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() })
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 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<typeof setupSchema>
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
export type KioskReportInput = z.input<typeof kioskReportSchema>
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
export type CreateActionInput = z.input<typeof createActionSchema>
export type UpdateActionInput = z.input<typeof updateActionSchema>
export type RootCauseInput = z.input<typeof rootCauseSchema>
export type UpdateRootCauseInput = z.input<typeof updateRootCauseSchema>
export type InvestigationInput = z.input<typeof investigationSchema>
export type IncidentListQuery = z.infer<typeof incidentListQuery>