feat(mental-health): add the WHO-5 and SCI screening instruments

Two new self-report instruments join the mental-wellbeing engine on the
existing PHQ-9 / GAD-7 infrastructure:

- WHO-5 Well-Being Index: 5 items on the 6-point 5..0 scale over the last
  two weeks; the reported total is raw-sum x 4 (0-100) per the official
  WHO scoring (WHO/UCN/MSD/MHE/2024.1, CC BY-NC-SA 3.0 IGO). A total of
  50 or below follows the WHO comment (below 50 = indication for further
  assessment).
- Sleep Condition Indicator: 8 DSM-5-aligned items, 0-4 each with
  item-specific anchors, total 0-32, higher = better sleep; a total of
  16 or below maps to the probable-insomnia range of the source
  publication (Espie et al., BMJ Open 2014;4:e004183, CC BY-NC,
  (c) Sleepio Limited).

The instrument registry generalises rather than forking: per-item answer
ceilings, option order/label schemes, recall-stem captions, a reported-
score multiplier, score direction (higher-is-better inverts the follow-up
threshold), per-instrument attribution lines, and the validated-item
locale set are now instrument properties. Scoring stays server-
authoritative; the create route validates the per-instrument answer
ceiling and projects the total onto a COMPUTED WHO5_SCORE / SCI_SCORE
measurement row (never client-attributable), which also auto-satisfies a
planned screening reminder. Migration 0226 adds the enum values
(additive, idempotent). OpenAPI regenerated.

Neither instrument carries a self-harm item; the PHQ-9 item-9 crisis
routing is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 20:33:29 +02:00
1 parent 4d0f16d5a9
commit 1262333acc
17 files changed
+666 -84

No files matched your search

