diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 42491d862..96cbdf70c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34485,6 +34485,8 @@ components: locale: type: string maxLength: 16 + englishOriginal: + type: boolean source: default: WEB type: string @@ -34501,8 +34503,9 @@ components: description: "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." + severity band + item-9 safety flag. `locale` is the reader's app language; set `englishOriginal` when the reader + answered in the English original instead of their language's text, and the server records the resulting + `wording`. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis." CreateAllergyRequest: type: object properties: @@ -44792,6 +44795,14 @@ components: locale: type: string minLength: 1 + wording: + anyOf: + - type: string + enum: + - validated + - unvalidated_translation + - original_en + - type: "null" version: type: string responsesEncrypted: @@ -78040,6 +78051,19 @@ components: - SCI locale: type: string + wording: + anyOf: + - type: string + enum: + - validated + - unvalidated_translation + - original_en + - type: "null" + description: "Which text the answers were given in. `validated`: the published validated version for `locale` (the + original for English). `unvalidated_translation`: a translation written for HealthLog where no validated + version exists; clients show that the result is orientation only. `original_en`: the English original, + chosen by a reader of another language (`englishOriginal` on create). Null on administrations recorded + before v1.46." version: type: string totalScore: @@ -78060,6 +78084,7 @@ components: - id - instrument - locale + - wording - version - totalScore - severityBand @@ -78265,6 +78290,19 @@ components: - SCI locale: type: string + wording: + anyOf: + - type: string + enum: + - validated + - unvalidated_translation + - original_en + - type: "null" + description: "Which text the answers were given in. `validated`: the published validated version for `locale` (the + original for English). `unvalidated_translation`: a translation written for HealthLog where no validated + version exists; clients show that the result is orientation only. `original_en`: the English original, + chosen by a reader of another language (`englishOriginal` on create). Null on administrations recorded + before v1.46." version: type: string totalScore: @@ -78301,6 +78339,7 @@ components: - id - instrument - locale + - wording - version - totalScore - severityBand diff --git a/prisma/migrations/0475_mental_health_assessment_wording/migration.sql b/prisma/migrations/0475_mental_health_assessment_wording/migration.sql new file mode 100644 index 000000000..6591fa1e1 --- /dev/null +++ b/prisma/migrations/0475_mental_health_assessment_wording/migration.sql @@ -0,0 +1,14 @@ +-- Questionnaires in every app language: an administration records which text +-- its answers were given in. "validated" is a published validated version, +-- "unvalidated_translation" a translation written for the app (shown with a +-- note), "original_en" the English original chosen by a reader of another +-- language. +-- +-- Nullable, no default, no backfill: earlier rows did not record it, and +-- which text they showed depended on releases since replaced, so null reads +-- as "not recorded" rather than as a guess. +-- +-- Reversibility: dropping the column loses only that marker; the scores and +-- the encrypted answers do not depend on it. + +ALTER TABLE "mental_health_assessments" ADD COLUMN IF NOT EXISTS "wording" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 970aeaa03..e78805032 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1808,9 +1808,16 @@ model MentalHealthAssessment { id String @id @default(cuid()) userId String @map("user_id") instrument AssessmentInstrument - /// The validated translation actually presented (officially-validated wording - /// per locale, English fallback otherwise — recorded for the audit trail). + /// The reader's app language when the administration was taken. It selects + /// the crisis-resource copy; with `wording` it says which text was shown. locale String + /// Which text the answers were given in: "validated" (the published + /// validated version for `locale`, the original for English), + /// "unvalidated_translation" (the app's own translation, shown with a note) + /// or "original_en" (the English original, chosen by a reader of another + /// language). Null on rows recorded before it was stored. Closed set: + /// ASSESSMENT_WORDINGS in src/lib/mental-health/instrument-wording.ts. + wording String? /// Instrument variant identifier (e.g. "standard"); future-proofs revisions. version String @default("standard") /// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }. diff --git a/src/app/api/mental-health/assessments/[id]/route.ts b/src/app/api/mental-health/assessments/[id]/route.ts index a2a720862..180962387 100644 --- a/src/app/api/mental-health/assessments/[id]/route.ts +++ b/src/app/api/mental-health/assessments/[id]/route.ts @@ -134,6 +134,7 @@ export const GET = apiHandler( userId: true, instrument: true, locale: true, + wording: true, version: true, totalScore: true, severityBand: true, @@ -165,6 +166,7 @@ export const GET = apiHandler( id: row.id, instrument: row.instrument, locale: row.locale, + wording: row.wording, version: row.version, totalScore: row.totalScore, severityBand: row.severityBand, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 740ae8d0d..02f57d119 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -53,6 +53,7 @@ import { type InstrumentId, } from "@/lib/mental-health/instruments"; import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources"; +import { presentedWording } from "@/lib/mental-health/wording/resolve"; import type { MeasurementType, MeasurementSource, @@ -70,10 +71,26 @@ import { const TREND_READ_CAP = 1500; import { listPage } from "@/lib/http/list-query"; +/** The columns every read of an administration returns (`shapeRow`). */ +const ASSESSMENT_SELECT = { + id: true, + instrument: true, + locale: true, + wording: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, +} as const; + function shapeRow(row: { id: string; instrument: string; locale: string; + wording: string | null; version: string; totalScore: number; severityBand: string; @@ -86,6 +103,7 @@ function shapeRow(row: { id: row.id, instrument: row.instrument, locale: row.locale, + wording: row.wording, version: row.version, totalScore: row.totalScore, severityBand: row.severityBand, @@ -145,18 +163,7 @@ export const GET = apiHandler(async (request: NextRequest) => { orderBy: [{ takenAt: "desc" }, { id: "desc" }], take, skip: offset ?? 0, - select: { - id: true, - instrument: true, - locale: true, - version: true, - totalScore: true, - severityBand: true, - item9Flagged: true, - crisisShownAt: true, - takenAt: true, - createdAt: true, - }, + select: ASSESSMENT_SELECT, }), ); // v1.44 — the change of each screener against its meaningful-change @@ -262,6 +269,7 @@ async function postAssessment(request: NextRequest): Promise { takenAt, tz, locale, + englishOriginal, source, externalId, } = parsed.data; @@ -282,18 +290,7 @@ async function postAssessment(request: NextRequest): Promise { externalId, instrument: { in: [...SCREENER_INSTRUMENTS] }, }, - select: { - id: true, - instrument: true, - locale: true, - version: true, - totalScore: true, - severityBand: true, - item9Flagged: true, - crisisShownAt: true, - takenAt: true, - createdAt: true, - }, + select: ASSESSMENT_SELECT, }); if (existing) return respondExisting(existing); } @@ -311,6 +308,15 @@ async function postAssessment(request: NextRequest): Promise { // `actor` is the caller in every case and equals `user` for everyone acting // on their own record, so this is the record's own locale for them. const presentedLocale = locale ?? actor.locale ?? "en"; + // Which text the answers were given in, resolved here rather than taken + // from the client: the client says only whether the reader switched to the + // English original; whether their language's text is validated is the + // wording registry's answer. + const wording = presentedWording( + id, + presentedLocale, + englishOriginal ?? false, + ); const when = takenAt ? new Date(takenAt) : new Date(); // Encrypt the per-item answers + the (unscored) functional-impairment follow-up. @@ -322,19 +328,6 @@ async function postAssessment(request: NextRequest): Promise { }), ); - const assessmentSelect = { - id: true, - instrument: true, - locale: true, - version: true, - totalScore: true, - severityBand: true, - item9Flagged: true, - crisisShownAt: true, - takenAt: true, - createdAt: true, - } as const; - let assessment; try { assessment = await prisma.mentalHealthAssessment.create({ @@ -342,6 +335,7 @@ async function postAssessment(request: NextRequest): Promise { userId: user.id, instrument: id, locale: presentedLocale, + wording, version: "standard", responsesEncrypted: blob, totalScore: total, @@ -353,7 +347,7 @@ async function postAssessment(request: NextRequest): Promise { source, externalId: externalId ?? null, }, - select: assessmentSelect, + select: ASSESSMENT_SELECT, }); } catch (err: unknown) { // Concurrent replay: two requests carrying the same externalId raced past @@ -373,7 +367,7 @@ async function postAssessment(request: NextRequest): Promise { externalId, instrument: { in: [...SCREENER_INSTRUMENTS] }, }, - select: assessmentSelect, + select: ASSESSMENT_SELECT, }); if (winner) { annotate({ @@ -438,7 +432,7 @@ async function postAssessment(request: NextRequest): Promise { }); annotate({ action: { name: "mental-health.create" }, - meta: { instrument: id, item9_flagged: flagged }, + meta: { instrument: id, item9_flagged: flagged, wording }, }); // SAFETY: surface the locale-aware crisis resources on a positive item-9. diff --git a/src/lib/export/sensitive-backup-schema.ts b/src/lib/export/sensitive-backup-schema.ts index bf7ea7f17..522659d51 100644 --- a/src/lib/export/sensitive-backup-schema.ts +++ b/src/lib/export/sensitive-backup-schema.ts @@ -9,6 +9,7 @@ import { z } from "zod/v4"; import { AssessmentInstrument } from "@/generated/prisma/enums"; import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; +import { ASSESSMENT_WORDINGS } from "@/lib/mental-health/wording/types"; /** * A completed screener administration. `responsesEncrypted` is REQUIRED rather @@ -22,6 +23,7 @@ export const mentalHealthAssessmentBackupSchema = z id: z.string().min(1), instrument: z.enum(AssessmentInstrument), locale: z.string().min(1), + wording: z.enum(ASSESSMENT_WORDINGS).nullable().optional(), version: z.string().optional(), responsesEncrypted: base64BytesSchema, totalScore: z.number().int(), diff --git a/src/lib/export/sensitive-backup.ts b/src/lib/export/sensitive-backup.ts index 383d90ce8..8204a81a7 100644 --- a/src/lib/export/sensitive-backup.ts +++ b/src/lib/export/sensitive-backup.ts @@ -55,6 +55,10 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { AssessmentInstrument } from "@/generated/prisma/client"; +import { + isAssessmentWording, + type AssessmentWording, +} from "@/lib/mental-health/wording/types"; export interface SensitiveBackupOptions { purpose?: "portable-export" | "disaster-recovery"; @@ -65,6 +69,8 @@ export interface MentalHealthAssessmentBackupEntry { id: string; instrument: AssessmentInstrument; locale: string; + /** Which text the answers were given in; null before it was recorded. */ + wording: AssessmentWording | null; version: string; /** Ciphertext as base64. The per-item answers, including PHQ-9 item 9. */ responsesEncrypted: string; @@ -146,6 +152,7 @@ const MENTAL_HEALTH_BACKUP_SELECT = { id: true, instrument: true, locale: true, + wording: true, version: true, responsesEncrypted: true, totalScore: true, @@ -210,6 +217,9 @@ export async function buildSensitiveBackupSection( id: row.id, instrument: row.instrument, locale: row.locale, + // The app writes only the closed set; anything else travels as null + // ("not recorded") rather than as a value the restore would refuse. + wording: isAssessmentWording(row.wording) ? row.wording : null, version: row.version, responsesEncrypted: Buffer.from(row.responsesEncrypted).toString( "base64", @@ -276,6 +286,7 @@ export type RestoredMentalHealthAssessment = Pick< Pick< MentalHealthAssessmentBackupEntry, | "version" + | "wording" | "item9Flagged" | "crisisShownAt" | "tz" @@ -326,6 +337,7 @@ export async function restoreSensitiveData( userId: ownerId, instrument: entry.instrument, locale: entry.locale, + wording: entry.wording ?? null, version: entry.version ?? "standard", responsesEncrypted: decodeBase64(entry.responsesEncrypted), totalScore: entry.totalScore, diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 8a15fd8e7..ffae0a242 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -18,6 +18,7 @@ import { createAssessmentSchema, listAssessmentsSchema, } from "@/lib/validations/mental-health"; +import { ASSESSMENT_WORDINGS } from "@/lib/mental-health/wording/types"; import { dataEnvelope, @@ -34,7 +35,7 @@ import { const createMentalHealthAssessmentRequest = createAssessmentSchema.meta({ id: "CreateMentalHealthAssessmentRequest", description: - "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.", + "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. `locale` is the reader's app language; set `englishOriginal` when the reader answered in the English original instead of their language's text, and the server records the resulting `wording`. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.", }); const listMentalHealthAssessmentsQuery = listAssessmentsSchema.meta({ @@ -48,6 +49,12 @@ const assessmentRow = z id: z.string(), instrument: z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]), locale: z.string(), + wording: z + .enum(ASSESSMENT_WORDINGS) + .nullable() + .describe( + "Which text the answers were given in. `validated`: the published validated version for `locale` (the original for English). `unvalidated_translation`: a translation written for HealthLog where no validated version exists; clients show that the result is orientation only. `original_en`: the English original, chosen by a reader of another language (`englishOriginal` on create). Null on administrations recorded before v1.46.", + ), version: z.string(), totalScore: z.number(), severityBand: z.string(), diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index e153bd624..6cbbd0777 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -60,8 +60,17 @@ export const createAssessmentSchema = z .max(64) .refine(isValidTimezone, "Invalid IANA timezone") .optional(), - /** Locale of the validated wording actually presented. */ + /** + * The reader's app language: it selects the questionnaire text that was + * shown and the crisis resources. Defaults to the account's language. + */ locale: z.string().max(16).optional(), + /** + * The reader switched to the English original instead of their + * language's text. The server records which wording the answers were + * given in from this and `locale`. + */ + englishOriginal: z.boolean().optional(), /** Client provenance; defaults to WEB. */ source: assessmentSourceEnum.optional().default("WEB"), /** diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index 48a526e97..e34d5552b 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -1905,6 +1905,7 @@ "id": "", "instrument": "GAD7", "locale": "en", + "wording": null, "version": "standard", "responsesEncrypted": "", "totalScore": 4, @@ -1917,13 +1918,14 @@ "externalId": null, "syncVersion": 0, "deletedAt": "", - "createdAt": "2026-05-05T13:56:01.000Z", - "updatedAt": "2026-05-05T13:57:01.000Z" + "createdAt": "2026-05-05T13:57:01.000Z", + "updatedAt": "2026-05-05T13:58:01.000Z" }, { "id": "", "instrument": "PHQ9", "locale": "de", + "wording": "unvalidated_translation", "version": "rt-MentalHealthAssessment-version", "responsesEncrypted": "", "totalScore": 14, @@ -1936,8 +1938,8 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "2026-05-05T13:56:00.000Z", - "updatedAt": "2026-05-05T13:57:00.000Z" + "createdAt": "2026-05-05T13:57:00.000Z", + "updatedAt": "2026-05-05T13:58:00.000Z" } ], "consentReceipts": [ @@ -4318,6 +4320,7 @@ "id": "", "instrument": "GAD7", "locale": "en", + "wording": null, "version": "standard", "responsesEncrypted": "", "totalScore": 4, @@ -4330,13 +4333,14 @@ "externalId": null, "syncVersion": 0, "deletedAt": "", - "createdAt": "2026-05-05T13:56:01.000Z", - "updatedAt": "2026-05-05T13:57:01.000Z" + "createdAt": "2026-05-05T13:57:01.000Z", + "updatedAt": "2026-05-05T13:58:01.000Z" }, { "id": "", "instrument": "PHQ9", "locale": "de", + "wording": "unvalidated_translation", "version": "rt-MentalHealthAssessment-version", "responsesEncrypted": "", "totalScore": 14, @@ -4349,8 +4353,8 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "2026-05-05T13:56:00.000Z", - "updatedAt": "2026-05-05T13:57:00.000Z" + "createdAt": "2026-05-05T13:57:00.000Z", + "updatedAt": "2026-05-05T13:58:00.000Z" } ], "consentReceipts": [ diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index 439fe95bc..7c7b4eb59 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -2153,6 +2153,7 @@ "userId": "round-trip-owner", "instrument": "GAD7", "locale": "en", + "wording": null, "version": "standard", "responsesEncrypted": "", "totalScore": 4, @@ -2165,14 +2166,15 @@ "externalId": null, "syncVersion": 0, "deletedAt": "", - "createdAt": "2026-05-05T13:56:01.000Z", - "updatedAt": "2026-05-05T13:57:01.000Z" + "createdAt": "2026-05-05T13:57:01.000Z", + "updatedAt": "2026-05-05T13:58:01.000Z" }, { "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", + "wording": "unvalidated_translation", "version": "rt-MentalHealthAssessment-version", "responsesEncrypted": "", "totalScore": 14, @@ -2185,8 +2187,8 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "2026-05-05T13:56:00.000Z", - "updatedAt": "2026-05-05T13:57:00.000Z" + "createdAt": "2026-05-05T13:57:00.000Z", + "updatedAt": "2026-05-05T13:58:00.000Z" } ], "ConsentReceipt": [ @@ -4800,6 +4802,7 @@ "id": "", "instrument": "GAD7", "locale": "en", + "wording": null, "version": "standard", "responsesEncrypted": "", "totalScore": 4, @@ -4812,13 +4815,14 @@ "externalId": null, "syncVersion": 0, "deletedAt": "", - "createdAt": "2026-05-05T13:56:01.000Z", - "updatedAt": "2026-05-05T13:57:01.000Z" + "createdAt": "2026-05-05T13:57:01.000Z", + "updatedAt": "2026-05-05T13:58:01.000Z" }, { "id": "", "instrument": "PHQ9", "locale": "de", + "wording": "unvalidated_translation", "version": "rt-MentalHealthAssessment-version", "responsesEncrypted": "", "totalScore": 14, @@ -4831,8 +4835,8 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "2026-05-05T13:56:00.000Z", - "updatedAt": "2026-05-05T13:57:00.000Z" + "createdAt": "2026-05-05T13:57:00.000Z", + "updatedAt": "2026-05-05T13:58:00.000Z" } ], "consentReceipts": [ diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 1622db7ff..0a7bc1df0 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -1780,6 +1780,7 @@ "userId": "round-trip-owner", "instrument": "GAD7", "locale": "en", + "wording": null, "version": "standard", "responsesEncrypted": "", "totalScore": 4, @@ -1792,14 +1793,15 @@ "externalId": null, "syncVersion": 0, "deletedAt": "", - "createdAt": "2026-05-05T13:56:01.000Z", - "updatedAt": "2026-05-05T13:57:01.000Z" + "createdAt": "2026-05-05T13:57:01.000Z", + "updatedAt": "2026-05-05T13:58:01.000Z" }, { "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", + "wording": "unvalidated_translation", "version": "rt-MentalHealthAssessment-version", "responsesEncrypted": "", "totalScore": 14, @@ -1812,8 +1814,8 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "2026-05-05T13:56:00.000Z", - "updatedAt": "2026-05-05T13:57:00.000Z" + "createdAt": "2026-05-05T13:57:00.000Z", + "updatedAt": "2026-05-05T13:58:00.000Z" } ], "ConsentReceipt": [ diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts index bfe84a6c5..ddf0f990e 100644 --- a/tests/integration/backup-fixture.ts +++ b/tests/integration/backup-fixture.ts @@ -1401,6 +1401,8 @@ export const FILL_OVERRIDES: Readonly> = { "Biomarker.contextEncrypted": () => encryptContextToBytes("fasted, morning draw"), "MoodEntry.tags": JSON.stringify(["round-trip-tag"]), + // A closed set (ASSESSMENT_WORDINGS); the export carries nothing else. + "MentalHealthAssessment.wording": "unvalidated_translation", "CorrelationPattern.qValue": 0.04, "CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32), // CHECK in migration 0408: the profile is built from samples or from hour buckets. diff --git a/tests/integration/mental-health-wording.test.ts b/tests/integration/mental-health-wording.test.ts new file mode 100644 index 000000000..76819a1f4 --- /dev/null +++ b/tests/integration/mental-health-wording.test.ts @@ -0,0 +1,183 @@ +/** + * Which text a check-in was answered in, end to end through the real routes + * and the real schema, checked against the published contract by the kit. + * + * The client says only two things: the reader's language and whether they + * switched to the English original. The server resolves the rest from the + * wording registry and stores it on the row, and every read returns it: + * - a language with a validated translation records `validated`; + * - a language with HealthLog's own translation records + * `unvalidated_translation`; + * - the switch records `original_en`; + * - English records `validated` whatever the switch says. + * The PHQ-9 crisis signpost answers item 9 the same way in every wording. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + GET as listAssessments, + POST as createAssessment, +} from "@/app/api/mental-health/assessments/route"; +import { GET as readAssessment } from "@/app/api/mental-health/assessments/[id]/route"; + +import { callRoute } from "../kit/call-route"; +import { makeSession, makeUser, type SessionFixture } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// The reminder satisfy and the rollup tail hand work to pg-boss; the queue is +// not what this file is about. +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ send: vi.fn().mockResolvedValue("wording-job") }), +})); + +interface Row { + id: string; + instrument: string; + locale: string; + wording: string | null; + item9Flagged: boolean; +} +interface Created { + assessment: Row; + crisis: { emergencyNumber: string } | null; +} + +const PHQ9_ITEM9 = [0, 0, 0, 0, 0, 0, 0, 0, 1]; +const SCI = [2, 2, 2, 2, 2, 2, 2, 2]; + +let session: SessionFixture; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + const user = await makeUser({ + locale: "nl", + modulePreferencesJson: { mentalHealth: true }, + }); + session = await makeSession(user); +}); + +async function submit(body: Record) { + const result = await callRoute(createAssessment, { + method: "POST", + route: "/api/mental-health/assessments", + body, + auth: session, + }); + expect(result.status, JSON.stringify(result.body)).toBe(201); + return result.data; +} + +describe("the wording a check-in records", () => { + it("records a validated translation, HealthLog's own translation and the English original apart", async () => { + const validated = await submit({ + instrument: "PHQ9", + items: PHQ9_ITEM9, + locale: "nl", + }); + const translation = await submit({ + instrument: "SCI", + items: SCI, + locale: "nl", + }); + const original = await submit({ + instrument: "SCI", + items: SCI, + locale: "nl", + englishOriginal: true, + }); + const english = await submit({ + instrument: "SCI", + items: SCI, + locale: "en", + englishOriginal: true, + }); + + expect(validated.assessment.wording).toBe("validated"); + expect(translation.assessment.wording).toBe("unvalidated_translation"); + expect(original.assessment.wording).toBe("original_en"); + expect(english.assessment.wording).toBe("validated"); + + const stored = await getPrismaClient().mentalHealthAssessment.findMany({ + select: { id: true, locale: true, wording: true }, + }); + const byId = new Map(stored.map((row) => [row.id, row])); + expect(byId.get(translation.assessment.id)).toMatchObject({ + locale: "nl", + wording: "unvalidated_translation", + }); + expect(byId.get(original.assessment.id)).toMatchObject({ + locale: "nl", + wording: "original_en", + }); + + const list = await callRoute<{ assessments: Row[] }>(listAssessments, { + method: "GET", + route: "/api/mental-health/assessments", + auth: session, + }); + expect(list.status).toBe(200); + expect( + Object.fromEntries(list.data.assessments.map((r) => [r.id, r.wording])), + ).toEqual({ + [validated.assessment.id]: "validated", + [translation.assessment.id]: "unvalidated_translation", + [original.assessment.id]: "original_en", + [english.assessment.id]: "validated", + }); + + const detail = await callRoute<{ assessment: Row }>(readAssessment, { + method: "GET", + route: "/api/mental-health/assessments/{id}", + params: { id: translation.assessment.id }, + auth: session, + }); + expect(detail.status).toBe(200); + expect(detail.data.assessment.wording).toBe("unvalidated_translation"); + }); + + it("defaults to the account's language when the client sends none", async () => { + const created = await submit({ instrument: "SCI", items: SCI }); + expect(created.assessment.locale).toBe("nl"); + expect(created.assessment.wording).toBe("unvalidated_translation"); + }); + + it("answers PHQ-9 item 9 with the crisis signpost in every wording", async () => { + for (const body of [ + { locale: "nl" }, + { locale: "ko" }, + { locale: "pt-BR" }, + { locale: "de" }, + { locale: "nl", englishOriginal: true }, + { locale: "en" }, + ]) { + const created = await submit({ + instrument: "PHQ9", + items: PHQ9_ITEM9, + ...body, + }); + expect(created.assessment.item9Flagged, JSON.stringify(body)).toBe(true); + expect(created.crisis, JSON.stringify(body)).not.toBeNull(); + } + const quiet = await submit({ + instrument: "PHQ9", + items: [3, 3, 3, 3, 3, 3, 3, 3, 0], + locale: "pt-BR", + }); + expect(quiet.crisis).toBeNull(); + }); + + it("refuses a switch that is not a boolean", async () => { + const result = await callRoute(createAssessment, { + method: "POST", + route: "/api/mental-health/assessments", + body: { instrument: "SCI", items: SCI, englishOriginal: "yes" }, + auth: session, + }); + expect(result.status).toBe(422); + expect(await getPrismaClient().mentalHealthAssessment.count()).toBe(0); + }); +});