mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
e4875db93e
commit
7575275d7e
11 files changed
+655
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.<id>.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.<id>.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;
|
||||
}
|
||||
@@ -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<InstrumentId, InstrumentDefinition> = {
|
||||
PHQ9,
|
||||
GAD7,
|
||||
};
|
||||
|
||||
/** The DB MeasurementType the instrument's total score projects to. */
|
||||
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
...ocrPaths,
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
...mentalHealthPaths,
|
||||
...onboardingPaths,
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<typeof createAssessmentSchema>;
|
||||
Reference in new issue
Block a user