From 74bda80abb71a9cd44264f64acee0724ca40fcb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:18:33 +0200 Subject: [PATCH] feat(modules): exclude disabled-module sections from doctor report and FHIR export Resolve the per-user module map at report-build start and drop a disabled module's section/resources from the aggregated payload, so the PDF and the FHIR bundle reflect only the modules the user keeps. The module gate wins over the per-report section toggle: a module turned off cannot be shared even if its report toggle is on. Map report sections / FHIR resource groups to module keys: mood, sleep, cycle, labs via the existing section toggles; glucose panel + raw glucose series; recovery + stress wellness scores; workout activity series + strain score. Core clinical sections (weight, BP, pulse, medications) carry no module key and stay unconditional. FHIR resources inherit the exclusion because every builder derives from the gated aggregator output. --- src/lib/doctor-report-data.ts | 98 ++++++++++- .../doctor-report-sections.test.ts | 152 ++++++++++++++++++ 2 files changed, 247 insertions(+), 3 deletions(-) diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index f08da5c08..0f61620b9 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -36,6 +36,7 @@ import { type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; +import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { buildComplianceMedicationContext, lastNonSkippedTakenAt, @@ -483,6 +484,18 @@ export interface CollectDoctorReportOptions { * don't accidentally print a section the user opted out of. */ sections?: Partial; + /** + * v1.18.0 — resolved per-user module enable/disable map. When omitted the + * aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled + * module forces its report section / FHIR resources out of the payload, + * regardless of the user's per-report `sections` toggles: a user who turned + * off the Sleep module exports no sleep series, mood Observations, glucose + * panel, recovery/strain scores, cycle summary, or lab results for the + * modules they no longer keep. Core clinical sections (weight / BP / pulse / + * medications) carry no module key and are always included. Injectable for + * tests so the module ↔ section mapping can be asserted without a DB. + */ + moduleMap?: Record; } /** @@ -602,10 +615,33 @@ export async function collectDoctorReportData( // either way (the queries are cheap on indexed columns) and stripped // from the returned payload below — keeping a single source of // truth for the data shape regardless of toggle state. + // v1.18.0 — resolve the per-user module map up-front (once per build). A + // disabled module's section/resources are excluded from the export so the + // PDF + FHIR bundle reflect only the modules the user keeps. The map is + // ANDed over the user's per-report `sections` toggles: a disabled module + // forces its section off even if the report toggle says on (you can't share + // data for a module you turned off), while leaving the others untouched. + // Core clinical sections (weight / BP / pulse / medications) have no module + // key and stay unconditional. Injectable for tests. + const moduleMap = + options.moduleMap ?? (await resolveModuleMap(userId)); + const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...(options.sections ?? {}), }; + // Module gate: a disabled module wins over the report toggle. Each report + // section maps to its owning module key; core sections (bp/weight/pulse/ + // bmi/compliance) carry no key and are never gated here. + if (moduleMap.mood === false) sections.mood = false; + if (moduleMap.sleep === false) sections.sleep = false; + if (moduleMap.cycle === false) sections.cycle = false; + if (moduleMap.labs === false) sections.labs = false; + // Module gates with no `sections` key — applied directly to the data slices + // below (glucose panel, recovery/strain wellness scores, workout series). + const glucoseEnabled = moduleMap.glucose !== false; + const recoveryEnabled = moduleMap.recovery !== false; + const workoutsEnabled = moduleMap.workouts !== false; const [measurements, medications, intakeEvents, moodEntries, userProfile] = await Promise.all([ @@ -916,7 +952,12 @@ export async function collectDoctorReportData( // Per-context glucose stats + effective ranges (canonical mg/dL). const glucoseStats: Record = {}; const glucoseRanges: Record = {}; - const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE"); + // v1.18.0 — when the glucose module is disabled, no glucose row enters the + // panel: stats, ranges, and the clinical metrics all collapse to empty so the + // PDF + FHIR Observations carry no glucose for this user. + const glucoseRows = glucoseEnabled + ? measurements.filter((m) => m.type === "BLOOD_GLUCOSE") + : []; const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null; const profileForRange = { @@ -966,8 +1007,8 @@ export async function collectDoctorReportData( // `stats`, and `compliance` lets the PDF renderer treat "section // disabled" identically to "section had no rows" — both paths skip // the table. Mood is already null when disabled (we never queried). - const filteredByType = filterMeasurementKeys(byType, sections); - const filteredStats = filterMeasurementKeys(stats, sections); + const filteredByType = filterMeasurementKeys(byType, sections, moduleMap); + const filteredStats = filterMeasurementKeys(stats, sections, moduleMap); const filteredCompliance = sections.compliance ? compliance : {}; const filteredBmi = sections.bmi ? bmi : null; @@ -1085,6 +1126,19 @@ export async function collectDoctorReportData( // series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads // the SAME value the tile + iOS feed show — one number, one engine. const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => { + // v1.18.0 — module gate per score type. Recovery + stress are the + // recovery/readiness signals (recovery module); strain is the + // training-load signal derived from the workouts table (workouts module). + // A disabled owning module drops the score from the wellness summary and, + // since the FHIR exporter sources these Observations from + // `data.wellnessScores`, from the bundle too. + if (type === "STRAIN_SCORE" && !workoutsEnabled) return []; + if ( + (type === "RECOVERY_SCORE" || type === "STRESS_SCORE") && + !recoveryEnabled + ) { + return []; + } if (type === "RECOVERY_SCORE") { const summary = summariseCanonicalRecovery(measurements, reportTz); return summary ? [summary] : []; @@ -1234,14 +1288,52 @@ const MEASUREMENT_TYPE_SECTION: Record = { SLEEP_DURATION: "sleep", }; +/** + * v1.18.0 — per-measurement-type → owning module mapping for the types that + * carry no `sections` toggle but DO belong to a toggleable module. When the + * module is disabled, the type is stripped from `measurements` + `stats` so the + * series never reaches the PDF tables or a FHIR Observation. + * + * - `workouts` owns the activity / movement series (steps, active energy, + * distance, flights, the walking-gait + stair metrics) and the + * training-load `STRAIN_SCORE`. + * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`. + * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is + * gated where it is assembled; this strips the raw rows too). + * + * Core clinical types (BP / weight / pulse / body-composition / vitals) carry + * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride + * the `sections` mechanism instead. + */ +const MEASUREMENT_TYPE_MODULE: Record = { + ACTIVITY_STEPS: "workouts", + ACTIVE_ENERGY_BURNED: "workouts", + FLIGHTS_CLIMBED: "workouts", + WALKING_RUNNING_DISTANCE: "workouts", + WALKING_SPEED: "workouts", + WALKING_ASYMMETRY: "workouts", + WALKING_STEP_LENGTH: "workouts", + WALKING_DOUBLE_SUPPORT: "workouts", + SIX_MINUTE_WALK_DISTANCE: "workouts", + STAIR_ASCENT_SPEED: "workouts", + STAIR_DESCENT_SPEED: "workouts", + STRAIN_SCORE: "workouts", + RECOVERY_SCORE: "recovery", + STRESS_SCORE: "recovery", + BLOOD_GLUCOSE: "glucose", +}; + function filterMeasurementKeys( map: Record, sections: DoctorReportPrefs, + moduleMap: Record, ): Record { const out: Record = {}; for (const [type, value] of Object.entries(map)) { const sectionKey = MEASUREMENT_TYPE_SECTION[type]; if (sectionKey && sections[sectionKey] === false) continue; + const moduleKey = MEASUREMENT_TYPE_MODULE[type]; + if (moduleKey && moduleMap[moduleKey] === false) continue; out[type] = value; } return out; diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 7075e163e..8fea366f3 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -110,9 +110,60 @@ async function seedUserWithEveryDataType(username: string): Promise { unit: "bpm", measuredAt, }, + // v1.18.0 module-gated data types (no `sections` toggle of their own). + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + glucoseContext: "FASTING", + measuredAt, + }, + { + userId: user.id, + type: "RECOVERY_SCORE", + value: 72, + unit: "score", + source: "WHOOP", + measuredAt, + }, + { + userId: user.id, + type: "STRESS_SCORE", + value: 40, + unit: "score", + measuredAt, + }, + { + userId: user.id, + type: "STRAIN_SCORE", + value: 12, + unit: "score", + measuredAt, + }, + { + userId: user.id, + type: "ACTIVITY_STEPS", + value: 8200, + unit: "count", + measuredAt, + }, ], }); + // Lab result — module `labs` owns these; the `labs` section toggle is ON by + // default so a present `labs` module surfaces the panel. + await prisma.labResult.create({ + data: { + userId: user.id, + panel: "Lipids", + analyte: "LDL", + value: 110, + unit: "mg/dL", + takenAt: measuredAt, + }, + }); + await prisma.moodEntry.create({ data: { userId: user.id, @@ -249,6 +300,107 @@ describe("doctor-report — per-section toggles", () => { }); }); +describe("doctor-report — module enable/disable gating", () => { + // A fully-enabled module map (the default-on shape `resolveModuleMap` would + // return for an account that never toggled a module off). + const ALL_ON = { + cycle: true, + mood: true, + sleep: true, + glucose: true, + workouts: true, + recovery: true, + labs: true, + achievements: true, + coach: true, + insights: true, + doctorReport: true, + } as const; + + it("includes every module's data when all modules are enabled", async () => { + const userId = await seedUserWithEveryDataType("dr-modules-all-on"); + + const data = await collectDoctorReportData(userId, RANGE, { + // Opt mood in so the present-case asserts the full surface. + sections: { mood: true }, + moduleMap: { ...ALL_ON }, + }); + + expect(Object.keys(data.glucoseStats).length).toBeGreaterThan(0); + expect(data.measurements.ACTIVITY_STEPS).toBeDefined(); + expect(data.measurements.BLOOD_GLUCOSE).toBeDefined(); + expect(data.labResults).not.toBeNull(); + expect(data.mood).not.toBeNull(); + const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type); + expect(scoreTypes).toEqual( + expect.arrayContaining([ + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", + ]), + ); + }); + + it("excludes a disabled module's section/resources from the payload", async () => { + const userId = await seedUserWithEveryDataType("dr-modules-off"); + + const data = await collectDoctorReportData(userId, RANGE, { + // The user opted mood in at the report level, but turned the modules off + // — the module gate wins, so none of these surface. + sections: { mood: true, sleep: true, labs: true }, + moduleMap: { + ...ALL_ON, + glucose: false, + sleep: false, + workouts: false, + recovery: false, + labs: false, + mood: false, + }, + }); + + // Glucose panel collapses entirely (stats + raw series). + expect(data.glucoseStats).toEqual({}); + expect(data.glucoseRanges).toEqual({}); + expect(data.measurements.BLOOD_GLUCOSE).toBeUndefined(); + // Sleep series stripped (sections + module agree). + expect(data.measurements.SLEEP_DURATION).toBeUndefined(); + // Workout activity series stripped. + expect(data.measurements.ACTIVITY_STEPS).toBeUndefined(); + expect(data.stats.ACTIVITY_STEPS).toBeUndefined(); + // Labs panel gone. + expect(data.labResults).toBeNull(); + // Mood never assembled. + expect(data.mood).toBeNull(); + // Recovery + strain wellness scores dropped (recovery + workouts off). + const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type); + expect(scoreTypes).not.toContain("RECOVERY_SCORE"); + expect(scoreTypes).not.toContain("STRESS_SCORE"); + expect(scoreTypes).not.toContain("STRAIN_SCORE"); + // Core clinical sections stay unconditional. + expect(data.stats.WEIGHT).toBeDefined(); + expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined(); + expect(data.stats.PULSE).toBeDefined(); + expect(Object.keys(data.compliance).length).toBeGreaterThan(0); + }); + + it("gates recovery and workouts independently (recovery off, workouts on)", async () => { + const userId = await seedUserWithEveryDataType("dr-modules-recovery-off"); + + const data = await collectDoctorReportData(userId, RANGE, { + moduleMap: { ...ALL_ON, recovery: false }, + }); + + const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type); + // Recovery readiness scores gone… + expect(scoreTypes).not.toContain("RECOVERY_SCORE"); + expect(scoreTypes).not.toContain("STRESS_SCORE"); + // …but the workout-owned strain score + activity series stay. + expect(scoreTypes).toContain("STRAIN_SCORE"); + expect(data.measurements.ACTIVITY_STEPS).toBeDefined(); + }); +}); + describe("doctor-report — MedicationAdministration export cap", () => { it("caps the administration set at the configured ceiling and flags the truncation", async () => { // The default ceiling is 5000; pin the operator-configurable