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

@@ -160,6 +160,52 @@ describe("runReminderSatisfyForUser", () => {
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
});
it("satisfies a screening reminder from the server-written score row (v1.27.6)", async () => {
// A completed PHQ-9 check-in writes a COMPUTED PHQ9_SCORE measurement;
// the screening reminder resolves from that row like any typed reminder.
const measuredAt = new Date("2026-07-01T10:00:00Z");
const { prisma, updates } = makePrisma({
reminders: [
reminderRow({ measurementType: "PHQ9_SCORE", intervalDays: 28 }),
],
measurement: { measuredAt },
});
const isModuleEnabled = vi.fn(async () => true);
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date("2026-07-01T12:00:00Z"),
{ isModuleEnabled },
);
expect(summary.satisfied).toBe(1);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
});
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
const { prisma, updates } = makePrisma({
reminders: [
reminderRow({ measurementType: "GAD7_SCORE", intervalDays: 28 }),
],
measurement: { measuredAt: new Date("2026-07-01T10:00:00Z") },
});
const isModuleEnabled = vi.fn(async () => false);
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date(),
{ isModuleEnabled },
);
expect(summary.skippedModuleDisabled).toBe(1);
expect(summary.satisfied).toBe(0);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
expect(updates).toHaveLength(0);
});
it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => {
const { prisma, updates } = makePrisma({
reminders: [
+8 -3
View File
@@ -51,9 +51,11 @@ import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
* pressure, pulse, body composition) or has no module of its own. A
* `null` reminder type (free-text Vorsorge entry) also yields `null`.
*
* Only the two secondary-domain types carry a gate: glucose and sleep.
* Everything else is core and dispatches regardless — disabling a module
* must never silence a core-vital reminder.
* Only the secondary-domain types carry a gate: glucose, sleep, and the
* mental-wellbeing screenings (v1.27.6 — the PHQ-9 / GAD-7 module is
* opt-in, so a screening reminder must fall silent the moment the module
* is off). Everything else is core and dispatches regardless — disabling
* a module must never silence a core-vital reminder.
*/
function moduleForMeasurementType(
type: MeasurementType | null,
@@ -63,6 +65,9 @@ function moduleForMeasurementType(
return "glucose";
case "SLEEP_DURATION":
return "sleep";
case "PHQ9_SCORE":
case "GAD7_SCORE":
return "mentalHealth";
default:
return null;
}
+1 -1
View File
@@ -108,7 +108,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Mental health"],
summary: "Record a completed PHQ-9 / GAD-7 screener",
description:
"Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
requestBody: {
content: {
"application/json": { schema: createAssessmentSchema },
@@ -14,7 +14,7 @@ import {
describe("measurementReminderTypeEnum (V3 — completeness)", () => {
const allowed = measurementReminderTypeEnum.options;
it("exposes the full active set: vitals + body composition", () => {
it("exposes the full active set: vitals + body composition + screenings", () => {
expect(new Set(allowed)).toEqual(
new Set([
// Vitals
@@ -34,10 +34,22 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
"BODY_MASS_INDEX",
// v1.27.6 — plannable mental-wellbeing screenings
"PHQ9_SCORE",
"GAD7_SCORE",
]),
);
});
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
const parsed = createMeasurementReminderSchema.safeParse({
label: "PHQ-9 Check-in",
measurementType: "PHQ9_SCORE",
intervalDays: 28,
});
expect(parsed.success).toBe(true);
});
it("accepts a newly-added body-composition type on create", () => {
const parsed = createMeasurementReminderSchema.safeParse({
label: "Körperzusammensetzung messen",
@@ -470,6 +470,24 @@ describe("measurement validation", () => {
}
});
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
// v1.27.6 — the mental-wellbeing screener projects each completed
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
// so a client can never mint a server-owned score trend point.
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
const parsed = createMeasurementSchema.safeParse({
type,
value: 5,
measuredAt: "2026-06-02T12:00:00Z",
source: "COMPUTED",
});
expect(parsed.success, `expected COMPUTED ${type} to be rejected`).toBe(
false,
);
}
});
it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => {
for (const source of ["MANUAL", "APPLE_HEALTH"]) {
const parsed = createMeasurementSchema.safeParse({
+28 -1
View File
@@ -56,17 +56,44 @@ export const measurementReminderTypeEnum = z
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
"BODY_MASS_INDEX",
// v1.27.6 — the mental-wellbeing screeners become plannable Vorsorge
// items. Not a passive wearable score: a screening is an active "go and
// do it" action exactly like stepping on the scale. Auto-resolves from
// the server-owned COMPUTED PHQ9_SCORE / GAD7_SCORE row the assessment
// route writes on completion. Gated on the opt-in mentalHealth module in
// the dispatcher (see `moduleForMeasurementType`).
"PHQ9_SCORE",
"GAD7_SCORE",
])
.meta({
id: "MeasurementReminderType",
description:
"Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
});
export type MeasurementReminderType = z.infer<
typeof measurementReminderTypeEnum
>;
/**
* v1.27.6 — the reminder types that are SCREENINGS, not numeric readings.
* Every surface with a per-reminder primary action branches on this: a
* screening routes to the check-in page (`/mental-wellbeing`) instead of
* opening the numeric MeasurementForm — a score is never typed in, it
* falls out of a completed test (which then auto-satisfies the reminder).
*/
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
"PHQ9_SCORE",
"GAD7_SCORE",
]);
/** Whether a reminder's `measurementType` targets a screening check-in. */
export function isScreeningReminderType(
type: string | null | undefined,
): boolean {
return type != null && SCREENING_REMINDER_TYPES.has(type);
}
/**
* RFC-5545 RRULE guard. The recurrence engine parses it; here we only
* bound the length and require the `FREQ=` token so a typo doesn't reach