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:
Marc-André Bombeck committed 2026-08-14 09:46:48 +02:00
1 parent 651d37b494
commit 90bad17cc8
23 files changed
+1036 -34

No files matched your search

+133 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+9 -1
View File
@@ -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>
);
}
+15 -2
View File
@@ -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,
};
+47 -6
View File
@@ -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,
},
},
},
};
+3
View File
@@ -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,
};