feat(mental-health): opt-in PHQ-9 / GAD-7 screeners with item-9 crisis signposting

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 15:47:39 +02:00
1 parent e4875db93e
commit 7575275d7e
11 files changed
+655

No files matched your search

@@ -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,
);
});