feat(settings): make medications a toggleable module

Medications graduated from the always-on core set (weight / blood
pressure / pulse stay core) to an opt-out module. The surface gates
off the resolved module map — the left-nav entry, the dashboard
medication widget, and the dedicated Medikamente settings entry hide
when the account turns the module off. The medication data routes stay
exempt from the gate (same data-layer reasoning as mood / labs): an
importer, a sync, and the user's ability to clean up entries keep
working while the surface is hidden, and re-enabling finds the
schedule and intake history intact.

Wire the module-route gate inventory, the dashboard widget/summary
maps, the PATCH allowlist, and the nav model; regenerate the OpenAPI
contract for the new module key.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:31:54 +02:00
1 parent 930c4a75dd
commit 559366e308
13 files changed
+119 -28

No files matched your search

+6
View File
@@ -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
@@ -86,6 +86,17 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// 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<string> = [
"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,
+5 -4
View File
@@ -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.
@@ -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");
+5 -2
View File
@@ -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<NavDestination> = [
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",
+8 -6
View File
@@ -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<ModuleKey, LucideIcon> = {
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<CoreDomainKey, LucideIcon> = {
weight: Scale,
bloodPressure: HeartPulse,
pulse: Activity,
medications: Pill,
};
export function ModulesSection() {
+5 -2
View File
@@ -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
+4 -1
View File
@@ -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<ModuleKey, boolean>> = {},
): Record<ModuleKey, boolean> {
@@ -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",
+6 -2
View File
@@ -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<Record<string, ModuleKey>> = {
mood: "mood",
@@ -30,6 +33,7 @@ export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
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",
+5 -4
View File
@@ -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", () => {
+15 -1
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
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",
+5 -4
View File
@@ -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.
@@ -315,6 +315,7 @@ describe("doctor-report — module enable/disable gating", () => {
achievements: true,
coach: true,
insights: true,
medications: true,
doctorReport: true,
} as const;