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:
Marc-André Bombeck committed 2026-08-14 09:46:48 +02:00
1 parent 651d37b494
commit 90bad17cc8
23 files changed
+1036 -34

No files matched your search

+9 -1
View File
@@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "mind",
why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
},
"app/api/mental-health/assessments/[id]/route.ts": {
domain: "mind",
why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.",
},
"app/api/anamnesis/facts/route.ts": {
domain: "profile",
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
@@ -1937,8 +1941,12 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze
* and the completion-ledger history read on the record list, plus skip and
* snooze on the manage literal. 224 -> 229.
*
* The screener detail read adds one: the per-administration read that
* decrypts the stored item answers, on the record list at the same level as
* the screener history beside it. 229 -> 230.
*/
const FROZEN_ENTRY_COUNT = 229;
const FROZEN_ENTRY_COUNT = 230;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 1,
},
"src/components/custom-metrics/custom-metric-history-list.tsx": {
"toast.success": 2,
// v1.37.20 (A3-11) — the third success is the restore confirmation
// behind the delete toast's Undo action.
"toast.success": 3,
},
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
// v1.36.x — two call sites, one decision: the intake toast routes through
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -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";
@@ -28,6 +28,8 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
update: { mutate: vi.fn(), isPending: false },
remove: { mutate: vi.fn(), isPending: false },
satisfy: { mutate: vi.fn(), isPending: false },
skip: { mutate: vi.fn(), isPending: false },
snooze: { mutate: vi.fn(), isPending: false },
}),
}));
@@ -0,0 +1,118 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
/**
* The expandable per-item answer breakdown (the read side of
* `responsesEncrypted`). SSR-only convention (`renderToStaticMarkup`) — the
* detail query is seeded into the cache, the fetch never runs.
*
* Pins: the official item texts render from the SAME `mentalHealth.items.*`
* keys the check-in wizard uses (validated instrument copy, never re-worded),
* each answer shows its option label + value, PHQ-9 item 9 gets NO alarm
* styling, and the server's decrypt-failure degrade renders as the honest
* unavailable line rather than a blank.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiPost: () => new Promise(() => {}),
}));
import { AssessmentItemBreakdown } from "../assessment-item-breakdown";
import type { AssessmentDetail } from "../types";
const mh = en.mentalHealth;
function detailRow(overrides: Partial<AssessmentDetail>): AssessmentDetail {
return {
id: "mha_1",
instrument: "PHQ9",
locale: "en",
totalScore: 10,
severityBand: "moderate",
item9Flagged: true,
crisisShownAt: null,
takenAt: "2026-06-28T00:00:00.000Z",
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
functionalDifficulty: null,
itemsUnavailable: false,
...overrides,
};
}
function render(detail: AssessmentDetail): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.mentalHealthAssessmentDetail(detail.id), {
assessment: detail,
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<AssessmentItemBreakdown
assessmentId={detail.id}
instrument={detail.instrument}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
describe("AssessmentItemBreakdown", () => {
it("renders every official PHQ-9 item text with its answer label + value", () => {
const html = render(detailRow({}));
// All nine validated item texts, from the wizard's own keys.
for (let n = 1; n <= 9; n++) {
expect(html).toContain(
mh.items.phq9[String(n) as keyof typeof mh.items.phq9],
);
}
// Answer labels are the shared PHQ/GAD anchors.
expect(html).toContain(mh.options["1"]);
expect(html).toContain(mh.options["2"]);
});
it("gives PHQ-9 item 9 no alarm styling — it renders like every other item", () => {
const html = render(detailRow({}));
expect(html).toContain(mh.items.phq9["9"]);
expect(html).not.toContain("text-destructive");
});
it("renders the unscored functional follow-up when it was answered", () => {
const html = render(detailRow({ functionalDifficulty: 2 }));
expect(html).toContain(mh.functionalTitle);
expect(html).toContain(mh.functional["2"]);
});
it("omits the functional follow-up row when it was not answered", () => {
const html = render(detailRow({ functionalDifficulty: null }));
expect(html).not.toContain(mh.functionalTitle);
});
it("paints the WHO-5 recall stem once above its run of items", () => {
const html = render(
detailRow({
instrument: "WHO5",
items: [5, 4, 3, 2, 1],
item9Flagged: false,
}),
);
// The shared stem appears exactly once, not per item.
const stem = mh.stems.who5.period;
expect(html.split(stem).length - 1).toBe(1);
expect(html).toContain(mh.items.who5["1"]);
expect(html).toContain(mh.who5Options["5"]);
});
it("renders the honest unavailable line on the server's decrypt degrade", () => {
const html = render(detailRow({ items: null, itemsUnavailable: true }));
expect(html).toContain(mh.history.answersUnavailable);
expect(html).not.toContain(mh.items.phq9["1"]);
});
});
@@ -15,6 +15,11 @@
* the row's stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or
* reveals the item-9 answer, and surfacing it triggers no third-party alert.
* No new data leaves the safety boundary.
*
* Each row also carries an opt-in answer breakdown: expanding it fetches the
* per-administration detail read, where the server decrypts the stored
* per-item answers (`AssessmentItemBreakdown`). Item 9 renders there like any
* other item — the flagged marker above stays the one crisis affordance.
*/
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
@@ -28,8 +33,10 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
import { Activity } from "lucide-react";
import { Activity, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { AssessmentItemBreakdown } from "./assessment-item-breakdown";
import { CrisisCard } from "./crisis-card";
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
@@ -80,6 +87,11 @@ export function AssessmentHistory({
const { date: formatDate } = useFormatters();
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
// Which row's per-item answer breakdown is expanded (single-open, like the
// crisis card — the detail fetch runs only when someone asks).
const [openBreakdownRowId, setOpenBreakdownRowId] = useState<string | null>(
null,
);
const forInstrument = useMemo(
() =>
@@ -116,6 +128,7 @@ export function AssessmentHistory({
{forInstrument.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
const breakdownOpen = openBreakdownRowId === row.id;
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
@@ -146,9 +159,37 @@ export function AssessmentHistory({
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0.5"
aria-expanded={breakdownOpen}
aria-label={t("mentalHealth.history.answersToggle")}
onClick={() =>
setOpenBreakdownRowId(breakdownOpen ? null : row.id)
}
data-slot="history-answers-toggle"
>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 transition-transform",
breakdownOpen && "rotate-180",
)}
/>
</Button>
</div>
</div>
{flagged && open && <CrisisCard crisis={crisisFromRow(row)} />}
{breakdownOpen && (
<div className="border-border/70 border-l pl-3">
<AssessmentItemBreakdown
assessmentId={row.id}
instrument={instrument}
/>
</div>
)}
</li>
);
})}
@@ -0,0 +1,136 @@
"use client";
/**
* Per-item answer breakdown for one recorded administration, expanded from a
* history row. Fetches the detail read (`GET …/assessments/[id]`), which is
* where the server decrypts `responsesEncrypted` — the answers never ride the
* list payload.
*
* Grammar: the detail surface's label/value rows — official item text muted
* (validated instrument copy via the SAME `mentalHealth.items.*` keys the
* check-in wizard renders; never re-worded here), the chosen answer label +
* its value in foreground. The WHO-5 / SCI recall stems paint once above each
* run of items, as on the source forms. PHQ-9 item 9 renders like every other
* item — no alarm colour; the row's existing "support shown" marker is the
* crisis affordance and is not duplicated here.
*/
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
INSTRUMENTS,
optionLabelKey,
stemKey,
} from "@/lib/mental-health/instruments";
import type { AssessmentDetail, InstrumentId } from "./types";
export function AssessmentItemBreakdown({
assessmentId,
instrument,
}: {
assessmentId: string;
instrument: InstrumentId;
}) {
const { t } = useTranslations();
const def = INSTRUMENTS[instrument];
const { data, isPending, isError } = useQuery({
queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId),
queryFn: () =>
apiGet<{ assessment: AssessmentDetail }>(
`/api/mental-health/assessments/${assessmentId}`,
),
});
if (isPending) {
return (
<div
className="flex flex-col gap-2"
data-slot="assessment-item-breakdown"
aria-busy="true"
>
{Array.from({ length: def.itemCount }, (_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
);
}
if (isError || !data) {
return (
<p
className="text-destructive text-sm"
role="alert"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.error")}
</p>
);
}
const detail = data.assessment;
if (detail.items === null) {
// The server's honest degrade: the blob exists but cannot be decrypted
// (key gap / corruption). The score above this breakdown still answers.
return (
<p
className="text-muted-foreground text-sm"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.history.answersUnavailable")}
</p>
);
}
return (
<div
className="flex flex-col gap-2 text-sm"
data-slot="assessment-item-breakdown"
>
{detail.items.map((value, index) => {
// Stem captions group runs of items exactly as the wizard presents
// them: paint the stem only where it differs from the previous item's.
const stem = stemKey(instrument, index);
const caption =
stem !== (index > 0 ? stemKey(instrument, index - 1) : null)
? stem
: null;
return (
<div key={index} className="flex flex-col gap-1">
{caption && (
<p className="text-muted-foreground pt-1 text-xs">
{t(`mentalHealth.stems.${caption}`)}
</p>
)}
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground min-w-0 flex-1">
{t(`mentalHealth.items.${def.i18nKey}.${index + 1}`)}
</span>
<span className="text-foreground shrink-0 text-right">
{t(optionLabelKey(instrument, index, value))}
{" · "}
<span className="tabular-nums">{value}</span>
</span>
</div>
</div>
);
})}
{detail.functionalDifficulty !== null && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground min-w-0 flex-1">
{t("mentalHealth.functionalTitle")}
</span>
{/* Unscored follow-up: the label alone, no value — it never counts
into the total and a number would imply it does. */}
<span className="text-foreground shrink-0 text-right">
{t(`mentalHealth.functional.${detail.functionalDifficulty}`)}
</span>
</div>
)}
</div>
);
}
+15 -2
View File
@@ -4,8 +4,9 @@
* The 387-line monolith split into a folder of focused components; these are
* the seams they plug into. The wire shapes mirror the assessment route's
* `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`)
* — totals + bands + flags only, NEVER item content (the item answers stay
* encrypted at rest and never ride this surface).
* — totals + bands + flags only on the list. The per-item answers ride ONLY
* the per-administration detail read (`…/assessments/[id]`), which decrypts
* them for the history's expandable answer breakdown.
*/
import type { InstrumentId } from "@/lib/mental-health/instruments";
@@ -25,6 +26,18 @@ export interface AssessmentRow {
takenAt: string;
}
/**
* One administration with its decrypted answers (`GET …/assessments/[id]`).
* `items: null` + `itemsUnavailable` is the server's honest degrade for a row
* whose blob cannot be decrypted — the score fields still answer.
*/
export interface AssessmentDetail extends AssessmentRow {
items: number[] | null;
/** The unscored PHQ-9 functional follow-up, when it was answered. */
functionalDifficulty: number | null;
itemsUnavailable: boolean;
}
export interface CrisisSet {
emergencyNumber: string;
resources: { id: string; contacts: string[] }[];
@@ -124,9 +124,11 @@ describe("evaluateMeasurementReminderDue — window boundary", () => {
interface FakeReminder {
id: string;
userId: string;
/** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */
origin?: string;
measurementType: string | null;
lastSkippedAt: Date | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
@@ -195,6 +197,14 @@ function makePrisma(opts: {
labResult: {
findFirst: vi.fn(async () => opts.labMatch ?? null),
},
// v1.37.20 (#223 / iOS #68) — the satisfy primitive appends a ledger
// row per applied satisfy.
measurementReminderEvent: {
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
id: "evt",
...data,
})),
},
};
return { prisma, updates };
}
@@ -202,6 +212,7 @@ function makePrisma(opts: {
function reminder(overrides: Partial<FakeReminder>): FakeReminder {
return {
id: "r1",
userId: "u1",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
rrule: null,
@@ -210,6 +221,7 @@ function reminder(overrides: Partial<FakeReminder>): FakeReminder {
location: null,
nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL
lastSatisfiedAt: null,
lastSkippedAt: null,
enabled: true,
createdAt: new Date("2026-06-01T00:00:00Z"),
user: {
@@ -96,6 +96,14 @@ function makePrisma(opts: {
},
),
},
// v1.37.20 (#223 / iOS #68) — the primitive appends a ledger row per
// applied satisfy; the harness records them so tests can assert one.
measurementReminderEvent: {
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
id: "evt",
...data,
})),
},
measurement: { findFirst: vi.fn(async () => opts.measurement ?? null) },
labResult: { findFirst: vi.fn(async () => opts.lab ?? null) },
};
@@ -105,12 +113,15 @@ function makePrisma(opts: {
function reminderRow(overrides: Record<string, unknown> = {}) {
return {
id: "r1",
userId: "u1",
measurementType: "WEIGHT",
intervalDays: 7,
rrule: null,
anchorDate: null,
notifyHour: 9,
nextDueAt: null,
lastSatisfiedAt: null,
lastSkippedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
...overrides,
};
+47 -6
View File
@@ -4,11 +4,12 @@
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
* wire contract stays single-source. The response deliberately excludes the
* raw item answers — only the server-authoritative total / band / safety flag
* are exposed. The iOS client mirrors this DTO and MUST surface its own
* crisis-resource card on a positive item-9 flag (never rely on server text
* alone).
* wire contract stays single-source. The list / create responses deliberately
* exclude the raw item answers — only the server-authoritative total / band /
* safety flag are exposed there; the per-administration detail read is the one
* surface that returns the decrypted answers. The iOS client mirrors these
* DTOs and MUST surface its own crisis-resource card on a positive item-9
* flag (never rely on server text alone).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -48,7 +49,19 @@ const assessmentRow = z
.meta({
id: "MentalHealthAssessment",
description:
"One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent.",
"One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted.",
});
const assessmentDetail = assessmentRow
.extend({
items: z.array(z.number().int()).nullable(),
functionalDifficulty: z.number().int().nullable(),
itemsUnavailable: z.boolean(),
})
.meta({
id: "MentalHealthAssessmentDetail",
description:
"One administration with its decrypted per-item answers, in the order the instrument presents its items (PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is null, `itemsUnavailable` is true, and the denormalised score fields still answer.",
});
const crisisResource = z
@@ -81,6 +94,10 @@ const listResponse = z
.object({ assessments: z.array(assessmentRow) })
.meta({ id: "ListMentalHealthAssessmentsResponse" });
const detailResponse = z
.object({ assessment: assessmentDetail })
.meta({ id: "GetMentalHealthAssessmentResponse" });
export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/mental-health/assessments": {
get: {
@@ -132,4 +149,28 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/mental-health/assessments/{id}": {
get: {
tags: ["Mental health"],
summary: "Read one administration with its decrypted answers",
description:
"Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band / flag still answer. A soft-deleted or foreign row answers 404.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
"200": {
description: "The administration with its answer breakdown.",
content: {
"application/json": {
schema: dataEnvelope(
detailResponse,
"GetMentalHealthAssessmentEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
+3
View File
@@ -6,4 +6,7 @@ export const mentalHealthKeys = {
/** The caller's screener history, optionally filtered by instrument. */
mentalHealthAssessments: (instrument?: string) =>
["mental-health-assessments", instrument ?? null] as const,
/** One administration with its decrypted per-item answers. */
mentalHealthAssessmentDetail: (id: string) =>
["mental-health-assessment-detail", id] as const,
};