diff --git a/src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts b/src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts new file mode 100644 index 0000000..2d0c989 --- /dev/null +++ b/src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { redeliverWebhookDelivery } from '@/lib/services/webhooks.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +/** + * Admin replay for a previously failed/dead-letter webhook delivery. + * Spawns a fresh `pending` row + enqueues a new BullMQ job so the + * original delivery's failure response is preserved for audit while + * the replay flows through the standard worker (HMAC-signed, SSRF + * gated, dead-lettered after max retries). + */ +export const POST = withAuth( + withPermission('admin', 'manage_webhooks', async (_req, ctx, params) => { + try { + const { webhookId, deliveryId } = params; + if (!webhookId || !deliveryId) throw new NotFoundError('Delivery'); + const result = await redeliverWebhookDelivery(ctx.portId, webhookId, deliveryId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/webhooks/webhook-delivery-log.tsx b/src/components/admin/webhooks/webhook-delivery-log.tsx index 3eef9df..2266ad7 100644 --- a/src/components/admin/webhooks/webhook-delivery-log.tsx +++ b/src/components/admin/webhooks/webhook-delivery-log.tsx @@ -1,6 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -39,6 +41,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) { const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); + const [retrying, setRetrying] = useState(null); + + async function retry(deliveryId: string) { + setRetrying(deliveryId); + try { + await apiFetch(`/api/v1/admin/webhooks/${webhookId}/deliveries/${deliveryId}/redeliver`, { + method: 'POST', + }); + toast.success('Replay queued'); + await load(page); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Replay failed'); + } finally { + setRetrying(null); + } + } async function load(p: number) { setLoading(true); @@ -80,6 +98,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) { HTTP Attempt Time + Replay @@ -96,6 +115,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) { ? new Date(d.deliveredAt).toLocaleString() : new Date(d.createdAt).toLocaleString()} + + {(d.status === 'failed' || d.status === 'dead_letter') && ( + + )} + ))} diff --git a/src/lib/services/webhooks.service.ts b/src/lib/services/webhooks.service.ts index 0d122e1..d2cb07a 100644 --- a/src/lib/services/webhooks.service.ts +++ b/src/lib/services/webhooks.service.ts @@ -269,6 +269,78 @@ export async function listDeliveries( return { data, total }; } +// ─── Redeliver a previously failed / dead-letter delivery ─────────────────── + +/** + * Clones a failed or dead-letter delivery into a fresh `pending` row and + * re-enqueues it. Multi-tenant safe: looks up the source delivery via + * its webhook → port. Idempotency: each redeliver creates a new row so + * the original record (and its failure response body) is preserved for + * audit. The new delivery's payload includes a `retried_from` marker + * that downstream receivers can use to recognise replays. + */ +export async function redeliverWebhookDelivery( + portId: string, + webhookId: string, + deliveryId: string, + meta: AuditMeta, +) { + const webhook = await db.query.webhooks.findFirst({ + where: eq(webhooks.id, webhookId), + }); + if (!webhook || webhook.portId !== portId) { + throw new NotFoundError('Webhook'); + } + if (!webhook.isActive) { + throw new NotFoundError('Webhook is inactive'); + } + + const [source] = await db + .select() + .from(webhookDeliveries) + .where(and(eq(webhookDeliveries.id, deliveryId), eq(webhookDeliveries.webhookId, webhookId))) + .limit(1); + if (!source) throw new NotFoundError('Delivery'); + + const replayPayload = { + ...(source.payload as Record), + retried_from: deliveryId, + retried_at: new Date().toISOString(), + }; + + const [next] = await db + .insert(webhookDeliveries) + .values({ + webhookId, + eventType: source.eventType, + payload: replayPayload, + status: 'pending', + }) + .returning(); + + const queue = getQueue('webhooks'); + await queue.add('deliver', { + webhookId, + portId, + event: source.eventType, + deliveryId: next!.id, + payload: replayPayload, + }); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'send', + entityType: 'webhook_delivery', + entityId: next!.id, + metadata: { redeliveredFrom: deliveryId, originalStatus: source.status }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + return { deliveryId: next!.id, status: 'queued' }; +} + // ─── Send Test Webhook ──────────────────────────────────────────────────────── export async function sendTestWebhook(portId: string, webhookId: string, eventType: WebhookEvent) {