mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user