mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(reminders): every-15-min dispatcher for preventive-care reminders
Add the measurement-reminder cron, mirroring the mood-reminder shape: a pure due-predicate (past nextDueAt AND inside the reminder's local notify-hour) and a tick runner that dispatches through the existing notification cascade. Auto-resolve runs in the cron, not on the ingest hot path: a matching reading of the reminder's type advances lastSatisfiedAt and skips the nudge (BP on the SYS sentinel; free-text reminders resolve only on a manual satisfy). Dedup is the nextDueAt advance past now, so no ledger table is needed. The queue is registered in the all-queues list, scheduled, and bound to its worker; a registration guard test pins that wiring.
This commit is contained in:
1 parent
0a4f00f850
commit
383963d3b4
5 files changed
+749
No files matched your search
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* v1.17.1 — measurement-reminder queue registration guard.
|
||||
*
|
||||
* Same source-text-grep approach as the cycle / mood / drain registration
|
||||
* guards: assert the Vorsorge-reminder queue is declared, listed in
|
||||
* `allQueues` (the v1.4.37 dead-queue lesson — an unregistered queue is
|
||||
* never provisioned and the schedule silently no-ops), scheduled on its
|
||||
* cron, and wired to a `boss.work` handler that delegates to
|
||||
* `runMeasurementReminderTick`. No pg-boss / Prisma boot.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source =
|
||||
readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") +
|
||||
readFileSync(
|
||||
join(__dirname, "..", "reminder", "mood-cycle-checks.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("reminder-worker — measurement-reminder wiring", () => {
|
||||
it("imports the measurement-reminder tick runner", () => {
|
||||
expect(source).toMatch(
|
||||
/from\s*["']@\/lib\/jobs\/measurement-reminder["']/,
|
||||
);
|
||||
expect(source).toMatch(/\brunMeasurementReminderTick\b/);
|
||||
});
|
||||
|
||||
it("declares the queue + cron constants", () => {
|
||||
expect(source).toMatch(
|
||||
/MEASUREMENT_REMINDER_QUEUE\s*=\s*["']measurement-reminder-check["']/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/MEASUREMENT_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the queue in the allQueues loop", () => {
|
||||
const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
expect(allQueuesMatch).not.toBeNull();
|
||||
expect(allQueuesMatch![1]).toMatch(/\bMEASUREMENT_REMINDER_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("schedules the measurement-reminder cron", () => {
|
||||
expect(source).toMatch(
|
||||
/\[MEASUREMENT_REMINDER_QUEUE,\s*MEASUREMENT_REMINDER_CRON\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers a boss.work handler that runs the tick", () => {
|
||||
expect(source).toMatch(
|
||||
/boss\.work[\s\S]{0,200}MEASUREMENT_REMINDER_QUEUE[\s\S]{0,200}handleMeasurementReminderCheck/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/handleMeasurementReminderCheck[\s\S]{0,400}runMeasurementReminderTick/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* v1.17.1 — Vorsorge (measurement) reminder dispatcher unit tests.
|
||||
*
|
||||
* Pins the contract:
|
||||
* - Due-predicate window: fires only when past-due AND inside the
|
||||
* reminder's local notify-hour (08:59 → no, 09:00 → yes, 09:59 →
|
||||
* yes, 10:00 → no). A disabled reminder / null nextDueAt never fires.
|
||||
* - Auto-resolve: a matching reading of the reminder's measurementType
|
||||
* logged since the last satisfy advances lastSatisfiedAt + recomputes
|
||||
* nextDueAt and suppresses the nudge. Free-text reminders never
|
||||
* auto-resolve.
|
||||
* - Ledger-free dedup: a successful dispatch advances nextDueAt past
|
||||
* now so the same due cycle never re-fires.
|
||||
* - clientManaged suppression skips the APNs send but still advances.
|
||||
*
|
||||
* The Prisma surface is stubbed manually to avoid a testcontainer boot.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import {
|
||||
evaluateMeasurementReminderDue,
|
||||
runMeasurementReminderTick,
|
||||
} from "../measurement-reminder";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
|
||||
|
||||
type DispatchFn = (payload: NotificationPayload) => Promise<DispatchOutcome>;
|
||||
|
||||
const OK: DispatchOutcome = {
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
};
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
// 09:00 Berlin in June = 07:00Z.
|
||||
const NINE_LOCAL = new Date("2026-06-15T07:00:00Z");
|
||||
|
||||
describe("evaluateMeasurementReminderDue — window boundary", () => {
|
||||
const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) };
|
||||
|
||||
it("08:59 local → not in hour window", () => {
|
||||
const d = evaluateMeasurementReminderDue(
|
||||
reminder,
|
||||
TZ,
|
||||
new Date("2026-06-15T06:59:00Z"),
|
||||
);
|
||||
expect(d.isDue).toBe(true);
|
||||
expect(d.inHourWindow).toBe(false);
|
||||
expect(d.fire).toBe(false);
|
||||
});
|
||||
|
||||
it("09:00 local → fires", () => {
|
||||
const d = evaluateMeasurementReminderDue(reminder, TZ, NINE_LOCAL);
|
||||
expect(d.fire).toBe(true);
|
||||
});
|
||||
|
||||
it("09:59 local → still in window", () => {
|
||||
const d = evaluateMeasurementReminderDue(
|
||||
reminder,
|
||||
TZ,
|
||||
new Date("2026-06-15T07:59:00Z"),
|
||||
);
|
||||
expect(d.fire).toBe(true);
|
||||
});
|
||||
|
||||
it("10:00 local → outside window", () => {
|
||||
const d = evaluateMeasurementReminderDue(
|
||||
reminder,
|
||||
TZ,
|
||||
new Date("2026-06-15T08:00:00Z"),
|
||||
);
|
||||
expect(d.inHourWindow).toBe(false);
|
||||
expect(d.fire).toBe(false);
|
||||
});
|
||||
|
||||
it("not yet due → never fires", () => {
|
||||
const d = evaluateMeasurementReminderDue(
|
||||
{ enabled: true, notifyHour: 9, nextDueAt: new Date("2099-01-01") },
|
||||
TZ,
|
||||
NINE_LOCAL,
|
||||
);
|
||||
expect(d.isDue).toBe(false);
|
||||
expect(d.fire).toBe(false);
|
||||
});
|
||||
|
||||
it("disabled / null nextDueAt → never fires", () => {
|
||||
expect(
|
||||
evaluateMeasurementReminderDue(
|
||||
{ enabled: false, notifyHour: 9, nextDueAt: new Date(0) },
|
||||
TZ,
|
||||
NINE_LOCAL,
|
||||
).fire,
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateMeasurementReminderDue(
|
||||
{ enabled: true, notifyHour: 9, nextDueAt: null },
|
||||
TZ,
|
||||
NINE_LOCAL,
|
||||
).fire,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
interface FakeReminder {
|
||||
id: string;
|
||||
measurementType: string | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
location: string | null;
|
||||
nextDueAt: Date | null;
|
||||
lastSatisfiedAt: Date | null;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
notificationPrefs: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function makePrisma(opts: {
|
||||
reminders: FakeReminder[];
|
||||
measurementMatch?: { measuredAt: Date } | null;
|
||||
}) {
|
||||
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
|
||||
const prisma = {
|
||||
measurementReminder: {
|
||||
findMany: vi.fn(async () => opts.reminders),
|
||||
update: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
updates.push({ id: where.id, data });
|
||||
return { id: where.id, ...data };
|
||||
},
|
||||
),
|
||||
},
|
||||
measurement: {
|
||||
findFirst: vi.fn(async () => opts.measurementMatch ?? null),
|
||||
},
|
||||
};
|
||||
return { prisma, updates };
|
||||
}
|
||||
|
||||
function reminder(overrides: Partial<FakeReminder>): FakeReminder {
|
||||
return {
|
||||
id: "r1",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
user: {
|
||||
id: "u1",
|
||||
timezone: TZ,
|
||||
locale: "de",
|
||||
notificationPrefs: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runMeasurementReminderTick", () => {
|
||||
it("auto-resolves a typed reminder when a matching reading landed", async () => {
|
||||
const matchAt = new Date("2026-06-14T18:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({})],
|
||||
measurementMatch: { measuredAt: matchAt },
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.autoResolved).toBe(1);
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
// Advanced lastSatisfiedAt to the reading instant + recomputed nextDueAt.
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(matchAt);
|
||||
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({})],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0].eventType).toBe("MEASUREMENT_REMINDER");
|
||||
// nextDueAt advanced strictly past now.
|
||||
expect(updates).toHaveLength(1);
|
||||
const advanced = updates[0].data.nextDueAt as Date;
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
});
|
||||
|
||||
it("free-text reminder never auto-resolves (no measurement query match path)", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [reminder({ measurementType: null })],
|
||||
// Even if a reading existed, a free-text reminder must not query it.
|
||||
measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") },
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.autoResolved).toBe(0);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses the push under clientManaged but still advances nextDueAt", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
reminder({
|
||||
measurementType: null,
|
||||
user: {
|
||||
id: "u1",
|
||||
timezone: TZ,
|
||||
locale: "de",
|
||||
notificationPrefs: { measurementReminder: { clientManaged: true } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.skippedClientManaged).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("skips a reminder outside its notify-hour window", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [reminder({ measurementType: null })],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
new Date("2026-06-15T08:00:00Z"), // 10:00 Berlin — outside the 09 window
|
||||
{ dispatch },
|
||||
);
|
||||
|
||||
expect(summary.skippedOutsideWindow).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* v1.17.1 — Vorsorge (measurement) reminder dispatcher.
|
||||
*
|
||||
* Mirrors the mood-reminder cron (`mood-reminder.ts`): a separate module,
|
||||
* a pure due-predicate, a `runMeasurementReminderTick(prisma, now)`
|
||||
* runner. The cron fires every 15 minutes so it picks up every IANA
|
||||
* timezone crossing the reminder's `notifyHour` without one cron entry
|
||||
* per zone.
|
||||
*
|
||||
* Differences from the mood reminder:
|
||||
*
|
||||
* 1. No dedicated dispatch-ledger table. The reminder's own
|
||||
* `nextDueAt` IS the dedup guard: after a successful dispatch the
|
||||
* runner advances `nextDueAt` to the next occurrence strictly past
|
||||
* now, so the same due cycle never re-fires. This collapses one
|
||||
* table + one cleanup queue versus the mood pattern.
|
||||
* 2. Auto-resolve from an incoming measurement. Before deciding to
|
||||
* fire, the runner checks whether a matching reading of the
|
||||
* reminder's `measurementType` has landed since the last satisfy
|
||||
* (BP matched on `BLOOD_PRESSURE_SYS`). If so it advances
|
||||
* `lastSatisfiedAt` + recomputes `nextDueAt` and skips the nudge —
|
||||
* the user who already measured today never gets nagged. This is a
|
||||
* query in the cron, NOT a hook on the hot iOS batch-ingest path.
|
||||
* Free-text reminders (no `measurementType`) never auto-resolve;
|
||||
* they advance only on a manual satisfy.
|
||||
* 3. Per-reminder `clientManaged` suppression of the server-side APNs
|
||||
* (the medication `clientManaged` precedent) is applied for the
|
||||
* whole user via `notificationPrefs.measurementReminder.clientManaged`.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
|
||||
export interface MeasurementReminderSummary {
|
||||
candidatesScanned: number;
|
||||
inWindow: number;
|
||||
dispatched: number;
|
||||
autoResolved: number;
|
||||
skippedNotDue: number;
|
||||
skippedOutsideWindow: number;
|
||||
skippedClientManaged: number;
|
||||
skippedNoChannel: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locales.includes(locale as Locale)
|
||||
? (locale as Locale)
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure due-predicate: at this instant, is the reminder due AND inside its
|
||||
* local notify-hour window?
|
||||
*
|
||||
* "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps
|
||||
* a reminder that became overdue overnight from firing at 03:00 — it
|
||||
* waits for the user's chosen `notifyHour` to come round in their local
|
||||
* timezone. Pulled out so the unit tests can pin the window boundary
|
||||
* (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB.
|
||||
*/
|
||||
export function evaluateMeasurementReminderDue(
|
||||
reminder: {
|
||||
enabled: boolean;
|
||||
nextDueAt: Date | null;
|
||||
notifyHour: number;
|
||||
},
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): { fire: boolean; inHourWindow: boolean; isDue: boolean } {
|
||||
if (!reminder.enabled || reminder.nextDueAt === null) {
|
||||
return { fire: false, inHourWindow: false, isDue: false };
|
||||
}
|
||||
const isDue = now.getTime() >= reminder.nextDueAt.getTime();
|
||||
const parts = wallClockInTz(now, timezone || "Europe/Berlin");
|
||||
const inHourWindow = parts.hour === reminder.notifyHour;
|
||||
return { fire: isDue && inHourWindow, inHourWindow, isDue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the localised title + body for the Vorsorge push.
|
||||
*/
|
||||
export function buildMeasurementReminderPayload(
|
||||
locale: string | null | undefined,
|
||||
label: string,
|
||||
location: string | null,
|
||||
): { title: string; body: string } {
|
||||
const t = getServerTranslator(resolveLocale(locale)).t;
|
||||
const base = t("measurementReminders.pushBody", { label });
|
||||
const body = location
|
||||
? `${base} ${t("measurementReminders.pushLocation", { location })}`
|
||||
: base;
|
||||
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,
|
||||
* and dispatches a `MEASUREMENT_REMINDER` push for any that are due
|
||||
* inside the user's local notify-hour window.
|
||||
*/
|
||||
export async function runMeasurementReminderTick(
|
||||
prisma: PrismaClient,
|
||||
now: Date,
|
||||
options: {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
} = {},
|
||||
): Promise<MeasurementReminderSummary> {
|
||||
const dispatchImpl = options.dispatch ?? dispatchNotification;
|
||||
|
||||
const summary: MeasurementReminderSummary = {
|
||||
candidatesScanned: 0,
|
||||
inWindow: 0,
|
||||
dispatched: 0,
|
||||
autoResolved: 0,
|
||||
skippedNotDue: 0,
|
||||
skippedOutsideWindow: 0,
|
||||
skippedClientManaged: 0,
|
||||
skippedNoChannel: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
where: { deletedAt: null, enabled: true },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
timezone: true,
|
||||
locale: true,
|
||||
notificationPrefs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const reminder of reminders) {
|
||||
summary.candidatesScanned += 1;
|
||||
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 },
|
||||
});
|
||||
summary.autoResolved += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = evaluateMeasurementReminderDue(
|
||||
{
|
||||
enabled: reminder.enabled,
|
||||
nextDueAt: reminder.nextDueAt,
|
||||
notifyHour: reminder.notifyHour,
|
||||
},
|
||||
timezone,
|
||||
now,
|
||||
);
|
||||
|
||||
if (!decision.isDue) {
|
||||
summary.skippedNotDue += 1;
|
||||
continue;
|
||||
}
|
||||
if (!decision.inHourWindow) {
|
||||
summary.skippedOutsideWindow += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.inWindow += 1;
|
||||
|
||||
// Per-user client-managed suppression (the medication precedent).
|
||||
if (
|
||||
isMeasurementReminderClientManaged(reminder.user.notificationPrefs)
|
||||
) {
|
||||
getEvent()?.addMeta(
|
||||
"measurement_reminder.suppressed_client_managed",
|
||||
reminder.id,
|
||||
);
|
||||
summary.skippedClientManaged += 1;
|
||||
// Advance past this cycle anyway so a client-managed reminder does
|
||||
// not pin the server tick re-evaluating the same overdue slot
|
||||
// every 15 minutes for the rest of the day.
|
||||
await advanceNextDue(prisma, reminder, timezone, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, body } = buildMeasurementReminderPayload(
|
||||
reminder.user.locale,
|
||||
reminder.label,
|
||||
reminder.location,
|
||||
);
|
||||
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
userId: reminder.user.id,
|
||||
title,
|
||||
message: body,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
reminderId: reminder.id,
|
||||
},
|
||||
});
|
||||
|
||||
// No channel succeeded — leave `nextDueAt` where it is so the next
|
||||
// tick (or the user's next channel) retries. The reminder simply
|
||||
// stays overdue, which the surface already shows as "überfällig".
|
||||
if (!outcome.dispatched) {
|
||||
summary.skippedNoChannel += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dispatch succeeded — advance `nextDueAt` past now so this due
|
||||
// cycle never re-fires (the ledger-free dedup guard).
|
||||
await advanceNextDue(prisma, reminder, timezone, now);
|
||||
summary.dispatched += 1;
|
||||
} catch (err: unknown) {
|
||||
summary.failed += 1;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
getEvent()?.addWarning(
|
||||
`measurement-reminder per-reminder dispatch 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
|
||||
* ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired
|
||||
* reminder is not "satisfied", it just rolls to its next slot.
|
||||
*/
|
||||
async function advanceNextDue(
|
||||
prisma: PrismaClient,
|
||||
reminder: {
|
||||
id: string;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
lastSatisfiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
},
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
// The dedup guard must move the slot strictly forward. For an `rrule`
|
||||
// the engine already walks to the next strictly-after-now occurrence,
|
||||
// so passing the row as-is is correct. For a ROLLING reminder the
|
||||
// engine anchors the first-due slot AT `anchorDate ?? createdAt` when
|
||||
// never satisfied, which stays ≤ now and would re-fire every tick — so
|
||||
// re-anchor the rolling cadence on `now` (a fire is the rhythm
|
||||
// restarting from this dispatch) to roll it forward by exactly one
|
||||
// interval. This does NOT advance `lastSatisfiedAt`: a fired reminder
|
||||
// is not satisfied, just rescheduled past the slot it nagged on.
|
||||
const rolling = reminder.intervalDays !== null;
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { nextDueAt },
|
||||
});
|
||||
}
|
||||
@@ -239,8 +239,10 @@ import {
|
||||
import {
|
||||
MoodReminderPayload,
|
||||
CycleReminderPayload,
|
||||
MeasurementReminderPayload,
|
||||
handleMoodReminderCheck,
|
||||
handleCycleReminderCheck,
|
||||
handleMeasurementReminderCheck,
|
||||
} from "./reminder/mood-cycle-checks";
|
||||
import {
|
||||
HostMetricSamplePayload,
|
||||
@@ -500,6 +502,17 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
|
||||
const CYCLE_REMINDER_QUEUE = "cycle-reminder-check";
|
||||
|
||||
const CYCLE_REMINDER_CRON = "*/15 * * * *";
|
||||
|
||||
// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders.
|
||||
// Same cadence + short-circuit shape as the mood / cycle reminders: the
|
||||
// handler only fires a reminder whose `nextDueAt` is past AND whose local
|
||||
// time is the reminder's `notifyHour`, so the 15-min cadence picks up
|
||||
// every IANA timezone crossing that hour without one cron entry per zone.
|
||||
// Dedup is the reminder's own `nextDueAt` advance — no ledger table.
|
||||
|
||||
const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check";
|
||||
|
||||
const MEASUREMENT_REMINDER_CRON = "*/15 * * * *";
|
||||
// v1.4.38 — the per-sample cutoff hours constant now lives on the
|
||||
// helper module so the worker, the admin route, and the CLI all read
|
||||
// the same source of truth. Re-export pulled in alongside
|
||||
@@ -716,6 +729,11 @@ export async function startReminderWorker() {
|
||||
// without this entry the every-15-min schedule silently no-ops and the
|
||||
// cycle dispatcher never fires (the v1.4.37 dead-queue class).
|
||||
CYCLE_REMINDER_QUEUE,
|
||||
// v1.17.1 — Vorsorge (measurement) reminder cron tick. Same pg-boss
|
||||
// v12 createQueue contract; without this entry pg-boss never
|
||||
// provisions the queue and the every-15-min schedule silently no-ops
|
||||
// (the v1.4.37 dead-queue class).
|
||||
MEASUREMENT_REMINDER_QUEUE,
|
||||
// v1.4.49 — push-attempt ledger cleanup. Same createQueue contract
|
||||
// as the other cleanup jobs; the daily schedule below would
|
||||
// silently no-op without this entry.
|
||||
@@ -908,6 +926,11 @@ export async function startReminderWorker() {
|
||||
// hour, so the cron costs ~one prediction-row scan per tick for the
|
||||
// opted-in cohort.
|
||||
[CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON],
|
||||
// v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder.
|
||||
// The handler short-circuits unless a reminder is past-due AND the
|
||||
// user's local time matches the reminder's notifyHour, so the cron
|
||||
// costs ~one reminder-row scan per tick for the active cohort.
|
||||
[MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON],
|
||||
// v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts.
|
||||
[PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON],
|
||||
// v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight
|
||||
@@ -1193,6 +1216,15 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleCycleReminderCheck,
|
||||
);
|
||||
// v1.17.1 — single-flight Vorsorge (measurement) reminder worker.
|
||||
// localConcurrency=1 keeps two ticks from racing the `nextDueAt`
|
||||
// advance that anchors the per-cycle idempotency, exactly like the
|
||||
// mood / cycle reminder workers.
|
||||
await boss.work<MeasurementReminderPayload>(
|
||||
MEASUREMENT_REMINDER_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMeasurementReminderCheck,
|
||||
);
|
||||
// v1.4.49 — daily prune of the push-attempt ledger. Single-flight
|
||||
// matches every other cleanup queue; two ticks racing on the same
|
||||
// DELETE statement is wasted work and the second tick's payload
|
||||
|
||||
@@ -9,6 +9,7 @@ import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { runMoodReminderTick } from "@/lib/jobs/mood-reminder";
|
||||
import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder";
|
||||
import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
export interface MoodReminderPayload {
|
||||
@@ -19,6 +20,10 @@ export interface CycleReminderPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export interface MeasurementReminderPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export async function handleMoodReminderCheck(
|
||||
jobs: Job<MoodReminderPayload>[],
|
||||
) {
|
||||
@@ -80,3 +85,38 @@ export async function handleCycleReminderCheck(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.1 — every-15-min Vorsorge (measurement) reminder tick. Thin shim
|
||||
* around `runMeasurementReminderTick` so the unit tests exercise the
|
||||
* due-predicate + auto-resolve + advance logic without pg-boss.
|
||||
*/
|
||||
export async function handleMeasurementReminderCheck(
|
||||
jobs: Job<MeasurementReminderPayload>[],
|
||||
) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.measurement_reminder", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const summary = await runMeasurementReminderTick(prisma, new Date());
|
||||
evt.setBackground({
|
||||
task_name: "job.measurement_reminder",
|
||||
result: {
|
||||
candidates_scanned: summary.candidatesScanned,
|
||||
in_window: summary.inWindow,
|
||||
dispatched: summary.dispatched,
|
||||
auto_resolved: summary.autoResolved,
|
||||
skipped_not_due: summary.skippedNotDue,
|
||||
skipped_outside_window: summary.skippedOutsideWindow,
|
||||
skipped_client_managed: summary.skippedClientManaged,
|
||||
skipped_no_channel: summary.skippedNoChannel,
|
||||
failed: summary.failed,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user