diff --git a/src/__tests__/baselines/import-cycles.json b/src/__tests__/baselines/import-cycles.json index 8a2c22e85..849d2ad0a 100644 --- a/src/__tests__/baselines/import-cycles.json +++ b/src/__tests__/baselines/import-cycles.json @@ -1,27 +1,12 @@ { "$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.", - "cycles": 6, + "cycles": 4, "components": [ [ "components/command-palette/command-palette.tsx", "components/command-palette/palette-store.ts", "components/layout/global-shortcuts.tsx" ], - [ - "lib/analytics/compliance.ts", - "lib/analytics/compliance/display.ts", - "lib/analytics/compliance/ledger.ts", - "lib/analytics/compliance/occurrences.ts", - "lib/analytics/compliance/windows.ts", - "lib/medications/scheduling/band-minter.ts", - "lib/medications/scheduling/cadence.ts", - "lib/medications/scheduling/compliance.ts", - "lib/medications/scheduling/schedule-eras.ts" - ], - [ - "lib/cycle/gate.ts", - "lib/modules/gate.ts" - ], [ "lib/insights/features.ts", "lib/insights/signals-of-day.ts" diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index 738408f41..dcbb84a94 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -27,7 +27,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/enabled"; import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { toCycleProfileDTO } from "@/lib/cycle/dto"; import { cyclePrefsSchema } from "@/lib/validations/cycle"; diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 24d43f989..46888523b 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -51,7 +51,7 @@ import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; import { resolveStoredTimezone } from "@/lib/tz/resolver"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/enabled"; import { resolveModuleMap, getOperatorModuleAvailability, diff --git a/src/app/api/cycle/profile/route.ts b/src/app/api/cycle/profile/route.ts index f0eb5813e..33c6d7da5 100644 --- a/src/app/api/cycle/profile/route.ts +++ b/src/app/api/cycle/profile/route.ts @@ -8,7 +8,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/enabled"; import { toCycleProfileDTO } from "@/lib/cycle/dto"; export const dynamic = "force-dynamic"; diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 86b6b465c..229973ec4 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -31,7 +31,7 @@ import { parseManagedRecordSettingsPatch, resolveGuardianRecordSettingsAccess, } from "@/lib/record-settings"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/enabled"; import { resolveInsightsLayout, serializeInsightsLayout, diff --git a/src/lib/cycle/__tests__/gate.test.ts b/src/lib/cycle/__tests__/gate.test.ts index 17dd46503..09a33899d 100644 --- a/src/lib/cycle/__tests__/gate.test.ts +++ b/src/lib/cycle/__tests__/gate.test.ts @@ -18,8 +18,8 @@ vi.mock("@/lib/cycle/profile", () => ({ getOrCreateCycleProfileMock(...args), })); +import { isCycleEnabled } from "../enabled"; import { - isCycleEnabled, isCycleAvailableForUser, requireCycleEnabled, CYCLE_DISABLED_ERROR_CODE, diff --git a/src/lib/cycle/enabled.ts b/src/lib/cycle/enabled.ts new file mode 100644 index 000000000..750f52e3a --- /dev/null +++ b/src/lib/cycle/enabled.ts @@ -0,0 +1,25 @@ +/** + * The per-user (LAYER 2) cycle-tracking decision, pure. + * + * A leaf, imported by both the module gate (`@/lib/modules/gate`, which + * AND-s it with the operator kill-switch) and the cycle route guard + * (`./gate.ts`, which asks the module gate). Kept apart from both so the + * two gates do not import each other. See `./gate.ts` for the layering. + */ +/** + * 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"; +} diff --git a/src/lib/cycle/gate.ts b/src/lib/cycle/gate.ts index c198a7361..909acacbf 100644 --- a/src/lib/cycle/gate.ts +++ b/src/lib/cycle/gate.ts @@ -5,13 +5,13 @@ * (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 + * `isCycleEnabled` (`./enabled.ts`) 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 + * `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 @@ -51,24 +51,6 @@ export function isCycleAvailableForUser(userId: string): Promise { 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. diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts index 6df77c886..3ddd8df54 100644 --- a/src/lib/jobs/__tests__/cycle-reminder.test.ts +++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts @@ -33,7 +33,7 @@ import { PERIOD_CONFIRM_GRACE_DAYS, FERTILE_SOON_LEAD_DAYS, } from "../cycle-reminder"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/enabled"; import type { NotificationPayload } from "@/lib/notifications/types"; import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index b0ff3f352..f3d0d09c8 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -37,7 +37,7 @@ */ import { RRule } from "rrule"; -import { DOSE_WINDOW_DEFAULTS } from "@/lib/analytics/compliance"; +import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; import { buildSlotBands, type SlotBand, diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index a4da80212..8419d4d4c 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -46,7 +46,7 @@ */ import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/enabled"; import { getAssistantFlags } from "@/lib/feature-flags"; import { memoizePerRequest } from "@/lib/request-cache"; import {