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:
Marc-André Bombeck committed 2026-06-14 23:33:22 +02:00
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();
});
});
+330
View File
@@ -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 },
});
}
+32
View File
@@ -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;
}
});
}