mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
merge(reconcile): g1a-cycleop audit findings
This commit is contained in:
commit
6a96accc51
11 files changed
+262
-55
No files matched your search
+10
-7
@@ -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;
|
||||
|
||||
@@ -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 ? <CycleRingTile /> : 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 ? <CycleRingTile /> : 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 ? (
|
||||
|
||||
@@ -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