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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 11:09:09 +02:00
1 parent 8eb74e6c76
commit 8956bd22af
12 files changed
+1022 -19

No files matched your search

@@ -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 $$;
+55
View File
@@ -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")
}
@@ -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);
});
});
@@ -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,
+63
View File
@@ -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<PushAttemptCleanupPayload>[],
) {
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<MoodReminderPayload>[]) {
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<PushAttemptCleanupPayload>(
PUSH_ATTEMPT_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handlePushAttemptCleanup,
);
await boss.work<PrDetectionPayload>(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },
@@ -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({}),
},
},
}));
@@ -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<string, string | undefined> = {}): 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<string, unknown> };
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<void> {
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<typeof vi.spyOn> | 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});
+39 -2
View File
@@ -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<SendOutcome> {
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,
};
}
+21
View File
@@ -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,
@@ -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}`,
);
});
}
+14
View File
@@ -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,
+42
View File
@@ -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,