mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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", {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user