mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
git, eslint and vitest each carry an exclusion for local development worktrees, and all three name /.worktrees/ while the convention actually in use is /.wt-*/. Every one of them was therefore walking eleven full copies of the source tree. The consequences differed by tool and none of them announced itself. pnpm lint took ten minutes instead of one and reported warnings from whatever commits those copies happened to sit on. vitest collected duplicate suites that drag in a second React instance, which fails in ways that have nothing to do with the working tree. git left them merely untracked, one git add -A away from committing the repository eleven times over. With the true warning count finally visible, teach no-unused-vars the underscore convention this codebase already follows and drop the five disable directives that convention made redundant. pnpm lint is clean. A non-underscore unused argument still warns; that was checked rather than assumed.
107 lines
4.5 KiB
TypeScript
107 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,
|
|
_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 };
|
|
}
|