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,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;
+11
View File
@@ -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,
);
});
+10
View File
@@ -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. */
+74
View File
@@ -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;
}
+118
View File
@@ -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;
}
+2
View File
@@ -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,
};
+133
View File
@@ -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,
},
},
},
};
+2
View File
@@ -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,
};
/**
+9
View File
@@ -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,
};
+44
View File
@@ -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>;