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] 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,