mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(mental-health): record which wording a check-in was answered in
An administration now stores `wording`: `validated` (the published validated version of the reader's language, the original for English), `unvalidated_translation` (HealthLog's own translation) or `original_en` (the English original, chosen by a reader of another language). The client sends only `englishOriginal`; the server resolves the rest from the wording registry, so a client cannot claim a validated wording the registry does not hold. `locale` keeps the reader's language, which still selects the crisis resources. Additive migration 0475: a nullable column with no backfill, since which text earlier rows showed depended on releases since replaced. Every read returns the field, the OpenAPI contract carries it and `englishOriginal`, and the disaster-recovery backup carries and restores it (the parity goldens gain the column; their pinned timestamps move by one step because the fill writes one more column). The PHQ-9 item-9 crisis signpost is answered from the answers alone and is proven in every wording through the real route.
This commit is contained in:
1 parent
3843705296
commit
02cc0631fa
14 files changed
+347
-66
No files matched your search
+41
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Response> {
|
||||
takenAt,
|
||||
tz,
|
||||
locale,
|
||||
englishOriginal,
|
||||
source,
|
||||
externalId,
|
||||
} = parsed.data;
|
||||
@@ -282,18 +290,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
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<Response> {
|
||||
// `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<Response> {
|
||||
}),
|
||||
);
|
||||
|
||||
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<Response> {
|
||||
userId: user.id,
|
||||
instrument: id,
|
||||
locale: presentedLocale,
|
||||
wording,
|
||||
version: "standard",
|
||||
responsesEncrypted: blob,
|
||||
totalScore: total,
|
||||
@@ -353,7 +347,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
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<Response> {
|
||||
externalId,
|
||||
instrument: { in: [...SCREENER_INSTRUMENTS] },
|
||||
},
|
||||
select: assessmentSelect,
|
||||
select: ASSESSMENT_SELECT,
|
||||
});
|
||||
if (winner) {
|
||||
annotate({
|
||||
@@ -438,7 +432,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
/**
|
||||
|
||||
@@ -1905,6 +1905,7 @@
|
||||
"id": "<id:89>",
|
||||
"instrument": "GAD7",
|
||||
"locale": "en",
|
||||
"wording": null,
|
||||
"version": "standard",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[1,1,0,1,0,1,0],\"schema\":1}>",
|
||||
"totalScore": 4,
|
||||
@@ -1917,13 +1918,14 @@
|
||||
"externalId": null,
|
||||
"syncVersion": 0,
|
||||
"deletedAt": "<tombstone>",
|
||||
"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": "<id:90>",
|
||||
"instrument": "PHQ9",
|
||||
"locale": "de",
|
||||
"wording": "unvalidated_translation",
|
||||
"version": "rt-MentalHealthAssessment-version",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[2,1,2,2,1,1,2,1,2],\"schema\":1}>",
|
||||
"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": "<id:89>",
|
||||
"instrument": "GAD7",
|
||||
"locale": "en",
|
||||
"wording": null,
|
||||
"version": "standard",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[1,1,0,1,0,1,0],\"schema\":1}>",
|
||||
"totalScore": 4,
|
||||
@@ -4330,13 +4333,14 @@
|
||||
"externalId": null,
|
||||
"syncVersion": 0,
|
||||
"deletedAt": "<tombstone>",
|
||||
"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": "<id:90>",
|
||||
"instrument": "PHQ9",
|
||||
"locale": "de",
|
||||
"wording": "unvalidated_translation",
|
||||
"version": "rt-MentalHealthAssessment-version",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[2,1,2,2,1,1,2,1,2],\"schema\":1}>",
|
||||
"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": [
|
||||
|
||||
@@ -2153,6 +2153,7 @@
|
||||
"userId": "round-trip-owner",
|
||||
"instrument": "GAD7",
|
||||
"locale": "en",
|
||||
"wording": null,
|
||||
"version": "standard",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[1,1,0,1,0,1,0],\"schema\":1}>",
|
||||
"totalScore": 4,
|
||||
@@ -2165,14 +2166,15 @@
|
||||
"externalId": null,
|
||||
"syncVersion": 0,
|
||||
"deletedAt": "<tombstone>",
|
||||
"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": "<id:99>",
|
||||
"userId": "round-trip-owner",
|
||||
"instrument": "PHQ9",
|
||||
"locale": "de",
|
||||
"wording": "unvalidated_translation",
|
||||
"version": "rt-MentalHealthAssessment-version",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[2,1,2,2,1,1,2,1,2],\"schema\":1}>",
|
||||
"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": "<id:89>",
|
||||
"instrument": "GAD7",
|
||||
"locale": "en",
|
||||
"wording": null,
|
||||
"version": "standard",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[1,1,0,1,0,1,0],\"schema\":1}>",
|
||||
"totalScore": 4,
|
||||
@@ -4812,13 +4815,14 @@
|
||||
"externalId": null,
|
||||
"syncVersion": 0,
|
||||
"deletedAt": "<tombstone>",
|
||||
"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": "<id:90>",
|
||||
"instrument": "PHQ9",
|
||||
"locale": "de",
|
||||
"wording": "unvalidated_translation",
|
||||
"version": "rt-MentalHealthAssessment-version",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[2,1,2,2,1,1,2,1,2],\"schema\":1}>",
|
||||
"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": [
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"userId": "round-trip-owner",
|
||||
"instrument": "GAD7",
|
||||
"locale": "en",
|
||||
"wording": null,
|
||||
"version": "standard",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[1,1,0,1,0,1,0],\"schema\":1}>",
|
||||
"totalScore": 4,
|
||||
@@ -1792,14 +1793,15 @@
|
||||
"externalId": null,
|
||||
"syncVersion": 0,
|
||||
"deletedAt": "<tombstone>",
|
||||
"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": "<id:100>",
|
||||
"userId": "round-trip-owner",
|
||||
"instrument": "PHQ9",
|
||||
"locale": "de",
|
||||
"wording": "unvalidated_translation",
|
||||
"version": "rt-MentalHealthAssessment-version",
|
||||
"responsesEncrypted": "<sealed:v1:{\"items\":[2,1,2,2,1,1,2,1,2],\"schema\":1}>",
|
||||
"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": [
|
||||
|
||||
@@ -1401,6 +1401,8 @@ export const FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
||||
"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.
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const result = await callRoute<Created>(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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user