mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user