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:
Marc-André Bombeck committed 2026-07-27 14:26:20 +02:00
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