diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 7924a0a08..dc217cb1d 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -129,6 +129,18 @@ export interface DoctorReportData { /** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */ deliveryForm: string | null; }>; + /** + * v1.9.0 — set when {@link MAX_MEDICATION_ADMINISTRATIONS} trimmed the + * administration set. `total` is the full acted-intake count over the + * window; `included` is how many (the most-recent) survived the cap. + * Null/absent when nothing was trimmed. The FHIR builder discloses + * this in the document narrative so the export is honest about the + * omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck. + */ + medicationAdministrationsTruncation?: { + total: number; + included: number; + } | null; mood: DoctorReportMood | null; /** * v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at @@ -194,6 +206,21 @@ const MIN_RANGE_DAYS = 1; const MAX_RANGE_DAYS = 730; const DEFAULT_RANGE_DAYS = 90; +/** + * v1.9.0 — hard ceiling on the number of `MedicationAdministration` + * source rows materialised per export. The report window is already + * bounded at {@link MAX_RANGE_DAYS} (730 days), but a chronic medication + * dosed several times a day across that window — multiplied by several + * such medications — can still produce thousands of administration + * resources in a single Bundle (e.g. 4×/day × 730 days ≈ 2 920 per med). + * Cap at the MOST-RECENT 1 000 acted intakes overall: that preserves the + * recent-adherence picture a clinician cares about while bounding the + * in-memory array and the serialised Bundle. When the cap trims rows the + * aggregator flags it so the FHIR narrative can disclose the truncation + * rather than silently dropping history. + */ +const MAX_MEDICATION_ADMINISTRATIONS = 1000; + export interface DoctorReportRange { /** Start of the reporting window (UTC, inclusive). */ start: Date; @@ -508,6 +535,20 @@ export async function collectDoctorReportData( }); } + // v1.9.0 — bound the administration set. `intakeEvents` is ordered + // `scheduledFor: asc`, so the most-recent acted rows are at the tail; + // keep the last N and flag the trim so the FHIR narrative can disclose + // it. The omitted rows are the OLDEST in the window — recent adherence + // is preserved intact. + const totalAdministrations = medicationAdministrations.length; + const medicationAdministrationsTruncated = + totalAdministrations > MAX_MEDICATION_ADMINISTRATIONS; + const cappedAdministrations = medicationAdministrationsTruncated + ? medicationAdministrations.slice( + totalAdministrations - MAX_MEDICATION_ADMINISTRATIONS, + ) + : medicationAdministrations; + // Mood summary. const moodScores = moodEntries.map((e) => e.score); const mood: DoctorReportMood | null = @@ -724,7 +765,10 @@ export async function collectDoctorReportData( label: s.label, })), })), - medicationAdministrations, + medicationAdministrations: cappedAdministrations, + medicationAdministrationsTruncation: medicationAdministrationsTruncated + ? { total: totalAdministrations, included: cappedAdministrations.length } + : null, mood, glp1, }; diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index c3aa8a1b7..4605ba42e 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -304,6 +304,47 @@ describe("buildFhirDocumentBundle", () => { } }); + it("discloses an administration truncation in the Composition narrative", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "INJECTION", + }, + ], + medicationAdministrationsTruncation: { total: 2920, included: 1000 }, + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource; + if (composition.resourceType === "Composition") { + expect(composition.text?.div).toContain( + "Medication administrations truncated: showing the most recent 1000 of 2920", + ); + } + }); + + it("omits the truncation sentence when nothing was capped", () => { + const bundle = buildFhirDocumentBundle( + makeData({ medicationAdministrationsTruncation: null }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource; + if (composition.resourceType === "Composition") { + expect(composition.text?.div).not.toContain("truncated"); + } + }); + it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 2578c6912..2dea9f6ca 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -534,10 +534,19 @@ export function buildFhirDocumentBundle( } // --- Composition (leading "cover" resource) ---------------------------- + // v1.9.0 — when the aggregator capped the administration set, disclose + // it in the narrative so the export is honest: it carries the + // most-recent N of M acted intakes, the oldest having been omitted. + const truncation = data.medicationAdministrationsTruncation; const narrativeText = [ `Health record for ${escapeXml(displayName ?? "patient")}.`, `Reporting period ${data.period.start.slice(0, 10)} to ${data.period.end.slice(0, 10)}.`, `${observationRefs.length} observation(s), ${medicationRefs.length} medication(s), ${administrationRefs.length} administration(s).`, + ...(truncation + ? [ + `Medication administrations truncated: showing the most recent ${truncation.included} of ${truncation.total} recorded; older entries omitted.`, + ] + : []), ].join(" "); const composition: FhirComposition = { diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index d584f255e..73ecdf2da 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -239,3 +239,59 @@ describe("doctor-report — per-section toggles", () => { expect(Object.keys(data.compliance).length).toBeGreaterThan(0); }); }); + +describe("doctor-report — MedicationAdministration export cap", () => { + it("caps the administration set at 1000 most-recent rows and flags the truncation", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "dr-admin-cap", + email: "dr-admin-cap@example.test", + heightCm: 180, + }, + }); + const med = await prisma.medication.create({ + data: { userId: user.id, name: "Metformin", dose: "500 mg", active: true }, + }); + + // 1001 acted (taken) intakes spread across the window so the cap + // must trim exactly one — the OLDEST — keeping the most-recent 1000. + const rangeMs = RANGE.end.getTime() - RANGE.start.getTime(); + const rows = Array.from({ length: 1001 }, (_, i) => { + const at = new Date( + RANGE.start.getTime() + Math.floor((rangeMs * i) / 1001), + ); + return { + userId: user.id, + medicationId: med.id, + scheduledFor: at, + takenAt: at, + }; + }); + await prisma.medicationIntakeEvent.createMany({ data: rows }); + + const data = await collectDoctorReportData(user.id, RANGE); + + expect(data.medicationAdministrations).toBeDefined(); + expect(data.medicationAdministrations?.length).toBe(1000); + expect(data.medicationAdministrationsTruncation).toEqual({ + total: 1001, + included: 1000, + }); + // The OLDEST row was dropped — the earliest surviving administration + // is strictly after the window start. + const first = data.medicationAdministrations?.[0]; + expect(new Date(first!.effectiveAt).getTime()).toBeGreaterThan( + RANGE.start.getTime(), + ); + }); + + it("leaves the set untouched (no truncation flag) below the cap", async () => { + const userId = await seedUserWithEveryDataType("dr-admin-no-cap"); + + const data = await collectDoctorReportData(userId, RANGE); + + expect(data.medicationAdministrations?.length).toBe(1); + expect(data.medicationAdministrationsTruncation ?? null).toBeNull(); + }); +});