diff --git a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts index 1f28468ba..8581ebece 100644 --- a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts @@ -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 }); + }); }); diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts index 288048d62..9c80e4df9 100644 --- a/src/app/api/admin/notifications/diagnostic/route.ts +++ b/src/app/api/admin/notifications/diagnostic/route.ts @@ -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: { diff --git a/src/app/api/admin/notifications/health/__tests__/route.test.ts b/src/app/api/admin/notifications/health/__tests__/route.test.ts index 319776185..a431abc2d 100644 --- a/src/app/api/admin/notifications/health/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/health/__tests__/route.test.ts @@ -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; diff --git a/src/app/api/admin/notifications/health/route.ts b/src/app/api/admin/notifications/health/route.ts index a4fc3825a..47460a9f5 100644 --- a/src/app/api/admin/notifications/health/route.ts +++ b/src/app/api/admin/notifications/health/route.ts @@ -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 diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts new file mode 100644 index 000000000..b212c4a2c --- /dev/null +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -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 }) => { + 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(); + 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, + ) => 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 { + 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); + }); +}); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 5680f6e03..ff64ab72d 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -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[]) { // 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[]) { 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[]) { } } - // 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[]) { `${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", diff --git a/src/lib/notifications/reminder-dedup.ts b/src/lib/notifications/reminder-dedup.ts new file mode 100644 index 000000000..014ee3512 --- /dev/null +++ b/src/lib/notifications/reminder-dedup.ts @@ -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 { + 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 { + 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. + } +}