mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(cycle): enforce the operator module kill-switch across every cycle gate
The cycle gate checked only the per-user toggle and gender, never the operator server-wide availability layer. With the Cycle module turned off by the operator, /api/cycle/* kept serving, the coach injected the cycle block, the Apple-Health importer kept writing cycle rows, and the cycle-reminder cron kept firing CYCLE_PERIOD pushes — while nav and the doctor report already honoured the operator switch. Add isCycleAvailableForUser, which delegates to isModuleEnabled(userId, "cycle"): the cycle ModuleKey resolves the per-user toggle AND the operator availability map, so there is one source of truth and no duplicated operator logic. Route requireCycleEnabled, the coach cycle block, and the import accumulator through it; switch the cron to the injectable isModuleEnabled gate, matching the mood and measurement reminder crons, and add a skippedModuleDisabled counter. Correct the cron comment that claimed isCycleEnabled was itself the module gate. isCycleEnabled stays the pure per-user (user-layer) resolver the module foundation AND-s at layer one. Tests: assert requireCycleEnabled returns a 403 cycle.disabled envelope and the cron skips the push when the operator turns the module off.
This commit is contained in:
1 parent
00d16ea275
commit
a73872435d
9 files changed
+245
-46
No files matched your search
@@ -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<ModuleKey, boolean>
|
||||
>)();
|
||||
return map[key] !== false;
|
||||
}),
|
||||
}));
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
|
||||
|
||||
@@ -38,6 +38,16 @@ const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
|
||||
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<ModuleKey, boolean>
|
||||
>)();
|
||||
return map[key] !== false;
|
||||
}),
|
||||
}));
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
|
||||
|
||||
@@ -37,6 +37,18 @@ const allModulesEnabled = (): Record<ModuleKey, boolean> =>
|
||||
|
||||
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<ModuleKey, boolean>
|
||||
>)();
|
||||
return map[key] !== false;
|
||||
}),
|
||||
}));
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
|
||||
|
||||
@@ -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<CoachDataCluster> = new Set<CoachDataCluster>([
|
||||
* 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;
|
||||
|
||||
@@ -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<boolean>>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+44
-9
@@ -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<boolean> {
|
||||
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<CycleGateResult> {
|
||||
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, {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DispatchFn>(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 () => {
|
||||
|
||||
@@ -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<CycleReminderSummary> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user