mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(reminders): send a dose reminder once per slot, phase and day
The per-phase "already sent today" guard read the Telegram message ledger, and that table is written only when a Telegram send succeeds. A user on email, APNs or ntfy never had a row, so the 15-minute tick re-derived the same overdue slot and sent it again on every pass — around fifty mails for a single missed dose. Anchor the guard in `push_attempts` instead, the ledger the daily briefing and the illness escalation already use. Two properties are deliberate: the anchor is stamped for every dispatched slot regardless of what any channel returned, because keying it on a delivered push would re-open the flood the moment a channel starts failing; and it is stamped before the dispatch, so a crash costs one phase's notice rather than a repeat every quarter hour. The key carries the schedule id and the slot's real HH:mm alongside the phase and the local date. Both are load-bearing: two legacy single-window schedules on one medication share the empty first-class time-of-day, so a coarser key would collapse the evening dose into the morning one. The tests assert the other direction too — the next phase still escalates, and the next local day starts clean. The Telegram ledger keeps its message-tracking duties; it is no longer the dedup key. Anchor rows carry a sentinel channel and stay out of the operator's notification health and diagnostic reads, which count deliveries, not decisions.
This commit is contained in:
1 parent
fcc4e661df
commit
c6b944b7fb
7 files changed
+518
-46
No files matched your search
@@ -68,6 +68,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
|
||||
const ADMIN_USER_ID = "admin-1";
|
||||
const ADMIN_CTX = {
|
||||
@@ -393,4 +394,20 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
|
||||
expect(args.orderBy).toEqual({ createdAt: "desc" });
|
||||
expect(args.take).toBe(20);
|
||||
});
|
||||
|
||||
it("excludes reminder-dedup bookkeeping rows so the last 20 are real sends", async () => {
|
||||
// The medication tick anchors its per-slot dedup in this table under a
|
||||
// sentinel channel. Letting those rows into the trailing window would
|
||||
// push the actual delivery attempts — the thing an operator opens this
|
||||
// endpoint for — off the list.
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
|
||||
await GET();
|
||||
|
||||
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
|
||||
where: { channel?: { not?: string } };
|
||||
};
|
||||
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import type { ChannelType } from "@/lib/notifications/types";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -174,7 +175,10 @@ export const GET = apiHandler(async () => {
|
||||
// turns the orderBy + take into a single index scan, so the cost is
|
||||
// bounded regardless of the user's lifetime push volume.
|
||||
prisma.pushAttempt.findMany({
|
||||
where: { userId: user.id },
|
||||
// The reminder tick's per-slot dedup anchors live in this table under
|
||||
// a sentinel channel. They are bookkeeping, not delivery attempts,
|
||||
// and would push the real sends out of the trailing window.
|
||||
where: { userId: user.id, channel: { not: REMINDER_DEDUP_CHANNEL } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
@@ -139,6 +140,17 @@ describe("GET /api/admin/notifications/health — aggregate", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes reminder-dedup bookkeeping rows from the channel picture", async () => {
|
||||
// The medication tick anchors its per-slot dedup in the same table
|
||||
// under a sentinel channel. Those rows are not delivery attempts, so
|
||||
// counting them would misreport what the channels are doing.
|
||||
await GET(req());
|
||||
const args = vi.mocked(prisma.pushAttempt.groupBy).mock.calls[0][0] as {
|
||||
where: { channel?: { not?: string } };
|
||||
};
|
||||
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
|
||||
});
|
||||
|
||||
it("defaults the window to 24h and clamps out-of-range values", async () => {
|
||||
let res = await GET(req("999"));
|
||||
let body = (await res.json()) as HealthEnvelope;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -58,7 +59,13 @@ export const GET = apiHandler(async (request) => {
|
||||
const [grouped, disabled] = await Promise.all([
|
||||
prisma.pushAttempt.groupBy({
|
||||
by: ["channel", "result"],
|
||||
where: { createdAt: { gte: since } },
|
||||
// The reminder tick anchors its per-slot dedup in the same table
|
||||
// under a sentinel channel. Those rows record a decision, not a
|
||||
// delivery, so they stay out of the per-channel health picture.
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
channel: { not: REMINDER_DEDUP_CHANNEL },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
// Channels disabled with an automated reason. A manual disable leaves
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Medication-reminder dedup + client-managed contract.
|
||||
*
|
||||
* Two behaviours are pinned here, both of them user-visible failures
|
||||
* before this suite existed:
|
||||
*
|
||||
* 1. **One dispatch per (medication, slot, phase, local day).** The
|
||||
* dedup used to key on the Telegram message ledger, which is written
|
||||
* only when a Telegram send succeeds. A user on email / APNs / ntfy
|
||||
* therefore never had a ledger row, so the 15-minute tick re-fired the
|
||||
* same overdue notice for the rest of the day — roughly fifty mails per
|
||||
* missed dose. The anchor now lives in `push_attempts` and is written
|
||||
* for every dispatched slot regardless of channel outcome.
|
||||
*
|
||||
* 2. **Coarser must not mean quieter.** The dedup is per phase and per
|
||||
* local day: the ORANGE → RED escalation still fires, and the next
|
||||
* day starts clean. That direction is the whole risk of the fix, so it
|
||||
* is asserted explicitly rather than implied.
|
||||
*
|
||||
* The tick reads the wall clock itself, so the fixture drives it with a
|
||||
* faked system time and a hand-rolled Prisma surface (no testcontainer).
|
||||
* All instants are UTC and the fixture user's timezone is UTC, so the
|
||||
* suite is identical under any `TZ`.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
interface FakePushAttempt {
|
||||
userId: string;
|
||||
channel: string;
|
||||
eventType: string;
|
||||
result: string;
|
||||
reason: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const pushAttempts: FakePushAttempt[] = [];
|
||||
|
||||
const prismaMock = {
|
||||
telegramScheduledDeletion: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
telegramPromptContext: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
medication: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
telegramReminderMessage: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
pushAttempt: {
|
||||
create: vi.fn(
|
||||
async ({ data }: { data: Omit<FakePushAttempt, "createdAt"> }) => {
|
||||
pushAttempts.push({ ...data, createdAt: new Date() });
|
||||
return { id: `pa-${pushAttempts.length}` };
|
||||
},
|
||||
),
|
||||
findFirst: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: {
|
||||
userId: string;
|
||||
eventType: string;
|
||||
reason: string;
|
||||
createdAt?: { gte: Date };
|
||||
};
|
||||
}) =>
|
||||
pushAttempts.find(
|
||||
(row) =>
|
||||
row.userId === where.userId &&
|
||||
row.eventType === where.eventType &&
|
||||
row.reason === where.reason &&
|
||||
(where.createdAt?.gte === undefined ||
|
||||
row.createdAt >= where.createdAt.gte),
|
||||
) ?? null,
|
||||
),
|
||||
},
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/jobs/reminder/shared", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/jobs/reminder/shared")>();
|
||||
return {
|
||||
...actual,
|
||||
getWorkerPrisma: () => prismaMock,
|
||||
workerLog: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
recordError: vi.fn(),
|
||||
recordReminderCheck: vi.fn(),
|
||||
markWorkerStarted: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: vi.fn(
|
||||
async (
|
||||
_name: string,
|
||||
fn: (evt: {
|
||||
addMeta: (k: string, v: unknown) => void;
|
||||
addWarning: (m: string) => void;
|
||||
setError: (e: unknown) => void;
|
||||
}) => Promise<void>,
|
||||
) => fn({ addMeta: vi.fn(), addWarning: vi.fn(), setError: vi.fn() }),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") }));
|
||||
vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() }));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
/** One 08:00–09:00 slot, every day, notifications on, no Telegram anywhere. */
|
||||
function medicationFixture(notificationPrefs: unknown = null) {
|
||||
return {
|
||||
id: "med-1",
|
||||
name: "Fixture",
|
||||
dose: "1",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
notificationsEnabled: true,
|
||||
snoozedUntil: null,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
phaseConfig: null,
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
id: "user-1",
|
||||
timezone: "UTC",
|
||||
locale: "en",
|
||||
notificationPrefs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Phases dispatched so far, in order. */
|
||||
function dispatchedPhases(): string[] {
|
||||
return vi
|
||||
.mocked(dispatchNotification)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0].metadata as { phase: string } | undefined)?.phase ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
async function tickAt(iso: string): Promise<void> {
|
||||
vi.setSystemTime(new Date(iso));
|
||||
await handleReminderCheck([]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pushAttempts.length = 0;
|
||||
vi.useFakeTimers();
|
||||
prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]);
|
||||
prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.medication.updateMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
|
||||
prismaMock.medicationIntakeEvent.count.mockResolvedValue(0);
|
||||
prismaMock.telegramReminderMessage.findUnique.mockResolvedValue(null);
|
||||
vi.mocked(dispatchNotification).mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("medication reminder — one dispatch per slot, phase and local day", () => {
|
||||
it("dispatches the overdue notice once across repeated ticks in the same phase", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]);
|
||||
|
||||
// Four ticks, 15 minutes apart, all inside the ORANGE phase of the
|
||||
// 08:00–09:00 slot. The pre-fix worker sent one notification per tick.
|
||||
await tickAt("2026-06-15T09:15:00Z");
|
||||
await tickAt("2026-06-15T09:30:00Z");
|
||||
await tickAt("2026-06-15T09:45:00Z");
|
||||
await tickAt("2026-06-15T10:00:00Z");
|
||||
|
||||
expect(dispatchedPhases()).toEqual(["ORANGE"]);
|
||||
});
|
||||
|
||||
it("still escalates to the next phase on the same day", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]);
|
||||
|
||||
await tickAt("2026-06-15T09:15:00Z"); // ORANGE
|
||||
await tickAt("2026-06-15T09:30:00Z"); // ORANGE again — suppressed
|
||||
await tickAt("2026-06-15T13:00:00Z"); // 240 min past window end → RED
|
||||
|
||||
expect(dispatchedPhases()).toEqual(["ORANGE", "RED"]);
|
||||
});
|
||||
|
||||
it("starts clean on the next local day", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]);
|
||||
|
||||
await tickAt("2026-06-15T09:15:00Z");
|
||||
await tickAt("2026-06-15T13:00:00Z");
|
||||
await tickAt("2026-06-16T09:15:00Z");
|
||||
await tickAt("2026-06-16T09:30:00Z");
|
||||
|
||||
expect(dispatchedPhases()).toEqual(["ORANGE", "RED", "ORANGE"]);
|
||||
});
|
||||
|
||||
it("suppresses the repeat even when no channel delivered", async () => {
|
||||
// The anchor is written per dispatched slot, not per successful send.
|
||||
// Anchoring on success would re-fire every 15 minutes against a broken
|
||||
// channel — the same flood wearing a different key.
|
||||
prismaMock.medication.findMany.mockResolvedValue([medicationFixture()]);
|
||||
vi.mocked(dispatchNotification).mockResolvedValue({
|
||||
dispatched: false,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
|
||||
await tickAt("2026-06-15T09:15:00Z");
|
||||
await tickAt("2026-06-15T09:30:00Z");
|
||||
await tickAt("2026-06-15T09:45:00Z");
|
||||
|
||||
expect(dispatchedPhases()).toEqual(["ORANGE"]);
|
||||
});
|
||||
|
||||
it("does not let one slot's anchor silence a second slot of the same medication", async () => {
|
||||
// Two legacy single-window schedules on one medication: both carry an
|
||||
// empty first-class `timesOfDay`, so a dedup key built from the dedup
|
||||
// placeholder alone would collapse the evening dose into the morning one.
|
||||
const med = medicationFixture();
|
||||
med.schedules.push({
|
||||
...med.schedules[0],
|
||||
id: "sched-2",
|
||||
windowStart: "20:00",
|
||||
windowEnd: "21:00",
|
||||
});
|
||||
prismaMock.medication.findMany.mockResolvedValue([med]);
|
||||
|
||||
await tickAt("2026-06-15T09:15:00Z"); // morning slot, ORANGE
|
||||
await tickAt("2026-06-15T21:15:00Z"); // morning RED + evening ORANGE
|
||||
|
||||
const slots = vi.mocked(dispatchNotification).mock.calls.map((call) => {
|
||||
const meta = call[0].metadata as { scheduleId: string; phase: string };
|
||||
return `${meta.scheduleId}:${meta.phase}`;
|
||||
});
|
||||
expect(slots).toEqual(["sched-1:ORANGE", "sched-1:RED", "sched-2:ORANGE"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication reminder — client-managed suppression is APNs-only", () => {
|
||||
it("still dispatches so the user's other channels deliver", async () => {
|
||||
// `medication.clientManaged` means the iOS client owns the local
|
||||
// banner. It never meant "stop telling me on Telegram / email"; the
|
||||
// published contract has always said APNs. The suppression now happens
|
||||
// in the dispatcher's APNs branch, so the dispatch itself must proceed.
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
medicationFixture({ medication: { clientManaged: true } }),
|
||||
]);
|
||||
|
||||
await tickAt("2026-06-15T09:15:00Z");
|
||||
|
||||
expect(dispatchNotification).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchedPhases()).toEqual(["ORANGE"]);
|
||||
});
|
||||
|
||||
it("keeps the per-slot dedup for a client-managed user", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([
|
||||
medicationFixture({ medication: { deliveryDefault: "client" } }),
|
||||
]);
|
||||
|
||||
await tickAt("2026-06-15T09:15:00Z");
|
||||
await tickAt("2026-06-15T09:30:00Z");
|
||||
|
||||
expect(dispatchNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
getPhaseMessage,
|
||||
getPhaseKeyboard,
|
||||
} from "@/lib/jobs/reminder-phases";
|
||||
import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs";
|
||||
import {
|
||||
hasReminderDedupAnchor,
|
||||
medicationReminderDedupKey,
|
||||
writeReminderDedupAnchor,
|
||||
} from "@/lib/notifications/reminder-dedup";
|
||||
import {
|
||||
getUserTodayBounds as getUserTodayBoundsUtil,
|
||||
localHmAsUtc,
|
||||
@@ -167,12 +171,6 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
// Used to localise the reminder title / message / keyboard
|
||||
// labels per user. Null falls back to the app default.
|
||||
locale: true,
|
||||
// v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs
|
||||
// blob so the dispatch step can skip APNs sends for users
|
||||
// whose iOS client has opted in to local SpeziScheduler
|
||||
// reminders. Null = legacy default (clientManaged: false),
|
||||
// i.e. the server reminder fires as before.
|
||||
notificationPrefs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -380,23 +378,29 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this phase was already notified today for this
|
||||
// time-of-day.
|
||||
const existingMessage =
|
||||
await prisma.telegramReminderMessage.findUnique({
|
||||
where: {
|
||||
medicationId_scheduleId_date_phase_timeOfDay: {
|
||||
medicationId: med.id,
|
||||
scheduleId: schedule.id,
|
||||
date: localDateStr,
|
||||
phase: currentPhase,
|
||||
timeOfDay: dedupTimeOfDay,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Already notified for this slot, phase and local day? The
|
||||
// anchor lives in `push_attempts` and is stamped for every
|
||||
// dispatched slot regardless of which channels delivered. It
|
||||
// used to be the Telegram message ledger, which is written only
|
||||
// when a Telegram send succeeds — so a user on email / APNs /
|
||||
// ntfy had no row and the tick re-sent the same overdue notice
|
||||
// every 15 minutes for the rest of the day.
|
||||
const dedupReason = medicationReminderDedupKey({
|
||||
medicationId: med.id,
|
||||
scheduleId: schedule.id,
|
||||
slotTime,
|
||||
phase: currentPhase,
|
||||
localDate: localDateStr,
|
||||
});
|
||||
|
||||
if (existingMessage) {
|
||||
// Already sent for this phase + time-of-day — skip
|
||||
if (
|
||||
await hasReminderDedupAnchor(prisma, {
|
||||
userId: med.user.id,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
reason: dedupReason,
|
||||
now,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -449,28 +453,14 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification if enabled
|
||||
// Send notification if enabled.
|
||||
//
|
||||
// `medication.clientManaged` is NOT consulted here. It suppresses
|
||||
// the server's APNs send only — the iOS client owns the local
|
||||
// banner, every other channel the user configured still delivers
|
||||
// — so the gate lives in the dispatcher's APNs branch, which is
|
||||
// the only place that knows whether an APNs send was on the table.
|
||||
if (med.notificationsEnabled) {
|
||||
// v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed
|
||||
// suppression. ONLY suppresses MEDICATION_REMINDER.
|
||||
if (
|
||||
isMedicationReminderClientManaged(med.user.notificationPrefs)
|
||||
) {
|
||||
const doseAtIso = slotScheduledFor.toISOString();
|
||||
evt.addMeta(
|
||||
"medication_reminder_suppressed_client_managed",
|
||||
`${med.name}:${slotTime}`,
|
||||
);
|
||||
evt.addMeta("medication_reminder_suppressed_meta", {
|
||||
user_id: med.user.id,
|
||||
medication_id: med.id,
|
||||
schedule_id: schedule.id,
|
||||
phase: currentPhase,
|
||||
dose_at: doseAtIso,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, message } = getPhaseMessage(
|
||||
currentPhase,
|
||||
med.name,
|
||||
@@ -495,6 +485,15 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
`${currentPhase}:${med.name}:${slotTime}`,
|
||||
);
|
||||
|
||||
// Stamp the dedup anchor BEFORE dispatching: a crash between
|
||||
// the two costs this phase's notice, a repeat costs the user
|
||||
// a notification every 15 minutes until the day rolls.
|
||||
await writeReminderDedupAnchor(prisma, {
|
||||
userId: med.user.id,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
reason: dedupReason,
|
||||
});
|
||||
|
||||
try {
|
||||
await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Reminder dedup anchor — "this slot already went out today".
|
||||
*
|
||||
* The medication-reminder tick runs every 15 minutes and re-derives the
|
||||
* same overdue slot on every pass, so it needs a durable record of what it
|
||||
* already sent. That record used to be the Telegram message ledger, which
|
||||
* only exists when a Telegram send succeeded: a user on email, APNs or
|
||||
* ntfy had no row, so the tick re-sent the same overdue notice on every
|
||||
* pass for the rest of the local day.
|
||||
*
|
||||
* The anchor now lives in `push_attempts`, the ledger the daily briefing
|
||||
* and the illness escalation already use for the same purpose. Two
|
||||
* properties matter and both are deliberate:
|
||||
*
|
||||
* - **Written per dispatched slot, not per successful send.** Anchoring
|
||||
* on a delivered push would re-open the flood the moment a channel
|
||||
* starts failing, which is the original defect wearing a new key.
|
||||
* - **Written before the dispatch.** A crash mid-dispatch burns the slot
|
||||
* for that phase rather than risking a repeat; the next phase still
|
||||
* escalates, and the next local day starts clean.
|
||||
*
|
||||
* The rows are bookkeeping, not delivery attempts, so they carry the
|
||||
* `DEDUP` channel sentinel and the operator-facing notification surfaces
|
||||
* filter them out.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Channel sentinel for a bookkeeping row. Never a real delivery target —
|
||||
* `/api/admin/notifications/{health,diagnostic}` exclude it so the
|
||||
* per-channel health picture stays a picture of actual sends.
|
||||
*/
|
||||
export const REMINDER_DEDUP_CHANNEL = "DEDUP";
|
||||
|
||||
/**
|
||||
* How far back the anchor lookup reads. The key already carries the local
|
||||
* date, so anything older than this cannot match; the bound exists to keep
|
||||
* the read on the `(user_id, created_at DESC)` index. Two days covers every
|
||||
* timezone offset plus a DST shift.
|
||||
*/
|
||||
const ANCHOR_LOOKBACK_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Dedup key for one medication dose slot in one phase on one local day.
|
||||
*
|
||||
* `scheduleId` and the slot's real `HH:mm` are BOTH in the key. Dropping
|
||||
* either one silently merges slots that must stay separate: two legacy
|
||||
* single-window schedules on the same medication share the empty
|
||||
* first-class time-of-day, and two schedules can name the same time with
|
||||
* different doses. Over-suppression is the failure mode this whole change
|
||||
* risks introducing, so the key stays at least as fine as the ledger it
|
||||
* replaces.
|
||||
*/
|
||||
export function medicationReminderDedupKey(input: {
|
||||
medicationId: string;
|
||||
scheduleId: string;
|
||||
slotTime: string;
|
||||
phase: string;
|
||||
localDate: string;
|
||||
}): string {
|
||||
return [
|
||||
"med",
|
||||
input.medicationId,
|
||||
input.scheduleId,
|
||||
input.slotTime,
|
||||
input.phase,
|
||||
input.localDate,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this exact slot+phase+day already been dispatched? Best-effort: a DB
|
||||
* error biases toward "not yet", so a transient blip delays nothing — the
|
||||
* cost of a rare duplicate is far below the cost of a silently dropped
|
||||
* dose reminder.
|
||||
*/
|
||||
export async function hasReminderDedupAnchor(
|
||||
prisma: PrismaClient,
|
||||
input: { userId: string; eventType: string; reason: string; now: Date },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const row = await prisma.pushAttempt.findFirst({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
eventType: input.eventType,
|
||||
reason: input.reason,
|
||||
createdAt: { gte: new Date(input.now.getTime() - ANCHOR_LOOKBACK_MS) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return row !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the anchor. Never throws — a failed stamp costs a duplicate
|
||||
* notification at the next tick, while a thrown error would cost the
|
||||
* dispatch itself.
|
||||
*/
|
||||
export async function writeReminderDedupAnchor(
|
||||
prisma: PrismaClient,
|
||||
input: { userId: string; eventType: string; reason: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.pushAttempt.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
channel: REMINDER_DEDUP_CHANNEL,
|
||||
eventType: input.eventType,
|
||||
result: "skipped",
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Best-effort by contract; the next tick re-derives the same slot.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user