mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(modules): skip reminders for disabled modules
This commit is contained in:
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 })],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user