From f28658c61198da8f806675d6b52c134f798b07db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:35:03 +0200 Subject: [PATCH 01/12] feat(vaccinations): add the immunization record and its document link - VaccinationRecord holds one administered dose: date, catalogue slug or the Pass's own wording, dose and series numbers, batch code, site, and optional links to a practitioner, a visit and a booster reminder - VaccinationDocumentLink keeps the scanned Impfpass page beside the dose - MeasurementReminder gains a nullable vaccination_antigen match key so a logged dose can clear a booster without a second reminder engine - migration 0330, additive apart from that one nullable column --- .../0330_vaccination_record/migration.sql | 87 ++++++++ prisma/schema.prisma | 205 ++++++++++++++++-- .../encounter-reminder-exclusion.test.ts | 1 + 3 files changed, 270 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/0330_vaccination_record/migration.sql diff --git a/prisma/migrations/0330_vaccination_record/migration.sql b/prisma/migrations/0330_vaccination_record/migration.sql new file mode 100644 index 000000000..62d24aa74 --- /dev/null +++ b/prisma/migrations/0330_vaccination_record/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3116419c1..4dec763c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -962,6 +962,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. @@ -5070,61 +5075,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]) @@ -7436,8 +7462,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 @@ -7744,6 +7774,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]) @@ -7807,8 +7840,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") @@ -7876,3 +7910,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") +} diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index 884523d2e..eecb9a461 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -95,6 +95,7 @@ function reminderRow( nextDueAt: at, lastSatisfiedAt: null, enabled: true, + vaccinationAntigen: null, createdAt: at, updatedAt: at, deletedAt: null, From 1bd97de943bb21e3d0eb292035a12c96854ed75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:39:06 +0200 Subject: [PATCH 02/12] feat(vaccinations): write the backup, wipe and rotation verdicts - both models are backed up and claimed two-ended: an immunization history has no other copy once the paper Pass has been transcribed - the document link is wiped ahead of the dose, and the dose ahead of the practitioner it points at with SetNull, so the audit row's per-model counts stay truthful - the dose note joins ENCRYPTED_COLUMNS and the rotation walk; the batch code and the other structured columns stay queryable plaintext --- scripts/rotate-encryption-key.ts | 11 +++++++++++ src/lib/crypto/encrypted-columns.ts | 7 +++++++ src/lib/data-wipe/wipe-plan.ts | 9 +++++++++ src/lib/export/backup-plan.ts | 17 +++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 89681a2f7..acfbe3c2a 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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 diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 6caf22db1..99afe45c9 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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 diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 98092efe2..04b1ac9f8 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -91,6 +91,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", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index ac3e0cdc5..12c5414e0 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -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", @@ -262,6 +270,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. */ From 6926caa11dbef0d6a1876dca5eb81a0b3e5215e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:54:24 +0200 Subject: [PATCH 03/12] feat(vaccinations): carry the immunization log through export and restore - a new backup section reads the dose and its document link through their own delegates and writes both ends in one file, the arrangement the visit section already uses - the practitioner and the visit remap to the rows the same restore put back; the booster reminder never travels, so its reference resolves to null and the drop is reported rather than swallowed - the antigen slug restores verbatim: a slug the catalogue has since dropped still says what a person was given - the round trip seeds a dose with every optional column filled and reads each one back, so a returning row count cannot stand in for a returning record --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../[id]/restore/__tests__/round-trip.test.ts | 2 + .../api/admin/backups/[id]/restore/route.ts | 17 + .../export/__tests__/per-type-routes.test.ts | 6 + .../export/encrypted/__tests__/route.test.ts | 2 + src/components/admin/backups-section.tsx | 3 + .../__tests__/full-backup-payload.test.ts | 2 + src/lib/export/backup-plan.ts | 5 + src/lib/export/full-backup-payload.ts | 19 +- src/lib/export/restore-skips.ts | 10 +- src/lib/export/vaccinations-backup.ts | 418 ++++++++++++++++++ src/lib/validations/backup.ts | 45 ++ tests/integration/backup-round-trip.test.ts | 166 ++++++- 18 files changed, 698 insertions(+), 3 deletions(-) create mode 100644 src/lib/export/vaccinations-backup.ts diff --git a/messages/de.json b/messages/de.json index 22e0b9015..3b3b32bb6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Stimmungsfaktor", "restoreSkippedMoodTag": "Stimmungs-Tag", "restoreSkippedVisitReference": "Besuchsverknüpfung", + "restoreSkippedVaccinationReference": "Impfverknüpfung", "restoreSkippedCheckupClosure": "Vorsorge-Abschluss", "docsLink": "Dokumentation" }, diff --git a/messages/en.json b/messages/en.json index afacc42d2..e4dd15b3e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Mood factor", "restoreSkippedMoodTag": "Mood tag", "restoreSkippedVisitReference": "Visit reference", + "restoreSkippedVaccinationReference": "Vaccination reference", "restoreSkippedCheckupClosure": "Checkup closure", "docsLink": "Documentation" }, diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..f59d0bfaa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6873,6 +6873,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" }, diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..c385161f5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6873,6 +6873,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" }, diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..b5e7ea5db 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Fattore dell'umore", "restoreSkippedMoodTag": "Etichetta dell'umore", "restoreSkippedVisitReference": "Riferimento alla visita", + "restoreSkippedVaccinationReference": "Riferimento alla vaccinazione", "restoreSkippedCheckupClosure": "Chiusura del controllo", "docsLink": "Documentazione" }, diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..c754c729f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6873,6 +6873,7 @@ "restoreSkippedMoodFactor": "Czynnik nastroju", "restoreSkippedMoodTag": "Tag nastroju", "restoreSkippedVisitReference": "Odwołanie wizyty", + "restoreSkippedVaccinationReference": "Odwołanie szczepienia", "restoreSkippedCheckupClosure": "Zamknięcie badania", "docsLink": "Dokumentacja" }, diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index a1b1d2209..4d42c8519 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -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([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 149213e80..d7fac6681 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 16e1c3292..2237b5c1f 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -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(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index f1e7b2723..47fc52e16 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -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([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b3b92484d..9b80465ea 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -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"); } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 8380077ea..c7aeccfbd 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 12c5414e0..a1099de52 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -198,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", ]; @@ -207,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", ]; diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index f587439c6..05a9537a7 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index b13148f07..24fa35348 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -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. */ diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts new file mode 100644 index 000000000..e4515747e --- /dev/null +++ b/src/lib/export/vaccinations-backup.ts @@ -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, + userId: string, + options: VaccinationsBackupOptions = {}, +): Promise { + 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 = { [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 { + 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 { + 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, + }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 9a198e7dd..a9fdb0522 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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() diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 8de0ebd8c..966919fd2 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -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 { 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, + }); }); }); From e190c32c77cf5f14703e46a14e04efd760cdbd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:58:17 +0200 Subject: [PATCH 04/12] feat(vaccinations): seed the antigen-level vaccine catalogue - 35 entries across the childhood, standard, later-decade, indication and travel sets, so a Pass covering a whole life has somewhere to land - ATC-coded from the WHO J07 subtree, component-aware so one combined dose counts into every antigen series it contains, and each entry cites the document its numbers come from - no manufacturer column and no trade name in any shipped string; the one place a trade name belongs is the person's own transcription - the resolver is the only lookup path and answers null rather than throwing, so a slug a later release drops still renders as what the person wrote --- .../__tests__/catalog-integrity.test.ts | 209 +++++++ src/lib/vaccinations/vaccine-catalog.ts | 559 ++++++++++++++++++ 2 files changed, 768 insertions(+) create mode 100644 src/lib/vaccinations/__tests__/catalog-integrity.test.ts create mode 100644 src/lib/vaccinations/vaccine-catalog.ts diff --git a/src/lib/vaccinations/__tests__/catalog-integrity.test.ts b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts new file mode 100644 index 000000000..5dc9dfe88 --- /dev/null +++ b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts @@ -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(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(); + 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(); + 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([ + "tetanus", + "diphtheria", + "pertussis", + ]); + }); +}); diff --git a/src/lib/vaccinations/vaccine-catalog.ts b/src/lib/vaccinations/vaccine-catalog.ts new file mode 100644 index 000000000..d10022d53 --- /dev/null +++ b/src/lib/vaccinations/vaccine-catalog.ts @@ -0,0 +1,559 @@ +/** + * 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.` 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, and the series concept does not apply. + slug: "influenza", + atc: "J07BB02", + components: ["influenza"], + typicalSeriesDoses: 1, + 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( + 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 ?? []; +} From e44b1615e1845ba10d05a782781dec2f9c8adba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:00:39 +0200 Subject: [PATCH 05/12] feat(vaccinations): derive the series position per component antigen - one pure function resolves "dose N of M" over a person's whole history, so the number rides the DTO already computed and no client re-derives it - a combined dose counts into every antigen it contains, at whatever position each of those series is at: one Tdap line is a tetanus booster and a first pertussis dose in the same act - the Pass's own dose number wins over the count, and the record's own series length wins over the catalogue's - a seasonal dose has no series to be part of, so nothing reads as a booster on a length it never had - interval adjudication is deliberately absent; this is arithmetic on what a person wrote down --- src/lib/vaccinations/__tests__/series.test.ts | 263 ++++++++++++++++++ src/lib/vaccinations/series.ts | 145 ++++++++++ src/lib/vaccinations/vaccine-catalog.ts | 6 +- 3 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/series.test.ts create mode 100644 src/lib/vaccinations/series.ts diff --git a/src/lib/vaccinations/__tests__/series.test.ts b/src/lib/vaccinations/__tests__/series.test.ts new file mode 100644 index 000000000..fc5d0938a --- /dev/null +++ b/src/lib/vaccinations/__tests__/series.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/src/lib/vaccinations/series.ts b/src/lib/vaccinations/series.ts new file mode 100644 index 000000000..d1749030b --- /dev/null +++ b/src/lib/vaccinations/series.ts @@ -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 { + const result = new Map(); + 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(); + + 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) ?? []; +} diff --git a/src/lib/vaccinations/vaccine-catalog.ts b/src/lib/vaccinations/vaccine-catalog.ts index d10022d53..a97e98d84 100644 --- a/src/lib/vaccinations/vaccine-catalog.ts +++ b/src/lib/vaccinations/vaccine-catalog.ts @@ -195,11 +195,13 @@ export const VACCINE_CATALOG: readonly VaccineSeed[] = [ // ── Monovalent: added in the later decades ────────────────────────────── { - // Seasonal: one dose per year, and the series concept does not apply. + // 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: 1, + typicalSeriesDoses: null, boosterIntervalMonths: 12, category: "standard60", source: STIKO, From e22c7ddfccdde4b2254c88eca8005e553ac9d56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:05:58 +0200 Subject: [PATCH 06/12] feat(vaccinations): teach the link facade the dose-to-document pair - one new source and one new pair; the module's invariants carry over unchanged, so ownership, idempotency, the batch cap and the soft-delete refusal need no new code - each invariant gets its own case against the new table, because every one of them is a where clause on a different table and a pair wired to the wrong column would pass all the existing ones - the ceiling is written down and asserted: the vaccination source owns exactly one pair, and asking for a second is refused loudly --- src/lib/links/link-service.ts | 19 ++- tests/integration/link-service.test.ts | 186 ++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index e52c226d3..19ff8423f 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -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 = "document" | "labResult" | "conditionEpisode"; @@ -137,6 +137,10 @@ const SOURCES: Record< delegate: (tx) => narrow(tx.inboundDocument), relationField: "document", }, + vaccination: { + delegate: (tx) => narrow(tx.vaccinationRecord), + relationField: "vaccination", + }, }; const DOCUMENT_ENDPOINT: EndpointSpec = { @@ -183,6 +187,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> @@ -211,6 +222,12 @@ const LINK_TABLES: Partial< targetColumn: "episodeId", target: CONDITION_ENDPOINT, }, + "vaccination:document": { + delegate: (tx) => narrow(tx.vaccinationDocumentLink), + sourceColumn: "vaccinationId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, }; function tableFor( diff --git a/tests/integration/link-service.test.ts b/tests/integration/link-service.test.ts index 19b12f81f..94dc0dd1d 100644 --- a/tests/integration/link-service.test.ts +++ b/tests/integration/link-service.test.ts @@ -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>; @@ -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/); + }); +}); From 4dc3b1aa279aa4a228b42338402d9243b54bc9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:09:52 +0200 Subject: [PATCH 07/12] feat(vaccinations): add the wire schemas and the resolved DTO - a dose needs a day and one way of naming what it was, and nothing else; a required catalogue pick would make an old Pass line unloggable - the write path refuses a future date and a slug the catalogue does not offer, while storage stays tolerant so an older backup restores whole - the DTO publishes the practitioner, the visit, the linked pages, the catalogue entry and the derived series already resolved, in numbers rather than localised text - a linked page's name and date are withheld from a delegate whose grant does not reach the vault, and the withholding says so rather than looking like a page with no name --- src/lib/vaccinations/dto.ts | 165 ++++++++++++ src/lib/vaccinations/service.ts | 170 ++++++++++++ .../__tests__/vaccinations.test.ts | 250 ++++++++++++++++++ src/lib/validations/vaccinations.ts | 211 +++++++++++++++ 4 files changed, 796 insertions(+) create mode 100644 src/lib/vaccinations/dto.ts create mode 100644 src/lib/vaccinations/service.ts create mode 100644 src/lib/validations/__tests__/vaccinations.test.ts create mode 100644 src/lib/validations/vaccinations.ts diff --git a/src/lib/vaccinations/dto.ts b/src/lib/vaccinations/dto.ts new file mode 100644 index 000000000..653b96908 --- /dev/null +++ b/src/lib/vaccinations/dto.ts @@ -0,0 +1,165 @@ +/** + * Vaccination row → wire DTO. + * + * Everything is published resolved. The practitioner comes back as an object + * and never as a bare id, the visit as a stub with its date and kind, the + * document links with a label and a date, the catalogue entry as an object or + * an explicit `null`, and — the one that matters — the SERIES already derived. + * + * "Dose 3 of 3" is a property of the whole history, not of the row, and it is + * computed once on the server (`src/lib/vaccinations/series.ts`) so no client + * re-derives it. The DTO carries numbers and a boolean, never a localised + * string: six locales must not ride the API. + * + * `catalogEntry: null` is the degrade signal. It means the slug the row + * carries is not one this release knows — a slug a past version wrote, or one + * restored from an older backup — and the client falls back to `vaccineName`. + * It is not an error and it is not empty data. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import type { + Encounter, + Practitioner, + VaccinationRecord, + VaccinationSite, +} from "@/generated/prisma/client"; +import { + toPractitionerDTO, + type PractitionerDTO, +} from "@/lib/practitioners/dto"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import type { SeriesPosition } from "@/lib/vaccinations/series"; + +/** + * One page a dose was transcribed from, as the caller is allowed to see it. + * + * `label` and `date` go null together when the caller's grant does not cover + * the vault. A dose lives in the health background and points into the + * documents domain, and a grant that opened the background was never consent + * for what is on the other side of that seam — the FILENAME of a scanned page + * is itself the sensitive part. `redacted` says which null is a withholding + * and which is simply a page with no date, so a client never has to guess. + */ +export interface VaccinationDocumentDTO { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +} + +/** What the catalogue knows about the pick, when it still knows it. */ +export interface VaccinationCatalogDTO { + slug: string; + atc: string; + category: string; +} + +/** The visit this dose was given at, enough to recognise it by. */ +export interface VaccinationEncounterDTO { + id: string; + occurredAt: string; + kind: string; +} + +export interface VaccinationDTO { + id: string; + /** The day, at UTC midnight. An Impfpass carries dates, never times. */ + occurredAt: string; + /** The stored slug, verbatim — resolvable or not. */ + antigenSlug: string | null; + /** What the person wrote. May be a trade name; it is their transcription. */ + vaccineName: string | null; + doseNumber: number | null; + seriesDoses: number | null; + lotNumber: string | null; + site: VaccinationSite | null; + /** + * The catalogue's view of `antigenSlug`, or `null` when this release does + * not know the slug. Null is the signal to render `vaccineName` instead. + */ + catalogEntry: VaccinationCatalogDTO | null; + /** + * Where this dose sits in each of its antigen series, resolved server-side. + * + * One entry per component antigen, so a combined dose carries several. Empty + * for a free-text-only row and for a slug the catalogue cannot resolve — no + * antigen is guessed from a name string. + * + * The client renders text from these numbers: both `position` and `total` → + * "N of M", only `position` → "dose N", `booster` → "booster". It never + * recomputes them. + */ + series: SeriesPosition[]; + /** Resolved, never just an id. `null` when the dose names no practice. */ + practitioner: PractitionerDTO | null; + /** Resolved stub, never just an id. `null` when no visit is linked. */ + encounter: VaccinationEncounterDTO | null; + /** The booster reminder this dose satisfied, when one matched. */ + reminderId: string | null; + note: string | null; + /** Present on the detail response; omitted from the list. */ + documents?: VaccinationDocumentDTO[]; + createdAt: string; + updatedAt: string; +} + +/** Decrypt the dose note fail-soft: a rotation gap reads as no note, not 500. */ +function decryptNote(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + try { + return decryptFromBytes(value); + } catch (err) { + getEvent()?.addWarning( + `vaccination note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +export type VaccinationWithRelations = VaccinationRecord & { + practitioner?: Practitioner | null; + encounter?: Pick | null; +}; + +export function toVaccinationDTO( + row: VaccinationWithRelations, + series: SeriesPosition[], + documents?: VaccinationDocumentDTO[], +): VaccinationDTO { + const entry = resolveCatalogEntry(row.antigenSlug); + return { + id: row.id, + occurredAt: row.occurredAt.toISOString(), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName, + doseNumber: row.doseNumber, + seriesDoses: row.seriesDoses, + lotNumber: row.lotNumber, + site: row.site, + catalogEntry: entry + ? { slug: entry.slug, atc: entry.atc, category: entry.category } + : null, + series, + practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null, + encounter: row.encounter + ? { + id: row.encounter.id, + occurredAt: row.encounter.occurredAt.toISOString(), + kind: row.encounter.kind, + } + : null, + reminderId: row.reminderId, + note: decryptNote(row.noteEncrypted), + ...(documents ? { documents } : {}), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** The list response: one array, newest dose first. */ +export interface VaccinationListDTO { + vaccinations: VaccinationDTO[]; +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts new file mode 100644 index 000000000..b07c0de3f --- /dev/null +++ b/src/lib/vaccinations/service.ts @@ -0,0 +1,170 @@ +/** + * What the vaccination routes share: the query shapes, the ownership probes, + * the domain redaction, and the link application. + * + * Extracted for the same reason `src/lib/encounters/service.ts` is: four route + * files need the same include, the same "does the caller own this practice" + * question and the same seam handling, and four copies of a redaction rule is + * three chances to get it wrong. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { listTargets, replaceTargets } from "@/lib/links"; +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; +import { + deriveSeries, + type SeriesInputRecord, +} from "@/lib/vaccinations/series"; + +/** The relations every vaccination response resolves. */ +export const VACCINATION_INCLUDE = { + practitioner: true, + encounter: { select: { id: true, occurredAt: true, kind: true } }, +} as const satisfies Prisma.VaccinationRecordInclude; + +/** What the series derivation needs, and nothing more. */ +export const SERIES_SELECT = { + id: true, + occurredAt: true, + antigenSlug: true, + doseNumber: true, + seriesDoses: true, +} as const satisfies Prisma.VaccinationRecordSelect; + +/** + * Which sections of the record the caller is actually inside. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. Same shape as the visits service, deliberately + * — the two features sit on the same seam and a second spelling of this would + * be a second thing to keep in step. + */ +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} + +/** + * The whole live history for one account, in the shape the derivation wants. + * + * The series is a property of the set, so every read that publishes a position + * loads the set — including the detail route, which holds one row and cannot + * answer the question from it. The rows are small (five scalar columns) and an + * Impfpass is a short document even after a long life, so this is cheaper than + * the cache that would keep it consistent. + */ +export async function loadSeriesHistory( + tx: Prisma.TransactionClient, + userId: string, +): Promise { + return tx.vaccinationRecord.findMany({ + where: { userId, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + select: SERIES_SELECT, + }); +} + +/** Every record's resolved series positions, keyed by record id. */ +export async function resolveSeriesFor( + tx: Prisma.TransactionClient, + userId: string, +) { + return deriveSeries(await loadSeriesHistory(tx, userId)); +} + +/** + * The linked pages, resolved and redacted against the caller's reach. + * + * A dose lives in the health background and points into the document vault. A + * grant that opened the background was never consent for the vault, and while + * no document CONTENT crosses the seam here, the filename is itself the + * sensitive part: a page called "oncology discharge letter" says most of what + * the person was worried about. + * + * So the label and the date are withheld and the LINK is not: the caller may + * know the dose was transcribed from a page, because the dose is theirs to + * read. `label: null` rather than an invented placeholder, so an absence reads + * as an absence, and `redacted` says which of the two nulls this is. + */ +export async function loadVaccinationDocuments( + tx: Prisma.TransactionClient, + userId: string, + vaccinationId: string, + visible: (domain: ShareDomain) => boolean, +): Promise { + const rows = await listTargets(tx, { + userId, + sourceKind: "vaccination", + sourceId: vaccinationId, + targetKind: "document", + }); + if (visible("documents")) { + return rows.map((row) => ({ ...row, redacted: false })); + } + return rows.map((row) => ({ + id: row.id, + label: null, + date: null, + redacted: true, + })); +} + +/** + * Apply the document array, when the request named one. + * + * An absent array leaves the links alone; a present one replaces them, empty + * array included. Unknown ids are dropped by the link service and never block + * the save — a dose is never refused because a page it pointed at has since + * been deleted. + */ +export async function applyVaccinationLinks( + tx: Prisma.TransactionClient, + userId: string, + vaccinationId: string, + documentIds: string[] | undefined, +): Promise { + if (documentIds === undefined) return; + await replaceTargets(tx, { + userId, + sourceKind: "vaccination", + sourceId: vaccinationId, + targetKind: "document", + targetIds: documentIds, + }); +} + +/** Does the caller own a live practitioner with this id? */ +export async function resolveOwnedPractitioner( + tx: Prisma.TransactionClient, + userId: string, + practitionerId: string, +): Promise<{ id: string } | null> { + return tx.practitioner.findFirst({ + where: { id: practitionerId, userId, deletedAt: null }, + select: { id: true }, + }); +} + +/** Does the caller own a live visit with this id? */ +export async function resolveOwnedEncounter( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, +): Promise<{ id: string } | null> { + return tx.encounter.findFirst({ + where: { id: encounterId, userId, deletedAt: null }, + select: { id: true }, + }); +} diff --git a/src/lib/validations/__tests__/vaccinations.test.ts b/src/lib/validations/__tests__/vaccinations.test.ts new file mode 100644 index 000000000..cba1936ab --- /dev/null +++ b/src/lib/validations/__tests__/vaccinations.test.ts @@ -0,0 +1,250 @@ +/** + * The vaccination wire contract, at the boundary. + * + * Four properties are worth holding here rather than only at the route: the + * either-arm rule (the one thing that IS mandatory beyond the date), the + * future-date refusal (the split from the visit schema next door), the + * catalogue check on write (the split from what storage accepts), and the + * absence of `userId` (which is narrowed from auth in every route and must + * never be something a body can name). + */ +import { describe, expect, it } from "vitest"; + +import { + vaccinationCreateSchema, + vaccinationLinkSchema, + vaccinationListQuerySchema, + vaccinationUpdateSchema, +} from "@/lib/validations/vaccinations"; + +const YESTERDAY = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +const NEXT_YEAR = new Date( + Date.now() + 365 * 24 * 60 * 60 * 1000, +).toISOString(); + +function pathsOf(result: { success: false; error: { issues: unknown[] } }) { + return (result.error.issues as Array<{ path: (string | number)[] }>).map( + (issue) => issue.path.join("."), + ); +} + +describe("a dose needs a date and one way of naming what it was", () => { + it("accepts a catalogue pick with nothing else", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tdap", + }); + expect(parsed.success).toBe(true); + }); + + it("accepts free text with nothing else, so a 1987 Pass line is loggable", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + vaccineName: "Tetanol, as the Pass wrote it", + }); + expect(parsed.success).toBe(true); + }); + + it("refuses a body with neither, naming BOTH fields", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + // Both, so a client highlighting one field per issue highlights the pick + // and the text box rather than leaving the person to guess which was + // meant. + expect(pathsOf(parsed).sort()).toEqual(["antigenSlug", "vaccineName"]); + }); + + it("returns every issue at once rather than the first", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: "not-a-date", + doseNumber: 99, + lotNumber: "x".repeat(200), + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("a dose is logged after the fact", () => { + it("refuses a date in the future", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: NEXT_YEAR, + antigenSlug: "tetanus", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("occurredAt"); + expect(JSON.stringify(parsed.error.issues)).toMatch( + /reminder, not a record/, + ); + }); + + it("allows a day's worth of clock skew, so a client east of here works", () => { + const inSixHours = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: inSixHours, + antigenSlug: "tetanus", + }).success, + ).toBe(true); + }); + + it("refuses a date before there were records to keep", () => { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: "1802-04-01T00:00:00.000Z", + antigenSlug: "tetanus", + }).success, + ).toBe(false); + }); +}); + +describe("the write path is stricter than the column", () => { + it("refuses a slug the catalogue does not offer", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "not-a-real-antigen", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("antigenSlug"); + }); + + it("accepts every slug the catalogue does offer", () => { + for (const slug of ["tetanus", "tdap", "hexavalent", "mmrv", "cholera"]) { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: slug, + }).success, + slug, + ).toBe(true); + } + }); +}); + +describe("no body may name the account it writes to", () => { + it("refuses a userId field on create", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tetanus", + userId: "somebody-else", + }); + expect( + parsed.success, + "userId is narrowed from auth; a body that can name it is a body that " + + "can write into another account", + ).toBe(false); + }); + + it("refuses a userId field on update", () => { + expect( + vaccinationUpdateSchema.safeParse({ + lotNumber: "AB-1", + userId: "somebody-else", + }).success, + ).toBe(false); + }); + + it("refuses an unknown field rather than ignoring it", () => { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tetanus", + manufacturer: "a trade name", + }).success, + ).toBe(false); + }); +}); + +describe("an edit names what it changes", () => { + it("refuses a body that names nothing", () => { + const parsed = vaccinationUpdateSchema.safeParse({}); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(JSON.stringify(parsed.error.issues)).toMatch( + /name at least one field/, + ); + }); + + it("allows one field on its own", () => { + expect( + vaccinationUpdateSchema.safeParse({ lotNumber: "AB-1" }).success, + ).toBe(true); + }); + + it("allows the date to be corrected", () => { + expect( + vaccinationUpdateSchema.safeParse({ occurredAt: YESTERDAY }).success, + ).toBe(true); + }); + + it("refuses an edit that clears both identity arms at once", () => { + expect( + vaccinationUpdateSchema.safeParse({ + antigenSlug: null, + vaccineName: null, + }).success, + ).toBe(false); + }); + + it("allows clearing one arm, because the other may still be there", () => { + expect( + vaccinationUpdateSchema.safeParse({ antigenSlug: null }).success, + ).toBe(true); + }); +}); + +describe("the list query and the link body", () => { + it("refuses a window that ends before it starts", () => { + const parsed = vaccinationListQuerySchema.safeParse({ + from: "2026-07-01T00:00:00.000Z", + to: "2026-06-01T00:00:00.000Z", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("to"); + }); + + it("coerces a limit from the query string and bounds it", () => { + expect(vaccinationListQuerySchema.safeParse({ limit: "50" })).toMatchObject( + { + success: true, + data: { limit: 50 }, + }, + ); + expect( + vaccinationListQuerySchema.safeParse({ limit: "5000" }).success, + ).toBe(false); + }); + + it("bounds a link call at the facade's own cap", () => { + const ids = (count: number) => + Array.from({ length: count }, (_, index) => `doc-${index}`); + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "document", + targetIds: ids(100), + }).success, + ).toBe(true); + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "document", + targetIds: ids(101), + }).success, + ).toBe(false); + }); + + it("knows exactly one link family", () => { + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "labResult", + targetIds: ["lab-1"], + }).success, + ).toBe(false); + }); +}); diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts new file mode 100644 index 000000000..0ae186dee --- /dev/null +++ b/src/lib/validations/vaccinations.ts @@ -0,0 +1,211 @@ +/** + * Vaccination request/response validation. + * + * Source of truth for the `/api/vaccinations*` wire contract; the OpenAPI + * registry reuses these schemas so the spec stays single-source. `userId` is + * NEVER a body field — it is narrowed from the session or Bearer in every + * route and fed to the Prisma `where`. + * + * Nothing is mandatory except the date and ONE identity arm. A dose saves with + * a day and either a catalogue pick or the person's own wording, and nothing + * else: no dose number, no batch code, no arm, no practice, no document. A + * required catalogue pick would make a 1987 Pass line unloggable, which is the + * whole population this feature exists for. + * + * Two places where the API is deliberately stricter than the column: + * + * - `occurredAt` refuses a future instant. A vaccination is logged after the + * fact; a planned booster is a reminder, not a record. The visit schema + * next door allows the future because a booked appointment genuinely is + * one; a dose that has not been given is not a dose. + * - `antigenSlug` must resolve in the catalogue this release ships. Storage + * stays tolerant so a backup written by an older version restores whole + * and its slug degrades to the free-text arm on screen; a write is the one + * moment the catalogue is authoritative, because the picker produced it. + */ +import { z } from "zod/v4"; + +import { VACCINE_CATALOG_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; + +const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); + +/** + * A day a dose can plausibly carry. + * + * Bounded on both sides: not before 1900, and not in the future beyond the + * clock skew a client's own "today" can plausibly show. The skew allowance is + * a day rather than minutes because the value is a DAY — a client in a + * timezone ahead of the server sends its own local date, and refusing that + * would make the form unusable east of here. + */ +const DAY_SKEW_MS = 24 * 60 * 60 * 1000; + +const doseInstant = z.iso.datetime({ offset: true }).refine( + (value) => { + const at = new Date(value).getTime(); + if (Number.isNaN(at)) return false; + if (at < Date.UTC(1900, 0, 1)) return false; + return at <= Date.now() + DAY_SKEW_MS; + }, + { + message: + "must be a day a dose was actually given (not before 1900, not in the future — a planned booster is a reminder, not a record)", + }, +); + +/* ── enum (mirrors the Prisma enum) ───────────────────────────────── */ + +export const vaccinationSiteEnum = z.enum([ + "LEFT_ARM", + "RIGHT_ARM", + "LEFT_THIGH", + "RIGHT_THIGH", + "ORAL", + "NASAL", + "OTHER", +]); + +export type VaccinationSiteInput = z.infer; + +const id = z.string().min(1).max(40); + +/** + * A slug the catalogue this release ships actually has. + * + * The message names the field rather than listing thirty-five slugs: the + * picker is the source of a legitimate value, so a slug arriving here that is + * not in the set is a client defect or a hand-written request, and neither is + * helped by an enumeration. + */ +const antigenSlug = z + .string() + .min(1) + .max(64) + .refine((value) => KNOWN_SLUGS.has(value), { + message: "must be a slug the vaccine catalogue offers", + }); + +/** Bounded at the link service's own cap, so an over-long list is a 422. */ +const linkIds = z.array(id).max(100).optional(); + +/* ── vaccination CRUD ─────────────────────────────────────────────── */ + +/** + * The either-arm rule, applied to whichever schema needs it. + * + * Written as a helper because create and update both need it and they need it + * to REPORT the same way: the issue is raised against both fields, so a client + * highlighting one field per issue highlights the pick and the free-text box + * rather than leaving the person guessing which of the two the server meant. + */ +const IDENTITY_MESSAGE = + "name the vaccine: pick one from the catalogue or type what the record says"; + +export const vaccinationCreateSchema = z + .object({ + occurredAt: doseInstant, + antigenSlug: antigenSlug.nullable().optional(), + /** The Pass's own wording, verbatim. May be a trade name — user data. */ + vaccineName: z.string().min(1).max(200).nullable().optional(), + doseNumber: z.number().int().min(1).max(20).nullable().optional(), + seriesDoses: z.number().int().min(1).max(10).nullable().optional(), + /** Chargenbezeichnung. A batch code, not a secret. */ + lotNumber: z.string().min(1).max(64).nullable().optional(), + site: vaccinationSiteEnum.nullable().optional(), + practitionerId: id.nullable().optional(), + encounterId: id.nullable().optional(), + /** Becomes `noteEncrypted`. Impfreaktionen live here. */ + note: z.string().max(2000).nullable().optional(), + documentIds: linkIds, + }) + .strict() + .superRefine((body, ctx) => { + if (body.antigenSlug || body.vaccineName) return; + for (const path of ["antigenSlug", "vaccineName"] as const) { + ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE }); + } + }); + +export type VaccinationCreate = z.infer; + +/** + * Edit a dose. Every field optional; an omitted field is left untouched. + * + * A body naming nothing is a 422 rather than a silent success: a PATCH that + * changed nothing and answered 200 is indistinguishable from one that worked. + * + * `occurredAt` is editable — a transcription typo is the common case, and the + * date is the field most likely to carry one. The identity rule is checked + * against the merged row in the route rather than here, because a PATCH that + * names only `lotNumber` says nothing about the identity and must not be + * refused for it. + */ +export const vaccinationUpdateSchema = z + .object({ + occurredAt: doseInstant.optional(), + antigenSlug: antigenSlug.nullable().optional(), + vaccineName: z.string().min(1).max(200).nullable().optional(), + doseNumber: z.number().int().min(1).max(20).nullable().optional(), + seriesDoses: z.number().int().min(1).max(10).nullable().optional(), + lotNumber: z.string().min(1).max(64).nullable().optional(), + site: vaccinationSiteEnum.nullable().optional(), + practitionerId: id.nullable().optional(), + encounterId: id.nullable().optional(), + note: z.string().max(2000).nullable().optional(), + documentIds: linkIds, + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "name at least one field to change", + }) + .refine( + (body) => + // Only when the edit clears BOTH arms in one call. Clearing one while + // the other stays is legal and the route checks the merged row. + !(body.antigenSlug === null && body.vaccineName === null), + { path: ["antigenSlug"], message: IDENTITY_MESSAGE }, + ); + +export type VaccinationUpdate = z.infer; + +/** + * List query. + * + * An Impfpass is a lifetime document but a small one, so the list is full and + * bounded rather than paged: the default `take` already covers a long life, + * and a client that wants less says so. + */ +export const vaccinationListQuerySchema = z + .object({ + antigenSlug: z.string().min(1).max(64).optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + limit: z.coerce.number().int().min(1).max(500).optional(), + }) + .strict() + .refine((q) => !(q.from && q.to) || q.from <= q.to, { + path: ["to"], + message: "to must be on or after from", + }); + +export type VaccinationListQuery = z.infer; + +/* ── links ────────────────────────────────────────────────────────── */ + +/** + * The one link family a dose has. + * + * A single-member enum rather than a bare constant, so the wire shape matches + * the encounter link route's and a second family — if this venture ever loses + * the argument against one — is an added member rather than a changed shape. + */ +export const vaccinationLinkTargetEnum = z.enum(["document"]); + +export const vaccinationLinkSchema = z + .object({ + targetKind: vaccinationLinkTargetEnum, + targetIds: z.array(id).min(1).max(100), + }) + .strict(); + +export type VaccinationLinkInput = z.infer; From 7ac81f3eed18601e01f7136f31ccbb7e861dcec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:27:36 +0200 Subject: [PATCH 08/12] feat(vaccinations): publish the routes, classify them, gate the module - four route files: list and create, read and edit and delete, restore, and the link pair; every delegable one is classified as health background, in each leg of the sharing guard, with its reason written - link and unlink stay bare, consistent with the document and visit link routes; the three are revisited as one decision - vaccinations becomes the nineteenth module key, default-on and surface-gated like medications: the data routes stay exempt so a restore keeps working and re-enabling finds every dose intact - a delegate's dose entries get their own sentence in the owner's activity view rather than reading as a generic change --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + src/__tests__/sharing-surface-guard.test.ts | 32 +- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../module-route-gate-inventory.test.ts | 20 ++ src/app/api/vaccinations/[id]/links/route.ts | 104 +++++++ .../api/vaccinations/[id]/restore/route.ts | 75 +++++ src/app/api/vaccinations/[id]/route.ts | 282 ++++++++++++++++++ src/app/api/vaccinations/route.ts | 219 ++++++++++++++ src/components/settings/modules-section.tsx | 3 + src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/modules/registry.ts | 27 ++ src/lib/record-activity/activity-verb.ts | 8 + tests/fixtures/v137/sharing-matrix.ts | 33 ++ .../doctor-report-dense-data.test.ts | 1 + .../doctor-report-sections.test.ts | 1 + 20 files changed, 853 insertions(+), 3 deletions(-) create mode 100644 src/app/api/vaccinations/[id]/links/route.ts create mode 100644 src/app/api/vaccinations/[id]/restore/route.ts create mode 100644 src/app/api/vaccinations/[id]/route.ts create mode 100644 src/app/api/vaccinations/route.ts diff --git a/messages/de.json b/messages/de.json index 3b3b32bb6..13b0a7861 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Nährstoffzufuhr", "description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus." + }, + "vaccinations": { + "label": "Impfungen", + "description": "Der Impfpass in strukturierter Form: welche Dosis wann gegeben wurde, in welcher Serie sie steht und wann die nächste Auffrischung ansteht." } }, "illness": { @@ -9944,6 +9948,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", diff --git a/messages/en.json b/messages/en.json index e4dd15b3e..585d15d14 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Nutrient intake", "description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default." + }, + "vaccinations": { + "label": "Vaccinations", + "description": "The vaccination record in structured form: which dose was given when, where it sits in its series, and when the next booster is due." } }, "illness": { @@ -9944,6 +9948,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", diff --git a/messages/es.json b/messages/es.json index f59d0bfaa..a966a48d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Ingesta de nutrientes", "description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto." + }, + "vaccinations": { + "label": "Vacunas", + "description": "La cartilla de vacunación en forma estructurada: qué dosis se administró y cuándo, en qué lugar de la serie está y cuándo toca el próximo refuerzo." } }, "illness": { @@ -9944,6 +9948,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", diff --git a/messages/fr.json b/messages/fr.json index c385161f5..e2c24c74b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Apports en nutriments", "description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut." + }, + "vaccinations": { + "label": "Vaccinations", + "description": "Le carnet de vaccination sous forme structurée : quelle dose a été administrée et quand, sa place dans la série, et la date du prochain rappel." } }, "illness": { @@ -9944,6 +9948,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", diff --git a/messages/it.json b/messages/it.json index b5e7ea5db..527bda760 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Assunzione di nutrienti", "description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita." + }, + "vaccinations": { + "label": "Vaccinazioni", + "description": "Il libretto vaccinale in forma strutturata: quale dose è stata somministrata e quando, in che punto della serie si trova e quando è previsto il prossimo richiamo." } }, "illness": { @@ -9944,6 +9948,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", diff --git a/messages/pl.json b/messages/pl.json index c754c729f..f1b6534d1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8523,6 +8523,10 @@ "nutrients": { "label": "Spożycie składników odżywczych", "description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone." + }, + "vaccinations": { + "label": "Szczepienia", + "description": "Książeczka szczepień w formie uporządkowanej: która dawka i kiedy została podana, w którym miejscu serii się znajduje i kiedy przypada następne przypomnienie." } }, "illness": { @@ -9944,6 +9948,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", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4af6c2557..529716083 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -935,6 +935,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.", }, + "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.", @@ -1136,6 +1144,10 @@ const DELEGABLE_ROUTES: Record = { 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.", @@ -1379,6 +1391,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "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": @@ -1505,6 +1519,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { 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: [], @@ -1861,8 +1885,12 @@ const ACTOR_ROUTES: Record = { * read-only profile summary makes the next explicit admission: 197 → 198. * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. * The mood day's linked figures, read-only, add one: 200 → 201. + * + * 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. 213 → 219. */ -const FROZEN_ENTRY_COUNT = 213; +const FROZEN_ENTRY_COUNT = 219; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2623,7 +2651,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 }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index f18f282b5..a21657ddb 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -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( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index b60133b32..cf730b806 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -140,6 +140,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // 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 = [ // 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("; diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts new file mode 100644 index 000000000..7a9b10442 --- /dev/null +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -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 { + 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"), +); diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts new file mode 100644 index 000000000..685e61657 --- /dev/null +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -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)); + }, +); diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts new file mode 100644 index 000000000..8a7c77bf2 --- /dev/null +++ b/src/app/api/vaccinations/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts new file mode 100644 index 000000000..46b207de1 --- /dev/null +++ b/src/app/api/vaccinations/route.ts @@ -0,0 +1,219 @@ +/** + * `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, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; + +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 { + 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 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); + + 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, + }, + }); + + return apiSuccess( + toVaccinationDTO(created, series.get(created.id) ?? []), + 201, + ); +} diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 606444432..f42a0b95f 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -53,6 +53,7 @@ import { Pill, Smile, Sparkles, + Syringe, TestTube, Thermometer, type LucideIcon, @@ -110,6 +111,8 @@ const MODULE_ICONS: Record = { 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() { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index fb6dacf08..73ca46678 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,7 @@ function moduleMap( inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 16546d4c3..a7edcfc44 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -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]; @@ -344,6 +363,14 @@ export const MODULE_REGISTRY: Readonly> = // 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 = new Set(MODULE_KEYS); diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index e64890544..fd279c055 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -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, diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 929e2f76e..2eeeeb2dd 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -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", diff --git a/tests/integration/doctor-report-dense-data.test.ts b/tests/integration/doctor-report-dense-data.test.ts index 6e52a4563..fac0f7a13 100644 --- a/tests/integration/doctor-report-dense-data.test.ts +++ b/tests/integration/doctor-report-dense-data.test.ts @@ -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. */ diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 7529ad80a..a034efd03 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -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 () => { From 82982038cbe6a089c45b0ffcfc868be114d435a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:36:08 +0200 Subject: [PATCH 09/12] feat(vaccinations): let a logged dose clear the boosters it answers - the dose is expanded to its component antigens first, so one Tdap entry settles a tetanus booster and a pertussis booster at once; matching the preparation's own slug would leave both standing - it runs inside the create transaction and nowhere else: no cron, no ingest hook, and an edit never re-runs it, so correcting a typo in a date cannot move a reminder - forward-only, which is what makes transcribing an old record safe: a page from decades ago never clears a booster that is due now - a free-text-only dose matches nothing; a name string is not evidence about what was in the syringe - an ordinary preventive-care reminder carries no antigen key and is structurally out of reach --- src/app/api/vaccinations/route.ts | 38 ++ src/lib/vaccinations/satisfy-matching.ts | 153 ++++++++ src/lib/vaccinations/service.ts | 19 + .../vaccination-booster-satisfy.test.ts | 362 ++++++++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 src/lib/vaccinations/satisfy-matching.ts create mode 100644 tests/integration/vaccination-booster-satisfy.test.ts diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 46b207de1..16d2be8b7 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -44,8 +44,10 @@ import { 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"); @@ -130,6 +132,11 @@ async function postVaccination(request: NextRequest): Promise { 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 @@ -169,6 +176,26 @@ async function postVaccination(request: NextRequest): Promise { 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, @@ -209,8 +236,19 @@ async function postVaccination(request: NextRequest): Promise { 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) ?? []), diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts new file mode 100644 index 000000000..8518b622c --- /dev/null +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index b07c0de3f..8d929ff5f 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -9,6 +9,7 @@ */ 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"; @@ -18,6 +19,9 @@ import { 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, @@ -157,6 +161,21 @@ export async function resolveOwnedPractitioner( }); } +/** + * 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 { + 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, diff --git a/tests/integration/vaccination-booster-satisfy.test.ts b/tests/integration/vaccination-booster-satisfy.test.ts new file mode 100644 index 000000000..8de0be3ef --- /dev/null +++ b/tests/integration/vaccination-booster-satisfy.test.ts @@ -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 { + 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()); + }); +}); From b97988279e6157769cfede888790f18838b62c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:43:59 +0200 Subject: [PATCH 10/12] feat(vaccinations): wire the query keys, the contract and the consent copy - a query-key module and a dependent bundle that also evicts the preventive-care root, because logging a dose settles a booster and the checkups list goes stale in the same tick - the OpenAPI module publishes the resolved series with the contract written into the description: render text from these numbers, never recompute them - the health-background consent sentence now names the immunization history in all six languages, so the widening is stated where it is agreed to rather than inferred from a code comment - no server-side cache helper: nothing cached reads a dose, and a sweep over caches nobody reads would look like coverage without being it --- docs/api/openapi.yaml | 898 ++++++++++++++++++++++++- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/vaccinations.ts | 367 ++++++++++ src/lib/query-keys/index.ts | 16 + src/lib/query-keys/vaccinations.ts | 42 ++ src/lib/sharing/scope.ts | 12 +- 12 files changed, 1309 insertions(+), 40 deletions(-) create mode 100644 src/lib/openapi/routes/vaccinations.ts create mode 100644 src/lib/query-keys/vaccinations.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..9b4beb85e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10322,6 +10322,422 @@ paths: "404": *a17 "422": *a3 "429": *a4 + /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. + parameters: + - in: query + name: antigenSlug + schema: + type: string + minLength: 1 + maxLength: 64 + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: The caller's immunization history. + content: + application/json: + schema: + $ref: "#/components/schemas/ListVaccinationsEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + 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: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + antigenSlug: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + vaccineName: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: 1 + maximum: 20 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: 1 + maximum: 10 + - type: "null" + lotNumber: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + practitionerId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + encounterId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + documentIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - occurredAt + additionalProperties: false + responses: + "201": + description: Dose logged. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateVaccinationEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The dose. + content: + application/json: + schema: + $ref: "#/components/schemas/GetVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": &a18 + description: Vaccination not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + 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. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + antigenSlug: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + vaccineName: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: 1 + maximum: 20 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: 1 + maximum: 10 + - type: "null" + lotNumber: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + practitionerId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + encounterId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + documentIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + additionalProperties: false + responses: + "200": + description: Dose updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + 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`." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + /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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Dose restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + /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." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetKind: + type: string + enum: + - document + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - targetKind + - targetIds + additionalProperties: false + responses: + "200": + description: The dose's linked pages after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/LinkVaccinationEnvelope" + "401": *a1 + "404": *a18 + "422": *a3 + "429": *a4 + 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. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetKind: + type: string + enum: + - document + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - targetKind + - targetIds + additionalProperties: false + responses: + "200": + description: The dose's linked pages after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/UnlinkVaccinationEnvelope" + "401": *a1 + "404": *a18 + "422": *a3 + "429": *a4 /api/illness/episodes: get: tags: @@ -10447,7 +10863,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": &a18 + "404": &a19 description: Illness episode not found (or owned by another user). content: application/json: @@ -10526,7 +10942,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10550,7 +10966,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -10577,7 +10993,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -10614,7 +11030,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -10665,7 +11081,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 post: @@ -10745,7 +11161,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -10772,7 +11188,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/insights: @@ -11045,7 +11461,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a6 - "404": &a19 + "404": &a20 description: Allergy not found (or owned by another user). content: application/json: @@ -11128,7 +11544,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -11152,7 +11568,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/family-history: @@ -11260,7 +11676,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": &a20 + "404": &a21 description: Family history entry not found (or owned by another user). content: application/json: @@ -11329,7 +11745,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11353,7 +11769,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 /api/documents/inbound: @@ -12473,7 +12889,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a22 + "409": &a23 description: A custom metric with this name already exists for the caller. content: application/json: @@ -12502,7 +12918,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a6 - "404": &a21 + "404": &a22 description: Custom metric not found (or owned by another user). content: application/json: @@ -12566,8 +12982,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a21 - "409": *a22 + "404": *a22 + "409": *a23 "422": *a3 "429": *a4 delete: @@ -12590,7 +13006,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -12634,7 +13050,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 post: @@ -12678,7 +13094,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -12728,7 +13144,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a23 + "404": &a24 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -12760,7 +13176,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a23 + "404": *a24 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -12930,7 +13346,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a24 + "403": &a25 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -12985,7 +13401,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a24 + "403": *a25 "422": *a3 "429": *a4 /api/nutrients/water: @@ -13012,7 +13428,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a24 + "403": *a25 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -13350,7 +13766,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a25 + "403": &a26 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -13375,7 +13791,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 post: @@ -13470,7 +13886,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such invitation for this account. content: @@ -13514,7 +13930,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such grant on this account's record. content: @@ -13555,7 +13971,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such grant held by this account. content: @@ -13854,7 +14270,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -15458,6 +15874,8 @@ components: type: boolean nutrients: type: boolean + vaccinations: + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -25707,6 +26125,9 @@ components: nutrients: type: boolean description: Whether the "nutrients" module is enabled for this account. + vaccinations: + type: boolean + description: Whether the "vaccinations" module is enabled for this account. required: - cycle - mood @@ -25726,6 +26147,7 @@ components: - inboundDocuments - mentalHealth - nutrients + - vaccinations additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; @@ -35163,6 +35585,418 @@ components: - data - error additionalProperties: false + ListVaccinationsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/VaccinationList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + VaccinationList: + type: object + properties: + vaccinations: + type: array + items: + $ref: "#/components/schemas/Vaccination" + required: + - vaccinations + additionalProperties: false + 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. + Vaccination: + type: object + properties: + id: + type: string + occurredAt: + type: string + antigenSlug: + anyOf: + - type: string + - type: "null" + vaccineName: + anyOf: + - type: string + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lotNumber: + anyOf: + - type: string + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + catalogEntry: + anyOf: + - $ref: "#/components/schemas/VaccinationCatalogEntry" + - type: "null" + series: + type: array + items: + $ref: "#/components/schemas/VaccinationSeriesPosition" + practitioner: + anyOf: + - $ref: "#/components/schemas/VaccinationPractitioner" + - type: "null" + encounter: + anyOf: + - $ref: "#/components/schemas/VaccinationEncounterStub" + - type: "null" + reminderId: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + documents: + type: array + items: + $ref: "#/components/schemas/VaccinationLinkedDocument" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - occurredAt + - antigenSlug + - vaccineName + - doseNumber + - seriesDoses + - lotNumber + - site + - catalogEntry + - series + - practitioner + - encounter + - reminderId + - note + - createdAt + - updatedAt + additionalProperties: false + 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." + VaccinationCatalogEntry: + type: object + properties: + slug: + type: string + atc: + type: string + category: + type: string + required: + - slug + - atc + - category + additionalProperties: false + 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." + VaccinationSeriesPosition: + type: object + properties: + antigen: + type: string + position: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + total: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + booster: + type: boolean + required: + - antigen + - position + - total + - booster + additionalProperties: false + 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." + VaccinationPractitioner: + type: object + properties: + id: + type: string + name: + type: string + specialty: + anyOf: + - type: string + - type: "null" + practice: + anyOf: + - type: string + - type: "null" + location: + anyOf: + - type: string + - type: "null" + phone: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - specialty + - practice + - location + - phone + - note + - createdAt + - updatedAt + additionalProperties: false + description: The practice that administered the dose, resolved rather than an id. Null when the record names none. + VaccinationEncounterStub: + type: object + properties: + id: + type: string + occurredAt: + type: string + kind: + type: string + required: + - id + - occurredAt + - kind + additionalProperties: false + description: The visit the dose was given at, enough to recognise it by. Null when no visit is linked. + VaccinationLinkedDocument: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + date: + anyOf: + - type: string + - type: "null" + redacted: + type: boolean + required: + - id + - label + - date + - redacted + additionalProperties: false + 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." + CreateVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteVaccinationEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RestoreVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LinkVaccinationEnvelope: + type: object + properties: + data: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/VaccinationLinkedDocument" + unknown: + type: array + items: + type: string + required: + - documents + - unknown + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UnlinkVaccinationEnvelope: + type: object + properties: + data: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/VaccinationLinkedDocument" + unknown: + type: array + items: + type: string + required: + - documents + - unknown + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ListIllnessEpisodesEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 13b0a7861..b24de7b7d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10031,7 +10031,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", diff --git a/messages/en.json b/messages/en.json index 585d15d14..72a715e43 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10031,7 +10031,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", diff --git a/messages/es.json b/messages/es.json index a966a48d7..a82f180c6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10031,7 +10031,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", diff --git a/messages/fr.json b/messages/fr.json index e2c24c74b..ff2c3a59e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10031,7 +10031,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", diff --git a/messages/it.json b/messages/it.json index 527bda760..6369b1b0f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10031,7 +10031,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", diff --git a/messages/pl.json b/messages/pl.json index f1b6534d1..4e6d99d20 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10031,7 +10031,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", diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index bbd5158f7..1d053c5ee 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...ocrPaths, ...biomarkerPaths, ...encounterPaths, + ...vaccinationPaths, ...illnessPaths, ...mentalHealthPaths, ...allergyPaths, diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts new file mode 100644 index 000000000..aa9a71c61 --- /dev/null +++ b/src/lib/openapi/routes/vaccinations.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index ed88535ff..e4f08f7c6 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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"; @@ -252,6 +254,20 @@ export const encounterDependentKeys = [ queryKeys.practitioners(), ]; +/** + * Keys invalidated when a dose changes. + * + * The preventive-care root rides along, and it is the whole reason this is a + * bundle rather than one key: logging a dose satisfies the booster reminders + * it answers, so the checkups list is stale the moment the dose saves. + * Evicting only the immunization list would leave a booster on screen that + * the server had already moved a decade out. + */ +export const vaccinationDependentKeys = [ + queryKeys.vaccinations(), + queryKeys.measurementReminders(), +]; + /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts new file mode 100644 index 000000000..60d8c10b3 --- /dev/null +++ b/src/lib/query-keys/vaccinations.ts @@ -0,0 +1,42 @@ +/** + * 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. + * + * The one server-side cache a dose CAN move belongs to somebody else: logging + * one satisfies a booster reminder, which is a preventive-care write. That + * eviction is the reminder module's, and the client bundle below invalidates + * the reminder keys for the same reason. + */ +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; diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index a76f96471..784297cf4 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -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. */ From 0a543abd187f76d6a2bfed5e334d69d515cccad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 15:51:54 +0200 Subject: [PATCH 11/12] refactor(vaccinations): drop the dependent-keys bundle nothing consumes yet The dead-code gate is right: a bundle with no caller is dead code. The capture surface brings it back with the mutation that needs it, and the key module says what that bundle will have to evict. --- src/lib/query-keys/index.ts | 14 -------------- src/lib/query-keys/vaccinations.ts | 11 +++++++---- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index e4f08f7c6..0ab335a29 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -254,20 +254,6 @@ export const encounterDependentKeys = [ queryKeys.practitioners(), ]; -/** - * Keys invalidated when a dose changes. - * - * The preventive-care root rides along, and it is the whole reason this is a - * bundle rather than one key: logging a dose satisfies the booster reminders - * it answers, so the checkups list is stale the moment the dose saves. - * Evicting only the immunization list would leave a booster on screen that - * the server had already moved a decade out. - */ -export const vaccinationDependentKeys = [ - queryKeys.vaccinations(), - queryKeys.measurementReminders(), -]; - /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts index 60d8c10b3..3c8ca4ce0 100644 --- a/src/lib/query-keys/vaccinations.ts +++ b/src/lib/query-keys/vaccinations.ts @@ -17,10 +17,13 @@ * like coverage. It belongs in the release that puts doses into one of those * payloads, alongside the reader that makes it necessary. * - * The one server-side cache a dose CAN move belongs to somebody else: logging - * one satisfies a booster reminder, which is a preventive-care write. That - * eviction is the reminder module's, and the client bundle below invalidates - * the reminder keys for the same reason. + * 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 = { /** From fa402f2f72efb422e0b731f522b347dc27ed939f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:47:50 +0200 Subject: [PATCH 12/12] test(vaccinations): drive the four new admissions through the manage matrix A frozen tag is a promise about code; the matrix is the one that proves behaviour. Each of the four vaccination mutations now has its own driver: a manager with a live grant changes the owner's record and the audit row names both people, the same person under a lesser grant is refused with nothing moved, and a caller who was never granted anything gets the same refusal. --- .../sharing-manage-handler-matrix.test.ts | 123 +++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index b687abd98..4d4261960 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob>( 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); }); });