Files
HealthLog/src/lib/cycle/gate.ts
T
Marc-André Bombeck a73872435d 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.
2026-06-16 01:19:46 +02:00

108 lines
4.5 KiB
TypeScript

/**
* Cycle-tracking feature gate.
*
* Two gates collapse into one resolved boolean (ios-contract §1):
* (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and
* (b) a gender-derived default when that toggle is NULL.
*
* `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
* `cycleTrackingEnabled = true` opt-in via the Settings toggle.
*/
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.
*
* @param gender the user's `gender` column (`"MALE" | "FEMALE" | null`).
* @param profile the user's `CycleProfile`, or just its
* `cycleTrackingEnabled` field (NULL ⇒ derive from gender).
*/
export function isCycleEnabled(
gender: string | null | undefined,
profile: { cycleTrackingEnabled: boolean | null } | null | undefined,
): boolean {
const toggle = profile?.cycleTrackingEnabled;
if (toggle === true) return true;
if (toggle === false) return false;
// NULL toggle → derive from gender.
return gender === "FEMALE";
}
/**
* Route-guard outcome: either the resolved profile (enabled) or a ready
* 403 envelope (disabled) to return verbatim.
*/
export type CycleGateResult =
| { enabled: true; profile: CycleProfile }
| { enabled: false; response: Response };
/**
* Enforce the gate for a `/api/cycle/*` route. Lazily upserts the
* `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,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_gender: string | null | undefined,
): Promise<CycleGateResult> {
const [profile, available] = await Promise.all([
getOrCreateCycleProfile(userId),
isCycleAvailableForUser(userId),
]);
if (!available) {
return {
enabled: false,
response: apiError("Cycle tracking is not enabled", 403, {
errorCode: CYCLE_DISABLED_ERROR_CODE,
}),
};
}
return { enabled: true, profile };
}