Merge pull request #748 from MBombeck/feat/vaccinations-phase-1

Vaccinations: the record, the catalogue and their carriers
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 19:52:52 +02:00
commit 11a6bc40c0
57 files changed
+6032 -74

No files matched your search

+866 -32
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -6857,6 +6857,7 @@
"restoreSkippedMoodFactor": "Stimmungsfaktor",
"restoreSkippedMoodTag": "Stimmungs-Tag",
"restoreSkippedVisitReference": "Besuchsverknüpfung",
"restoreSkippedVaccinationReference": "Impfverknüpfung",
"restoreSkippedCheckupClosure": "Vorsorge-Abschluss",
"docsLink": "Dokumentation"
},
@@ -8507,6 +8508,10 @@
"nutrients": {
"label": "Nährstoffzufuhr",
"description": "Tägliche Mikronährstoff-Summen aus Apple Health."
},
"vaccinations": {
"label": "Impfungen",
"description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung."
}
},
"illness": {
@@ -9935,6 +9940,10 @@
"encounterVisitUpdate": "{name} hat einen Arztbesuch bearbeitet",
"encounterVisitDelete": "{name} hat einen Arztbesuch gelöscht",
"encounterVisitRestore": "{name} hat einen Arztbesuch wiederhergestellt",
"vaccinationRecordCreate": "{name} hat eine Impfung eingetragen",
"vaccinationRecordUpdate": "{name} hat eine Impfung bearbeitet",
"vaccinationRecordDelete": "{name} hat eine Impfung gelöscht",
"vaccinationRecordRestore": "{name} hat eine Impfung wiederhergestellt",
"practitionerContactCreate": "{name} hat eine Praxis zum Adressbuch hinzugefügt",
"practitionerContactUpdate": "{name} hat einen Adressbucheintrag bearbeitet",
"practitionerContactDelete": "{name} hat einen Adressbucheintrag gelöscht",
@@ -10014,7 +10023,7 @@
},
"profile": {
"label": "Gesundheitliche Vorgeschichte",
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil sowie Arztbesuche und Praxen."
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen sowie deine Impfungen."
},
"illness": {
"label": "Krankheiten",
+10 -1
View File
@@ -6857,6 +6857,7 @@
"restoreSkippedMoodFactor": "Mood factor",
"restoreSkippedMoodTag": "Mood tag",
"restoreSkippedVisitReference": "Visit reference",
"restoreSkippedVaccinationReference": "Vaccination reference",
"restoreSkippedCheckupClosure": "Checkup closure",
"docsLink": "Documentation"
},
@@ -8507,6 +8508,10 @@
"nutrients": {
"label": "Nutrient intake",
"description": "Daily micronutrient totals synced from Apple Health."
},
"vaccinations": {
"label": "Vaccinations",
"description": "The vaccination record, structured: dose, series, next booster."
}
},
"illness": {
@@ -9935,6 +9940,10 @@
"encounterVisitUpdate": "{name} edited a visit",
"encounterVisitDelete": "{name} deleted a visit",
"encounterVisitRestore": "{name} restored a visit",
"vaccinationRecordCreate": "{name} added a vaccination",
"vaccinationRecordUpdate": "{name} edited a vaccination",
"vaccinationRecordDelete": "{name} deleted a vaccination",
"vaccinationRecordRestore": "{name} restored a vaccination",
"practitionerContactCreate": "{name} added a practice to the address book",
"practitionerContactUpdate": "{name} edited an address book entry",
"practitionerContactDelete": "{name} deleted an address book entry",
@@ -10014,7 +10023,7 @@
},
"profile": {
"label": "Health background",
"detail": "Allergies, family history, the facts in your health profile, and your visits and practices."
"detail": "Allergies, family history, the facts in your health profile, your visits and practices, and your vaccination record."
},
"illness": {
"label": "Illness",
+10 -1
View File
@@ -6857,6 +6857,7 @@
"restoreSkippedMoodFactor": "Factor de ánimo",
"restoreSkippedMoodTag": "Etiqueta de ánimo",
"restoreSkippedVisitReference": "Referencia de visita",
"restoreSkippedVaccinationReference": "Referencia de vacunación",
"restoreSkippedCheckupClosure": "Cierre de revisión",
"docsLink": "Documentación"
},
@@ -8507,6 +8508,10 @@
"nutrients": {
"label": "Ingesta de nutrientes",
"description": "Totales diarios de micronutrientes sincronizados desde Apple Health."
},
"vaccinations": {
"label": "Vacunas",
"description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo."
}
},
"illness": {
@@ -9935,6 +9940,10 @@
"encounterVisitUpdate": "{name} editó una visita médica",
"encounterVisitDelete": "{name} eliminó una visita médica",
"encounterVisitRestore": "{name} restauró una visita médica",
"vaccinationRecordCreate": "{name} añadió una vacuna",
"vaccinationRecordUpdate": "{name} editó una vacuna",
"vaccinationRecordDelete": "{name} eliminó una vacuna",
"vaccinationRecordRestore": "{name} restauró una vacuna",
"practitionerContactCreate": "{name} añadió una consulta a la agenda",
"practitionerContactUpdate": "{name} editó una entrada de la agenda",
"practitionerContactDelete": "{name} eliminó una entrada de la agenda",
@@ -10014,7 +10023,7 @@
},
"profile": {
"label": "Antecedentes de salud",
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud y tus visitas médicas y consultas."
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, y tu cartilla de vacunación."
},
"illness": {
"label": "Enfermedades",
+10 -1
View File
@@ -6857,6 +6857,7 @@
"restoreSkippedMoodFactor": "Facteur d'humeur",
"restoreSkippedMoodTag": "Étiquette d'humeur",
"restoreSkippedVisitReference": "Référence de visite",
"restoreSkippedVaccinationReference": "Référence de vaccination",
"restoreSkippedCheckupClosure": "Clôture d'un examen",
"docsLink": "Documentation"
},
@@ -8507,6 +8508,10 @@
"nutrients": {
"label": "Apports en nutriments",
"description": "Totaux quotidiens de micronutriments synchronisés depuis Apple Health."
},
"vaccinations": {
"label": "Vaccinations",
"description": "Le carnet de vaccination structuré : dose, série, prochain rappel."
}
},
"illness": {
@@ -9935,6 +9940,10 @@
"encounterVisitUpdate": "{name} a modifié une consultation",
"encounterVisitDelete": "{name} a supprimé une consultation",
"encounterVisitRestore": "{name} a restauré une consultation",
"vaccinationRecordCreate": "{name} a ajouté une vaccination",
"vaccinationRecordUpdate": "{name} a modifié une vaccination",
"vaccinationRecordDelete": "{name} a supprimé une vaccination",
"vaccinationRecordRestore": "{name} a restauré une vaccination",
"practitionerContactCreate": "{name} a ajouté un cabinet au carnet d'adresses",
"practitionerContactUpdate": "{name} a modifié une entrée du carnet d'adresses",
"practitionerContactDelete": "{name} a supprimé une entrée du carnet d'adresses",
@@ -10014,7 +10023,7 @@
},
"profile": {
"label": "Antécédents de santé",
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, ainsi que vos consultations et cabinets."
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, ainsi que votre carnet de vaccination."
},
"illness": {
"label": "Maladies",
+10 -1
View File
@@ -6857,6 +6857,7 @@
"restoreSkippedMoodFactor": "Fattore dell'umore",
"restoreSkippedMoodTag": "Etichetta dell'umore",
"restoreSkippedVisitReference": "Riferimento alla visita",
"restoreSkippedVaccinationReference": "Riferimento alla vaccinazione",
"restoreSkippedCheckupClosure": "Chiusura del controllo",
"docsLink": "Documentazione"
},
@@ -8507,6 +8508,10 @@
"nutrients": {
"label": "Assunzione di nutrienti",
"description": "Totali giornalieri di micronutrienti sincronizzati da Apple Health."
},
"vaccinations": {
"label": "Vaccinazioni",
"description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo."
}
},
"illness": {
@@ -9935,6 +9940,10 @@
"encounterVisitUpdate": "{name} ha modificato una visita medica",
"encounterVisitDelete": "{name} ha eliminato una visita medica",
"encounterVisitRestore": "{name} ha ripristinato una visita medica",
"vaccinationRecordCreate": "{name} ha aggiunto una vaccinazione",
"vaccinationRecordUpdate": "{name} ha modificato una vaccinazione",
"vaccinationRecordDelete": "{name} ha eliminato una vaccinazione",
"vaccinationRecordRestore": "{name} ha ripristinato una vaccinazione",
"practitionerContactCreate": "{name} ha aggiunto uno studio alla rubrica",
"practitionerContactUpdate": "{name} ha modificato una voce della rubrica",
"practitionerContactDelete": "{name} ha eliminato una voce della rubrica",
@@ -10014,7 +10023,7 @@
},
"profile": {
"label": "Anamnesi",
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario e le tue visite mediche e studi."
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, e il tuo libretto vaccinale."
},
"illness": {
"label": "Malattie",
+10 -1
View File
@@ -6857,6 +6857,7 @@
"restoreSkippedMoodFactor": "Czynnik nastroju",
"restoreSkippedMoodTag": "Tag nastroju",
"restoreSkippedVisitReference": "Odwołanie wizyty",
"restoreSkippedVaccinationReference": "Odwołanie szczepienia",
"restoreSkippedCheckupClosure": "Zamknięcie badania",
"docsLink": "Dokumentacja"
},
@@ -8507,6 +8508,10 @@
"nutrients": {
"label": "Spożycie składników odżywczych",
"description": "Dzienne sumy mikroskładników synchronizowane z Apple Health."
},
"vaccinations": {
"label": "Szczepienia",
"description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie."
}
},
"illness": {
@@ -9935,6 +9940,10 @@
"encounterVisitUpdate": "{name} zmienił wizytę lekarską",
"encounterVisitDelete": "{name} usunął wizytę lekarską",
"encounterVisitRestore": "{name} przywrócił wizytę lekarską",
"vaccinationRecordCreate": "{name} dodał szczepienie",
"vaccinationRecordUpdate": "{name} zmienił szczepienie",
"vaccinationRecordDelete": "{name} usunął szczepienie",
"vaccinationRecordRestore": "{name} przywrócił szczepienie",
"practitionerContactCreate": "{name} dodał gabinet do książki adresowej",
"practitionerContactUpdate": "{name} zmienił wpis w książce adresowej",
"practitionerContactDelete": "{name} usunął wpis w książce adresowej",
@@ -10014,7 +10023,7 @@
},
"profile": {
"label": "Historia zdrowia",
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego oraz wizyty lekarskie i gabinety."
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety oraz Twoja historia szczepień."
},
"illness": {
"label": "Choroby",
@@ -0,0 +1,87 @@
-- Vaccinations: the immunization log and the page it was transcribed from.
--
-- Until now a scanned Impfpass could be filed as a document and nothing more —
-- no structured dose existed anywhere in the model. These two tables turn a
-- Pass line into a record: one row per administered dose, and the link that
-- keeps the scanned page beside it.
--
-- Additive, with one exception that is deliberate and small: the nullable
-- `vaccination_antigen` column on `measurement_reminders`. It is the match key
-- a logged dose uses to clear a booster reminder. No default, no backfill, no
-- read path outside the vaccination create route, and the published reminder
-- contract does not move.
-- CreateEnum
CREATE TYPE "vaccination_site" AS ENUM ('LEFT_ARM', 'RIGHT_ARM', 'LEFT_THIGH', 'RIGHT_THIGH', 'ORAL', 'NASAL', 'OTHER');
-- AlterTable
ALTER TABLE "measurement_reminders" ADD COLUMN "vaccination_antigen" TEXT;
-- CreateTable
CREATE TABLE "vaccination_records" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"antigen_slug" TEXT,
"vaccine_name" TEXT,
"dose_number" INTEGER,
"series_doses" INTEGER,
"lot_number" TEXT,
"site" "vaccination_site",
"practitioner_id" TEXT,
"encounter_id" TEXT,
"reminder_id" TEXT,
"note_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "vaccination_records_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "vaccination_document_links" (
"id" TEXT NOT NULL,
"vaccination_id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vaccination_document_links_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "vaccination_records_user_id_deleted_at_occurred_at_idx" ON "vaccination_records"("user_id", "deleted_at", "occurred_at");
-- CreateIndex
CREATE INDEX "vaccination_records_user_id_antigen_slug_occurred_at_idx" ON "vaccination_records"("user_id", "antigen_slug", "occurred_at");
-- CreateIndex
CREATE INDEX "vaccination_document_links_document_id_idx" ON "vaccination_document_links"("document_id");
-- CreateIndex
CREATE INDEX "vaccination_document_links_user_id_idx" ON "vaccination_document_links"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "vaccination_document_links_vaccination_id_document_id_key" ON "vaccination_document_links"("vaccination_id", "document_id");
-- AddForeignKey
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_practitioner_id_fkey" FOREIGN KEY ("practitioner_id") REFERENCES "practitioners"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_reminder_id_fkey" FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_vaccination_id_fkey" FOREIGN KEY ("vaccination_id") REFERENCES "vaccination_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+182 -23
View File
@@ -940,6 +940,11 @@ model User {
encounterDocumentLinks EncounterDocumentLink[]
encounterLabLinks EncounterLabLink[]
encounterConditionLinks EncounterConditionLink[]
// v1.38.0 — the immunization log. One row per administered dose, plus the
// link table that files the scanned Impfpass page beside it. Gated by the
// `vaccinations` module key, which governs the surfaces and not the store.
vaccinationRecords VaccinationRecord[]
vaccinationDocumentLinks VaccinationDocumentLink[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -5110,61 +5115,82 @@ model Device {
/// server-computed + stored (server-authoritative — iOS consumes the resolved
/// value, never recomputes). Soft-deleted.
model MeasurementReminder {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
/// User-facing copy: "Blutdruck messen", "Großes Blutbild".
label String
label String
/// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood
/// panel, a dermatology check) with no measurement to match against; those
/// resolve only on a manual satisfy. Non-NULL = the cron advances
/// `lastSatisfiedAt` when a matching Measurement of this type lands.
measurementType MeasurementType? @map("measurement_type")
measurementType MeasurementType? @map("measurement_type")
/// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence
/// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in
/// practice; both NULL is a one-shot reminder anchored on `anchorDate`.
intervalDays Int? @map("interval_days")
intervalDays Int? @map("interval_days")
/// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through
/// the same recurrence engine the medication schedules use.
rrule String?
rrule String?
/// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt`
/// when NULL.
anchorDate DateTime? @map("anchor_date")
anchorDate DateTime? @map("anchor_date")
/// Optional course-window end. NULL = open-ended cadence (the existing
/// behaviour). Non-NULL bounds a finite cadence: the recurrence engine
/// (`medication.endsOn` precedent) stops producing occurrences past this
/// instant, so a Coach-suggested "measure BP morning+evening for a week"
/// self-expires. Additive; every existing reminder keeps `NULL` and stays
/// open-ended.
endsOn DateTime? @map("ends_on")
endsOn DateTime? @map("ends_on")
/// Who produced this reminder. `VORSORGE` = the user created it through the
/// Vorsorge surface (the existing, default behaviour). `COACH` = the AI
/// coach minted it from an evidence-based cadence suggestion, posted to the
/// SAME `POST /api/measurement-reminders` endpoint — one engine, not two.
/// Lets the UI label provenance and lets Coach dedupe its own suggestions.
origin ReminderOrigin @default(VORSORGE)
origin ReminderOrigin @default(VORSORGE)
/// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour.
/// DST-safe windowing is applied at the cron via the existing TZ helpers.
notifyHour Int @default(9) @map("notify_hour")
notifyHour Int @default(9) @map("notify_hour")
/// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional.
location String?
location String?
/// Server-computed canonical next-due instant (server-authoritative).
nextDueAt DateTime? @map("next_due_at")
nextDueAt DateTime? @map("next_due_at")
/// When the reminder was last fulfilled (a matching measurement or a manual
/// satisfy). Re-anchors the next-due computation.
lastSatisfiedAt DateTime? @map("last_satisfied_at")
lastSatisfiedAt DateTime? @map("last_satisfied_at")
/// Master on/off for this reminder. Distinct from soft-delete: a disabled
/// reminder is retained + editable but never fires.
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
enabled Boolean @default(true)
/// Booster match key: the antigen slug a logged vaccination clears this
/// reminder for. Written only by the vaccination booster mint (rung 2 —
/// prefilled, user-confirmed) and read only by the create path's satisfy
/// matcher (`src/lib/vaccinations/satisfy-matching.ts`), which resolves a
/// dose's component antigens and satisfies every reminder they match.
///
/// Deliberately absent from the reminder DTO
/// (`src/lib/measurement-reminders/dto.ts`): this is a match key, not a
/// behaviour, so the published contract does not move — the `origin` enum
/// stays two-armed, the cron still fires by `nextDueAt` regardless of why a
/// row exists, and no Vorsorge surface excludes these rows. A booster
/// belongs on `/checkups`. That is also why a fourth nullable column here
/// is not the signal the appointment arm's warning described: an
/// appointment had to be bent into this engine (one-shot, no cadence,
/// excluded everywhere), whereas a booster is the engine's native case —
/// recurring, satisfiable, cadence-bearing.
vaccinationAntigen String? @map("vaccination_antigen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Back-relation for the appointment arm: a PLANNED `Encounter` mints
/// exactly one ENCOUNTER-origin row and points at it. SetNull on the far
/// side, so deleting the reminder leaves the visit intact.
encounters Encounter[]
encounters Encounter[]
/// Back-relation for the booster arm: the doses that recorded satisfying
/// this reminder. SetNull on the far side — deleting the reminder leaves
/// the vaccination record intact.
vaccinations VaccinationRecord[]
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@ -7476,8 +7502,12 @@ model InboundDocument {
/// Soft-delete tombstone. NULL = live; non-NULL = discarded.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
encounterLinks EncounterDocumentLink[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
encounterLinks EncounterDocumentLink[]
/// Vaccination memberships (m:n via `VaccinationDocumentLink`): a scanned
/// Impfpass page carries many doses, and a dose may be evidenced by more
/// than one page.
vaccinationLinks VaccinationDocumentLink[]
@@index([userId, createdAt])
// The list always filters `deletedAt: null` and sorts by `documentDate`, so
@@ -7784,6 +7814,9 @@ model Encounter {
documentLinks EncounterDocumentLink[]
labLinks EncounterLabLink[]
conditionLinks EncounterConditionLink[]
/// Doses administered at this visit. SetNull on the far side: deleting the
/// visit leaves the immunization history intact.
vaccinations VaccinationRecord[]
@@index([userId, deletedAt, occurredAt])
@@index([userId, status, occurredAt])
@@ -7847,8 +7880,9 @@ model Practitioner {
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
encounters Encounter[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
encounters Encounter[]
vaccinations VaccinationRecord[]
@@index([userId, deletedAt, name])
@@map("practitioners")
@@ -7916,3 +7950,128 @@ model EncounterConditionLink {
@@index([userId])
@@map("encounter_condition_links")
}
// ─── Vaccinations ─────────────────────────────────────────────
/// v1.38.0 — one administered dose, the Impfpass line made structured.
///
/// The model is deliberately slim, and every absence below is a decision:
///
/// - The disease, the typical series length and the booster interval are
/// NOT columns. They belong to the antigen, not to the dose, and live on
/// the catalogue entry behind `antigenSlug`
/// (`src/lib/vaccinations/vaccine-catalog.ts`) where they can be corrected
/// in a patch release instead of a migration.
/// - The practice address is not here either; it is on the linked
/// `Practitioner`. The scanned Pass page is not here; it is on the
/// document link.
/// - Either `antigenSlug` or `vaccineName` must be present, and that rule
/// lives in Zod (`src/lib/validations/vaccinations.ts`), not in a DB
/// CHECK. A CHECK would refuse a legacy row on restore, and a backup that
/// cannot round-trip is not a backup.
/// - There is no manufacturer column and no route column. Route follows the
/// antigen and derives from the catalogue at the FHIR boundary. A
/// manufacturer column would pull trade names into shipped content; the
/// only place a trade name legitimately appears is `vaccineName`, which is
/// the person's own transcription of their Pass — user data, not ours.
///
/// Module gate: `"vaccinations"`, SURFACE-gated like `medications`. The nav
/// entry, the dashboard and report surfaces and the picker hide when the
/// module is off; these data routes stay exempt so a restore or an import
/// keeps working and re-enabling finds every row intact.
model VaccinationRecord {
id String @id @default(cuid())
userId String @map("user_id")
/// The day the dose was given, at UTC midnight. An Impfpass carries dates,
/// never times, so the time component is not meaningful and is not
/// collected. The API refuses future instants: a vaccination is logged
/// after the fact, and a planned booster is a reminder, not a record.
occurredAt DateTime @map("occurred_at")
/// Catalogue identity. NULL = a free-text-only row, which is the arm that
/// makes a 1987 Pass line loggable at all.
///
/// Slugs are append-only by convention (the `BIOMARKER_CATALOG` precedent,
/// `src/lib/labs/biomarker-catalog.ts`). Storage is deliberately tolerant
/// where the API is strict: `POST` refuses a slug the catalogue does not
/// resolve, but a restore writes whatever the backup held. A slug that
/// stops resolving degrades to the free-text arm — a tested behaviour, not
/// an accident (`src/lib/vaccinations/__tests__/catalog-integrity.test.ts`).
antigenSlug String? @map("antigen_slug")
/// The Pass's own wording, verbatim. May be a trade name; that is user
/// data. Plaintext and bounded so the list can render and search it.
vaccineName String? @map("vaccine_name")
/// The Pass's claim about which dose this is. Optional — the series
/// derivation falls back to the chronological index per component antigen.
doseNumber Int? @map("dose_number")
/// Per-record override of the catalogue's typical series length, for the
/// schedule a person was actually given.
seriesDoses Int? @map("series_doses")
/// Chargenbezeichnung (IfSG §22). A batch code, not a secret: plaintext and
/// bounded so the list and the report can render it without a decrypt.
lotNumber String? @map("lot_number")
site VaccinationSite?
practitionerId String? @map("practitioner_id")
encounterId String? @map("encounter_id")
/// The booster reminder this dose satisfied, when one matched. The act runs
/// through `satisfyReminder`; this column is the record of it, not the act.
/// One id for possibly several satisfied rows — the engine keeps
/// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue
/// component order.
reminderId String? @map("reminder_id")
/// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes
/// convention). Impfreaktionen live here in v1 rather than in a structured
/// reaction model nobody has asked for yet.
noteEncrypted Bytes? @map("note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull)
reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull)
documentLinks VaccinationDocumentLink[]
@@index([userId, deletedAt, occurredAt])
@@index([userId, antigenSlug, occurredAt])
@@map("vaccination_records")
}
/// Where the dose went. Small and closed, because the Pass's own vocabulary
/// is small and closed; OTHER absorbs the rest rather than growing the enum.
enum VaccinationSite {
LEFT_ARM
RIGHT_ARM
LEFT_THIGH
RIGHT_THIGH
ORAL
NASAL
OTHER
@@map("vaccination_site")
}
/// Dose ↔ stored document, the `DocumentConditionLink` shape. m:n because a
/// scanned Impfpass page carries every dose on it, and a dose may be
/// evidenced by more than one page. Never mandatory: a dose saves without a
/// document, and a document files without a dose. `userId` is denormalised
/// for the owner-scoped list query. This is the only link table the
/// vaccination source owns — practitioner and encounter attach by scalar FK,
/// because a dose is administered once.
model VaccinationDocumentLink {
id String @id @default(cuid())
vaccinationId String @map("vaccination_id")
documentId String @map("document_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([vaccinationId, documentId])
@@index([documentId])
@@index([userId])
@@map("vaccination_document_links")
}
+11
View File
@@ -562,6 +562,17 @@ async function main() {
),
);
// ───── v1.38.0 dose free text (Bytes column) ─────
// The note on an administered dose — a reaction, a sore arm. Always
// encrypted on write, so every non-null row rotates.
results.push(
await rotateBytesColumn(
"VaccinationRecord",
"noteEncrypted",
prisma.vaccinationRecord,
),
);
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
// The raw uploaded document. Two layouts recorded per row in
// `contentCodec` ("base64v1" string codec | "binary2" binary codec), so
@@ -95,6 +95,7 @@ function reminderRow(
nextDueAt: at,
lastSatisfiedAt: null,
enabled: true,
vaccinationAntigen: null,
createdAt: at,
updatedAt: at,
deletedAt: null,
+30 -2
View File
@@ -943,6 +943,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/vaccinations/route.ts": {
domain: "profile",
why: "The record's immunization history. Health background in the same sense as the allergies and the visits already under this domain: it names no credential, no integration and no notification channel. It carries the same honest cost as those two, which is that a delegate granted `profile` to read an allergy list also sees every dose the person has had; the consent copy names the immunization history for exactly this reason. The create arm is a delegable write.",
},
"app/api/vaccinations/[id]/route.ts": {
domain: "profile",
why: "One dose of the record, fetch-then-guard against the resolved user. The pages it resolves are read through the link service, which narrows both ends to the same resolved id, and their names are withheld from a grant that does not reach the vault — the filename of a scanned page is itself the sensitive part.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1144,6 +1152,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "Reopening a deleted address-book entry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/vaccinations/[id]/restore/route.ts": {
domain: "profile",
why: "Reopening a deleted dose with the page it was transcribed from still filed against it. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/mood-entries/bulk-delete/route.ts": {
domain: "mind",
why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -1387,6 +1399,8 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
"Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.",
"app/api/practitioners/route.ts":
"Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.",
"app/api/vaccinations/route.ts":
"Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.",
"app/api/medications/[id]/side-effects/route.ts":
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
"app/api/medications/route.ts":
@@ -1513,6 +1527,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "Reopening a deleted address-book entry. It does not re-attach the visits whose reference was nulled, because which visits meant this practice is not recoverable.",
},
"app/api/vaccinations/[id]/route.ts": {
domain: "profile",
conditions: ["C4"],
why: "Correcting and removing one dose; the delete soft-deletes and deliberately does not rewind the booster reminder the dose once cleared, because tidying a transcription is not evidence the dose never happened. C4 on the edit: the date, the antigen, the dose number and the two references are filed before and after, and the encrypted note deliberately is not. The edit never re-runs the satisfy matcher, so a delegate cannot move a booster's due date by correcting a typo.",
},
"app/api/vaccinations/[id]/restore/route.ts": {
domain: "profile",
conditions: [],
why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.",
},
"app/api/illness/episodes/[id]/resolve/route.ts": {
domain: "illness",
conditions: [],
@@ -1873,8 +1897,12 @@ const ACTOR_ROUTES: Record<string, string> = {
* read-only and admitted on the whole record for the same reason as the linked
* figures, adds one: 213 -> 214. The visit-suggestion read, which serves the
* three incidental-linking moments from the same rule, adds one: 214 -> 215.
*
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
* record list, one create on the write literal, and the edit/delete pair plus
* the restore on the manage literal. 215 -> 221.
*/
const FROZEN_ENTRY_COUNT = 215;
const FROZEN_ENTRY_COUNT = 221;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2635,7 +2663,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(70);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -140,6 +140,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// phone whose user never opted in cannot land rows server-side. Walking
// the tree means a NEW ungated nutrients route fails BY NAME.
"src/app/api/nutrients",
// v1.38.0 — the immunization log (`vaccinations` module). SURFACE-gated
// like medications, not data-layer-gated: every `/api/vaccinations/*` route
// is raw CRUD over the person's own doses, so all four are EXEMPT below
// under the same reasoning. Walking the tree means a NEW vaccination route
// must justify itself — gate or documented exemption — rather than
// silently appearing.
"src/app/api/vaccinations",
];
/**
@@ -283,6 +290,19 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// rationale as glp1-timeline above: a deterministic detector over weight +
// dose history, no AI narrative, medications is core/always-on.
"src/app/api/insights/glp1-plateau/route.ts",
// ── DATA LAYER (vaccinations) ─────────────────────────────────────
// v1.38.0 — the immunization log is SURFACE-gated (nav entry, dashboard
// and report leaves, the picker), not data-layer-gated. Every
// `/api/vaccinations/*` route is raw CRUD over the person's own doses and
// the pages they were transcribed from — the same reasoning as mood, labs
// and medications above: the module toggle governs whether vaccinations
// SURFACE, not whether the row store accepts rows. A restore or an import
// must keep working while the module is off, and re-enabling must find
// every dose intact rather than a gap in an Impfpass nobody can rebuild.
"src/app/api/vaccinations/route.ts",
"src/app/api/vaccinations/[id]/route.ts",
"src/app/api/vaccinations/[id]/restore/route.ts",
"src/app/api/vaccinations/[id]/links/route.ts",
];
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
@@ -290,6 +290,8 @@ function sourceClient() {
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
@@ -49,6 +49,7 @@ import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -92,6 +93,8 @@ interface RestoreResponse {
practitioners: number;
encounters: number;
encounterLinks: number;
vaccinations: number;
vaccinationLinks: number;
};
}
@@ -1332,6 +1335,18 @@ const handler = apiHandler(
skips,
);
// The immunization log, after the visits: a dose remaps its
// practitioner and its encounter against rows the branch above has
// just written, and its document link needs the documents that
// landed before that. Both ends of this section live in
// `src/lib/export/vaccinations-backup.ts`.
const vaccinationsCleared = await restoreVaccinationsData(
tx,
ownerId,
payload,
skips,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
@@ -1361,6 +1376,8 @@ const handler = apiHandler(
practitioners: visitsCleared.practitioners,
encounters: visitsCleared.encounters,
encounterLinks: visitsCleared.encounterLinks,
vaccinations: vaccinationsCleared.vaccinations,
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -115,6 +117,10 @@ beforeEach(() => {
vi.mocked(prisma.encounterConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
[] as never,
);
});
afterEach(() => {
@@ -34,6 +34,8 @@ vi.mock("@/lib/db", () => ({
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
@@ -0,0 +1,104 @@
/**
* `POST /api/vaccinations/{id}/links` — file a scanned page against a dose.
* `DELETE /api/vaccinations/{id}/links` — unfile it.
*
* Deliberately bare `requireAuth()` rather than a delegable declaration, and
* the reason is consistency rather than oversight: document link and unlink
* are not delegable today, and neither is the visit link beside it. Making a
* third answer to the same question would be the drift. The set is revisited
* as one decision when the polymorphic-linking work lands.
*
* Both verbs are idempotent, both are capped, and neither ever blocks a save:
* an id that names nothing this account owns comes back in `unknown` rather
* than failing the request, because a dose must never be un-saveable because
* one page it pointed at has since been deleted.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { vaccinationLinkSchema } from "@/lib/validations/vaccinations";
import { linkTargets, unlinkTargets } from "@/lib/links";
import {
actingDomainVisibility,
loadVaccinationDocuments,
} from "@/lib/vaccinations/service";
type RouteParams = { params: Promise<{ id: string }> };
async function handle(
request: NextRequest,
{ params }: RouteParams,
verb: "link" | "unlink",
): Promise<Response> {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = vaccinationLinkSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.link.invalid",
});
}
const apply = verb === "link" ? linkTargets : unlinkTargets;
const result = await apply(prisma, {
userId: user.id,
sourceKind: "vaccination",
sourceId: id,
targetKind: parsed.data.targetKind,
targetIds: parsed.data.targetIds,
});
if (result.unknownSource) {
return apiError("Vaccination not found", 404);
}
annotate({
action: {
name: `vaccination.link.${verb}`,
entity_type: "vaccination",
entity_id: id,
},
meta: {
target_kind: parsed.data.targetKind,
changed: result.changed,
unknown: result.unknownTargetIds.length,
},
});
// Bare `requireAuth()` resolves the caller's OWN record — this route is not
// delegable — so there is no narrowed grant to honour and every domain is
// the caller's own.
const visible = await actingDomainVisibility(prisma, null);
const documents = await loadVaccinationDocuments(
prisma,
user.id,
id,
visible,
);
return apiSuccess({ documents, unknown: result.unknownTargetIds });
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "link"),
);
export const DELETE = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "unlink"),
);
@@ -0,0 +1,75 @@
/**
* `POST /api/vaccinations/{id}/restore` — clear a dose's tombstone.
*
* MANAGE rather than WRITE: undoing a deletion is a management act when the
* manager is the one who holds delete.
*
* The document links survived the soft delete — they cascade only on a hard
* one — so a restored dose comes back with the page it was transcribed from
* still filed against it. The booster satisfy matcher does not run: restoring
* is not logging a dose, and the reminder it once satisfied was never rewound
* by the deletion either.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
import {
VACCINATION_INCLUDE,
actingDomainVisibility,
loadVaccinationDocuments,
resolveSeriesFor,
} from "@/lib/vaccinations/service";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user, grantId } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.vaccinationRecord.findUnique({
where: { id },
});
if (!existing || existing.userId !== user.id) {
return apiError("Vaccination not found", 404);
}
if (existing.deletedAt !== null) {
await prisma.vaccinationRecord.update({
where: { id },
data: { deletedAt: null },
});
}
const row = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id },
include: VACCINATION_INCLUDE,
});
const visible = await actingDomainVisibility(prisma, grantId);
const [documents, series] = await Promise.all([
loadVaccinationDocuments(prisma, user.id, id, visible),
resolveSeriesFor(prisma, user.id),
]);
await auditLog("vaccination.record.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { vaccinationId: id, occurredAt: row.occurredAt.toISOString() },
});
annotate({
action: {
name: "vaccination.record.restore",
entity_type: "vaccination",
entity_id: id,
},
});
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
},
);
+282
View File
@@ -0,0 +1,282 @@
/**
* `GET /api/vaccinations/{id}` — one owned, live dose with its links.
* `PATCH /api/vaccinations/{id}` — edit it (partial; field-by-field).
* `DELETE /api/vaccinations/{id}` — soft-delete it.
*
* Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body
* never carries it.
*
* PATCH deliberately does NOT re-run the booster satisfy matcher. An edit is a
* correction of a transcription, not a new clinical event, and re-satisfying
* on edit would make `occurredAt` edits side-effectful in a way no other
* clinical model here is: fixing a typo in a date would silently push a
* booster reminder's next due date out. The side effect belongs to the act of
* logging a dose, which happens once.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations";
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
import {
VACCINATION_INCLUDE,
actingDomainVisibility,
applyVaccinationLinks,
loadVaccinationDocuments,
resolveOwnedEncounter,
resolveOwnedPractitioner,
resolveSeriesFor,
} from "@/lib/vaccinations/service";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user, grantId } = await requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.vaccinationRecord.findUnique({
where: { id },
include: VACCINATION_INCLUDE,
});
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Vaccination not found", 404);
}
const visible = await actingDomainVisibility(prisma, grantId);
const [documents, series] = await Promise.all([
loadVaccinationDocuments(prisma, user.id, id, visible),
resolveSeriesFor(prisma, user.id),
]);
annotate({
action: {
name: "vaccination.record.read",
entity_type: "vaccination",
entity_id: id,
},
});
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user, grantId } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.vaccinationRecord.findUnique({
where: { id },
});
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Vaccination not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
});
if (jsonError) return jsonError;
const parsed = vaccinationUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.invalid",
});
}
const entry = parsed.data;
// The identity rule is checked against the MERGED row rather than the
// body, because a PATCH naming only the batch code says nothing about the
// identity and must not be refused for it — while a PATCH that clears the
// one arm a record was saved with would leave a dose with no name at all.
const nextSlug =
entry.antigenSlug !== undefined
? entry.antigenSlug
: existing.antigenSlug;
const nextName =
entry.vaccineName !== undefined
? entry.vaccineName
: existing.vaccineName;
if (!nextSlug && !nextName) {
return apiError(
"A dose keeps a name: pick one from the catalogue or type what the record says",
422,
{ errorCode: "vaccination.identity-required" },
);
}
const outcome = await prisma.$transaction(async (tx) => {
if (entry.practitionerId) {
const owned = await resolveOwnedPractitioner(
tx,
user.id,
entry.practitionerId,
);
if (!owned) return "unknown-practitioner" as const;
}
if (entry.encounterId) {
const owned = await resolveOwnedEncounter(
tx,
user.id,
entry.encounterId,
);
if (!owned) return "unknown-encounter" as const;
}
const data: Prisma.VaccinationRecordUpdateInput = {};
if (entry.occurredAt !== undefined) {
data.occurredAt = new Date(entry.occurredAt);
}
if (entry.antigenSlug !== undefined) data.antigenSlug = entry.antigenSlug;
if (entry.vaccineName !== undefined) data.vaccineName = entry.vaccineName;
if (entry.doseNumber !== undefined) data.doseNumber = entry.doseNumber;
if (entry.seriesDoses !== undefined) data.seriesDoses = entry.seriesDoses;
if (entry.lotNumber !== undefined) data.lotNumber = entry.lotNumber;
if (entry.site !== undefined) data.site = entry.site;
if (entry.note !== undefined) {
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
if (entry.practitionerId !== undefined) {
data.practitioner = entry.practitionerId
? { connect: { id: entry.practitionerId } }
: { disconnect: true };
}
if (entry.encounterId !== undefined) {
data.encounter = entry.encounterId
? { connect: { id: entry.encounterId } }
: { disconnect: true };
}
const updated = await tx.vaccinationRecord.update({
where: { id },
data,
});
await applyVaccinationLinks(tx, user.id, id, entry.documentIds);
return { updated };
});
if (outcome === "unknown-practitioner") {
return apiError("Practitioner not found", 404, {
errorCode: "vaccination.practitioner-not-found",
});
}
if (outcome === "unknown-encounter") {
return apiError("Visit not found", 404, {
errorCode: "vaccination.encounter-not-found",
});
}
const row = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id },
include: VACCINATION_INCLUDE,
});
const visible = await actingDomainVisibility(prisma, grantId);
const [documents, series] = await Promise.all([
loadVaccinationDocuments(prisma, user.id, id, visible),
resolveSeriesFor(prisma, user.id),
]);
await auditLog("vaccination.record.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
vaccinationId: id,
// The note is deliberately absent: encrypted PHI, and the audit table
// is not a second copy of the record.
...overwriteDetails({
before: {
occurredAt: existing.occurredAt,
antigenSlug: existing.antigenSlug,
doseNumber: existing.doseNumber,
practitionerId: existing.practitionerId,
encounterId: existing.encounterId,
},
after: {
occurredAt: outcome.updated.occurredAt,
antigenSlug: outcome.updated.antigenSlug,
doseNumber: outcome.updated.doseNumber,
practitionerId: outcome.updated.practitionerId,
encounterId: outcome.updated.encounterId,
},
}),
},
});
annotate({
action: {
name: "vaccination.record.update",
entity_type: "vaccination",
entity_id: id,
},
meta: { antigen_slug: row.antigenSlug ?? "free-text" },
});
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.vaccinationRecord.findUnique({
where: { id },
});
if (!existing || existing.userId !== user.id) {
return apiError("Vaccination not found", 404);
}
// Already tombstoned: deleting twice is a no-op that still succeeds.
//
// A booster reminder this dose satisfied is deliberately NOT re-opened.
// The reminder belongs to the person's preventive-care list and outlives
// the record; rewinding its due date because a transcription was tidied up
// would turn a correction into a nudge.
if (existing.deletedAt === null) {
await prisma.vaccinationRecord.update({
where: { id },
data: { deletedAt: new Date() },
});
}
await auditLog("vaccination.record.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
vaccinationId: id,
occurredAt: existing.occurredAt.toISOString(),
antigenSlug: existing.antigenSlug,
},
});
annotate({
action: {
name: "vaccination.record.delete",
entity_type: "vaccination",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+257
View File
@@ -0,0 +1,257 @@
/**
* `GET /api/vaccinations` — the account's immunization log, series resolved.
* `POST /api/vaccinations` — log one dose, optionally pre-linked to the page
* it was transcribed from.
*
* Classified `profile`: an immunization history is health background in the
* same sense as the allergy list and the visit record already under that
* domain. No credential, no integration, no outbound channel.
*
* The module gate governs the SURFACES, not this route. A disabled
* `vaccinations` module hides the nav entry, the dashboard and report leaves
* and the picker; the data routes stay reachable so a restore or an import
* keeps working and re-enabling the module finds every row intact. That is the
* `medications` posture, and the route-gate inventory records it.
*
* `userId` is narrowed from auth and fed to the Prisma `where`; it is never a
* body field. The `data` object is built field-by-field.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
vaccinationCreateSchema,
vaccinationListQuerySchema,
} from "@/lib/validations/vaccinations";
import {
toVaccinationDTO,
type VaccinationListDTO,
} from "@/lib/vaccinations/dto";
import {
VACCINATION_INCLUDE,
applyVaccinationLinks,
resolveOwnedEncounter,
resolveOwnedPractitioner,
resolveOwnerTimezone,
resolveSeriesFor,
} from "@/lib/vaccinations/service";
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = vaccinationListQuerySchema.safeParse({
antigenSlug: params.get("antigenSlug") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.invalid",
});
}
const query = parsed.data;
// Full and bounded rather than paged: an Impfpass is a lifetime document but
// a small one, and the default already covers a long life.
const take = query.limit ?? 300;
const [rows, series] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take,
include: VACCINATION_INCLUDE,
}),
// Over the WHOLE live set, never over the filtered page: a position is a
// count of what came before it, so deriving it from a window would report
// the oldest dose in that window as the first one ever given.
resolveSeriesFor(prisma, user.id),
]);
annotate({
action: { name: "vaccination.record.list", entity_type: "vaccination" },
meta: { count: rows.length, filtered: Boolean(query.antigenSlug) },
});
const body: VaccinationListDTO = {
vaccinations: rows.map((row) =>
toVaccinationDTO(row, series.get(row.id) ?? []),
),
};
return apiSuccess(body);
});
// A double-tap on "save" must not log the same dose twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
async function postVaccination(request: NextRequest): Promise<Response> {
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
});
if (jsonError) return jsonError;
const parsed = vaccinationCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "vaccination.record.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.invalid",
});
}
const entry = parsed.data;
const occurredAt = new Date(entry.occurredAt);
const timezone = await resolveOwnerTimezone(user.id);
// Filled inside the transaction, read after it for the wide event: an
// annotation is not worth holding a transaction open for.
let satisfiedReminderIds: string[] = [];
const created = await prisma.$transaction(async (tx) => {
// Every id the body carries is re-narrowed to the resolved account before
// anything is written. A practice or a visit the caller does not own is a
// refusal rather than a silently dropped field: the person named one and
// would otherwise watch the dose save without it.
if (entry.practitionerId) {
const owned = await resolveOwnedPractitioner(
tx,
user.id,
entry.practitionerId,
);
if (!owned) return "unknown-practitioner" as const;
}
if (entry.encounterId) {
const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId);
if (!owned) return "unknown-encounter" as const;
}
// Field-by-field — never spread the parsed object whole.
const row = await tx.vaccinationRecord.create({
data: {
userId: user.id,
occurredAt,
antigenSlug: entry.antigenSlug ?? null,
vaccineName: entry.vaccineName ?? null,
doseNumber: entry.doseNumber ?? null,
seriesDoses: entry.seriesDoses ?? null,
lotNumber: entry.lotNumber ?? null,
site: entry.site ?? null,
practitionerId: entry.practitionerId ?? null,
encounterId: entry.encounterId ?? null,
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
select: { id: true },
});
await applyVaccinationLinks(tx, user.id, row.id, entry.documentIds);
// The booster arm, inside the same transaction so a dose that failed to
// save cannot leave a reminder looking settled. It runs on the OWNER's
// reminders, which is correct even when a delegate is transcribing: it is
// the owner's booster plan, and filing their Impfpass is exactly the work
// that should settle it.
const boosters = await satisfyBoostersForDose(
tx,
user.id,
entry.antigenSlug ?? null,
occurredAt,
timezone,
);
satisfiedReminderIds = boosters.satisfiedReminderIds;
if (boosters.primaryReminderId) {
await tx.vaccinationRecord.update({
where: { id: row.id },
data: { reminderId: boosters.primaryReminderId },
});
}
return tx.vaccinationRecord.findUniqueOrThrow({
where: { id: row.id },
include: VACCINATION_INCLUDE,
});
});
if (created === "unknown-practitioner") {
return apiError("Practitioner not found", 404, {
errorCode: "vaccination.practitioner-not-found",
});
}
if (created === "unknown-encounter") {
return apiError("Visit not found", 404, {
errorCode: "vaccination.encounter-not-found",
});
}
const series = await resolveSeriesFor(prisma, user.id);
await auditLog("vaccination.record.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
vaccinationId: created.id,
occurredAt: created.occurredAt.toISOString(),
antigenSlug: created.antigenSlug,
// The note is deliberately absent: it is encrypted PHI and the audit
// table is not a second copy of the record.
},
});
annotate({
action: {
name: "vaccination.record.create",
entity_type: "vaccination",
entity_id: created.id,
},
meta: {
antigen_slug: created.antigenSlug ?? "free-text",
linked_documents: entry.documentIds?.length ?? 0,
satisfied_boosters: satisfiedReminderIds.length,
},
});
for (const reminderId of satisfiedReminderIds) {
annotate({
action: {
name: "vaccination.booster.satisfied",
entity_type: "measurement-reminder",
entity_id: reminderId,
},
meta: { antigen_slug: created.antigenSlug ?? "free-text" },
});
}
return apiSuccess(
toVaccinationDTO(created, series.get(created.id) ?? []),
201,
);
}
+3
View File
@@ -201,6 +201,9 @@ function catalogueLabel(
if (catalogue === "visitReference") {
return t("admin.section.backups.restoreSkippedVisitReference");
}
if (catalogue === "vaccinationReference") {
return t("admin.section.backups.restoreSkippedVaccinationReference");
}
if (catalogue === "checkupClosure") {
return t("admin.section.backups.restoreSkippedCheckupClosure");
}
@@ -53,6 +53,7 @@ import {
Pill,
Smile,
Sparkles,
Syringe,
TestTube,
Thermometer,
type LucideIcon,
@@ -110,6 +111,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
mentalHealth: Brain,
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
nutrients: Leaf,
// v1.38.0 — the immunization log.
vaccinations: Syringe,
};
export function ModulesSection() {
+7
View File
@@ -242,6 +242,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "Encounter", field: "outcomeEncrypted", kind: "bytes" },
{ model: "Practitioner", field: "noteEncrypted", kind: "bytes" },
// ───── v1.38.0 dose free text (Bytes column) ─────
// What the person wrote about the dose — a reaction, a sore arm, why the
// schedule went the way it did. The Pass's own structured fields stay
// queryable plaintext (the batch code is a batch code, not a secret), so
// this is the one column on the record that rotates.
{ model: "VaccinationRecord", field: "noteEncrypted", kind: "bytes" },
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
// The raw uploaded document (the most sensitive blob in the corpus; never
// logged, never in wide-event meta). Two layouts, recorded per row in
@@ -142,6 +142,7 @@ function moduleMap(
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
vaccinations: true,
};
return { ...base, ...overrides };
}
+9
View File
@@ -97,6 +97,15 @@ export const WIPE_MODELS = [
"EncounterConditionLink",
"Encounter",
// ── Vaccinations ────────────────────────────────────────────────────────
// Same ordering argument as the visit links: the document link cascades
// from the dose AND from the stored document, so it is deleted before
// either or its count reads zero on the audit row. The dose points at a
// practitioner and an encounter with SetNull, which is why it goes here,
// ahead of both.
"VaccinationDocumentLink",
"VaccinationRecord",
// ── Clinical record ─────────────────────────────────────────────────────
"MentalHealthAssessment",
"LabResult",
@@ -255,6 +255,8 @@ function makePrisma() {
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
+22
View File
@@ -142,6 +142,14 @@ export const BACKED_UP_MODELS = [
"EncounterLabLink",
"EncounterConditionLink",
// ── Vaccinations ──────────────────────────────────────────────────────────
// An immunization history is the one part of a health record that has no
// other copy: the paper Pass is the original, and once it has been
// transcribed the transcription is what the person relies on. Nothing
// re-syncs it from a provider.
"VaccinationRecord",
"VaccinationDocumentLink",
// ── Coach ─────────────────────────────────────────────────────────────────
"CoachConversation",
"CoachMessage",
@@ -190,6 +198,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// relation-shaped proof would be attributed to whichever one a matcher found
// first — the `symptomLinks` confusion, avoided by construction.
"src/lib/export/visits-backup.ts",
// The immunization log and its document link, same arrangement for the same
// reason: `documentLinks` is a field name on three models now, so each of
// these reads through its OWN delegate here.
"src/lib/export/vaccinations-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -199,6 +211,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/intraday-profile-backup.ts",
"src/lib/export/health-score-backup.ts",
"src/lib/export/visits-backup.ts",
"src/lib/export/vaccinations-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -262,6 +275,15 @@ export const TWO_ENDED_MODELS = [
"EncounterDocumentLink",
"EncounterLabLink",
"EncounterConditionLink",
// Doses travel both ways from the release that introduces them, and the
// link with them. `DocumentConditionLink` one list down says what the
// alternative costs — documents and conditions both restore, the filing
// between them does not. Repeating that here would return a restored
// Impfpass scan and a restored dose with nothing between them, which is the
// same regret against a record a person cannot reconstruct from anywhere
// else.
"VaccinationRecord",
"VaccinationDocumentLink",
] as const;
/** One model claimed to travel both ways. */
+18 -1
View File
@@ -43,6 +43,12 @@ import {
type VisitsBackupCounts,
type VisitsBackupSection,
} from "@/lib/export/visits-backup";
import {
buildVaccinationsBackupSection,
countVaccinationsBackupSection,
type VaccinationsBackupCounts,
type VaccinationsBackupSection,
} from "@/lib/export/vaccinations-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -56,7 +62,8 @@ export interface FullBackupCounts
ProfileBackupCounts,
IntradayProfileBackupCounts,
HealthScoreBackupCounts,
VisitsBackupCounts {
VisitsBackupCounts,
VaccinationsBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -269,6 +276,7 @@ export async function buildFullBackupPayload(
intradayProfiles,
healthScoreRecords,
visits,
vaccinations,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -402,6 +410,12 @@ export async function buildFullBackupPayload(
buildVisitsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The immunization log and the pages it was transcribed from. Both ends
// live in `src/lib/export/vaccinations-backup.ts` beside each other, the
// same arrangement as the visits above and for the same reason.
buildVaccinationsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -429,6 +443,7 @@ export async function buildFullBackupPayload(
const intradaySection: IntradayProfileBackupSection = intradayProfiles;
const healthScoreSection: HealthScoreBackupSection = healthScoreRecords;
const visitsSection: VisitsBackupSection = visits;
const vaccinationsSection: VaccinationsBackupSection = vaccinations;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -659,6 +674,7 @@ export async function buildFullBackupPayload(
...intradaySection,
...healthScoreSection,
...visitsSection,
...vaccinationsSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -687,6 +703,7 @@ export async function buildFullBackupPayload(
...countIntradayProfileBackupSection(intradayProfiles),
...countHealthScoreBackupSection(healthScoreRecords),
...countVisitsBackupSection(visits),
...countVaccinationsBackupSection(vaccinations),
},
};
}
+9 -1
View File
@@ -40,7 +40,14 @@
* travels, or a document, lab result or condition episode whose id a portable
* export does not carry.
*
* The sixth, `checkupClosure`, is not about a restore at all, and it borrows
* The sixth, `vaccinationReference`, is the same kind as `visitReference` and
* is kept separate rather than folded into it so the report says which part of
* the record lost an edge. It names a row an immunization entry pointed at
* that the restore did not put back — a booster reminder, which never travels,
* a practitioner or visit the file referenced without carrying, or a scanned
* page whose id a portable export does not include.
*
* The seventh, `checkupClosure`, is not about a restore at all, and it borrows
* this shape deliberately rather than growing a second reporting mechanism
* beside it. The situation is the same one: something a write was asked to do
* could not be done, the record itself survives, and the person is told which
@@ -54,6 +61,7 @@ export type SkippedCatalogue =
| "moodFactor"
| "moodTag"
| "visitReference"
| "vaccinationReference"
| "checkupClosure";
/** One key this instance does not know, and the links it cost. */
+418
View File
@@ -0,0 +1,418 @@
/**
* The immunization log, with both backup ends in one file.
*
* Same arrangement as `visits-backup.ts` and for the same reason: a reader
* asking "is this carried at both ways?" answers it here, and a reader who
* greps only the restore ROUTE gets a false negative because the route
* delegates.
*
* Two models ride: the dose and the link that keeps the scanned Impfpass page
* beside it. Both are carried rather than owed. `DocumentConditionLink` on the
* debt register says what the alternative costs — documents and conditions
* both restore, the filing between them does not — and an immunization history
* is the record with the least chance of being reconstructed from anywhere
* else, so the same regret is not repeated here.
*
* Each model uses its own named select constant and its own delegate call
* rather than riding a parent's `include`: `documentLinks` is a relation field
* name on three different models now, so a relation-shaped write would be
* attributed to whichever of them a matcher happened to find first.
*
* Three references need care on the way back:
*
* - `practitionerId` and `encounterId` point at models that restore in the
* same run. They remap to the restored rows, and drop to NULL when the
* file references a row it does not carry.
* - `reminderId` points at a `MeasurementReminder`, which is on the
* coverage-pending register and does not come back. It resolves to NULL,
* never to a dangling id, and the drop is reported.
* - `antigenSlug` is restored verbatim with no validation against the
* current catalogue. A slug this release no longer resolves must still
* come back; the renderer degrades to the free-text arm, which is the
* guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`
* holds.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { VaccinationSite } from "@/generated/prisma/client";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
export interface VaccinationsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One administered dose, as the Pass recorded it. */
export interface VaccinationBackupEntry {
/** Always carried: the document link addresses a dose by it. */
id: string;
occurredAt: string;
/**
* Carried verbatim, never checked against the catalogue this release ships.
* A slug that stopped resolving still describes what the person was given.
*/
antigenSlug: string | null;
vaccineName: string | null;
doseNumber: number | null;
seriesDoses: number | null;
lotNumber: string | null;
site: VaccinationSite | null;
practitionerId: string | null;
encounterId: string | null;
/**
* Carried so the loss is visible in the file rather than inferred from its
* absence. The restore resolves it to NULL and names the drop: the reminder
* it points at is not in the backup.
*/
reminderId: string | null;
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
noteEncrypted: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One edge from a dose to the page it was transcribed from. */
export interface VaccinationDocumentLinkBackupEntry {
vaccinationId: string;
targetId: string;
createdAt: string;
}
export interface VaccinationsBackupSection {
vaccinations: VaccinationBackupEntry[];
vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[];
}
export interface VaccinationsBackupCounts {
vaccinations: number;
vaccinationLinks: number;
}
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
function encodeCiphertext(value: Uint8Array | null): string | null {
if (!value || value.byteLength === 0) return null;
return Buffer.from(value).toString("base64");
}
/**
* Named select constants, one per model — a structural matcher binds a model
* to the literal beside its delegate call, and an inline object inside a
* `Promise.all` is the shape earlier work here had to refactor away from.
*/
const VACCINATION_BACKUP_SELECT = {
id: true,
occurredAt: true,
antigenSlug: true,
vaccineName: true,
doseNumber: true,
seriesDoses: true,
lotNumber: true,
site: true,
practitionerId: true,
encounterId: true,
reminderId: true,
noteEncrypted: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = {
vaccinationId: true,
documentId: true,
createdAt: true,
} as const;
/**
* Build the immunization slice of a user's full backup.
*
* Takes the delegates it uses rather than a whole client, matching the other
* section builders. A portable export omits tombstones so a restore cannot
* resurrect them; a disaster-recovery payload keeps them so the account comes
* back as itself.
*/
export async function buildVaccinationsBackupSection(
prisma: Pick<PrismaClient, "vaccinationRecord" | "vaccinationDocumentLink">,
userId: string,
options: VaccinationsBackupOptions = {},
): Promise<VaccinationsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [vaccinationRows, documentLinkRows] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { occurredAt: "desc" },
select: VACCINATION_BACKUP_SELECT,
}),
prisma.vaccinationDocumentLink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT,
}),
]);
return {
vaccinations: vaccinationRows.map((row) => ({
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,
practitionerId: row.practitionerId,
encounterId: row.encounterId,
reminderId: row.reminderId,
noteEncrypted: encodeCiphertext(row.noteEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
vaccinationDocumentLinks: documentLinkRows.map((row) => ({
vaccinationId: row.vaccinationId,
targetId: row.documentId,
createdAt: row.createdAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countVaccinationsBackupSection(
section: VaccinationsBackupSection,
): VaccinationsBackupCounts {
return {
vaccinations: section.vaccinations.length,
vaccinationLinks: section.vaccinationDocumentLinks.length,
};
}
/** Counts the immunization restore wiped, for the audit trail. */
export interface VaccinationsRestoreCleared {
vaccinations: number;
vaccinationLinks: number;
}
/**
* The slice of a parsed backup this restore consumes.
*
* Required rather than optional: the payload schema defaults every key, so
* every caller already satisfies this, and one that stops satisfying it fails
* to compile instead of passing `undefined` into a loop that iterates zero
* times and reports success.
*/
export interface VaccinationsRestoreInput {
vaccinations: RestoredVaccination[];
vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[];
}
/**
* What the restore reads, as the parsed file actually presents it — a wider
* set than what this release writes: a portable export omits the tombstone,
* and every optional column arrives as `undefined` rather than `null`.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredVaccination = Pick<
VaccinationBackupEntry,
"id" | "occurredAt" | "createdAt" | "updatedAt"
> &
OptionalNullable<
Pick<
VaccinationBackupEntry,
| "antigenSlug"
| "vaccineName"
| "doseNumber"
| "seriesDoses"
| "lotNumber"
| "site"
| "practitionerId"
| "encounterId"
| "reminderId"
| "noteEncrypted"
>
> & { deletedAt?: string | null };
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
/**
* Re-create the account's immunization log and its document links.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. The dose first, then the link, because a link addresses both of its
* ends by id.
*
* MUST be called after the practitioners, encounters and documents have been
* restored: the dose remaps two of those references against the database and
* the link is written only when the document actually came back. Calling it
* earlier would drop every reference and be counted as a successful restore of
* a thinner record.
*/
export async function restoreVaccinationsData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: VaccinationsRestoreInput,
skips: RestoreSkipLog,
): Promise<VaccinationsRestoreCleared> {
const clearedLinks = await tx.vaccinationDocumentLink.deleteMany({
where: { userId: ownerId },
});
const clearedVaccinations = await tx.vaccinationRecord.deleteMany({
where: { userId: ownerId },
});
// Both far sides are restored by other branches of the same transaction, so
// the check is against the database rather than against the payload — this
// is the only place that can see all three sections at once.
const [practitionerIds, encounterIds] = await Promise.all([
tx.practitioner.findMany({
where: { userId: ownerId },
select: { id: true },
}),
tx.encounter.findMany({
where: { userId: ownerId },
select: { id: true },
}),
]);
const restoredPractitioners = new Set(practitionerIds.map((row) => row.id));
const restoredEncounters = new Set(encounterIds.map((row) => row.id));
// A row the file references but the restore did not bring back cannot be
// invented, and a dangling id would fail the foreign key and roll the whole
// restore back over one dose. The reference drops to NULL instead — the
// dose, its date, its antigen and its batch code all survive.
const droppedPractitioners: string[] = [];
const droppedEncounters: string[] = [];
// The reminder is on the coverage-pending register: it is not in the file at
// all, so every reference to one is a known, reportable drop.
const droppedReminders: string[] = [];
if (payload.vaccinations.length > 0) {
await tx.vaccinationRecord.createMany({
data: payload.vaccinations.map((entry) => {
const practitionerId =
entry.practitionerId &&
restoredPractitioners.has(entry.practitionerId)
? entry.practitionerId
: null;
if (entry.practitionerId && practitionerId === null) {
droppedPractitioners.push(entry.practitionerId);
}
const encounterId =
entry.encounterId && restoredEncounters.has(entry.encounterId)
? entry.encounterId
: null;
if (entry.encounterId && encounterId === null) {
droppedEncounters.push(entry.encounterId);
}
if (entry.reminderId) droppedReminders.push(entry.reminderId);
return {
id: entry.id,
userId: ownerId,
occurredAt: new Date(entry.occurredAt),
// Verbatim, deliberately: a slug the current catalogue no longer
// resolves still describes what was given, and the renderer falls
// back to `vaccineName`.
antigenSlug: entry.antigenSlug ?? null,
vaccineName: entry.vaccineName ?? null,
doseNumber: entry.doseNumber ?? null,
seriesDoses: entry.seriesDoses ?? null,
lotNumber: entry.lotNumber ?? null,
site: entry.site ?? null,
practitionerId,
encounterId,
// Never the id the file carried: the row it named is not restored.
reminderId: null,
noteEncrypted:
entry.noteEncrypted == null
? null
: decodeCiphertext(entry.noteEncrypted),
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
};
}),
});
}
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedPractitioners)],
droppedPractitioners,
);
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedEncounters)],
droppedEncounters,
);
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedReminders)],
droppedReminders,
);
const restoredVaccinations = new Set(
payload.vaccinations.map((entry) => entry.id),
);
const documentIds = await tx.inboundDocument.findMany({
where: { userId: ownerId },
select: { id: true },
});
const restoredDocuments = new Set(documentIds.map((row) => row.id));
const writableLinks: Array<{
vaccinationId: string;
documentId: string;
createdAt: Date;
}> = [];
const droppedLinks: string[] = [];
for (const entry of payload.vaccinationDocumentLinks) {
if (
restoredVaccinations.has(entry.vaccinationId) &&
restoredDocuments.has(entry.targetId)
) {
writableLinks.push({
vaccinationId: entry.vaccinationId,
documentId: entry.targetId,
createdAt: new Date(entry.createdAt),
});
} else {
droppedLinks.push(entry.targetId);
}
}
if (writableLinks.length > 0) {
await tx.vaccinationDocumentLink.createMany({
data: writableLinks.map((row) => ({
userId: ownerId,
vaccinationId: row.vaccinationId,
documentId: row.documentId,
createdAt: row.createdAt,
})),
});
}
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedLinks)],
droppedLinks,
);
return {
vaccinations: clearedVaccinations.count,
vaccinationLinks: clearedLinks.count,
};
}
+18 -1
View File
@@ -12,7 +12,7 @@ import type { Prisma } from "@/generated/prisma/client";
export const MAX_TARGETS_PER_CALL = 100;
/** The record a link hangs off. */
export type LinkSourceKind = "encounter" | "document";
export type LinkSourceKind = "encounter" | "document" | "vaccination";
/** The record a link points at. */
export type LinkTargetKind =
@@ -138,6 +138,10 @@ const SOURCES: Record<
delegate: (tx) => narrow<OwnedRowDelegate>(tx.inboundDocument),
relationField: "document",
},
vaccination: {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.vaccinationRecord),
relationField: "vaccination",
},
};
const DOCUMENT_ENDPOINT: EndpointSpec = {
@@ -198,6 +202,13 @@ const CONDITION_ENDPOINT: EndpointSpec = {
* than a result field. Three is the number of encounter link tables this
* design allows; a fourth is the point at which this stops being a small set
* of named relations and starts being a general edge store.
*
* That ceiling is per source, and the `vaccination` source owns exactly ONE
* pair. A dose is administered once, so the practitioner and the visit attach
* by scalar FK rather than through a table; the many-to-many argument that
* justifies the lab and condition links does not transfer. A second
* vaccination pair is this becoming the edge store, and is the point to stop
* and raise it rather than to add a row here.
*/
const LINK_TABLES: Partial<
Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec>
@@ -238,6 +249,12 @@ const LINK_TABLES: Partial<
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
"vaccination:document": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.vaccinationDocumentLink),
sourceColumn: "vaccinationId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
};
function tableFor(
+27
View File
@@ -154,6 +154,25 @@ export const MODULE_KEYS = [
// ingest route refuses (403 `module.disabled`) and the settings card
// does not render. The `optIn` marker inverts the per-user default.
"nutrients",
// v1.38.0 — the immunization log (`/vaccinations`). A tracked clinical
// vertical a person may not use at all, which is exactly the shape the
// existing opt-out verticals have (`labs`, `illness`, `medications`), so it
// gets a key rather than riding somebody else's. Default-on, no `optIn`
// marker: no egress, no new attack surface — the `mentalHealth` /
// `inboundDocuments` posture.
//
// SURFACE-gated like `medications`: the nav entry, the dashboard and report
// surfaces and the picker hide, and the `/api/vaccinations*` data routes
// stay exempt so a restore / import keeps working and re-enabling finds
// every row intact.
//
// The one subtlety, stated so nobody reads it as a leak: a booster reminder
// the user CONFIRMED does not follow this off-switch. It is an ordinary
// preventive-care row on an engine that is never gated, and a reminder
// somebody agreed to keeps its promise. Turning the module off hides
// surfaces the person made content for; it does not silently retract a
// commitment the app made to them.
"vaccinations",
] as const;
export type ModuleKey = (typeof MODULE_KEYS)[number];
@@ -341,6 +360,14 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
// ingest refuses while off (refuse-ingest posture, not surface-only).
optIn: true,
},
vaccinations: {
key: "vaccinations",
labelKey: "modules.vaccinations.label",
descriptionKey: "modules.vaccinations.description",
category: "tracking",
// No `optIn`: default-on. Nothing leaves the instance and the surfaces
// are inert until the person logs a dose.
},
});
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
+2
View File
@@ -49,6 +49,7 @@ import { exportPaths } from "./export";
import { familyHistoryPaths } from "./family-history";
import { healthRecordPaths } from "./health-record";
import { encounterPaths } from "./encounters";
import { vaccinationPaths } from "./vaccinations";
import { illnessPaths } from "./illness";
import { importPaths } from "./import";
import { insightsPaths } from "./insights";
@@ -104,6 +105,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...ocrPaths,
...biomarkerPaths,
...encounterPaths,
...vaccinationPaths,
...illnessPaths,
...mentalHealthPaths,
...allergyPaths,
+367
View File
@@ -0,0 +1,367 @@
/**
* OpenAPI route table for the immunization log (`/api/vaccinations`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
* and queries reuse the runtime Zod schemas so the wire contract stays
* single-source. Response shapes are declared here to mirror the
* server-authoritative DTOs.
*
* The one contract worth reading twice is `series`. "Dose 3 of 3" is a
* property of a person's whole history for an antigen, not of the row it is
* printed beside, so it is derived once on the server and published as
* resolved numbers. A client renders text from those numbers. It must never
* recompute them, and it is not given the input it would need to try.
*
* Note what is NOT here: the reminder contract does not move. Logging a dose
* satisfies the booster reminders it answers, and that happens through the
* ordinary preventive-care engine — same endpoints, same shape, same
* two-member `origin` enum. The match key that connects them is a server-side
* column and reaches no response.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
vaccinationCreateSchema,
vaccinationUpdateSchema,
vaccinationListQuerySchema,
vaccinationLinkSchema,
vaccinationSiteEnum,
} from "@/lib/validations/vaccinations";
import {
dataEnvelope,
errorEnvelope,
recordRefusal,
stdResponses,
} from "./shared";
vaccinationCreateSchema.meta({
id: "CreateVaccinationRequest",
description:
"Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens.",
});
vaccinationUpdateSchema.meta({
id: "UpdateVaccinationRequest",
description:
"Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty array included.",
});
vaccinationListQuerySchema.meta({
id: "ListVaccinationsQuery",
description:
"Filters for the immunization list: `antigenSlug`, `from` / `to` (ISO-8601 instants) and `limit` (1–500, default 300). The list is full and bounded rather than paged — a vaccination record is a lifetime document but a short one.",
});
vaccinationLinkSchema.meta({
id: "VaccinationLinkRequest",
description:
"Documents to file against, or unfile from, a dose. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.",
});
const catalogEntry = z
.object({
slug: z.string(),
atc: z.string(),
category: z.string(),
})
.meta({
id: "VaccinationCatalogEntry",
description:
"What the catalogue this release ships knows about the record's `antigenSlug`: the slug itself, its WHO ATC code from the J07 subtree, and its picker grouping. Null when the slug does not resolve — a slug written by an older release, or one restored verbatim from an older backup. Null is the signal to render `vaccineName` instead; it is not an error and not missing data.",
});
const seriesPosition = z
.object({
antigen: z.string(),
position: z.number().int(),
total: z.number().int().nullable(),
booster: z.boolean(),
})
.meta({
id: "VaccinationSeriesPosition",
description:
"Where this dose sits in ONE antigen's series, derived server-side over the whole history. `position` is the person's own recorded dose number when they gave one, otherwise the chronological index among their doses for that antigen. `total` is the record's own series length, then the catalogue's typical one, then null when neither knows — a seasonal vaccine has no 'of M'. `booster` is true once the series is complete and this dose comes after it. Render text from these values: both resolved reads as 'N of M', position alone as 'dose N', `booster` as a booster. Do not recompute them — a client cannot see the whole history and the two would drift.",
});
const vaccinationDocument = z
.object({
id: z.string(),
label: z.string().nullable(),
date: z.string().nullable(),
redacted: z.boolean(),
})
.meta({
id: "VaccinationLinkedDocument",
description:
"One page filed against a dose, resolved. The label and the date are computed server-side from the document itself, so the client never re-derives them. Both go null together, with `redacted: true`, when the caller's grant does not cover the document vault: a dose lives in the health background but points into the vault, and a page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date.",
});
const vaccinationEncounter = z
.object({
id: z.string(),
occurredAt: z.string(),
kind: z.string(),
})
.meta({
id: "VaccinationEncounterStub",
description:
"The visit the dose was given at, enough to recognise it by. Null when no visit is linked.",
});
const practitioner = z
.object({
id: z.string(),
name: z.string(),
specialty: z.string().nullable(),
practice: z.string().nullable(),
location: z.string().nullable(),
phone: z.string().nullable(),
note: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "VaccinationPractitioner",
description:
"The practice that administered the dose, resolved rather than an id. Null when the record names none.",
});
const vaccination = z
.object({
id: z.string(),
occurredAt: z.string(),
antigenSlug: z.string().nullable(),
vaccineName: z.string().nullable(),
doseNumber: z.number().int().nullable(),
seriesDoses: z.number().int().nullable(),
lotNumber: z.string().nullable(),
site: vaccinationSiteEnum.nullable(),
catalogEntry: catalogEntry.nullable(),
series: z.array(seriesPosition),
practitioner: practitioner.nullable(),
encounter: vaccinationEncounter.nullable(),
reminderId: z.string().nullable(),
note: z.string().nullable(),
documents: z.array(vaccinationDocument).optional(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "Vaccination",
description:
"One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list.",
});
const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({
id: "VaccinationList",
description:
"The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.",
});
const vaccinationNotFound = {
"404": {
description: "Vaccination not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
const idPath = { path: z.object({ id: z.string() }) };
export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/vaccinations": {
get: {
tags: ["Records"],
summary: "List vaccinations",
description:
"Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.",
requestParams: { query: vaccinationListQuerySchema },
responses: {
...recordRefusal(),
"200": {
description: "The caller's immunization history.",
content: {
"application/json": {
schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Records"],
summary: "Log a vaccination",
description:
"Records one administered dose and satisfies every live booster reminder keyed to one of its component antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationCreateSchema } },
},
responses: {
...recordRefusal(),
"201": {
description: "Dose logged.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "CreateVaccinationEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/vaccinations/{id}": {
get: {
tags: ["Records"],
summary: "Read a single vaccination",
description:
"Returns the dose with its linked pages resolved. Owner-scoped; a cross-user or tombstoned id 404s.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "The dose.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "GetVaccinationEnvelope"),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
patch: {
tags: ["Records"],
summary: "Edit a vaccination",
description:
"Partial edit; omitted fields are left untouched. Never re-runs booster satisfaction. Audits as `vaccination.record.update`. Owner-scoped.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationUpdateSchema } },
},
responses: {
...recordRefusal(),
"200": {
description: "Dose updated.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "UpdateVaccinationEnvelope"),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
delete: {
tags: ["Records"],
summary: "Soft-delete a vaccination",
description:
"Stamps `deletedAt`. A booster reminder the dose once satisfied is deliberately not rewound: the reminder belongs to the preventive-care list and outlives the record, and tidying a transcription is not evidence the dose never happened. Idempotent. Audits as `vaccination.record.delete`.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "Soft-deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteVaccinationEnvelope",
),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
},
"/api/vaccinations/{id}/restore": {
post: {
tags: ["Records"],
summary: "Restore a soft-deleted vaccination",
description:
"Clears the tombstone. The link rows survived the soft delete, so the dose returns with its pages still filed against it. Restoring a live dose is a no-op that still succeeds.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "Dose restored.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "RestoreVaccinationEnvelope"),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
},
"/api/vaccinations/{id}/links": {
post: {
tags: ["Records"],
summary: "File documents against a vaccination",
description:
"Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationLinkSchema } },
},
responses: {
"200": {
description: "The dose's linked pages after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
documents: z.array(vaccinationDocument),
unknown: z.array(z.string()),
}),
"LinkVaccinationEnvelope",
),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
delete: {
tags: ["Records"],
summary: "Unfile documents from a vaccination",
description:
"Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationLinkSchema } },
},
responses: {
"200": {
description: "The dose's linked pages after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
documents: z.array(vaccinationDocument),
unknown: z.array(z.string()),
}),
"UnlinkVaccinationEnvelope",
),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
},
};
+2
View File
@@ -40,6 +40,7 @@ import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
export const queryKeys = {
@@ -70,6 +71,7 @@ export const queryKeys = {
...recordSettingsKeys,
...profileKeys,
...sharingKeys,
...vaccinationKeys,
};
export { recordSettingsKeys } from "./record-settings";
+45
View File
@@ -0,0 +1,45 @@
/**
* Query keys — the immunization log.
* Part of the centralized factory; aggregated in `./index.ts`.
*
* These exist before any client reads them, because the in-repo
* `healthlog/queryKey-factory` rule errors on a bare array: the first
* component that lists doses cannot compile without a slot here. Same key with
* a different `queryFn` shape silently poisons the cache, which is the whole
* reason the factory is the only legal source.
*
* There is deliberately no matching `invalidateUserVaccinations` in
* `src/lib/cache/invalidate.ts`, for the same reason the visits module next
* door has none. That module evicts SERVER-side cached payloads, and no cached
* payload reads a dose: the dashboard snapshot, the insights targets and the
* analytics buckets are computed from measurements, medications and mood.
* Adding a sweep over caches nothing reads would be a placeholder that looks
* like coverage. It belongs in the release that puts doses into one of those
* payloads, alongside the reader that makes it necessary.
*
* There is no dependent-keys bundle here yet either, and that omission is the
* same rule one layer up: a bundle with no caller is dead code, and the gate
* says so. When the capture surface lands it brings its own, and that bundle
* has to evict the preventive-care root alongside this one — logging a dose
* satisfies the booster reminders it answers, so the checkups list goes stale
* in the same tick. Evicting only the immunization list would leave a booster
* on screen that the server had already moved a decade out.
*/
export const vaccinationKeys = {
/**
* The root prefix every dose write invalidates through
* (`vaccinationDependentKeys` in `./index.ts`). TanStack's hierarchical
* prefix semantics mean evicting `["vaccinations"]` clears the list and
* every open detail in one tick, so a dose edited in a sheet and the list
* behind it never disagree.
*/
vaccinations: () => ["vaccinations"] as const,
/**
* The list. The filter is part of the key: a page showing one antigen's
* history and the page showing everything are two different answers rather
* than one answer that overwrites the other.
*/
vaccinationList: (antigenSlug?: string | null) =>
["vaccinations", "list", antigenSlug ?? null] as const,
vaccination: (id: string) => ["vaccinations", "detail", id] as const,
} as const;
+8
View File
@@ -74,6 +74,8 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "encounter.visit.create":
return t("recordSharing.activityVerb.encounterVisitCreate", { name });
case "vaccination.record.create":
return t("recordSharing.activityVerb.vaccinationRecordCreate", { name });
case "practitioner.contact.create":
return t("recordSharing.activityVerb.practitionerContactCreate", {
name,
@@ -187,6 +189,12 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.encounterVisitDelete", { name });
case "encounter.visit.restore":
return t("recordSharing.activityVerb.encounterVisitRestore", { name });
case "vaccination.record.update":
return t("recordSharing.activityVerb.vaccinationRecordUpdate", { name });
case "vaccination.record.delete":
return t("recordSharing.activityVerb.vaccinationRecordDelete", { name });
case "vaccination.record.restore":
return t("recordSharing.activityVerb.vaccinationRecordRestore", { name });
case "practitioner.contact.update":
return t("recordSharing.activityVerb.practitionerContactUpdate", {
name,
+10 -2
View File
@@ -47,8 +47,8 @@ export const SHARE_DOMAINS = [
/** Lab results and the biomarker catalogue behind them. */
"labs",
/**
* Health background: allergies, family history, anamnesis facts, and visits
* and practitioners.
* Health background: allergies, family history, anamnesis facts, visits and
* practitioners, and the immunization history.
*
* Visits joined this domain rather than getting one of their own, and the
* cost is worth naming: somebody granted `profile` so a relative could see
@@ -56,6 +56,14 @@ export const SHARE_DOMAINS = [
* book behind it. The mitigation is not this comment — it is the consent
* copy, which names visits in all six locales, so the widening is stated
* where it is agreed to rather than inferred from the code.
*
* The immunization log joined on the same argument and carries the same
* cost, stated the same way: a delegate holding this domain for the allergy
* list also sees every dose the person has had, and the consent copy names
* it. Vaccinations are a tracked clinical vertical with their own module
* toggle, which is a separate question from consent — a module the owner
* enabled is not a section the owner shared, and a person granting health
* background is granting the section whether or not the surfaces are on.
*/
"profile",
/** Illness episodes and the day logs under them. */
@@ -0,0 +1,209 @@
/**
* What the catalogue promises about itself, and the one promise it makes to
* every row that outlives it.
*
* The structural checks below are cheap and mostly hold shapes the type system
* already holds — they exist because the file is edited by hand, entry by
* entry, and a paste that duplicates a slug or points a combination at an
* antigen that no longer exists compiles fine right up until two doses share
* an identity.
*
* The one that matters is the last block: a slug this release does not know
* must degrade to the record's own free text. A record keeps `antigenSlug`
* verbatim, backups restore it without validation, and slugs are append-only
* by convention rather than by constraint — so the day one is dropped or
* renamed, every dose that carried it is still a real dose belonging to a real
* person. The resolver returning `null` is what makes that survivable, and a
* resolver that threw or a renderer that produced an empty label would turn a
* catalogue edit into data loss on screen.
*/
import { describe, expect, it } from "vitest";
import {
ANTIGEN_SLUGS,
VACCINE_CATALOG,
VACCINE_CATALOG_SLUGS,
componentsForSlug,
resolveCatalogEntry,
type AntigenSlug,
} from "@/lib/vaccinations/vaccine-catalog";
const MONOVALENT = new Set<string>(ANTIGEN_SLUGS);
describe("the vaccine catalogue holds its own shape", () => {
it("carries a whole life's worth of entries, not a token list", () => {
// The floor is not decoration: an adult-only catalogue was the rejected
// alternative, and it would ship a module that cannot hold a child's
// Pass. A count that collapses is the symptom.
expect(VACCINE_CATALOG.length).toBeGreaterThanOrEqual(30);
expect(MONOVALENT.size).toBeGreaterThanOrEqual(20);
});
it("gives every entry a unique slug", () => {
const seen = new Map<string, number>();
for (const slug of VACCINE_CATALOG_SLUGS) {
seen.set(slug, (seen.get(slug) ?? 0) + 1);
}
const duplicated = [...seen.entries()]
.filter(([, count]) => count > 1)
.map(([slug]) => slug);
expect(
duplicated,
"two entries under one slug means two doses share an identity",
).toEqual([]);
});
it("offers every monovalent antigen as an entry of its own", () => {
// An older Pass line records a component on its own, so every antigen a
// combination can contain must also be loggable by itself.
const missing = ANTIGEN_SLUGS.filter(
(slug) => !VACCINE_CATALOG_SLUGS.includes(slug),
);
expect(missing).toEqual([]);
});
it("resolves every combination's components to real monovalent antigens", () => {
const foreign: string[] = [];
for (const entry of VACCINE_CATALOG) {
for (const component of entry.components) {
if (!MONOVALENT.has(component))
foreign.push(`${entry.slug}/${component}`);
}
}
expect(
foreign,
"a component that is not an antigen breaks the series axis silently",
).toEqual([]);
});
it("makes every monovalent entry its own single component", () => {
const wrong = VACCINE_CATALOG.filter(
(entry) =>
MONOVALENT.has(entry.slug) &&
(entry.components.length !== 1 || entry.components[0] !== entry.slug),
).map((entry) => entry.slug);
expect(
wrong,
"a monovalent that does not count into its own series counts into none",
).toEqual([]);
});
it("gives every entry an ATC code in the J07 subtree", () => {
const wrong = VACCINE_CATALOG.filter(
(entry) => !/^J07[A-Z]{2}\d{2}$/.test(entry.atc),
).map((entry) => `${entry.slug}: ${entry.atc}`);
expect(wrong).toEqual([]);
});
it("cites a source for every entry's numbers", () => {
// Rendered numbers need a rendered citation. A blank or one-word source
// is how a footnote stops being checkable.
const thin = VACCINE_CATALOG.filter(
(entry) => entry.source.trim().length < 8,
).map((entry) => entry.slug);
expect(thin).toEqual([]);
});
it("keeps the numbers inside the range a schedule can mean", () => {
const wrong: string[] = [];
for (const entry of VACCINE_CATALOG) {
const { typicalSeriesDoses: doses, boosterIntervalMonths: months } =
entry;
if (doses !== null && (doses < 1 || doses > 10)) {
wrong.push(`${entry.slug}: ${doses} doses`);
}
if (months !== null && (months < 1 || months > 240)) {
wrong.push(`${entry.slug}: every ${months} months`);
}
}
expect(wrong).toEqual([]);
});
it("keeps every synonym generic, lowercase and distinct", () => {
// The vendor-name rule cannot be fully held by a matcher — a trade name
// is a proper noun and nothing here knows the register. What a test CAN
// hold is the shape a trade name would have to break: synonyms are
// lowercase generic wording, never blank, never repeated.
const wrong: string[] = [];
const seen = new Set<string>();
for (const entry of VACCINE_CATALOG) {
for (const synonym of entry.synonyms ?? []) {
if (synonym !== synonym.toLowerCase() || synonym.trim().length === 0) {
wrong.push(`${entry.slug}: ${synonym}`);
}
if (seen.has(synonym)) wrong.push(`${entry.slug}: repeats ${synonym}`);
seen.add(synonym);
}
}
expect(wrong).toEqual([]);
});
});
describe("a slug the catalogue no longer knows degrades to the free text", () => {
/**
* A record as the app would hold it after the catalogue moved on: a slug
* written by an older release, and the person's own transcription beside it.
*/
const ORPHANED_RECORD = {
antigenSlug: "an-antigen-this-release-does-not-list",
vaccineName: "Tetanus, as the 1987 Pass wrote it",
};
/**
* What every consumer of the catalogue does with a record: ask the resolver,
* take the catalogue's name when it answers, fall back to the person's own
* wording when it does not. Written here rather than imported so the
* guarantee is checked as a contract on the resolver, which is what every
* caller depends on.
*/
function displayIdentity(record: {
antigenSlug: string | null;
vaccineName: string | null;
}): string {
const entry = resolveCatalogEntry(record.antigenSlug);
return entry ? entry.slug : (record.vaccineName ?? "");
}
it("returns null instead of throwing for a slug it does not have", () => {
expect(() =>
resolveCatalogEntry(ORPHANED_RECORD.antigenSlug),
).not.toThrow();
expect(resolveCatalogEntry(ORPHANED_RECORD.antigenSlug)).toBeNull();
});
it("renders the person's own wording rather than an empty label", () => {
const label = displayIdentity(ORPHANED_RECORD);
expect(
label,
"an unresolvable slug must degrade to the free-text arm; an empty " +
"label turns a catalogue edit into data loss on screen",
).toBe("Tetanus, as the 1987 Pass wrote it");
expect(label.length).toBeGreaterThan(0);
});
it("degrades the same way for a null slug and an empty one", () => {
expect(resolveCatalogEntry(null)).toBeNull();
expect(resolveCatalogEntry(undefined)).toBeNull();
expect(resolveCatalogEntry("")).toBeNull();
expect(
displayIdentity({ antigenSlug: null, vaccineName: "Free text only" }),
).toBe("Free text only");
});
it("matches no antigen at all rather than guessing from the name", () => {
// The consequence that matters: an unresolvable dose must not clear a
// booster reminder. No components means no match.
expect(componentsForSlug(ORPHANED_RECORD.antigenSlug)).toEqual([]);
expect(componentsForSlug(null)).toEqual([]);
});
it("still resolves a slug it does have, so the check above is not vacuous", () => {
const tdap = resolveCatalogEntry("tdap");
expect(tdap).not.toBeNull();
expect(tdap?.components).toEqual<AntigenSlug[]>([
"tetanus",
"diphtheria",
"pertussis",
]);
});
});
@@ -0,0 +1,263 @@
/**
* The series derivation, case by case.
*
* Every fixture date is relative to now. A fixed calendar date in a test about
* ordering rots the moment the window it sat inside slides past it, and this
* repository has shipped that defect before: the dates here are "eleven years
* ago" and "last month", never "2024-03-01".
*/
import { describe, expect, it } from "vitest";
import {
deriveSeries,
seriesForRecord,
type SeriesInputRecord,
} from "@/lib/vaccinations/series";
const MONTH_MS = 30 * 24 * 60 * 60 * 1000;
/** `monthsAgo(132)` — eleven years back from whenever the suite runs. */
function monthsAgo(months: number): Date {
return new Date(Date.now() - months * MONTH_MS);
}
function record(
id: string,
monthsBack: number,
antigenSlug: string | null,
extra: Partial<SeriesInputRecord> = {},
): SeriesInputRecord {
return {
id,
occurredAt: monthsAgo(monthsBack),
antigenSlug,
doseNumber: null,
seriesDoses: null,
...extra,
};
}
describe("a monovalent dose counts into its own series", () => {
it("places three tetanus doses at positions one, two and three", () => {
const history = [
record("t1", 300, "tetanus"),
record("t2", 296, "tetanus"),
record("t3", 288, "tetanus"),
];
const series = deriveSeries(history);
expect(series.get("t1")).toEqual([
{ antigen: "tetanus", position: 1, total: 3, booster: false },
]);
expect(series.get("t2")).toEqual([
{ antigen: "tetanus", position: 2, total: 3, booster: false },
]);
expect(series.get("t3")).toEqual([
{ antigen: "tetanus", position: 3, total: 3, booster: false },
]);
});
it("orders by date rather than by the order the query returned them", () => {
// Deliberately handed to the function newest-first.
const series = deriveSeries([
record("newest", 12, "tetanus"),
record("oldest", 240, "tetanus"),
]);
expect(series.get("oldest")?.[0].position).toBe(1);
expect(series.get("newest")?.[0].position).toBe(2);
});
});
describe("a combination counts into every component series", () => {
it("puts one Tdap dose at three different positions at once", () => {
// The case the whole component axis exists for: two plain Td doses first,
// so tetanus and diphtheria are already at two while pertussis is at none.
const history = [
record("td-1", 240, "td"),
record("td-2", 120, "td"),
record("tdap", 6, "tdap"),
];
const series = deriveSeries(history);
expect(series.get("tdap")).toEqual([
{ antigen: "tetanus", position: 3, total: 3, booster: false },
{ antigen: "diphtheria", position: 3, total: 3, booster: false },
{ antigen: "pertussis", position: 1, total: 3, booster: false },
]);
});
it("counts a six-antigen infant dose into all six", () => {
const series = deriveSeries([record("hex", 400, "hexavalent")]);
expect(series.get("hex")?.map((entry) => entry.antigen)).toEqual([
"diphtheria",
"tetanus",
"pertussis",
"polio",
"hib",
"hepatitis-b",
]);
expect(
series.get("hex")?.every((entry) => entry.position === 1),
"the first dose of a combination is the first of each of its series",
).toBe(true);
});
it("lets a monovalent dose continue a series a combination started", () => {
const series = deriveSeries([
record("mmr", 400, "mmr"),
record("measles-only", 396, "measles"),
]);
expect(series.get("measles-only")).toEqual([
{ antigen: "measles", position: 2, total: 2, booster: false },
]);
});
});
describe("position and total prefer what the record itself says", () => {
it("takes the Pass's own dose number over the chronological count", () => {
// Someone transcribing only the last line of a page they never entered
// the rest of: they are right about their history, the count is not.
const series = deriveSeries([
record("only-entry", 6, "hepatitis-b", { doseNumber: 3 }),
]);
expect(series.get("only-entry")).toEqual([
{ antigen: "hepatitis-b", position: 3, total: 3, booster: false },
]);
});
it("takes the record's own series length over the catalogue's", () => {
// HPV is two doses when the course starts early and three when it starts
// later; the override is for the person whose Pass says three.
const series = deriveSeries([
record("hpv-1", 24, "hpv", { seriesDoses: 3 }),
record("hpv-2", 22, "hpv", { seriesDoses: 3 }),
record("hpv-3", 18, "hpv", { seriesDoses: 3 }),
]);
expect(series.get("hpv-3")).toEqual([
{ antigen: "hpv", position: 3, total: 3, booster: false },
]);
});
it("reports a null total for a seasonal dose that is not part of a series", () => {
// Influenza has no series length, so there is no "of M" to report and
// nothing to be past the end of. Five annual doses are five first doses
// of five different years, never four boosters.
const series = deriveSeries([
record("flu-1", 39, "influenza"),
record("flu-2", 27, "influenza"),
record("flu-3", 15, "influenza"),
record("flu-4", 3, "influenza"),
]);
expect(series.get("flu-4")).toEqual([
{ antigen: "influenza", position: 4, total: null, booster: false },
]);
expect(
[1, 2, 3, 4].every((n) => series.get(`flu-${n}`)?.[0].booster === false),
"a null total means nothing can be past the end of it",
).toBe(true);
});
});
describe("a dose past the end of its series reads as a booster", () => {
it("marks the fourth hepatitis B dose after a completed three-dose primary", () => {
const series = deriveSeries([
record("hb-1", 300, "hepatitis-b"),
record("hb-2", 299, "hepatitis-b"),
record("hb-3", 294, "hepatitis-b"),
record("hb-later", 60, "hepatitis-b"),
]);
expect(series.get("hb-3")?.[0].booster).toBe(false);
expect(series.get("hb-later")).toEqual([
{ antigen: "hepatitis-b", position: 4, total: 3, booster: true },
]);
});
it("marks every tetanus dose after the primary series, not just the first", () => {
const series = deriveSeries([
record("t1", 480, "tetanus"),
record("t2", 476, "tetanus"),
record("t3", 468, "tetanus"),
record("t4", 348, "tetanus"),
record("t5", 228, "tetanus"),
record("t6", 108, "tetanus"),
]);
expect(["t4", "t5", "t6"].map((id) => series.get(id)?.[0].booster)).toEqual(
[true, true, true],
);
});
it("marks a booster per component, so a combination can be both at once", () => {
// Tetanus is long finished; pertussis has never been given. One Tdap dose
// is a tetanus booster and a first pertussis dose in the same act.
const history = [
record("t1", 480, "tetanus"),
record("t2", 476, "tetanus"),
record("t3", 468, "tetanus"),
record("tdap", 2, "tdap"),
];
const series = deriveSeries(history);
const tdap = series.get("tdap") ?? [];
expect(tdap.find((e) => e.antigen === "tetanus")).toEqual({
antigen: "tetanus",
position: 4,
total: 3,
booster: true,
});
expect(tdap.find((e) => e.antigen === "pertussis")).toEqual({
antigen: "pertussis",
position: 1,
total: 3,
booster: false,
});
});
});
describe("a record with no resolvable antigen yields nothing", () => {
it("returns an empty array for a free-text-only row", () => {
const series = deriveSeries([record("free-text", 12, null)]);
expect(
series.get("free-text"),
"a name string is not evidence about which antigens were given",
).toEqual([]);
});
it("returns an empty array for a slug the catalogue no longer lists", () => {
const series = deriveSeries([record("orphan", 400, "retired-slug")]);
expect(series.get("orphan")).toEqual([]);
});
it("keeps a key for every input, resolvable or not", () => {
// Total in the mathematical sense: a caller reading a missing key as "no
// series" and one reading it as "not computed" would disagree about the
// same dose.
const history = [
record("a", 12, "tetanus"),
record("b", 6, null),
record("c", 3, "retired-slug"),
];
const series = deriveSeries(history);
expect([...series.keys()].sort()).toEqual(["a", "b", "c"]);
});
it("does not let an unresolvable row shift the positions around it", () => {
const series = deriveSeries([
record("t1", 300, "tetanus"),
record("free", 200, null),
record("t2", 100, "tetanus"),
]);
expect(series.get("t2")?.[0].position).toBe(2);
});
});
describe("the single-record helper agrees with the whole-set derivation", () => {
it("returns the same entry the full derivation would", () => {
const history = [record("td-1", 240, "td"), record("tdap", 6, "tdap")];
expect(seriesForRecord("tdap", history)).toEqual(
deriveSeries(history).get("tdap"),
);
});
it("returns an empty array for a record the history does not contain", () => {
expect(
seriesForRecord("not-in-history", [record("t1", 12, "tetanus")]),
).toEqual([]);
});
});
+165
View File
@@ -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<Encounter, "id" | "occurredAt" | "kind"> | 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[];
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Logging a dose clears the booster reminders it answers.
*
* This is the reader half of `MeasurementReminder.vaccinationAntigen`, and the
* whole of it. The writer — the booster mint, which is rung 2: prefilled,
* user-confirmed, editable, declinable — lands with the capture surface. The
* column is a match key and nothing else: no cron query changes, no reminder
* DTO field, no new queue, no new notification event type, no new preference,
* and the published `origin` enum still reads `["VORSORGE","COACH"]`. A
* booster reminder is an ordinary preventive-care row that happens to say
* which antigen would settle it.
*
* ── Why components, not the slug ───────────────────────────────────────────
*
* A reminder is keyed by an ANTIGEN. A dose is recorded as whatever
* preparation the person was given, which is usually a combination. Matching
* the record's own slug against the reminder's key would mean a Tdap dose left
* a tetanus booster standing, which is the named failure mode of this whole
* design. So the dose is expanded to its component antigens first, and every
* reminder any of them matches is satisfied.
*
* A free-text-only dose matches nothing. Nothing here reads `vaccineName`: a
* name string is not evidence about which antigens were in the syringe, and a
* booster cleared by a guess is worse than a booster not cleared at all.
*
* ── Cold path only ─────────────────────────────────────────────────────────
*
* It runs inside the create transaction of `POST /api/vaccinations` and
* nowhere else. Never on a cron, never on an ingest hook, never on an edit —
* the never-nag query in `src/lib/jobs/measurement-reminder.ts` is untouched
* by this feature, and PATCH deliberately does not re-run this, because
* correcting a transcription typo is not a second clinical event.
*
* ── The two properties that come from `satisfyReminder` ────────────────────
*
* Both are the design rather than an accident, and both have a fixture:
*
* - **Forward-only.** A dose older than the reminder's `lastSatisfiedAt` is
* a no-op. Transcribing a 1987 Pass page can never clear a booster that is
* due today, which is the property that makes bulk back-entry safe.
* - **Re-anchored.** `nextDueAt` recomputes from the satisfy instant, so
* logging a tetanus dose from 2019 against a ten-year reminder moves the
* due date to 2029 — and logging one old enough that the recomputed date
* is already past leaves the reminder due, which is correct. An old dose
* does not silence a real gap.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog";
/** What the create route needs back: which reminders moved, and the first. */
export interface BoosterSatisfyResult {
/** Ids of every reminder this dose advanced, in catalogue component order. */
satisfiedReminderIds: string[];
/**
* The id stamped onto `VaccinationRecord.reminderId`.
*
* The first satisfied reminder in catalogue component order, so a Tdap dose
* that clears a tetanus and a pertussis booster always records the same one
* rather than whichever the database returned first. The column records ONE
* act; the engine records them all, in each row's own `lastSatisfiedAt`.
*/
primaryReminderId: string | null;
}
const EMPTY: BoosterSatisfyResult = {
satisfiedReminderIds: [],
primaryReminderId: null,
};
/**
* Satisfy every live booster reminder this dose answers.
*
* Takes the transaction client the create runs in, so the reminder advance and
* the record insert commit or roll back together: a dose that failed to save
* must not leave a booster looking settled.
*/
export async function satisfyBoostersForDose(
tx: Prisma.TransactionClient,
userId: string,
antigenSlug: string | null,
occurredAt: Date,
timezone: string,
): Promise<BoosterSatisfyResult> {
const components = componentsForSlug(antigenSlug);
if (components.length === 0) return EMPTY;
const reminders = await tx.measurementReminder.findMany({
where: {
userId,
deletedAt: null,
// An ordinary Vorsorge reminder carries NULL here and is never touched:
// the `in` filter cannot match a null, so the fence is structural rather
// than a condition somebody has to remember.
vaccinationAntigen: { in: [...components] },
},
select: {
id: true,
vaccinationAntigen: true,
intervalDays: true,
rrule: true,
anchorDate: true,
notifyHour: true,
lastSatisfiedAt: true,
createdAt: true,
},
});
if (reminders.length === 0) return EMPTY;
// Deterministic: catalogue component order, then id, so two reminders for
// the same antigen still resolve to a stable primary.
const rank = new Map(components.map((antigen, index) => [antigen, index]));
const ordered = [...reminders].sort((a, b) => {
const byAntigen =
(rank.get(a.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER) -
(rank.get(b.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER);
return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id);
});
const satisfiedReminderIds: string[] = [];
for (const reminder of ordered) {
const row: SatisfiableReminder = {
id: reminder.id,
intervalDays: reminder.intervalDays,
rrule: reminder.rrule,
anchorDate: reminder.anchorDate,
notifyHour: reminder.notifyHour,
lastSatisfiedAt: reminder.lastSatisfiedAt,
createdAt: reminder.createdAt,
};
// `satisfyReminder` types its client as the full `PrismaClient` because
// every other caller hands it one; the only member it touches is
// `measurementReminder.updateMany`, which a transaction client has. The
// cast is confined to this line so the advance stays inside the create
// transaction rather than committing on its own.
const result = await satisfyReminder(
tx as unknown as PrismaClient,
row,
timezone,
occurredAt,
);
if (result.satisfied) satisfiedReminderIds.push(reminder.id);
}
return {
satisfiedReminderIds,
primaryReminderId: satisfiedReminderIds[0] ?? null,
};
}
+145
View File
@@ -0,0 +1,145 @@
/**
* "Dose 3 of 3", derived once, on the server, per component antigen.
*
* The number beside a dose is not a property of that dose. It is a property of
* the person's whole history for the antigen in question, which means it can
* only be computed by something that can see all of it — and it must be
* computed in exactly one place, or the web client, the native client and the
* doctor report each answer the question slightly differently. So this
* function resolves it once and the result rides the DTO as numbers. No client
* re-derives, and no localised string crosses the wire: six locales must not
* ride the API.
*
* ── The component axis ─────────────────────────────────────────────────────
*
* A combined dose counts into EVERY antigen series it contains, at whatever
* position each of those series happens to be at. A history of Td, Td, Tdap
* puts that last dose at tetanus position 3 and pertussis position 1, and both
* are true at once. Grouping by the record's own slug instead of by its
* components is the failure mode this whole design exists to avoid: it would
* report an adult with three tetanus doses as having had one.
*
* ── What it deliberately does not do ───────────────────────────────────────
*
* It does not adjudicate a schedule. Whether dose 2 came at the right interval
* after dose 1, whether a gap invalidates a series, whether a person is due
* anything at all — none of that is here, and none of it is anywhere in this
* module. Unsolicited due-computation from age, sex and history is where the
* medical-device boundary starts, and it is a separate venture's territory.
* What this function reports is arithmetic on what a person wrote down.
*/
import {
componentsForSlug,
resolveCatalogEntry,
type AntigenSlug,
} from "@/lib/vaccinations/vaccine-catalog";
/** One antigen's view of one dose. */
export interface SeriesPosition {
/** The component antigen this entry speaks for. */
antigen: AntigenSlug;
/**
* Where this dose sits in that antigen's series. Prefers the number the
* person transcribed from their Pass; falls back to the chronological index
* among their own doses for the antigen.
*/
position: number;
/**
* How long the series is: the record's own override first, then the
* catalogue's typical value, then null when neither knows. Null is honest —
* a seasonal vaccine has no "of M".
*/
total: number | null;
/**
* True once the series is complete and this dose comes after it. The client
* renders "booster" rather than a position out of a total it has passed.
*/
booster: boolean;
}
/** The minimum a record has to carry to be placed in a series. */
export interface SeriesInputRecord {
id: string;
occurredAt: Date;
antigenSlug: string | null;
doseNumber: number | null;
seriesDoses: number | null;
}
/**
* Resolve every record's series positions, per component antigen.
*
* Takes the whole live set rather than one record because the answer for any
* single dose depends on the ones around it. Returns a map keyed by record id
* so a caller can attach the result to a DTO without re-scanning.
*
* Total, in the mathematical sense: every input id is a key in the output, and
* a record with no resolvable antigen maps to an empty array rather than being
* absent. A caller that reads a missing key as "no series" and a caller that
* reads it as "not computed yet" would disagree about the same dose.
*/
export function deriveSeries(
records: readonly SeriesInputRecord[],
): Map<string, SeriesPosition[]> {
const result = new Map<string, SeriesPosition[]>();
for (const record of records) result.set(record.id, []);
// Oldest first: a position is a count of what came before it, and ties break
// on id so two doses recorded for the same day are ordered deterministically
// rather than by whatever the query happened to return.
const chronological = [...records].sort((a, b) => {
const byDate = a.occurredAt.getTime() - b.occurredAt.getTime();
return byDate !== 0 ? byDate : a.id.localeCompare(b.id);
});
/** How many doses of each antigen have been seen so far in the walk. */
const seen = new Map<AntigenSlug, number>();
for (const record of chronological) {
// A free-text-only record — and a record whose slug the catalogue no
// longer resolves — yields nothing. Guessing an antigen from a name string
// is how a booster gets cleared by a dose that never contained it.
const components = componentsForSlug(record.antigenSlug);
if (components.length === 0) continue;
const catalogTotal =
resolveCatalogEntry(record.antigenSlug)?.typicalSeriesDoses ?? null;
// The record's own override wins: it is what the person's actual schedule
// was, and the catalogue only knows the typical one.
const total = record.seriesDoses ?? catalogTotal;
const positions: SeriesPosition[] = [];
for (const antigen of components) {
const index = (seen.get(antigen) ?? 0) + 1;
seen.set(antigen, index);
// The Pass's own claim wins over the count. Someone transcribing
// "3. Impfung" from a page whose earlier lines they never entered is
// right about their own history and this arithmetic is not.
const position = record.doseNumber ?? index;
positions.push({
antigen,
position,
total,
booster: total !== null && position > total,
});
}
result.set(record.id, positions);
}
return result;
}
/**
* The same answer for one record, against a history.
*
* A convenience over {@link deriveSeries} for the detail route, which holds
* one record and needs the set it belongs to anyway. Returns an empty array
* for a record the history does not contain, rather than deriving a lonely
* position that would contradict the list.
*/
export function seriesForRecord(
recordId: string,
history: readonly SeriesInputRecord[],
): SeriesPosition[] {
return deriveSeries(history).get(recordId) ?? [];
}
+189
View File
@@ -0,0 +1,189 @@
/**
* 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 { prisma } from "@/lib/db";
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";
/** Matches the visits service: the fallback when an account never set one. */
const DEFAULT_TIMEZONE = "Europe/Berlin";
/** 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<SeriesInputRecord[]> {
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<VaccinationDocumentDTO[]> {
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<void> {
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 },
});
}
/**
* The RECORD owner's timezone, for the booster reschedule.
*
* The owner's, never the acting delegate's: a reminder fires on the owner's
* phone at the owner's local hour, and a helper transcribing from another
* timezone must not shift when it rings.
*/
export async function resolveOwnerTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return row?.timezone || DEFAULT_TIMEZONE;
}
/** 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 },
});
}
+561
View File
@@ -0,0 +1,561 @@
/**
* The antigen-level vaccine catalogue: what a dose can be, in one static list.
*
* This is NOT a DB table, for the same reason `BIOMARKER_CATALOG` is not one
* (`src/lib/labs/biomarker-catalog.ts`): the slug is the stable identity, the
* display name resolves through i18n, the numbers are correctable in a patch
* release rather than a migration, and an account that has never opened the
* picker carries no rows for it.
*
* ── What it deliberately is not ────────────────────────────────────────────
*
* There is no manufacturer column and there is no trade name anywhere in it.
* Entries are named for the antigen or the disease, synonyms are generic or
* colloquial, and the one place a trade name may legitimately appear in this
* feature is the user's own `vaccineName` transcription of their Pass — their
* data, not our content.
*
* The numbers are TYPICAL values for orientation, and `source` names where
* each one comes from so the sentence rendered beside a dose carries its
* citation. They are not an individual schedule: what a particular person
* needs and when depends on their history, their age and their reasons, and
* that adjudication belongs to their doctor. Nothing in this file or in
* anything reading it computes what is due — the module renders what a person
* logged and, at most, offers to set a reminder they confirm themselves.
*
* ── Slugs are append-only ──────────────────────────────────────────────────
*
* A record stores `antigenSlug` verbatim, backups restore it without
* validation, and a slug this file has stopped listing must still describe
* what someone was given. {@link resolveCatalogEntry} is the ONLY lookup path
* and it returns `null` rather than throwing; every consumer answers `null` by
* falling back to the record's own `vaccineName`. That degrade is a tested
* guarantee, not an accident — see
* `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`.
*
* ── Combinations ───────────────────────────────────────────────────────────
*
* `components` is the axis everything downstream turns on. A combination lists
* the monovalent antigens it contains, so one Tdap dose counts into the
* tetanus, the diphtheria AND the pertussis series, and clears a booster
* reminder for any of them. A monovalent lists itself.
*
* ATC codes are the WHO J07 subtree at the most specific level that stays
* product-blind. Two entries may legitimately share a code: ATC classifies by
* antigen combination and does not distinguish a full-strength childhood
* preparation from the reduced-antigen adult one.
*/
/** The monovalent antigens a dose can contain. Combinations reference these. */
export const ANTIGEN_SLUGS = [
"tetanus",
"diphtheria",
"pertussis",
"polio",
"measles",
"mumps",
"rubella",
"varicella",
"hib",
"hepatitis-a",
"hepatitis-b",
"influenza",
"pneumococcal",
"herpes-zoster",
"rsv",
"covid-19",
"fsme",
"hpv",
"meningococcal-acwy",
"meningococcal-b",
"rotavirus",
"typhoid",
"rabies",
"yellow-fever",
"japanese-encephalitis",
"cholera",
] as const;
export type AntigenSlug = (typeof ANTIGEN_SLUGS)[number];
/**
* How the picker groups an entry.
*
* `standard` is the general schedule, `standard60` the additions recommended
* from the later decades, `childhood` what a child's Pass fills up with,
* `indication` what a particular exposure or condition calls for, and `travel`
* what a destination does. A person's own Pass mixes all five, which is why
* the catalogue covers a whole life rather than an adult floor.
*/
export type VaccineCategory =
"standard" | "standard60" | "childhood" | "indication" | "travel";
export interface VaccineSeed {
/** Stable EN identity; `vaccinations.catalog.<slug>` resolves the name. */
slug: string;
/** WHO ATC code (J07 subtree), most specific level that stays product-blind. */
atc: string;
/** Component antigens — self for monovalents. The series-derivation axis. */
components: readonly AntigenSlug[];
/** Typical primary-series length, or null (seasonal / single-dose). */
typicalSeriesDoses: number | null;
/** Typical booster interval in months, or null. 12 = yearly / seasonal. */
boosterIntervalMonths: number | null;
/** Picker grouping. */
category: VaccineCategory;
/**
* The document the numbers on this entry come from, rendered as the
* footnote beside them. A citation is a proper noun, like a unit: literal,
* not translated.
*/
source: string;
/** Generic search aliases only — never trade names. */
synonyms?: readonly string[];
}
/** The German standing-committee schedule, for everything on it. */
const STIKO = "STIKO Epid Bull 4/2026";
export const VACCINE_CATALOG: readonly VaccineSeed[] = [
// ── Monovalent: the general schedule ────────────────────────────────────
{
// Primary series in infancy, then a lifelong ten-year rhythm. The
// ten-year figure is the one number in this file most people already
// know, and the one a reminder is most often set from.
slug: "tetanus",
atc: "J07AM01",
components: ["tetanus"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
synonyms: ["lockjaw"],
},
{
// Travels with tetanus in practice; the schedule and the interval match,
// which is why the combined preparation is the usual one.
slug: "diphtheria",
atc: "J07AF01",
components: ["diphtheria"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
},
{
// No fixed adult rhythm: refreshed once in adulthood, and again in each
// pregnancy, neither of which is an interval. Null rather than a number
// invented to fill the field.
slug: "pertussis",
atc: "J07AJ02",
components: ["pertussis"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "standard",
source: STIKO,
synonyms: ["whooping cough"],
},
{
// Four doses in childhood, one refresher in adolescence. Adults refresh
// only for travel to a region where it still circulates, which is a
// destination question rather than an interval.
slug: "polio",
atc: "J07BF03",
components: ["polio"],
typicalSeriesDoses: 4,
boosterIntervalMonths: null,
category: "standard",
source: STIKO,
synonyms: ["poliomyelitis", "ipv"],
},
{
// Three doses in infancy; no routine adult booster.
slug: "hepatitis-b",
atc: "J07BC01",
components: ["hepatitis-b"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "standard",
source: STIKO,
synonyms: ["hep b"],
},
{
// Two doses, then variant-adapted refreshers whose cadence is a moving
// recommendation rather than a fixed schedule. Twelve months is the
// rhythm the seasonal refresher has settled into.
slug: "covid-19",
atc: "J07BN01",
components: ["covid-19"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 12,
category: "standard",
source: STIKO,
synonyms: ["coronavirus", "sars-cov-2"],
},
// ── Monovalent: added in the later decades ──────────────────────────────
{
// Seasonal: one dose per year against that year's strains, so there is no
// series to be part of. Null rather than 1 — every autumn's dose would
// otherwise read as a booster on a one-dose series it had already passed.
slug: "influenza",
atc: "J07BB02",
components: ["influenza"],
typicalSeriesDoses: null,
boosterIntervalMonths: 12,
category: "standard60",
source: STIKO,
synonyms: ["flu", "seasonal flu"],
},
{
slug: "pneumococcal",
atc: "J07AL02",
components: ["pneumococcal"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "standard60",
source: STIKO,
synonyms: ["pneumonia"],
},
{
// Two doses, two to six months apart; no booster after them.
slug: "herpes-zoster",
atc: "J07BK03",
components: ["herpes-zoster"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "standard60",
source: STIKO,
synonyms: ["shingles", "zoster"],
},
{
slug: "rsv",
atc: "J07BX05",
components: ["rsv"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "standard60",
source: STIKO,
synonyms: ["respiratory syncytial virus"],
},
// ── Monovalent: the childhood Pass ──────────────────────────────────────
{
// Given as part of a combined preparation in practice; the monovalent
// entry exists because an older Pass line may record it on its own.
slug: "measles",
atc: "J07BD01",
components: ["measles"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
slug: "mumps",
atc: "J07BE01",
components: ["mumps"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
slug: "rubella",
atc: "J07BJ01",
components: ["rubella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["german measles"],
},
{
slug: "varicella",
atc: "J07BK01",
components: ["varicella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["chickenpox"],
},
{
slug: "hib",
atc: "J07AG01",
components: ["hib"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["haemophilus influenzae type b"],
},
{
// Oral, and the series is finished early — the whole course sits inside
// the first months of life.
slug: "rotavirus",
atc: "J07BH01",
components: ["rotavirus"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
// Two doses when the course starts before fifteen, three when it starts
// later. Two is the typical value; the record's own `seriesDoses`
// override is there for the person whose Pass says three.
slug: "hpv",
atc: "J07BM03",
components: ["hpv"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["human papillomavirus"],
},
// ── Monovalent: by indication ───────────────────────────────────────────
{
// Three doses, then a refresher every three to five years while the
// exposure lasts. Sixty months is the longer end of that window.
slug: "fsme",
atc: "J07BA02",
components: ["fsme"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 60,
category: "indication",
source: STIKO,
synonyms: ["tick-borne encephalitis", "tbe"],
},
{
slug: "hepatitis-a",
atc: "J07BC02",
components: ["hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["hep a"],
},
{
slug: "meningococcal-acwy",
atc: "J07AH08",
components: ["meningococcal-acwy"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["meningococcus acwy", "quadrivalent meningococcal"],
},
{
slug: "meningococcal-b",
atc: "J07AH09",
components: ["meningococcal-b"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["meningococcus b"],
},
{
// The pre-exposure course. What follows an actual exposure is treatment
// and is not a schedule this catalogue has an opinion about.
slug: "rabies",
atc: "J07BG01",
components: ["rabies"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "indication",
source: STIKO,
synonyms: ["lyssa"],
},
// ── Monovalent: by destination ──────────────────────────────────────────
// Not on the standing-committee schedule, so each cites the WHO position
// paper for its own antigen rather than borrowing a citation from a
// document that does not cover it.
{
slug: "typhoid",
atc: "J07AP03",
components: ["typhoid"],
typicalSeriesDoses: 1,
boosterIntervalMonths: 36,
category: "travel",
source: "WHO position paper on typhoid vaccines",
synonyms: ["typhoid fever"],
},
{
// One dose is treated as lifelong protection; the certificate that used
// to expire after ten years no longer does.
slug: "yellow-fever",
atc: "J07BL01",
components: ["yellow-fever"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "travel",
source: "WHO position paper on yellow fever vaccines",
},
{
slug: "japanese-encephalitis",
atc: "J07BA01",
components: ["japanese-encephalitis"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 12,
category: "travel",
source: "WHO position paper on Japanese encephalitis vaccines",
},
{
// Oral, and the only entry here whose route is not an injection — which
// is why route derives from the antigen at the export boundary rather
// than being asked of the person filling in the form.
slug: "cholera",
atc: "J07AE01",
components: ["cholera"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 24,
category: "travel",
source: "WHO position paper on cholera vaccines",
},
// ── Combinations ────────────────────────────────────────────────────────
{
// The adult refresher most Passes are full of.
slug: "td",
atc: "J07AM51",
components: ["tetanus", "diphtheria"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
synonyms: ["tetanus diphtheria"],
},
{
slug: "tdap",
atc: "J07AJ52",
components: ["tetanus", "diphtheria", "pertussis"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
synonyms: ["tetanus diphtheria pertussis"],
},
{
slug: "tdap-ipv",
atc: "J07CA02",
components: ["tetanus", "diphtheria", "pertussis", "polio"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
category: "standard",
source: STIKO,
},
{
// The childhood preparation. Same ATC code as the adult one above: ATC
// classifies the antigen combination and does not distinguish the
// full-strength form from the reduced-antigen one.
slug: "dtap-ipv",
atc: "J07CA02",
components: ["diphtheria", "tetanus", "pertussis", "polio"],
typicalSeriesDoses: 4,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
},
{
// Six antigens in one injection, which is what makes an infant Pass page
// short and this catalogue's component axis necessary: one line here
// counts into six separate series.
slug: "hexavalent",
atc: "J07CA09",
components: [
"diphtheria",
"tetanus",
"pertussis",
"polio",
"hib",
"hepatitis-b",
],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["six-in-one"],
},
{
slug: "mmr",
atc: "J07BD52",
components: ["measles", "mumps", "rubella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["measles mumps rubella"],
},
{
slug: "mmrv",
atc: "J07BD54",
components: ["measles", "mumps", "rubella", "varicella"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
category: "childhood",
source: STIKO,
synonyms: ["measles mumps rubella varicella"],
},
{
slug: "hepa-hepb",
atc: "J07BC20",
components: ["hepatitis-a", "hepatitis-b"],
typicalSeriesDoses: 3,
boosterIntervalMonths: null,
category: "travel",
source: STIKO,
synonyms: ["hepatitis a and b"],
},
{
slug: "hepa-typhoid",
atc: "J07CA10",
components: ["hepatitis-a", "typhoid"],
typicalSeriesDoses: 1,
boosterIntervalMonths: null,
category: "travel",
source: "WHO position paper on typhoid vaccines",
},
];
/** Every slug the catalogue currently offers, combinations included. */
export const VACCINE_CATALOG_SLUGS: readonly string[] = VACCINE_CATALOG.map(
(entry) => entry.slug,
);
const BY_SLUG = new Map<string, VaccineSeed>(
VACCINE_CATALOG.map((entry) => [entry.slug, entry]),
);
/**
* The one lookup path into the catalogue.
*
* Returns `null` for anything it does not know — an empty slug, a slug a past
* release wrote and this one no longer lists, a slug restored verbatim from a
* backup written by an older version. It never throws, because every one of
* those is a real row belonging to a real person, and the caller's answer is
* to fall back to the record's own `vaccineName` rather than to fail.
*/
export function resolveCatalogEntry(
slug: string | null | undefined,
): VaccineSeed | null {
if (!slug) return null;
return BY_SLUG.get(slug) ?? null;
}
/**
* The antigens a dose with this slug counts into.
*
* Empty for an unknown slug and for a free-text-only record: a name string is
* not evidence about which antigens were given, and guessing from one is how
* a booster reminder would be cleared by a dose that never contained it.
*/
export function componentsForSlug(
slug: string | null | undefined,
): readonly AntigenSlug[] {
return resolveCatalogEntry(slug)?.components ?? [];
}
@@ -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);
});
});
+45
View File
@@ -57,6 +57,7 @@ import {
RhythmClassification,
SecondarySymptom,
SleepStage,
VaccinationSite,
} from "@/generated/prisma/enums";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
@@ -884,6 +885,44 @@ const encounterLinkBackupSchema = z
})
.passthrough();
/**
* One administered dose.
*
* `antigenSlug` is a free string here rather than an enum of the catalogue
* this release ships, deliberately: a slug the catalogue has since dropped
* must still restore, and the renderer degrades to `vaccineName`. Validating
* it at the restore boundary would refuse a file the app itself wrote.
*/
const vaccinationBackupSchema = z
.object({
id: z.string().min(1),
occurredAt: isoDateTime,
antigenSlug: z.string().nullable().optional(),
vaccineName: z.string().nullable().optional(),
doseNumber: z.number().int().nullable().optional(),
seriesDoses: z.number().int().nullable().optional(),
lotNumber: z.string().nullable().optional(),
site: z.enum(VaccinationSite).nullable().optional(),
practitionerId: z.string().nullable().optional(),
encounterId: z.string().nullable().optional(),
// Carried so the loss is visible in the file. The restore resolves it to
// NULL and names the drop: the reminder it points at does not travel.
reminderId: z.string().nullable().optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const vaccinationLinkBackupSchema = z
.object({
vaccinationId: z.string().min(1),
targetId: z.string().min(1),
createdAt: isoDateTime,
})
.passthrough();
const allergyBackupSchema = z
.object({
id: z.string().min(1),
@@ -1036,6 +1075,12 @@ export const backupPayloadSchema = z
encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]),
encounterLabLinks: z.array(encounterLinkBackupSchema).default([]),
encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]),
// The immunization log and the pages it was transcribed from. Defaulted
// for the same reason as the sections above: a file written before the
// tables existed carries no key, and an account with an empty Impfpass
// writes [].
vaccinations: z.array(vaccinationBackupSchema).default([]),
vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]),
manifest: backupManifestSchema.nullable().default(null),
})
.passthrough()
+211
View File
@@ -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<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>;
+33
View File
@@ -400,6 +400,39 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"restore",
),
// v1.38.0 — the immunization log.
handler(
"app/api/vaccinations/route.ts",
"/api/vaccinations",
"POST",
"profile",
"write",
"create",
),
handler(
"app/api/vaccinations/[id]/route.ts",
"/api/vaccinations/[id]",
"PATCH",
"profile",
"manage",
"update",
),
handler(
"app/api/vaccinations/[id]/route.ts",
"/api/vaccinations/[id]",
"DELETE",
"profile",
"manage",
"delete",
),
handler(
"app/api/vaccinations/[id]/restore/route.ts",
"/api/vaccinations/[id]/restore",
"POST",
"profile",
"manage",
"restore",
),
handler(
"app/api/illness/episodes/[id]/day-logs/route.ts",
"/api/illness/episodes/[id]/day-logs",
+165 -1
View File
@@ -46,7 +46,7 @@ process.env.ENCRYPTION_KEY ??=
import type { PrismaClient } from "@/generated/prisma/client";
import { encrypt, encryptBytes } from "@/lib/crypto";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
@@ -149,6 +149,10 @@ const COUNT_BACK: Record<
p.encounterLabLink.count({ where: { userId } }),
EncounterConditionLink: (p, userId) =>
p.encounterConditionLink.count({ where: { userId } }),
VaccinationRecord: (p, userId) =>
p.vaccinationRecord.count({ where: { userId } }),
VaccinationDocumentLink: (p, userId) =>
p.vaccinationDocumentLink.count({ where: { userId } }),
};
/**
@@ -468,6 +472,40 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
episodeId: episode.id,
},
});
// One dose with every optional column filled, and the page it was
// transcribed from. Fat on purpose, against this file's own minimal-fixture
// rule: a count-back proves the row returned, and the assertion after the
// restore reads each column back so it also proves the row returned WHOLE.
// The practitioner and the encounter are the ones seeded above, because both
// are remapped on the way back and a reference to something the restore did
// not put back is a different case (covered by the skip test below).
//
// `reminderId` is deliberately NOT set here: it is the one reference that
// can never resolve, so setting it would make this test's "nothing was
// skipped" assertion false for a reason that is correct behaviour.
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: AT("2026-05-14T00:00:00.000Z"),
antigenSlug: "tdap",
vaccineName: "Tetanus, diphtheria and pertussis",
doseNumber: 3,
seriesDoses: 3,
lotNumber: "RT-4471",
site: "LEFT_ARM",
practitionerId: practitioner.id,
encounterId: encounter.id,
noteEncrypted: encryptToBytes("sore arm for a day, nothing else"),
},
});
await prisma.vaccinationDocumentLink.create({
data: {
userId: OWNER_ID,
vaccinationId: vaccination.id,
documentId: document.id,
},
});
}
async function createOwner(prisma: PrismaClient) {
@@ -596,5 +634,131 @@ describe("every model the plan claims two-ended survives a real restore", () =>
expect(sideEffect.notes, "plaintext must not come back in the column").toBe(
null,
);
// The dose, read back column by column. The count above says a row
// returned; a restore that wrote one row with every optional column
// defaulted would satisfy it and hand back an Impfpass line with no
// antigen, no batch code and no arm. The two remapped references are
// asserted as ids that exist rather than as the ids the file carried,
// because that is the property the remap owes.
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
where: { userId: OWNER_ID },
include: {
practitioner: { select: { name: true } },
encounter: { select: { occurredAt: true } },
documentLinks: { select: { documentId: true } },
},
});
expect({
occurredAt: dose.occurredAt.toISOString(),
antigenSlug: dose.antigenSlug,
vaccineName: dose.vaccineName,
doseNumber: dose.doseNumber,
seriesDoses: dose.seriesDoses,
lotNumber: dose.lotNumber,
site: dose.site,
practitioner: dose.practitioner?.name ?? null,
encounterAt: dose.encounter?.occurredAt.toISOString() ?? null,
links: dose.documentLinks.length,
note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null,
}).toEqual({
occurredAt: "2026-05-14T00:00:00.000Z",
antigenSlug: "tdap",
vaccineName: "Tetanus, diphtheria and pertussis",
doseNumber: 3,
seriesDoses: 3,
lotNumber: "RT-4471",
site: "LEFT_ARM",
practitioner: "Round-trip practice",
encounterAt: "2026-06-30T08:00:00.000Z",
links: 1,
note: "sore arm for a day, nothing else",
});
});
/**
* The two references a dose can carry that the restore cannot honour, and
* the slug it must not judge.
*
* Separated from the round trip above because both are correct behaviour
* that shows up as a reported skip, and that file asserts nothing was
* skipped. Reported is the whole point: a reference dropped in silence is
* the same defect as a row dropped in silence, one field smaller.
*/
it("reports the reminder it cannot restore and keeps a slug it cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const reminder = await prisma.measurementReminder.create({
data: {
userId: OWNER_ID,
label: "Tetanus booster",
intervalDays: 3650,
vaccinationAntigen: "tetanus",
},
});
await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: AT("1987-09-02T00:00:00.000Z"),
// A slug no catalogue this release ships resolves. It must come back
// exactly as written: the renderer degrades to `vaccineName`, and a
// restore that dropped or rewrote it would lose what a person was
// actually given.
antigenSlug: "retired-antigen-from-an-older-release",
vaccineName: "Whatever the Pass called it in 1987",
reminderId: reminder.id,
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
}>;
expect(
reported.filter((entry) => entry.catalogue === "vaccinationReference"),
"the reminder the dose pointed at is not in the backup at all, so " +
"dropping the reference is correct and saying nothing about it is not",
).toEqual([
{ catalogue: "vaccinationReference", key: reminder.id, links: 1 },
]);
const dose = await prisma.vaccinationRecord.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
antigenSlug: dose.antigenSlug,
vaccineName: dose.vaccineName,
reminderId: dose.reminderId,
}).toEqual({
antigenSlug: "retired-antigen-from-an-older-release",
vaccineName: "Whatever the Pass called it in 1987",
reminderId: null,
});
});
});
@@ -31,6 +31,7 @@ const MODULES = {
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
vaccinations: true,
} as const;
/** The dense path is about pulse and glucose; nothing else is in scope here. */
@@ -335,6 +335,7 @@ describe("doctor-report — module enable/disable gating", () => {
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
vaccinations: true,
} as const;
it("includes every module's data when all modules are enabled", async () => {
+185 -1
View File
@@ -74,7 +74,14 @@ async function seedAccount(id: string) {
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
},
});
return { encounter, document, labResult, episode };
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId: id,
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
antigenSlug: "tdap",
},
});
return { encounter, document, labResult, episode, vaccination };
}
let owner: Awaited<ReturnType<typeof seedAccount>>;
@@ -461,3 +468,180 @@ describe("link service — resolved reads", () => {
expect(listed).toEqual([]);
});
});
/**
* The `vaccination:document` pair, one case per invariant the facade claims.
*
* The pair is new; the invariants are not. Repeating them here rather than
* trusting the encounter cases is the point — every one of them is a `where`
* clause or an index on a DIFFERENT table, and a pair wired to the wrong
* column or the wrong ownership probe would pass every test above while
* exposing this one.
*/
describe("link service — the vaccination pair", () => {
it("links a dose to the page it was transcribed from", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.changed).toBe(1);
expect(result.unknownTargetIds).toEqual([]);
expect(await prisma.vaccinationDocumentLink.count()).toBe(1);
// Written against the right columns, not merely written: a pair wired to
// the wrong column would still increment the count above.
const row = await prisma.vaccinationDocumentLink.findFirstOrThrow();
expect({
vaccinationId: row.vaccinationId,
documentId: row.documentId,
userId: row.userId,
}).toEqual({
vaccinationId: owner.vaccination.id,
documentId: owner.document.id,
userId: OWNER,
});
});
it("refuses a document another account owns", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "document",
targetIds: [stranger.document.id],
});
expect(result.changed).toBe(0);
expect(result.unknownTargetIds).toEqual([stranger.document.id]);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("refuses a dose another account owns", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: stranger.vaccination.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownSource).toBe(true);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("links the same pair twice without writing a second row", async () => {
const prisma = getPrismaClient();
const request = {
userId: OWNER,
sourceKind: "vaccination" as const,
sourceId: owner.vaccination.id,
targetKind: "document" as const,
targetIds: [owner.document.id],
};
expect((await linkTargets(prisma, request)).changed).toBe(1);
expect((await linkTargets(prisma, request)).changed).toBe(0);
expect(await prisma.vaccinationDocumentLink.count()).toBe(1);
});
it("refuses a call naming more targets than the cap", async () => {
const prisma = getPrismaClient();
await expect(
linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "document",
targetIds: Array.from(
{ length: MAX_TARGETS_PER_CALL + 1 },
(_, index) => `doc-${index}`,
),
}),
).rejects.toThrow(/cap/);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("will not link onto a dose the account has deleted", async () => {
const prisma = getPrismaClient();
await prisma.vaccinationRecord.update({
where: { id: owner.vaccination.id },
data: { deletedAt: new Date() },
});
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownSource).toBe(true);
expect(await prisma.vaccinationDocumentLink.count()).toBe(0);
});
it("resolves the linked page to a label and a date, never a bare id", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "document",
targetIds: [owner.document.id],
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "document",
});
expect(listed).toEqual([
{
id: owner.document.id,
label: `${OWNER} report`,
date: "2026-06-30T00:00:00.000Z",
},
]);
});
it("does not leak another account's dose links into a read", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: STRANGER,
sourceKind: "vaccination",
sourceId: stranger.vaccination.id,
targetKind: "document",
targetIds: [stranger.document.id],
});
expect(
await listTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: stranger.vaccination.id,
targetKind: "document",
}),
).toEqual([]);
});
it("has no table joining a dose to a lab result", async () => {
// The ceiling, asserted rather than only commented: the vaccination
// source owns exactly one pair, and asking for a second is a programming
// error the facade refuses loudly instead of inventing a table for.
const prisma = getPrismaClient();
await expect(
linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "labResult",
targetIds: [owner.labResult.id],
}),
).rejects.toThrow(/No link table joins a vaccination to a labResult/);
});
});
@@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
describe("the complete MANAGE handler matrix", () => {
it("starts with a non-empty, exact handler inventory", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74);
});
});
@@ -537,6 +537,20 @@ async function makeVisit(userId: string) {
});
}
/**
* One logged dose, catalogue-identified.
*
* `tetanus` rather than a combination on purpose: the drivers below assert one
* effect each, and a combination would satisfy several booster reminders at
* once if the fixture ever grew one. There is none here, so no reminder moves
* and the edit and delete verbs say only what they are here to say.
*/
async function makeVaccination(userId: string) {
return getPrismaClient().vaccinationRecord.create({
data: { userId, occurredAt: new Date(ISO), antigenSlug: "tetanus" },
});
}
async function makeFamilyHistory(userId: string) {
return getPrismaClient().familyHistoryEntry.create({
data: { userId, relationship: "MOTHER", condition: "Hypertension" },
@@ -1439,6 +1453,109 @@ manageContract("POST /api/practitioners/[id]/restore", {
?.deletedAt === null,
});
writeEffectContract("POST /api/vaccinations", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) => entry.route === "/api/vaccinations" && entry.action === "POST",
)!,
prepare: async (ownerId) => ({ ownerId }),
act: async () => {
const { POST } = await import("@/app/api/vaccinations/route");
return call(POST as Handler, "POST", "/api/vaccinations", {
occurredAt: ISO,
antigenSlug: "tetanus",
});
},
ok: 201,
auditAction: "vaccination.record.create",
applied: async ({ ownerId }) =>
(await getPrismaClient().vaccinationRecord.count({
where: { userId: ownerId },
})) === 1,
});
manageContract("PATCH /api/vaccinations/[id]", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
entry.route === "/api/vaccinations/[id]" && entry.action === "PATCH",
)!,
prepare: (ownerId) => makeVaccination(ownerId),
act: async (row) => {
const { PATCH } = await import("@/app/api/vaccinations/[id]/route");
return call(
PATCH as Handler,
"PATCH",
`/api/vaccinations/${row.id}`,
{ lotNumber: "MX-2211" },
{ id: row.id },
);
},
ok: 200,
auditAction: "vaccination.record.update",
applied: async (row) =>
(
await getPrismaClient().vaccinationRecord.findUnique({
where: { id: row.id },
})
)?.lotNumber === "MX-2211",
});
manageContract("DELETE /api/vaccinations/[id]", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
entry.route === "/api/vaccinations/[id]" && entry.action === "DELETE",
)!,
prepare: (ownerId) => makeVaccination(ownerId),
act: async (row) => {
const { DELETE } = await import("@/app/api/vaccinations/[id]/route");
return call(
DELETE as Handler,
"DELETE",
`/api/vaccinations/${row.id}`,
undefined,
{ id: row.id },
);
},
ok: 200,
auditAction: "vaccination.record.delete",
applied: async (row) =>
(
await getPrismaClient().vaccinationRecord.findUnique({
where: { id: row.id },
})
)?.deletedAt !== null,
});
manageContract("POST /api/vaccinations/[id]/restore", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) => entry.route === "/api/vaccinations/[id]/restore",
)!,
prepare: async (ownerId) => {
const row = await makeVaccination(ownerId);
return getPrismaClient().vaccinationRecord.update({
where: { id: row.id },
data: { deletedAt: new Date() },
});
},
act: async (row) => {
const { POST } = await import("@/app/api/vaccinations/[id]/restore/route");
return call(
POST as Handler,
"POST",
`/api/vaccinations/${row.id}/restore`,
{},
{ id: row.id },
);
},
ok: 200,
auditAction: "vaccination.record.restore",
applied: async (row) =>
(
await getPrismaClient().vaccinationRecord.findUnique({
where: { id: row.id },
})
)?.deletedAt === null,
});
manageContract("POST /api/illness/episodes/[id]/day-logs", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
@@ -2379,13 +2496,13 @@ describe("the conditions the admissions were granted on", () => {
describe("the complete MANAGE handler matrix", () => {
it("registers one strict actor-and-effect driver for every admission", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(STRICT_DRIVER_KEYS.size).toBe(70);
expect(STRICT_DRIVER_KEYS.size).toBe(74);
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
});
it("executes every registered driver through its owned effect and actor audit", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(REAL_EFFECT_KEYS.size).toBe(70);
expect(REAL_EFFECT_KEYS.size).toBe(74);
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
});
});
@@ -0,0 +1,362 @@
/**
* Logging a dose clears the boosters it answers — through the real route, real
* Postgres, real reminder engine.
*
* Every property here is about ROWS: which `lastSatisfiedAt` moved, which did
* not, and whether a reminder nobody keyed to an antigen was touched. None of
* them can be answered by a mocked client, and the one that matters most is a
* negative: a fake that ignored the `where` would report the ordinary Vorsorge
* reminder as untouched while having satisfied it.
*
* The combination case is the reason this file exists. A Tdap dose has to
* clear a tetanus booster AND a pertussis booster in one call, because a
* reminder is keyed by an antigen and a dose is recorded as whatever
* preparation the person was given. Matching the dose's own slug would leave
* the tetanus booster standing.
*
* Fixture dates are relative. A fixed calendar date in a test about "older
* than" and "moves forward" rots the moment it slides out of the window it
* was written inside.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { POST as createVaccination } from "@/app/api/vaccinations/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const OWNER_ID = "booster-owner";
const DAY_MS = 24 * 60 * 60 * 1000;
function daysAgo(days: number): Date {
return new Date(Date.now() - days * DAY_MS);
}
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * DAY_MS);
}
async function seedSession() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: OWNER_ID,
username: "booster-owner",
email: "booster-owner@example.test",
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: daysFromNow(7),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
/**
* A booster reminder as the mint will write it: an ordinary ten-year VORSORGE
* row that names the antigen a dose would settle.
*/
async function seedBooster(
antigen: string | null,
overrides: { lastSatisfiedAt?: Date | null; label?: string } = {},
) {
return getPrismaClient().measurementReminder.create({
data: {
userId: OWNER_ID,
label: overrides.label ?? `${antigen ?? "generic"} booster`,
intervalDays: 3650,
notifyHour: 9,
vaccinationAntigen: antigen,
lastSatisfiedAt: overrides.lastSatisfiedAt ?? null,
nextDueAt: daysAgo(1),
},
});
}
function post(body: unknown): Promise<Response> {
return createVaccination(
new Request("http://localhost/api/vaccinations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never,
);
}
async function reminderById(id: string) {
return getPrismaClient().measurementReminder.findUniqueOrThrow({
where: { id },
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedSession();
});
describe("a dose satisfies the boosters it answers", () => {
it("clears a monovalent antigen's own booster and re-anchors it", async () => {
const reminder = await seedBooster("tetanus");
const occurredAt = daysAgo(2);
const res = await post({
occurredAt: occurredAt.toISOString(),
antigenSlug: "tetanus",
});
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
const after = await reminderById(reminder.id);
expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString());
// Re-anchored from the satisfy instant, so the next one lands roughly ten
// years out rather than staying on the date it was already overdue on.
expect(after.nextDueAt).not.toBeNull();
expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now());
});
it("records the satisfied reminder on the dose", async () => {
const reminder = await seedBooster("tetanus");
const res = await post({
occurredAt: daysAgo(2).toISOString(),
antigenSlug: "tetanus",
});
const payload = (await res.json()) as {
data: { id: string; reminderId: string | null };
};
expect(payload.data.reminderId).toBe(reminder.id);
});
it("clears a tetanus AND a pertussis booster from one Tdap dose", async () => {
// The property the whole component axis exists for. A matcher keyed on the
// record's own slug would find neither of these.
const tetanus = await seedBooster("tetanus");
const pertussis = await seedBooster("pertussis");
const occurredAt = daysAgo(3);
const res = await post({
occurredAt: occurredAt.toISOString(),
antigenSlug: "tdap",
});
expect(res.status).toBe(201);
expect((await reminderById(tetanus.id)).lastSatisfiedAt).not.toBeNull();
expect((await reminderById(pertussis.id)).lastSatisfiedAt).not.toBeNull();
});
it("clears every component of a six-antigen infant dose", async () => {
const reminders = await Promise.all(
["diphtheria", "tetanus", "pertussis", "polio", "hib", "hepatitis-b"].map(
(antigen) => seedBooster(antigen),
),
);
const res = await post({
occurredAt: daysAgo(1).toISOString(),
antigenSlug: "hexavalent",
});
expect(res.status).toBe(201);
const after = await Promise.all(
reminders.map((reminder) => reminderById(reminder.id)),
);
expect(after.filter((row) => row.lastSatisfiedAt !== null)).toHaveLength(6);
});
});
describe("what a dose must never touch", () => {
it("leaves an ordinary Vorsorge reminder alone", async () => {
// No antigen key at all — the blood-panel reminder somebody set years ago.
// A matcher whose `where` did not hold would clear this and nothing in the
// response would say so.
const ordinary = await seedBooster(null, { label: "Blood panel" });
const res = await post({
occurredAt: daysAgo(1).toISOString(),
antigenSlug: "tetanus",
});
expect(res.status).toBe(201);
const after = await reminderById(ordinary.id);
expect(after.lastSatisfiedAt).toBeNull();
expect(after.nextDueAt?.toISOString()).toBe(
ordinary.nextDueAt?.toISOString(),
);
});
it("leaves a booster for an antigen the dose does not contain", async () => {
const measles = await seedBooster("measles");
const res = await post({
occurredAt: daysAgo(1).toISOString(),
antigenSlug: "tdap",
});
expect(res.status).toBe(201);
expect((await reminderById(measles.id)).lastSatisfiedAt).toBeNull();
});
it("matches nothing at all for a free-text-only dose", async () => {
// A name string is not evidence about which antigens were in the syringe.
const tetanus = await seedBooster("tetanus");
const res = await post({
occurredAt: daysAgo(1).toISOString(),
vaccineName: "Tetanus, as the 1987 Pass wrote it",
});
expect(res.status).toBe(201);
const payload = (await res.json()) as {
data: { reminderId: string | null };
};
expect(payload.data.reminderId).toBeNull();
expect((await reminderById(tetanus.id)).lastSatisfiedAt).toBeNull();
});
it("leaves another account's booster alone", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: "booster-stranger",
username: "booster-stranger",
email: "booster-stranger@example.test",
},
});
const foreign = await prisma.measurementReminder.create({
data: {
userId: "booster-stranger",
label: "tetanus booster",
intervalDays: 3650,
vaccinationAntigen: "tetanus",
nextDueAt: daysAgo(1),
},
});
const res = await post({
occurredAt: daysAgo(1).toISOString(),
antigenSlug: "tetanus",
});
expect(res.status).toBe(201);
expect((await reminderById(foreign.id)).lastSatisfiedAt).toBeNull();
});
it("leaves a soft-deleted booster alone", async () => {
const deleted = await seedBooster("tetanus");
await getPrismaClient().measurementReminder.update({
where: { id: deleted.id },
data: { deletedAt: new Date() },
});
const res = await post({
occurredAt: daysAgo(1).toISOString(),
antigenSlug: "tetanus",
});
expect(res.status).toBe(201);
expect((await reminderById(deleted.id)).lastSatisfiedAt).toBeNull();
});
});
describe("the two properties the engine brings with it", () => {
it("is forward-only: a backdated transcription never clears a due booster", async () => {
// The property that makes bulk back-entry of an old Pass safe. The
// reminder was satisfied a month ago; a page from 1987 must not move it.
const satisfiedAt = daysAgo(30);
const reminder = await seedBooster("tetanus", {
lastSatisfiedAt: satisfiedAt,
});
const res = await post({
occurredAt: daysAgo(14000).toISOString(),
antigenSlug: "tetanus",
});
expect(res.status).toBe(201);
const after = await reminderById(reminder.id);
expect(after.lastSatisfiedAt?.toISOString()).toBe(
satisfiedAt.toISOString(),
);
// And the record says so: no reminder was satisfied, so none is recorded.
const payload = (await res.json()) as {
data: { reminderId: string | null };
};
expect(payload.data.reminderId).toBeNull();
});
it("is idempotent: logging the same dose twice moves nothing the second time", async () => {
const reminder = await seedBooster("tetanus");
const occurredAt = daysAgo(5);
const bodyIn = {
occurredAt: occurredAt.toISOString(),
antigenSlug: "tetanus",
};
expect((await post(bodyIn)).status).toBe(201);
const afterFirst = await reminderById(reminder.id);
// A duplicate entry is still a valid dose — two doses on one day happen —
// so it returns 201. What must not happen is the reminder advancing again.
const second = await post(bodyIn);
expect(second.status).toBe(201);
const afterSecond = await reminderById(reminder.id);
expect(afterSecond.lastSatisfiedAt?.toISOString()).toBe(
afterFirst.lastSatisfiedAt?.toISOString(),
);
expect(afterSecond.nextDueAt?.toISOString()).toBe(
afterFirst.nextDueAt?.toISOString(),
);
const payload = (await second.json()) as {
data: { reminderId: string | null };
};
expect(
payload.data.reminderId,
"the second dose satisfied nothing, so it records nothing",
).toBeNull();
});
it("leaves a reminder due when the dose is too old to have settled it", async () => {
// Re-anchoring from an old instant can land the next due date in the past,
// and that is correct: an old dose does not silence a real gap.
const reminder = await seedBooster("tetanus");
const res = await post({
occurredAt: daysAgo(5000).toISOString(),
antigenSlug: "tetanus",
});
expect(res.status).toBe(201);
const after = await reminderById(reminder.id);
expect(after.lastSatisfiedAt).not.toBeNull();
expect(
after.nextDueAt!.getTime(),
"a dose from fourteen years ago on a ten-year cadence is still overdue",
).toBeLessThan(Date.now());
});
});