diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index b305c94b7..cf865896c 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2309,7 +2309,7 @@ "lib/jobs/lab-biomarker-backfill.ts": 2, "lib/jobs/mean-consolidation.ts": 1, "lib/jobs/measurement-fold-repair.ts": 1, - "lib/jobs/measurement-reminder.ts": 14, + "lib/jobs/measurement-reminder.ts": 13, "lib/jobs/med-notes-encryption-backfill.ts": 2, "lib/jobs/medication-intake-import.ts": 1, "lib/jobs/medication-inventory-expire.ts": 1, diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 4670b9a83..f2176d8b8 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -590,6 +590,25 @@ describe("runMeasurementReminderTick", () => { expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); }); + it("skips a ketone checkup when the Glucose module is off, like glucose", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: "BLOOD_KETONES" })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => false); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch, isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + }); + it("v1.18.0 — a core-vital (BP) reminder never consults the module gate and dispatches", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: "BLOOD_PRESSURE_SYS" })], diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index dfc04c2b1..b16b7f8f3 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -186,6 +186,25 @@ describe("runReminderSatisfyForUser", () => { expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); }); + it("leaves a ketone checkup alone while the Glucose module is off", async () => { + const { prisma, updates } = makePrisma({ + reminders: [reminderRow({ measurementType: "BLOOD_KETONES" })], + measurement: { measuredAt: new Date("2026-06-14T18:00:00Z") }, + }); + const isModuleEnabled = vi.fn(async () => false); + + const summary = await runReminderSatisfyForUser( + prisma as never, + "u1", + new Date(), + { isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(1); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + expect(updates).toHaveLength(0); + }); + it("satisfies a screening reminder from the server-written score row (v1.27.6)", async () => { // A completed PHQ-9 check-in writes a COMPUTED PHQ9_SCORE measurement; // the screening reminder resolves from that row like any typed reminder. diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index cc3670df1..fbf0ed302 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -50,6 +50,7 @@ import { } from "@/lib/notifications/reminder-dedup"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import type { ModuleKey } from "@/lib/modules/registry"; import { byHourTimesOfDay, @@ -69,33 +70,15 @@ import { } from "@/lib/encounters/appointment-reminder"; /** - * v1.18.0 — map a reminder's `measurementType` to the toggleable module - * that owns it, or `null` when the type is a CORE domain (weight, blood - * pressure, pulse, body composition) or has no module of its own. A - * `null` reminder type (free-text Vorsorge entry) also yields `null`. - * - * Only the secondary-domain types carry a gate: glucose, sleep, and the - * mental-wellbeing screenings (v1.27.6 — the PHQ-9 / GAD-7 module is - * opt-in, so a screening reminder must fall silent the moment the module - * is off). Everything else is core and dispatches regardless — disabling - * a module must never silence a core-vital reminder. + * The toggleable module a reminder's `measurementType` belongs to, from the + * one ownership table (`measurement-scope.ts`): glucose and ketones, sleep, + * the mental-wellbeing screenings. A reminder of such a type falls silent + * the moment its module is off. A core vital (weight, blood pressure, pulse, + * body composition) and a free-text checkup (`null` type) have no module + * and dispatch regardless: disabling a module never silences them. */ -function moduleForMeasurementType( - type: MeasurementType | null, -): ModuleKey | null { - switch (type) { - case "BLOOD_GLUCOSE": - return "glucose"; - case "SLEEP_DURATION": - return "sleep"; - case "PHQ9_SCORE": - case "GAD7_SCORE": - case "WHO5_SCORE": - case "SCI_SCORE": - return "mentalHealth"; - default: - return null; - } +function reminderModule(type: MeasurementType | null): ModuleKey | null { + return type === null ? null : moduleForMeasurementType(type); } /** @@ -437,12 +420,12 @@ export async function runMeasurementReminderTick( summary.inWindow += 1; // v1.18.0 module gate — a reminder whose measurement type belongs to - // a toggleable module (glucose / sleep) must not fire once the user + // a toggleable module (`reminderModule`) must not fire once the user // turns that module off. Core-vital reminders (weight / BP / pulse / // body comp) and free-text reminders map to no module and are never // gated. Checked after the due + window gates so the gate read only // fires for reminders that would otherwise dispatch this tick. - const gatedModule = moduleForMeasurementType(reminder.measurementType); + const gatedModule = reminderModule(reminder.measurementType); if ( gatedModule !== null && !(await moduleGate(reminder.user.id, gatedModule)) @@ -633,7 +616,7 @@ export async function runReminderSatisfyForUser( // Module toggle — no engine activity for a reminder whose type // belongs to a disabled module. Core-vital + free-text reminders map // to no module and are never gated. - const gatedModule = moduleForMeasurementType(reminder.measurementType); + const gatedModule = reminderModule(reminder.measurementType); if (gatedModule !== null && !(await moduleGate(userId, gatedModule))) { summary.skippedModuleDisabled += 1; continue; diff --git a/src/lib/modules/__tests__/measurement-scope.test.ts b/src/lib/modules/__tests__/measurement-scope.test.ts index c9bc9eda8..d231286fb 100644 --- a/src/lib/modules/__tests__/measurement-scope.test.ts +++ b/src/lib/modules/__tests__/measurement-scope.test.ts @@ -122,6 +122,20 @@ describe("module→data ownership table", () => { expect(moduleForMeasurementType("CARDIO_LOAD")).toBe("workouts"); expect(moduleForMeasurementType("ANS_CHARGE")).toBe("recovery"); expect(moduleForMeasurementType("CARDIO_RECOVERY")).toBe("recovery"); + expect(moduleForMeasurementType("BLOOD_KETONES")).toBe("glucose"); + }); + + it("gives every module-owned checkup type the module the checkup job gates on", () => { + // The checkup job reads this table; a screening checkup must fall silent + // with the mental-health module, a ketone one with Glucose. + for (const type of [ + "PHQ9_SCORE", + "GAD7_SCORE", + "WHO5_SCORE", + "SCI_SCORE", + ] as const) { + expect(moduleForMeasurementType(type), type).toBe("mentalHealth"); + } }); it("leaves the core clinical figures unowned on purpose", () => { diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 122ba1757..576a7e051 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -109,6 +109,13 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = // belong to the glucose module; they stay off the Coach snapshot, so the // scope token cannot reach them. BLOOD_KETONES: "glucose", + // The mental-wellbeing screenings: the score row a completed check-in + // writes. Off the Coach snapshot and MCP by construction, so layer 1 + // never reaches them; a screening checkup follows the module too. + PHQ9_SCORE: "mentalHealth", + GAD7_SCORE: "mentalHealth", + WHO5_SCORE: "mentalHealth", + SCI_SCORE: "mentalHealth", // The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD // fallback for an account that only has ring/strap nightly RMSSD rows, so // leaving this unowned would have let the fallback path serve exactly the