diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a3fbda2be..a7e3b30db 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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." diff --git a/messages/de.json b/messages/de.json index 2c836d87a..49ce66bee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 3e30da5f2..cb3ea8113 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 72134f305..851bac220 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index ce9f99f5e..0cfb46887 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index f24b77f37..11e37762e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index cbc72c276..d1a425342 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d37eee72c..09cd78e1b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8d5ccc2ef..79b1b0a9e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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 diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 97250c15b..34bfae2db 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -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( diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts index 735060c17..8c0d14034 100644 --- a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts @@ -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(); }); diff --git a/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts b/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..be19cee4b --- /dev/null +++ b/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts @@ -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()), + 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; + +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 = {}) { + 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(); + }); +}); diff --git a/src/app/api/mental-health/assessments/[id]/route.ts b/src/app/api/mental-health/assessments/[id]/route.ts new file mode 100644 index 000000000..3472b760a --- /dev/null +++ b/src/app/api/mental-health/assessments/[id]/route.ts @@ -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, + }, + }); + }, +); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index c89f61540..a37b223b3 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -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"; diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 5e6a9f1df..869e18532 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -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 }, }), })); diff --git a/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx b/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx new file mode 100644 index 000000000..3ec89a015 --- /dev/null +++ b/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx @@ -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 { + 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( + + + + + , + ); +} + +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"]); + }); +}); diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx index bc59c1402..42ffa7abd 100644 --- a/src/components/mental-health/assessment-history.tsx +++ b/src/components/mental-health/assessment-history.tsx @@ -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(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( + 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 (
  • @@ -146,9 +159,37 @@ export function AssessmentHistory({ {row.totalScore} +
    {flagged && open && } + {breakdownOpen && ( +
    + +
    + )}
  • ); })} diff --git a/src/components/mental-health/assessment-item-breakdown.tsx b/src/components/mental-health/assessment-item-breakdown.tsx new file mode 100644 index 000000000..95d20d729 --- /dev/null +++ b/src/components/mental-health/assessment-item-breakdown.tsx @@ -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 ( +
    + {Array.from({ length: def.itemCount }, (_, i) => ( + + ))} +
    + ); + } + + if (isError || !data) { + return ( +

    + {t("mentalHealth.error")} +

    + ); + } + + 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 ( +

    + {t("mentalHealth.history.answersUnavailable")} +

    + ); + } + + return ( +
    + {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 ( +
    + {caption && ( +

    + {t(`mentalHealth.stems.${caption}`)} +

    + )} +
    + + {t(`mentalHealth.items.${def.i18nKey}.${index + 1}`)} + + + {t(optionLabelKey(instrument, index, value))} + {" · "} + {value} + +
    +
    + ); + })} + {detail.functionalDifficulty !== null && ( +
    + + {t("mentalHealth.functionalTitle")} + + {/* Unscored follow-up: the label alone, no value — it never counts + into the total and a number would imply it does. */} + + {t(`mentalHealth.functional.${detail.functionalDifficulty}`)} + +
    + )} +
    + ); +} diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index 7b66ca1ff..26d47f766 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -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[] }[]; diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 5ba9cb414..34826a27c 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -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 }) => ({ + id: "evt", + ...data, + })), + }, }; return { prisma, updates }; } @@ -202,6 +212,7 @@ function makePrisma(opts: { function reminder(overrides: Partial): FakeReminder { return { id: "r1", + userId: "u1", measurementType: "BLOOD_PRESSURE_SYS", intervalDays: 7, rrule: null, @@ -210,6 +221,7 @@ function reminder(overrides: Partial): 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: { diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index b67ac2e74..dfc04c2b1 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -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 }) => ({ + 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 = {}) { 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, }; diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 9f60e092c..565cf19f7 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -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 = { "/api/mental-health/assessments": { get: { @@ -132,4 +149,28 @@ export const mentalHealthPaths: NonNullable = { }, }, }, + "/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, + }, + }, + }, }; diff --git a/src/lib/query-keys/mental-health.ts b/src/lib/query-keys/mental-health.ts index c147fff3c..fe7382d7a 100644 --- a/src/lib/query-keys/mental-health.ts +++ b/src/lib/query-keys/mental-health.ts @@ -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, };