feat(mental-health): make the screener score rows server-owned COMPUTED measurements

Each completed PHQ-9 / GAD-7 administration already projects its total
onto a PHQ9_SCORE / GAD7_SCORE measurement row (unit score, linked via
externalId assessment:<id>). Those rows were attributed MANUAL — a
client-writable source. The projection is a server-derived value, so it
now writes source COMPUTED, the same server-owned source RECOVERY_SCORE
uses: no client write surface can attribute COMPUTED, so a forged score
trend point cannot enter through the measurement POST. Migration 0225
re-attributes the existing assessment-linked projection rows; a score a
user typed into the generic measurement form keeps MANUAL. OpenAPI
description updated and the spec regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 14:54:17 +02:00
1 parent f7920d81a6
commit b6aedfeb61
7 files changed
+64 -10

No files matched your search

@@ -183,12 +183,22 @@ describe("POST /api/mental-health/assessments", () => {
expect(createArg.data.item9Flagged).toBe(false);
// Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment.
// Server-owned: the row carries the COMPUTED source (the RECOVERY_SCORE
// precedent) — a client can never attribute COMPUTED on a write surface,
// so the trend cannot be forged through the measurement POST.
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { type: string; value: number; unit: string; externalId: string };
data: {
type: string;
value: number;
unit: string;
source: string;
externalId: string;
};
};
expect(measArg.data.type).toBe("GAD7_SCORE");
expect(measArg.data.value).toBe(6);
expect(measArg.data.unit).toBe("score");
expect(measArg.data.source).toBe("COMPUTED");
expect(measArg.data.externalId).toBe("assessment:mha_2");
});
@@ -308,9 +308,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// chart/rollup infra reads the trend without ever touching item content. The
// externalId anchors the trend point to this administration; duplicate trend
// points are structurally impossible because a replayed externalId returns
// the existing assessment above before reaching this create. `MeasurementSource`
// carries no WEB/IOS member (a screener is questionnaire input, not a device
// sample), so the row stays MANUAL — the WEB/IOS provenance + client
// the existing assessment above before reaching this create. The row is
// `COMPUTED` — a server-derived projection of the encrypted answers, exactly
// like RECOVERY_SCORE: clients cannot attribute the COMPUTED source on any
// write surface, so a forged PHQ9_SCORE / GAD7_SCORE trend point can never
// enter through the measurement POST. The WEB/IOS provenance + client
// externalId live on the assessment this row links to.
await prisma.measurement.create({
data: {
@@ -318,7 +320,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
value: total,
unit: "score",
source: "MANUAL" as MeasurementSource,
source: "COMPUTED" as MeasurementSource,
measuredAt: when,
notes: null,
externalId: `assessment:${assessment.id}`,