feat(vaccinations): add the immunization history to the doctor report

- 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
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 21:21:12 +02:00
1 parent 9b58394724
commit 2da0159577
14 files changed
+239 -15

No files matched your search

+8 -2
View File
@@ -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": {
+8 -2
View File
@@ -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": {
+8 -2
View File
@@ -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": {
+8 -2
View File
@@ -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": {
+8 -2
View File
@@ -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": {
+8 -2
View File
@@ -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": {
@@ -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,
+25
View File
@@ -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;
@@ -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"],
];
+57
View File
@@ -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<DoctorReportData["immunizations"]> {
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.
+8
View File
@@ -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,
@@ -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", () => {
+8 -1
View File
@@ -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<StructuredLeafId, ReportGroupId> = {
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<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
INSURANCE: "reportSelection.leafInsurance",
@@ -220,6 +222,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
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<Record<ReportLeafId, ModuleKey>> = {
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
+1
View File
@@ -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. */