mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
|
||||
|
||||
Reference in new issue
Block a user