/** * Vaccination request/response validation. * * Source of truth for the `/api/vaccinations*` wire contract; the OpenAPI * registry reuses these schemas so the spec stays single-source. `userId` is * NEVER a body field — it is narrowed from the session or Bearer in every * route and fed to the Prisma `where`. * * Nothing is mandatory except the date and ONE identity arm. A dose saves with * a day and either a catalogue pick or the person's own wording, and nothing * else: no dose number, no batch code, no arm, no practice, no document. A * required catalogue pick would make a 1987 Pass line unloggable, which is the * whole population this feature exists for. * * Two places where the API is deliberately stricter than the column: * * - `occurredAt` refuses a future instant. A vaccination is logged after the * fact; a planned booster is a reminder, not a record. The visit schema * next door allows the future because a booked appointment genuinely is * one; a dose that has not been given is not a dose. * - `antigenSlug` must resolve in the catalogue this release ships. Storage * stays tolerant so a backup written by an older version restores whole * and its slug degrades to the free-text arm on screen; a write is the one * moment the catalogue is authoritative, because the picker produced it. */ import { z } from "zod/v4"; import { listOffsetField } from "@/lib/http/list-query-fields"; import { ANTIGEN_SLUGS, VACCINE_CATALOG_SLUGS, } from "@/lib/vaccinations/vaccine-catalog"; import { isoDateTime } from "@/lib/validations/datetime"; const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); /** * A day a dose can plausibly carry. * * Bounded on both sides: not before 1900, and not in the future beyond the * clock skew a client's own "today" can plausibly show. The skew allowance is * a day rather than minutes because the value is a DAY — a client in a * timezone ahead of the server sends its own local date, and refusing that * would make the form unusable east of here. */ const DAY_SKEW_MS = 24 * 60 * 60 * 1000; const doseInstant = isoDateTime().refine( (value) => { const at = new Date(value).getTime(); if (Number.isNaN(at)) return false; if (at < Date.UTC(1900, 0, 1)) return false; return at <= Date.now() + DAY_SKEW_MS; }, { message: "must be a day a dose was actually given (not before 1900, not in the future — a planned booster is a reminder, not a record)", }, ); /* ── enum (mirrors the Prisma enum) ───────────────────────────────── */ export const vaccinationSiteEnum = z.enum([ "LEFT_ARM", "RIGHT_ARM", "LEFT_THIGH", "RIGHT_THIGH", "ORAL", "NASAL", "OTHER", ]); export type VaccinationSiteInput = z.infer; const id = z.string().min(1).max(40); /** * A slug the catalogue this release ships actually has. * * The message names the field rather than listing thirty-five slugs: the * picker is the source of a legitimate value, so a slug arriving here that is * not in the set is a client defect or a hand-written request, and neither is * helped by an enumeration. */ const antigenSlug = z .string() .min(1) .max(64) .refine((value) => KNOWN_SLUGS.has(value), { message: "must be a slug the vaccine catalogue offers", }); /** Bounded at the link service's own cap, so an over-long list is a 422. */ const linkIds = z.array(id).max(100).optional(); /* ── vaccination CRUD ─────────────────────────────────────────────── */ /** * The either-arm rule, applied to whichever schema needs it. * * Written as a helper because create and update both need it and they need it * to REPORT the same way: the issue is raised against both fields, so a client * highlighting one field per issue highlights the pick and the free-text box * rather than leaving the person guessing which of the two the server meant. */ const IDENTITY_MESSAGE = "name the vaccine: pick one from the catalogue or your own vaccines, or type what the record says"; export const vaccinationCreateSchema = z .object({ occurredAt: doseInstant, antigenSlug: antigenSlug.nullable().optional(), /** The Pass's own wording, verbatim. May be a trade name — user data. */ vaccineName: z.string().min(1).max(200).nullable().optional(), doseNumber: z.number().int().min(1).max(20).nullable().optional(), seriesDoses: z.number().int().min(1).max(10).nullable().optional(), /** Chargenbezeichnung. A batch code, not a secret. */ lotNumber: z.string().min(1).max(64).nullable().optional(), site: vaccinationSiteEnum.nullable().optional(), practitionerId: id.nullable().optional(), encounterId: id.nullable().optional(), /** * v1.42 (#1005) — one of the record's own vaccine definitions, the third * identity arm. Re-narrowed to the resolved record in the route. */ customVaccineId: id.nullable().optional(), /** Becomes `noteEncrypted`. Impfreaktionen live here. */ note: z.string().max(2000).nullable().optional(), documentIds: linkIds, }) .strict() .superRefine((body, ctx) => { if (body.antigenSlug || body.vaccineName || body.customVaccineId) return; for (const path of ["antigenSlug", "vaccineName"] as const) { ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE }); } }); export type VaccinationCreate = z.infer; /** * Edit a dose. Every field optional; an omitted field is left untouched. * * A body naming nothing is a 422 rather than a silent success: a PATCH that * changed nothing and answered 200 is indistinguishable from one that worked. * * `occurredAt` is editable — a transcription typo is the common case, and the * date is the field most likely to carry one. The identity rule is checked * against the merged row in the route rather than here, because a PATCH that * names only `lotNumber` says nothing about the identity and must not be * refused for it. */ export const vaccinationUpdateSchema = z .object({ occurredAt: doseInstant.optional(), antigenSlug: antigenSlug.nullable().optional(), vaccineName: z.string().min(1).max(200).nullable().optional(), doseNumber: z.number().int().min(1).max(20).nullable().optional(), seriesDoses: z.number().int().min(1).max(10).nullable().optional(), lotNumber: z.string().min(1).max(64).nullable().optional(), site: vaccinationSiteEnum.nullable().optional(), practitionerId: id.nullable().optional(), encounterId: id.nullable().optional(), customVaccineId: id.nullable().optional(), note: z.string().max(2000).nullable().optional(), documentIds: linkIds, }) .strict() .refine((body) => Object.keys(body).length > 0, { message: "name at least one field to change", }) .refine( (body) => // Only when the edit clears EVERY arm in one call. Clearing one while // another stays is legal and the route checks the merged row. !( body.antigenSlug === null && body.vaccineName === null && body.customVaccineId === null ), { path: ["antigenSlug"], message: IDENTITY_MESSAGE }, ); export type VaccinationUpdate = z.infer; /** * List query. * * An Impfpass is a lifetime document but a small one, so the list is full and * bounded rather than paged: the default `take` already covers a long life, * and a client that wants less says so. */ export const vaccinationListQuerySchema = z .object({ antigenSlug: z.string().min(1).max(64).optional(), from: isoDateTime().optional(), to: isoDateTime().optional(), limit: z.coerce.number().int().min(1).max(500).optional(), offset: listOffsetField() .optional() .describe( "Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists.", ), }) .strict() .refine((q) => !(q.from && q.to) || q.from <= q.to, { path: ["to"], message: "to must be on or after from", }); export type VaccinationListQuery = z.infer; /* ── the record's own vaccines (v1.42, #1005) ─────────────────────── */ /** * The antigens a definition protects against: only from the catalogue's * closed antigen list, because the series derivation and the booster match * both key on those slugs. A disease the list does not carry is a free-text * dose, not a definition. */ export const customVaccineComponentsSchema = z .array(z.enum(ANTIGEN_SLUGS)) .min(1) .max(ANTIGEN_SLUGS.length) .refine((list) => new Set(list).size === list.length, { message: "name each antigen once", }); /** The definition's name: what the person calls the product. */ const customVaccineName = z.string().trim().min(1).max(100); export const customVaccineCreateSchema = z .object({ name: customVaccineName, components: customVaccineComponentsSchema, typicalSeriesDoses: z.number().int().min(1).max(10).nullable().optional(), /** Months; a decade booster is 120, bounded like the booster confirm. */ boosterIntervalMonths: z .number() .int() .min(1) .max(600) .nullable() .optional(), }) .strict(); export type CustomVaccineCreate = z.infer; /** Edit a definition; an omitted field is left untouched, an empty body 422s. */ export const customVaccineUpdateSchema = customVaccineCreateSchema .partial() .strict() .refine((body) => Object.keys(body).length > 0, { message: "name at least one field to change", }); export type CustomVaccineUpdate = z.infer; /* ── links ────────────────────────────────────────────────────────── */ /** * The link families a dose has: the page it was transcribed from and, since * v1.43, the visit it was given at (`encounter_vaccination_links`, the same * table the visit's own links route writes from its end). */ export const vaccinationLinkTargetEnum = z.enum(["document", "encounter"]); export const vaccinationLinkSchema = z .object({ targetKind: vaccinationLinkTargetEnum, targetIds: z.array(id).min(1).max(100), }) .strict(); export type VaccinationLinkInput = z.infer; /* ── booster mint (rung 2) ────────────────────────────────────────── */ /** * The confirm body for a booster reminder. * * Every value is the user's — accepted or edited from the catalogue's * prefill. `intervalMonths` is bounded generously (a decade booster is 120) * and the label is composed client-side so its locale is the person's own. No * `vaccinationAntigen` field: the server reads the antigen from the dose's * catalogue entry, never from the request, so a client cannot key a reminder * onto an antigen the dose does not contain. */ export const vaccinationBoosterSchema = z .object({ intervalMonths: z.number().int().min(1).max(600), label: z.string().trim().min(1).max(120), notifyHour: z.number().int().min(0).max(23).optional(), }) .strict(); export type VaccinationBoosterInput = z.infer; /* ── upload suggestion ────────────────────────────────────────────── */ /** * The single input to the upload suggestion: the document's anchor date. No * ids — the candidate doses come from the caller's own record, narrowed from * the session. */ export const vaccinationSuggestQuerySchema = z .object({ anchor: isoDateTime(), }) .strict(); export type VaccinationSuggestQuery = z.infer< typeof vaccinationSuggestQuerySchema >;