From 2da0159577a8faf43d2e565f20cda39759c09a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 21:21:12 +0200 Subject: [PATCH] feat(vaccinations): add the immunization history to the doctor report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IMMUNIZATIONS report leaf in the history group, gated both ways: the vaccinations module and the leaf selection each exclude independently (WR-7 seen red on the selection gate, reverted by content hash) - collector loads the whole live history (reference data, not windowed, like allergies) with server-resolved series positions; the PDF renders a compact per-dose table — date, vaccine, dose, lot — with no due-status or gap analysis - added to the default template so a new report offers it; stored selections and frozen share links never widen - leaf label and section copy in six locales --- messages/de.json | 10 ++- messages/en.json | 10 ++- messages/es.json | 10 ++- messages/fr.json | 10 ++- messages/it.json | 10 ++- messages/pl.json | 10 ++- .../clinical-records-notes-section.ts | 76 +++++++++++++++++++ src/lib/doctor-report-types.ts | 25 ++++++ .../__tests__/leaf-gating-sweep.test.ts | 14 ++++ src/lib/doctor-report/clinical-records.ts | 57 ++++++++++++++ src/lib/doctor-report/collect.ts | 8 ++ .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 9 ++- src/lib/report-selection/template.ts | 1 + 14 files changed, 239 insertions(+), 15 deletions(-) diff --git a/messages/de.json b/messages/de.json index 3c61e9416..e673efb71 100644 --- a/messages/de.json +++ b/messages/de.json @@ -290,7 +290,12 @@ "visitsColKind": "Art", "visitsColReason": "Anlass", "visitsColOutcome": "Ergebnis", - "visitsColConditions": "Erkrankungen" + "visitsColConditions": "Erkrankungen", + "immunizationsTitle": "Impfungen", + "immunizationsColDate": "Datum", + "immunizationsColVaccine": "Impfstoff", + "immunizationsColDose": "Dosis", + "immunizationsColLot": "Charge" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -9805,7 +9810,8 @@ "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", "scopeSensitive": "inkl. {names}", - "leafVisits": "Arztbesuche" + "leafVisits": "Arztbesuche", + "leafImmunizations": "Impfungen" }, "recordSharing": { "banner": { diff --git a/messages/en.json b/messages/en.json index 36f7d3bc3..dfc64f0e2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -290,7 +290,12 @@ "visitsColKind": "Kind", "visitsColReason": "Reason", "visitsColOutcome": "Outcome", - "visitsColConditions": "Conditions" + "visitsColConditions": "Conditions", + "immunizationsTitle": "Immunizations", + "immunizationsColDate": "Date", + "immunizationsColVaccine": "Vaccine", + "immunizationsColDose": "Dose", + "immunizationsColLot": "Lot" }, "errorBoundary": { "title": "Something went wrong", @@ -9805,7 +9810,8 @@ "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", "scopeCount": "{count} entries from {groups} areas", "scopeSensitive": "incl. {names}", - "leafVisits": "Visits" + "leafVisits": "Visits", + "leafImmunizations": "Immunizations" }, "recordSharing": { "banner": { diff --git a/messages/es.json b/messages/es.json index b44149579..fd7f228a1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -290,7 +290,12 @@ "visitsColKind": "Tipo", "visitsColReason": "Motivo", "visitsColOutcome": "Resultado", - "visitsColConditions": "Enfermedades" + "visitsColConditions": "Enfermedades", + "immunizationsTitle": "Vacunas", + "immunizationsColDate": "Fecha", + "immunizationsColVaccine": "Vacuna", + "immunizationsColDose": "Dosis", + "immunizationsColLot": "Lote" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -9805,7 +9810,8 @@ "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", "scopeSensitive": "incl. {names}", - "leafVisits": "Visitas médicas" + "leafVisits": "Visitas médicas", + "leafImmunizations": "Vacunas" }, "recordSharing": { "banner": { diff --git a/messages/fr.json b/messages/fr.json index 23131d60b..ba33b6f43 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -290,7 +290,12 @@ "visitsColKind": "Type", "visitsColReason": "Motif", "visitsColOutcome": "Résultat", - "visitsColConditions": "Maladies" + "visitsColConditions": "Maladies", + "immunizationsTitle": "Vaccinations", + "immunizationsColDate": "Date", + "immunizationsColVaccine": "Vaccin", + "immunizationsColDose": "Dose", + "immunizationsColLot": "Lot" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -9805,7 +9810,8 @@ "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", "scopeCount": "{count} éléments issus de {groups} domaines", "scopeSensitive": "dont {names}", - "leafVisits": "Consultations" + "leafVisits": "Consultations", + "leafImmunizations": "Vaccinations" }, "recordSharing": { "banner": { diff --git a/messages/it.json b/messages/it.json index 6dd96ed24..2f42b34e0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -290,7 +290,12 @@ "visitsColKind": "Tipo", "visitsColReason": "Motivo", "visitsColOutcome": "Esito", - "visitsColConditions": "Malattie" + "visitsColConditions": "Malattie", + "immunizationsTitle": "Vaccinazioni", + "immunizationsColDate": "Data", + "immunizationsColVaccine": "Vaccino", + "immunizationsColDose": "Dose", + "immunizationsColLot": "Lotto" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -9805,7 +9810,8 @@ "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", "scopeCount": "{count} voci da {groups} aree", "scopeSensitive": "incl. {names}", - "leafVisits": "Visite mediche" + "leafVisits": "Visite mediche", + "leafImmunizations": "Vaccinazioni" }, "recordSharing": { "banner": { diff --git a/messages/pl.json b/messages/pl.json index a02bacdf2..a4b274cc7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -290,7 +290,12 @@ "visitsColKind": "Rodzaj", "visitsColReason": "Powód", "visitsColOutcome": "Wynik", - "visitsColConditions": "Choroby" + "visitsColConditions": "Choroby", + "immunizationsTitle": "Szczepienia", + "immunizationsColDate": "Data", + "immunizationsColVaccine": "Szczepionka", + "immunizationsColDose": "Dawka", + "immunizationsColLot": "Seria" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -9805,7 +9810,8 @@ "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", "scopeCount": "{count} pozycji z {groups} obszarów", "scopeSensitive": "w tym {names}", - "leafVisits": "Wizyty lekarskie" + "leafVisits": "Wizyty lekarskie", + "leafImmunizations": "Szczepienia" }, "recordSharing": { "banner": { diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 3461dacdd..a082cf282 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -284,6 +284,82 @@ export function buildClinicalRecordsNotesSection( .finalY + 8; } + // The immunization history. Reference data (not time-windowed), populated + // when the `IMMUNIZATIONS` leaf is selected AND the `vaccinations` module is + // on AND live doses exist. A compact chronological table — date, vaccine + // (catalogue name or verbatim free text), dose display, lot — in the same + // calm factual register. No due-status, no gap analysis: the report + // reproduces the record, it does not adjudicate it. The dose display is + // composed from the server-resolved series numbers; nothing is re-derived. + if (data.immunizations && data.immunizations.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.immunizationsTitle"), margin, y); + y += 6; + + const doseDisplay = ( + series: (typeof data.immunizations)[number]["series"], + ): string => { + const primary = series[0]; + if (!primary) return "—"; + if (primary.booster) return t("vaccinations.series.booster"); + if (primary.total !== null) { + return t("vaccinations.series.ofTotal", { + position: primary.position, + total: primary.total, + }); + } + return t("vaccinations.series.doseN", { position: primary.position }); + }; + + const immunizationRows = data.immunizations.map((row) => [ + fmtDate(row.occurredAt), + row.antigenSlug + ? t(`vaccinations.catalog.${row.antigenSlug}`) + : (row.vaccineName ?? "—"), + doseDisplay(row.series), + row.lotNumber ?? "—", + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.immunizationsColDate"), + t("doctorReport.immunizationsColVaccine"), + t("doctorReport.immunizationsColDose"), + t("doctorReport.immunizationsColLot"), + ], + ], + body: immunizationRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.27.x — structured allergy / intolerance records. Reference data // (not time-windowed) the aggregator populates when the `allergies` // toggle is ON (default) and rows exist. Stored fields only — substance, diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index af2b7fc85..056cbdd1e 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -296,6 +296,31 @@ export interface DoctorReportData { /** Labels of the conditions this visit was filed against. */ conditionLabels: string[]; }> | null; + /** + * The immunization history. Reference data, not time-windowed — an Impfpass + * is a lifetime document and a doctor wants the whole of it, so this mirrors + * the allergies stance rather than the visit window. Populated when the + * `IMMUNIZATIONS` leaf is selected AND the `vaccinations` module is on AND at + * least one live dose exists; null/absent otherwise, and the PDF then skips + * the section. `series` carries the server-resolved position per component + * antigen so the renderer composes "N of M" without re-deriving it; the + * catalogue name resolves from `antigenSlug` at render, with `vaccineName` + * the free-text fallback. + */ + immunizations?: Array<{ + occurredAt: string; + antigenSlug: string | null; + vaccineName: string | null; + lotNumber: string | null; + site: string | null; + practitionerName: string | null; + series: Array<{ + antigen: string; + position: number; + total: number | null; + booster: boolean; + }>; + }> | null; illnessEpisodes?: Array<{ /** User-facing condition label (e.g. "Erkältung"). */ label: string; diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 18a428688..ffb179dd2 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -237,6 +237,19 @@ function seed() { reactionEncrypted: null, }, ]; + rows.vaccinationRecord = [ + { + id: "vacc-1", + occurredAt: new Date("2019-03-04T00:00:00.000Z"), + antigenSlug: "tetanus", + vaccineName: null, + lotNumber: "AB123", + seriesDoses: null, + doseNumber: 3, + site: "LEFT_ARM", + practitioner: { name: "Sample practice" }, + }, + ]; rows.familyHistoryEntry = [ { relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 }, ]; @@ -428,6 +441,7 @@ describe("zero read for unchosen leaves", () => { ["LAB_RESULTS", "labResult"], ["ILLNESS_EPISODES", "illnessEpisode"], ["VISITS", "encounter"], + ["IMMUNIZATIONS", "vaccinationRecord"], ["ANAMNESIS", "userHealthProfile"], ["ANAMNESIS", "healthProfileFactRevision"], ]; diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index 4ad0f456c..380896a91 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -11,6 +11,7 @@ * Split out of the aggregator with the selection rework. */ import { prisma } from "@/lib/db"; +import { deriveSeries } from "@/lib/vaccinations/series"; import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range"; import { getEvent } from "@/lib/logging/context"; import { decryptAllergyReaction } from "@/lib/doctor-report-helpers"; @@ -194,6 +195,62 @@ export async function loadVisits( return mapped.length > 0 ? mapped : null; } +/** + * The immunization history. Reference data, not time-windowed — an Impfpass is + * a lifetime document, so like allergies it ignores the report window. + * + * The series positions are derived server-side over the whole live set (the + * same derivation the list and the API use), so the report reproduces exactly + * the numbers the person sees on screen and no client re-derives them. No note + * column is read on this path — an Impfreaktion is the person's private note, + * not part of a handed-over record. + */ +export async function loadImmunizations( + userId: string, +): Promise { + const rows = await prisma.vaccinationRecord.findMany({ + where: { userId, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + select: { + id: true, + occurredAt: true, + antigenSlug: true, + vaccineName: true, + lotNumber: true, + seriesDoses: true, + doseNumber: true, + site: true, + practitioner: { select: { name: true } }, + }, + }); + if (rows.length === 0) return null; + + const series = deriveSeries( + rows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt, + antigenSlug: row.antigenSlug, + doseNumber: row.doseNumber, + seriesDoses: row.seriesDoses, + })), + ); + + return rows.map((row) => ({ + occurredAt: row.occurredAt.toISOString(), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName, + lotNumber: row.lotNumber, + site: row.site, + practitionerName: row.practitioner?.name ?? null, + series: (series.get(row.id) ?? []).map((position) => ({ + antigen: position.antigen, + position: position.position, + total: position.total, + booster: position.booster, + })), + })); +} + /** * Structured allergy records. Reference data, not time-windowed — a * penicillin allergy does not expire with the report window. diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 79c4ccc0f..50c9ca5fe 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -64,6 +64,7 @@ import { loadAnamnesis, loadFamilyHistory, loadIllnessEpisodes, + loadImmunizations, loadVisits, loadLabResults, } from "./clinical-records"; @@ -500,6 +501,7 @@ export async function collectDoctorReportData( labResults, illnessEpisodes, visits, + immunizations, allergies, familyHistory, anamnesis, @@ -516,6 +518,11 @@ export async function collectDoctorReportData( gate.admits("VISITS") ? loadVisits(userId, start, end) : Promise.resolve(null), + // Reference data, not windowed — the immunization history is a lifetime + // document, so the whole live set rides when the leaf and module admit it. + gate.admits("IMMUNIZATIONS") + ? loadImmunizations(userId) + : Promise.resolve(null), gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null), gate.admits("FAMILY_HISTORY") ? loadFamilyHistory(userId) @@ -585,6 +592,7 @@ export async function collectDoctorReportData( labResults, illnessEpisodes, visits, + immunizations, allergies, familyHistory, anamnesis, diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 27d0c73cf..cce014fe6 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -52,8 +52,8 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { expect(MEASUREMENT_LEAF_IDS).toHaveLength(77); - expect(STRUCTURED_LEAF_IDS).toHaveLength(15); - expect(ALL_LEAF_IDS).toHaveLength(92); + expect(STRUCTURED_LEAF_IDS).toHaveLength(16); + expect(ALL_LEAF_IDS).toHaveLength(93); }); it("resolves a label for every leaf", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index d65fa64d3..ce96feed5 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -47,6 +47,7 @@ export type StructuredLeafId = | "ALLERGIES" | "ILLNESS_EPISODES" | "VISITS" + | "IMMUNIZATIONS" | "FAMILY_HISTORY" | "MOOD" | "CYCLE" @@ -201,13 +202,14 @@ export const STRUCTURED_LEAF_GROUP: Record = { ALLERGIES: "history", ILLNESS_EPISODES: "history", VISITS: "history", + IMMUNIZATIONS: "history", FAMILY_HISTORY: "sensitive", MOOD: "sensitive", CYCLE: "sensitive", ANAMNESIS: "sensitive", }; -/** i18n label keys for the 15 structured leaves. */ +/** i18n label keys for the 16 structured leaves. */ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { PATIENT_IDENTITY: "reportSelection.leafPatientIdentity", INSURANCE: "reportSelection.leafInsurance", @@ -220,6 +222,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record = { ALLERGIES: "reportSelection.leafAllergies", ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes", VISITS: "reportSelection.leafVisits", + IMMUNIZATIONS: "reportSelection.leafImmunizations", FAMILY_HISTORY: "reportSelection.leafFamilyHistory", MOOD: "reportSelection.leafMood", CYCLE: "reportSelection.leafCycle", @@ -309,6 +312,10 @@ export const LEAF_MODULE: Partial> = { MEDICATION_ADMINISTRATIONS: "medications", MEDICATION_COMPLIANCE: "medications", GLP1_THERAPY: "medications", + // The immunization log is its own opt-out module: the leaf hides when the + // `vaccinations` module is off, and the deselected leaf hides regardless of + // the module — the two exclude independently, like `ILLNESS_EPISODES`. + IMMUNIZATIONS: "vaccinations", // VISITS is deliberately absent, like ALLERGIES beside it. A visit carries // no module of its own — it is core, like the checkups page it lives on — // and the whole report is already gated by `doctorReport`. Reading the diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts index 347b4e051..722efbbbe 100644 --- a/src/lib/report-selection/template.ts +++ b/src/lib/report-selection/template.ts @@ -38,6 +38,7 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [ "MEDICATION_COMPLIANCE", "ALLERGIES", "ILLNESS_EPISODES", + "IMMUNIZATIONS", ]; /** The template's i18n label — it is named on the button, never anonymous. */