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:
Marc-André Bombeck committed 2026-10-23 08:14:15 +02:00
1 parent 3843705296
commit 02cc0631fa
14 files changed
+347 -66

No files matched your search

@@ -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,
+34 -40
View File
@@ -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(),
+12
View File
@@ -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,
+8 -1
View File
@@ -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(),
+10 -1
View File
@@ -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"),
/**