From 7575275d7eeb83daa091841385a153ca52f0d56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:47:39 +0200 Subject: [PATCH] feat(mental-health): opt-in PHQ-9 / GAD-7 screeners with item-9 crisis signposting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the validated mental-health self-assessment surface, beside mood tracking (never replacing it) and off by default: - MentalHealthAssessment stores per-item answers in a single AES-256-GCM Bytes blob (registered for key rotation); only the server-authoritative total, severity band, and item-9 flag are denormalised. The total projects to a PHQ9_SCORE / GAD7_SCORE measurement row so the trend rides existing infra without ever exposing item content. - Item-9 safety: any non-zero PHQ-9 self-harm answer surfaces a calm, locale-aware crisis-resource set (DE: TelefonSeelsorge 0800 111 0 111 / 112; international fallback) and stamps crisisShownAt. No clinical interpretation, no AI ingestion of item content, no third-party alerting. - API + OpenAPI: POST/GET /api/mental-health/assessments; the response excludes raw item answers by construction. Required Pfizer/PHQ attribution lives in the instrument module, rendered verbatim. The screener scores are kept off the Coach snapshot and MCP surfaces, and out of exports by default — item content never reaches the AI pipeline. --- .../migration.sql | 46 ++++ scripts/rotate-encryption-key.ts | 11 + .../api/mental-health/assessments/route.ts | 206 ++++++++++++++++++ src/lib/crypto/encrypted-columns.ts | 10 + src/lib/mental-health/crisis-resources.ts | 74 +++++++ src/lib/mental-health/instruments.ts | 118 ++++++++++ src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/mental-health.ts | 133 +++++++++++ src/lib/query-keys/index.ts | 2 + src/lib/query-keys/mental-health.ts | 9 + src/lib/validations/mental-health.ts | 44 ++++ 11 files changed, 655 insertions(+) create mode 100644 prisma/migrations/0213_v125_mental_health_assessment/migration.sql create mode 100644 src/app/api/mental-health/assessments/route.ts create mode 100644 src/lib/mental-health/crisis-resources.ts create mode 100644 src/lib/mental-health/instruments.ts create mode 100644 src/lib/openapi/routes/mental-health.ts create mode 100644 src/lib/query-keys/mental-health.ts create mode 100644 src/lib/validations/mental-health.ts diff --git a/prisma/migrations/0213_v125_mental_health_assessment/migration.sql b/prisma/migrations/0213_v125_mental_health_assessment/migration.sql new file mode 100644 index 000000000..5981f1312 --- /dev/null +++ b/prisma/migrations/0213_v125_mental_health_assessment/migration.sql @@ -0,0 +1,46 @@ +-- v1.25 — clinical-signals wave: validated mental-health screeners (PHQ-9 + GAD-7). +-- +-- Adds the two derived total-score MeasurementType members and the +-- `mental_health_assessments` table that holds one completed administration per +-- row. The per-item answers ride a single AES-256-GCM `bytea` blob +-- (`responses_encrypted`); only the derived total / band / item-9 flag are +-- stored in the clear, for cheap history reads. Opt-in, beside mood tracking. + +-- New MeasurementType members for the derived scores. ADD VALUE IF NOT EXISTS is +-- idempotent and not used within this migration, so the in-transaction guard +-- does not apply. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PHQ9_SCORE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'GAD7_SCORE'; + +-- Instrument discriminator. +CREATE TYPE "assessment_instrument" AS ENUM ('PHQ9', 'GAD7'); + +CREATE TABLE "mental_health_assessments" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "instrument" "assessment_instrument" NOT NULL, + "locale" TEXT NOT NULL, + "version" TEXT NOT NULL DEFAULT 'standard', + "responses_encrypted" BYTEA NOT NULL, + "total_score" INTEGER NOT NULL, + "severity_band" TEXT NOT NULL, + "item9_flagged" BOOLEAN NOT NULL DEFAULT false, + "crisis_shown_at" TIMESTAMP(3), + "taken_at" TIMESTAMP(3) NOT NULL, + "tz" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "external_id" TEXT, + "sync_version" INTEGER NOT NULL DEFAULT 0, + "deleted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "mental_health_assessments_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "mental_health_assessments_user_id_source_external_id_key" ON "mental_health_assessments"("user_id", "source", "external_id"); +CREATE INDEX "mental_health_assessments_user_id_instrument_taken_at_idx" ON "mental_health_assessments"("user_id", "instrument", "taken_at"); +CREATE INDEX "mental_health_assessments_user_id_updated_at_id_idx" ON "mental_health_assessments"("user_id", "updated_at", "id"); + +ALTER TABLE "mental_health_assessments" ADD CONSTRAINT "mental_health_assessments_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 3663f3f56..992598d0c 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -448,6 +448,17 @@ async function main() { ), ); + // ───── v1.25 mental-health screener item answers (Bytes column) ───── + // The PHQ-9 / GAD-7 encrypted per-item blob. Always present (NOT NULL), so + // every row rotates. + results.push( + await rotateBytesColumn( + "MentalHealthAssessment", + "responsesEncrypted", + prisma.mentalHealthAssessment, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts new file mode 100644 index 000000000..aa3cc644e --- /dev/null +++ b/src/app/api/mental-health/assessments/route.ts @@ -0,0 +1,206 @@ +/** + * v1.25 — opt-in mental-health screener assessments (PHQ-9 / GAD-7). + * + * POST records one completed administration: the per-item answers are encrypted + * into a single AES-256-GCM Bytes blob (never logged, never returned by + * default), and the server-authoritative total + severity band + item-9 safety + * flag are denormalised for cheap history reads. A matching PHQ9_SCORE / + * GAD7_SCORE Measurement row is written so the score trend rides the existing + * chart/rollup infra — the trend, never the item content. + * + * SAFETY: on any non-zero PHQ-9 item 9, the response carries the calm, + * locale-aware crisis-resource set and stamps `crisisShownAt`. No clinical + * 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). + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + createAssessmentSchema, + listAssessmentsSchema, +} from "@/lib/validations/mental-health"; +import { + INSTRUMENTS, + INSTRUMENT_MEASUREMENT_TYPE, + isSafetyFlagged, + scoreTotal, + severityBand, + type InstrumentId, +} from "@/lib/mental-health/instruments"; +import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources"; +import type { MeasurementType, MeasurementSource } from "@/generated/prisma/client"; + +function shapeRow(row: { + id: string; + instrument: string; + locale: string; + version: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + crisisShownAt: Date | null; + takenAt: Date; + createdAt: Date; +}) { + return { + 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(), + }; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listAssessmentsSchema.safeParse(params); + if (!parsed.success) { + annotate({ + action: { name: "mental-health.list.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const { instrument, limit, offset } = parsed.data; + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { userId: user.id, deletedAt: null, ...(instrument ? { instrument } : {}) }, + orderBy: { takenAt: "desc" }, + take: limit ?? 100, + skip: offset ?? 0, + select: { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + }, + }); + annotate({ + action: { name: "mental-health.list" }, + meta: { count: rows.length }, + }); + return apiSuccess({ assessments: rows.map(shapeRow) }); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const body = await safeJson(request); + const parsed = createAssessmentSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "mental-health.create.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { instrument, items, functionalDifficulty, takenAt, tz, locale } = + parsed.data; + const id = instrument as InstrumentId; + const def = INSTRUMENTS[id]; + + // Server-authoritative scoring (the client never computes these). + const total = scoreTotal(items); + const band = severityBand(id, total); + const flagged = isSafetyFlagged(id, items); + const presentedLocale = locale ?? user.locale ?? "en"; + const when = takenAt ? new Date(takenAt) : new Date(); + + // Encrypt the per-item answers + the (unscored) functional-impairment follow-up. + const blob = encryptToBytes( + JSON.stringify({ + items, + ...(functionalDifficulty !== undefined ? { functionalDifficulty } : {}), + schema: 1, + }), + ); + + const assessment = await prisma.mentalHealthAssessment.create({ + data: { + userId: user.id, + instrument: id, + locale: presentedLocale, + version: "standard", + responsesEncrypted: blob, + totalScore: total, + severityBand: band, + item9Flagged: flagged, + crisisShownAt: flagged ? new Date() : null, + takenAt: when, + tz: tz ?? null, + source: "WEB", + }, + select: { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + }, + }); + + // Read-optimised projection: the total rides a Measurement row so the existing + // chart/rollup infra reads the trend without ever touching item content. + await prisma.measurement.create({ + data: { + userId: user.id, + type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, + value: total, + unit: "score", + source: "MANUAL" as MeasurementSource, + measuredAt: when, + notes: null, + externalId: `assessment:${assessment.id}`, + }, + }); + + await auditLog("mental-health.create", { + userId: user.id, + // The score band is non-sensitive; item answers are NEVER logged. + details: { instrument: id, severityBand: band }, + }); + annotate({ + action: { name: "mental-health.create" }, + meta: { instrument: id, item9_flagged: flagged }, + }); + + // SAFETY: surface the locale-aware crisis resources on a positive item-9. + const crisis = flagged ? crisisResourcesForLocale(presentedLocale) : null; + + return apiSuccess( + { + assessment: shapeRow(assessment), + actionThreshold: def.actionThreshold, + crisis, + }, + 201, + ); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 4b0c91191..ee89efd8a 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -156,6 +156,16 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" }, { model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" }, { model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" }, + + // ───── v1.25 mental-health screener item answers (Bytes column) ───── + // The PHQ-9 / GAD-7 per-item responses (incl. the safety-critical item 9) + // ride a single AES-256-GCM blob. The most sensitive payload in the wave; + // never indexed, never logged, never in wide-event meta. + { + model: "MentalHealthAssessment", + field: "responsesEncrypted", + kind: "bytes", + }, ] as const; /** Stable `Model.field` key for a registry entry. */ diff --git a/src/lib/mental-health/crisis-resources.ts b/src/lib/mental-health/crisis-resources.ts new file mode 100644 index 000000000..808341979 --- /dev/null +++ b/src/lib/mental-health/crisis-resources.ts @@ -0,0 +1,74 @@ +/** + * v1.25 — locale-aware crisis-resource signposting for the PHQ-9 item-9 safety + * path. This is SUPPORT, not interpretation: when item 9 is answered with any + * non-zero value the UI shows a calm, non-alarmist card pointing to real help. + * + * The list is maintained config (not hardcoded in a component) so a self-hoster + * can localise it. Names resolve via the `mentalHealth.crisisResource..name` + * i18n key; the contact lines (phone numbers / URLs) are literal data, never + * translated. The emergency number is region-specific and always shown first. + * + * HARD RULE: showing these resources NEVER triggers any third-party alert, + * never notifies a contact, never emails. It is private, on-device-only + * signposting on a self-host. + */ + +export interface CrisisResource { + /** Stable id; the `mentalHealth.crisisResource..name` i18n key resolves the label. */ + id: string; + /** Contact lines (phone / SMS / chat URL) — literal, not translated. */ + contacts: readonly string[]; +} + +export interface CrisisResourceSet { + /** Region's universal emergency number, shown first ("if in immediate danger…"). */ + emergencyNumber: string; + resources: readonly CrisisResource[]; +} + +/** International fallback set (used when a region has no dedicated entry). */ +const INTERNATIONAL: CrisisResourceSet = { + emergencyNumber: "112", + resources: [ + { id: "findahelpline", contacts: ["findahelpline.com"] }, + { id: "euEmotionalSupport", contacts: ["116 123"] }, + ], +}; + +/** Germany (de) — TelefonSeelsorge + youth lines + acute emergency. */ +const DE: CrisisResourceSet = { + emergencyNumber: "112", + resources: [ + { + id: "telefonSeelsorge", + contacts: ["0800 111 0 111", "0800 111 0 222", "telefonseelsorge.de"], + }, + { id: "nummerGegenKummer", contacts: ["116 111"] }, + { id: "krisenchat", contacts: ["krisenchat.de"] }, + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ], +}; + +/** United States (en-US region hint) — 988 + Crisis Text Line. */ +const US: CrisisResourceSet = { + emergencyNumber: "911", + resources: [ + { id: "lifeline988", contacts: ["988"] }, + { id: "crisisTextLine", contacts: ["Text HOME to 741741"] }, + { id: "findahelpline", contacts: ["findahelpline.com"] }, + ], +}; + +/** + * Resolve a crisis-resource set from the user's locale. Region detection is + * coarse (locale prefix) and defaults to the international set — the goal is to + * always surface SOMETHING actionable, never to be exhaustive. + */ +export function crisisResourcesForLocale( + locale: string | null | undefined, +): CrisisResourceSet { + const lc = (locale ?? "").toLowerCase(); + if (lc.startsWith("de")) return DE; + if (lc === "en-us" || lc.startsWith("en_us") || lc === "us") return US; + return INTERNATIONAL; +} diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts new file mode 100644 index 000000000..aa43364c3 --- /dev/null +++ b/src/lib/mental-health/instruments.ts @@ -0,0 +1,118 @@ +/** + * v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7). + * + * Pure, server-authoritative scoring. The instrument item wording, 0–3 scale, + * totals, and severity bands are taken from the primary sources (Kroenke 2001 + * for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC + * contract; the human wording lives in `messages/*.json` under + * `mentalHealth.items.*` (officially-validated translations per locale, English + * fallback otherwise — re-wording invalidates the score). + * + * PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the + * safety-critical field (see `crisis-resources.ts` + the API handler): any + * non-zero answer triggers a calm, non-alarmist crisis-resource signpost. The + * raw item answers are NEVER exposed to the Coach / Insights / export surfaces — + * only the derived total score rides a `*_SCORE` Measurement row. + */ + +export type InstrumentId = "PHQ9" | "GAD7"; + +/** + * Required attribution — render VERBATIM on every screen displaying the + * instrument and in the PDF/FHIR export. The PHQ family is public-domain (Pfizer + * 2010); this is the canonical footer text. By convention it stays in English + * (the developers' names are not translated). + */ +export const PHQ_GAD_ATTRIBUTION = + "Developed by Drs. Robert L. Spitzer, Janet B.W. Williams, Kurt Kroenke and " + + "colleagues, with an educational grant from Pfizer Inc. No permission " + + "required to reproduce, translate, display or distribute."; + +export interface InstrumentDefinition { + id: InstrumentId; + /** Number of scored items (PHQ-9 = 9, GAD-7 = 7). */ + itemCount: number; + /** Inclusive total range. */ + minScore: number; + maxScore: number; + /** Total-score LOINC for the FHIR/export contract. */ + totalLoinc: string; + /** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */ + bands: ReadonlyArray<{ key: string; min: number; max: number }>; + /** Optimal screening cut-off (≥ this total = consider professional follow-up). */ + actionThreshold: number; + /** Item index (0-based) of the safety-critical self-harm item, or null. */ + safetyItemIndex: number | null; +} + +const PHQ9: InstrumentDefinition = { + id: "PHQ9", + itemCount: 9, + minScore: 0, + maxScore: 27, + totalLoinc: "44261-6", + bands: [ + { key: "minimal", min: 0, max: 4 }, + { key: "mild", min: 5, max: 9 }, + { key: "moderate", min: 10, max: 14 }, + { key: "modSevere", min: 15, max: 19 }, + { key: "severe", min: 20, max: 27 }, + ], + actionThreshold: 10, + // Item 9 ("Thoughts that you would be better off dead…") — the safety field. + safetyItemIndex: 8, +}; + +const GAD7: InstrumentDefinition = { + id: "GAD7", + itemCount: 7, + minScore: 0, + maxScore: 21, + totalLoinc: "70274-6", + bands: [ + { key: "minimal", min: 0, max: 4 }, + { key: "mild", min: 5, max: 9 }, + { key: "moderate", min: 10, max: 14 }, + { key: "severe", min: 15, max: 21 }, + ], + actionThreshold: 10, + safetyItemIndex: null, +}; + +export const INSTRUMENTS: Record = { + PHQ9, + GAD7, +}; + +/** The DB MeasurementType the instrument's total score projects to. */ +export const INSTRUMENT_MEASUREMENT_TYPE: Record = { + PHQ9: "PHQ9_SCORE", + GAD7: "GAD7_SCORE", +}; + +/** Sum the item answers into a total (server-authoritative). */ +export function scoreTotal(items: readonly number[]): number { + return items.reduce((sum, v) => sum + v, 0); +} + +/** Resolve the severity-band key for a total score. */ +export function severityBand(id: InstrumentId, total: number): string { + const def = INSTRUMENTS[id]; + const band = def.bands.find((b) => total >= b.min && total <= b.max); + // Defensive clamp — the API validates the total is in-range before this runs. + return band?.key ?? def.bands[def.bands.length - 1].key; +} + +/** + * Whether the safety signpost must be shown for these answers. True iff the + * instrument has a safety item and that item was answered with any non-zero + * value — independent of the total score (item 9 is never gated by the band). + */ +export function isSafetyFlagged( + id: InstrumentId, + items: readonly number[], +): boolean { + const idx = INSTRUMENTS[id].safetyItemIndex; + if (idx === null) return false; + return (items[idx] ?? 0) > 0; +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index ae44bef96..8d7241e66 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -44,6 +44,7 @@ import { labsPaths } from "./labs"; import { ocrPaths } from "./ocr"; import { measurementPaths } from "./measurements"; import { measurementReminderPaths } from "./measurement-reminders"; +import { mentalHealthPaths } from "./mental-health"; import { medicationPaths, medicationResource } from "./medications"; import { metaPaths } from "./meta"; import { moodPaths } from "./mood"; @@ -83,6 +84,7 @@ export const openApiPaths: NonNullable = { ...ocrPaths, ...biomarkerPaths, ...illnessPaths, + ...mentalHealthPaths, ...onboardingPaths, }; diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts new file mode 100644 index 000000000..61d19e9ef --- /dev/null +++ b/src/lib/openapi/routes/mental-health.ts @@ -0,0 +1,133 @@ +/** + * OpenAPI route table for the opt-in mental-health screeners + * (`/api/mental-health/assessments`). PHQ-9 / GAD-7. + * + * 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). + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + createAssessmentSchema, + listAssessmentsSchema, +} from "@/lib/validations/mental-health"; + +import { dataEnvelope, stdResponses } from "./shared"; + +createAssessmentSchema.meta({ + id: "CreateMentalHealthAssessmentRequest", + description: + "Record one completed PHQ-9 (9 items) / GAD-7 (7 items) administration. Each item answer is 0–3; the array length must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.", +}); + +listAssessmentsSchema.meta({ + id: "ListMentalHealthAssessmentsQuery", + description: + "Filter the caller's screener history by instrument; paginate with limit/offset.", +}); + +const assessmentRow = z + .object({ + id: z.string(), + instrument: z.enum(["PHQ9", "GAD7"]), + locale: z.string(), + version: z.string(), + totalScore: z.number(), + severityBand: z.string(), + item9Flagged: z.boolean(), + crisisShownAt: z.string().nullable(), + takenAt: z.string(), + createdAt: z.string(), + }) + .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.", + }); + +const crisisResource = z + .object({ + id: z.string(), + contacts: z.array(z.string()), + }) + .meta({ id: "CrisisResource" }); + +const crisisSet = z + .object({ + emergencyNumber: z.string(), + resources: z.array(crisisResource), + }) + .meta({ + id: "CrisisResourceSet", + description: + "Locale-aware crisis-resource signposting, present only when item-9 is flagged. Names resolve via i18n on the client; contacts are literal.", + }); + +const createResponse = z + .object({ + assessment: assessmentRow, + actionThreshold: z.number(), + crisis: crisisSet.nullable(), + }) + .meta({ id: "CreateMentalHealthAssessmentResponse" }); + +const listResponse = z + .object({ assessments: z.array(assessmentRow) }) + .meta({ id: "ListMentalHealthAssessmentsResponse" }); + +export const mentalHealthPaths: NonNullable = { + "/api/mental-health/assessments": { + get: { + tags: ["Mental health"], + summary: "List the caller's screener history", + description: + "Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are never returned.", + requestParams: { query: listAssessmentsSchema }, + responses: { + "200": { + description: "Screener history.", + content: { + "application/json": { + schema: dataEnvelope( + listResponse, + "ListMentalHealthAssessmentsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Mental health"], + summary: "Record a completed PHQ-9 / GAD-7 screener", + description: + "Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.", + requestBody: { + content: { + "application/json": { schema: createAssessmentSchema }, + }, + }, + responses: { + "201": { + description: "Assessment recorded.", + content: { + "application/json": { + schema: dataEnvelope( + createResponse, + "CreateMentalHealthAssessmentEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d84854923..47cdc1251 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -26,6 +26,7 @@ import { labKeys } from "./labs"; import { measurementKeys } from "./measurements"; import { measurementReminderKeys } from "./measurement-reminders"; import { medicationKeys } from "./medications"; +import { mentalHealthKeys } from "./mental-health"; import { moodKeys } from "./mood"; import { settingsKeys } from "./settings"; import { workoutKeys } from "./workouts"; @@ -46,6 +47,7 @@ export const queryKeys = { ...measurementReminderKeys, ...labKeys, ...illnessKeys, + ...mentalHealthKeys, }; /** diff --git a/src/lib/query-keys/mental-health.ts b/src/lib/query-keys/mental-health.ts new file mode 100644 index 000000000..c147fff3c --- /dev/null +++ b/src/lib/query-keys/mental-health.ts @@ -0,0 +1,9 @@ +/** + * Query keys — opt-in mental-health screeners (PHQ-9 / GAD-7). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const mentalHealthKeys = { + /** The caller's screener history, optionally filtered by instrument. */ + mentalHealthAssessments: (instrument?: string) => + ["mental-health-assessments", instrument ?? null] as const, +}; diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts new file mode 100644 index 000000000..73f24442a --- /dev/null +++ b/src/lib/validations/mental-health.ts @@ -0,0 +1,44 @@ +/** + * v1.25 — request/response validation for the opt-in mental-health screeners + * (PHQ-9 / GAD-7). The item answers are 0–3 integers; the array length must + * match the instrument (PHQ-9 = 9, GAD-7 = 7). `userId` is NEVER a body field — + * it is narrowed from the session in the handler (CLAUDE.md convention). + */ +import { z } from "zod/v4"; +import { INSTRUMENTS, type InstrumentId } from "@/lib/mental-health/instruments"; + +export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]); + +const itemAnswer = z.number().int().min(0).max(3); + +export const createAssessmentSchema = z + .object({ + instrument: assessmentInstrumentEnum, + /** Per-item answers, each 0–3, ordered as the instrument presents them. */ + items: z.array(itemAnswer).min(1).max(12), + /** Optional functional-impairment follow-up (not scored into the total). */ + functionalDifficulty: z.number().int().min(0).max(3).optional(), + /** ISO instant the screener was taken; defaults to now server-side. */ + takenAt: z.string().datetime().optional(), + tz: z.string().max(64).optional(), + /** Locale of the validated wording actually presented. */ + locale: z.string().max(16).optional(), + }) + .superRefine((val, ctx) => { + const def = INSTRUMENTS[val.instrument as InstrumentId]; + if (val.items.length !== def.itemCount) { + ctx.addIssue({ + code: "custom", + path: ["items"], + message: `${val.instrument} expects exactly ${def.itemCount} item answers`, + }); + } + }); + +export const listAssessmentsSchema = z.object({ + instrument: assessmentInstrumentEnum.optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +export type CreateAssessmentInput = z.infer;