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; }