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

@@ -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";