diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e55644f1..71de69b3f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7076,6 +7076,8 @@ components: type: boolean insights: type: boolean + medications: + type: boolean doctorReport: type: boolean additionalProperties: false @@ -12730,6 +12732,9 @@ components: insights: type: boolean description: Whether the "insights" module is enabled for this account. + medications: + type: boolean + description: Whether the "medications" module is enabled for this account. doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. @@ -12745,6 +12750,7 @@ components: - achievements - coach - insights + - medications - doctorReport additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 7d1629fa6..f909433eb 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -86,6 +86,17 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // surface over a Bearer token when the account never opted in. "src/app/api/illness", "src/app/api/gamification", + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. + // It is SURFACE-gated (nav entry, dashboard widget, the dedicated + // Medikamente settings entry), not data-layer-gated: every `/api/medications/*` + // route is raw medication CRUD / intake / inventory / compliance over the + // user's own rows, so they are EXEMPT below under the same data-layer + // reasoning as mood/labs — disabling the module hides the surface, it does + // not wedge an importer / sync / the user's ability to clean up, and + // re-enabling finds the schedule + intake history intact. Walking the tree + // means a NEW medication route must justify itself (gate or document-exempt) + // rather than silently appearing. + "src/app/api/medications", // v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF + // availability probe) was orphaned dead code (no production caller) and // removed. The live doctor-report / FHIR surface is `/api/export/health-record`, @@ -147,6 +158,40 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/labs/restore/route.ts", "src/app/api/biomarkers/route.ts", "src/app/api/biomarkers/[id]/route.ts", + // ── DATA LAYER (medications) ────────────────────────────────────── + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module, + // but it is SURFACE-gated (nav / dashboard widget / settings entry), not + // data-layer-gated. Every `/api/medications/*` route is raw CRUD over the + // user's own medication / intake / inventory / compliance / side-effect + // rows — the same data-layer reasoning as mood/labs: the module gate + // governs whether medications SURFACES, not whether the row store accepts + // writes. An importer / sync / the user's ability to clean up entries must + // keep working while the module is off, and re-enabling must find the + // schedule + intake history intact. + "src/app/api/medications/route.ts", + "src/app/api/medications/layout/route.ts", + "src/app/api/medications/compliance/route.ts", + "src/app/api/medications/intake/route.ts", + "src/app/api/medications/intake/bulk/route.ts", + "src/app/api/medications/extract/route.ts", + "src/app/api/medications/[id]/route.ts", + "src/app/api/medications/[id]/api-endpoint/route.ts", + "src/app/api/medications/[id]/cadence/route.ts", + "src/app/api/medications/[id]/compliance/route.ts", + "src/app/api/medications/[id]/dose-history/route.ts", + "src/app/api/medications/[id]/glp1/route.ts", + "src/app/api/medications/[id]/phase-config/route.ts", + "src/app/api/medications/[id]/intake/route.ts", + "src/app/api/medications/[id]/intake/[eventId]/route.ts", + "src/app/api/medications/[id]/intake/bulk-delete/route.ts", + "src/app/api/medications/[id]/intake/import/route.ts", + "src/app/api/medications/[id]/intake/purge/route.ts", + "src/app/api/medications/[id]/inventory/route.ts", + "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + "src/app/api/medications/[id]/schedule-revisions/route.ts", + "src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts", + "src/app/api/medications/[id]/side-effects/route.ts", + "src/app/api/medications/[id]/side-effects/[logId]/route.ts", // ── INFRA / UI-ONLY ─────────────────────────────────────────────── // Static FHIR CapabilityStatement — server metadata, no user data. "src/app/api/fhir/metadata/route.ts", @@ -281,6 +326,7 @@ describe("module API route gate inventory", () => { "coach", "doctorReport", "insights", + "medications", ] as const) { expect(known.has(key), `unknown module key in inventory: ${key}`).toBe( true, diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index c846f4368..b5f9080ec 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -12,10 +12,11 @@ * * The body schema (`modulePrefsPatchSchema`) is `strict()` over the * directly-owned toggleable key set, so a core-domain key (`weight`, - * `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`, - * `coach`), or any unknown key is a 422 — the core measurement engine + - * meds can never be disabled here, and a value for a delegated module can - * never land inert in `modulePreferencesJson`. The delegated modules are + * `bloodPressure`, `pulse`), a delegated key (`cycle`, `coach`), or any + * unknown key is a 422 — the core measurement engine can never be disabled + * here, and a value for a delegated module can never land inert in + * `modulePreferencesJson`. v1.18.1 (D3) — `medications` graduated to a + * toggleable module, so it IS accepted here now. The delegated modules are * managed at their real control (cycle in Account, coach in Settings → * Coach); the Modules hub deep-links there rather than offering a dead * toggle. diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 7b43cc980..a694b42b5 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -115,7 +115,7 @@ describe("visibleNavDestinations module gate", () => { expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); }); - it("drops a module-gated entry (mood / labs / coach / achievements / insights) when its module is disabled", () => { + it("drops a module-gated entry (mood / labs / coach / achievements / insights / medications) when its module is disabled", () => { const disabled = visibleNavDestinations({ mood: false, labs: false, @@ -124,12 +124,15 @@ describe("visibleNavDestinations module gate", () => { // v1.18.0 — Insights is now `requiresModule: "insights"`, so the // top-level /insights entry drops when the module is off. insights: false, + // v1.18.1 (D3) — medications graduated to a toggleable module. + medications: false, }).map((d) => d.href); expect(disabled).not.toContain("/mood"); expect(disabled).not.toContain("/labs"); expect(disabled).not.toContain("/coach"); expect(disabled).not.toContain("/achievements"); expect(disabled).not.toContain("/insights"); + expect(disabled).not.toContain("/medications"); }); it("keeps every module-gated entry when its module is enabled", () => { @@ -139,12 +142,14 @@ describe("visibleNavDestinations module gate", () => { coach: true, achievements: true, insights: true, + medications: true, }).map((d) => d.href); expect(enabled).toContain("/mood"); expect(enabled).toContain("/labs"); expect(enabled).toContain("/coach"); expect(enabled).toContain("/achievements"); expect(enabled).toContain("/insights"); + expect(enabled).toContain("/medications"); }); it("fails open: a missing key or an undefined map keeps the gated entry", () => { @@ -159,7 +164,8 @@ describe("visibleNavDestinations module gate", () => { expect(hrefs).toContain("/coach"); expect(hrefs).toContain("/achievements"); } - // Core destinations always render regardless of the map. + // Core / fail-open destinations render regardless of the map. Medications + // is module-gated (v1.18.1 D3) but fails open on a missing key. expect(emptyMap).toContain("/measurements"); expect(emptyMap).toContain("/medications"); expect(emptyMap).toContain("/insights"); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 822711344..89eb41a8b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -49,8 +49,8 @@ export interface NavDestination { * v1.18.0 — gate the entry on a per-user module toggle. When set, the * entry is dropped unless the account's resolved module map (from * `GET /api/auth/me`'s `modules`) has the key enabled. Core destinations - * (weight / BP / pulse / medications + always-on pages) carry no key and - * always render. `cycle` and `coach` are delegated keys (cycle → gender + + * (weight / BP / pulse + always-on pages) carry no key and always render. + * `cycle` and `coach` are delegated keys (cycle → gender + * opt-in, coach → operator flag + per-user opt-out); the auth/me map * already reflects that delegation, so reading them here is correct and * not a re-derivation. @@ -83,6 +83,9 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tKey: "nav.medications", icon: Pill, tourId: "nav-medications", + // v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable + // module; the nav entry now drops when the account turns the module off. + requiresModule: "medications", }, { href: "/cycle", diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 67ff12410..12939a8af 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -11,11 +11,12 @@ * core keys) and then invalidates the `authMe()` query so the nav, Insights * pills, and dashboard tiles re-gate live off `useAuth().user.modules`. * - * The four CORE domains (weight, blood pressure, pulse, medications) render - * as a separate read-only "always on" group — locked switches with a short - * note — so the always-on measurement engine + meds read as deliberately - * fixed, never disableable. Palette stays neutral throughout; this is a - * calm configuration surface, not an alarm panel. + * The three CORE domains (weight, blood pressure, pulse) render as a + * separate read-only "always on" group — locked switches with a short note — + * so the always-on measurement engine reads as deliberately fixed, never + * disableable. Palette stays neutral throughout; this is a calm + * configuration surface, not an alarm panel. (v1.18.1 D3 — medications + * graduated to the toggleable group above.) * * State source is `useAuth().user.modules` (the resolved `/auth/me` map). * A module is enabled unless its key is explicitly `false` — default-on. @@ -71,6 +72,8 @@ const MODULE_ICONS: Record = { achievements: Award, coach: MessageCircleHeart, insights: Sparkles, + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. + medications: Pill, doctorReport: FileText, }; @@ -79,7 +82,6 @@ const CORE_ICONS: Record = { weight: Scale, bloodPressure: HeartPulse, pulse: Activity, - medications: Pill, }; export function ModulesSection() { diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 9e773d6eb..d8a247f4a 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -124,12 +124,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: LayoutDashboard, }, // v1.18.0 (S5) — Medikamente: medication-specific settings (list view + - // order + injection sites). Medications is a CORE domain, so the entry is - // always shown. Was a hidden child of the Layout hub. + // order + injection sites). Was a hidden child of the Layout hub. + // v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable + // module, so this entry now hides when the account turns the module off + // (the medication data routes stay live; this only hides the surface). { slug: "medications", titleKey: "settings.sections.medications.title", icon: Pill, + moduleGate: "medications", }, // v1.18.0 (S5) — Stimmung: the mood-tag management surface gets its own // nav entry, shown only when the mood module is enabled. Was a hidden diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 13ee7c246..727551635 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -65,7 +65,7 @@ vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), })); -/** All eleven toggleable keys on, with optional per-key overrides. */ +/** Every toggleable key on, with optional per-key overrides. */ function moduleMap( overrides: Partial> = {}, ): Record { @@ -81,6 +81,7 @@ function moduleMap( achievements: true, coach: true, insights: true, + medications: true, doctorReport: true, }; return { ...base, ...overrides }; @@ -810,6 +811,8 @@ describe("module gating maps — every toggleable-domain widget is mapped (v1.18 "glucose", "achievements", "recentWorkouts", + // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. + "medications", "cardioRecovery", "sixMinuteWalk", "stairAscentSpeed", diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 80af8e63b..99468d6d5 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -21,8 +21,11 @@ import type { ModuleKey } from "@/lib/modules/registry"; * module, the matching widget is forced invisible on both the web `layout` * and the iOS `layoutCatalogue`, so the tile/chart never paints. Only the * toggleable surfaces appear here; CORE widgets (weight / bp / pulse / - * bodyFat / medications / bpInTarget and the vital-derived HealthKit - * metrics) carry NO entry and are never hidden. + * bodyFat / bpInTarget and the vital-derived HealthKit metrics) carry NO + * entry and are never hidden. + * + * v1.18.1 (D3) — `medications` graduated from CORE to a toggleable module, + * so the medication tile now gates off it like the other toggleable widgets. */ export const WIDGET_MODULE_BY_ID: Partial> = { mood: "mood", @@ -30,6 +33,7 @@ export const WIDGET_MODULE_BY_ID: Partial> = { glucose: "glucose", achievements: "achievements", recentWorkouts: "workouts", + medications: "medications", // v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery // module; the per-night breathing-disturbance widget belongs to sleep. cardioRecovery: "recovery", diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 422e09589..b4f72b2cf 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -318,7 +318,7 @@ describe("normalisePrefs — fail-open coercion", () => { }); describe("registry — core domains can never be disabled", () => { - it("the four core domains are not toggleable module keys", () => { + it("the three core domains are not toggleable module keys", () => { for (const core of CORE_DOMAIN_KEYS) { expect(isModuleKey(core)).toBe(false); expect(isCoreDomain(core)).toBe(true); @@ -327,10 +327,11 @@ describe("registry — core domains can never be disabled", () => { it("a crafted core-domain key is inert (never read as a module)", () => { // `weight: false` in the blob can't matter: weight is not a ModuleKey, - // so the gate never consults it. The resolver only ever runs for the - // 11 declared toggleable keys. + // so the gate never consults it. The resolver only runs for the declared + // toggleable keys. v1.18.1 (D3) — medications graduated to a toggleable + // module, so it IS a ModuleKey now (weight / bp / pulse stay core). expect(isModuleKey("weight")).toBe(false); - expect(isModuleKey("medications")).toBe(false); + expect(isModuleKey("medications")).toBe(true); }); it("every toggleable key is a known module and not a core domain", () => { diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 8e21fd20b..1cf57e8a7 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -103,6 +103,15 @@ export const MODULE_KEYS = [ "achievements", "coach", "insights", + // v1.18.1 (D3) — medications graduated from the always-on CORE set to a + // toggleable module. Weight / blood pressure / pulse stay CORE (the + // measurement engine is never disableable); medications is now an opt-out + // domain so an account that does not track meds can hide the surface. + // SURFACE-gated (nav entry, dashboard medication widget, the dedicated + // Medikamente settings entry) — the medication data-layer routes stay + // exempt like mood/labs so an importer / sync / cleanup keeps working and + // re-enabling finds the rows intact. + "medications", "doctorReport", ] as const; @@ -119,7 +128,6 @@ export const CORE_DOMAIN_KEYS = [ "weight", "bloodPressure", "pulse", - "medications", ] as const; export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number]; @@ -232,6 +240,12 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.insights.description", category: "intelligence", }, + medications: { + key: "medications", + labelKey: "modules.medications.label", + descriptionKey: "modules.medications.description", + category: "tracking", + }, doctorReport: { key: "doctorReport", labelKey: "modules.doctorReport.label", diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts index d33ae6870..05c041870 100644 --- a/src/lib/validations/modules.ts +++ b/src/lib/validations/modules.ts @@ -3,10 +3,11 @@ * * The PATCH body is a partial map of TOGGLEABLE module keys → boolean. * Only keys in the canonical registry (`MODULE_KEYS`) are accepted; a - * core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or - * any unknown key is a 422 — so the core measurement engine + meds can - * never be disabled through this surface (defence-in-depth on top of the - * gate, which has no key to flip for core domains anyway). + * core-domain key (`weight`, `bloodPressure`, `pulse`) or any unknown key + * is a 422 — so the core measurement engine can never be disabled through + * this surface (defence-in-depth on top of the gate, which has no key to + * flip for core domains anyway). v1.18.1 (D3) — `medications` graduated to + * a toggleable module, so it IS writable here now. * * `strict()` rejects unknown keys outright rather than silently dropping * them, so a client gets a clear error instead of a no-op. diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index af952660f..65df1f589 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -315,6 +315,7 @@ describe("doctor-report — module enable/disable gating", () => { achievements: true, coach: true, insights: true, + medications: true, doctorReport: true, } as const;