From 15e740bc2b16af7da85c7bded75e481c7a7da074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:25:16 +0200 Subject: [PATCH 1/6] i18n(mental-health): ship validated PHQ-9/GAD-7 translations and localize crisis copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHQ-9/GAD-7 item wordings rendered in English for es/fr/it/pl users (de was already validated). Replace them with the officially validated, public-domain Pfizer/PRIME-MD localized screener wordings (distributed via the MAPI Research Trust) rather than ad-hoc translations — re-wording a validated instrument invalidates the score. Localize the crisis-resource copy too: translate the descriptive parentheticals while keeping the org proper-nouns (TelefonSeelsorge, Nummer gegen Kummer, krisenchat, Find a Helpline) and the US brand lines (988, Crisis Text Line) verbatim. Add the new mental-wellbeing module label/description and the nav label across all six locales. --- messages/de.json | 7 +++- messages/en.json | 7 +++- messages/es.json | 49 +++++++++++++++------------- messages/fr.json | 49 +++++++++++++++------------- messages/it.json | 49 +++++++++++++++------------- messages/pl.json | 49 +++++++++++++++------------- src/lib/mental-health/instruments.ts | 5 ++- 7 files changed, 124 insertions(+), 91 deletions(-) diff --git a/messages/de.json b/messages/de.json index dc0e00086..b6ec0e31c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -240,7 +240,8 @@ "labs": "Laborwerte", "vorsorge": "Vorsorge", "illness": "Krankheit", - "documents": "Dokumente" + "documents": "Dokumente", + "mentalWellbeing": "Seelisches Wohlbefinden" }, "auth": { "login": "Anmelden", @@ -7267,6 +7268,10 @@ "inboundDocuments": { "label": "Eingehende Dokumente", "description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus." + }, + "mentalHealth": { + "label": "Seelisches Wohlbefinden", + "description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus." } }, "illness": { diff --git a/messages/en.json b/messages/en.json index e6e776db1..d767ff1bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -240,7 +240,8 @@ "labs": "Labs", "vorsorge": "Checkups", "illness": "Illness", - "documents": "Documents" + "documents": "Documents", + "mentalWellbeing": "Mental wellbeing" }, "auth": { "login": "Sign in", @@ -7267,6 +7268,10 @@ "inboundDocuments": { "label": "Inbound documents", "description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default." + }, + "mentalHealth": { + "label": "Mental wellbeing", + "description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach." } }, "illness": { diff --git a/messages/es.json b/messages/es.json index b9687c4c0..95a5c328d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -240,7 +240,8 @@ "labs": "Análisis", "vorsorge": "Revisiones", "illness": "Enfermedad", - "documents": "Documentos" + "documents": "Documentos", + "mentalWellbeing": "Bienestar mental" }, "auth": { "login": "Iniciar sesión", @@ -7267,6 +7268,10 @@ "inboundDocuments": { "label": "Documentos entrantes", "description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada." + }, + "mentalHealth": { + "label": "Bienestar mental", + "description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA." } }, "illness": { @@ -7567,41 +7572,41 @@ }, "items": { "phq9": { - "1": "Little interest or pleasure in doing things", - "2": "Feeling down, depressed, or hopeless", - "3": "Trouble falling or staying asleep, or sleeping too much", - "4": "Feeling tired or having little energy", - "5": "Poor appetite or overeating", - "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", - "7": "Trouble concentrating on things, such as reading the newspaper or watching television", - "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", - "9": "Thoughts that you would be better off dead, or of hurting yourself in some way" + "1": "Tener poco interés o placer en hacer las cosas", + "2": "Sentirse desanimado/a, deprimido/a o sin esperanza", + "3": "Tener problemas para dormir o mantenerse dormido/a, o dormir demasiado", + "4": "Sentirse cansado/a o tener poca energía", + "5": "Tener poco apetito o comer en exceso", + "6": "Sentirse mal con usted mismo/a, o sentir que es un fracaso o que se ha fallado a sí mismo/a o a su familia", + "7": "Tener dificultad para concentrarse en cosas tales como leer el periódico o ver la televisión", + "8": "¿Moverse o hablar tan despacio que otras personas podrían haberlo notado? O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo mucho más de lo habitual", + "9": "Pensar que estaría mejor muerto/a o en hacerse daño de alguna manera" }, "gad7": { - "1": "Feeling nervous, anxious, or on edge", - "2": "Not being able to stop or control worrying", - "3": "Worrying too much about different things", - "4": "Trouble relaxing", - "5": "Being so restless that it is hard to sit still", - "6": "Becoming easily annoyed or irritable", - "7": "Feeling afraid as if something awful might happen" + "1": "Sentirse nervioso/a, ansioso/a o muy alterado/a", + "2": "No poder dejar de preocuparse o no poder controlar la preocupación", + "3": "Preocuparse demasiado por diferentes cosas", + "4": "Tener dificultad para relajarse", + "5": "Estar tan inquieto/a que es difícil quedarse sentado/a tranquilamente", + "6": "Molestarse o irritarse fácilmente", + "7": "Sentir miedo como si algo terrible fuera a suceder" } }, "crisisResource": { "telefonSeelsorge": { - "name": "TelefonSeelsorge (24/7, free, confidential)" + "name": "TelefonSeelsorge (24/7, gratuito y confidencial)" }, "nummerGegenKummer": { - "name": "Nummer gegen Kummer (for young people)" + "name": "Nummer gegen Kummer (para jóvenes)" }, "krisenchat": { - "name": "krisenchat (24/7 chat, young people)" + "name": "krisenchat (chat 24/7, para jóvenes)" }, "findahelpline": { - "name": "Find a helpline (worldwide directory)" + "name": "Find a Helpline (directorio mundial)" }, "euEmotionalSupport": { - "name": "Emotional-support helpline" + "name": "Línea de apoyo emocional" }, "lifeline988": { "name": "988 Suicide & Crisis Lifeline" diff --git a/messages/fr.json b/messages/fr.json index 7df786db9..b08cedb2d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -240,7 +240,8 @@ "labs": "Analyses", "vorsorge": "Examens", "illness": "Maladie", - "documents": "Documents" + "documents": "Documents", + "mentalWellbeing": "Bien-être mental" }, "auth": { "login": "Se connecter", @@ -7267,6 +7268,10 @@ "inboundDocuments": { "label": "Documents entrants", "description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut." + }, + "mentalHealth": { + "label": "Bien-être mental", + "description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA." } }, "illness": { @@ -7567,41 +7572,41 @@ }, "items": { "phq9": { - "1": "Little interest or pleasure in doing things", - "2": "Feeling down, depressed, or hopeless", - "3": "Trouble falling or staying asleep, or sleeping too much", - "4": "Feeling tired or having little energy", - "5": "Poor appetite or overeating", - "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", - "7": "Trouble concentrating on things, such as reading the newspaper or watching television", - "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", - "9": "Thoughts that you would be better off dead, or of hurting yourself in some way" + "1": "Peu d'intérêt ou de plaisir à faire les choses", + "2": "Se sentir triste, déprimé(e) ou désespéré(e)", + "3": "Difficultés à s'endormir ou à rester endormi(e), ou dormir trop", + "4": "Se sentir fatigué(e) ou avoir peu d'énergie", + "5": "Avoir peu d'appétit ou manger trop", + "6": "Avoir une mauvaise opinion de soi-même — ou avoir le sentiment d'être nul(le) ou d'avoir déçu sa famille ou soi-même", + "7": "Avoir du mal à se concentrer, par exemple pour lire le journal ou regarder la télévision", + "8": "Bouger ou parler si lentement que les autres ont pu le remarquer. Ou au contraire, être si agité(e) que vous avez bougé beaucoup plus que d'habitude", + "9": "Penser qu'il vaudrait mieux mourir ou envisager de vous faire du mal d'une manière ou d'une autre" }, "gad7": { - "1": "Feeling nervous, anxious, or on edge", - "2": "Not being able to stop or control worrying", - "3": "Worrying too much about different things", - "4": "Trouble relaxing", - "5": "Being so restless that it is hard to sit still", - "6": "Becoming easily annoyed or irritable", - "7": "Feeling afraid as if something awful might happen" + "1": "Se sentir nerveux/nerveuse, anxieux/anxieuse ou tendu(e)", + "2": "Être incapable d'arrêter de vous inquiéter ou de contrôler vos inquiétudes", + "3": "Vous inquiéter trop à propos de choses diverses", + "4": "Avoir du mal à vous détendre", + "5": "Être si agité(e) qu'il est difficile de rester tranquille", + "6": "Devenir facilement contrarié(e) ou irritable", + "7": "Avoir peur que quelque chose d'épouvantable puisse arriver" } }, "crisisResource": { "telefonSeelsorge": { - "name": "TelefonSeelsorge (24/7, free, confidential)" + "name": "TelefonSeelsorge (24h/24, 7j/7, gratuit et confidentiel)" }, "nummerGegenKummer": { - "name": "Nummer gegen Kummer (for young people)" + "name": "Nummer gegen Kummer (pour les jeunes)" }, "krisenchat": { - "name": "krisenchat (24/7 chat, young people)" + "name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)" }, "findahelpline": { - "name": "Find a helpline (worldwide directory)" + "name": "Find a Helpline (annuaire mondial)" }, "euEmotionalSupport": { - "name": "Emotional-support helpline" + "name": "Ligne d'écoute et de soutien émotionnel" }, "lifeline988": { "name": "988 Suicide & Crisis Lifeline" diff --git a/messages/it.json b/messages/it.json index e4f800775..24b7ab6cc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -240,7 +240,8 @@ "labs": "Esami", "vorsorge": "Controlli", "illness": "Malattia", - "documents": "Documenti" + "documents": "Documenti", + "mentalWellbeing": "Benessere mentale" }, "auth": { "login": "Accedi", @@ -7267,6 +7268,10 @@ "inboundDocuments": { "label": "Documenti in entrata", "description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita." + }, + "mentalHealth": { + "label": "Benessere mentale", + "description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA." } }, "illness": { @@ -7567,41 +7572,41 @@ }, "items": { "phq9": { - "1": "Little interest or pleasure in doing things", - "2": "Feeling down, depressed, or hopeless", - "3": "Trouble falling or staying asleep, or sleeping too much", - "4": "Feeling tired or having little energy", - "5": "Poor appetite or overeating", - "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", - "7": "Trouble concentrating on things, such as reading the newspaper or watching television", - "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", - "9": "Thoughts that you would be better off dead, or of hurting yourself in some way" + "1": "Scarso interesse o piacere nel fare le cose", + "2": "Sentirsi giù di morale, depresso/a o disperato/a", + "3": "Difficoltà ad addormentarsi o a mantenere il sonno, oppure dormire troppo", + "4": "Sentirsi stanco/a o avere poca energia", + "5": "Scarso appetito o mangiare troppo", + "6": "Avere una cattiva opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari", + "7": "Difficoltà a concentrarsi, ad esempio nel leggere il giornale o nel guardare la televisione", + "8": "Muoversi o parlare così lentamente che altre persone potrebbero averlo notato? Oppure, al contrario, essere così agitato/a o irrequieto/a da muoversi molto più del solito", + "9": "Pensare che sarebbe meglio morire o di farsi del male in qualche modo" }, "gad7": { - "1": "Feeling nervous, anxious, or on edge", - "2": "Not being able to stop or control worrying", - "3": "Worrying too much about different things", - "4": "Trouble relaxing", - "5": "Being so restless that it is hard to sit still", - "6": "Becoming easily annoyed or irritable", - "7": "Feeling afraid as if something awful might happen" + "1": "Sentirsi nervoso/a, ansioso/a o teso/a", + "2": "Non riuscire a smettere di preoccuparsi o a controllare le preoccupazioni", + "3": "Preoccuparsi troppo per cose diverse", + "4": "Avere difficoltà a rilassarsi", + "5": "Essere così irrequieto/a da far fatica a stare seduto/a fermo/a", + "6": "Infastidirsi o irritarsi facilmente", + "7": "Avere paura come se potesse accadere qualcosa di terribile" } }, "crisisResource": { "telefonSeelsorge": { - "name": "TelefonSeelsorge (24/7, free, confidential)" + "name": "TelefonSeelsorge (24/7, gratuito e riservato)" }, "nummerGegenKummer": { - "name": "Nummer gegen Kummer (for young people)" + "name": "Nummer gegen Kummer (per i giovani)" }, "krisenchat": { - "name": "krisenchat (24/7 chat, young people)" + "name": "krisenchat (chat 24/7, per i giovani)" }, "findahelpline": { - "name": "Find a helpline (worldwide directory)" + "name": "Find a Helpline (elenco mondiale)" }, "euEmotionalSupport": { - "name": "Emotional-support helpline" + "name": "Linea di supporto emotivo" }, "lifeline988": { "name": "988 Suicide & Crisis Lifeline" diff --git a/messages/pl.json b/messages/pl.json index 52381c2ba..b16da9a79 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -240,7 +240,8 @@ "labs": "Wyniki badań", "vorsorge": "Badania", "illness": "Choroba", - "documents": "Dokumenty" + "documents": "Dokumenty", + "mentalWellbeing": "Dobrostan psychiczny" }, "auth": { "login": "Zaloguj się", @@ -7267,6 +7268,10 @@ "inboundDocuments": { "label": "Dokumenty przychodzące", "description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone." + }, + "mentalHealth": { + "label": "Dobrostan psychiczny", + "description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI." } }, "illness": { @@ -7567,41 +7572,41 @@ }, "items": { "phq9": { - "1": "Little interest or pleasure in doing things", - "2": "Feeling down, depressed, or hopeless", - "3": "Trouble falling or staying asleep, or sleeping too much", - "4": "Feeling tired or having little energy", - "5": "Poor appetite or overeating", - "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", - "7": "Trouble concentrating on things, such as reading the newspaper or watching television", - "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", - "9": "Thoughts that you would be better off dead, or of hurting yourself in some way" + "1": "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności", + "2": "Uczucie przygnębienia, depresji lub beznadziejności", + "3": "Kłopoty z zaśnięciem lub utrzymaniem snu albo zbyt długi sen", + "4": "Uczucie zmęczenia lub brak energii", + "5": "Brak apetytu lub przejadanie się", + "6": "Złe myślenie o sobie — poczucie, że jest się nieudacznikiem lub że zawiodło się siebie albo rodzinę", + "7": "Trudności ze skupieniem się, na przykład podczas czytania gazety lub oglądania telewizji", + "8": "Poruszanie się lub mówienie tak wolno, że inni mogli to zauważyć? Albo przeciwnie — niepokój lub pobudzenie powodujące, że poruszasz się znacznie więcej niż zwykle", + "9": "Myśli, że lepiej byłoby umrzeć albo chęć zrobienia sobie w jakiś sposób krzywdy" }, "gad7": { - "1": "Feeling nervous, anxious, or on edge", - "2": "Not being able to stop or control worrying", - "3": "Worrying too much about different things", - "4": "Trouble relaxing", - "5": "Being so restless that it is hard to sit still", - "6": "Becoming easily annoyed or irritable", - "7": "Feeling afraid as if something awful might happen" + "1": "Odczuwanie zdenerwowania, lęku lub napięcia", + "2": "Niemożność przerwania lub kontrolowania zamartwiania się", + "3": "Zbytnie zamartwianie się różnymi sprawami", + "4": "Trudności z odprężeniem się", + "5": "Niepokój tak duży, że trudno usiedzieć w miejscu", + "6": "Łatwe wpadanie w rozdrażnienie lub irytację", + "7": "Odczuwanie lęku, jakby miało się zdarzyć coś strasznego" } }, "crisisResource": { "telefonSeelsorge": { - "name": "TelefonSeelsorge (24/7, free, confidential)" + "name": "TelefonSeelsorge (całodobowo, bezpłatnie i poufnie)" }, "nummerGegenKummer": { - "name": "Nummer gegen Kummer (for young people)" + "name": "Nummer gegen Kummer (dla młodych ludzi)" }, "krisenchat": { - "name": "krisenchat (24/7 chat, young people)" + "name": "krisenchat (czat całodobowy, dla młodych ludzi)" }, "findahelpline": { - "name": "Find a helpline (worldwide directory)" + "name": "Find a Helpline (światowy katalog)" }, "euEmotionalSupport": { - "name": "Emotional-support helpline" + "name": "Telefon wsparcia emocjonalnego" }, "lifeline988": { "name": "988 Suicide & Crisis Lifeline" diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts index aa43364c3..d54b7ac44 100644 --- a/src/lib/mental-health/instruments.ts +++ b/src/lib/mental-health/instruments.ts @@ -6,7 +6,10 @@ * for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC * contract; the human wording lives in `messages/*.json` under * `mentalHealth.items.*` (officially-validated translations per locale, English - * fallback otherwise — re-wording invalidates the score). + * fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl + * item wordings are the public-domain Pfizer/PRIME-MD validated localized + * versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they + * are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`. * * PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the * safety-critical field (see `crisis-resources.ts` + the API handler): any From f62ba50ff07ec40d77ba1c237dd2a7f88f1c41d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:25:27 +0200 Subject: [PATCH 2/6] feat(mental-health): gate the PHQ-9/GAD-7 check-in behind an opt-in module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mental-wellbeing screener surface shipped built but unreachable: no nav entry, no module key, ungated. Add a `mentalHealth` module key (opt-in, default OFF — a depression/anxiety self-assessment is at least as sensitive as mood) to the registry, the module-hub icon map, and the exhaustive module maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated on the module so it appears only once the account opts in, and redirect the page home on a direct hit when the module is off. Item answers stay encrypted and off the AI Coach / MCP regardless of the toggle; this only controls whether the surface is visible and reachable. --- src/app/insights/mental-wellbeing/page.tsx | 37 ++++++++++++++++++- src/components/layout/nav-model.ts | 14 +++++++ src/components/settings/modules-section.tsx | 3 ++ src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/modules/__tests__/gate.test.ts | 19 +++++++--- src/lib/modules/registry.ts | 20 ++++++++++ .../doctor-report-sections.test.ts | 1 + 7 files changed, 88 insertions(+), 7 deletions(-) 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 () => { From be4bc3bd10701c8eb6120f551886a2427e94e656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:25:43 +0200 Subject: [PATCH 3/6] fix(mental-health): repair the dead-on-arrival assessment POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler passed the `safeJson` `{ data, error }` wrapper straight into `safeParse`, so the schema never matched and every POST returned 422 — the screener was dead on arrival. Destructure the envelope and read an unbounded body no more: cap the request at 8 KB before validation, matching the sibling clinical routes. Add the missing write protections: gate both verbs on the opt-in module server-side (a valid Bearer token included), wrap the create in idempotency so a double-tap/retry cannot mint duplicate assessment + score rows, and add a tight per-user write rate-limit. --- .../api/mental-health/assessments/route.ts | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index ea78e2f08..d98915794 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -19,6 +19,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, returnAllZodIssues, safeJson, @@ -26,6 +27,9 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { createAssessmentSchema, @@ -73,6 +77,11 @@ function shapeRow(row: { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // Opt-in module (default OFF): the screener history is unreachable until the + // account turns the mental-health module on. Enforced server-side even for a + // valid Bearer token (the nav entry / page redirect are UX-only). + const gate = await requireModuleEnabled(user.id, "mentalHealth"); + if (!gate.enabled) return gate.response; const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listAssessmentsSchema.safeParse(params); if (!parsed.success) { @@ -112,9 +121,48 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess({ assessments: rows.map(shapeRow) }); }); -export const POST = apiHandler(async (request: NextRequest) => { +// `withIdempotency` lets a double-tap / retry re-send the same +// `Idempotency-Key` without minting a duplicate assessment row AND a duplicate +// `*_SCORE` Measurement row that would skew the trend (the allergy / labs +// create precedent). +export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment)); + +async function postAssessment(request: NextRequest): Promise { const { user } = await requireAuth(); - const body = await safeJson(request); + + // Opt-in module (default OFF) — same gate as GET. + const gate = await requireModuleEnabled(user.id, "mentalHealth"); + if (!gate.enabled) return gate.response; + + // Write rate-limit: a screener is a deliberate, infrequent action, so the + // bucket is tight — it only caps a double-tap / scripted loop minting + // duplicate trend points (the recall window is 2 weeks; 30/hour is generous). + const rl = await checkRateLimit( + `mental-health-create:${user.id}`, + 30, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + const res = apiError( + "Too many check-ins. Please wait before submitting another.", + 429, + { errorCode: "mentalHealth.rateLimited" }, + ); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + res.headers.set(k, v); + } + return res; + } + + // Bounded body read BEFORE validation (the item answers are small; cap so an + // unbounded body can never reach `request.json()`). Destructure the + // `{ data, error }` envelope — passing the wrapper to `safeParse` would never + // match the schema, which is what left this route dead-on-arrival. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createAssessmentSchema.safeParse(body); if (!parsed.success) { annotate({ @@ -210,4 +258,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }, 201, ); -}); +} From 5f0ca355f96b0da33ebe2ab242cdac9cdf75aca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:25:43 +0200 Subject: [PATCH 4/6] fix(mental-health): a11y on the check-in result and option controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On submit the form (and its submit button) unmounts, dropping focus to the body — move focus to the result heading so keyboard/AT users land on the new content. Announce the item-9 crisis-resource card via role="alert" so a screen-reader user hears the resources. Bump the PHQ/GAD scale and functional-difficulty option buttons to the 44px touch-target floor. --- .../mental-health/mental-wellbeing.tsx | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 21974aac3..1cd85d9c1 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -13,7 +13,7 @@ * positive PHQ-9 item-9 the server returns a calm, locale-aware crisis-resource * set which is rendered immediately below the result. */ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -63,6 +63,16 @@ export function MentalWellbeing() { const [functional, setFunctional] = useState(null); const [result, setResult] = useState(null); + // a11y: on submit the form (incl. the submit button) unmounts and `phase` + // flips to "result". Move focus to the result heading so keyboard / screen- + // reader users are not dropped to `` and land on the new content. + const resultHeadingRef = useRef(null); + useEffect(() => { + if (phase === "result") { + resultHeadingRef.current?.focus(); + } + }, [phase]); + const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7"); const { data: history } = useQuery({ @@ -191,6 +201,9 @@ export function MentalWellbeing() { key={v} type="button" size="sm" + // 44px touch-target floor (WCAG 2.5.5) — these are the + // questionnaire option controls a full PHQ-9 taps 36 times. + className="min-h-11" variant={items[i] === v ? "default" : "outline"} aria-pressed={items[i] === v} onClick={() => @@ -221,6 +234,8 @@ export function MentalWellbeing() { key={v} type="button" size="sm" + // 44px touch-target floor (WCAG 2.5.5), matching the scale. + className="min-h-11" variant={functional === v ? "default" : "outline"} aria-pressed={functional === v} onClick={() => setFunctional(v)} @@ -251,7 +266,13 @@ export function MentalWellbeing() {

- {t("mentalHealth.result.title")} + + {t("mentalHealth.result.title")} +
@@ -281,7 +302,11 @@ export function MentalWellbeing() { {result.crisis && ( - + {t("mentalHealth.crisis.title")} From 12579466c08eec483bf94e952acdbcbe1146ec49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:25:43 +0200 Subject: [PATCH 5/6] fix(mental-health): gate screener-score export on the opt-in module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHQ-9/GAD-7 totals flowed into the doctor-report / FHIR export unconditionally while the AI/MCP surfaces were correctly fenced off. Map the two `*_SCORE` measurement types to the mental-health module so the totals export only once the account opts the module in (default OFF — privacy by default), consistent with how mood/sleep/glucose gate. Item-level answers are never a measurement, so they can never leak through this path. --- src/lib/doctor-report-data.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index d2783626d..112084178 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -1515,6 +1515,16 @@ const MEASUREMENT_TYPE_MODULE: Record = { RECOVERY_SCORE: "recovery", STRESS_SCORE: "recovery", BLOOD_GLUCOSE: "glucose", + // v1.25.0 — the PHQ-9 / GAD-7 screener TOTALS ride the doctor-report / FHIR + // export only when the opt-in mental-health module is on (default OFF → + // excluded by default, privacy-by-default). Item-level answers are never a + // Measurement, so they can never leak through this path regardless. The total + // is the intended clinical artefact (total + band per clinical-instruments.md + // §4); gating it on the module keeps the export consistent with how mood / + // sleep / glucose are gated and avoids exporting a screener the account never + // opted into. + PHQ9_SCORE: "mentalHealth", + GAD7_SCORE: "mentalHealth", }; function filterMeasurementKeys( From d9c419aab294d45b8331ebde178cc0d11bd61e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:25:43 +0200 Subject: [PATCH 6/6] test(mental-health): cover PHQ-9/GAD-7 scoring and the assessment POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the highest-consequence numbers in the release: every PHQ-9/GAD-7 severity band edge, the >=10 action threshold, and the item-9 ANY-non-zero safety flag (true on item9 1/2/3, false on 0 or for GAD-7). Add a POST happy-path test that would have caught the dead-on-arrival bug — a valid body now parses, persists server-authoritative scoring, writes the derived score measurement, and surfaces the crisis set on a positive item 9. --- .../assessments/__tests__/route.test.ts | 211 ++++++++++++++++++ .../__tests__/instruments.test.ts | 94 ++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/app/api/mental-health/assessments/__tests__/route.test.ts create mode 100644 src/lib/mental-health/__tests__/instruments.test.ts diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts new file mode 100644 index 000000000..40bdc9bea --- /dev/null +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25.0 — `POST /api/mental-health/assessments` happy path. + * + * Regression guard for the dead-on-arrival bug: the handler used to pass the + * `safeJson` `{ data, error }` WRAPPER straight into `safeParse`, so the schema + * never matched and every POST returned 422. This pins that a valid body now + * (a) parses, (b) persists with a server-authoritative total / band / item-9 + * flag, (c) writes the derived `*_SCORE` Measurement, and (d) surfaces the + * locale-aware crisis set on a positive item 9. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + mentalHealthAssessment: { create: vi.fn() }, + measurement: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 29, resetAt: Date.now() }), + rateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => Buffer.from("ciphertext")), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest( + new URL("http://localhost/api/mental-health/assessments"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now(), + }); +}); + +describe("POST /api/mental-health/assessments", () => { + it("parses a valid PHQ-9 body and returns 201 with the server-computed total/band/flag", async () => { + // Item 9 = 2 → safety-flagged; total = 8*1 + 2 = 10 → moderate band. + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_1", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 10, + severityBand: "moderate", + item9Flagged: true, + crisisShownAt: new Date("2026-06-28T00:00:00.000Z"), + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 1, 1, 1, 1, 2], + locale: "en", + }), + ); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { + assessment: { + totalScore: number; + severityBand: string; + item9Flagged: boolean; + }; + actionThreshold: number; + crisis: { emergencyNumber: string } | null; + }; + }; + expect(body.data.assessment.totalScore).toBe(10); + expect(body.data.assessment.severityBand).toBe("moderate"); + expect(body.data.assessment.item9Flagged).toBe(true); + expect(body.data.actionThreshold).toBe(10); + // Positive item 9 → crisis resources surfaced immediately. + expect(body.data.crisis).not.toBeNull(); + expect(body.data.crisis?.emergencyNumber).toBeTruthy(); + }); + + it("persists server-authoritative scoring (userId from session, total recomputed) + the derived measurement", async () => { + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_2", + instrument: "GAD7", + locale: "en", + version: "standard", + totalScore: 6, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + // GAD-7, total 1+2+0+1+1+1+0 = 6 → mild, no safety item. + const res = await callPost( + makeReq({ + instrument: "GAD7", + items: [1, 2, 0, 1, 1, 1, 0], + }), + ); + expect(res.status).toBe(201); + + const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock + .calls[0][0] as { + data: { + userId: string; + instrument: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + }; + }; + expect(createArg.data.userId).toBe("user-1"); + expect(createArg.data.instrument).toBe("GAD7"); + expect(createArg.data.totalScore).toBe(6); + expect(createArg.data.severityBand).toBe("mild"); + expect(createArg.data.item9Flagged).toBe(false); + + // Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment. + const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { + data: { type: string; value: number; unit: string; externalId: string }; + }; + expect(measArg.data.type).toBe("GAD7_SCORE"); + expect(measArg.data.value).toBe(6); + expect(measArg.data.unit).toBe("score"); + expect(measArg.data.externalId).toBe("assessment:mha_2"); + }); + + it("rejects a wrong item count with 422 and never writes", async () => { + const res = await callPost( + makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9 + ); + expect(res.status).toBe(422); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); + + it("returns the module 403 when the mental-health module is off", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("disabled", 403, { errorCode: "module.disabled" }), + }); + const res = await callPost( + makeReq({ instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] }), + ); + expect(res.status).toBe(403); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts new file mode 100644 index 000000000..a97e92853 --- /dev/null +++ b/src/lib/mental-health/__tests__/instruments.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; + +import { + INSTRUMENTS, + scoreTotal, + severityBand, + isSafetyFlagged, +} from "../instruments"; + +/** + * v1.25.0 — pin the highest-consequence numbers in the release: the PHQ-9 / + * GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and + * 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety + * flag. A future off-by-one on any band edge or the safety predicate must fail + * here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in + * `.planning/research/v125/clinical-instruments.md`. + */ +describe("mental-health instruments", () => { + describe("scoreTotal", () => { + it("sums the item answers", () => { + expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13); + expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0); + }); + + it("returns the max when every item is 3", () => { + expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max + expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max + }); + }); + + describe("severityBand — PHQ-9 edges (0–4 / 5–9 / 10–14 / 15–19 / 20–27)", () => { + const cases: Array<[number, string]> = [ + [0, "minimal"], + [4, "minimal"], + [5, "mild"], + [9, "mild"], + [10, "moderate"], + [14, "moderate"], + [15, "modSevere"], + [19, "modSevere"], + [20, "severe"], + [27, "severe"], + ]; + it.each(cases)("PHQ9 total %i → %s", (total, band) => { + expect(severityBand("PHQ9", total)).toBe(band); + }); + }); + + describe("severityBand — GAD-7 edges (0–4 / 5–9 / 10–14 / 15–21)", () => { + const cases: Array<[number, string]> = [ + [0, "minimal"], + [4, "minimal"], + [5, "mild"], + [9, "mild"], + [10, "moderate"], + [14, "moderate"], + [15, "severe"], + [21, "severe"], + ]; + it.each(cases)("GAD7 total %i → %s", (total, band) => { + expect(severityBand("GAD7", total)).toBe(band); + }); + }); + + describe("action threshold is ≥10 for both instruments", () => { + it("PHQ-9", () => { + expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10); + }); + it("GAD-7", () => { + expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10); + }); + }); + + describe("isSafetyFlagged — PHQ-9 item 9 (index 8), ANY non-zero", () => { + it("flags when item 9 = 1, 2, or 3 regardless of total", () => { + for (const answer of [1, 2, 3]) { + const items = [0, 0, 0, 0, 0, 0, 0, 0, answer]; + expect(isSafetyFlagged("PHQ9", items)).toBe(true); + } + }); + + it("does not flag when item 9 = 0 even with a high total", () => { + // Every other item maxed (24), item 9 = 0 → severe band, no flag. + const items = [3, 3, 3, 3, 3, 3, 3, 3, 0]; + expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe"); + expect(isSafetyFlagged("PHQ9", items)).toBe(false); + }); + + it("never flags GAD-7 (no safety item)", () => { + expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false); + expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull(); + }); + }); +});