From b6aedfeb61c881af1cd68e29be21c99eb1a1ca99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 14:54:17 +0200 Subject: [PATCH] feat(mental-health): make the screener score rows server-owned COMPUTED measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:). 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. --- docs/api/openapi.yaml | 5 +++-- .../migration.sql | 21 +++++++++++++++++++ prisma/schema.prisma | 6 ++++-- .../assessments/__tests__/route.test.ts | 12 ++++++++++- .../api/mental-health/assessments/route.ts | 10 +++++---- src/lib/openapi/routes/mental-health.ts | 2 +- .../validations/__tests__/measurement.test.ts | 18 ++++++++++++++++ 7 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5a8533ea..3e8f38ecf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8131,8 +8131,9 @@ paths: tags: - Mental health summary: Record a completed PHQ-9 / GAD-7 screener - description: Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / - GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set. + description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned + PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the + response carries the locale-aware crisis-resource set. requestBody: content: application/json: diff --git a/prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql b/prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql new file mode 100644 index 000000000..f93aed1fc --- /dev/null +++ b/prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql @@ -0,0 +1,21 @@ +-- v1.27.6 — mental-wellbeing score rows become server-owned. +-- +-- The `PHQ9_SCORE` / `GAD7_SCORE` enum values exist since 0213; the screener +-- route has projected each completed administration onto a Measurement row +-- from the start. Those rows were attributed `MANUAL`, which is a +-- client-writable source — the projection is a server-derived value (the sum +-- over the encrypted item answers), so it now writes `COMPUTED`, the same +-- server-owned source RECOVERY_SCORE uses. Clients cannot attribute COMPUTED +-- on any write surface, so a forged score trend point cannot enter through +-- the measurement POST. +-- +-- Re-attribute the existing projection rows so the trend is uniformly +-- server-owned. Scoped to the `assessment:` externalId prefix the route +-- stamps: a hand-entered score row a user may have logged deliberately +-- through the generic measurement form keeps its MANUAL attribution. +-- Idempotent, forward-only. +UPDATE "measurements" +SET "source" = 'COMPUTED' +WHERE "type" IN ('PHQ9_SCORE', 'GAD7_SCORE') + AND "source" = 'MANUAL' + AND "external_id" LIKE 'assessment:%'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e2b483a3b..4c25019fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1306,8 +1306,10 @@ enum AssessmentInstrument { /// nor logged. The derived total / band / item-9 flag are denormalised /// (server-authoritative) for cheap history reads without decrypting. A /// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per -/// administration (linked by `externalId = assessment:`) so the score rides -/// the existing chart / rollup infra — the trend, never the item content. +/// administration (linked by `externalId = assessment:`, `source = +/// COMPUTED` — server-owned like RECOVERY_SCORE, never client-attributable) so +/// the score rides the existing chart / rollup infra — the trend, never the +/// item content. model MentalHealthAssessment { id String @id @default(cuid()) userId String @map("user_id") diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index aba10d668..3181d9a5c 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -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"); }); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 315e547b0..2a4d40aec 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -308,9 +308,11 @@ async function postAssessment(request: NextRequest): Promise { // 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 { 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}`, diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 61d19e9ef..21f66a1fd 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -108,7 +108,7 @@ export const mentalHealthPaths: NonNullable = { tags: ["Mental health"], summary: "Record a completed PHQ-9 / GAD-7 screener", description: - "Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.", + "Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.", requestBody: { content: { "application/json": { schema: createAssessmentSchema }, diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index eefaa73d8..b110d1e9f 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -470,6 +470,24 @@ describe("measurement validation", () => { } }); + it("rejects a COMPUTED-attributed screener score row on a client write", () => { + // v1.27.6 — the mental-wellbeing screener projects each completed + // administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row + // (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable, + // so a client can never mint a server-owned score trend point. + for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) { + const parsed = createMeasurementSchema.safeParse({ + type, + value: 5, + measuredAt: "2026-06-02T12:00:00Z", + source: "COMPUTED", + }); + expect(parsed.success, `expected COMPUTED ${type} to be rejected`).toBe( + false, + ); + } + }); + it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => { for (const source of ["MANUAL", "APPLE_HEALTH"]) { const parsed = createMeasurementSchema.safeParse({