From b1400a8130dcf754c26fe7456e771a0cf442dfdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:05:08 +0200 Subject: [PATCH] refactor(cycle,medications): break the gate and compliance import cycles The module gate and the cycle gate imported each other for one pure predicate. isCycleEnabled moves to lib/cycle/enabled.ts and both gates import it from there. The band minter read DOSE_WINDOW_DEFAULTS through the analytics/compliance re-export barrel, which loads the compliance display and with it the scheduling modules that import the minter. It now imports the constant from the module that defines it. Runtime import cycles fall from 6 to 4. --- src/__tests__/baselines/import-cycles.json | 17 +------------ src/app/api/auth/me/cycle-prefs/route.ts | 2 +- src/app/api/auth/me/route.ts | 2 +- src/app/api/cycle/profile/route.ts | 2 +- src/app/api/record-settings/[family]/route.ts | 2 +- src/lib/cycle/__tests__/gate.test.ts | 2 +- src/lib/cycle/enabled.ts | 25 +++++++++++++++++++ src/lib/cycle/gate.ts | 22 ++-------------- src/lib/jobs/__tests__/cycle-reminder.test.ts | 2 +- src/lib/medications/scheduling/band-minter.ts | 2 +- src/lib/modules/gate.ts | 2 +- 11 files changed, 36 insertions(+), 44 deletions(-) create mode 100644 src/lib/cycle/enabled.ts 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 {