mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
9b58394724
commit
2da0159577
14 files changed
+239
-15
No files matched your search
+8
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user