mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge: QA fix mental-health into release/v1.25.0
This commit is contained in:
commit
8e4c107a18
19 files changed
+606
-104
No files matched your search
+6
-1
@@ -240,7 +240,8 @@
|
||||
"labs": "Laborwerte",
|
||||
"vorsorge": "Vorsorge",
|
||||
"illness": "Krankheit",
|
||||
"documents": "Dokumente"
|
||||
"documents": "Dokumente",
|
||||
"mentalWellbeing": "Seelisches Wohlbefinden"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -7317,6 +7318,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": {
|
||||
|
||||
+6
-1
@@ -240,7 +240,8 @@
|
||||
"labs": "Labs",
|
||||
"vorsorge": "Checkups",
|
||||
"illness": "Illness",
|
||||
"documents": "Documents"
|
||||
"documents": "Documents",
|
||||
"mentalWellbeing": "Mental wellbeing"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -7317,6 +7318,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": {
|
||||
|
||||
+27
-22
@@ -240,7 +240,8 @@
|
||||
"labs": "Análisis",
|
||||
"vorsorge": "Revisiones",
|
||||
"illness": "Enfermedad",
|
||||
"documents": "Documentos"
|
||||
"documents": "Documentos",
|
||||
"mentalWellbeing": "Bienestar mental"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -7317,6 +7318,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": {
|
||||
@@ -7617,41 +7622,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"
|
||||
|
||||
+27
-22
@@ -240,7 +240,8 @@
|
||||
"labs": "Analyses",
|
||||
"vorsorge": "Examens",
|
||||
"illness": "Maladie",
|
||||
"documents": "Documents"
|
||||
"documents": "Documents",
|
||||
"mentalWellbeing": "Bien-être mental"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Se connecter",
|
||||
@@ -7317,6 +7318,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": {
|
||||
@@ -7617,41 +7622,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"
|
||||
|
||||
+27
-22
@@ -240,7 +240,8 @@
|
||||
"labs": "Esami",
|
||||
"vorsorge": "Controlli",
|
||||
"illness": "Malattia",
|
||||
"documents": "Documenti"
|
||||
"documents": "Documenti",
|
||||
"mentalWellbeing": "Benessere mentale"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -7317,6 +7318,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": {
|
||||
@@ -7617,41 +7622,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"
|
||||
|
||||
+27
-22
@@ -240,7 +240,8 @@
|
||||
"labs": "Wyniki badań",
|
||||
"vorsorge": "Badania",
|
||||
"illness": "Choroba",
|
||||
"documents": "Dokumenty"
|
||||
"documents": "Dokumenty",
|
||||
"mentalWellbeing": "Dobrostan psychiczny"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj się",
|
||||
@@ -7317,6 +7318,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": {
|
||||
@@ -7617,41 +7622,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"
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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<Response>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<MentalWellbeing />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Brain,
|
||||
Droplets,
|
||||
FileScan,
|
||||
FlaskConical,
|
||||
@@ -82,6 +83,19 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
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",
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [result, setResult] = useState<CreateResponse | null>(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 `<body>` and land on the new content.
|
||||
const resultHeadingRef = useRef<HTMLDivElement | null>(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() {
|
||||
</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("mentalHealth.result.title")}</CardTitle>
|
||||
<CardTitle
|
||||
ref={resultHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 outline-none"
|
||||
>
|
||||
{t("mentalHealth.result.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
@@ -281,7 +302,11 @@ export function MentalWellbeing() {
|
||||
</Card>
|
||||
|
||||
{result.crisis && (
|
||||
<Card className="border-amber-400/50">
|
||||
<Card
|
||||
className="border-amber-400/50"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.crisis.title")}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Activity,
|
||||
Award,
|
||||
Blocks,
|
||||
Brain,
|
||||
CalendarHeart,
|
||||
Droplet,
|
||||
CloudSun,
|
||||
@@ -80,6 +81,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
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() {
|
||||
|
||||
@@ -86,6 +86,7 @@ function moduleMap(
|
||||
environment: true,
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
@@ -1515,6 +1515,16 @@ const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||||
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<T>(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Record<ModuleKey, ModuleDefinition>> =
|
||||
// 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<string> = new Set(MODULE_KEYS);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user