Files
HealthLog/src/lib/validations/vaccinations.ts
T
Marc-André Bombeck 9b58394724 feat(vaccinations): link documents to doses, with the upload suggestion
- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
2026-08-08 21:11:21 +02:00

251 lines
9.8 KiB
TypeScript

/**
* 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<string> = 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<typeof vaccinationSiteEnum>;
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<typeof vaccinationCreateSchema>;
/**
* 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<typeof vaccinationUpdateSchema>;
/**
* 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<typeof vaccinationListQuerySchema>;
/* ── 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<typeof vaccinationLinkSchema>;
/* ── 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<typeof vaccinationBoosterSchema>;
/* ── 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: z.iso.datetime({ offset: true }),
})
.strict();
export type VaccinationSuggestQuery = z.infer<
typeof vaccinationSuggestQuerySchema
>;