From 0e943019c7771522bef6b08c364580b2ab7c3088 Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem Date: Thu, 27 Aug 2026 19:07:37 +0100 Subject: [PATCH] feat: persist webhook retry state --- .../migration.sql | 8 ++++ prisma/schema.prisma | 40 +++++++++++++++++++ src/services/webhook.ts | 13 ++++++ 3 files changed, 61 insertions(+) create mode 100644 prisma/migrations/20260827130000_add_webhook_retry_state/migration.sql diff --git a/prisma/migrations/20260827130000_add_webhook_retry_state/migration.sql b/prisma/migrations/20260827130000_add_webhook_retry_state/migration.sql new file mode 100644 index 0000000..3b12279 --- /dev/null +++ b/prisma/migrations/20260827130000_add_webhook_retry_state/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "WebhookDelivery" + ADD COLUMN IF NOT EXISTS "attemptCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "nextRetryAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS "lastError" TEXT; + +CREATE INDEX IF NOT EXISTS "WebhookDelivery_status_nextRetryAt_idx" + ON "WebhookDelivery"("status", "nextRetryAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a68eeba..ad8f01c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { approvals TreasuryApproval[] auditLogs AuditLog[] idempotencyKeys IdempotencyKey[] + webhooks Webhook[] @@map("users") } @@ -52,6 +53,7 @@ model Group { invites Invite[] invitations Invitation[] auditLogs AuditLog[] + webhooks Webhook[] @@index([createdByUserId]) @@map("groups") @@ -73,6 +75,44 @@ model GroupMember { @@map("group_members") } +model Webhook { + id String @id @default(cuid()) + groupId String? @map("groupId") + userId String? @map("userId") + url String + secret String + events String[] + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deliveries WebhookDelivery[] + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([groupId]) + @@index([userId]) + @@map("Webhook") +} + +model WebhookDelivery { + id String @id @default(cuid()) + webhookId String + eventType String + payload Json + responseStatusCode Int? + responseBody String? + success Boolean @default(false) + attempts Int @default(0) + attemptCount Int @default(0) + nextRetryAt DateTime? + status String @default("pending") + lastError String? + createdAt DateTime @default(now()) + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + @@index([webhookId, createdAt]) + @@index([status, nextRetryAt]) + @@map("WebhookDelivery") +} + model Expense { id String @id @default(cuid()) groupId String @map("group_id") diff --git a/src/services/webhook.ts b/src/services/webhook.ts index f0924c8..bd16d79 100644 --- a/src/services/webhook.ts +++ b/src/services/webhook.ts @@ -50,6 +50,10 @@ async function deliver( responseBody: null, success: false, attempts: 0, + attemptCount: 0, + nextRetryAt: new Date(), + status: "pending", + lastError: null, }, }); @@ -89,6 +93,10 @@ async function deliver( responseBody: lastResponseBody, success: true, attempts: attempt, + attemptCount: attempt, + nextRetryAt: null, + status: "delivered", + lastError: null, }, }); return; @@ -101,6 +109,7 @@ async function deliver( clearTimeout(timeout); } + const exhausted = attempt === MAX_ATTEMPTS; await (prisma as any).webhookDelivery.update({ where: { id: delivery.id }, data: { @@ -108,6 +117,10 @@ async function deliver( responseBody: lastResponseBody, success: false, attempts: attempt, + attemptCount: attempt, + nextRetryAt: exhausted ? null : new Date(Date.now() + RETRY_DELAYS_MS[attempt - 1]), + status: exhausted ? "failed" : "pending", + lastError: lastResponseBody, }, }); }