mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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 $$;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user