From 4dc3b1aa279aa4a228b42338402d9243b54bc9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:09:52 +0200 Subject: [PATCH] feat(vaccinations): add the wire schemas and the resolved DTO - a dose needs a day and one way of naming what it was, and nothing else; a required catalogue pick would make an old Pass line unloggable - the write path refuses a future date and a slug the catalogue does not offer, while storage stays tolerant so an older backup restores whole - the DTO publishes the practitioner, the visit, the linked pages, the catalogue entry and the derived series already resolved, in numbers rather than localised text - a linked page's name and date are withheld from a delegate whose grant does not reach the vault, and the withholding says so rather than looking like a page with no name --- src/lib/vaccinations/dto.ts | 165 ++++++++++++ src/lib/vaccinations/service.ts | 170 ++++++++++++ .../__tests__/vaccinations.test.ts | 250 ++++++++++++++++++ src/lib/validations/vaccinations.ts | 211 +++++++++++++++ 4 files changed, 796 insertions(+) create mode 100644 src/lib/vaccinations/dto.ts create mode 100644 src/lib/vaccinations/service.ts create mode 100644 src/lib/validations/__tests__/vaccinations.test.ts create mode 100644 src/lib/validations/vaccinations.ts diff --git a/src/lib/vaccinations/dto.ts b/src/lib/vaccinations/dto.ts new file mode 100644 index 000000000..653b96908 --- /dev/null +++ b/src/lib/vaccinations/dto.ts @@ -0,0 +1,165 @@ +/** + * Vaccination row → wire DTO. + * + * Everything is published resolved. The practitioner comes back as an object + * and never as a bare id, the visit as a stub with its date and kind, the + * document links with a label and a date, the catalogue entry as an object or + * an explicit `null`, and — the one that matters — the SERIES already derived. + * + * "Dose 3 of 3" is a property of the whole history, not of the row, and it is + * computed once on the server (`src/lib/vaccinations/series.ts`) so no client + * re-derives it. The DTO carries numbers and a boolean, never a localised + * string: six locales must not ride the API. + * + * `catalogEntry: null` is the degrade signal. It means the slug the row + * carries is not one this release knows — a slug a past version wrote, or one + * restored from an older backup — and the client falls back to `vaccineName`. + * It is not an error and it is not empty data. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import type { + Encounter, + Practitioner, + VaccinationRecord, + VaccinationSite, +} from "@/generated/prisma/client"; +import { + toPractitionerDTO, + type PractitionerDTO, +} from "@/lib/practitioners/dto"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import type { SeriesPosition } from "@/lib/vaccinations/series"; + +/** + * One page a dose was transcribed from, as the caller is allowed to see it. + * + * `label` and `date` go null together when the caller's grant does not cover + * the vault. A dose lives in the health background and points into the + * documents domain, and a grant that opened the background was never consent + * for what is on the other side of that seam — the FILENAME of a scanned page + * is itself the sensitive part. `redacted` says which null is a withholding + * and which is simply a page with no date, so a client never has to guess. + */ +export interface VaccinationDocumentDTO { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +} + +/** What the catalogue knows about the pick, when it still knows it. */ +export interface VaccinationCatalogDTO { + slug: string; + atc: string; + category: string; +} + +/** The visit this dose was given at, enough to recognise it by. */ +export interface VaccinationEncounterDTO { + id: string; + occurredAt: string; + kind: string; +} + +export interface VaccinationDTO { + id: string; + /** The day, at UTC midnight. An Impfpass carries dates, never times. */ + occurredAt: string; + /** The stored slug, verbatim — resolvable or not. */ + antigenSlug: string | null; + /** What the person wrote. May be a trade name; it is their transcription. */ + vaccineName: string | null; + doseNumber: number | null; + seriesDoses: number | null; + lotNumber: string | null; + site: VaccinationSite | null; + /** + * The catalogue's view of `antigenSlug`, or `null` when this release does + * not know the slug. Null is the signal to render `vaccineName` instead. + */ + catalogEntry: VaccinationCatalogDTO | null; + /** + * Where this dose sits in each of its antigen series, resolved server-side. + * + * One entry per component antigen, so a combined dose carries several. Empty + * for a free-text-only row and for a slug the catalogue cannot resolve — no + * antigen is guessed from a name string. + * + * The client renders text from these numbers: both `position` and `total` → + * "N of M", only `position` → "dose N", `booster` → "booster". It never + * recomputes them. + */ + series: SeriesPosition[]; + /** Resolved, never just an id. `null` when the dose names no practice. */ + practitioner: PractitionerDTO | null; + /** Resolved stub, never just an id. `null` when no visit is linked. */ + encounter: VaccinationEncounterDTO | null; + /** The booster reminder this dose satisfied, when one matched. */ + reminderId: string | null; + note: string | null; + /** Present on the detail response; omitted from the list. */ + documents?: VaccinationDocumentDTO[]; + createdAt: string; + updatedAt: string; +} + +/** Decrypt the dose note fail-soft: a rotation gap reads as no note, not 500. */ +function decryptNote(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + try { + return decryptFromBytes(value); + } catch (err) { + getEvent()?.addWarning( + `vaccination note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +export type VaccinationWithRelations = VaccinationRecord & { + practitioner?: Practitioner | null; + encounter?: Pick | null; +}; + +export function toVaccinationDTO( + row: VaccinationWithRelations, + series: SeriesPosition[], + documents?: VaccinationDocumentDTO[], +): VaccinationDTO { + const entry = resolveCatalogEntry(row.antigenSlug); + return { + id: row.id, + occurredAt: row.occurredAt.toISOString(), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName, + doseNumber: row.doseNumber, + seriesDoses: row.seriesDoses, + lotNumber: row.lotNumber, + site: row.site, + catalogEntry: entry + ? { slug: entry.slug, atc: entry.atc, category: entry.category } + : null, + series, + practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null, + encounter: row.encounter + ? { + id: row.encounter.id, + occurredAt: row.encounter.occurredAt.toISOString(), + kind: row.encounter.kind, + } + : null, + reminderId: row.reminderId, + note: decryptNote(row.noteEncrypted), + ...(documents ? { documents } : {}), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** The list response: one array, newest dose first. */ +export interface VaccinationListDTO { + vaccinations: VaccinationDTO[]; +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts new file mode 100644 index 000000000..b07c0de3f --- /dev/null +++ b/src/lib/vaccinations/service.ts @@ -0,0 +1,170 @@ +/** + * What the vaccination routes share: the query shapes, the ownership probes, + * the domain redaction, and the link application. + * + * Extracted for the same reason `src/lib/encounters/service.ts` is: four route + * files need the same include, the same "does the caller own this practice" + * question and the same seam handling, and four copies of a redaction rule is + * three chances to get it wrong. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { listTargets, replaceTargets } from "@/lib/links"; +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; +import { + deriveSeries, + type SeriesInputRecord, +} from "@/lib/vaccinations/series"; + +/** The relations every vaccination response resolves. */ +export const VACCINATION_INCLUDE = { + practitioner: true, + encounter: { select: { id: true, occurredAt: true, kind: true } }, +} as const satisfies Prisma.VaccinationRecordInclude; + +/** What the series derivation needs, and nothing more. */ +export const SERIES_SELECT = { + id: true, + occurredAt: true, + antigenSlug: true, + doseNumber: true, + seriesDoses: true, +} as const satisfies Prisma.VaccinationRecordSelect; + +/** + * Which sections of the record the caller is actually inside. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. Same shape as the visits service, deliberately + * — the two features sit on the same seam and a second spelling of this would + * be a second thing to keep in step. + */ +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} + +/** + * The whole live history for one account, in the shape the derivation wants. + * + * The series is a property of the set, so every read that publishes a position + * loads the set — including the detail route, which holds one row and cannot + * answer the question from it. The rows are small (five scalar columns) and an + * Impfpass is a short document even after a long life, so this is cheaper than + * the cache that would keep it consistent. + */ +export async function loadSeriesHistory( + tx: Prisma.TransactionClient, + userId: string, +): Promise { + return tx.vaccinationRecord.findMany({ + where: { userId, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + select: SERIES_SELECT, + }); +} + +/** Every record's resolved series positions, keyed by record id. */ +export async function resolveSeriesFor( + tx: Prisma.TransactionClient, + userId: string, +) { + return deriveSeries(await loadSeriesHistory(tx, userId)); +} + +/** + * The linked pages, resolved and redacted against the caller's reach. + * + * A dose lives in the health background and points into the document vault. A + * grant that opened the background was never consent for the vault, and while + * no document CONTENT crosses the seam here, the filename is itself the + * sensitive part: a page called "oncology discharge letter" says most of what + * the person was worried about. + * + * So the label and the date are withheld and the LINK is not: the caller may + * know the dose was transcribed from a page, because the dose is theirs to + * read. `label: null` rather than an invented placeholder, so an absence reads + * as an absence, and `redacted` says which of the two nulls this is. + */ +export async function loadVaccinationDocuments( + tx: Prisma.TransactionClient, + userId: string, + vaccinationId: string, + visible: (domain: ShareDomain) => boolean, +): Promise { + const rows = await listTargets(tx, { + userId, + sourceKind: "vaccination", + sourceId: vaccinationId, + targetKind: "document", + }); + if (visible("documents")) { + return rows.map((row) => ({ ...row, redacted: false })); + } + return rows.map((row) => ({ + id: row.id, + label: null, + date: null, + redacted: true, + })); +} + +/** + * Apply the document array, when the request named one. + * + * An absent array leaves the links alone; a present one replaces them, empty + * array included. Unknown ids are dropped by the link service and never block + * the save — a dose is never refused because a page it pointed at has since + * been deleted. + */ +export async function applyVaccinationLinks( + tx: Prisma.TransactionClient, + userId: string, + vaccinationId: string, + documentIds: string[] | undefined, +): Promise { + if (documentIds === undefined) return; + await replaceTargets(tx, { + userId, + sourceKind: "vaccination", + sourceId: vaccinationId, + targetKind: "document", + targetIds: documentIds, + }); +} + +/** Does the caller own a live practitioner with this id? */ +export async function resolveOwnedPractitioner( + tx: Prisma.TransactionClient, + userId: string, + practitionerId: string, +): Promise<{ id: string } | null> { + return tx.practitioner.findFirst({ + where: { id: practitionerId, userId, deletedAt: null }, + select: { id: true }, + }); +} + +/** Does the caller own a live visit with this id? */ +export async function resolveOwnedEncounter( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, +): Promise<{ id: string } | null> { + return tx.encounter.findFirst({ + where: { id: encounterId, userId, deletedAt: null }, + select: { id: true }, + }); +} diff --git a/src/lib/validations/__tests__/vaccinations.test.ts b/src/lib/validations/__tests__/vaccinations.test.ts new file mode 100644 index 000000000..cba1936ab --- /dev/null +++ b/src/lib/validations/__tests__/vaccinations.test.ts @@ -0,0 +1,250 @@ +/** + * The vaccination wire contract, at the boundary. + * + * Four properties are worth holding here rather than only at the route: the + * either-arm rule (the one thing that IS mandatory beyond the date), the + * future-date refusal (the split from the visit schema next door), the + * catalogue check on write (the split from what storage accepts), and the + * absence of `userId` (which is narrowed from auth in every route and must + * never be something a body can name). + */ +import { describe, expect, it } from "vitest"; + +import { + vaccinationCreateSchema, + vaccinationLinkSchema, + vaccinationListQuerySchema, + vaccinationUpdateSchema, +} from "@/lib/validations/vaccinations"; + +const YESTERDAY = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +const NEXT_YEAR = new Date( + Date.now() + 365 * 24 * 60 * 60 * 1000, +).toISOString(); + +function pathsOf(result: { success: false; error: { issues: unknown[] } }) { + return (result.error.issues as Array<{ path: (string | number)[] }>).map( + (issue) => issue.path.join("."), + ); +} + +describe("a dose needs a date and one way of naming what it was", () => { + it("accepts a catalogue pick with nothing else", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tdap", + }); + expect(parsed.success).toBe(true); + }); + + it("accepts free text with nothing else, so a 1987 Pass line is loggable", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + vaccineName: "Tetanol, as the Pass wrote it", + }); + expect(parsed.success).toBe(true); + }); + + it("refuses a body with neither, naming BOTH fields", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + // Both, so a client highlighting one field per issue highlights the pick + // and the text box rather than leaving the person to guess which was + // meant. + expect(pathsOf(parsed).sort()).toEqual(["antigenSlug", "vaccineName"]); + }); + + it("returns every issue at once rather than the first", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: "not-a-date", + doseNumber: 99, + lotNumber: "x".repeat(200), + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("a dose is logged after the fact", () => { + it("refuses a date in the future", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: NEXT_YEAR, + antigenSlug: "tetanus", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("occurredAt"); + expect(JSON.stringify(parsed.error.issues)).toMatch( + /reminder, not a record/, + ); + }); + + it("allows a day's worth of clock skew, so a client east of here works", () => { + const inSixHours = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: inSixHours, + antigenSlug: "tetanus", + }).success, + ).toBe(true); + }); + + it("refuses a date before there were records to keep", () => { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: "1802-04-01T00:00:00.000Z", + antigenSlug: "tetanus", + }).success, + ).toBe(false); + }); +}); + +describe("the write path is stricter than the column", () => { + it("refuses a slug the catalogue does not offer", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "not-a-real-antigen", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("antigenSlug"); + }); + + it("accepts every slug the catalogue does offer", () => { + for (const slug of ["tetanus", "tdap", "hexavalent", "mmrv", "cholera"]) { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: slug, + }).success, + slug, + ).toBe(true); + } + }); +}); + +describe("no body may name the account it writes to", () => { + it("refuses a userId field on create", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tetanus", + userId: "somebody-else", + }); + expect( + parsed.success, + "userId is narrowed from auth; a body that can name it is a body that " + + "can write into another account", + ).toBe(false); + }); + + it("refuses a userId field on update", () => { + expect( + vaccinationUpdateSchema.safeParse({ + lotNumber: "AB-1", + userId: "somebody-else", + }).success, + ).toBe(false); + }); + + it("refuses an unknown field rather than ignoring it", () => { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tetanus", + manufacturer: "a trade name", + }).success, + ).toBe(false); + }); +}); + +describe("an edit names what it changes", () => { + it("refuses a body that names nothing", () => { + const parsed = vaccinationUpdateSchema.safeParse({}); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(JSON.stringify(parsed.error.issues)).toMatch( + /name at least one field/, + ); + }); + + it("allows one field on its own", () => { + expect( + vaccinationUpdateSchema.safeParse({ lotNumber: "AB-1" }).success, + ).toBe(true); + }); + + it("allows the date to be corrected", () => { + expect( + vaccinationUpdateSchema.safeParse({ occurredAt: YESTERDAY }).success, + ).toBe(true); + }); + + it("refuses an edit that clears both identity arms at once", () => { + expect( + vaccinationUpdateSchema.safeParse({ + antigenSlug: null, + vaccineName: null, + }).success, + ).toBe(false); + }); + + it("allows clearing one arm, because the other may still be there", () => { + expect( + vaccinationUpdateSchema.safeParse({ antigenSlug: null }).success, + ).toBe(true); + }); +}); + +describe("the list query and the link body", () => { + it("refuses a window that ends before it starts", () => { + const parsed = vaccinationListQuerySchema.safeParse({ + from: "2026-07-01T00:00:00.000Z", + to: "2026-06-01T00:00:00.000Z", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("to"); + }); + + it("coerces a limit from the query string and bounds it", () => { + expect(vaccinationListQuerySchema.safeParse({ limit: "50" })).toMatchObject( + { + success: true, + data: { limit: 50 }, + }, + ); + expect( + vaccinationListQuerySchema.safeParse({ limit: "5000" }).success, + ).toBe(false); + }); + + it("bounds a link call at the facade's own cap", () => { + const ids = (count: number) => + Array.from({ length: count }, (_, index) => `doc-${index}`); + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "document", + targetIds: ids(100), + }).success, + ).toBe(true); + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "document", + targetIds: ids(101), + }).success, + ).toBe(false); + }); + + it("knows exactly one link family", () => { + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "labResult", + targetIds: ["lab-1"], + }).success, + ).toBe(false); + }); +}); diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts new file mode 100644 index 000000000..0ae186dee --- /dev/null +++ b/src/lib/validations/vaccinations.ts @@ -0,0 +1,211 @@ +/** + * 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 { VACCINE_CATALOG_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; + +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 = z.iso.datetime({ offset: true }).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 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(), + /** Becomes `noteEncrypted`. Impfreaktionen live here. */ + note: z.string().max(2000).nullable().optional(), + documentIds: linkIds, + }) + .strict() + .superRefine((body, ctx) => { + if (body.antigenSlug || body.vaccineName) 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(), + 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 BOTH arms in one call. Clearing one while + // the other stays is legal and the route checks the merged row. + !(body.antigenSlug === null && body.vaccineName === 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: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + limit: z.coerce.number().int().min(1).max(500).optional(), + }) + .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; + +/* ── links ────────────────────────────────────────────────────────── */ + +/** + * The one link family a dose has. + * + * A single-member enum rather than a bare constant, so the wire shape matches + * the encounter link route's and a second family — if this venture ever loses + * the argument against one — is an added member rather than a changed shape. + */ +export const vaccinationLinkTargetEnum = z.enum(["document"]); + +export const vaccinationLinkSchema = z + .object({ + targetKind: vaccinationLinkTargetEnum, + targetIds: z.array(id).min(1).max(100), + }) + .strict(); + +export type VaccinationLinkInput = z.infer;