merge(modules): skip reminders for disabled modules

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:16:24 +02:00
commit 3264dd2e86
5 files changed
+212 -2

No files matched your search

@@ -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<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(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<DispatchFn>(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<DispatchFn>(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 })],
+73 -2
View File
@@ -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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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;
+4
View File
@@ -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;
+54
View File
@@ -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<MeasurementReminderSummary> {
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)
+19
View File
@@ -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<MoodReminderSummary> {
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({