Merge feat/w-d-mental-wellbeing into release/v1.27.6

The Vorsorge card resolves both rules at once: the action button keeps
its constant look in every state (v1.27.5), and a screening reminder's
action reads 'start the check-in' instead of the numeric measure
wording (this wave).
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 15:27:44 +02:00
commit f1e5df52ab
28 files changed
+657 -886

No files matched your search

@@ -37,6 +37,12 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: vi.fn(() => Buffer.from("ciphertext")),
}));
// v1.27.6 — a completed check-in kicks the eventful Vorsorge satisfy worker
// so a plannable screening reminder resolves immediately.
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +61,7 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../route";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -183,13 +190,27 @@ describe("POST /api/mental-health/assessments", () => {
expect(createArg.data.item9Flagged).toBe(false);
// Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment.
// Server-owned: the row carries the COMPUTED source (the RECOVERY_SCORE
// precedent) — a client can never attribute COMPUTED on a write surface,
// so the trend cannot be forged through the measurement POST.
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { type: string; value: number; unit: string; externalId: string };
data: {
type: string;
value: number;
unit: string;
source: string;
externalId: string;
};
};
expect(measArg.data.type).toBe("GAD7_SCORE");
expect(measArg.data.value).toBe(6);
expect(measArg.data.unit).toBe("score");
expect(measArg.data.source).toBe("COMPUTED");
expect(measArg.data.externalId).toBe("assessment:mha_2");
// The eventful Vorsorge satisfy worker is kicked so a plannable
// screening reminder (PHQ9_SCORE / GAD7_SCORE) auto-resolves at once.
expect(enqueueReminderSatisfy).toHaveBeenCalledWith("user-1");
});
it("dedups a repeat externalId: returns the existing administration, writes nothing new", async () => {
@@ -229,6 +250,8 @@ describe("POST /api/mental-health/assessments", () => {
);
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
// No new trend point → nothing to satisfy either.
expect(enqueueReminderSatisfy).not.toHaveBeenCalled();
});
it("persists client provenance + externalId on a first write", async () => {
+13 -4
View File
@@ -28,6 +28,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
@@ -308,9 +309,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// chart/rollup infra reads the trend without ever touching item content. The
// externalId anchors the trend point to this administration; duplicate trend
// points are structurally impossible because a replayed externalId returns
// the existing assessment above before reaching this create. `MeasurementSource`
// carries no WEB/IOS member (a screener is questionnaire input, not a device
// sample), so the row stays MANUAL — the WEB/IOS provenance + client
// the existing assessment above before reaching this create. The row is
// `COMPUTED` — a server-derived projection of the encrypted answers, exactly
// like RECOVERY_SCORE: clients cannot attribute the COMPUTED source on any
// write surface, so a forged PHQ9_SCORE / GAD7_SCORE trend point can never
// enter through the measurement POST. The WEB/IOS provenance + client
// externalId live on the assessment this row links to.
await prisma.measurement.create({
data: {
@@ -318,13 +321,19 @@ async function postAssessment(request: NextRequest): Promise<Response> {
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
value: total,
unit: "score",
source: "MANUAL" as MeasurementSource,
source: "COMPUTED" as MeasurementSource,
measuredAt: when,
notes: null,
externalId: `assessment:${assessment.id}`,
},
});
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
// a check-in resolves the reminder immediately (the ingest-route
// precedent); the 15-min cron stays the idempotent safety-net.
await enqueueReminderSatisfy(user.id);
await auditLog("mental-health.create", {
userId: user.id,
// The score band is non-sensitive; item answers are NEVER logged.