@open-mercato/webhooks 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7237.1.635faf2c7a
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +3 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/test/route.js +1 -1
- package/dist/modules/webhooks/api/webhooks/[id]/test/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/route.js +13 -0
- package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
- package/dist/modules/webhooks/lib/delivery.js +8 -1
- package/dist/modules/webhooks/lib/delivery.js.map +2 -2
- package/dist/modules/webhooks/lib/queue.js +1 -1
- package/dist/modules/webhooks/lib/queue.js.map +2 -2
- package/dist/modules/webhooks/lib/subscription-cache.js +93 -0
- package/dist/modules/webhooks/lib/subscription-cache.js.map +7 -0
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js +34 -2
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
- package/dist/modules/webhooks/workers/webhook-delivery.js +1 -1
- package/dist/modules/webhooks/workers/webhook-delivery.js.map +2 -2
- package/package.json +7 -6
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +107 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +5 -0
- package/src/modules/webhooks/api/webhooks/[id]/test/route.ts +1 -1
- package/src/modules/webhooks/api/webhooks/__tests__/subscription-cache-invalidation.test.ts +93 -0
- package/src/modules/webhooks/api/webhooks/route.ts +14 -0
- package/src/modules/webhooks/lib/__tests__/subscription-cache-invalidation.test.ts +207 -0
- package/src/modules/webhooks/lib/delivery.ts +12 -2
- package/src/modules/webhooks/lib/queue.ts +1 -1
- package/src/modules/webhooks/lib/subscription-cache.ts +135 -0
- package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.subscription-cache.test.ts +289 -0
- package/src/modules/webhooks/subscribers/outbound-dispatch.ts +49 -6
- package/src/modules/webhooks/workers/__tests__/webhook-delivery.test.ts +5 -1
- package/src/modules/webhooks/workers/webhook-delivery.ts +1 -1
package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.subscription-cache.test.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
|
|
3
|
+
import handler from '../outbound-dispatch'
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
6
|
+
const mocked = {
|
|
7
|
+
debug: jest.fn(),
|
|
8
|
+
info: jest.fn(),
|
|
9
|
+
warn: jest.fn(),
|
|
10
|
+
error: jest.fn(),
|
|
11
|
+
child: jest.fn(),
|
|
12
|
+
}
|
|
13
|
+
mocked.child.mockImplementation(() => mocked)
|
|
14
|
+
return { createLogger: jest.fn(() => mocked) }
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
18
|
+
findWithDecryption: jest.fn(),
|
|
19
|
+
findOneWithDecryption: jest.fn(),
|
|
20
|
+
}))
|
|
21
|
+
jest.mock('@open-mercato/shared/modules/events', () => ({
|
|
22
|
+
getDeclaredEvents: jest.fn(() => []),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
jest.mock('../../lib/delivery', () => ({
|
|
26
|
+
createWebhookDelivery: jest.fn(),
|
|
27
|
+
}))
|
|
28
|
+
jest.mock('../../lib/queue', () => ({
|
|
29
|
+
enqueueWebhookDelivery: jest.fn(),
|
|
30
|
+
}))
|
|
31
|
+
jest.mock('../../lib/integration-state', () => ({
|
|
32
|
+
isWebhookIntegrationEnabled: jest.fn(),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
36
|
+
import { createWebhookDelivery } from '../../lib/delivery'
|
|
37
|
+
import { enqueueWebhookDelivery } from '../../lib/queue'
|
|
38
|
+
import { isWebhookIntegrationEnabled } from '../../lib/integration-state'
|
|
39
|
+
|
|
40
|
+
function createForkableEntityManager(): EntityManager {
|
|
41
|
+
const em = {
|
|
42
|
+
fork: jest.fn(function fork() {
|
|
43
|
+
return em
|
|
44
|
+
}),
|
|
45
|
+
flush: jest.fn(async () => undefined),
|
|
46
|
+
} as unknown as EntityManager
|
|
47
|
+
return em
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createContext(em: EntityManager, cache?: CacheStrategy | null, eventName = 'catalog.product.deleted') {
|
|
51
|
+
const resolve = jest.fn(<T,>(name: string): T => {
|
|
52
|
+
if (name === 'em') return em as T
|
|
53
|
+
if (name === 'cache') {
|
|
54
|
+
if (cache === null) throw new Error('cache unavailable')
|
|
55
|
+
return cache as T
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Unexpected dependency: ${name}`)
|
|
58
|
+
})
|
|
59
|
+
return { eventName, resolve }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('webhooks outbound dispatch subscriber - subscription cache', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks()
|
|
65
|
+
delete process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('queries the database once for zero active webhooks and reuses the cached empty list', async () => {
|
|
69
|
+
const em = createForkableEntityManager()
|
|
70
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
71
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([])
|
|
72
|
+
|
|
73
|
+
const payload = { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
74
|
+
await handler(payload, createContext(em, cache))
|
|
75
|
+
await handler(payload, createContext(em, cache))
|
|
76
|
+
|
|
77
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does not resolve or fork an entity manager on the cached zero-webhook path', async () => {
|
|
81
|
+
const em = createForkableEntityManager()
|
|
82
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
83
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([])
|
|
84
|
+
|
|
85
|
+
const payload = { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
86
|
+
await handler(payload, createContext(em, cache))
|
|
87
|
+
expect(em.fork).toHaveBeenCalledTimes(1)
|
|
88
|
+
|
|
89
|
+
const secondContext = createContext(em, cache)
|
|
90
|
+
await handler(payload, secondContext)
|
|
91
|
+
|
|
92
|
+
expect(em.fork).toHaveBeenCalledTimes(1)
|
|
93
|
+
expect(secondContext.resolve).not.toHaveBeenCalledWith('em')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not resolve or fork an entity manager when the cached list has no matching pattern', async () => {
|
|
97
|
+
const em = createForkableEntityManager()
|
|
98
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
99
|
+
|
|
100
|
+
;(findWithDecryption as jest.Mock).mockResolvedValueOnce([
|
|
101
|
+
{ id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] },
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
await handler(
|
|
105
|
+
{ id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
106
|
+
createContext(em, cache, 'catalog.product.created'),
|
|
107
|
+
)
|
|
108
|
+
expect(em.fork).toHaveBeenCalledTimes(1)
|
|
109
|
+
|
|
110
|
+
const secondContext = createContext(em, cache, 'catalog.product.deleted')
|
|
111
|
+
await handler({ id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' }, secondContext)
|
|
112
|
+
|
|
113
|
+
expect(em.fork).toHaveBeenCalledTimes(1)
|
|
114
|
+
expect(secondContext.resolve).not.toHaveBeenCalledWith('em')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not hit the database for a cached non-matching subscribedEvents pattern', async () => {
|
|
118
|
+
const em = createForkableEntityManager()
|
|
119
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
120
|
+
|
|
121
|
+
;(findWithDecryption as jest.Mock).mockResolvedValueOnce([
|
|
122
|
+
{ id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] },
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
await handler(
|
|
126
|
+
{ id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
127
|
+
createContext(em, cache, 'catalog.product.created'),
|
|
128
|
+
)
|
|
129
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(1)
|
|
130
|
+
|
|
131
|
+
await handler(
|
|
132
|
+
{ id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
133
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(1)
|
|
137
|
+
expect(createWebhookDelivery).not.toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('loads matching webhooks by id and creates a delivery on a cache hit', async () => {
|
|
141
|
+
const em = createForkableEntityManager()
|
|
142
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
143
|
+
|
|
144
|
+
const fullWebhook = {
|
|
145
|
+
id: 'webhook-1',
|
|
146
|
+
tenantId: 'tenant-1',
|
|
147
|
+
organizationId: 'org-1',
|
|
148
|
+
subscribedEvents: ['catalog.product.deleted'],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
;(findWithDecryption as jest.Mock)
|
|
152
|
+
.mockResolvedValueOnce([fullWebhook])
|
|
153
|
+
.mockResolvedValueOnce([fullWebhook])
|
|
154
|
+
;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
|
|
155
|
+
;(createWebhookDelivery as jest.Mock).mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
156
|
+
;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
|
|
157
|
+
|
|
158
|
+
await handler(
|
|
159
|
+
{ id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
160
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
161
|
+
)
|
|
162
|
+
await handler(
|
|
163
|
+
{ id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
164
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(2)
|
|
168
|
+
expect(findWithDecryption).toHaveBeenNthCalledWith(
|
|
169
|
+
2,
|
|
170
|
+
em,
|
|
171
|
+
expect.anything(),
|
|
172
|
+
expect.objectContaining({ id: { $in: ['webhook-1'] } }),
|
|
173
|
+
{},
|
|
174
|
+
expect.objectContaining({ tenantId: 'tenant-1' }),
|
|
175
|
+
)
|
|
176
|
+
expect(createWebhookDelivery).toHaveBeenCalledTimes(2)
|
|
177
|
+
expect(enqueueWebhookDelivery).toHaveBeenCalledTimes(2)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('re-queries the database after the invalidation event runs', async () => {
|
|
181
|
+
const em = createForkableEntityManager()
|
|
182
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
183
|
+
|
|
184
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([])
|
|
185
|
+
|
|
186
|
+
const payload = { id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
187
|
+
await handler(payload, createContext(em, cache))
|
|
188
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(1)
|
|
189
|
+
|
|
190
|
+
await handler(payload, createContext(em, cache))
|
|
191
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(1)
|
|
192
|
+
|
|
193
|
+
await handler(
|
|
194
|
+
{ id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
195
|
+
createContext(em, cache, 'webhooks.webhook.created'),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
await handler(payload, createContext(em, cache))
|
|
199
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(2)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('never serves one tenant cached list to another tenant', async () => {
|
|
203
|
+
const em = createForkableEntityManager()
|
|
204
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
205
|
+
|
|
206
|
+
;(findWithDecryption as jest.Mock)
|
|
207
|
+
.mockResolvedValueOnce([
|
|
208
|
+
{ id: 'webhook-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.deleted'] },
|
|
209
|
+
])
|
|
210
|
+
.mockResolvedValueOnce([])
|
|
211
|
+
|
|
212
|
+
await handler(
|
|
213
|
+
{ id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
214
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
215
|
+
)
|
|
216
|
+
await handler(
|
|
217
|
+
{ id: 'p2', tenantId: 'tenant-2', organizationId: 'org-1' },
|
|
218
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(2)
|
|
222
|
+
expect((findWithDecryption as jest.Mock).mock.calls[1][2]).toEqual(
|
|
223
|
+
expect.objectContaining({ tenantId: 'tenant-2' }),
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('never reads or writes the cache when OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS=0', async () => {
|
|
228
|
+
process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS = '0'
|
|
229
|
+
|
|
230
|
+
const em = createForkableEntityManager()
|
|
231
|
+
const cache = createCacheService({ strategy: 'memory' })
|
|
232
|
+
const getSpy = jest.spyOn(cache, 'get')
|
|
233
|
+
const setSpy = jest.spyOn(cache, 'set')
|
|
234
|
+
|
|
235
|
+
const fullWebhook = {
|
|
236
|
+
id: 'webhook-1',
|
|
237
|
+
tenantId: 'tenant-1',
|
|
238
|
+
organizationId: 'org-1',
|
|
239
|
+
subscribedEvents: ['catalog.product.deleted'],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([fullWebhook])
|
|
243
|
+
;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
|
|
244
|
+
;(createWebhookDelivery as jest.Mock).mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
245
|
+
;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
|
|
246
|
+
|
|
247
|
+
await handler(
|
|
248
|
+
{ id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
249
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
250
|
+
)
|
|
251
|
+
await handler(
|
|
252
|
+
{ id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
253
|
+
createContext(em, cache, 'catalog.product.deleted'),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
expect(getSpy).not.toHaveBeenCalled()
|
|
257
|
+
expect(setSpy).not.toHaveBeenCalled()
|
|
258
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(2)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('falls back to the uncached query and still dispatches when the cache is unavailable', async () => {
|
|
262
|
+
const em = createForkableEntityManager()
|
|
263
|
+
|
|
264
|
+
const fullWebhook = {
|
|
265
|
+
id: 'webhook-1',
|
|
266
|
+
tenantId: 'tenant-1',
|
|
267
|
+
organizationId: 'org-1',
|
|
268
|
+
subscribedEvents: ['catalog.product.deleted'],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([fullWebhook])
|
|
272
|
+
;(isWebhookIntegrationEnabled as jest.Mock).mockResolvedValue(true)
|
|
273
|
+
;(createWebhookDelivery as jest.Mock).mockResolvedValue({ id: 'delivery-1', tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
274
|
+
;(enqueueWebhookDelivery as jest.Mock).mockResolvedValue('job-1')
|
|
275
|
+
|
|
276
|
+
await handler(
|
|
277
|
+
{ id: 'p1', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
278
|
+
createContext(em, null, 'catalog.product.deleted'),
|
|
279
|
+
)
|
|
280
|
+
await handler(
|
|
281
|
+
{ id: 'p2', tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
282
|
+
createContext(em, null, 'catalog.product.deleted'),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
expect(findWithDecryption).toHaveBeenCalledTimes(2)
|
|
286
|
+
expect(createWebhookDelivery).toHaveBeenCalledTimes(2)
|
|
287
|
+
expect(enqueueWebhookDelivery).toHaveBeenCalledTimes(2)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
@@ -8,6 +8,14 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
8
8
|
import { createWebhookDelivery } from '../lib/delivery'
|
|
9
9
|
import { enqueueWebhookDelivery } from '../lib/queue'
|
|
10
10
|
import { isWebhookIntegrationEnabled } from '../lib/integration-state'
|
|
11
|
+
import {
|
|
12
|
+
getCachedActiveWebhooks,
|
|
13
|
+
getWebhookSubscriptionCacheTtlMs,
|
|
14
|
+
invalidateWebhookSubscriptionCacheFor,
|
|
15
|
+
resolveWebhookSubscriptionCache,
|
|
16
|
+
setCachedActiveWebhooks,
|
|
17
|
+
type CachedActiveWebhook,
|
|
18
|
+
} from '../lib/subscription-cache'
|
|
11
19
|
|
|
12
20
|
const logger = createLogger('webhooks')
|
|
13
21
|
|
|
@@ -40,6 +48,21 @@ export default async function handler(
|
|
|
40
48
|
) {
|
|
41
49
|
const eventId = ctx.eventId ?? ctx.eventName ?? (payload.eventId as string) ?? (payload.type as string)
|
|
42
50
|
if (!eventId) return
|
|
51
|
+
|
|
52
|
+
const resolve = ('resolve' in ctx && typeof ctx.resolve === 'function')
|
|
53
|
+
? ctx.resolve
|
|
54
|
+
: ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')
|
|
55
|
+
? ctx.container.resolve.bind(ctx.container)
|
|
56
|
+
: null
|
|
57
|
+
|
|
58
|
+
if (eventId.startsWith('webhooks.webhook.')) {
|
|
59
|
+
const changedTenantId = payload.tenantId as string | undefined
|
|
60
|
+
if (resolve && changedTenantId) {
|
|
61
|
+
await invalidateWebhookSubscriptionCacheFor(resolve, changedTenantId)
|
|
62
|
+
}
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
if (shouldSkipOutboundDispatch(eventId)) return
|
|
44
67
|
|
|
45
68
|
const tenantId = payload.tenantId as string | undefined
|
|
@@ -49,14 +72,23 @@ export default async function handler(
|
|
|
49
72
|
if (eventId.startsWith('webhooks.')) return
|
|
50
73
|
if (eventId.startsWith('query_index.')) return
|
|
51
74
|
|
|
75
|
+
if (!resolve) return
|
|
76
|
+
|
|
77
|
+
const cacheTtlMs = getWebhookSubscriptionCacheTtlMs()
|
|
78
|
+
const subscriptionCache = cacheTtlMs > 0 ? resolveWebhookSubscriptionCache(resolve) : null
|
|
79
|
+
const cachedActiveWebhooks = subscriptionCache
|
|
80
|
+
? await getCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null)
|
|
81
|
+
: null
|
|
52
82
|
|
|
53
|
-
|
|
54
|
-
? ctx.resolve
|
|
55
|
-
: ('container' in ctx && ctx.container && typeof ctx.container.resolve === 'function')
|
|
56
|
-
? ctx.container.resolve.bind(ctx.container)
|
|
57
|
-
: null
|
|
83
|
+
let matchingIds: string[] | null = null
|
|
58
84
|
|
|
59
|
-
if (
|
|
85
|
+
if (cachedActiveWebhooks) {
|
|
86
|
+
matchingIds = cachedActiveWebhooks
|
|
87
|
+
.filter((entry) => matchAnyWebhookEventPattern(eventId, entry.subscribedEvents))
|
|
88
|
+
.map((entry) => entry.id)
|
|
89
|
+
|
|
90
|
+
if (!matchingIds.length) return
|
|
91
|
+
}
|
|
60
92
|
|
|
61
93
|
const em = (resolve('em') as EntityManager).fork()
|
|
62
94
|
|
|
@@ -68,11 +100,22 @@ export default async function handler(
|
|
|
68
100
|
deletedAt: null,
|
|
69
101
|
tenantId,
|
|
70
102
|
...(organizationId ? { organizationId } : {}),
|
|
103
|
+
...(matchingIds ? { id: { $in: matchingIds } } : {}),
|
|
71
104
|
},
|
|
72
105
|
{},
|
|
73
106
|
{ tenantId, organizationId: organizationId ?? null },
|
|
74
107
|
)
|
|
75
108
|
|
|
109
|
+
if (!matchingIds && subscriptionCache) {
|
|
110
|
+
const cacheable: CachedActiveWebhook[] = webhooks.map((webhook) => ({
|
|
111
|
+
id: webhook.id,
|
|
112
|
+
tenantId: webhook.tenantId,
|
|
113
|
+
organizationId: webhook.organizationId ?? null,
|
|
114
|
+
subscribedEvents: webhook.subscribedEvents,
|
|
115
|
+
}))
|
|
116
|
+
await setCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null, cacheable, cacheTtlMs)
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
if (!webhooks.length) return
|
|
77
120
|
|
|
78
121
|
const matchingWebhooks = webhooks.filter((webhook) =>
|
|
@@ -29,7 +29,11 @@ describe('webhooks delivery worker', () => {
|
|
|
29
29
|
|
|
30
30
|
await handler({ payload: jobData }, makeCtx(em))
|
|
31
31
|
|
|
32
|
-
expect(mockProcessWebhookDeliveryJob).toHaveBeenCalledWith(
|
|
32
|
+
expect(mockProcessWebhookDeliveryJob).toHaveBeenCalledWith(
|
|
33
|
+
em,
|
|
34
|
+
jobData,
|
|
35
|
+
expect.objectContaining({ resolver: expect.any(Function) }),
|
|
36
|
+
)
|
|
33
37
|
})
|
|
34
38
|
|
|
35
39
|
it('re-throws on job failure so the queue can retry', async () => {
|
|
@@ -16,7 +16,7 @@ export default async function handler(
|
|
|
16
16
|
) {
|
|
17
17
|
const em = (ctx.resolve('em') as EntityManager).fork()
|
|
18
18
|
try {
|
|
19
|
-
await processWebhookDeliveryJob(em, job.payload)
|
|
19
|
+
await processWebhookDeliveryJob(em, job.payload, { resolver: ctx.resolve })
|
|
20
20
|
} catch (error) {
|
|
21
21
|
logger.error('Job processing failed', {
|
|
22
22
|
deliveryId: job.payload?.deliveryId,
|