feat(vorsorge): one satisfaction primitive + lab auto-resolve

Extract a single satisfyReminder() that every path uses to mark a
reminder's cadence fulfilled: stamp lastSatisfiedAt (forward-only) and
recompute the server-authoritative nextDueAt. The cron auto-resolve and
the manual satisfy route now route through it instead of duplicating the
reschedule.

Add findSatisfyingEvent(): a typed reminder resolves from a matching
Measurement, a free-text reminder from any LabResult. This closes the
Lab<->Vorsorge loop — an annual-blood-panel reminder is now satisfied
when a lab result lands, not only on a manual check-off.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 20:58:46 +02:00
1 parent bee7295b19
commit f92247760c
6 files changed
+542 -67

No files matched your search

@@ -13,10 +13,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
@@ -42,19 +39,14 @@ export const POST = apiHandler(
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const scheduleInput: ReminderScheduleInput = {
intervalDays: existing.intervalDays,
rrule: existing.rrule,
anchorDate: existing.anchorDate,
notifyHour: existing.notifyHour,
lastSatisfiedAt: now,
createdAt: existing.createdAt,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// The ONE shared satisfaction primitive — same code the cron
// auto-resolve and the eventful worker use. A manual "Erledigt" is
// always strictly after any prior satisfy, so the forward-only guard
// advances it.
await satisfyReminder(prisma, existing, timezone, now);
const updated = await prisma.measurementReminder.update({
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
data: { lastSatisfiedAt: now, nextDueAt },
});
await auditLog("measurementReminder.satisfy", {
+122 -52
View File
@@ -42,6 +42,8 @@ import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
/**
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
@@ -137,17 +139,6 @@ export function buildMeasurementReminderPayload(
return { title: t("measurementReminders.pushTitle"), body };
}
/**
* The canonical sentinel measurement type the cron queries for
* auto-resolve. BP is two rows (SYS + DIA); SYS is the "a BP was measured"
* anchor (matching both would double-count).
*/
function autoResolveQueryType(
reminderType: MeasurementType | null,
): MeasurementType | null {
return reminderType;
}
/**
* Run one Vorsorge-reminder cron tick. Iterates every live, enabled
* reminder, auto-resolves the typed ones against incoming readings,
@@ -216,47 +207,27 @@ export async function runMeasurementReminderTick(
try {
const timezone = reminder.user.timezone || "Europe/Berlin";
// ── Auto-resolve from an incoming measurement ──────────────────
// Cheap guard, in the cron (not on the ingest path). Only typed
// reminders auto-resolve; free-text ones wait for a manual satisfy.
const queryType = autoResolveQueryType(reminder.measurementType);
if (queryType !== null) {
// Match readings logged AFTER the last satisfy (or, never
// satisfied, after the anchor). A reading inside the current due
// cycle means the user already measured — re-anchor + skip the
// nudge. `deletedAt: null` so a tombstoned reading never counts.
const sinceFloor =
reminder.lastSatisfiedAt ??
reminder.anchorDate ??
reminder.createdAt;
const match = await prisma.measurement.findFirst({
where: {
userId: reminder.user.id,
type: queryType,
deletedAt: null,
measuredAt: { gt: sinceFloor },
},
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
if (match) {
const scheduleInput: ReminderScheduleInput = {
intervalDays: reminder.intervalDays,
rrule: reminder.rrule,
anchorDate: reminder.anchorDate,
notifyHour: reminder.notifyHour,
lastSatisfiedAt: match.measuredAt,
createdAt: reminder.createdAt,
};
const nextDueAt = computeReminderNextDueAt(
scheduleInput,
timezone,
match.measuredAt,
);
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: { lastSatisfiedAt: match.measuredAt, nextDueAt },
});
// ── Auto-resolve from an incoming event ────────────────────────
// Cheap safety-net poll, in the cron (the eventful `reminder-satisfy`
// worker is the fast path). A typed reminder resolves from a matching
// Measurement; a free-text reminder resolves from a LabResult (the
// Lab↔Vorsorge link). Both go through the shared `findSatisfyingEvent`
// + `satisfyReminder` primitives so the cron and the worker can never
// diverge. A reading inside the current due cycle means the user
// already measured — re-anchor + skip the nudge.
const satisfiedAt = await findSatisfyingEvent(
prisma,
reminder.user.id,
reminder,
);
if (satisfiedAt) {
const result = await satisfyReminder(
prisma,
reminder,
timezone,
satisfiedAt,
);
if (result.satisfied) {
summary.autoResolved += 1;
continue;
}
@@ -360,6 +331,105 @@ export async function runMeasurementReminderTick(
return summary;
}
export interface ReminderSatisfySummary {
candidatesScanned: number;
satisfied: number;
skippedModuleDisabled: number;
skippedNoEvent: number;
failed: number;
}
/**
* v1.18.1 — eventful satisfaction for one user. Called by the
* `reminder-satisfy` worker right after a measurement / lab write lands
* (from any ingest path: manual create, batch, or a device sync). The
* 15-min cron remains the idempotent safety-net behind this.
*
* Loads the user's live, enabled reminders that could auto-resolve (typed
* → a Measurement; free-text → a LabResult), and for each runs the SAME
* `findSatisfyingEvent` + `satisfyReminder` primitives the cron uses, so
* "I just weighed myself, stop reminding me" resolves immediately instead
* of waiting up to 15 minutes.
*
* Respects the module toggle: a reminder whose measurement type belongs to
* a disabled module produces no engine activity (the same gate the cron
* dispatch applies). Forward-only `satisfyReminder` makes a duplicate
* enqueue + the trailing cron poll converge without double-stamping.
*/
export async function runReminderSatisfyForUser(
prisma: PrismaClient,
userId: string,
now: Date,
options: { isModuleEnabled?: typeof isModuleEnabled } = {},
): Promise<ReminderSatisfySummary> {
void now;
const moduleGate = options.isModuleEnabled ?? isModuleEnabled;
const summary: ReminderSatisfySummary = {
candidatesScanned: 0,
satisfied: 0,
skippedModuleDisabled: 0,
skippedNoEvent: 0,
failed: 0,
};
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const timezone = user?.timezone || "Europe/Berlin";
const reminders = await prisma.measurementReminder.findMany({
where: { userId, deletedAt: null, enabled: true },
});
for (const reminder of reminders) {
summary.candidatesScanned += 1;
try {
// Module toggle — no engine activity for a reminder whose type
// belongs to a disabled module. Core-vital + free-text reminders map
// to no module and are never gated.
const gatedModule = moduleForMeasurementType(reminder.measurementType);
if (
gatedModule !== null &&
!(await moduleGate(userId, gatedModule))
) {
summary.skippedModuleDisabled += 1;
continue;
}
const satisfiedAt = await findSatisfyingEvent(prisma, userId, reminder);
if (!satisfiedAt) {
summary.skippedNoEvent += 1;
continue;
}
const result = await satisfyReminder(
prisma,
reminder,
timezone,
satisfiedAt,
);
if (result.satisfied) {
summary.satisfied += 1;
getEvent()?.addMeta("measurement_reminder.satisfied_eventful", reminder.id);
} else {
// Forward-only no-op — the cron or a prior enqueue already
// advanced this row past the event.
summary.skippedNoEvent += 1;
}
} catch (err: unknown) {
summary.failed += 1;
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(
`reminder-satisfy per-reminder resolve failed for ${reminder.id}: ${message}`,
);
}
}
return summary;
}
/**
* Advance `nextDueAt` to the next occurrence strictly after `now`. Used
* after a successful dispatch (and after a client-managed skip) as the
@@ -0,0 +1,112 @@
/**
* v1.18.1 — `findSatisfyingEvent`: the shared "did a satisfying event
* land?" matcher. A typed reminder resolves from a Measurement; a
* free-text reminder resolves from a LabResult (the Lab↔Vorsorge link, D2).
*/
import { describe, expect, it, vi } from "vitest";
import { findSatisfyingEvent, satisfactionFloor } from "../resolve";
type FindFirstArgs = { where: Record<string, unknown> };
function makePrisma(opts: {
measurement?: { measuredAt: Date } | null;
lab?: { takenAt: Date } | null;
}) {
return {
measurement: {
findFirst: vi.fn((args: FindFirstArgs) => {
void args;
return Promise.resolve(opts.measurement ?? null);
}),
},
labResult: {
findFirst: vi.fn((args: FindFirstArgs) => {
void args;
return Promise.resolve(opts.lab ?? null);
}),
},
};
}
const base = {
measurementType: null,
anchorDate: null,
lastSatisfiedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
} as const;
describe("satisfactionFloor", () => {
it("prefers lastSatisfiedAt, then anchorDate, then createdAt", () => {
const last = new Date("2026-06-10T00:00:00Z");
const anchor = new Date("2026-06-05T00:00:00Z");
expect(
satisfactionFloor({ ...base, lastSatisfiedAt: last, anchorDate: anchor }),
).toEqual(last);
expect(satisfactionFloor({ ...base, anchorDate: anchor })).toEqual(anchor);
expect(satisfactionFloor(base)).toEqual(base.createdAt);
});
});
describe("findSatisfyingEvent", () => {
it("resolves a typed reminder from a matching Measurement, not a lab", async () => {
const measuredAt = new Date("2026-06-14T18:00:00Z");
const prisma = makePrisma({ measurement: { measuredAt }, lab: { takenAt: new Date() } });
const at = await findSatisfyingEvent(prisma as never, "u1", {
...base,
measurementType: "WEIGHT",
});
expect(at).toEqual(measuredAt);
expect(prisma.measurement.findFirst).toHaveBeenCalledTimes(1);
expect(prisma.labResult.findFirst).not.toHaveBeenCalled();
// Queries the right type, non-deleted, after the floor.
const where = prisma.measurement.findFirst.mock.calls[0]![0].where as {
type: string;
deletedAt: null;
measuredAt: { gt: Date };
};
expect(where.type).toBe("WEIGHT");
expect(where.deletedAt).toBeNull();
expect(where.measuredAt.gt).toEqual(base.createdAt);
});
it("returns null for a typed reminder with no matching reading", async () => {
const prisma = makePrisma({ measurement: null });
const at = await findSatisfyingEvent(prisma as never, "u1", {
...base,
measurementType: "BLOOD_PRESSURE_SYS",
});
expect(at).toBeNull();
});
it("resolves a free-text reminder from any LabResult (D2)", async () => {
const takenAt = new Date("2026-06-15T09:00:00Z");
const prisma = makePrisma({ lab: { takenAt } });
const at = await findSatisfyingEvent(prisma as never, "u1", {
...base,
measurementType: null,
});
expect(at).toEqual(takenAt);
expect(prisma.labResult.findFirst).toHaveBeenCalledTimes(1);
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
const where = prisma.labResult.findFirst.mock.calls[0]![0].where as {
deletedAt: null;
takenAt: { gt: Date };
};
expect(where.deletedAt).toBeNull();
expect(where.takenAt.gt).toEqual(base.createdAt);
});
it("returns null for a free-text reminder with no lab landed", async () => {
const prisma = makePrisma({ lab: null });
const at = await findSatisfyingEvent(prisma as never, "u1", {
...base,
measurementType: null,
});
expect(at).toBeNull();
});
});
@@ -0,0 +1,117 @@
/**
* v1.18.1 — the shared `satisfyReminder` primitive: forward-only guard +
* re-anchored reschedule. The cron, the manual satisfy route, and the
* eventful worker all route through it, so these tests pin the invariants
* every caller relies on.
*/
import { describe, expect, it, vi } from "vitest";
import { satisfyReminder } from "../satisfy";
const TZ = "Europe/Berlin";
function makePrisma() {
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
const prisma = {
measurementReminder: {
update: vi.fn(
async ({
where,
data,
}: {
where: { id: string };
data: Record<string, unknown>;
}) => {
updates.push({ id: where.id, data });
return { id: where.id, ...data };
},
),
},
};
return { prisma, updates };
}
function reminder(overrides: Partial<Parameters<typeof satisfyReminder>[1]> = {}) {
return {
id: "r1",
intervalDays: 7,
rrule: null,
anchorDate: null,
notifyHour: 9,
lastSatisfiedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
...overrides,
};
}
describe("satisfyReminder", () => {
it("stamps lastSatisfiedAt + recomputes nextDueAt when never satisfied", async () => {
const { prisma, updates } = makePrisma();
const at = new Date("2026-06-14T18:00:00Z");
const result = await satisfyReminder(
prisma as never,
reminder(),
TZ,
at,
);
expect(result.satisfied).toBe(true);
expect(result.nextDueAt).toBeInstanceOf(Date);
expect(updates).toHaveLength(1);
expect(updates[0].data.lastSatisfiedAt).toEqual(at);
// Rolling +7d from the satisfy instant — strictly in the future.
expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan(
at.getTime(),
);
});
it("advances when the event is strictly after the existing lastSatisfiedAt", async () => {
const { prisma, updates } = makePrisma();
const prev = new Date("2026-06-10T08:00:00Z");
const at = new Date("2026-06-17T08:00:00Z");
const result = await satisfyReminder(
prisma as never,
reminder({ lastSatisfiedAt: prev }),
TZ,
at,
);
expect(result.satisfied).toBe(true);
expect(updates[0].data.lastSatisfiedAt).toEqual(at);
});
it("is a forward-only no-op when the event is older than lastSatisfiedAt", async () => {
const { prisma, updates } = makePrisma();
const prev = new Date("2026-06-17T08:00:00Z");
const stale = new Date("2026-06-10T08:00:00Z");
const result = await satisfyReminder(
prisma as never,
reminder({ lastSatisfiedAt: prev }),
TZ,
stale,
);
expect(result.satisfied).toBe(false);
expect(result.nextDueAt).toBeNull();
expect(prisma.measurementReminder.update).not.toHaveBeenCalled();
expect(updates).toHaveLength(0);
});
it("is a no-op when the event equals lastSatisfiedAt (cron behind an applied hook)", async () => {
const { prisma } = makePrisma();
const same = new Date("2026-06-17T08:00:00Z");
const result = await satisfyReminder(
prisma as never,
reminder({ lastSatisfiedAt: same }),
TZ,
same,
);
expect(result.satisfied).toBe(false);
expect(prisma.measurementReminder.update).not.toHaveBeenCalled();
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* v1.18.1 — the shared "did a satisfying event land?" matcher for the
* Vorsorge engine.
*
* One reminder, one question: since the last satisfy (or the anchor when
* never satisfied), did a reading land that fulfils this reminder's
* cadence? Both the 15-min cron and the eventful ingest-driven worker call
* this, so the suppression logic lives in exactly one place.
*
* Two satisfaction sources:
*
* 1. **Typed reminder** (`measurementType` non-NULL): a non-deleted
* `Measurement` of that type measured after the floor. BP matches on
* `BLOOD_PRESSURE_SYS` (the SYS sentinel) so SYS + DIA don't
* double-count — the validation allow-list only ever stores SYS.
* 2. **Free-text reminder** (`measurementType` NULL): an "annual blood
* panel" / lab Vorsorge has no Measurement to match. v1.18.1 closes
* the clinical loop (gap D2): a non-deleted `LabResult` taken after
* the floor satisfies it. Without this a lab reminder could only ever
* resolve on a manual "Erledigt".
*/
import type { PrismaClient } from "@/generated/prisma/client";
/** The reminder fields the matcher reads. */
export interface ResolvableReminder {
measurementType: import("@/generated/prisma/client").MeasurementType | null;
anchorDate: Date | null;
lastSatisfiedAt: Date | null;
createdAt: Date;
}
/**
* The floor a satisfying event must land strictly after: the last satisfy,
* else the anchor, else the create instant. A reading inside the current
* due cycle means the user already measured.
*/
export function satisfactionFloor(reminder: ResolvableReminder): Date {
return reminder.lastSatisfiedAt ?? reminder.anchorDate ?? reminder.createdAt;
}
/**
* Find the most recent satisfying event instant for a reminder, or `null`
* when none has landed since the floor.
*
* A typed reminder resolves from a matching `Measurement`; a free-text
* reminder resolves from any `LabResult` (the Lab↔Vorsorge link, D2).
*/
export async function findSatisfyingEvent(
prisma: PrismaClient,
userId: string,
reminder: ResolvableReminder,
): Promise<Date | null> {
const floor = satisfactionFloor(reminder);
if (reminder.measurementType !== null) {
const match = await prisma.measurement.findFirst({
where: {
userId,
type: reminder.measurementType,
deletedAt: null,
measuredAt: { gt: floor },
},
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
return match?.measuredAt ?? null;
}
// Free-text / checklist reminder → a lab panel fulfils it. Match on
// `takenAt` (the sample instant), mirroring the measurement path's
// `measuredAt`. Any analyte counts: a "Großes Blutbild" reminder is
// satisfied the moment the user records any value from that panel.
const lab = await prisma.labResult.findFirst({
where: {
userId,
deletedAt: null,
takenAt: { gt: floor },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
});
return lab?.takenAt ?? null;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* v1.18.1 — the ONE shared satisfaction primitive for the Vorsorge /
* measurement-reminder engine.
*
* Every path that marks a reminder's cadence fulfilled routes through
* `satisfyReminder`: the manual "Erledigt" route, the cron auto-resolve,
* and the eventful ingest-driven satisfaction worker. No duplicated
* reschedule logic — one place stamps `lastSatisfiedAt` and recomputes the
* server-authoritative `nextDueAt`.
*
* Two invariants the primitive owns:
*
* 1. **Forward-only.** `lastSatisfiedAt` only ever moves forward. A
* cron poll and an ingest enqueue can both fire for the same reading;
* the second is a no-op (returns `false`) rather than re-stamping an
* older instant. This is what makes the cron a safe idempotent
* safety-net behind the eventful hook.
* 2. **Re-anchored reschedule.** `nextDueAt` is recomputed from the
* satisfy instant via the canonical recurrence engine
* (`computeReminderNextDueAt`), so a freshly-satisfied reminder rolls
* strictly past the current due cycle and the self-tracker never gets
* nagged.
*
* Pure-ish: it reads the row fields it is handed and issues one Prisma
* update. The caller supplies the reminder row + the user's timezone.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
/**
* The reminder fields `satisfyReminder` needs. A subset of the Prisma row
* so callers (and tests) can construct it without the full model.
*/
export interface SatisfiableReminder {
id: string;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
}
export interface SatisfyResult {
/** True when the reminder was advanced; false when the event was older
* than (or equal to) the existing `lastSatisfiedAt` (forward-only
* no-op). */
satisfied: boolean;
/** The recomputed next-due instant when satisfied, else `null`. */
nextDueAt: Date | null;
}
/**
* Mark a reminder's cadence satisfied at `satisfiedAt` and reschedule.
*
* Forward-only: if `satisfiedAt` is not strictly after the existing
* `lastSatisfiedAt`, nothing is written and `{ satisfied: false }` is
* returned. Otherwise stamps `lastSatisfiedAt = satisfiedAt`, recomputes
* `nextDueAt` from the satisfy instant, persists both, and returns
* `{ satisfied: true, nextDueAt }`.
*/
export async function satisfyReminder(
prisma: PrismaClient,
reminder: SatisfiableReminder,
timezone: string,
satisfiedAt: Date,
): Promise<SatisfyResult> {
// Forward-only guard. A null `lastSatisfiedAt` always advances. Equal
// instants are a no-op so a cron poll behind an already-applied ingest
// hook doesn't churn the row.
if (
reminder.lastSatisfiedAt !== null &&
satisfiedAt.getTime() <= reminder.lastSatisfiedAt.getTime()
) {
return { satisfied: false, nextDueAt: null };
}
const scheduleInput: ReminderScheduleInput = {
intervalDays: reminder.intervalDays,
rrule: reminder.rrule,
anchorDate: reminder.anchorDate,
notifyHour: reminder.notifyHour,
lastSatisfiedAt: satisfiedAt,
createdAt: reminder.createdAt,
};
const nextDueAt = computeReminderNextDueAt(
scheduleInput,
timezone,
satisfiedAt,
);
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: { lastSatisfiedAt: satisfiedAt, nextDueAt },
});
return { satisfied: true, nextDueAt };
}