diff --git a/src/app/insights/mental-wellbeing/page.tsx b/src/app/insights/mental-wellbeing/page.tsx index fb6f1039d..778255ddf 100644 --- a/src/app/insights/mental-wellbeing/page.tsx +++ b/src/app/insights/mental-wellbeing/page.tsx @@ -1,16 +1,51 @@ "use client"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; /** - * v1.25 — `/insights/mental-wellbeing`. + * v1.25.0 — `/insights/mental-wellbeing`. * * Opt-in PHQ-9 / GAD-7 screener surface, beside mood tracking. The screen owns * its own questionnaire + result + crisis-resource flow; it deliberately does * NOT mount the shared Coach-launch assessment card (mental-health item content * is kept out of the AI Coach by construction). + * + * Born-gated on the resolved `modules.mentalHealth` flag from `GET /api/auth/me` + * (opt-in / default-off — the per-user opt-in AND the operator server-wide + * kill-switch). An unauthenticated visitor is bounced to login; an authenticated + * account without the module opted in is bounced home (the nav entry is already + * hidden for them, so this only catches a direct URL hit). Both assessment + * routes also enforce the gate server-side, so this is a UX redirect, not the + * security boundary. */ export default function MentalWellbeingPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.mentalHealth === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + return (
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 01d81ad3a..49e04b45b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -1,6 +1,7 @@ import { Activity, Bell, + Brain, Droplets, FileScan, FlaskConical, @@ -82,6 +83,19 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-mood", requiresModule: "mood", }, + // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood. + // Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off) + // key from the resolved module map, so the entry is absent until the account + // turns the module on from the Modules hub. The destination is the dedicated + // `/insights/mental-wellbeing` check-in surface (its own nav home — the + // active-detection helper defers `/insights` to it on that path). + { + href: "/insights/mental-wellbeing", + tKey: "nav.mentalWellbeing", + icon: Brain, + tourId: "nav-mental-wellbeing", + requiresModule: "mentalHealth", + }, { href: "/cycle", tKey: "nav.cycle", diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 3dfede292..e67da7e3f 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -28,6 +28,7 @@ import { Activity, Award, Blocks, + Brain, CalendarHeart, Droplet, CloudSun, @@ -80,6 +81,8 @@ const MODULE_ICONS: Record = { mcp: Plug, // v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in). inboundDocuments: FileScan, + // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7). + mentalHealth: Brain, }; export function ModulesSection() { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 0e4b2baed..0cf95f422 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -86,6 +86,7 @@ function moduleMap( environment: true, mcp: true, inboundDocuments: true, + mentalHealth: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 944386804..730bf8b87 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -352,12 +352,19 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => { }); describe("registry — opt-in marker", () => { - // The opt-in modules ship dark because they open an egress/attack surface: - // `mcp` (remote external-assistant endpoint), `environment` (outbound weather - // fetch tied to a coarse location), and `inboundDocuments` (sends an uploaded - // clinical document to the OCR/vision provider). Every other module is the - // default-on disabled-allowlist. - const OPT_IN_KEYS = new Set(["mcp", "environment", "inboundDocuments"]); + // The opt-in modules ship dark because they open an egress/attack surface or + // are highly sensitive: `mcp` (remote external-assistant endpoint), + // `environment` (outbound weather fetch tied to a coarse location), + // `inboundDocuments` (sends an uploaded clinical document to the OCR/vision + // provider), and `mentalHealth` (a depression / anxiety self-assessment — at + // least as sensitive as mood, surfaced only on explicit opt-in). Every other + // module is the default-on disabled-allowlist. + const OPT_IN_KEYS = new Set([ + "mcp", + "environment", + "inboundDocuments", + "mentalHealth", + ]); it("marks only the egress-surface modules opt-in (every other is default-on)", () => { for (const key of MODULE_KEYS) { diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 663b32043..0fae157e5 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -136,6 +136,14 @@ export const MODULE_KEYS = [ // `optIn` marker on its registry entry inverts the per-user default; // everything else reuses the existing module machinery unchanged. "inboundDocuments", + // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood + // tracking. OPT-IN (off by default): a depression / anxiety self-assessment + // is at least as sensitive as mood, so it ships dark and the user turns it on + // deliberately. The `optIn` marker inverts the per-user default; with it off + // the `/insights/mental-wellbeing` surface is hidden from nav and the + // assessment routes refuse server-side. Item answers are always encrypted and + // always excluded from the AI Coach / MCP regardless of this toggle. + "mentalHealth", ] as const; export type ModuleKey = (typeof MODULE_KEYS)[number]; @@ -298,6 +306,18 @@ export const MODULE_REGISTRY: Readonly> = // configured OCR/vision provider, so the user turns it on deliberately. optIn: true, }, + mentalHealth: { + key: "mentalHealth", + labelKey: "modules.mentalHealth.label", + descriptionKey: "modules.mentalHealth.description", + category: "tracking", + // Off by default: a depression / anxiety screener is highly sensitive, so + // the surface ships dark and the user opts in deliberately. Turning it on + // reveals the `/insights/mental-wellbeing` check-in and lets the screener + // total ride the doctor-report export; item answers stay encrypted and + // off the AI Coach / MCP regardless. + optIn: true, + }, }); const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 0f2732235..c3999f6ae 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -320,6 +320,7 @@ describe("doctor-report — module enable/disable gating", () => { environment: true, mcp: true, inboundDocuments: true, + mentalHealth: true, } as const; it("includes every module's data when all modules are enabled", async () => {