fix(reminders): gate ketone checkups on the Glucose module

A "Measure blood ketones" checkup kept sending its push, Telegram or
email with the Glucose module switched off, while the glucose checkup
beside it went quiet. The checkup job kept its own copy of the module
map, which knew glucose but not ketones.

The job now asks the one ownership table the rest of the app gates on.
That table did not list the four mental-wellbeing screenings, which only
the job's copy knew, so they join it under the mental-health module; a
screening score row now also follows that module in the day view and
search.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 15:10:53 +01:00
1 parent 7b6d518ca6
commit 6e8554fd37
6 files changed
+72 -30

No files matched your search

@@ -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,
@@ -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<DispatchFn>(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" })],
@@ -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.
+12 -29
View File
@@ -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;
@@ -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", () => {
+7
View File
@@ -109,6 +109,13 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
// 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