mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
f7920d81a6
commit
b6aedfeb61
7 files changed
+64
-10
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:%';
|
||||
@@ -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:<id>`) so the score rides
|
||||
/// the existing chart / rollup infra — the trend, never the item content.
|
||||
/// administration (linked by `externalId = assessment:<id>`, `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")
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -108,7 +108,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["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.",
|
||||
"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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user