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:
Matt Ciaccio
2026-05-06 19:31:34 +02:00
parent 7274baf1e1
commit 44db579988
3 changed files with 137 additions and 0 deletions

View File

@@ -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>