mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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
This commit is contained in:
1 parent
f599eba671
commit
f28658c611
3 files changed
+270
-23
No files matched your search
@@ -0,0 +1,87 @@
|
||||
-- Vaccinations: the immunization log and the page it was transcribed from.
|
||||
--
|
||||
-- Until now a scanned Impfpass could be filed as a document and nothing more —
|
||||
-- no structured dose existed anywhere in the model. These two tables turn a
|
||||
-- Pass line into a record: one row per administered dose, and the link that
|
||||
-- keeps the scanned page beside it.
|
||||
--
|
||||
-- Additive, with one exception that is deliberate and small: the nullable
|
||||
-- `vaccination_antigen` column on `measurement_reminders`. It is the match key
|
||||
-- a logged dose uses to clear a booster reminder. No default, no backfill, no
|
||||
-- read path outside the vaccination create route, and the published reminder
|
||||
-- contract does not move.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "vaccination_site" AS ENUM ('LEFT_ARM', 'RIGHT_ARM', 'LEFT_THIGH', 'RIGHT_THIGH', 'ORAL', 'NASAL', 'OTHER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "measurement_reminders" ADD COLUMN "vaccination_antigen" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "vaccination_records" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"antigen_slug" TEXT,
|
||||
"vaccine_name" TEXT,
|
||||
"dose_number" INTEGER,
|
||||
"series_doses" INTEGER,
|
||||
"lot_number" TEXT,
|
||||
"site" "vaccination_site",
|
||||
"practitioner_id" TEXT,
|
||||
"encounter_id" TEXT,
|
||||
"reminder_id" TEXT,
|
||||
"note_encrypted" BYTEA,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "vaccination_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "vaccination_document_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"vaccination_id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "vaccination_document_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "vaccination_records_user_id_deleted_at_occurred_at_idx" ON "vaccination_records"("user_id", "deleted_at", "occurred_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "vaccination_records_user_id_antigen_slug_occurred_at_idx" ON "vaccination_records"("user_id", "antigen_slug", "occurred_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "vaccination_document_links_document_id_idx" ON "vaccination_document_links"("document_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "vaccination_document_links_user_id_idx" ON "vaccination_document_links"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "vaccination_document_links_vaccination_id_document_id_key" ON "vaccination_document_links"("vaccination_id", "document_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_practitioner_id_fkey" FOREIGN KEY ("practitioner_id") REFERENCES "practitioners"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_reminder_id_fkey" FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_vaccination_id_fkey" FOREIGN KEY ("vaccination_id") REFERENCES "vaccination_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+182
-23
@@ -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")
|
||||
}
|
||||
@@ -95,6 +95,7 @@ function reminderRow(
|
||||
nextDueAt: at,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
vaccinationAntigen: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
deletedAt: null,
|
||||
|
||||
Reference in new issue
Block a user