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

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