mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Show the answers behind a screening score
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been stored encrypted since the assessments shipped, and nothing ever read them back — the clinical detail (item 9 included) was invisible. A new by-id read decrypts them for whoever holds the mind read grant (the same grant that already serves the mood diary's prose and the item-9 flag), degrades to an honest items-unavailable state on any decrypt or shape problem, and the history rows gain an expandable breakdown rendering the official instrument texts the check-in flow already carries — nothing retranslated, no alarm styling on item 9 beyond what the house already does. Rides along: the guard inventories learn the branch's new routes (frozen counts moved with reasons), the reminder-engine test harnesses carry the ledger delegate, and the custom-metric restore toast keys sit in their right namespace in all six locales.
This commit is contained in:
1 parent
651d37b494
commit
90bad17cc8
23 files changed
+1036
-34
No files matched your search
@@ -107,8 +107,8 @@ describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock.calls[0][0]
|
||||
.where;
|
||||
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock
|
||||
.calls[0]![0]!.where!;
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* `GET /api/mental-health/assessments/[id]` — the per-administration detail
|
||||
* read that finally consumes `responsesEncrypted` (audit finding: the column
|
||||
* was write-only; its single reader was key rotation).
|
||||
*
|
||||
* Pins the two halves of the contract:
|
||||
* (a) the owner's detail read decrypts the stored blob and returns the
|
||||
* per-item answers + the unscored functional follow-up, and
|
||||
* (b) a decrypt failure (key gap / corruption / shape drift) degrades to
|
||||
* `items: null` + `itemsUnavailable: true` while the denormalised
|
||||
* total / band / flag still answer — never a 500 for the whole row
|
||||
* (the records-DTO fail-soft precedent).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
mentalHealthAssessment: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function makeReq(
|
||||
id: string,
|
||||
): [NextRequest, { params: Promise<{ id: string }> }] {
|
||||
return [
|
||||
new NextRequest(
|
||||
new URL(`http://localhost/api/mental-health/assessments/${id}`),
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
];
|
||||
}
|
||||
|
||||
/** A stored PHQ-9 administration as the select returns it. */
|
||||
function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "mha_1",
|
||||
userId: "user-1",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
version: "standard",
|
||||
totalScore: 10,
|
||||
severityBand: "moderate",
|
||||
item9Flagged: true,
|
||||
crisisShownAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
createdAt: new Date("2026-06-28T00:00:00.000Z"),
|
||||
responsesEncrypted: Buffer.from("ciphertext"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
describe("GET /api/mental-health/assessments/[id]", () => {
|
||||
it("returns the decrypted per-item answers + functional follow-up for the owner", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(decryptFromBytes).mockReturnValue(
|
||||
JSON.stringify({
|
||||
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
|
||||
functionalDifficulty: 1,
|
||||
schema: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: {
|
||||
id: string;
|
||||
totalScore: number;
|
||||
items: number[] | null;
|
||||
functionalDifficulty: number | null;
|
||||
itemsUnavailable: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.id).toBe("mha_1");
|
||||
expect(body.data.assessment.items).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 2]);
|
||||
expect(body.data.assessment.functionalDifficulty).toBe(1);
|
||||
expect(body.data.assessment.itemsUnavailable).toBe(false);
|
||||
// The denormalised score fields ride along unchanged.
|
||||
expect(body.data.assessment.totalScore).toBe(10);
|
||||
});
|
||||
|
||||
it("omits functionalDifficulty as null when the stored blob has none", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow({
|
||||
instrument: "GAD7",
|
||||
totalScore: 6,
|
||||
item9Flagged: false,
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(decryptFromBytes).mockReturnValue(
|
||||
JSON.stringify({ items: [1, 2, 0, 1, 1, 1, 0], schema: 1 }),
|
||||
);
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: {
|
||||
items: number[] | null;
|
||||
functionalDifficulty: number | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.items).toEqual([1, 2, 0, 1, 1, 1, 0]);
|
||||
expect(body.data.assessment.functionalDifficulty).toBeNull();
|
||||
});
|
||||
|
||||
it("degrades to items: null + itemsUnavailable on a decrypt failure — never a 500", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(decryptFromBytes).mockImplementation(() => {
|
||||
throw new Error("Unknown encryption key id: v9");
|
||||
});
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: {
|
||||
totalScore: number;
|
||||
items: number[] | null;
|
||||
itemsUnavailable: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.items).toBeNull();
|
||||
expect(body.data.assessment.itemsUnavailable).toBe(true);
|
||||
// The score half of the row still answers.
|
||||
expect(body.data.assessment.totalScore).toBe(10);
|
||||
});
|
||||
|
||||
it("degrades on a malformed or shape-drifted plaintext (wrong item count)", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
// 3 answers on a stored PHQ-9 row: pairing them to the 9 official item
|
||||
// texts would mislabel every answer, so the read refuses to guess.
|
||||
vi.mocked(decryptFromBytes).mockReturnValue(
|
||||
JSON.stringify({ items: [1, 2, 3], schema: 1 }),
|
||||
);
|
||||
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assessment: { items: number[] | null; itemsUnavailable: boolean };
|
||||
};
|
||||
};
|
||||
expect(body.data.assessment.items).toBeNull();
|
||||
expect(body.data.assessment.itemsUnavailable).toBe(true);
|
||||
});
|
||||
|
||||
it("404s another user's row without leaking its existence", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
storedRow({ userId: "user-2" }) as never,
|
||||
);
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(404);
|
||||
// The item content never left the guard.
|
||||
expect(decryptFromBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s a missing row", async () => {
|
||||
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await callGet(...makeReq("mha_missing"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the module 403 when the mental-health module is off", async () => {
|
||||
const { apiError } = await import("@/lib/api-response");
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError("disabled", 403, { errorCode: "module.disabled" }),
|
||||
});
|
||||
const res = await callGet(...makeReq("mha_1"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.mentalHealthAssessment.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* `GET /api/mental-health/assessments/[id]` — one screener administration WITH
|
||||
* its decrypted per-item answers.
|
||||
*
|
||||
* The list route beside this one denormalises total + band + item-9 flag for
|
||||
* cheap history reads and deliberately never touches `responsesEncrypted`.
|
||||
* Until this route existed nothing read the blob back at all (its only
|
||||
* consumer was key rotation), so the stored clinical detail — including the
|
||||
* PHQ-9 item-9 answer — was invisible to the person it belongs to. This is
|
||||
* the read side of that write.
|
||||
*
|
||||
* GATING — same declaration as the sibling list read (`read`, `mind`), on
|
||||
* purpose: the mind domain already serves decrypted free text at this level
|
||||
* (the mood entry's encrypted note on `GET /api/mood-entries/[id]`, the Coach
|
||||
* reminder notes), and the list read already publishes `item9Flagged` — the
|
||||
* fact that the self-harm item was answered non-zero. A delegate the owner
|
||||
* trusted with a mind-scope read grant therefore learns nothing here that is
|
||||
* categorically beyond what the domain already shows; splitting the per-item
|
||||
* values onto a stricter gate than the mood diary's prose would be an
|
||||
* inconsistency, not a protection. The module gate resolves against the
|
||||
* RECORD, so the surface stays unreachable wherever the owner keeps the
|
||||
* module off.
|
||||
*
|
||||
* DECRYPT FAILURES degrade honestly (the records-DTO fail-soft precedent):
|
||||
* `items: null` + `itemsUnavailable: true`, with the denormalised score
|
||||
* fields still answering — a key gap on one row never 500s the read. A
|
||||
* plaintext whose item count no longer matches the instrument degrades the
|
||||
* same way, because pairing answers to the wrong official item texts would be
|
||||
* worse than showing none.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
type InstrumentDefinition,
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
interface DecodedResponses {
|
||||
/** Per-item answers in presentation order, or null when unreadable. */
|
||||
items: number[] | null;
|
||||
/** The unscored PHQ-9 functional follow-up, when it was answered. */
|
||||
functionalDifficulty: number | null;
|
||||
itemsUnavailable: boolean;
|
||||
}
|
||||
|
||||
const UNAVAILABLE: DecodedResponses = {
|
||||
items: null,
|
||||
functionalDifficulty: null,
|
||||
itemsUnavailable: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypt + decode the stored `{ items, functionalDifficulty?, schema: 1 }`
|
||||
* blob, fail-soft. Every answer is re-checked against the instrument's own
|
||||
* ceiling before it is served: the write path validated exactly these bounds,
|
||||
* so anything outside them is corruption, not data.
|
||||
*/
|
||||
function decodeResponses(
|
||||
buf: Uint8Array,
|
||||
def: InstrumentDefinition,
|
||||
): DecodedResponses {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decryptFromBytes(buf));
|
||||
} catch (err) {
|
||||
// Undecryptable payload (key gap / corruption): fail soft but log it
|
||||
// (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank.
|
||||
getEvent()?.addWarning(
|
||||
`assessment responses decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return UNAVAILABLE;
|
||||
const { items, functionalDifficulty } = parsed as {
|
||||
items?: unknown;
|
||||
functionalDifficulty?: unknown;
|
||||
};
|
||||
const valid =
|
||||
Array.isArray(items) &&
|
||||
items.length === def.itemCount &&
|
||||
items.every(
|
||||
(v) => Number.isInteger(v) && (v as number) >= 0 && v <= def.itemMax,
|
||||
);
|
||||
if (!valid) {
|
||||
getEvent()?.addWarning(
|
||||
"assessment responses shape drifted from the instrument definition",
|
||||
);
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
return {
|
||||
items: items as number[],
|
||||
functionalDifficulty:
|
||||
Number.isInteger(functionalDifficulty) &&
|
||||
(functionalDifficulty as number) >= 0 &&
|
||||
(functionalDifficulty as number) <= 3
|
||||
? (functionalDifficulty as number)
|
||||
: null,
|
||||
itemsUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "mind");
|
||||
|
||||
// Opt-in module (default OFF) — the same record-resolved gate as the list.
|
||||
const gate = await requireModuleEnabled(user.id, "mentalHealth");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
|
||||
// a soft-deleted row 404s like it does on the list.
|
||||
const row = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
instrument: true,
|
||||
locale: true,
|
||||
version: true,
|
||||
totalScore: true,
|
||||
severityBand: true,
|
||||
item9Flagged: true,
|
||||
crisisShownAt: true,
|
||||
takenAt: true,
|
||||
createdAt: true,
|
||||
responsesEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Assessment not found", 404);
|
||||
}
|
||||
|
||||
const def = INSTRUMENTS[row.instrument as InstrumentId];
|
||||
const decoded = decodeResponses(row.responsesEncrypted, def);
|
||||
|
||||
annotate({
|
||||
action: { name: "mental-health.detail" },
|
||||
// The instrument + degrade flag only — item answers are NEVER logged.
|
||||
meta: {
|
||||
instrument: row.instrument,
|
||||
items_unavailable: decoded.itemsUnavailable,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
assessment: {
|
||||
id: row.id,
|
||||
instrument: row.instrument,
|
||||
locale: row.locale,
|
||||
version: row.version,
|
||||
totalScore: row.totalScore,
|
||||
severityBand: row.severityBand,
|
||||
item9Flagged: row.item9Flagged,
|
||||
crisisShownAt: row.crisisShownAt?.toISOString() ?? null,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
items: decoded.items,
|
||||
functionalDifficulty: decoded.functionalDifficulty,
|
||||
itemsUnavailable: decoded.itemsUnavailable,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -13,7 +13,8 @@
|
||||
* interpretation, no AI ingestion of item content, no third-party alerting.
|
||||
*
|
||||
* GET lists the caller's assessments (totals + bands + flags only — item-level
|
||||
* answers are excluded from this surface by construction).
|
||||
* answers are excluded from this surface by construction; the by-id sibling
|
||||
* route is the one read that decrypts them back).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
Reference in new issue
Block a user