@open-mercato/webhooks 0.6.4-develop.4210.1.d412061cfe → 0.6.4-develop.4236.1.9fa6806b34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js +62 -36
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.test.ts +116 -37
- package/src/modules/webhooks/subscribers/outbound-dispatch.ts +67 -38
|
@@ -15,6 +15,14 @@ function shouldSkipOutboundDispatch(eventId) {
|
|
|
15
15
|
const declaredEvent = getDeclaredEvents().find((event) => event.id === eventId);
|
|
16
16
|
return declaredEvent?.excludeFromTriggers === true;
|
|
17
17
|
}
|
|
18
|
+
function forkOutboundEntityManager(em) {
|
|
19
|
+
const fork = em.fork;
|
|
20
|
+
if (typeof fork !== "function") return em;
|
|
21
|
+
return fork.call(em, { clear: true, useContext: false });
|
|
22
|
+
}
|
|
23
|
+
function integrationScopeKey(tenantId, organizationId) {
|
|
24
|
+
return `${tenantId}:${organizationId}`;
|
|
25
|
+
}
|
|
18
26
|
async function handler(payload, ctx) {
|
|
19
27
|
const eventId = ctx.eventId ?? ctx.eventName ?? payload.eventId ?? payload.type;
|
|
20
28
|
if (!eventId) return;
|
|
@@ -44,45 +52,63 @@ async function handler(payload, ctx) {
|
|
|
44
52
|
(webhook) => matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents)
|
|
45
53
|
);
|
|
46
54
|
if (!matchingWebhooks.length) return;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const delivery = await createWebhookDelivery({
|
|
56
|
-
em,
|
|
57
|
-
webhook,
|
|
58
|
-
eventId,
|
|
59
|
-
payload
|
|
55
|
+
const integrationEnabledByScope = /* @__PURE__ */ new Map();
|
|
56
|
+
const resolveIntegrationEnabled = (webhook) => {
|
|
57
|
+
const key = integrationScopeKey(webhook.tenantId, webhook.organizationId);
|
|
58
|
+
let pending = integrationEnabledByScope.get(key);
|
|
59
|
+
if (!pending) {
|
|
60
|
+
pending = isWebhookIntegrationEnabled(em, {
|
|
61
|
+
tenantId: webhook.tenantId,
|
|
62
|
+
organizationId: webhook.organizationId
|
|
60
63
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
64
|
+
integrationEnabledByScope.set(key, pending);
|
|
65
|
+
}
|
|
66
|
+
return pending;
|
|
67
|
+
};
|
|
68
|
+
await Promise.all(
|
|
69
|
+
matchingWebhooks.map(async (webhook) => {
|
|
70
|
+
if (!await resolveIntegrationEnabled(webhook)) return;
|
|
71
|
+
const webhookEm = forkOutboundEntityManager(em);
|
|
72
|
+
let createdDeliveryId = null;
|
|
73
|
+
try {
|
|
74
|
+
const delivery = await createWebhookDelivery({
|
|
75
|
+
em: webhookEm,
|
|
76
|
+
webhook,
|
|
77
|
+
eventId,
|
|
78
|
+
payload
|
|
79
|
+
});
|
|
80
|
+
createdDeliveryId = delivery.id;
|
|
81
|
+
await enqueueWebhookDelivery({
|
|
82
|
+
deliveryId: delivery.id,
|
|
83
|
+
tenantId: delivery.tenantId,
|
|
84
|
+
organizationId: delivery.organizationId
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (createdDeliveryId) {
|
|
88
|
+
const failedDelivery = await findOneWithDecryption(
|
|
89
|
+
webhookEm,
|
|
90
|
+
WebhookDeliveryEntity,
|
|
91
|
+
{ id: createdDeliveryId, tenantId: webhook.tenantId, organizationId: webhook.organizationId },
|
|
92
|
+
void 0,
|
|
93
|
+
{ tenantId: webhook.tenantId, organizationId: webhook.organizationId }
|
|
94
|
+
);
|
|
95
|
+
if (failedDelivery) {
|
|
96
|
+
failedDelivery.status = "failed";
|
|
97
|
+
failedDelivery.errorMessage = error instanceof Error ? `Queue enqueue failed: ${error.message}` : "Queue enqueue failed";
|
|
98
|
+
failedDelivery.nextRetryAt = null;
|
|
99
|
+
await webhookEm.flush();
|
|
100
|
+
}
|
|
75
101
|
}
|
|
102
|
+
console.error("[webhooks] Failed to enqueue outbound delivery", {
|
|
103
|
+
webhookId: webhook.id,
|
|
104
|
+
eventId,
|
|
105
|
+
tenantId,
|
|
106
|
+
organizationId: organizationId ?? webhook.organizationId,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error)
|
|
108
|
+
});
|
|
76
109
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
eventId,
|
|
80
|
-
tenantId,
|
|
81
|
-
organizationId: organizationId ?? webhook.organizationId,
|
|
82
|
-
error: error instanceof Error ? error.message : String(error)
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
110
|
+
})
|
|
111
|
+
);
|
|
86
112
|
}
|
|
87
113
|
export {
|
|
88
114
|
handler as default,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/webhooks/subscribers/outbound-dispatch.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SubscriberContext } from '@open-mercato/events/types'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { matchAnyWebhookEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\nimport { createWebhookDelivery } from '../lib/delivery'\nimport { enqueueWebhookDelivery } from '../lib/queue'\nimport { isWebhookIntegrationEnabled } from '../lib/integration-state'\n\nexport const metadata = {\n event: '*',\n persistent: true,\n id: 'webhooks:outbound-dispatch',\n}\n\nfunction shouldSkipOutboundDispatch(eventId: string): boolean {\n if (eventId.startsWith('webhooks.') || eventId.startsWith('application.')) return true\n\n const declaredEvent = getDeclaredEvents().find((event) => event.id === eventId)\n return declaredEvent?.excludeFromTriggers === true\n}\n\nexport default async function handler(\n payload: Record<string, unknown>,\n ctx: (SubscriberContext & { eventId?: string }) | { container?: { resolve: <T = unknown>(name: string) => T }; eventId?: string; eventName?: string; resolve?: <T = unknown>(name: string) => T },\n) {\n const eventId = ctx.eventId ?? ctx.eventName ?? (payload.eventId as string) ?? (payload.type as string)\n if (!eventId) return\n if (shouldSkipOutboundDispatch(eventId)) return\n\n const tenantId = payload.tenantId as string | undefined\n const organizationId = payload.organizationId as string | undefined\n if (!tenantId) return\n\n if (eventId.startsWith('webhooks.')) return\n if (eventId.startsWith('query_index.')) return\n\n\n const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')\n ? ctx.resolve\n : ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')\n ? ctx.container.resolve.bind(ctx.container)\n : null\n\n if (!resolve) return\n\n const em = (resolve('em') as EntityManager).fork()\n\n const webhooks = await findWithDecryption(\n em,\n WebhookEntity,\n {\n isActive: true,\n deletedAt: null,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n },\n {},\n { tenantId, organizationId: organizationId ?? '' },\n )\n\n if (!webhooks.length) return\n\n const matchingWebhooks = webhooks.filter((webhook) =>\n matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents),\n )\n\n if (!matchingWebhooks.length) return\n\n
|
|
5
|
-
"mappings": "AAEA,SAAS,uBAAuB,qBAAqB;AACrD,SAAS,oBAAoB,6BAA6B;AAC1D,SAAS,mCAAmC;AAC5C,SAAS,yBAAyB;AAClC,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,mCAAmC;AAErC,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAEA,SAAS,2BAA2B,SAA0B;AAC5D,MAAI,QAAQ,WAAW,WAAW,KAAK,QAAQ,WAAW,cAAc,EAAG,QAAO;AAElF,QAAM,gBAAgB,kBAAkB,EAAE,KAAK,CAAC,UAAU,MAAM,OAAO,OAAO;AAC9E,SAAO,eAAe,wBAAwB;AAChD;AAEA,eAAO,QACL,SACA,KACA;AACA,QAAM,UAAU,IAAI,WAAW,IAAI,aAAc,QAAQ,WAAuB,QAAQ;AACxF,MAAI,CAAC,QAAS;AACd,MAAI,2BAA2B,OAAO,EAAG;AAEzC,QAAM,WAAW,QAAQ;AACzB,QAAM,iBAAiB,QAAQ;AAC/B,MAAI,CAAC,SAAU;AAEf,MAAI,QAAQ,WAAW,WAAW,EAAG;AACrC,MAAI,QAAQ,WAAW,cAAc,EAAG;AAGxC,QAAM,UAAW,aAAa,OAAO,OAAO,IAAI,YAAY,aACxD,IAAI,UACH,eAAe,OAAO,IAAI,aAAa,OAAO,IAAI,UAAU,YAAY,aACvE,IAAI,UAAU,QAAQ,KAAK,IAAI,SAAS,IACxC;AAEN,MAAI,CAAC,QAAS;AAEd,QAAM,KAAM,QAAQ,IAAI,EAAoB,KAAK;AAEjD,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,MACE,UAAU;AAAA,MACV,WAAW;AAAA,MACX;AAAA,MACA,GAAI,iBAAiB,EAAE,eAAe,IAAI,CAAC;AAAA,IAC7C;AAAA,IACA,CAAC;AAAA,IACD,EAAE,UAAU,gBAAgB,kBAAkB,GAAG;AAAA,EACnD;AAEA,MAAI,CAAC,SAAS,OAAQ;AAEtB,QAAM,mBAAmB,SAAS;AAAA,IAAO,CAAC,YACxC,4BAA4B,SAAS,QAAQ,gBAAgB;AAAA,EAC/D;AAEA,MAAI,CAAC,iBAAiB,OAAQ;AAE9B,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SubscriberContext } from '@open-mercato/events/types'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { matchAnyWebhookEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\nimport { createWebhookDelivery } from '../lib/delivery'\nimport { enqueueWebhookDelivery } from '../lib/queue'\nimport { isWebhookIntegrationEnabled } from '../lib/integration-state'\n\nexport const metadata = {\n event: '*',\n persistent: true,\n id: 'webhooks:outbound-dispatch',\n}\n\nfunction shouldSkipOutboundDispatch(eventId: string): boolean {\n if (eventId.startsWith('webhooks.') || eventId.startsWith('application.')) return true\n\n const declaredEvent = getDeclaredEvents().find((event) => event.id === eventId)\n return declaredEvent?.excludeFromTriggers === true\n}\n\nfunction forkOutboundEntityManager(em: EntityManager): EntityManager {\n const fork = (em as unknown as { fork?: (options?: Record<string, unknown>) => EntityManager }).fork\n if (typeof fork !== 'function') return em\n return fork.call(em, { clear: true, useContext: false })\n}\n\nfunction integrationScopeKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\nexport default async function handler(\n payload: Record<string, unknown>,\n ctx: (SubscriberContext & { eventId?: string }) | { container?: { resolve: <T = unknown>(name: string) => T }; eventId?: string; eventName?: string; resolve?: <T = unknown>(name: string) => T },\n) {\n const eventId = ctx.eventId ?? ctx.eventName ?? (payload.eventId as string) ?? (payload.type as string)\n if (!eventId) return\n if (shouldSkipOutboundDispatch(eventId)) return\n\n const tenantId = payload.tenantId as string | undefined\n const organizationId = payload.organizationId as string | undefined\n if (!tenantId) return\n\n if (eventId.startsWith('webhooks.')) return\n if (eventId.startsWith('query_index.')) return\n\n\n const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')\n ? ctx.resolve\n : ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')\n ? ctx.container.resolve.bind(ctx.container)\n : null\n\n if (!resolve) return\n\n const em = (resolve('em') as EntityManager).fork()\n\n const webhooks = await findWithDecryption(\n em,\n WebhookEntity,\n {\n isActive: true,\n deletedAt: null,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n },\n {},\n { tenantId, organizationId: organizationId ?? '' },\n )\n\n if (!webhooks.length) return\n\n const matchingWebhooks = webhooks.filter((webhook) =>\n matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents),\n )\n\n if (!matchingWebhooks.length) return\n\n const integrationEnabledByScope = new Map<string, Promise<boolean>>()\n\n const resolveIntegrationEnabled = (webhook: WebhookEntity): Promise<boolean> => {\n const key = integrationScopeKey(webhook.tenantId, webhook.organizationId)\n let pending = integrationEnabledByScope.get(key)\n if (!pending) {\n pending = isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n integrationEnabledByScope.set(key, pending)\n }\n return pending\n }\n\n await Promise.all(\n matchingWebhooks.map(async (webhook) => {\n if (!(await resolveIntegrationEnabled(webhook))) return\n\n const webhookEm = forkOutboundEntityManager(em)\n let createdDeliveryId: string | null = null\n try {\n const delivery = await createWebhookDelivery({\n em: webhookEm,\n webhook,\n eventId,\n payload,\n })\n createdDeliveryId = delivery.id\n\n await enqueueWebhookDelivery({\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n })\n } catch (error) {\n if (createdDeliveryId) {\n const failedDelivery = await findOneWithDecryption(\n webhookEm,\n WebhookDeliveryEntity,\n { id: createdDeliveryId, tenantId: webhook.tenantId, organizationId: webhook.organizationId },\n undefined,\n { tenantId: webhook.tenantId, organizationId: webhook.organizationId },\n )\n if (failedDelivery) {\n failedDelivery.status = 'failed'\n failedDelivery.errorMessage = error instanceof Error ? `Queue enqueue failed: ${error.message}` : 'Queue enqueue failed'\n failedDelivery.nextRetryAt = null\n await webhookEm.flush()\n }\n }\n console.error('[webhooks] Failed to enqueue outbound delivery', {\n webhookId: webhook.id,\n eventId,\n tenantId,\n organizationId: organizationId ?? webhook.organizationId,\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }),\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/webhooks",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4236.1.9fa6806b34",
|
|
4
4
|
"description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -69,19 +69,19 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@open-mercato/core": "0.6.4-develop.
|
|
73
|
-
"@open-mercato/queue": "0.6.4-develop.
|
|
74
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
72
|
+
"@open-mercato/core": "0.6.4-develop.4236.1.9fa6806b34",
|
|
73
|
+
"@open-mercato/queue": "0.6.4-develop.4236.1.9fa6806b34",
|
|
74
|
+
"@open-mercato/ui": "0.6.4-develop.4236.1.9fa6806b34",
|
|
75
75
|
"svix": "^1.92.2"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
78
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
79
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.4-develop.4236.1.9fa6806b34",
|
|
80
80
|
"react": "^19.0.0",
|
|
81
81
|
"react-dom": "^19.0.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
84
|
+
"@open-mercato/shared": "0.6.4-develop.4236.1.9fa6806b34",
|
|
85
85
|
"@types/jest": "^30.0.0",
|
|
86
86
|
"@types/react": "^19.2.15",
|
|
87
87
|
"@types/react-dom": "^19.2.3",
|
|
@@ -26,6 +26,22 @@ import { createWebhookDelivery } from '../../lib/delivery'
|
|
|
26
26
|
import { enqueueWebhookDelivery } from '../../lib/queue'
|
|
27
27
|
import { isWebhookIntegrationEnabled } from '../../lib/integration-state'
|
|
28
28
|
|
|
29
|
+
function createDispatchEntityManagers() {
|
|
30
|
+
const webhookEm = {
|
|
31
|
+
flush: jest.fn(async () => undefined),
|
|
32
|
+
} as unknown as EntityManager
|
|
33
|
+
const handlerEm = {
|
|
34
|
+
fork: jest.fn(() => webhookEm),
|
|
35
|
+
flush: jest.fn(async () => undefined),
|
|
36
|
+
} as unknown as EntityManager
|
|
37
|
+
const rootEm = {
|
|
38
|
+
fork: jest.fn(() => handlerEm),
|
|
39
|
+
flush: jest.fn(async () => undefined),
|
|
40
|
+
} as unknown as EntityManager
|
|
41
|
+
|
|
42
|
+
return { rootEm, handlerEm, webhookEm }
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
describe('webhooks outbound dispatch subscriber', () => {
|
|
30
46
|
afterEach(() => {
|
|
31
47
|
jest.clearAllMocks()
|
|
@@ -36,13 +52,7 @@ describe('webhooks outbound dispatch subscriber', () => {
|
|
|
36
52
|
})
|
|
37
53
|
|
|
38
54
|
it('uses the event bus eventName for wildcard subscribers and schedules matching webhook deliveries', async () => {
|
|
39
|
-
const
|
|
40
|
-
fork: jest.fn(function fork() {
|
|
41
|
-
return em
|
|
42
|
-
}),
|
|
43
|
-
findOne: jest.fn(),
|
|
44
|
-
flush: jest.fn(async () => undefined),
|
|
45
|
-
} as unknown as EntityManager
|
|
55
|
+
const { rootEm, handlerEm, webhookEm } = createDispatchEntityManagers()
|
|
46
56
|
|
|
47
57
|
;(findWithDecryption as jest.Mock).mockResolvedValue([
|
|
48
58
|
{
|
|
@@ -69,13 +79,15 @@ describe('webhooks outbound dispatch subscriber', () => {
|
|
|
69
79
|
{
|
|
70
80
|
eventName: 'catalog.product.deleted',
|
|
71
81
|
resolve: <T,>(name: string): T => {
|
|
72
|
-
if (name === 'em') return
|
|
82
|
+
if (name === 'em') return rootEm as T
|
|
73
83
|
throw new Error(`Unexpected dependency: ${name}`)
|
|
74
84
|
},
|
|
75
85
|
},
|
|
76
86
|
)
|
|
77
87
|
|
|
88
|
+
expect(handlerEm.fork).toHaveBeenCalled()
|
|
78
89
|
expect(createWebhookDelivery).toHaveBeenCalledWith(expect.objectContaining({
|
|
90
|
+
em: webhookEm,
|
|
79
91
|
eventId: 'catalog.product.deleted',
|
|
80
92
|
payload: expect.objectContaining({
|
|
81
93
|
id: 'product-1',
|
|
@@ -90,53 +102,120 @@ describe('webhooks outbound dispatch subscriber', () => {
|
|
|
90
102
|
})
|
|
91
103
|
})
|
|
92
104
|
|
|
93
|
-
it('
|
|
94
|
-
const
|
|
95
|
-
fork: jest.fn(function fork() {
|
|
96
|
-
return em
|
|
97
|
-
}),
|
|
98
|
-
flush: jest.fn(async () => undefined),
|
|
99
|
-
} as unknown as EntityManager
|
|
105
|
+
it('checks integration state once per organization when multiple webhooks match', async () => {
|
|
106
|
+
const { rootEm, handlerEm } = createDispatchEntityManagers()
|
|
100
107
|
|
|
101
108
|
;(findWithDecryption as jest.Mock).mockResolvedValue([
|
|
102
109
|
{
|
|
103
110
|
id: 'webhook-1',
|
|
104
111
|
organizationId: 'org-1',
|
|
105
112
|
tenantId: 'tenant-1',
|
|
106
|
-
subscribedEvents: ['catalog.product.
|
|
113
|
+
subscribedEvents: ['catalog.product.deleted'],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'webhook-2',
|
|
117
|
+
organizationId: 'org-1',
|
|
118
|
+
tenantId: 'tenant-1',
|
|
119
|
+
subscribedEvents: ['catalog.product.deleted'],
|
|
107
120
|
},
|
|
108
121
|
])
|
|
109
122
|
;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
|
|
110
|
-
;(createWebhookDelivery as jest.Mock)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
;(createWebhookDelivery as jest.Mock)
|
|
124
|
+
.mockResolvedValueOnce({
|
|
125
|
+
id: 'delivery-1',
|
|
126
|
+
tenantId: 'tenant-1',
|
|
127
|
+
organizationId: 'org-1',
|
|
128
|
+
})
|
|
129
|
+
.mockResolvedValueOnce({
|
|
130
|
+
id: 'delivery-2',
|
|
131
|
+
tenantId: 'tenant-1',
|
|
132
|
+
organizationId: 'org-1',
|
|
133
|
+
})
|
|
134
|
+
;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
|
|
121
135
|
|
|
122
136
|
await handler(
|
|
123
|
-
{ id: 'product-2', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
124
137
|
{
|
|
125
|
-
|
|
138
|
+
id: 'product-1',
|
|
139
|
+
tenantId: 'tenant-1',
|
|
140
|
+
organizationId: 'org-1',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
eventName: 'catalog.product.deleted',
|
|
126
144
|
resolve: <T,>(name: string): T => {
|
|
127
|
-
if (name === 'em') return
|
|
145
|
+
if (name === 'em') return rootEm as T
|
|
128
146
|
throw new Error(`Unexpected dependency: ${name}`)
|
|
129
147
|
},
|
|
130
148
|
},
|
|
131
149
|
)
|
|
132
150
|
|
|
133
|
-
expect(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
)
|
|
151
|
+
expect(isWebhookIntegrationEnabled).toHaveBeenCalledTimes(1)
|
|
152
|
+
expect(isWebhookIntegrationEnabled).toHaveBeenCalledWith(handlerEm, {
|
|
153
|
+
tenantId: 'tenant-1',
|
|
154
|
+
organizationId: 'org-1',
|
|
155
|
+
})
|
|
156
|
+
expect(createWebhookDelivery).toHaveBeenCalledTimes(2)
|
|
157
|
+
expect(enqueueWebhookDelivery).toHaveBeenCalledTimes(2)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('scopes the failed-delivery lookup to webhook tenantId and organizationId when enqueue throws', async () => {
|
|
161
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const { rootEm, webhookEm } = createDispatchEntityManagers()
|
|
165
|
+
|
|
166
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([
|
|
167
|
+
{
|
|
168
|
+
id: 'webhook-1',
|
|
169
|
+
organizationId: 'org-1',
|
|
170
|
+
tenantId: 'tenant-1',
|
|
171
|
+
subscribedEvents: ['catalog.product.created'],
|
|
172
|
+
},
|
|
173
|
+
])
|
|
174
|
+
;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
|
|
175
|
+
;(createWebhookDelivery as jest.Mock).mockResolvedValue({
|
|
176
|
+
id: 'delivery-1',
|
|
177
|
+
tenantId: 'tenant-1',
|
|
178
|
+
organizationId: 'org-1',
|
|
179
|
+
})
|
|
180
|
+
;(enqueueWebhookDelivery as jest.Mock).mockRejectedValue(new Error('Queue unavailable'))
|
|
181
|
+
;(findOneWithDecryption as jest.Mock).mockResolvedValue({
|
|
182
|
+
id: 'delivery-1',
|
|
183
|
+
status: 'pending',
|
|
184
|
+
nextRetryAt: null,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
await handler(
|
|
188
|
+
{ id: 'product-2', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
189
|
+
{
|
|
190
|
+
eventName: 'catalog.product.created',
|
|
191
|
+
resolve: <T,>(name: string): T => {
|
|
192
|
+
if (name === 'em') return rootEm as T
|
|
193
|
+
throw new Error(`Unexpected dependency: ${name}`)
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(findOneWithDecryption).toHaveBeenCalledWith(
|
|
199
|
+
webhookEm,
|
|
200
|
+
expect.anything(),
|
|
201
|
+
expect.objectContaining({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
202
|
+
undefined,
|
|
203
|
+
expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
204
|
+
)
|
|
205
|
+
expect(webhookEm.flush).toHaveBeenCalled()
|
|
206
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
207
|
+
'[webhooks] Failed to enqueue outbound delivery',
|
|
208
|
+
expect.objectContaining({
|
|
209
|
+
webhookId: 'webhook-1',
|
|
210
|
+
eventId: 'catalog.product.created',
|
|
211
|
+
tenantId: 'tenant-1',
|
|
212
|
+
organizationId: 'org-1',
|
|
213
|
+
error: 'Queue unavailable',
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
} finally {
|
|
217
|
+
consoleErrorSpy.mockRestore()
|
|
218
|
+
}
|
|
140
219
|
})
|
|
141
220
|
|
|
142
221
|
it('does not crash when the event bus provides only ctx.resolve', async () => {
|
|
@@ -21,6 +21,16 @@ function shouldSkipOutboundDispatch(eventId: string): boolean {
|
|
|
21
21
|
return declaredEvent?.excludeFromTriggers === true
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function forkOutboundEntityManager(em: EntityManager): EntityManager {
|
|
25
|
+
const fork = (em as unknown as { fork?: (options?: Record<string, unknown>) => EntityManager }).fork
|
|
26
|
+
if (typeof fork !== 'function') return em
|
|
27
|
+
return fork.call(em, { clear: true, useContext: false })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function integrationScopeKey(tenantId: string, organizationId: string): string {
|
|
31
|
+
return `${tenantId}:${organizationId}`
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
export default async function handler(
|
|
25
35
|
payload: Record<string, unknown>,
|
|
26
36
|
ctx: (SubscriberContext & { eventId?: string }) | { container?: { resolve: <T = unknown>(name: string) => T }; eventId?: string; eventName?: string; resolve?: <T = unknown>(name: string) => T },
|
|
@@ -68,46 +78,65 @@ export default async function handler(
|
|
|
68
78
|
|
|
69
79
|
if (!matchingWebhooks.length) return
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
const integrationEnabled = await isWebhookIntegrationEnabled(em, {
|
|
73
|
-
tenantId: webhook.tenantId,
|
|
74
|
-
organizationId: webhook.organizationId,
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
if (!integrationEnabled) continue
|
|
78
|
-
|
|
79
|
-
let createdDeliveryId: string | null = null
|
|
80
|
-
try {
|
|
81
|
-
const delivery = await createWebhookDelivery({
|
|
82
|
-
em,
|
|
83
|
-
webhook,
|
|
84
|
-
eventId,
|
|
85
|
-
payload,
|
|
86
|
-
})
|
|
87
|
-
createdDeliveryId = delivery.id
|
|
81
|
+
const integrationEnabledByScope = new Map<string, Promise<boolean>>()
|
|
88
82
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const failedDelivery = await findOneWithDecryption(em, WebhookDeliveryEntity, { id: createdDeliveryId, tenantId: webhook.tenantId, organizationId: webhook.organizationId }, undefined, { tenantId: webhook.tenantId, organizationId: webhook.organizationId })
|
|
97
|
-
if (failedDelivery) {
|
|
98
|
-
failedDelivery.status = 'failed'
|
|
99
|
-
failedDelivery.errorMessage = error instanceof Error ? `Queue enqueue failed: ${error.message}` : 'Queue enqueue failed'
|
|
100
|
-
failedDelivery.nextRetryAt = null
|
|
101
|
-
await em.flush()
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
console.error('[webhooks] Failed to enqueue outbound delivery', {
|
|
105
|
-
webhookId: webhook.id,
|
|
106
|
-
eventId,
|
|
107
|
-
tenantId,
|
|
108
|
-
organizationId: organizationId ?? webhook.organizationId,
|
|
109
|
-
error: error instanceof Error ? error.message : String(error),
|
|
83
|
+
const resolveIntegrationEnabled = (webhook: WebhookEntity): Promise<boolean> => {
|
|
84
|
+
const key = integrationScopeKey(webhook.tenantId, webhook.organizationId)
|
|
85
|
+
let pending = integrationEnabledByScope.get(key)
|
|
86
|
+
if (!pending) {
|
|
87
|
+
pending = isWebhookIntegrationEnabled(em, {
|
|
88
|
+
tenantId: webhook.tenantId,
|
|
89
|
+
organizationId: webhook.organizationId,
|
|
110
90
|
})
|
|
91
|
+
integrationEnabledByScope.set(key, pending)
|
|
111
92
|
}
|
|
93
|
+
return pending
|
|
112
94
|
}
|
|
95
|
+
|
|
96
|
+
await Promise.all(
|
|
97
|
+
matchingWebhooks.map(async (webhook) => {
|
|
98
|
+
if (!(await resolveIntegrationEnabled(webhook))) return
|
|
99
|
+
|
|
100
|
+
const webhookEm = forkOutboundEntityManager(em)
|
|
101
|
+
let createdDeliveryId: string | null = null
|
|
102
|
+
try {
|
|
103
|
+
const delivery = await createWebhookDelivery({
|
|
104
|
+
em: webhookEm,
|
|
105
|
+
webhook,
|
|
106
|
+
eventId,
|
|
107
|
+
payload,
|
|
108
|
+
})
|
|
109
|
+
createdDeliveryId = delivery.id
|
|
110
|
+
|
|
111
|
+
await enqueueWebhookDelivery({
|
|
112
|
+
deliveryId: delivery.id,
|
|
113
|
+
tenantId: delivery.tenantId,
|
|
114
|
+
organizationId: delivery.organizationId,
|
|
115
|
+
})
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (createdDeliveryId) {
|
|
118
|
+
const failedDelivery = await findOneWithDecryption(
|
|
119
|
+
webhookEm,
|
|
120
|
+
WebhookDeliveryEntity,
|
|
121
|
+
{ id: createdDeliveryId, tenantId: webhook.tenantId, organizationId: webhook.organizationId },
|
|
122
|
+
undefined,
|
|
123
|
+
{ tenantId: webhook.tenantId, organizationId: webhook.organizationId },
|
|
124
|
+
)
|
|
125
|
+
if (failedDelivery) {
|
|
126
|
+
failedDelivery.status = 'failed'
|
|
127
|
+
failedDelivery.errorMessage = error instanceof Error ? `Queue enqueue failed: ${error.message}` : 'Queue enqueue failed'
|
|
128
|
+
failedDelivery.nextRetryAt = null
|
|
129
|
+
await webhookEm.flush()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
console.error('[webhooks] Failed to enqueue outbound delivery', {
|
|
133
|
+
webhookId: webhook.id,
|
|
134
|
+
eventId,
|
|
135
|
+
tenantId,
|
|
136
|
+
organizationId: organizationId ?? webhook.organizationId,
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
113
142
|
}
|