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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +41,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
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) {
|
async function load(p: number) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -80,6 +98,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
|||||||
<TableHead>HTTP</TableHead>
|
<TableHead>HTTP</TableHead>
|
||||||
<TableHead>Attempt</TableHead>
|
<TableHead>Attempt</TableHead>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead className="w-16 text-right">Replay</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -96,6 +115,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
|||||||
? new Date(d.deliveredAt).toLocaleString()
|
? new Date(d.deliveredAt).toLocaleString()
|
||||||
: new Date(d.createdAt).toLocaleString()}
|
: new Date(d.createdAt).toLocaleString()}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -269,6 +269,78 @@ export async function listDeliveries(
|
|||||||
return { data, total };
|
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 ────────────────────────────────────────────────────────
|
// ─── Send Test Webhook ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendTestWebhook(portId: string, webhookId: string, eventType: WebhookEvent) {
|
export async function sendTestWebhook(portId: string, webhookId: string, eventType: WebhookEvent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user