feat(webhooks): admin replay for failed/dead-letter deliveries
Outbound webhook deliveries already retry with backoff, dead-letter after maxAttempts, and notify super admins. This adds operator-level replay: a per-row button on the deliveries log spawns a fresh pending delivery + queues a new BullMQ job. The original failed row stays intact so the response body remains for audit; the replay payload carries retried_from/retried_at markers so receivers can deduplicate. Inbound idempotency was already handled via the documentEvents signatureHash unique index. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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<string | null>(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) {
|
||||
<TableHead>HTTP</TableHead>
|
||||
<TableHead>Attempt</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead className="w-16 text-right">Replay</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -96,6 +115,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
||||
? new Date(d.deliveredAt).toLocaleString()
|
||||
: new Date(d.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{(d.status === 'failed' || d.status === 'dead_letter') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={retrying === d.id}
|
||||
onClick={() => retry(d.id)}
|
||||
title="Replay this delivery"
|
||||
aria-label="Replay this delivery"
|
||||
>
|
||||
<RotateCcw
|
||||
className={`h-3.5 w-3.5 ${retrying === d.id ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user