diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx
index dbdbe72d1..680ea2223 100644
--- a/src/app/cycle/page.tsx
+++ b/src/app/cycle/page.tsx
@@ -10,18 +10,21 @@ import { CycleView } from "@/components/cycle/cycle-view";
/**
* v1.15.0 — the cycle vertical entry.
*
- * Gated on `cycleTrackingEnabled` from `GET /api/auth/me` (resolved
- * server-side from gender + the per-user opt-in). An unauthenticated visitor
- * is bounced to login; an authenticated account without the feature enabled
- * is bounced home (the nav entry is already hidden for them, so this only
- * catches a direct URL hit). Every `/api/cycle/*` route also enforces the
- * gate server-side, so this is a UX redirect, not the security boundary.
+ * Gated on the resolved `modules.cycle` flag from `GET /api/auth/me`
+ * (v1.18.0 — the per-user toggle AND the operator server-wide kill-switch;
+ * the operator-unaware `cycleTrackingEnabled` field is NOT used here so an
+ * operator-disabled instance bounces a direct URL hit too). An
+ * unauthenticated visitor is bounced to login; an authenticated account
+ * without the module enabled is bounced home (the nav entry is already
+ * hidden for them, so this only catches a direct URL hit). Every
+ * `/api/cycle/*` route also enforces the gate server-side, so this is a UX
+ * redirect, not the security boundary.
*/
export default function CyclePage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
- const enabled = user?.cycleTrackingEnabled === true;
+ const enabled = user?.modules?.cycle !== false;
useEffect(() => {
if (isLoading) return;
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 4336534f5..2b13d97e3 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -404,11 +404,16 @@ export default function InsightsPage() {
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
- extraTile={user?.cycleTrackingEnabled ? : undefined}
+ // v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle
+ // AND the operator server-wide kill-switch) so an operator-off
+ // instance never renders the ring nor fires its calendar read.
+ extraTile={
+ user?.modules?.cycle === true ? : undefined
+ }
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
- hideStrain={user?.cycleTrackingEnabled === true}
+ hideStrain={user?.modules?.cycle === true}
/>
),
"daily-briefing": flags.briefing ? (
diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts
index ecb2b5e37..96820f0d4 100644
--- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts
@@ -27,6 +27,16 @@ vi.mock("@/lib/insights/features", () => ({
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
+ // v1.18.0 — the coach cycle block gates through
+ // `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back it
+ // with the same resolved map the tests drive via `resolveModuleMap`.
+ isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
+ const key = args[1];
+ const map = await (resolveModuleMap as unknown as () => Promise<
+ Record
+ >)();
+ return map[key] !== false;
+ }),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
index 5f61bd35d..99aa27a74 100644
--- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
@@ -38,6 +38,16 @@ const featuresMock = extractFeatures as unknown as ReturnType;
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
+ // v1.18.0 — the coach cycle block gates through
+ // `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back it
+ // with the same resolved map the tests drive via `resolveModuleMap`.
+ isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
+ const key = args[1];
+ const map = await (resolveModuleMap as unknown as () => Promise<
+ Record
+ >)();
+ return map[key] !== false;
+ }),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts
index 32f1a208a..102241c92 100644
--- a/src/lib/ai/coach/__tests__/snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot.test.ts
@@ -37,6 +37,18 @@ const allModulesEnabled = (): Record =>
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
+ // v1.18.0 — the coach cycle block now gates through
+ // `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back the
+ // mock with the same resolved map the tests already drive via
+ // `resolveModuleMap`, so flipping `cycle` off in a fixture also closes the
+ // cycle block (operator/user kill-switch parity) without a DB read.
+ isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
+ const key = args[1];
+ const map = await (resolveModuleMap as unknown as () => Promise<
+ Record
+ >)();
+ return map[key] !== false;
+ }),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index dad38f8c3..3f2402b35 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -47,7 +47,7 @@ import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
-import { isCycleEnabled } from "@/lib/cycle/gate";
+import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
buildComplianceLedgerRows,
@@ -177,8 +177,9 @@ const CORE_CLUSTERS: ReadonlySet = new Set([
* build off the sleep signal alone.
*
* `cycle` is intentionally absent: its block already resolves through the
- * single-source-of-truth cycle gate (`isCycleEnabled`) below, exactly as
- * the W1 foundation prescribes. `coach` is the surface being narrated, not
+ * fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user
+ * toggle AND the operator server-wide kill-switch) below, exactly as the
+ * W1 foundation prescribes. `coach` is the surface being narrated, not
* a data domain. `labs` / `achievements` / `insights` / `doctorReport`
* own no coach-snapshot data domain.
*/
@@ -1037,15 +1038,16 @@ async function buildCoachSnapshotImpl(
now,
coachLocale,
);
- // v1.15 — cycle/phase block, gated on the resolved cycle toggle so a
+ // v1.15 — cycle/phase block, gated on the resolved cycle module so a
// non-cycle account issues no query (the helper short-circuits to null
// before any read for a disabled account). The block is descriptive only —
// current phase + day-of-cycle, the next predicted event (period range,
// fertile window goal-gated), and the headline phase-correlation finding.
- const cycleEnabled = isCycleEnabled(
- prefsRow?.gender,
- prefsRow?.cycleProfile ?? null,
- );
+ // v1.18.0 — the gate is the FULLY-resolved cycle module
+ // (`isCycleAvailableForUser` → the per-user toggle AND the operator
+ // server-wide kill-switch), so an operator-off instance never injects the
+ // cycle block into the coach prompt.
+ const cycleEnabled = await isCycleAvailableForUser(userId);
const cycleBlockPromise = cycleEnabled
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
diff --git a/src/lib/cycle/__tests__/gate.test.ts b/src/lib/cycle/__tests__/gate.test.ts
index df6dd2266..5f0fe6eff 100644
--- a/src/lib/cycle/__tests__/gate.test.ts
+++ b/src/lib/cycle/__tests__/gate.test.ts
@@ -1,9 +1,28 @@
/**
* v1.15.0 — cycle-tracking feature gate resolver.
+ * v1.18.0 — the route guard + the server-side availability helper now AND
+ * in the operator server-wide kill-switch via the module foundation.
*/
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
-import { isCycleEnabled, CYCLE_DISABLED_ERROR_CODE } from "../gate";
+const isModuleEnabledMock = vi.fn<(userId: string, key: string) => Promise>();
+const getOrCreateCycleProfileMock = vi.fn();
+
+vi.mock("@/lib/modules/gate", () => ({
+ isModuleEnabled: (userId: string, key: string) =>
+ isModuleEnabledMock(userId, key),
+}));
+vi.mock("@/lib/cycle/profile", () => ({
+ getOrCreateCycleProfile: (...args: unknown[]) =>
+ getOrCreateCycleProfileMock(...args),
+}));
+
+import {
+ isCycleEnabled,
+ isCycleAvailableForUser,
+ requireCycleEnabled,
+ CYCLE_DISABLED_ERROR_CODE,
+} from "../gate";
describe("isCycleEnabled", () => {
it("derives from gender when the toggle is null", () => {
@@ -39,3 +58,59 @@ describe("isCycleEnabled", () => {
expect(CYCLE_DISABLED_ERROR_CODE).toBe("cycle.disabled");
});
});
+
+describe("isCycleAvailableForUser (operator AND user — v1.18.0)", () => {
+ beforeEach(() => {
+ isModuleEnabledMock.mockReset();
+ getOrCreateCycleProfileMock.mockReset();
+ });
+
+ it("delegates to the cycle ModuleKey so the operator kill-switch is AND-ed in", async () => {
+ isModuleEnabledMock.mockResolvedValue(false);
+ await expect(isCycleAvailableForUser("u1")).resolves.toBe(false);
+ expect(isModuleEnabledMock).toHaveBeenCalledWith("u1", "cycle");
+ });
+
+ it("reports available when the module gate resolves true", async () => {
+ isModuleEnabledMock.mockResolvedValue(true);
+ await expect(isCycleAvailableForUser("u1")).resolves.toBe(true);
+ });
+});
+
+describe("requireCycleEnabled (route guard — v1.18.0)", () => {
+ beforeEach(() => {
+ isModuleEnabledMock.mockReset();
+ getOrCreateCycleProfileMock.mockReset();
+ getOrCreateCycleProfileMock.mockResolvedValue({
+ id: "cp-1",
+ cycleTrackingEnabled: true,
+ });
+ });
+
+ it("passes through the profile when the resolved module is enabled", async () => {
+ isModuleEnabledMock.mockResolvedValue(true);
+ const result = await requireCycleEnabled("u1", "FEMALE");
+ expect(result.enabled).toBe(true);
+ if (result.enabled) {
+ expect(result.profile).toMatchObject({ id: "cp-1" });
+ }
+ expect(isModuleEnabledMock).toHaveBeenCalledWith("u1", "cycle");
+ });
+
+ it("returns a 403 cycle.disabled envelope when the operator turned the module off", async () => {
+ // Per-user toggle is on (profile.cycleTrackingEnabled = true) — the only
+ // thing closing the gate is the operator server-wide kill-switch routed
+ // through the module foundation.
+ isModuleEnabledMock.mockResolvedValue(false);
+ const result = await requireCycleEnabled("u1", "FEMALE");
+ expect(result.enabled).toBe(false);
+ if (!result.enabled) {
+ expect(result.response.status).toBe(403);
+ const body = (await result.response.json()) as {
+ error: string | null;
+ meta?: { errorCode?: string };
+ };
+ expect(body.meta?.errorCode).toBe(CYCLE_DISABLED_ERROR_CODE);
+ }
+ });
+});
diff --git a/src/lib/cycle/gate.ts b/src/lib/cycle/gate.ts
index 8f4ce1062..839ff1e02 100644
--- a/src/lib/cycle/gate.ts
+++ b/src/lib/cycle/gate.ts
@@ -5,12 +5,22 @@
* (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and
* (b) a gender-derived default when that toggle is NULL.
*
- * `isCycleEnabled` is the single source of truth. The server gender enum
- * ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys on
- * `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit
+ * `isCycleEnabled` is the per-user (LAYER 2) source of truth. The server
+ * gender enum ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys
+ * on `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit
* `true`/`false` overrides the derivation (so a non-FEMALE account can
* opt in and a FEMALE account can opt out).
*
+ * v1.18.0 — `isCycleEnabled` is the USER layer only and stays pure: the
+ * module foundation's `resolveModuleEnabled` AND-s it with the operator
+ * server-wide kill-switch (LAYER 1). Every SERVER-SIDE consumer that wants
+ * the fully-resolved decision (routes, the cycle-reminder cron, the coach
+ * cycle block, the import accumulator) must go through
+ * `isCycleAvailableForUser` / `requireCycleEnabled`, which delegate to
+ * `isModuleEnabled(userId, "cycle")` so an operator-off instance suppresses
+ * the whole vertical automatically. Clients read `user.modules.cycle`
+ * (already AND-ed) rather than the operator-unaware `cycleTrackingEnabled`.
+ *
* NOTE: the gender enum normalisation (adding `OTHER` / lowercasing) is a
* separate, iOS-blocked decision (migration 0131, reserved). Until that
* lands, `OTHER` / non-binary accounts gate purely on the explicit
@@ -18,11 +28,29 @@
*/
import { apiError } from "@/lib/api-response";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
+import { isModuleEnabled } from "@/lib/modules/gate";
import type { CycleProfile } from "@/generated/prisma/client";
/** Wire `errorCode` the iOS retry classifier branches on. */
export const CYCLE_DISABLED_ERROR_CODE = "cycle.disabled";
+/**
+ * Fully-resolved cycle availability for an account: the per-user toggle
+ * (`isCycleEnabled`) AND-ed with the operator server-wide kill-switch
+ * (`AppSettings.moduleAvailabilityJson.cycle`). This delegates to the
+ * module foundation's `isModuleEnabled(userId, "cycle")`, which routes the
+ * `cycle` ModuleKey back through `isCycleEnabled` for the user layer — so
+ * there is exactly one source of truth and no duplicated operator logic.
+ *
+ * Every SERVER-SIDE cycle consumer (the cron, the coach cycle block, the
+ * import accumulator) should call this rather than the operator-unaware
+ * `isCycleEnabled`, so an operator-disabled instance suppresses the whole
+ * vertical automatically.
+ */
+export function isCycleAvailableForUser(userId: string): Promise {
+ return isModuleEnabled(userId, "cycle");
+}
+
/**
* Resolve whether cycle tracking is enabled for an account.
*
@@ -51,16 +79,23 @@ export type CycleGateResult =
/**
* Enforce the gate for a `/api/cycle/*` route. Lazily upserts the
- * `CycleProfile` (so the toggle row exists), resolves `isCycleEnabled`,
- * and returns a 403 `cycle.disabled` envelope when the account may not
- * write/read cycle data — even with a valid Bearer token.
+ * `CycleProfile` (so the toggle row exists), resolves the FULLY two-layer
+ * decision — the per-user toggle (`isCycleEnabled`) AND the operator
+ * server-wide kill-switch — and returns a 403 `cycle.disabled` envelope
+ * when the account may not write/read cycle data, even with a valid Bearer
+ * token. The `gender` argument stays in the signature for call-site
+ * symmetry; the resolved decision reads gender through the module gate.
*/
export async function requireCycleEnabled(
userId: string,
- gender: string | null | undefined,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _gender: string | null | undefined,
): Promise {
- const profile = await getOrCreateCycleProfile(userId);
- if (!isCycleEnabled(gender, profile)) {
+ const [profile, available] = await Promise.all([
+ getOrCreateCycleProfile(userId),
+ isCycleAvailableForUser(userId),
+ ]);
+ if (!available) {
return {
enabled: false,
response: apiError("Cycle tracking is not enabled", 403, {
diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts
index ef3e9ea00..d1b1c1939 100644
--- a/src/lib/cycle/import-accumulator.ts
+++ b/src/lib/cycle/import-accumulator.ts
@@ -21,7 +21,7 @@
*/
import { prisma } from "@/lib/db";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
-import { isCycleEnabled } from "@/lib/cycle/gate";
+import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { auditLog } from "@/lib/auth/audit";
import {
mapHkCycleSample,
@@ -154,25 +154,19 @@ export class CycleImportAccumulator {
}
/**
- * Resolve whether cycle tracking is enabled for the user (gender-derived
- * or the explicit toggle). Cached for the parse lifetime. The importer
+ * Resolve whether the cycle module is enabled for the user — the
+ * fully-resolved two-layer gate (per-user toggle AND the operator
+ * server-wide kill-switch). Cached for the parse lifetime. The importer
* skips the whole cycle fold when this is false so a non-cycle account's
- * Apple Health export never silently provisions cycle rows.
+ * (or an operator-disabled instance's) Apple Health export never silently
+ * provisions cycle rows.
*/
async isEnabled(): Promise {
if (this.gated !== null) return this.gated;
- const [user, profile] = await Promise.all([
- prisma.user.findUnique({
- where: { id: this.userId },
- select: { gender: true },
- }),
- prisma.cycleProfile.findUnique({
- where: { userId: this.userId },
- select: { cycleTrackingEnabled: true },
- }),
- ]);
- // The single source-of-truth gate (no profile auto-create on import).
- this.gated = isCycleEnabled(user?.gender, profile);
+ // v1.18.0 — the FULLY-resolved cycle module: the per-user toggle AND the
+ // operator server-wide kill-switch. An operator-disabled instance never
+ // accumulates / writes cycle rows from an import. No profile auto-create.
+ this.gated = await isCycleAvailableForUser(this.userId);
return this.gated;
}
diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts
index a6e83b7d1..e667ff408 100644
--- a/src/lib/jobs/__tests__/cycle-reminder.test.ts
+++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts
@@ -33,6 +33,7 @@ import {
PERIOD_CONFIRM_GRACE_DAYS,
FERTILE_SOON_LEAD_DAYS,
} from "../cycle-reminder";
+import { isCycleEnabled } from "@/lib/cycle/gate";
import type { NotificationPayload } from "@/lib/notifications/types";
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
@@ -297,13 +298,38 @@ describe("buildCycleReminderPayload", () => {
});
describe("runCycleReminderTick", () => {
- function run(state: FakeState, dispatch: DispatchFn, now = AT_0900_BERLIN) {
+ function run(
+ state: FakeState,
+ dispatch: DispatchFn,
+ now = AT_0900_BERLIN,
+ overrides: {
+ /**
+ * v1.18.0 — inject the module gate. Default resolves the per-user
+ * cycle toggle off the FakeState user (the same `isCycleEnabled`
+ * derivation the cron used to apply inline), so the gender-derivation
+ * cases stay faithful. Pass `operatorOff: true` to assert the operator
+ * server-wide kill-switch suppresses every push regardless of the
+ * per-user toggle.
+ */
+ operatorOff?: boolean;
+ } = {},
+ ) {
const prisma = makePrisma(state);
+ const isModuleEnabled = vi.fn(async (userId: string) => {
+ if (overrides.operatorOff) return false;
+ const pred = state.predictions.find((p) => p.userId === userId);
+ const u = pred?.user;
+ return isCycleEnabled(u?.gender ?? null, u?.cycleProfile ?? null);
+ });
return runCycleReminderTick(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
now,
- { dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification },
+ {
+ dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification,
+ isModuleEnabled:
+ isModuleEnabled as unknown as typeof import("@/lib/modules/gate").isModuleEnabled,
+ },
);
}
@@ -447,6 +473,26 @@ describe("runCycleReminderTick", () => {
const summary = await run(state, dispatch);
expect(dispatch).not.toHaveBeenCalled();
expect(summary.dispatchedPeriodSoon).toBe(0);
+ expect(summary.skippedModuleDisabled).toBe(1);
+ });
+
+ it("operator server-wide kill-switch suppresses every cycle push (v1.18.0)", async () => {
+ // A fully cycle-enabled FEMALE account with an in-window prediction:
+ // the only thing stopping the push is the operator-level module gate.
+ const state: FakeState = {
+ predictions: [
+ { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() },
+ ],
+ observedStarts: {},
+ alreadyOk: new Set(),
+ };
+ const dispatch = vi.fn(async () => OK);
+ const summary = await run(state, dispatch, AT_0900_BERLIN, {
+ operatorOff: true,
+ });
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(summary.dispatchedPeriodSoon).toBe(0);
+ expect(summary.skippedModuleDisabled).toBe(1);
});
it("opts a non-FEMALE account in via the explicit cycleTrackingEnabled flag", async () => {
diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts
index 3e5e5c3df..66c13adbc 100644
--- a/src/lib/jobs/cycle-reminder.ts
+++ b/src/lib/jobs/cycle-reminder.ts
@@ -43,7 +43,7 @@ import { defaultLocale, locales } from "@/lib/i18n/config";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dayDiff } from "@/lib/cycle/day-math";
-import { isCycleEnabled } from "@/lib/cycle/gate";
+import { isModuleEnabled } from "@/lib/modules/gate";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import type { EventType } from "@/lib/notifications/types";
import { isCycleReminderClientManaged } from "@/lib/validations/notification-prefs";
@@ -92,6 +92,7 @@ export interface CycleReminderSummary {
skippedNoChannel: number;
skippedOutsideWindow: number;
skippedPredictionDisabled: number;
+ skippedModuleDisabled: number;
skippedAlreadyLogged: number;
failed: number;
}
@@ -129,6 +130,7 @@ function emptySummary(): CycleReminderSummary {
skippedNoChannel: 0,
skippedOutsideWindow: 0,
skippedPredictionDisabled: 0,
+ skippedModuleDisabled: 0,
skippedAlreadyLogged: 0,
failed: 0,
};
@@ -290,9 +292,18 @@ export async function runCycleReminderTick(
now: Date,
options: {
dispatch?: typeof dispatchNotification;
+ /**
+ * v1.18.0 module gate — injectable so the unit tests pin the
+ * operator/user disabled-module skip without booting the gate's DB
+ * reads. Defaults to the real `isModuleEnabled` resolver. The `cycle`
+ * ModuleKey resolves the per-user toggle AND the operator server-wide
+ * kill-switch, mirroring the mood / measurement reminder crons.
+ */
+ isModuleEnabled?: typeof isModuleEnabled;
} = {},
): Promise {
const dispatchImpl = options.dispatch ?? dispatchNotification;
+ const moduleGate = options.isModuleEnabled ?? isModuleEnabled;
const summary = emptySummary();
// Push the gating into the query so the per-tick cost scales with the
@@ -362,14 +373,18 @@ export async function runCycleReminderTick(
try {
const profile = user.cycleProfile;
- // 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;
+ // Gate on the FULLY-resolved cycle module — the per-user toggle
+ // (gender-derived or explicit opt-in) AND the operator server-wide
+ // kill-switch. v1.18.0: the `cycle` ModuleKey resolves through
+ // `isModuleEnabled`, so an operator who disables the Cycle module
+ // (`AppSettings.moduleAvailabilityJson.cycle = false`) suppresses
+ // every CYCLE_PERIOD push for every account — the same two-layer gate
+ // the routes and the coach cycle block read. A per-user-disabled
+ // account is likewise skipped. The `profile` row read below still
+ // applies the `predictionEnabled` opt-out.
+ if (!(await moduleGate(user.id, "cycle"))) {
+ summary.skippedModuleDisabled += 1;
+ getEvent()?.addMeta("cycle_reminder.skipped_module_disabled", user.id);
continue;
}