From 8956bd22afdc548000f77b299d6d202973c7a7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 23 May 2026 11:06:09 +0200 Subject: [PATCH] feat(notifications): persist push attempts in push_attempts table The /api/admin/notifications/diagnostic endpoint (v1.4.48) carried a recentPushAttempts shape but returned an empty array because no persistent table backed the field. This release adds the table, wires fire-and-forget writes from every sender (APNS, web-push, Telegram, NTFY), and swaps the diagnostic endpoint's stub for a real query that returns the last 20 attempts ordered by recency. A daily prune at 03:35 Europe/Berlin retires rows older than 90 days; retention matches the mood-reminder dispatch ledger so both behavioural footprints follow the same one-billing-cycle window. --- .../0080_v1449_push_attempts/migration.sql | 76 +++ prisma/schema.prisma | 55 ++ .../diagnostic/__tests__/route.test.ts | 71 ++- .../admin/notifications/diagnostic/route.ts | 52 +- src/lib/jobs/reminder-worker.ts | 63 ++ .../senders/__tests__/apns.test.ts | 7 + .../__tests__/push-attempt-record.test.ts | 537 ++++++++++++++++++ src/lib/notifications/senders/apns.ts | 41 +- src/lib/notifications/senders/ntfy.ts | 21 + .../senders/push-attempt-record.ts | 62 ++ src/lib/notifications/senders/telegram.ts | 14 + src/lib/notifications/senders/web-push.ts | 42 ++ 12 files changed, 1022 insertions(+), 19 deletions(-) create mode 100644 prisma/migrations/0080_v1449_push_attempts/migration.sql create mode 100644 src/lib/notifications/senders/__tests__/push-attempt-record.test.ts create mode 100644 src/lib/notifications/senders/push-attempt-record.ts diff --git a/prisma/migrations/0080_v1449_push_attempts/migration.sql b/prisma/migrations/0080_v1449_push_attempts/migration.sql new file mode 100644 index 000000000..d91e9bd5e --- /dev/null +++ b/prisma/migrations/0080_v1449_push_attempts/migration.sql @@ -0,0 +1,76 @@ +-- v1.4.49 — per-attempt push-delivery ledger. +-- +-- The /api/admin/notifications/diagnostic endpoint shipped in +-- v1.4.48 carries a `recentPushAttempts` shape but returned an empty +-- array because no persistent table backed the field. This migration +-- adds the table; the matching app-side changes wire fire-and-forget +-- writes from every sender (APNS, web-push, Telegram, NTFY) and swap +-- the diagnostic stub for a real query. +-- +-- Schema design: +-- * `id` — cuid surrogate key. There is no natural deduplication +-- key — the same (user, channel, event-type) tuple can fire +-- multiple times legitimately (e.g. two MEDICATION_REMINDERs in +-- a day on different schedules). +-- * `channel` — dispatcher `ChannelType` string ("APNS" | +-- "WEB_PUSH" | "TELEGRAM" | "NTFY"). +-- * `event_type` — dispatcher payload `eventType` string. Stored +-- as plain text so future event-type additions +-- never require a follow-up migration. +-- * `result` — "ok" | "error" | "skipped". `skipped` covers +-- the soft "no recipient" / "config missing" +-- cases the sender contracts surface today. +-- * `reason` — sender-side reason on error/skip results, +-- null on `ok`. For APNS this is Apple's reason +-- code (`BadDeviceToken`, `Unregistered`, …). +-- * `created_at` — DEFAULT now(); the diagnostic endpoint orders +-- by this column DESC + maps to the response +-- shape's `at` ISO string. +-- +-- Index strategy: `(user_id, created_at DESC)` matches the only +-- documented read path — the diagnostic endpoint pulls the trailing +-- 20 rows per user ordered by recency. The PRIMARY KEY on `id` is +-- never queried by the application; it's there to give the row a +-- stable identity for the retention sweep's WHERE clause. +-- +-- Retention: a daily pg-boss schedule (`push-attempt-cleanup` queue, +-- cron `35 3 * * *` Europe/Berlin) deletes rows older than 90 days. +-- The cutoff lines up with the mood-reminder dispatch ledger window +-- (v1.4.38.2) — both surfaces are behavioural footprints we keep +-- long enough to debug a duplicate-push report (~one billing cycle) +-- but no longer. See `handlePushAttemptCleanup` in +-- `src/lib/jobs/reminder-worker.ts`. +-- +-- Idempotent guards (`IF NOT EXISTS` on the table + index, plus the +-- `DO $$ … duplicate_object` block on the FK) match the pattern used +-- by 0070 / 0071 / 0072 / 0074 / 0078 so reruns are safe in +-- environments where the migration has already been applied. +-- +-- Reversibility: down migration is `DROP TABLE IF EXISTS +-- "push_attempts" CASCADE;`. The data is purely diagnostic — no +-- reader outside the admin endpoint queries it — so a roll-back +-- only loses observability, never user-visible state. + +CREATE TABLE IF NOT EXISTS "push_attempts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "result" TEXT NOT NULL, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "push_attempts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "push_attempts_user_id_created_at_desc_idx" + ON "push_attempts" ("user_id", "created_at" DESC); + +DO $$ BEGIN + ALTER TABLE "push_attempts" + ADD CONSTRAINT "push_attempts_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a0033e80..c40f16d52 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -326,6 +326,13 @@ model User { // burden. Append-only — a user revoking + re-granting consent mints // a fresh row so the timeline reads chronologically. consentReceipts ConsentReceipt[] + // v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS, + // WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per + // dispatch so the admin diagnostic endpoint can return the trailing + // 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin + // retires rows older than 90 days (see `handlePushAttemptCleanup` in + // `reminder-worker.ts`). + pushAttempts PushAttempt[] @@map("users") } @@ -2023,3 +2030,51 @@ model ConsentReceipt { @@index([userId, createdAt(sort: Desc)]) @@map("consent_receipts") } + +// ─── Push attempt ledger (v1.4.49) ─────────────────────────── +// +// Per-attempt push-delivery history. Every sender on the dispatcher +// cascade (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget +// row per dispatch so the admin diagnostic endpoint can surface the +// trailing 20 attempts per user without needing DB shell access. +// +// The write is best-effort: a DB hiccup on the insert must not break +// the actual push delivery, so every sender wraps the `create()` in a +// `void … .catch(() => {})` block. There is no retry — a single +// missing row is acceptable. +// +// Retention: a daily cron at 03:35 Europe/Berlin (between the +// audit-log cleanup at 03:15 and the mood-reminder cleanup at 03:25) +// deletes rows older than 90 days. See `handlePushAttemptCleanup` in +// `src/lib/jobs/reminder-worker.ts`. +// +// `eventType` mirrors the dispatcher's `NotificationPayload.eventType` +// string (`MEDICATION_REMINDER`, `MOOD_REMINDER`, `PERSONAL_RECORD`, +// `MEASUREMENT_ANOMALY`, `SYSTEM_ALERT`, …); stored as plain text so +// future event-type additions never require a schema migration. +// +// `channel` is the dispatcher `ChannelType` string (`APNS`, +// `WEB_PUSH`, `TELEGRAM`, `NTFY`). +// +// `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped` +// covers the "no recipient" / "config missing" soft cases. +// +// `reason` is the failure reason on `error` or `skipped` results, and +// null on `ok`. For APNS this is Apple's reason code +// (`BadDeviceToken`, `Unregistered`, …); for Web Push it carries the +// classified `web_push_*` reason; Telegram / NTFY likewise mirror +// their sender-side `SendOutcome.reason`. +model PushAttempt { + id String @id @default(cuid()) + userId String @map("user_id") + channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" + eventType String @map("event_type") // dispatcher event-type string + result String // "ok" | "error" | "skipped" + reason String? // sender-side reason on error/skip; null on ok + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt(sort: Desc)]) + @@map("push_attempts") +} 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 3f91d3b52..05a6d5eee 100644 --- a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts @@ -25,6 +25,12 @@ vi.mock("@/lib/db", () => ({ notificationChannel: { findMany: vi.fn(), }, + // v1.4.49 — backing query for `recentPushAttempts`. The route + // pulls the trailing 20 rows ordered by `createdAt` DESC and maps + // the `createdAt` Date to an `at` ISO string for the response. + pushAttempt: { + findMany: vi.fn(), + }, }, })); @@ -106,6 +112,7 @@ beforeEach(() => { vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never); }); describe("GET /api/admin/notifications/diagnostic — auth gates", () => { @@ -319,8 +326,8 @@ describe("GET /api/admin/notifications/diagnostic — channel configPresent", () }); }); -describe("GET /api/admin/notifications/diagnostic — recentPushAttempts placeholder", () => { - it("returns recentPushAttempts as an empty array (no per-attempt push history table yet)", async () => { +describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => { + it("returns recentPushAttempts as an empty array when the user has no rows", async () => { vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); const res = await GET(); @@ -328,4 +335,64 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts placeho expect(body.data.recentPushAttempts).toEqual([]); }); + + it("maps push_attempts rows to the documented response shape (createdAt → at ISO string)", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + // The route's findMany select returns `createdAt` as a Date; the + // mapping step normalises to an ISO string under `at`. + vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([ + { + eventType: "MEDICATION_REMINDER", + channel: "APNS", + result: "ok", + reason: null, + createdAt: new Date("2026-05-22T09:00:00.000Z"), + }, + { + eventType: "MOOD_REMINDER", + channel: "TELEGRAM", + result: "error", + reason: "telegram_blocked_by_user", + createdAt: new Date("2026-05-22T08:30:00.000Z"), + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(res.status).toBe(200); + expect(body.data.recentPushAttempts).toEqual([ + { + eventType: "MEDICATION_REMINDER", + channel: "APNS", + result: "ok", + reason: null, + at: "2026-05-22T09:00:00.000Z", + }, + { + eventType: "MOOD_REMINDER", + channel: "TELEGRAM", + result: "error", + reason: "telegram_blocked_by_user", + at: "2026-05-22T08:30:00.000Z", + }, + ]); + }); + + it("scopes the push-attempt query to the calling admin's userId, ordered desc by recency, capped at 20", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never); + + await GET(); + + expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as { + where: { userId: string }; + orderBy: { createdAt: "desc" }; + take: number; + }; + expect(args.where.userId).toBe(ADMIN_USER_ID); + expect(args.orderBy).toEqual({ createdAt: "desc" }); + expect(args.take).toBe(20); + }); }); diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts index 10d95bf07..d7f68dec2 100644 --- a/src/app/api/admin/notifications/diagnostic/route.ts +++ b/src/app/api/admin/notifications/diagnostic/route.ts @@ -25,12 +25,15 @@ export const dynamic = "force-dynamic"; * the channel-specific config * blob is present. * recentPushAttempts: PushAttempt[] — last 20 push deliveries for - * the calling user. See - * comment below — HealthLog - * does not currently persist - * per-attempt push history, so - * this is `[]` until a - * dedicated table is added. + * the calling user, ordered by + * recency. Backed by the + * `push_attempts` table — every + * sender (APNS, WEB_PUSH, + * TELEGRAM, NTFY) writes one + * fire-and-forget row per + * dispatch. v1.4.49 closed the + * empty-array stub by adding + * the table + the write path. * } * * Security: admin-only (cookie auth, never Bearer). The route never @@ -141,7 +144,7 @@ export const GET = apiHandler(async () => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.notifications.diagnostic" } }); - const [devices, channels] = await Promise.all([ + const [devices, channels, pushAttempts] = await Promise.all([ prisma.device.findMany({ where: { userId: user.id }, select: { @@ -157,6 +160,22 @@ export const GET = apiHandler(async () => { where: { userId: user.id }, select: { type: true, enabled: true, config: true }, }), + // v1.4.49 — trailing 20 push attempts for the calling admin's own + // account. The `(user_id, created_at DESC)` index on `push_attempts` + // 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 }, + orderBy: { createdAt: "desc" }, + take: 20, + select: { + eventType: true, + channel: true, + result: true, + reason: true, + createdAt: true, + }, + }), ]); const diagnosticDevices: DiagnosticDevice[] = devices.map((d) => { @@ -178,14 +197,17 @@ export const GET = apiHandler(async () => { configPresent: isChannelConfigPresent(c.type, c.config), })); - // HealthLog does not currently persist per-attempt push delivery - // history (no `PushAttempt` / equivalent table — Wide Events emit to - // stdout via `emitIfSampled` and audit-log captures only - // channel-state transitions, not individual sends). Surfacing the - // last 20 attempts is a known v1.4.48+ follow-up; until then this - // field is intentionally an empty array so the response shape stays - // stable for iOS-side consumers. - const recentPushAttempts: DiagnosticPushAttempt[] = []; + // v1.4.49 — backed by the `push_attempts` table. Map `createdAt` + // → `at` ISO string so the response shape matches the + // `DiagnosticPushAttempt` interface above (the existing consumer + // contract uses `at`, not `createdAt`). + const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({ + eventType: a.eventType, + channel: a.channel, + result: a.result, + reason: a.reason, + at: a.createdAt.toISOString(), + })); const payload: DiagnosticPayload = { devices: diagnosticDevices, diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index a5b867869..e61b6d35d 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -233,6 +233,15 @@ const MOOD_REMINDER_CRON = "*/15 * * * *"; const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup"; const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *"; const MOOD_REMINDER_RETENTION_DAYS = 90; +// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day +// retention as the mood-reminder dispatch ledger; both surfaces are +// behavioural footprints we keep long enough to debug a duplicate-push +// report (~one billing cycle) but no longer. Slots at 03:35 between +// mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the +// 03:xx maintenance window stays ordered. +const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup"; +const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *"; +const PUSH_ATTEMPT_RETENTION_DAYS = 90; // 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 @@ -1191,6 +1200,45 @@ async function handleMoodReminderCleanup( }); } +/** + * v1.4.49 — daily prune for the per-attempt push-delivery ledger. + * + * Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one + * fire-and-forget row to `push_attempts` per dispatch. The admin + * diagnostic endpoint only ever reads the trailing 20 rows per user, + * so anything older than the 90-day retention window is dead weight + * inflating the table and the `(user_id, created_at DESC)` index. + * + * The DELETE is unbounded by user — the index covers `created_at` + * directly, so a `WHERE created_at < cutoff` scan is bounded by the + * size of the trailing-edge of the table rather than the live working + * set. On a one-million-row table with the documented retention + * window, the daily prune touches ~11k rows (1M / 90d × 1d) and + * completes in milliseconds. + */ +interface PushAttemptCleanupPayload { + triggeredAt: string; +} + +async function handlePushAttemptCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.push_attempt_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS); + const deleted = await p.pushAttempt.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + evt.addMeta("push_attempt_cleanup_deleted", deleted.count); + } catch (err) { + evt.addWarning(`push-attempt-cleanup failed: ${err}`); + } + }); +} + async function handleMoodReminderCheck(jobs: Job[]) { void jobs; await withBackgroundEvent("job.mood_reminder", async (evt) => { @@ -1747,6 +1795,10 @@ export async function startReminderWorker() { // dispatcher never fires. MOOD_REMINDER_QUEUE, MOOD_REMINDER_CLEANUP_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. + PUSH_ATTEMPT_CLEANUP_QUEUE, ]; for (const q of allQueues) { @@ -1829,6 +1881,8 @@ export async function startReminderWorker() { // per tick for the entire opted-in cohort. [MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON], [MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON], + // v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts. + [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], ]; for (const [name, cron] of schedules) { @@ -1950,6 +2004,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleMoodReminderCleanup, ); + // 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 + // would be a no-op anyway. + await boss.work( + PUSH_ATTEMPT_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handlePushAttemptCleanup, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index e244463fe..f41fda74d 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -57,6 +57,13 @@ vi.mock("@/lib/db", () => ({ deleteMany: vi.fn(), update: vi.fn().mockResolvedValue({}), }, + // v1.4.49 — sendViaApns now writes a fire-and-forget row to + // push_attempts on every exit path. The mock returns a resolved + // promise so the swallowed-error helper has nothing to swallow, + // keeping these legacy tests focused on the dispatcher contract. + pushAttempt: { + create: vi.fn().mockResolvedValue({}), + }, }, })); diff --git a/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts b/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts new file mode 100644 index 000000000..b89eef0b7 --- /dev/null +++ b/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts @@ -0,0 +1,537 @@ +/** + * v1.4.49 — fire-and-forget push-attempt ledger writes across the + * four senders (APNS, WEB_PUSH, TELEGRAM, NTFY). + * + * Pins: + * 1. Every sender calls `prisma.pushAttempt.create` with the + * documented row shape (userId / channel / eventType / result / + * reason) on its primary exit paths. + * 2. The write is fire-and-forget: a DB error from + * `pushAttempt.create` MUST NOT surface to the caller. The + * sender's `SendOutcome` carries on as if the ledger write had + * succeeded so the actual push delivery semantics stay intact. + * 3. The ledger writes are scoped to the right channel discriminator + * ("APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY") — no accidental + * cross-channel mislabeling under the shared helper. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// vi.mock factories run before module top-level executes, so anything +// referenced inside a factory must be created via vi.hoisted to share +// the same hoisted scope. +const hoisted = vi.hoisted(() => ({ + apnsSendMock: vi.fn(), + apnsShutdownMock: vi.fn(), + webPushSetVapidDetailsMock: vi.fn(), + webPushSendNotificationMock: vi.fn(), + sendTelegramMessageMock: vi.fn(), + deleteTelegramMessageMock: vi.fn(), +})); + +vi.mock("@parse/node-apn", () => { + class Provider { + send(...args: unknown[]) { + return hoisted.apnsSendMock(...args); + } + shutdown() { + return hoisted.apnsShutdownMock(); + } + } + class Notification { + public topic = ""; + public alert: unknown; + public sound: string | undefined; + public badge: number | undefined; + public threadId: string | undefined; + public collapseId: string | undefined; + public payload: unknown; + public category: string | undefined; + public mutableContent: boolean | undefined; + public interruptionLevel: string | undefined; + public priority: number | undefined; + } + return { default: { Provider, Notification }, Provider, Notification }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + device: { + findMany: vi.fn(), + deleteMany: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + pushSubscription: { + findMany: vi.fn(), + deleteMany: vi.fn(), + }, + pushAttempt: { + create: vi.fn(), + }, + telegramReminderMessage: { + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc(${s})`, + decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addExternalCall: vi.fn(), + addWarning: vi.fn(), + setError: vi.fn(), + }), +})); + +vi.mock("@/lib/notifications/vapid-config", () => ({ + getVapidConfig: vi.fn().mockResolvedValue({ + subject: "mailto:test@healthlog.local", + publicKey: "vapid-public-key", + privateKey: "vapid-private-key", + }), +})); + +vi.mock("web-push", () => ({ + default: { + setVapidDetails: hoisted.webPushSetVapidDetailsMock, + sendNotification: hoisted.webPushSendNotificationMock, + }, + setVapidDetails: hoisted.webPushSetVapidDetailsMock, + sendNotification: hoisted.webPushSendNotificationMock, +})); + +vi.mock("@/lib/telegram", () => ({ + sendTelegramMessage: hoisted.sendTelegramMessageMock, + deleteMessage: hoisted.deleteTelegramMessageMock, +})); + +import { sendViaApns, resetApnsForTesting } from "@/lib/notifications/senders/apns"; +import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; +import { sendViaTelegram } from "@/lib/notifications/senders/telegram"; +import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; +import { prisma } from "@/lib/db"; + +const TEST_EC_PEM_LINES = [ + "-----BEGIN PRIVATE KEY-----", + "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN", + "pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU", + "1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ", + "bw81BG89", + "-----END PRIVATE KEY-----", +]; +const APNS_ENV = { + APNS_KEY_ID: "ABCDE12345", + APNS_TEAM_ID: "TEAM123456", + APNS_BUNDLE_ID: "test.healthlog.ios", + APNS_KEY: TEST_EC_PEM_LINES.join("\\n"), +}; + +function setApnsEnv(over: Record = {}): void { + for (const k of [ + "APNS_KEY_ID", + "APNS_TEAM_ID", + "APNS_BUNDLE_ID", + "APNS_KEY", + "APNS_KEY_B64", + "APNS_KEY_FILE", + "APNS_PRODUCTION", + ]) { + delete process.env[k]; + } + for (const [k, v] of Object.entries(over)) { + if (v !== undefined) process.env[k] = v; + } +} + +// Capture every `create` call without forcing the test to a specific +// argument order. Each test asserts the call it cares about by +// filtering on `channel`. +function pushAttemptCalls(): Array<{ + userId: string; + channel: string; + eventType: string; + result: string; + reason: string | null; +}> { + const create = vi.mocked(prisma.pushAttempt.create); + return create.mock.calls.map((c) => { + const args = c[0] as { data: Record }; + return args.data as never; + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.pushAttempt.create).mockResolvedValue({} as never); + vi.mocked(prisma.device.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.pushSubscription.deleteMany).mockResolvedValue({ + count: 0, + } as never); + resetApnsForTesting(); + setApnsEnv(APNS_ENV); +}); + +afterEach(() => { + setApnsEnv(); + resetApnsForTesting(); +}); + +// Helper — the recordPushAttempt helper is fire-and-forget. Tests +// resolve a microtask tick to give the swallowed promise time to land +// before assertions. +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("recordPushAttempt — APNS sender", () => { + it("writes one ok row when the dispatch succeeds", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValue([ + { + id: "dev-1", + apnsToken: "a".repeat(64), + apnsEnvironment: "sandbox", + }, + ] as never); + hoisted.apnsSendMock.mockResolvedValue({ + sent: [{ device: "a".repeat(64) }], + failed: [], + }); + + const outcome = await sendViaApns("user-1", { + title: "T", + message: "M", + eventType: "MEDICATION_REMINDER", + }); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + const apns = pushAttemptCalls().filter((c) => c.channel === "APNS"); + expect(apns).toHaveLength(1); + expect(apns[0]).toMatchObject({ + userId: "user-1", + channel: "APNS", + eventType: "MEDICATION_REMINDER", + result: "ok", + reason: null, + }); + }); + + it("writes one error row carrying Apple's reason on hard reject", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValue([ + { + id: "dev-1", + apnsToken: "a".repeat(64), + apnsEnvironment: "sandbox", + }, + ] as never); + hoisted.apnsSendMock.mockResolvedValue({ + sent: [], + failed: [{ response: { reason: "BadDeviceToken" }, status: "410" }], + }); + + await sendViaApns("user-2", { + title: "T", + message: "M", + eventType: "MEDICATION_REMINDER", + }); + await flushMicrotasks(); + + const apns = pushAttemptCalls().filter((c) => c.channel === "APNS"); + expect(apns).toHaveLength(1); + expect(apns[0]).toMatchObject({ + userId: "user-2", + channel: "APNS", + eventType: "MEDICATION_REMINDER", + result: "error", + reason: "BadDeviceToken", + }); + }); + + it("writes a skipped row when no APNs devices are paired", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValue([] as never); + + await sendViaApns("user-3", { + title: "T", + message: "M", + eventType: "MOOD_REMINDER", + }); + await flushMicrotasks(); + + const apns = pushAttemptCalls().filter((c) => c.channel === "APNS"); + expect(apns).toHaveLength(1); + expect(apns[0]).toMatchObject({ + channel: "APNS", + result: "skipped", + reason: "apns_no_devices", + }); + }); + + it("does NOT surface a ledger DB error to the caller", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValue([ + { + id: "dev-1", + apnsToken: "a".repeat(64), + apnsEnvironment: "sandbox", + }, + ] as never); + hoisted.apnsSendMock.mockResolvedValue({ + sent: [{ device: "a".repeat(64) }], + failed: [], + }); + // Simulate a DB hiccup on the ledger write. The sender contract + // says this must not propagate. + vi.mocked(prisma.pushAttempt.create).mockRejectedValue( + new Error("connection refused"), + ); + + const outcome = await sendViaApns("user-4", { + title: "T", + message: "M", + eventType: "MEDICATION_REMINDER", + }); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + }); +}); + +describe("recordPushAttempt — WEB_PUSH sender", () => { + it("writes one ok row when at least one subscription accepts the push", async () => { + vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([ + { + id: "sub-1", + endpoint: "https://example.test/push", + p256dh: "enc(p)", + auth: "enc(a)", + }, + ] as never); + hoisted.webPushSendNotificationMock.mockResolvedValue(undefined); + + const outcome = await sendViaWebPush("user-1", { + userId: "user-1", + title: "T", + message: "M", + eventType: "MEASUREMENT_ANOMALY", + }); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + const rows = pushAttemptCalls().filter((c) => c.channel === "WEB_PUSH"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + userId: "user-1", + channel: "WEB_PUSH", + eventType: "MEASUREMENT_ANOMALY", + result: "ok", + }); + }); + + it("writes a skipped row when the user has zero subscriptions", async () => { + vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([] as never); + + await sendViaWebPush("user-2", { + userId: "user-2", + title: "T", + message: "M", + eventType: "SYSTEM_ALERT", + }); + await flushMicrotasks(); + + const rows = pushAttemptCalls().filter((c) => c.channel === "WEB_PUSH"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + result: "skipped", + reason: "web_push_no_subscriptions", + }); + }); + + it("does NOT surface a ledger DB error to the caller", async () => { + vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([ + { + id: "sub-1", + endpoint: "https://example.test/push", + p256dh: "enc(p)", + auth: "enc(a)", + }, + ] as never); + hoisted.webPushSendNotificationMock.mockResolvedValue(undefined); + vi.mocked(prisma.pushAttempt.create).mockRejectedValue( + new Error("connection refused"), + ); + + const outcome = await sendViaWebPush("user-3", { + userId: "user-3", + title: "T", + message: "M", + eventType: "MEDICATION_REMINDER", + }); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + }); +}); + +describe("recordPushAttempt — TELEGRAM sender", () => { + it("writes one ok row carrying payload.userId when Telegram accepts the message", async () => { + hoisted.sendTelegramMessageMock.mockResolvedValue({ + ok: true, + messageId: 42, + }); + + const outcome = await sendViaTelegram( + { botToken: "bot:test", chatId: "chat-1" }, + { + userId: "user-tg-1", + title: "T", + message: "M", + eventType: "MEDICATION_REMINDER", + }, + ); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + const rows = pushAttemptCalls().filter((c) => c.channel === "TELEGRAM"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + userId: "user-tg-1", + channel: "TELEGRAM", + eventType: "MEDICATION_REMINDER", + result: "ok", + }); + }); + + it("writes one error row with the classified reason on Telegram failure", async () => { + hoisted.sendTelegramMessageMock.mockResolvedValue({ + ok: false, + errorDescription: "Forbidden: bot was blocked by the user", + }); + + await sendViaTelegram( + { botToken: "bot:test", chatId: "chat-1" }, + { + userId: "user-tg-2", + title: "T", + message: "M", + eventType: "MOOD_REMINDER", + }, + ); + await flushMicrotasks(); + + const rows = pushAttemptCalls().filter((c) => c.channel === "TELEGRAM"); + expect(rows).toHaveLength(1); + expect(rows[0].result).toBe("error"); + expect(rows[0].reason).toBeTruthy(); + }); + + it("does NOT surface a ledger DB error to the caller", async () => { + hoisted.sendTelegramMessageMock.mockResolvedValue({ + ok: true, + messageId: 99, + }); + vi.mocked(prisma.pushAttempt.create).mockRejectedValue( + new Error("connection refused"), + ); + + const outcome = await sendViaTelegram( + { botToken: "bot:test", chatId: "chat-1" }, + { + userId: "user-tg-3", + title: "T", + message: "M", + eventType: "SYSTEM_ALERT", + }, + ); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + }); +}); + +describe("recordPushAttempt — NTFY sender", () => { + let fetchSpy: ReturnType | undefined; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch") as never; + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it("writes one ok row when ntfy returns 200", async () => { + (fetchSpy as ReturnType).mockResolvedValue( + new Response("", { status: 200 }), + ); + + const outcome = await sendViaNtfy( + { serverUrl: "https://ntfy.test", topic: "hl" }, + { + userId: "user-ntfy-1", + title: "T", + message: "M", + eventType: "PERSONAL_RECORD", + }, + ); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + const rows = pushAttemptCalls().filter((c) => c.channel === "NTFY"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + userId: "user-ntfy-1", + channel: "NTFY", + eventType: "PERSONAL_RECORD", + result: "ok", + }); + }); + + it("writes one error row when ntfy returns 5xx", async () => { + (fetchSpy as ReturnType).mockResolvedValue( + new Response("", { status: 503 }), + ); + + await sendViaNtfy( + { serverUrl: "https://ntfy.test", topic: "hl" }, + { + userId: "user-ntfy-2", + title: "T", + message: "M", + eventType: "SYSTEM_ALERT", + }, + ); + await flushMicrotasks(); + + const rows = pushAttemptCalls().filter((c) => c.channel === "NTFY"); + expect(rows).toHaveLength(1); + expect(rows[0].result).toBe("error"); + }); + + it("does NOT surface a ledger DB error to the caller", async () => { + (fetchSpy as ReturnType).mockResolvedValue( + new Response("", { status: 200 }), + ); + vi.mocked(prisma.pushAttempt.create).mockRejectedValue( + new Error("connection refused"), + ); + + const outcome = await sendViaNtfy( + { serverUrl: "https://ntfy.test", topic: "hl" }, + { + userId: "user-ntfy-3", + title: "T", + message: "M", + eventType: "PERSONAL_RECORD", + }, + ); + await flushMicrotasks(); + + expect(outcome.ok).toBe(true); + }); +}); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index e705d0341..fe17639a4 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -29,6 +29,7 @@ import apn from "@parse/node-apn"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; export interface ApnsPayload { /** APNs `aps.alert.title` + `aps.alert.body`. */ @@ -526,6 +527,13 @@ export async function sendViaApns( ): Promise { const config = loadApnsConfig(); if (!config) { + recordPushAttempt({ + userId, + channel: "APNS", + eventType: payload.eventType, + result: "skipped", + reason: "apns_not_configured", + }); return { ok: false, hardReject: false, @@ -548,6 +556,13 @@ export async function sendViaApns( if (devices.length === 0) { // Same rationale as web-push: a user who hasn't paired an iPhone yet // shouldn't see the channel auto-disabled. Treat as soft "no recipient". + recordPushAttempt({ + userId, + channel: "APNS", + eventType: payload.eventType, + result: "skipped", + reason: "apns_no_devices", + }); return { ok: false, hardReject: false, @@ -667,25 +682,47 @@ export async function sendViaApns( } if (anySuccess) { + recordPushAttempt({ + userId, + channel: "APNS", + eventType: payload.eventType, + result: "ok", + }); return { ok: true }; } // Every surviving device returned a permanent reason → channel is dead // until the user re-registers from a fresh iOS install. if (deadDeviceIds.length === devices.length) { + const reason = lastFailureReason ?? "apns_all_devices_unregistered"; + recordPushAttempt({ + userId, + channel: "APNS", + eventType: payload.eventType, + result: "error", + reason, + }); return { ok: false, hardReject: true, statusCode: lastFailureStatus, - reason: lastFailureReason ?? "apns_all_devices_unregistered", + reason, }; } + const reason = lastFailureReason ?? "apns_send_failed"; + recordPushAttempt({ + userId, + channel: "APNS", + eventType: payload.eventType, + result: "error", + reason, + }); return { ok: false, hardReject: false, statusCode: lastFailureStatus, - reason: lastFailureReason ?? "apns_send_failed", + reason, }; } diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 126c89fe2..79927d94a 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -5,6 +5,7 @@ import type { import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; /** * Send notification via ntfy (simple HTTP POST). @@ -52,9 +53,22 @@ export async function sendViaNtfy( }); if (res.ok) { + recordPushAttempt({ + userId: payload.userId, + channel: "NTFY", + eventType: payload.eventType, + result: "ok", + }); return { ok: true, statusCode: res.status }; } const classified = classifyHttpStatus(res.status, "ntfy"); + recordPushAttempt({ + userId: payload.userId, + channel: "NTFY", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); return { ok: false, statusCode: res.status, @@ -69,6 +83,13 @@ export async function sendViaNtfy( duration_ms: Math.round(performance.now() - start), error: message, }); + recordPushAttempt({ + userId: payload.userId, + channel: "NTFY", + eventType: payload.eventType, + result: "error", + reason: "ntfy_network_error", + }); return { ok: false, hardReject: false, diff --git a/src/lib/notifications/senders/push-attempt-record.ts b/src/lib/notifications/senders/push-attempt-record.ts new file mode 100644 index 000000000..f829eca9a --- /dev/null +++ b/src/lib/notifications/senders/push-attempt-record.ts @@ -0,0 +1,62 @@ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +/** + * v1.4.49 — fire-and-forget push-attempt ledger write. + * + * Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) calls this helper + * once per dispatch so the admin diagnostic endpoint + * (`/api/admin/notifications/diagnostic`) can surface the trailing + * 20 attempts per user without DB shell access. + * + * Contract: + * * NEVER throws. A DB hiccup on the insert must not break the + * actual push delivery, so we swallow the error and emit a + * wide-event warning the operator can correlate with the + * primary sender's outcome. + * * Single insert per call. No retry, no batching — a missing row + * is a strictly worse outcome than a duplicate one, but we + * accept it rather than amplify a DB blip into a retry storm. + * * Synchronous return is `void`. Callers must use + * `void recordPushAttempt(...)` so the sender's hot path doesn't + * await the ledger write. The pg connection pool is shared with + * the dispatcher's read queries; awaiting here would block the + * next channel in the cascade for no operational benefit. + * + * Result discriminator: + * * `"ok"` — the sender returned a successful `SendOutcome`. + * * `"error"` — the sender returned `ok: false` with a + * classified failure (hard or soft reject). + * * `"skipped"` — soft "no recipient" / "config missing" cases + * the sender contracts surface without contacting + * the upstream provider. + */ +export interface PushAttemptRecord { + userId: string; + channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"; + eventType: string; + result: "ok" | "error" | "skipped"; + reason?: string | null; +} + +export function recordPushAttempt(record: PushAttemptRecord): void { + // The `void` here is the doubly-explicit form: we want neither the + // caller nor a linter to wait on this promise. The `.catch` traps + // a rejected promise without surfacing it to the caller. + void prisma.pushAttempt + .create({ + data: { + userId: record.userId, + channel: record.channel, + eventType: record.eventType, + result: record.result, + reason: record.reason ?? null, + }, + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : "unknown_error"; + getEvent()?.addWarning( + `push_attempt_ledger_write_failed channel=${record.channel} reason=${message}`, + ); + }); +} diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index bafdb7c6f..4cf6770ff 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -8,6 +8,7 @@ import { classifyTelegramError } from "@/lib/notifications/retry-policy"; import { prisma } from "@/lib/db"; import type { ReminderPhase } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; /** * Telegram sender result. Inherits from `SendOutcome` so the dispatcher @@ -154,9 +155,22 @@ export async function sendViaTelegram( } if (result.ok) { + recordPushAttempt({ + userId: payload.userId, + channel: "TELEGRAM", + eventType: payload.eventType, + result: "ok", + }); return { ok: true, messageId: result.messageId }; } const classified = classifyTelegramError(result.errorDescription); + recordPushAttempt({ + userId: payload.userId, + channel: "TELEGRAM", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); return { ok: false, hardReject: classified.hardReject, diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 45852c448..923248eb8 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -5,6 +5,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getVapidConfig } from "@/lib/notifications/vapid-config"; import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; /** * Send Web Push notification to all subscribed devices of a user. @@ -32,6 +33,13 @@ export async function sendViaWebPush( const config = await getVapidConfig(); if (!config) { getEvent()?.addWarning("Web Push: VAPID keys not configured"); + recordPushAttempt({ + userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "skipped", + reason: "web_push_vapid_not_configured", + }); return { ok: false, hardReject: false, @@ -54,6 +62,13 @@ export async function sendViaWebPush( // may simply not have hit "Subscribe" on this browser. Counting // this as a give-up signal would lock the user out of the channel // they just configured. Treat it as a soft "no recipient" instead. + recordPushAttempt({ + userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "skipped", + reason: "web_push_no_subscriptions", + }); return { ok: false, hardReject: false, @@ -119,12 +134,25 @@ export async function sendViaWebPush( } if (anySuccess) { + recordPushAttempt({ + userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "ok", + }); return { ok: true }; } // Every sub returned a permanent 410/404 → channel is dead until // the user re-pairs a device. This is a hard reject. if (allPermanentReject && expiredIds.length === subscriptions.length) { + recordPushAttempt({ + userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "error", + reason: "web_push_410_gone", + }); return { ok: false, hardReject: true, @@ -135,6 +163,13 @@ export async function sendViaWebPush( // Soft failure (5xx / 429 / fetch error) on at least one sub. const classified = classifyHttpStatus(lastTransientStatus, "web-push"); + recordPushAttempt({ + userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); return { ok: false, hardReject: false, @@ -147,6 +182,13 @@ export async function sendViaWebPush( ? err : new Error("[web-push] sendViaWebPush failed"), ); + recordPushAttempt({ + userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "error", + reason: "web_push_unexpected_error", + }); return { ok: false, hardReject: false,