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