diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index d64cd34aa..22f3ad6fe 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -295,6 +295,68 @@ describe("runMeasurementReminderTick", () => { ); }); + it("v1.18.0 — skips a glucose reminder when the Glucose module is off, and advances", async () => { + const { prisma, updates } = makePrisma({ + reminders: [reminder({ measurementType: "BLOOD_GLUCOSE" })], + 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(summary.dispatched).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + // Advances past the cycle so it does not re-evaluate every tick. + expect(updates).toHaveLength(1); + expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + }); + + it("v1.18.0 — still dispatches a glucose reminder when the Glucose module is on", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: "BLOOD_GLUCOSE" })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => true); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch, isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(0); + expect(summary.dispatched).toBe(1); + 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" })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => false); // even if everything is "off" + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch, isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(0); + expect(summary.dispatched).toBe(1); + // A core domain has no ModuleKey — the gate is never consulted. + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); + it("skips a reminder outside its notify-hour window", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/__tests__/mood-reminder.test.ts b/src/lib/jobs/__tests__/mood-reminder.test.ts index 448ea8528..0fb0ad20a 100644 --- a/src/lib/jobs/__tests__/mood-reminder.test.ts +++ b/src/lib/jobs/__tests__/mood-reminder.test.ts @@ -46,6 +46,14 @@ const NO_CHANNEL: DispatchOutcome = { channelsSucceeded: 0, }; +/** + * v1.18.0 — default module-gate stub for the dispatch-path tests: every + * module reads as enabled, so these tests exercise the window / ledger / + * locale contract unchanged. The disabled-module skip has its own test + * that injects a gate returning `false`. + */ +const MODULE_ON = async () => true; + interface FakePrismaState { candidates: Array<{ id: string; @@ -254,6 +262,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -292,6 +301,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, at09, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -317,6 +327,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -338,6 +349,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -357,6 +369,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, outOfWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -374,7 +387,10 @@ describe("runMoodReminderTick", () => { const prisma = makePrisma(state); const dispatch = vi.fn(async () => OK); - await runMoodReminderTick(prisma as never, inWindow, { dispatch }); + await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled: MODULE_ON, + }); expect(prisma.user.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { moodReminderEnabled: true } }), @@ -395,6 +411,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -417,6 +434,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -438,6 +456,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -463,6 +482,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); // The dispatcher delivered (OK), the ledger insert hit P2002. The @@ -492,6 +512,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -514,6 +535,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(2); @@ -526,6 +548,52 @@ describe("runMoodReminderTick", () => { expect(titles).toContain("Log your mood"); }); + it("v1.18.0 — skips a user who turned the Mood module off", async () => { + const state: FakePrismaState = { + candidates: [{ id: "u-off", timezone: "Europe/Berlin", locale: "de" }], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => OK); + // Module disabled for this user. + const isModuleEnabled = vi.fn(async () => false); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled, + }); + + expect(summary.skippedModuleDisabled).toBe(1); + expect(summary.dispatched).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(state.dispatches).toHaveLength(0); + expect(isModuleEnabled).toHaveBeenCalledWith("u-off", "mood"); + }); + + it("v1.18.0 — still dispatches when the Mood module is enabled", async () => { + const state: FakePrismaState = { + candidates: [{ id: "u-on", timezone: "Europe/Berlin", locale: "de" }], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => true); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled, + }); + + expect(summary.skippedModuleDisabled).toBe(0); + expect(summary.dispatched).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("u-on", "mood"); + }); + it("dispatches per-user locale for fr/es/it/pl (regression: resolver dropped 4/6)", async () => { const state: FakePrismaState = { candidates: [ @@ -541,7 +609,10 @@ describe("runMoodReminderTick", () => { const prisma = makePrisma(state); const dispatch = vi.fn(async () => OK); - await runMoodReminderTick(prisma as never, inWindow, { dispatch }); + await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled: MODULE_ON, + }); const titles = dispatch.mock.calls.map((c) => c[0].title); const enTitle = buildMoodReminderPayload("en").title; diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index 24bc8c809..3e5e5c3df 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -364,6 +364,10 @@ export async function runCycleReminderTick( // Gate ENTIRELY on cycle tracking being enabled (gender-derived or // explicit opt-in) — a disabled account never receives a cycle push. + // v1.18.0 — this IS the cycle module gate: the `cycle` ModuleKey + // delegates to `isCycleEnabled`, so turning the Cycle module off + // here is the same single source of truth the gate reads. No extra + // `isModuleEnabled(userId, "cycle")` round-trip is needed. if (!isCycleEnabled(user.gender, profile)) { summary.skippedOutsideWindow += 1; continue; diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 2afa31687..230f398bb 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -36,11 +36,36 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; import { computeReminderNextDueAt, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; +/** + * 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 two secondary-domain types carry a gate: glucose and sleep. + * Everything else is core and dispatches regardless — disabling a module + * must never silence a core-vital reminder. + */ +function moduleForMeasurementType( + type: MeasurementType | null, +): ModuleKey | null { + switch (type) { + case "BLOOD_GLUCOSE": + return "glucose"; + case "SLEEP_DURATION": + return "sleep"; + default: + return null; + } +} + /** * Slack added to `now` when scanning for due reminders, so a `nextDueAt` * stamped just ahead of a tick still falls inside the same hour window. @@ -56,6 +81,7 @@ export interface MeasurementReminderSummary { autoResolved: number; skippedNotDue: number; skippedOutsideWindow: number; + skippedModuleDisabled: number; skippedClientManaged: number; skippedNoChannel: number; failed: number; @@ -133,9 +159,16 @@ export async function runMeasurementReminderTick( now: Date, options: { dispatch?: typeof dispatchNotification; + /** + * v1.18.0 module gate — injectable so the unit tests pin the + * disabled-module skip without the gate's DB reads. Defaults to the + * real `isModuleEnabled` resolver. + */ + isModuleEnabled?: typeof isModuleEnabled; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const moduleGate = options.isModuleEnabled ?? isModuleEnabled; const summary: MeasurementReminderSummary = { candidatesScanned: 0, @@ -144,6 +177,7 @@ export async function runMeasurementReminderTick( autoResolved: 0, skippedNotDue: 0, skippedOutsideWindow: 0, + skippedModuleDisabled: 0, skippedClientManaged: 0, skippedNoChannel: 0, failed: 0, @@ -249,6 +283,26 @@ 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 + // 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); + if (gatedModule !== null && !(await moduleGate(reminder.user.id, gatedModule))) { + getEvent()?.addMeta( + "measurement_reminder.skipped_module_disabled", + `${reminder.id}:${gatedModule}`, + ); + summary.skippedModuleDisabled += 1; + // Advance past this cycle so a disabled-module reminder does not pin + // the server tick re-evaluating the same overdue slot every 15 + // minutes for the rest of the day (the client-managed precedent). + await advanceNextDue(prisma, reminder, timezone, now); + continue; + } + // Per-user client-managed suppression (the medication precedent). if ( isMeasurementReminderClientManaged(reminder.user.notificationPrefs) diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts index 20aa58604..cc3a0fe3f 100644 --- a/src/lib/jobs/mood-reminder.ts +++ b/src/lib/jobs/mood-reminder.ts @@ -27,6 +27,7 @@ import { defaultLocale, locales } from "@/lib/i18n/config"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { DEFAULT_MOOD_REMINDER_HOUR, resolveMoodReminderHour, @@ -53,6 +54,7 @@ export interface MoodReminderSummary { skippedAlreadyLogged: number; skippedAlreadyDispatched: number; skippedOutsideWindow: number; + skippedModuleDisabled: number; skippedNoChannel: number; failed: number; } @@ -142,9 +144,16 @@ export async function runMoodReminderTick( now: Date, options: { dispatch?: typeof dispatchNotification; + /** + * v1.18.0 module gate — injectable so the unit tests pin the + * disabled-module skip without booting the gate's DB reads. Defaults + * to the real `isModuleEnabled` resolver. + */ + isModuleEnabled?: typeof isModuleEnabled; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const moduleGate = options.isModuleEnabled ?? isModuleEnabled; const summary: MoodReminderSummary = { candidatesScanned: 0, @@ -153,6 +162,7 @@ export async function runMoodReminderTick( skippedAlreadyLogged: 0, skippedAlreadyDispatched: 0, skippedOutsideWindow: 0, + skippedModuleDisabled: 0, skippedNoChannel: 0, failed: 0, }; @@ -181,6 +191,15 @@ export async function runMoodReminderTick( summary.inWindow += 1; + // v1.18.0 module gate — a user who turned the Mood module off never + // receives its reminder. Checked after the window gate so the gate + // read only fires for the in-window cohort, not every opted-in user. + if (!(await moduleGate(user.id, "mood"))) { + summary.skippedModuleDisabled += 1; + getEvent()?.addMeta("mood_reminder.skipped_module_disabled", user.id); + continue; + } + // Cheaper guard first: the ledger lookup is a single indexed row // while the mood-entry scan touches today's mood writes. const existingDispatch = await prisma.moodReminderDispatch.findUnique({