mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Show the answers behind a screening score
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been stored encrypted since the assessments shipped, and nothing ever read them back — the clinical detail (item 9 included) was invisible. A new by-id read decrypts them for whoever holds the mind read grant (the same grant that already serves the mood diary's prose and the item-9 flag), degrades to an honest items-unavailable state on any decrypt or shape problem, and the history rows gain an expandable breakdown rendering the official instrument texts the check-in flow already carries — nothing retranslated, no alarm styling on item 9 beyond what the house already does. Rides along: the guard inventories learn the branch's new routes (frozen counts moved with reasons), the reminder-engine test harnesses carry the ledger delegate, and the custom-metric restore toast keys sit in their right namespace in all six locales.
This commit is contained in:
1 parent
651d37b494
commit
90bad17cc8
23 files changed
+1036
-34
No files matched your search
+133
-1
@@ -12114,6 +12114,32 @@ paths:
|
||||
"403": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mental-health/assessments/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Mental health
|
||||
summary: Read one administration with its decrypted answers
|
||||
description: "Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's
|
||||
unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot
|
||||
be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band
|
||||
/ flag still answer. A soft-deleted or foreign row answers 404."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: The administration with its answer breakdown.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope"
|
||||
"401": *a1
|
||||
"403": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/allergies:
|
||||
get:
|
||||
tags:
|
||||
@@ -14558,7 +14584,7 @@ paths:
|
||||
- Dashboard
|
||||
summary: Reset the calling user's dashboard widget layout
|
||||
description: Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart
|
||||
overlays, selected score rings, and hero ring order. Idempotent.
|
||||
overlays, selected score rings, hero ring order, and hero primary content. Idempotent.
|
||||
responses:
|
||||
"200":
|
||||
description: Web-controlled layout settings reset; preserved dashboard preferences are returned with the defaults.
|
||||
@@ -18917,6 +18943,14 @@ components:
|
||||
- tension_window
|
||||
- same_time_baseline
|
||||
- upcoming_visit
|
||||
hero:
|
||||
description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the
|
||||
Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is
|
||||
omitted from responses.'
|
||||
type: string
|
||||
enum:
|
||||
- score
|
||||
- reminders
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
@@ -39796,6 +39830,96 @@ components:
|
||||
- id
|
||||
- contacts
|
||||
additionalProperties: false
|
||||
GetMentalHealthAssessmentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/GetMentalHealthAssessmentResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetMentalHealthAssessmentResponse:
|
||||
type: object
|
||||
properties:
|
||||
assessment:
|
||||
$ref: "#/components/schemas/MentalHealthAssessmentDetail"
|
||||
required:
|
||||
- assessment
|
||||
additionalProperties: false
|
||||
MentalHealthAssessmentDetail:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
instrument:
|
||||
type: string
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- WHO5
|
||||
- SCI
|
||||
locale:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
totalScore:
|
||||
type: number
|
||||
severityBand:
|
||||
type: string
|
||||
item9Flagged:
|
||||
type: boolean
|
||||
crisisShownAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
takenAt:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
items:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
functionalDifficulty:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
itemsUnavailable:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- instrument
|
||||
- locale
|
||||
- version
|
||||
- totalScore
|
||||
- severityBand
|
||||
- item9Flagged
|
||||
- crisisShownAt
|
||||
- takenAt
|
||||
- createdAt
|
||||
- items
|
||||
- functionalDifficulty
|
||||
- itemsUnavailable
|
||||
additionalProperties: false
|
||||
description: "One administration with its decrypted per-item answers, in the order the instrument presents its items
|
||||
(PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional
|
||||
follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is
|
||||
null, `itemsUnavailable` is true, and the denormalised score fields still answer."
|
||||
ListAllergiesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -42696,6 +42820,14 @@ components:
|
||||
- tension_window
|
||||
- same_time_baseline
|
||||
- upcoming_visit
|
||||
hero:
|
||||
description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the
|
||||
Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is
|
||||
omitted from responses.'
|
||||
type: string
|
||||
enum:
|
||||
- score
|
||||
- reminders
|
||||
updatedAt:
|
||||
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
|
||||
`baseUpdatedAt` on the next write. Opaque."
|
||||
|
||||
+14
-3
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Highlights für heute",
|
||||
"heroItemsDescription": "Wähle aus, welche Eintragstypen in den Highlights für heute erscheinen.",
|
||||
"heroItemsNotificationNote": "Benachrichtigungseinstellungen werden separat verwaltet.",
|
||||
"heroContentTitle": "Obere Karte zeigt",
|
||||
"heroContentScore": "Gesundheitsscore und Tagesüberblick",
|
||||
"heroContentReminders": "Highlights für heute",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Coach-Check-ins",
|
||||
"dose_window": "Medikamentendosen",
|
||||
@@ -4529,7 +4532,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health hat keine Web-Verbindung — schließe das Onboarding hier ab und richte Apple Health danach in der HealthLog-App auf deinem iPhone ein."
|
||||
},
|
||||
"import": "Bestehende Daten importieren"
|
||||
},
|
||||
@@ -6388,6 +6392,13 @@
|
||||
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
|
||||
"importAction": "Einmaligen Import öffnen",
|
||||
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.",
|
||||
"progress": {
|
||||
"title": "Sync-Fortschritt",
|
||||
"receivedLabel": "Empfangene Datensätze",
|
||||
"oldestLabel": "Älteste erreichte Messung",
|
||||
"flowing": "In der letzten Stunde sind Daten angekommen — die iPhone-App liefert weiter.",
|
||||
"waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt — eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Übermittlung",
|
||||
"lastTriggerLabel": "Letzte Synchronisierung kam",
|
||||
@@ -9710,8 +9721,6 @@
|
||||
"deleteConfirmTitle": "Diese eigene Metrik löschen?",
|
||||
"deleteConfirmDescription": "Die Metrik wird aus deiner Liste entfernt. Erfasste Werte bleiben erhalten.",
|
||||
"deletedToast": "Eigene Metrik gelöscht",
|
||||
"restoredToast": "Wert wiederhergestellt",
|
||||
"restoreError": "Wiederherstellen fehlgeschlagen",
|
||||
"deleteError": "Eigene Metrik konnte nicht gelöscht werden",
|
||||
"showAllValues": "Alle Werte anzeigen",
|
||||
"emptyDescription": "Definiere eigene Messwerte, um sie zu verfolgen und darzustellen.",
|
||||
@@ -9761,6 +9770,8 @@
|
||||
"editedToast": "Wert aktualisiert",
|
||||
"editError": "Wert konnte nicht aktualisiert werden",
|
||||
"deletedToast": "Wert gelöscht",
|
||||
"restoredToast": "Messwert wiederhergestellt",
|
||||
"restoreError": "Wiederherstellen fehlgeschlagen",
|
||||
"deleteError": "Wert konnte nicht gelöscht werden"
|
||||
},
|
||||
"chart": {
|
||||
|
||||
+14
-3
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Today highlights",
|
||||
"heroItemsDescription": "Choose which item types can appear in Today highlights.",
|
||||
"heroItemsNotificationNote": "Notification settings are managed separately.",
|
||||
"heroContentTitle": "Top card shows",
|
||||
"heroContentScore": "Health score and daily summary",
|
||||
"heroContentReminders": "Today highlights",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Coach check-ins",
|
||||
"dose_window": "Medication doses",
|
||||
@@ -4529,7 +4532,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup."
|
||||
},
|
||||
"import": "Import existing history"
|
||||
},
|
||||
@@ -6388,6 +6392,13 @@
|
||||
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
|
||||
"importAction": "Open one-shot import",
|
||||
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
|
||||
"progress": {
|
||||
"title": "Sync progress",
|
||||
"receivedLabel": "Records received",
|
||||
"oldestLabel": "Oldest reading reached",
|
||||
"flowing": "Data arrived within the last hour — the iPhone app is still delivering.",
|
||||
"waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Delivery",
|
||||
"lastTriggerLabel": "Latest sync arrived",
|
||||
@@ -9710,8 +9721,6 @@
|
||||
"deleteConfirmTitle": "Delete this custom metric?",
|
||||
"deleteConfirmDescription": "The metric is removed from your list. Its logged values are kept.",
|
||||
"deletedToast": "Custom metric deleted",
|
||||
"restoredToast": "Value restored",
|
||||
"restoreError": "Restore failed",
|
||||
"deleteError": "Could not delete the custom metric",
|
||||
"showAllValues": "Show all values",
|
||||
"emptyDescription": "Define your own measurements to track and chart them.",
|
||||
@@ -9761,6 +9770,8 @@
|
||||
"editedToast": "Value updated",
|
||||
"editError": "Could not update the value",
|
||||
"deletedToast": "Value deleted",
|
||||
"restoredToast": "Value restored",
|
||||
"restoreError": "Restore failed",
|
||||
"deleteError": "Could not delete the value"
|
||||
},
|
||||
"chart": {
|
||||
|
||||
+14
-3
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Destacados de hoy",
|
||||
"heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.",
|
||||
"heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.",
|
||||
"heroContentTitle": "La tarjeta superior muestra",
|
||||
"heroContentScore": "Puntuación de salud y resumen del día",
|
||||
"heroContentReminders": "Destacados de hoy",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Revisión con Coach",
|
||||
"dose_window": "Dosis de medicamentos",
|
||||
@@ -4529,7 +4532,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health."
|
||||
},
|
||||
"import": "Importar historial existente"
|
||||
},
|
||||
@@ -6388,6 +6392,13 @@
|
||||
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
|
||||
"importAction": "Abrir importación puntual",
|
||||
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
|
||||
"progress": {
|
||||
"title": "Progreso de sincronización",
|
||||
"receivedLabel": "Registros recibidos",
|
||||
"oldestLabel": "Lectura más antigua alcanzada",
|
||||
"flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.",
|
||||
"waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Entrega",
|
||||
"lastTriggerLabel": "La última sincronización llegó",
|
||||
@@ -9710,8 +9721,6 @@
|
||||
"deleteConfirmTitle": "¿Eliminar esta métrica personalizada?",
|
||||
"deleteConfirmDescription": "La métrica se quita de tu lista. Los valores registrados se conservan.",
|
||||
"deletedToast": "Métrica personalizada eliminada",
|
||||
"restoredToast": "Valor restaurado",
|
||||
"restoreError": "No se pudo restaurar",
|
||||
"deleteError": "No se pudo eliminar la métrica personalizada",
|
||||
"showAllValues": "Ver todos los valores",
|
||||
"emptyDescription": "Define tus propias mediciones para registrarlas y representarlas.",
|
||||
@@ -9761,6 +9770,8 @@
|
||||
"editedToast": "Valor actualizado",
|
||||
"editError": "No se pudo actualizar el valor",
|
||||
"deletedToast": "Valor eliminado",
|
||||
"restoredToast": "Valor restaurado",
|
||||
"restoreError": "No se pudo restaurar",
|
||||
"deleteError": "No se pudo eliminar el valor"
|
||||
},
|
||||
"chart": {
|
||||
|
||||
+14
-3
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Temps forts du jour",
|
||||
"heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.",
|
||||
"heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.",
|
||||
"heroContentTitle": "La carte du haut affiche",
|
||||
"heroContentScore": "Score de santé et résumé du jour",
|
||||
"heroContentReminders": "Temps forts du jour",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Bilan avec Coach",
|
||||
"dose_window": "Doses de médicaments",
|
||||
@@ -4529,7 +4532,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health."
|
||||
},
|
||||
"import": "Importer un historique existant"
|
||||
},
|
||||
@@ -6388,6 +6392,13 @@
|
||||
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
|
||||
"importAction": "Ouvrir l’import ponctuel",
|
||||
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
|
||||
"progress": {
|
||||
"title": "Progression de la synchronisation",
|
||||
"receivedLabel": "Enregistrements reçus",
|
||||
"oldestLabel": "Mesure la plus ancienne atteinte",
|
||||
"flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.",
|
||||
"waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Livraison",
|
||||
"lastTriggerLabel": "La dernière synchronisation est arrivée",
|
||||
@@ -9710,8 +9721,6 @@
|
||||
"deleteConfirmTitle": "Supprimer cette mesure personnalisée ?",
|
||||
"deleteConfirmDescription": "La mesure est retirée de votre liste. Les valeurs enregistrées sont conservées.",
|
||||
"deletedToast": "Mesure personnalisée supprimée",
|
||||
"restoredToast": "Valeur restaurée",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"deleteError": "Impossible de supprimer la mesure personnalisée",
|
||||
"showAllValues": "Afficher toutes les valeurs",
|
||||
"emptyDescription": "Définissez vos propres mesures pour les suivre et les représenter.",
|
||||
@@ -9761,6 +9770,8 @@
|
||||
"editedToast": "Valeur mise à jour",
|
||||
"editError": "Impossible de mettre à jour la valeur",
|
||||
"deletedToast": "Valeur supprimée",
|
||||
"restoredToast": "Valeur restaurée",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"deleteError": "Impossible de supprimer la valeur"
|
||||
},
|
||||
"chart": {
|
||||
|
||||
+14
-3
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "In evidenza oggi",
|
||||
"heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.",
|
||||
"heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.",
|
||||
"heroContentTitle": "La scheda in alto mostra",
|
||||
"heroContentScore": "Punteggio di salute e riepilogo del giorno",
|
||||
"heroContentReminders": "In evidenza oggi",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Verifiche con Coach",
|
||||
"dose_window": "Dosi dei farmaci",
|
||||
@@ -4529,7 +4532,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health."
|
||||
},
|
||||
"import": "Importa la cronologia esistente"
|
||||
},
|
||||
@@ -6388,6 +6392,13 @@
|
||||
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
|
||||
"importAction": "Apri importazione una tantum",
|
||||
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
|
||||
"progress": {
|
||||
"title": "Avanzamento della sincronizzazione",
|
||||
"receivedLabel": "Record ricevuti",
|
||||
"oldestLabel": "Lettura più vecchia raggiunta",
|
||||
"flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.",
|
||||
"waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Consegna",
|
||||
"lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
|
||||
@@ -9710,8 +9721,6 @@
|
||||
"deleteConfirmTitle": "Eliminare questa metrica personalizzata?",
|
||||
"deleteConfirmDescription": "La metrica viene rimossa dal tuo elenco. I valori registrati vengono mantenuti.",
|
||||
"deletedToast": "Metrica personalizzata eliminata",
|
||||
"restoredToast": "Valore ripristinato",
|
||||
"restoreError": "Ripristino non riuscito",
|
||||
"deleteError": "Impossibile eliminare la metrica personalizzata",
|
||||
"showAllValues": "Mostra tutti i valori",
|
||||
"emptyDescription": "Definisci le tue misurazioni per monitorarle e rappresentarle.",
|
||||
@@ -9761,6 +9770,8 @@
|
||||
"editedToast": "Valore aggiornato",
|
||||
"editError": "Impossibile aggiornare il valore",
|
||||
"deletedToast": "Valore eliminato",
|
||||
"restoredToast": "Valore ripristinato",
|
||||
"restoreError": "Ripristino non riuscito",
|
||||
"deleteError": "Impossibile eliminare il valore"
|
||||
},
|
||||
"chart": {
|
||||
|
||||
+14
-3
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Dzisiejsze wyróżnienia",
|
||||
"heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.",
|
||||
"heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.",
|
||||
"heroContentTitle": "Górna karta pokazuje",
|
||||
"heroContentScore": "Wynik zdrowia i podsumowanie dnia",
|
||||
"heroContentReminders": "Dzisiejsze wyróżnienia",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Podsumowania z Coachem",
|
||||
"dose_window": "Dawki leków",
|
||||
@@ -4529,7 +4532,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health."
|
||||
},
|
||||
"import": "Importuj istniejącą historię"
|
||||
},
|
||||
@@ -6388,6 +6392,13 @@
|
||||
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
|
||||
"importAction": "Otwórz jednorazowy import",
|
||||
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
|
||||
"progress": {
|
||||
"title": "Postęp synchronizacji",
|
||||
"receivedLabel": "Odebrane rekordy",
|
||||
"oldestLabel": "Najstarszy osiągnięty pomiar",
|
||||
"flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.",
|
||||
"waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Dostarczanie",
|
||||
"lastTriggerLabel": "Ostatnia synchronizacja dotarła",
|
||||
@@ -9710,8 +9721,6 @@
|
||||
"deleteConfirmTitle": "Usunąć tę metrykę własną?",
|
||||
"deleteConfirmDescription": "Metryka zostanie usunięta z listy. Zapisane wartości pozostaną.",
|
||||
"deletedToast": "Usunięto metrykę własną",
|
||||
"restoredToast": "Przywrócono wartość",
|
||||
"restoreError": "Nie udało się przywrócić",
|
||||
"deleteError": "Nie udało się usunąć metryki własnej",
|
||||
"showAllValues": "Pokaż wszystkie wartości",
|
||||
"emptyDescription": "Zdefiniuj własne pomiary, aby je śledzić i przedstawiać na wykresie.",
|
||||
@@ -9761,6 +9770,8 @@
|
||||
"editedToast": "Zaktualizowano wartość",
|
||||
"editError": "Nie udało się zaktualizować wartości",
|
||||
"deletedToast": "Usunięto wartość",
|
||||
"restoredToast": "Przywrócono wartość",
|
||||
"restoreError": "Nie udało się przywrócić",
|
||||
"deleteError": "Nie udało się usunąć wartości"
|
||||
},
|
||||
"chart": {
|
||||
|
||||
@@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "mind",
|
||||
why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
|
||||
},
|
||||
"app/api/mental-health/assessments/[id]/route.ts": {
|
||||
domain: "mind",
|
||||
why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.",
|
||||
},
|
||||
"app/api/anamnesis/facts/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
@@ -1937,8 +1941,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze
|
||||
* and the completion-ledger history read on the record list, plus skip and
|
||||
* snooze on the manage literal. 224 -> 229.
|
||||
*
|
||||
* The screener detail read adds one: the per-administration read that
|
||||
* decrypts the stored item answers, on the record list at the same level as
|
||||
* the screener history beside it. 229 -> 230.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 229;
|
||||
const FROZEN_ENTRY_COUNT = 230;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/custom-metrics/custom-metric-history-list.tsx": {
|
||||
"toast.success": 2,
|
||||
// v1.37.20 (A3-11) — the third success is the restore confirmation
|
||||
// behind the delete toast's Undo action.
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
|
||||
// v1.36.x — two call sites, one decision: the intake toast routes through
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -107,8 +107,8 @@ describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock.calls[0][0]
|
||||
.where;
|
||||
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock
|
||||
.calls[0]![0]!.where!;
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* `GET /api/mental-health/assessments/[id]` — the per-administration detail
|
||||
* read that finally consumes `responsesEncrypted` (audit finding: the column
|
||||
* was write-only; its single reader was key rotation).
|
||||
*
|
||||
* Pins the two halves of the contract:
|
||||
* (a) the owner's detail read decrypts the stored blob and returns the
|
||||
* per-item answers + the unscored functional follow-up, and
|
||||
* (b) a decrypt failure (key gap / corruption / shape drift) degrades to
|
||||
* `items: null` + `itemsUnavailable: true` while the denormalised
|
||||
* total / band / flag still answer — never a 500 for the whole row
|
||||
* (the records-DTO fail-soft precedent).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
mentalHealthAssessment: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
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/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
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 { GET } from "../route";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
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 callGet = GET as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function makeReq(
|
||||
id: string,
|
||||
): [NextRequest, { params: Promise<{ id: string }> }] {
|
||||
return [
|
||||
new NextRequest(
|
||||
new URL(`http://localhost/api/mental-health/assessments/${id}`),
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
];
|
||||
}
|
||||
|
||||
/** A stored PHQ-9 administration as the select returns it. */
|
||||
function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "mha_1",
|
||||
userId: "user-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"),
|
||||
responsesEncrypted: Buffer.from("ciphertext"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
describe("GET /api/mental-health/assessments/[id]", () => {
|
||||
it("returns the decrypted per-item answers + functional follow-up for the owner", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(decryptFromBytes).mockReturnValue(
|
||||
JSON.stringify({
|
||||
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
|
||||
functionalDifficulty: 1,
|
||||
schema: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: {
|
||||
id: string;
|
||||
totalScore: number;
|
||||
items: number[] | null;
|
||||
functionalDifficulty: number | null;
|
||||
itemsUnavailable: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.id).toBe("mha_1");
|
||||
expect(body.data.assessment.items).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 2]);
|
||||
expect(body.data.assessment.functionalDifficulty).toBe(1);
|
||||
expect(body.data.assessment.itemsUnavailable).toBe(false);
|
||||
// The denormalised score fields ride along unchanged.
|
||||
expect(body.data.assessment.totalScore).toBe(10);
|
||||
});
|
||||
|
||||
it("omits functionalDifficulty as null when the stored blob has none", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow({
|
||||
instrument: "GAD7",
|
||||
totalScore: 6,
|
||||
item9Flagged: false,
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(decryptFromBytes).mockReturnValue(
|
||||
JSON.stringify({ items: [1, 2, 0, 1, 1, 1, 0], schema: 1 }),
|
||||
);
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: {
|
||||
items: number[] | null;
|
||||
functionalDifficulty: number | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.items).toEqual([1, 2, 0, 1, 1, 1, 0]);
|
||||
expect(body.data.assessment.functionalDifficulty).toBeNull();
|
||||
});
|
||||
|
||||
it("degrades to items: null + itemsUnavailable on a decrypt failure — never a 500", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(decryptFromBytes).mockImplementation(() => {
|
||||
throw new Error("Unknown encryption key id: v9");
|
||||
});
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: {
|
||||
totalScore: number;
|
||||
items: number[] | null;
|
||||
itemsUnavailable: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.items).toBeNull();
|
||||
expect(body.data.assessment.itemsUnavailable).toBe(true);
|
||||
// The score half of the row still answers.
|
||||
expect(body.data.assessment.totalScore).toBe(10);
|
||||
});
|
||||
|
||||
it("degrades on a malformed or shape-drifted plaintext (wrong item count)", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
// 3 answers on a stored PHQ-9 row: pairing them to the 9 official item
|
||||
// texts would mislabel every answer, so the read refuses to guess.
|
||||
vi.mocked(decryptFromBytes).mockReturnValue(
|
||||
JSON.stringify({ items: [1, 2, 3], schema: 1 }),
|
||||
);
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: { items: number[] | null; itemsUnavailable: boolean };
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.items).toBeNull();
|
||||
expect(body.data.assessment.itemsUnavailable).toBe(true);
|
||||
});
|
||||
|
||||
it("404s another user's row without leaking its existence", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow({ userId: "user-2" }) as never,
|
||||
);
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(404);
|
||||
// The item content never left the guard.
|
||||
expect(decryptFromBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s a missing row", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await callGet(...makeReq("mha_missing"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
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 callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.mentalHealthAssessment.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* `GET /api/mental-health/assessments/[id]` — one screener administration WITH
|
||||
* its decrypted per-item answers.
|
||||
*
|
||||
* The list route beside this one denormalises total + band + item-9 flag for
|
||||
* cheap history reads and deliberately never touches `responsesEncrypted`.
|
||||
* Until this route existed nothing read the blob back at all (its only
|
||||
* consumer was key rotation), so the stored clinical detail — including the
|
||||
* PHQ-9 item-9 answer — was invisible to the person it belongs to. This is
|
||||
* the read side of that write.
|
||||
*
|
||||
* GATING — same declaration as the sibling list read (`read`, `mind`), on
|
||||
* purpose: the mind domain already serves decrypted free text at this level
|
||||
* (the mood entry's encrypted note on `GET /api/mood-entries/[id]`, the Coach
|
||||
* reminder notes), and the list read already publishes `item9Flagged` — the
|
||||
* fact that the self-harm item was answered non-zero. A delegate the owner
|
||||
* trusted with a mind-scope read grant therefore learns nothing here that is
|
||||
* categorically beyond what the domain already shows; splitting the per-item
|
||||
* values onto a stricter gate than the mood diary's prose would be an
|
||||
* inconsistency, not a protection. The module gate resolves against the
|
||||
* RECORD, so the surface stays unreachable wherever the owner keeps the
|
||||
* module off.
|
||||
*
|
||||
* DECRYPT FAILURES degrade honestly (the records-DTO fail-soft precedent):
|
||||
* `items: null` + `itemsUnavailable: true`, with the denormalised score
|
||||
* fields still answering — a key gap on one row never 500s the read. A
|
||||
* plaintext whose item count no longer matches the instrument degrades the
|
||||
* same way, because pairing answers to the wrong official item texts would be
|
||||
* worse than showing none.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
type InstrumentDefinition,
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
interface DecodedResponses {
|
||||
/** Per-item answers in presentation order, or null when unreadable. */
|
||||
items: number[] | null;
|
||||
/** The unscored PHQ-9 functional follow-up, when it was answered. */
|
||||
functionalDifficulty: number | null;
|
||||
itemsUnavailable: boolean;
|
||||
}
|
||||
|
||||
const UNAVAILABLE: DecodedResponses = {
|
||||
items: null,
|
||||
functionalDifficulty: null,
|
||||
itemsUnavailable: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypt + decode the stored `{ items, functionalDifficulty?, schema: 1 }`
|
||||
* blob, fail-soft. Every answer is re-checked against the instrument's own
|
||||
* ceiling before it is served: the write path validated exactly these bounds,
|
||||
* so anything outside them is corruption, not data.
|
||||
*/
|
||||
function decodeResponses(
|
||||
buf: Uint8Array,
|
||||
def: InstrumentDefinition,
|
||||
): DecodedResponses {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decryptFromBytes(buf));
|
||||
} catch (err) {
|
||||
// Undecryptable payload (key gap / corruption): fail soft but log it
|
||||
// (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank.
|
||||
getEvent()?.addWarning(
|
||||
`assessment responses decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return UNAVAILABLE;
|
||||
const { items, functionalDifficulty } = parsed as {
|
||||
items?: unknown;
|
||||
functionalDifficulty?: unknown;
|
||||
};
|
||||
const valid =
|
||||
Array.isArray(items) &&
|
||||
items.length === def.itemCount &&
|
||||
items.every(
|
||||
(v) => Number.isInteger(v) && (v as number) >= 0 && v <= def.itemMax,
|
||||
);
|
||||
if (!valid) {
|
||||
getEvent()?.addWarning(
|
||||
"assessment responses shape drifted from the instrument definition",
|
||||
);
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
return {
|
||||
items: items as number[],
|
||||
functionalDifficulty:
|
||||
Number.isInteger(functionalDifficulty) &&
|
||||
(functionalDifficulty as number) >= 0 &&
|
||||
(functionalDifficulty as number) <= 3
|
||||
? (functionalDifficulty as number)
|
||||
: null,
|
||||
itemsUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "mind");
|
||||
|
||||
// Opt-in module (default OFF) — the same record-resolved gate as the list.
|
||||
const gate = await requireModuleEnabled(user.id, "mentalHealth");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
|
||||
// a soft-deleted row 404s like it does on the list.
|
||||
const row = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
instrument: true,
|
||||
locale: true,
|
||||
version: true,
|
||||
totalScore: true,
|
||||
severityBand: true,
|
||||
item9Flagged: true,
|
||||
crisisShownAt: true,
|
||||
takenAt: true,
|
||||
createdAt: true,
|
||||
responsesEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Assessment not found", 404);
|
||||
}
|
||||
|
||||
const def = INSTRUMENTS[row.instrument as InstrumentId];
|
||||
const decoded = decodeResponses(row.responsesEncrypted, def);
|
||||
|
||||
annotate({
|
||||
action: { name: "mental-health.detail" },
|
||||
// The instrument + degrade flag only — item answers are NEVER logged.
|
||||
meta: {
|
||||
instrument: row.instrument,
|
||||
items_unavailable: decoded.itemsUnavailable,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
assessment: {
|
||||
id: row.id,
|
||||
instrument: row.instrument,
|
||||
locale: row.locale,
|
||||
version: row.version,
|
||||
totalScore: row.totalScore,
|
||||
severityBand: row.severityBand,
|
||||
item9Flagged: row.item9Flagged,
|
||||
crisisShownAt: row.crisisShownAt?.toISOString() ?? null,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
items: decoded.items,
|
||||
functionalDifficulty: decoded.functionalDifficulty,
|
||||
itemsUnavailable: decoded.itemsUnavailable,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -13,7 +13,8 @@
|
||||
* interpretation, no AI ingestion of item content, no third-party alerting.
|
||||
*
|
||||
* GET lists the caller's assessments (totals + bands + flags only — item-level
|
||||
* answers are excluded from this surface by construction).
|
||||
* answers are excluded from this surface by construction; the by-id sibling
|
||||
* route is the one read that decrypts them back).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -28,6 +28,8 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
|
||||
update: { mutate: vi.fn(), isPending: false },
|
||||
remove: { mutate: vi.fn(), isPending: false },
|
||||
satisfy: { mutate: vi.fn(), isPending: false },
|
||||
skip: { mutate: vi.fn(), isPending: false },
|
||||
snooze: { mutate: vi.fn(), isPending: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* The expandable per-item answer breakdown (the read side of
|
||||
* `responsesEncrypted`). SSR-only convention (`renderToStaticMarkup`) — the
|
||||
* detail query is seeded into the cache, the fetch never runs.
|
||||
*
|
||||
* Pins: the official item texts render from the SAME `mentalHealth.items.*`
|
||||
* keys the check-in wizard uses (validated instrument copy, never re-worded),
|
||||
* each answer shows its option label + value, PHQ-9 item 9 gets NO alarm
|
||||
* styling, and the server's decrypt-failure degrade renders as the honest
|
||||
* unavailable line rather than a blank.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
import { AssessmentItemBreakdown } from "../assessment-item-breakdown";
|
||||
import type { AssessmentDetail } from "../types";
|
||||
|
||||
const mh = en.mentalHealth;
|
||||
|
||||
function detailRow(overrides: Partial<AssessmentDetail>): AssessmentDetail {
|
||||
return {
|
||||
id: "mha_1",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
totalScore: 10,
|
||||
severityBand: "moderate",
|
||||
item9Flagged: true,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-28T00:00:00.000Z",
|
||||
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
|
||||
functionalDifficulty: null,
|
||||
itemsUnavailable: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(detail: AssessmentDetail): string {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.mentalHealthAssessmentDetail(detail.id), {
|
||||
assessment: detail,
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AssessmentItemBreakdown
|
||||
assessmentId={detail.id}
|
||||
instrument={detail.instrument}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AssessmentItemBreakdown", () => {
|
||||
it("renders every official PHQ-9 item text with its answer label + value", () => {
|
||||
const html = render(detailRow({}));
|
||||
// All nine validated item texts, from the wizard's own keys.
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
expect(html).toContain(
|
||||
mh.items.phq9[String(n) as keyof typeof mh.items.phq9],
|
||||
);
|
||||
}
|
||||
// Answer labels are the shared PHQ/GAD anchors.
|
||||
expect(html).toContain(mh.options["1"]);
|
||||
expect(html).toContain(mh.options["2"]);
|
||||
});
|
||||
|
||||
it("gives PHQ-9 item 9 no alarm styling — it renders like every other item", () => {
|
||||
const html = render(detailRow({}));
|
||||
expect(html).toContain(mh.items.phq9["9"]);
|
||||
expect(html).not.toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("renders the unscored functional follow-up when it was answered", () => {
|
||||
const html = render(detailRow({ functionalDifficulty: 2 }));
|
||||
expect(html).toContain(mh.functionalTitle);
|
||||
expect(html).toContain(mh.functional["2"]);
|
||||
});
|
||||
|
||||
it("omits the functional follow-up row when it was not answered", () => {
|
||||
const html = render(detailRow({ functionalDifficulty: null }));
|
||||
expect(html).not.toContain(mh.functionalTitle);
|
||||
});
|
||||
|
||||
it("paints the WHO-5 recall stem once above its run of items", () => {
|
||||
const html = render(
|
||||
detailRow({
|
||||
instrument: "WHO5",
|
||||
items: [5, 4, 3, 2, 1],
|
||||
item9Flagged: false,
|
||||
}),
|
||||
);
|
||||
// The shared stem appears exactly once, not per item.
|
||||
const stem = mh.stems.who5.period;
|
||||
expect(html.split(stem).length - 1).toBe(1);
|
||||
expect(html).toContain(mh.items.who5["1"]);
|
||||
expect(html).toContain(mh.who5Options["5"]);
|
||||
});
|
||||
|
||||
it("renders the honest unavailable line on the server's decrypt degrade", () => {
|
||||
const html = render(detailRow({ items: null, itemsUnavailable: true }));
|
||||
expect(html).toContain(mh.history.answersUnavailable);
|
||||
expect(html).not.toContain(mh.items.phq9["1"]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,11 @@
|
||||
* the row's stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or
|
||||
* reveals the item-9 answer, and surfacing it triggers no third-party alert.
|
||||
* No new data leaves the safety boundary.
|
||||
*
|
||||
* Each row also carries an opt-in answer breakdown: expanding it fetches the
|
||||
* per-administration detail read, where the server decrypts the stored
|
||||
* per-item answers (`AssessmentItemBreakdown`). Item 9 renders there like any
|
||||
* other item — the flagged marker above stays the one crisis affordance.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -28,8 +33,10 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AssessmentItemBreakdown } from "./assessment-item-breakdown";
|
||||
import { CrisisCard } from "./crisis-card";
|
||||
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
|
||||
|
||||
@@ -80,6 +87,11 @@ export function AssessmentHistory({
|
||||
const { date: formatDate } = useFormatters();
|
||||
// Which flagged row's crisis card is currently expanded (re-surfaced).
|
||||
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
|
||||
// Which row's per-item answer breakdown is expanded (single-open, like the
|
||||
// crisis card — the detail fetch runs only when someone asks).
|
||||
const [openBreakdownRowId, setOpenBreakdownRowId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const forInstrument = useMemo(
|
||||
() =>
|
||||
@@ -116,6 +128,7 @@ export function AssessmentHistory({
|
||||
{forInstrument.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
const breakdownOpen = openBreakdownRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
@@ -146,9 +159,37 @@ export function AssessmentHistory({
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0.5"
|
||||
aria-expanded={breakdownOpen}
|
||||
aria-label={t("mentalHealth.history.answersToggle")}
|
||||
onClick={() =>
|
||||
setOpenBreakdownRowId(breakdownOpen ? null : row.id)
|
||||
}
|
||||
data-slot="history-answers-toggle"
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 transition-transform",
|
||||
breakdownOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{flagged && open && <CrisisCard crisis={crisisFromRow(row)} />}
|
||||
{breakdownOpen && (
|
||||
<div className="border-border/70 border-l pl-3">
|
||||
<AssessmentItemBreakdown
|
||||
assessmentId={row.id}
|
||||
instrument={instrument}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Per-item answer breakdown for one recorded administration, expanded from a
|
||||
* history row. Fetches the detail read (`GET …/assessments/[id]`), which is
|
||||
* where the server decrypts `responsesEncrypted` — the answers never ride the
|
||||
* list payload.
|
||||
*
|
||||
* Grammar: the detail surface's label/value rows — official item text muted
|
||||
* (validated instrument copy via the SAME `mentalHealth.items.*` keys the
|
||||
* check-in wizard renders; never re-worded here), the chosen answer label +
|
||||
* its value in foreground. The WHO-5 / SCI recall stems paint once above each
|
||||
* run of items, as on the source forms. PHQ-9 item 9 renders like every other
|
||||
* item — no alarm colour; the row's existing "support shown" marker is the
|
||||
* crisis affordance and is not duplicated here.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
optionLabelKey,
|
||||
stemKey,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
import type { AssessmentDetail, InstrumentId } from "./types";
|
||||
|
||||
export function AssessmentItemBreakdown({
|
||||
assessmentId,
|
||||
instrument,
|
||||
}: {
|
||||
assessmentId: string;
|
||||
instrument: InstrumentId;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const def = INSTRUMENTS[instrument];
|
||||
|
||||
const { data, isPending, isError } = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId),
|
||||
queryFn: () =>
|
||||
apiGet<{ assessment: AssessmentDetail }>(
|
||||
`/api/mental-health/assessments/${assessmentId}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
data-slot="assessment-item-breakdown"
|
||||
aria-busy="true"
|
||||
>
|
||||
{Array.from({ length: def.itemCount }, (_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<p
|
||||
className="text-destructive text-sm"
|
||||
role="alert"
|
||||
data-slot="assessment-item-breakdown"
|
||||
>
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = data.assessment;
|
||||
if (detail.items === null) {
|
||||
// The server's honest degrade: the blob exists but cannot be decrypted
|
||||
// (key gap / corruption). The score above this breakdown still answers.
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="assessment-item-breakdown"
|
||||
>
|
||||
{t("mentalHealth.history.answersUnavailable")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 text-sm"
|
||||
data-slot="assessment-item-breakdown"
|
||||
>
|
||||
{detail.items.map((value, index) => {
|
||||
// Stem captions group runs of items exactly as the wizard presents
|
||||
// them: paint the stem only where it differs from the previous item's.
|
||||
const stem = stemKey(instrument, index);
|
||||
const caption =
|
||||
stem !== (index > 0 ? stemKey(instrument, index - 1) : null)
|
||||
? stem
|
||||
: null;
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-1">
|
||||
{caption && (
|
||||
<p className="text-muted-foreground pt-1 text-xs">
|
||||
{t(`mentalHealth.stems.${caption}`)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-muted-foreground min-w-0 flex-1">
|
||||
{t(`mentalHealth.items.${def.i18nKey}.${index + 1}`)}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-right">
|
||||
{t(optionLabelKey(instrument, index, value))}
|
||||
{" · "}
|
||||
<span className="tabular-nums">{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{detail.functionalDifficulty !== null && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-muted-foreground min-w-0 flex-1">
|
||||
{t("mentalHealth.functionalTitle")}
|
||||
</span>
|
||||
{/* Unscored follow-up: the label alone, no value — it never counts
|
||||
into the total and a number would imply it does. */}
|
||||
<span className="text-foreground shrink-0 text-right">
|
||||
{t(`mentalHealth.functional.${detail.functionalDifficulty}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
* The 387-line monolith split into a folder of focused components; these are
|
||||
* the seams they plug into. The wire shapes mirror the assessment route's
|
||||
* `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`)
|
||||
* — totals + bands + flags only, NEVER item content (the item answers stay
|
||||
* encrypted at rest and never ride this surface).
|
||||
* — totals + bands + flags only on the list. The per-item answers ride ONLY
|
||||
* the per-administration detail read (`…/assessments/[id]`), which decrypts
|
||||
* them for the history's expandable answer breakdown.
|
||||
*/
|
||||
import type { InstrumentId } from "@/lib/mental-health/instruments";
|
||||
|
||||
@@ -25,6 +26,18 @@ export interface AssessmentRow {
|
||||
takenAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One administration with its decrypted answers (`GET …/assessments/[id]`).
|
||||
* `items: null` + `itemsUnavailable` is the server's honest degrade for a row
|
||||
* whose blob cannot be decrypted — the score fields still answer.
|
||||
*/
|
||||
export interface AssessmentDetail extends AssessmentRow {
|
||||
items: number[] | null;
|
||||
/** The unscored PHQ-9 functional follow-up, when it was answered. */
|
||||
functionalDifficulty: number | null;
|
||||
itemsUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface CrisisSet {
|
||||
emergencyNumber: string;
|
||||
resources: { id: string; contacts: string[] }[];
|
||||
|
||||
@@ -124,9 +124,11 @@ describe("evaluateMeasurementReminderDue — window boundary", () => {
|
||||
|
||||
interface FakeReminder {
|
||||
id: string;
|
||||
userId: string;
|
||||
/** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */
|
||||
origin?: string;
|
||||
measurementType: string | null;
|
||||
lastSkippedAt: Date | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
@@ -195,6 +197,14 @@ function makePrisma(opts: {
|
||||
labResult: {
|
||||
findFirst: vi.fn(async () => opts.labMatch ?? null),
|
||||
},
|
||||
// v1.37.20 (#223 / iOS #68) — the satisfy primitive appends a ledger
|
||||
// row per applied satisfy.
|
||||
measurementReminderEvent: {
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: "evt",
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
};
|
||||
return { prisma, updates };
|
||||
}
|
||||
@@ -202,6 +212,7 @@ function makePrisma(opts: {
|
||||
function reminder(overrides: Partial<FakeReminder>): FakeReminder {
|
||||
return {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
@@ -210,6 +221,7 @@ function reminder(overrides: Partial<FakeReminder>): FakeReminder {
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL
|
||||
lastSatisfiedAt: null,
|
||||
lastSkippedAt: null,
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
user: {
|
||||
|
||||
@@ -96,6 +96,14 @@ function makePrisma(opts: {
|
||||
},
|
||||
),
|
||||
},
|
||||
// v1.37.20 (#223 / iOS #68) — the primitive appends a ledger row per
|
||||
// applied satisfy; the harness records them so tests can assert one.
|
||||
measurementReminderEvent: {
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: "evt",
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
measurement: { findFirst: vi.fn(async () => opts.measurement ?? null) },
|
||||
labResult: { findFirst: vi.fn(async () => opts.lab ?? null) },
|
||||
};
|
||||
@@ -105,12 +113,15 @@ function makePrisma(opts: {
|
||||
function reminderRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
measurementType: "WEIGHT",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
nextDueAt: null,
|
||||
lastSatisfiedAt: null,
|
||||
lastSkippedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
|
||||
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
|
||||
* wire contract stays single-source. The response deliberately excludes the
|
||||
* raw item answers — only the server-authoritative total / band / safety flag
|
||||
* are exposed. The iOS client mirrors this DTO and MUST surface its own
|
||||
* crisis-resource card on a positive item-9 flag (never rely on server text
|
||||
* alone).
|
||||
* wire contract stays single-source. The list / create responses deliberately
|
||||
* exclude the raw item answers — only the server-authoritative total / band /
|
||||
* safety flag are exposed there; the per-administration detail read is the one
|
||||
* surface that returns the decrypted answers. The iOS client mirrors these
|
||||
* DTOs and MUST surface its own crisis-resource card on a positive item-9
|
||||
* flag (never rely on server text alone).
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
@@ -48,7 +49,19 @@ const assessmentRow = z
|
||||
.meta({
|
||||
id: "MentalHealthAssessment",
|
||||
description:
|
||||
"One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent.",
|
||||
"One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted.",
|
||||
});
|
||||
|
||||
const assessmentDetail = assessmentRow
|
||||
.extend({
|
||||
items: z.array(z.number().int()).nullable(),
|
||||
functionalDifficulty: z.number().int().nullable(),
|
||||
itemsUnavailable: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
id: "MentalHealthAssessmentDetail",
|
||||
description:
|
||||
"One administration with its decrypted per-item answers, in the order the instrument presents its items (PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is null, `itemsUnavailable` is true, and the denormalised score fields still answer.",
|
||||
});
|
||||
|
||||
const crisisResource = z
|
||||
@@ -81,6 +94,10 @@ const listResponse = z
|
||||
.object({ assessments: z.array(assessmentRow) })
|
||||
.meta({ id: "ListMentalHealthAssessmentsResponse" });
|
||||
|
||||
const detailResponse = z
|
||||
.object({ assessment: assessmentDetail })
|
||||
.meta({ id: "GetMentalHealthAssessmentResponse" });
|
||||
|
||||
export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/mental-health/assessments": {
|
||||
get: {
|
||||
@@ -132,4 +149,28 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/mental-health/assessments/{id}": {
|
||||
get: {
|
||||
tags: ["Mental health"],
|
||||
summary: "Read one administration with its decrypted answers",
|
||||
description:
|
||||
"Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band / flag still answer. A soft-deleted or foreign row answers 404.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The administration with its answer breakdown.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
detailResponse,
|
||||
"GetMentalHealthAssessmentEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -6,4 +6,7 @@ export const mentalHealthKeys = {
|
||||
/** The caller's screener history, optionally filtered by instrument. */
|
||||
mentalHealthAssessments: (instrument?: string) =>
|
||||
["mental-health-assessments", instrument ?? null] as const,
|
||||
/** One administration with its decrypted per-item answers. */
|
||||
mentalHealthAssessmentDetail: (id: string) =>
|
||||
["mental-health-assessment-detail", id] as const,
|
||||
};
|
||||
Reference in new issue
Block a user