+34 -9
View File
@@ -2103,6 +2103,8 @@ paths:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- in: query
name: from
schema:
@@ -2299,6 +2301,8 @@ paths:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
value:
type: number
measuredAt:
@@ -5865,6 +5869,8 @@ paths:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
required: true
description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s."
@@ -8103,8 +8109,8 @@ paths:
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.
description: Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item
answers are never returned.
parameters:
- in: query
name: instrument
@@ -8113,6 +8119,8 @@ paths:
enum:
- PHQ9
- GAD7
- WHO5
- SCI
- in: query
name: limit
schema:
@@ -8138,10 +8146,11 @@ paths:
post:
tags:
- Mental health
summary: Record a completed PHQ-9 / GAD-7 screener
summary: Record a completed screener
description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned
PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the
response carries the locale-aware crisis-resource set.
PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5
and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive
PHQ-9 item-9 the response carries the locale-aware crisis-resource set.
requestBody:
content:
application/json:
@@ -8153,6 +8162,8 @@ paths:
enum:
- PHQ9
- GAD7
- WHO5
- SCI
items:
minItems: 1
maxItems: 12
@@ -8160,7 +8171,7 @@ paths:
items:
type: integer
minimum: 0
maximum: 3
maximum: 5
functionalDifficulty:
type: integer
minimum: 0
@@ -11145,9 +11156,11 @@ components:
- BODY_MASS_INDEX
- PHQ9_SCORE
- GAD7_SCORE
description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves
on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the
server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that
- WHO5_SCORE
- SCI_SCORE
description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings).
BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder
resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that
resolves only on a manual satisfy or a matching lab result.
MeasurementReminderUpdate:
type: object
@@ -13904,6 +13917,8 @@ components:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
value:
type: number
unit:
@@ -14227,6 +14242,8 @@ components:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
additionalProperties:
type: array
items:
@@ -14310,6 +14327,8 @@ components:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
value:
type: number
description: Daily mean (spot metrics) or SUM (cumulative).
@@ -14414,6 +14433,8 @@ components:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
description: Per-type daily series, keyed by MeasurementType.
required:
- series
@@ -14653,6 +14674,8 @@ components:
- PAIN_NRS
- WAIST_CIRCUMFERENCE
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
value:
type: number
unit:
@@ -25212,6 +25235,8 @@ components:
enum:
- PHQ9
- GAD7
- WHO5
- SCI
locale:
type: string
version:
@@ -0,0 +1,15 @@
-- v1.27.9 — screening-score enum values for the two new self-report
-- instruments: the WHO-5 well-being index (percentage 0–100, raw sum × 4)
-- and the Sleep Condition Indicator (total 0–32, higher = better sleep).
--
-- Same contract as PHQ9_SCORE / GAD7_SCORE (0213/0225 lineage): the value is
-- server-derived from a completed MentalHealthAssessment and written as a
-- `COMPUTED`-source Measurement row, so clients can never forge a trend point
-- through the measurement write surfaces. Additive, idempotent.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WHO5_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SCI_SCORE';
-- The assessment discriminator learns the two new instruments (0213 created
-- it with PHQ9/GAD7 only).
ALTER TYPE "assessment_instrument" ADD VALUE IF NOT EXISTS 'WHO5';
ALTER TYPE "assessment_instrument" ADD VALUE IF NOT EXISTS 'SCI';
+11 -2
View File
@@ -1284,17 +1284,26 @@ enum MeasurementType {
// waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5).
WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display).
WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk).
// ── v1.27.9 — screening scores (WHO-5 well-being + Sleep Condition Indicator) ──
// Same contract as PHQ9_SCORE / GAD7_SCORE: server-derived from a completed
// `MentalHealthAssessment`, `source = COMPUTED`, one row per administration.
// Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both.
WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment.
SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment.
@@map("measurement_type")
}
/// v1.25 — validated mental-health self-assessment instruments: PHQ-9
/// (depression) + GAD-7 (anxiety). The discriminator on
/// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future
/// siblings) without a schema change.
/// `MentalHealthAssessment`. v1.27.9 adds the WHO-5 well-being index and the
/// Sleep Condition Indicator (SCI) — same administration contract, inverted
/// score direction (higher = better).
enum AssessmentInstrument {
PHQ9
GAD7
WHO5
SCI
@@map("assessment_instrument")
}
@@ -284,6 +284,98 @@ describe("POST /api/mental-health/assessments", () => {
expect(createArg.data.externalId).toBe("outbox-aa01");
});
it("scores a WHO-5 check-in as raw-sum × 4 and projects a WHO5_SCORE measurement", async () => {
// Raw 3+3+2+2+2 = 12 → reported 48 (below the WHO "below 50" cut → low).
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
id: "mha_who5",
instrument: "WHO5",
locale: "en",
version: "standard",
totalScore: 48,
severityBand: "low",
item9Flagged: false,
crisisShownAt: null,
takenAt: new Date("2026-06-28T00:00:00.000Z"),
createdAt: new Date("2026-06-28T00:00:00.000Z"),
} as never);
const res = await callPost(
makeReq({ instrument: "WHO5", items: [3, 3, 2, 2, 2] }),
);
expect(res.status).toBe(201);
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
.calls[0][0] as {
data: { totalScore: number; severityBand: string; item9Flagged: boolean };
};
expect(createArg.data.totalScore).toBe(48);
expect(createArg.data.severityBand).toBe("low");
// Positively-worded instrument, no safety item — never flagged.
expect(createArg.data.item9Flagged).toBe(false);
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { type: string; value: number; source: string };
};
expect(measArg.data.type).toBe("WHO5_SCORE");
expect(measArg.data.value).toBe(48);
expect(measArg.data.source).toBe("COMPUTED");
const body = (await res.json()) as {
data: { crisis: unknown; actionThreshold: number };
};
// No crisis routing on the WHO-5 — the ≤50 pointer is client-side copy.
expect(body.data.crisis).toBeNull();
expect(body.data.actionThreshold).toBe(50);
});
it("accepts an SCI check-in (0–4 anchors) and projects an SCI_SCORE measurement", async () => {
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
id: "mha_sci",
instrument: "SCI",
locale: "en",
version: "standard",
totalScore: 16,
severityBand: "belowThreshold",
item9Flagged: false,
crisisShownAt: null,
takenAt: new Date("2026-06-28T00:00:00.000Z"),
createdAt: new Date("2026-06-28T00:00:00.000Z"),
} as never);
// Total 4+3+2+1+0+4+1+1 = 16 → the paper's ≤16 probable-insomnia range.
const res = await callPost(
makeReq({ instrument: "SCI", items: [4, 3, 2, 1, 0, 4, 1, 1] }),
);
expect(res.status).toBe(201);
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
.calls[0][0] as { data: { totalScore: number; severityBand: string } };
expect(createArg.data.totalScore).toBe(16);
expect(createArg.data.severityBand).toBe("belowThreshold");
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { type: string; value: number; source: string };
};
expect(measArg.data.type).toBe("SCI_SCORE");
expect(measArg.data.value).toBe(16);
expect(measArg.data.source).toBe("COMPUTED");
});
it("rejects an out-of-scale answer for the instrument with 422 (SCI max 4, PHQ-9 max 3)", async () => {
// 5 is a legal WHO-5 answer but out of scale for the SCI…
const sciRes = await callPost(
makeReq({ instrument: "SCI", items: [5, 3, 2, 1, 0, 4, 1, 1] }),
);
expect(sciRes.status).toBe(422);
// …and 4 is out of scale for the PHQ-9.
const phqRes = await callPost(
makeReq({ instrument: "PHQ9", items: [4, 0, 0, 0, 0, 0, 0, 0, 0] }),
);
expect(phqRes.status).toBe(422);
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
});
it("rejects a wrong item count with 422 and never writes", async () => {
const res = await callPost(
makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9
@@ -229,8 +229,9 @@ async function postAssessment(request: NextRequest): Promise<Response> {
if (existing) return respondExisting(existing);
}
// Server-authoritative scoring (the client never computes these).
const total = scoreTotal(items);
// Server-authoritative scoring (the client never computes these). The
// total is the REPORTED score — the WHO-5 projects raw-sum × 4 (0–100).
const total = scoreTotal(id, items);
const band = severityBand(id, total);
const flagged = isSafetyFlagged(id, items);
const presentedLocale = locale ?? user.locale ?? "en";
@@ -105,10 +105,14 @@ const EXPECTED_TYPES = [
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// ── v1.27.9 — screening scores (WHO-5 percentage 0–100, SCI total 0–32;
// both server-derived from a completed assessment, higher = better) ──
"WHO5_SCORE",
"SCI_SCORE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 73 canonical measurement types", () => {
it("exposes the 75 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -264,6 +268,12 @@ describe("measurementTypeEnum coverage", () => {
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
// precedent: opt-in questionnaire derivations, excluded from the vitals
// table; they ride the module-gated mental-health export section
// instead. See doctor-report-pdf-core.ts for the matching rationale.
"WHO5_SCORE",
"SCI_SCORE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
@@ -184,6 +184,28 @@ describe("runReminderSatisfyForUser", () => {
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
});
it("satisfies the WHO-5 / SCI screening reminders on the identical contract (v1.27.9)", async () => {
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
const measuredAt = new Date("2026-07-01T10:00:00Z");
const { prisma, updates } = makePrisma({
reminders: [reminderRow({ measurementType, intervalDays: 28 })],
measurement: { measuredAt },
});
const isModuleEnabled = vi.fn(async () => true);
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date("2026-07-01T12:00:00Z"),
{ isModuleEnabled },
);
expect(summary.satisfied, `${measurementType} should satisfy`).toBe(1);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
}
});
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
const { prisma, updates } = makePrisma({
reminders: [
+2
View File
@@ -67,6 +67,8 @@ function moduleForMeasurementType(
return "sleep";
case "PHQ9_SCORE":
case "GAD7_SCORE":
case "WHO5_SCORE":
case "SCI_SCORE":
return "mentalHealth";
default:
return null;
@@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_TO_HEIGHT",
// v1.27.9 — WHO-5 / SCI screening totals: same in-app questionnaire
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
"WHO5_SCORE",
"SCI_SCORE",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
@@ -2,9 +2,14 @@ import { describe, it, expect } from "vitest";
import {
INSTRUMENTS,
INSTRUMENT_MEASUREMENT_TYPE,
hasValidatedItems,
isSafetyFlagged,
needsFollowUp,
optionLabelKey,
scoreTotal,
severityBand,
isSafetyFlagged,
stemKey,
} from "../instruments";
/**
@@ -12,19 +17,40 @@ import {
* GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
* 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
* flag. A future off-by-one on any band edge or the safety predicate must fail
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
* `.planning/research/v125/clinical-instruments.md`.
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7).
*
* v1.27.9 — the WHO-5 / SCI pins join. Ground truth: the official WHO
* publication WHO/UCN/MSD/MHE/2024.1 ("raw score … zero to 25 … multiplied by
* four" → 0–100; "a percentage score below 50 … an indication for further
* assessment") and Espie et al., BMJ Open 2014;4:e004183 ("total score ranges
* from 0 to 32, with higher values indicative of better sleep"; "an SCI
* cut-off ≤16 … 'probable insomnia disorder'").
*/
describe("mental-health instruments", () => {
describe("scoreTotal", () => {
it("sums the item answers", () => {
expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
it("sums the item answers for PHQ-9 / GAD-7 (multiplier 1)", () => {
expect(scoreTotal("PHQ9", [0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
expect(scoreTotal("GAD7", [0, 0, 0, 0, 0, 0, 0])).toBe(0);
});
it("returns the max when every item is 3", () => {
expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
expect(scoreTotal("PHQ9", Array(9).fill(3))).toBe(27); // PHQ-9 max
expect(scoreTotal("GAD7", Array(7).fill(3))).toBe(21); // GAD-7 max
});
it("WHO-5 reports raw-sum × 4 (the official 0–100 percentage)", () => {
// WHO scoring page: raw 0–25, "multiplied by four" → 0–100.
expect(scoreTotal("WHO5", [0, 0, 0, 0, 0])).toBe(0);
expect(scoreTotal("WHO5", [5, 5, 5, 5, 5])).toBe(100);
expect(scoreTotal("WHO5", [3, 3, 3, 3, 3])).toBe(60);
// Raw 12 (below the raw-13 cut) → 48 on the percentage scale.
expect(scoreTotal("WHO5", [3, 3, 2, 2, 2])).toBe(48);
});
it("SCI sums to 0–32 (higher = better sleep)", () => {
expect(scoreTotal("SCI", Array(8).fill(0))).toBe(0);
expect(scoreTotal("SCI", Array(8).fill(4))).toBe(32);
expect(scoreTotal("SCI", [4, 3, 2, 1, 0, 4, 1, 1])).toBe(16);
});
});
@@ -62,12 +88,47 @@ describe("mental-health instruments", () => {
});
});
describe("action threshold is ≥10 for both instruments", () => {
it("PHQ-9", () => {
expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
describe("severityBand — WHO-5 (below 50 = low; achievable totals are ×4)", () => {
const cases: Array<[number, string]> = [
[0, "low"],
[48, "low"], // raw 12 — below the WHO raw-13 cut
[52, "good"], // raw 13 — at/above the cut
[100, "good"],
];
it.each(cases)("WHO5 total %i → %s", (total, band) => {
expect(severityBand("WHO5", total)).toBe(band);
});
it("GAD-7", () => {
expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
});
describe("severityBand — SCI (≤16 probable-insomnia range per Espie 2014)", () => {
const cases: Array<[number, string]> = [
[0, "belowThreshold"],
[16, "belowThreshold"],
[17, "aboveThreshold"],
[32, "aboveThreshold"],
];
it.each(cases)("SCI total %i → %s", (total, band) => {
expect(severityBand("SCI", total)).toBe(band);
});
});
describe("needsFollowUp — direction-aware thresholds", () => {
it("PHQ-9 / GAD-7 point up (≥ 10)", () => {
expect(needsFollowUp("PHQ9", 9)).toBe(false);
expect(needsFollowUp("PHQ9", 10)).toBe(true);
expect(needsFollowUp("GAD7", 9)).toBe(false);
expect(needsFollowUp("GAD7", 10)).toBe(true);
});
it("WHO-5 points down (≤ 50 → gentle PHQ-9 pointer)", () => {
expect(needsFollowUp("WHO5", 48)).toBe(true);
expect(needsFollowUp("WHO5", 50)).toBe(true);
expect(needsFollowUp("WHO5", 52)).toBe(false);
});
it("SCI points down (≤ 16)", () => {
expect(needsFollowUp("SCI", 16)).toBe(true);
expect(needsFollowUp("SCI", 17)).toBe(false);
});
});
@@ -82,13 +143,70 @@ describe("mental-health instruments", () => {
it("does not flag when item 9 = 0 even with a high total", () => {
// Every other item maxed (24), item 9 = 0 → severe band, no flag.
const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
expect(severityBand("PHQ9", scoreTotal("PHQ9", items))).toBe("severe");
expect(isSafetyFlagged("PHQ9", items)).toBe(false);
});
it("never flags GAD-7 (no safety item)", () => {
it("never flags the instruments without a safety item", () => {
expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
expect(isSafetyFlagged("WHO5", [0, 0, 0, 0, 0])).toBe(false);
expect(isSafetyFlagged("SCI", [0, 0, 0, 0, 0, 0, 0, 0])).toBe(false);
expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
expect(INSTRUMENTS.WHO5.safetyItemIndex).toBeNull();
expect(INSTRUMENTS.SCI.safetyItemIndex).toBeNull();
});
});
describe("registry shape — the config the surfaces drive from", () => {
it("presents options in the source order (WHO-5 leads with 5, SCI with 4)", () => {
expect(INSTRUMENTS.WHO5.optionOrder).toEqual([5, 4, 3, 2, 1, 0]);
expect(INSTRUMENTS.SCI.optionOrder).toEqual([4, 3, 2, 1, 0]);
expect(INSTRUMENTS.PHQ9.optionOrder).toEqual([0, 1, 2, 3]);
});
it("resolves option-label keys per scheme (shared vs SCI per-item groups)", () => {
expect(optionLabelKey("PHQ9", 0, 2)).toBe("options.2");
expect(optionLabelKey("WHO5", 4, 5)).toBe("who5Options.5");
// SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration.
expect(optionLabelKey("SCI", 0, 4)).toBe("sciOptions.duration.4");
expect(optionLabelKey("SCI", 2, 0)).toBe("sciOptions.nights.0");
expect(optionLabelKey("SCI", 3, 2)).toBe("sciOptions.quality.2");
expect(optionLabelKey("SCI", 6, 1)).toBe("sciOptions.impact.1");
expect(optionLabelKey("SCI", 7, 3)).toBe("sciOptions.problemDuration.3");
});
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
expect(stemKey("WHO5", 0)).toBe("who5.period");
expect(stemKey("SCI", 0)).toBe("sci.night");
expect(stemKey("SCI", 3)).toBe("sci.night");
expect(stemKey("SCI", 4)).toBe("sci.impact");
expect(stemKey("SCI", 7)).toBe("sci.finally");
expect(stemKey("PHQ9", 0)).toBeNull();
expect(stemKey("GAD7", 0)).toBeNull();
});
it("declares validated item locales honestly (SCI = English only)", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
expect(hasValidatedItems("WHO5", locale)).toBe(true);
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
}
expect(hasValidatedItems("SCI", "en")).toBe(true);
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
expect(hasValidatedItems("SCI", "de")).toBe(false);
expect(hasValidatedItems("SCI", "pl")).toBe(false);
});
it("projects each instrument onto its measurement type", () => {
expect(INSTRUMENT_MEASUREMENT_TYPE.WHO5).toBe("WHO5_SCORE");
expect(INSTRUMENT_MEASUREMENT_TYPE.SCI).toBe("SCI_SCORE");
});
it("every instrument carries a non-empty attribution line", () => {
for (const def of Object.values(INSTRUMENTS)) {
expect(def.attribution.length).toBeGreaterThan(20);
}
expect(INSTRUMENTS.WHO5.attribution).toContain("CC BY-NC-SA 3.0 IGO");
expect(INSTRUMENTS.SCI.attribution).toContain("BMJ Open");
});
});
});
+268 -39
View File
@@ -1,58 +1,123 @@
/**
* v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7).
* v1.27.9 — the registry generalises to carry the WHO-5 well-being index and
* the Sleep Condition Indicator (SCI) on the same infrastructure: per-item
* answer scales, response-option schemes, score direction, and the reported
* total (WHO-5 reports raw-sum × 4 as a 0–100 percentage) are now instrument
* properties instead of PHQ/GAD assumptions.
*
* 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). The de/es/fr/it/pl
* item wordings are the public-domain Pfizer/PRIME-MD validated localized
* versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
* are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
* Pure, server-authoritative scoring. Item wording, response anchors, scoring
* and band thresholds are taken from the primary sources only:
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
* further assessment". WHO publishes official translations for all six
* app locales; they ship verbatim.
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
* possible insomnia disorder". Item text is validated in English; app
* locales without a validated translation present the English items.
* This module owns the canonical NUMERIC contract; the human wording lives in
* `messages/*.json` under `mentalHealth.items.*` (validated translations per
* locale where they exist — re-wording invalidates the score). See
* `.planning/research/screening-instruments-2026-07.md`.
*
* 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.
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost.
* Neither the WHO-5 (all items positively worded) nor the SCI (sleep-specific
* items only) carries a crisis item. 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";
export type InstrumentId = "PHQ9" | "GAD7" | "WHO5" | "SCI";
export interface InstrumentDefinition {
id: InstrumentId;
/** Lowercase i18n slug — the key segment under `mentalHealth.*`. */
i18nKey: "phq9" | "gad7" | "who5" | "sci";
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7, WHO-5 = 5, SCI = 8). */
itemCount: number;
/** Highest per-item answer value (PHQ/GAD 3, SCI 4, WHO-5 5). */
itemMax: number;
/**
* Answer values in the order the source instrument presents them. The
* PHQ/GAD forms run 0→3; the WHO-5 form leads with 5 ("All of the time");
* the SCI form leads with 4.
*/
optionOrder: readonly number[];
/**
* i18n namespace for the response-option labels under `mentalHealth.`:
* without `optionGroups` one shared anchor set (`<ns>.<value>`); with
* `optionGroups` (SCI) per-item anchor groups (`<ns>.<group>.<value>`,
* group = `optionGroups[itemIndex]`).
*/
optionNamespace: string;
optionGroups?: readonly string[];
/**
* Recall-stem caption key per item under `mentalHealth.stems.` — the WHO-5
* and SCI items are fragments under a shared stem on the source forms, so
* the wizard paints the applicable stem above the question. PHQ/GAD keep
* their frame in the instrument description line (shipped contract).
*/
stemKeys?: readonly string[];
/** Multiplier from item-sum to the reported total (WHO-5: × 4). */
scoreMultiplier: number;
/** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */
minScore: number;
maxScore: number;
/** Whether a HIGHER total is the good direction (WHO-5 / SCI). */
higherIsBetter: boolean;
/** Total-score LOINC for the FHIR/export contract; null when none exists. */
totalLoinc: string | null;
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
/**
* Follow-up threshold on the reported scale. The concerning side follows
* the direction: PHQ/GAD ≥ threshold, WHO-5/SCI ≤ threshold (WHO-5 "below
* 50" — 50 itself is unreachable on the ×4 scale; SCI ≤ 16 per Espie 2014).
*/
actionThreshold: number;
/** Item index (0-based) of the safety-critical self-harm item, or null. */
safetyItemIndex: number | null;
/**
* Locales whose bundled item wording is a validated translation. A locale
* outside this list renders the validated source-language items plus an
* honest chrome note — NEVER a self-made translation.
*/
validatedItemLocales: readonly string[];
/**
* Required attribution — render VERBATIM on the instrument's start card and
* the result view (and in exports). Stays in English by convention (the
* developers' names and licence identifiers are not translated).
*/
attribution: string;
}
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
/**
* 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).
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
* text per the instrument's own permission grant.
*/
export const PHQ_GAD_ATTRIBUTION =
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",
i18nKey: "phq9",
itemCount: 9,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "options",
scoreMultiplier: 1,
minScore: 0,
maxScore: 27,
higherIsBetter: false,
totalLoinc: "44261-6",
bands: [
{ key: "minimal", min: 0, max: 4 },
@@ -64,13 +129,21 @@ const PHQ9: InstrumentDefinition = {
actionThreshold: 10,
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
safetyItemIndex: 8,
validatedItemLocales: ALL_APP_LOCALES,
attribution: PHQ_GAD_ATTRIBUTION,
};
const GAD7: InstrumentDefinition = {
id: "GAD7",
i18nKey: "gad7",
itemCount: 7,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "options",
scoreMultiplier: 1,
minScore: 0,
maxScore: 21,
higherIsBetter: false,
totalLoinc: "70274-6",
bands: [
{ key: "minimal", min: 0, max: 4 },
@@ -80,25 +153,137 @@ const GAD7: InstrumentDefinition = {
],
actionThreshold: 10,
safetyItemIndex: null,
validatedItemLocales: ALL_APP_LOCALES,
attribution: PHQ_GAD_ATTRIBUTION,
};
/**
* WHO-5 well-being index — five positively-worded items over the last two
* weeks, 6-point 5→0 scale. Reported score = raw sum × 4 (0–100, higher =
* better well-being) per the official WHO scoring. "A percentage score below
* 50 … has been suggested as a cut-off for poor mental well-being and as an
* indication for further assessment" (WHO/UCN/MSD/MHE/2024.1) — the result
* view answers a ≤ 50 total with a gentle pointer to the PHQ-9 check-in.
* Achievable totals are multiples of 4, so the 0–50 band equals "below 50".
*/
const WHO5: InstrumentDefinition = {
id: "WHO5",
i18nKey: "who5",
itemCount: 5,
itemMax: 5,
optionOrder: [5, 4, 3, 2, 1, 0],
optionNamespace: "who5Options",
stemKeys: Array(5).fill("who5.period"),
scoreMultiplier: 4,
minScore: 0,
maxScore: 100,
higherIsBetter: true,
totalLoinc: null,
bands: [
{ key: "low", min: 0, max: 50 },
{ key: "good", min: 51, max: 100 },
],
actionThreshold: 50,
safetyItemIndex: null,
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
validatedItemLocales: ALL_APP_LOCALES,
attribution:
"World Health Organization. The World Health Organization-Five Well-Being " +
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
};
/**
* Sleep Condition Indicator — eight DSM-5-aligned items, 0–4 each with
* item-specific anchors, total 0–32, HIGHER = better sleep. "A score of ≤16
* on the SCI seems reasonable to detect possible insomnia disorder" (Espie
* et al. 2014) — the band wording stays neutral per the paper, no diagnosis
* language. Items 1–4 ask about a typical night in the last month, items 5–7
* about the past month's daytime impact, item 8 about problem duration.
*/
const SCI: InstrumentDefinition = {
id: "SCI",
i18nKey: "sci",
itemCount: 8,
itemMax: 4,
optionOrder: [4, 3, 2, 1, 0],
optionNamespace: "sciOptions",
optionGroups: [
"duration",
"duration",
"nights",
"quality",
"impact",
"impact",
"impact",
"problemDuration",
],
stemKeys: [
"sci.night",
"sci.night",
"sci.night",
"sci.night",
"sci.impact",
"sci.impact",
"sci.impact",
"sci.finally",
],
scoreMultiplier: 1,
minScore: 0,
maxScore: 32,
higherIsBetter: true,
totalLoinc: null,
bands: [
{ key: "belowThreshold", min: 0, max: 16 },
{ key: "aboveThreshold", min: 17, max: 32 },
],
actionThreshold: 16,
safetyItemIndex: null,
// Validated in English (the CC BY-NC BMJ Open publication). French/Italian
// validations exist in the literature but their item text is not openly
// redistributable, so every non-EN locale presents the English items with
// an honest note — never a self-made translation.
validatedItemLocales: ["en"],
attribution:
"Espie CA, Kyle SD, Hames P, et al. The Sleep Condition Indicator: a " +
"clinical screening tool to evaluate insomnia disorder. BMJ Open " +
"2014;4:e004183. © Sleepio Limited; free for non-commercial use (CC BY-NC).",
};
export const INSTRUMENTS: Record<InstrumentId, InstrumentDefinition> = {
PHQ9,
GAD7,
WHO5,
SCI,
};
/** Landing / history ordering — the shipped pair first, then the new pair. */
export const INSTRUMENT_ORDER: readonly InstrumentId[] = [
"PHQ9",
"GAD7",
"WHO5",
"SCI",
];
/** The DB MeasurementType the instrument's total score projects to. */
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
PHQ9: "PHQ9_SCORE",
GAD7: "GAD7_SCORE",
WHO5: "WHO5_SCORE",
SCI: "SCI_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);
/**
* The REPORTED total for a completed administration (server-authoritative):
* item-sum × the instrument's multiplier (WHO-5 reports the 0–100 percentage,
* everything else the raw sum).
*/
export function scoreTotal(id: InstrumentId, items: readonly number[]): number {
const sum = items.reduce((acc, v) => acc + v, 0);
return sum * INSTRUMENTS[id].scoreMultiplier;
}
/** Resolve the severity-band key for a total score. */
/** Resolve the severity-band key for a reported 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);
@@ -106,6 +291,50 @@ export function severityBand(id: InstrumentId, total: number): string {
return band?.key ?? def.bands[def.bands.length - 1].key;
}
/**
* Whether the gentle follow-up hint applies for a reported total. Direction-
* aware: PHQ-9/GAD-7 point up (≥ 10), WHO-5 and the SCI point down (≤ 50 /
* ≤ 16 — lower totals mean lower well-being / worse sleep).
*/
export function needsFollowUp(id: InstrumentId, total: number): boolean {
const def = INSTRUMENTS[id];
return def.higherIsBetter
? total <= def.actionThreshold
: total >= def.actionThreshold;
}
/**
* i18n key (under `mentalHealth.`) for one response option of one item —
* shared anchors (`options.2`), instrument-specific shared anchors
* (`who5Options.5`), or the SCI's per-item anchor groups
* (`sciOptions.duration.4`).
*/
export function optionLabelKey(
id: InstrumentId,
itemIndex: number,
value: number,
): string {
const def = INSTRUMENTS[id];
return def.optionGroups
? `${def.optionNamespace}.${def.optionGroups[itemIndex]}.${value}`
: `${def.optionNamespace}.${value}`;
}
/** Stem caption key (under `mentalHealth.stems.`) for an item, or null. */
export function stemKey(id: InstrumentId, itemIndex: number): string | null {
return INSTRUMENTS[id].stemKeys?.[itemIndex] ?? null;
}
/**
* Whether the bundled item wording for `locale` is a validated translation.
* When false the surface shows the validated source-language items plus a
* localized "validated in English" chrome note.
*/
export function hasValidatedItems(id: InstrumentId, locale: string): boolean {
const base = locale.toLowerCase().split("-")[0];
return INSTRUMENTS[id].validatedItemLocales.includes(base);
}
/**
* 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
+6 -6
View File
@@ -1,6 +1,6 @@
/**
* OpenAPI route table for the opt-in mental-health screeners
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7.
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7 / WHO-5 / SCI.
*
* 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
@@ -23,7 +23,7 @@ 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.",
"Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
});
listAssessmentsSchema.meta({
@@ -35,7 +35,7 @@ listAssessmentsSchema.meta({
const assessmentRow = z
.object({
id: z.string(),
instrument: z.enum(["PHQ9", "GAD7"]),
instrument: z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]),
locale: z.string(),
version: z.string(),
totalScore: z.number(),
@@ -87,7 +87,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.",
"Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
requestParams: { query: listAssessmentsSchema },
responses: {
"200": {
@@ -106,9 +106,9 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
post: {
tags: ["Mental health"],
summary: "Record a completed PHQ-9 / GAD-7 screener",
summary: "Record a completed screener",
description:
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
requestBody: {
content: {
"application/json": { schema: createAssessmentSchema },
@@ -37,10 +37,26 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
// v1.27.6 — plannable mental-wellbeing screenings
"PHQ9_SCORE",
"GAD7_SCORE",
// v1.27.9 — WHO-5 + SCI join on the identical contract
"WHO5_SCORE",
"SCI_SCORE",
]),
);
});
it("accepts the WHO-5 / SCI screenings with the 4-week default cadence", () => {
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
const parsed = createMeasurementReminderSchema.safeParse({
label: "Screening check-in",
measurementType,
intervalDays: 28,
});
expect(parsed.success, `expected ${measurementType} to be allowed`).toBe(
true,
);
}
});
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
const parsed = createMeasurementReminderSchema.safeParse({
label: "PHQ-9 Check-in",
@@ -473,9 +473,15 @@ describe("measurement validation", () => {
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
// v1.27.6 — the mental-wellbeing screener projects each completed
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
// so a client can never mint a server-owned score trend point.
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
// (the RECOVERY_SCORE precedent; v1.27.9 adds WHO5_SCORE / SCI_SCORE).
// COMPUTED is not client-attributable, so a client can never mint a
// server-owned score trend point.
for (const type of [
"PHQ9_SCORE",
"GAD7_SCORE",
"WHO5_SCORE",
"SCI_SCORE",
]) {
const parsed = createMeasurementSchema.safeParse({
type,
value: 5,
+8 -1
View File
@@ -64,11 +64,16 @@ export const measurementReminderTypeEnum = z
// the dispatcher (see `moduleForMeasurementType`).
"PHQ9_SCORE",
"GAD7_SCORE",
// v1.27.9 — WHO-5 + SCI join the plannable screenings on the identical
// contract (auto-resolve on the server-owned COMPUTED score row, module-
// gated in the dispatcher).
"WHO5_SCORE",
"SCI_SCORE",
])
.meta({
id: "MeasurementReminderType",
description:
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
});
export type MeasurementReminderType = z.infer<
@@ -85,6 +90,8 @@ export type MeasurementReminderType = z.infer<
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
"PHQ9_SCORE",
"GAD7_SCORE",
"WHO5_SCORE",
"SCI_SCORE",
]);
/** Whether a reminder's `measurementType` targets a screening check-in. */
+12
View File
@@ -135,6 +135,11 @@ export const measurementTypeEnum = z.enum([
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// ── v1.27.9 — screening scores (same assessment contract as PHQ-9/GAD-7,
// inverted direction: HIGHER is better). WHO-5 stores the reported 0–100
// percentage (raw sum × 4); SCI stores the 0–32 total.
"WHO5_SCORE",
"SCI_SCORE",
]);
/**
@@ -379,6 +384,9 @@ const unitMap: Record<string, string> = {
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "score",
SCI_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
@@ -581,6 +589,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
PHQ9_SCORE: { min: 0, max: 27 },
GAD7_SCORE: { min: 0, max: 21 },
// ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4),
// SCI total 0–32 — both server-derived, higher = better.
WHO5_SCORE: { min: 0, max: 100 },
SCI_SCORE: { min: 0, max: 32 },
// Grip strength kg — a child can read in the single digits, an elite athlete
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
GRIP_STRENGTH: { min: 0, max: 120 },
+20 -6
View File
@@ -1,8 +1,10 @@
/**
* 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).
* (PHQ-9 / GAD-7; v1.27.9 adds WHO-5 + SCI). The item answers are small
* integers whose ceiling is instrument-specific (PHQ/GAD 0–3, SCI 0–4,
* WHO-5 0–5); the array length must match the instrument. `userId` is NEVER
* a body field — it is narrowed from the session in the handler (CLAUDE.md
* convention).
*/
import { z } from "zod/v4";
import {
@@ -10,7 +12,7 @@ import {
type InstrumentId,
} from "@/lib/mental-health/instruments";
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
/**
* Client provenance for an administration. Closed enum so the column can never
@@ -21,12 +23,15 @@ export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
*/
export const assessmentSourceEnum = z.enum(["WEB", "IOS"]);
const itemAnswer = z.number().int().min(0).max(3);
// Structural ceiling — the WHO-5's 0–5 scale is the widest. The exact
// per-instrument ceiling (PHQ/GAD 3, SCI 4) is enforced in the superRefine
// below, where the instrument is known.
const itemAnswer = z.number().int().min(0).max(5);
export const createAssessmentSchema = z
.object({
instrument: assessmentInstrumentEnum,
/** Per-item answers, each 0–3, ordered as the instrument presents them. */
/** Per-item answers, 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(),
@@ -55,6 +60,15 @@ export const createAssessmentSchema = z
message: `${val.instrument} expects exactly ${def.itemCount} item answers`,
});
}
val.items.forEach((answer, index) => {
if (answer > def.itemMax) {
ctx.addIssue({
code: "custom",
path: ["items", index],
message: `${val.instrument} answers range 0–${def.itemMax}`,
});
}
});
});
export const listAssessmentsSchema = z.object({