diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 88bc29ec8..e8dc5bb0a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/prisma/migrations/0226_v1279_screening_scores/migration.sql b/prisma/migrations/0226_v1279_screening_scores/migration.sql new file mode 100644 index 000000000..ab9f6f77b --- /dev/null +++ b/prisma/migrations/0226_v1279_screening_scores/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c25019fb..a4f69bf9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index ec64359db..24f2ba941 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 457d82dd1..939802cdb 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -229,8 +229,9 @@ async function postAssessment(request: NextRequest): Promise { 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"; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 6f98364cd..d4869a3cc 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index add9c1298..ea5d9f841 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -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: [ diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 1138cf54b..6041c8d05 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -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; diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index d109e739c..f57427ded 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "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", () => { diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts index a97e92853..55cdf3d9d 100644 --- a/src/lib/mental-health/__tests__/instruments.test.ts +++ b/src/lib/mental-health/__tests__/instruments.test.ts @@ -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"); }); }); }); diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts index d54b7ac44..92103184b 100644 --- a/src/lib/mental-health/instruments.ts +++ b/src/lib/mental-health/instruments.ts @@ -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 (`.`); with + * `optionGroups` (SCI) per-item anchor groups (`..`, + * 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 = { 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 = { 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 diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 21f66a1fd..5f61be24d 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -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 = { 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 = { }, 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 }, diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts index 9ac7458cc..325e21019 100644 --- a/src/lib/validations/__tests__/measurement-reminders.test.ts +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -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", diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index b110d1e9f..281e7cdf9 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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, diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 39794123a..0ee684b90 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -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 = new Set([ "PHQ9_SCORE", "GAD7_SCORE", + "WHO5_SCORE", + "SCI_SCORE", ]); /** Whether a reminder's `measurementType` targets a screening check-in. */ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index e78adc2be..5b5961e8d 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { // ── 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 = { // 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 }, diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index 9cd08f999..8415afe60 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -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({