@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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/lib/subscription-cache.ts"],
|
|
4
|
+
"sourcesContent": ["import type { CacheStrategy } from '@open-mercato/cache'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('webhooks').child({ component: 'subscription-cache' })\n\nconst DEFAULT_TTL_MS = 60_000\nconst GLOBAL_SCOPE_SEGMENT = 'global'\n\nexport type CachedActiveWebhook = {\n id: string\n tenantId: string\n organizationId: string | null\n subscribedEvents: string[]\n}\n\nexport type WebhookSubscriptionCache = Pick<CacheStrategy, 'get' | 'set' | 'deleteByTags'>\n\nexport function isWebhookSubscriptionCache(value: unknown): value is WebhookSubscriptionCache {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Partial<WebhookSubscriptionCache>\n return (\n typeof candidate.get === 'function'\n && typeof candidate.set === 'function'\n && typeof candidate.deleteByTags === 'function'\n )\n}\n\nexport type WebhookCacheDependencyResolver =\n | ((token: string) => unknown)\n | { resolve: (token: string) => unknown }\n\nexport function resolveWebhookSubscriptionCache(resolve: (token: string) => unknown): WebhookSubscriptionCache | null {\n try {\n const cache = resolve('cache')\n return isWebhookSubscriptionCache(cache) ? cache : null\n } catch {\n return null\n }\n}\n\nfunction toResolveFunction(source: WebhookCacheDependencyResolver | null | undefined): ((token: string) => unknown) | null {\n if (typeof source === 'function') return source\n if (source && typeof source.resolve === 'function') return source.resolve.bind(source)\n return null\n}\n\nexport function getWebhookSubscriptionCacheTtlMs(): number {\n const raw = process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS\n if (raw === undefined || raw.trim() === '') return DEFAULT_TTL_MS\n const parsed = Number.parseInt(raw, 10)\n if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_TTL_MS\n return parsed\n}\n\nfunction normalizeScopeSegment(organizationId: string | null | undefined): string {\n return organizationId ?? GLOBAL_SCOPE_SEGMENT\n}\n\nexport function getWebhookSubscriptionCacheKey(tenantId: string, organizationId: string | null | undefined): string {\n return `webhooks:subscriptions:${tenantId}:${normalizeScopeSegment(organizationId)}`\n}\n\nexport function getWebhookSubscriptionCacheTag(tenantId: string): string {\n return `webhooks:subscriptions:tenant:${tenantId}`\n}\n\nfunction isCachedActiveWebhookList(value: unknown): value is CachedActiveWebhook[] {\n return Array.isArray(value) && value.every((entry) => (\n entry !== null\n && typeof entry === 'object'\n && typeof (entry as CachedActiveWebhook).id === 'string'\n && typeof (entry as CachedActiveWebhook).tenantId === 'string'\n && Array.isArray((entry as CachedActiveWebhook).subscribedEvents)\n ))\n}\n\nexport async function getCachedActiveWebhooks(\n cache: WebhookSubscriptionCache | null,\n tenantId: string,\n organizationId: string | null | undefined,\n): Promise<CachedActiveWebhook[] | null> {\n if (!cache) return null\n try {\n const value = await runWithCacheTenant(tenantId, () => cache.get(getWebhookSubscriptionCacheKey(tenantId, organizationId)))\n return isCachedActiveWebhookList(value) ? value : null\n } catch (error) {\n logger.warn('Failed to read webhook subscription cache', { err: error })\n return null\n }\n}\n\nexport async function setCachedActiveWebhooks(\n cache: WebhookSubscriptionCache | null,\n tenantId: string,\n organizationId: string | null | undefined,\n webhooks: CachedActiveWebhook[],\n ttlMs: number,\n): Promise<void> {\n if (!cache || ttlMs <= 0) return\n try {\n await runWithCacheTenant(tenantId, () => cache.set(\n getWebhookSubscriptionCacheKey(tenantId, organizationId),\n webhooks,\n { ttl: ttlMs, tags: [getWebhookSubscriptionCacheTag(tenantId)] },\n ))\n } catch (error) {\n logger.warn('Failed to write webhook subscription cache', { err: error })\n }\n}\n\nexport async function invalidateWebhookSubscriptionCache(\n cache: WebhookSubscriptionCache | null,\n tenantId: string | null | undefined,\n): Promise<void> {\n if (!cache) return\n const normalizedTenantId = tenantId?.trim()\n if (!normalizedTenantId) return\n try {\n await runWithCacheTenant(normalizedTenantId, () => cache.deleteByTags([getWebhookSubscriptionCacheTag(normalizedTenantId)]))\n } catch (error) {\n logger.warn('Failed to invalidate webhook subscription cache', { err: error })\n }\n}\n\nexport async function invalidateWebhookSubscriptionCacheFor(\n source: WebhookCacheDependencyResolver | null | undefined,\n tenantId: string | null | undefined,\n): Promise<void> {\n const normalizedTenantId = tenantId?.trim()\n if (!normalizedTenantId) return\n const resolve = toResolveFunction(source)\n if (!resolve) return\n await invalidateWebhookSubscriptionCache(resolveWebhookSubscriptionCache(resolve), normalizedTenantId)\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AAEjF,MAAM,iBAAiB;AACvB,MAAM,uBAAuB;AAWtB,SAAS,2BAA2B,OAAmD;AAC5F,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,YAAY;AAClB,SACE,OAAO,UAAU,QAAQ,cACtB,OAAO,UAAU,QAAQ,cACzB,OAAO,UAAU,iBAAiB;AAEzC;AAMO,SAAS,gCAAgC,SAAsE;AACpH,MAAI;AACF,UAAM,QAAQ,QAAQ,OAAO;AAC7B,WAAO,2BAA2B,KAAK,IAAI,QAAQ;AAAA,EACrD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,kBAAkB,QAAgG;AACzH,MAAI,OAAO,WAAW,WAAY,QAAO;AACzC,MAAI,UAAU,OAAO,OAAO,YAAY,WAAY,QAAO,OAAO,QAAQ,KAAK,MAAM;AACrF,SAAO;AACT;AAEO,SAAS,mCAA2C;AACzD,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,QAAQ,UAAa,IAAI,KAAK,MAAM,GAAI,QAAO;AACnD,QAAM,SAAS,OAAO,SAAS,KAAK,EAAE;AACtC,MAAI,CAAC,OAAO,SAAS,MAAM,KAAK,SAAS,EAAG,QAAO;AACnD,SAAO;AACT;AAEA,SAAS,sBAAsB,gBAAmD;AAChF,SAAO,kBAAkB;AAC3B;AAEO,SAAS,+BAA+B,UAAkB,gBAAmD;AAClH,SAAO,0BAA0B,QAAQ,IAAI,sBAAsB,cAAc,CAAC;AACpF;AAEO,SAAS,+BAA+B,UAA0B;AACvE,SAAO,iCAAiC,QAAQ;AAClD;AAEA,SAAS,0BAA0B,OAAgD;AACjF,SAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,MAAM,CAAC,UAC1C,UAAU,QACP,OAAO,UAAU,YACjB,OAAQ,MAA8B,OAAO,YAC7C,OAAQ,MAA8B,aAAa,YACnD,MAAM,QAAS,MAA8B,gBAAgB,CACjE;AACH;AAEA,eAAsB,wBACpB,OACA,UACA,gBACuC;AACvC,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI;AACF,UAAM,QAAQ,MAAM,mBAAmB,UAAU,MAAM,MAAM,IAAI,+BAA+B,UAAU,cAAc,CAAC,CAAC;AAC1H,WAAO,0BAA0B,KAAK,IAAI,QAAQ;AAAA,EACpD,SAAS,OAAO;AACd,WAAO,KAAK,6CAA6C,EAAE,KAAK,MAAM,CAAC;AACvE,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,wBACpB,OACA,UACA,gBACA,UACA,OACe;AACf,MAAI,CAAC,SAAS,SAAS,EAAG;AAC1B,MAAI;AACF,UAAM,mBAAmB,UAAU,MAAM,MAAM;AAAA,MAC7C,+BAA+B,UAAU,cAAc;AAAA,MACvD;AAAA,MACA,EAAE,KAAK,OAAO,MAAM,CAAC,+BAA+B,QAAQ,CAAC,EAAE;AAAA,IACjE,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,KAAK,8CAA8C,EAAE,KAAK,MAAM,CAAC;AAAA,EAC1E;AACF;AAEA,eAAsB,mCACpB,OACA,UACe;AACf,MAAI,CAAC,MAAO;AACZ,QAAM,qBAAqB,UAAU,KAAK;AAC1C,MAAI,CAAC,mBAAoB;AACzB,MAAI;AACF,UAAM,mBAAmB,oBAAoB,MAAM,MAAM,aAAa,CAAC,+BAA+B,kBAAkB,CAAC,CAAC,CAAC;AAAA,EAC7H,SAAS,OAAO;AACd,WAAO,KAAK,mDAAmD,EAAE,KAAK,MAAM,CAAC;AAAA,EAC/E;AACF;AAEA,eAAsB,sCACpB,QACA,UACe;AACf,QAAM,qBAAqB,UAAU,KAAK;AAC1C,MAAI,CAAC,mBAAoB;AACzB,QAAM,UAAU,kBAAkB,MAAM;AACxC,MAAI,CAAC,QAAS;AACd,QAAM,mCAAmC,gCAAgC,OAAO,GAAG,kBAAkB;AACvG;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -6,6 +6,13 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
6
6
|
import { createWebhookDelivery } from "../lib/delivery.js";
|
|
7
7
|
import { enqueueWebhookDelivery } from "../lib/queue.js";
|
|
8
8
|
import { isWebhookIntegrationEnabled } from "../lib/integration-state.js";
|
|
9
|
+
import {
|
|
10
|
+
getCachedActiveWebhooks,
|
|
11
|
+
getWebhookSubscriptionCacheTtlMs,
|
|
12
|
+
invalidateWebhookSubscriptionCacheFor,
|
|
13
|
+
resolveWebhookSubscriptionCache,
|
|
14
|
+
setCachedActiveWebhooks
|
|
15
|
+
} from "../lib/subscription-cache.js";
|
|
9
16
|
const logger = createLogger("webhooks");
|
|
10
17
|
const metadata = {
|
|
11
18
|
event: "*",
|
|
@@ -28,14 +35,29 @@ function integrationScopeKey(tenantId, organizationId) {
|
|
|
28
35
|
async function handler(payload, ctx) {
|
|
29
36
|
const eventId = ctx.eventId ?? ctx.eventName ?? payload.eventId ?? payload.type;
|
|
30
37
|
if (!eventId) return;
|
|
38
|
+
const resolve = "resolve" in ctx && typeof ctx.resolve === "function" ? ctx.resolve : "container" in ctx && ctx.container && typeof ctx.container.resolve === "function" ? ctx.container.resolve.bind(ctx.container) : null;
|
|
39
|
+
if (eventId.startsWith("webhooks.webhook.")) {
|
|
40
|
+
const changedTenantId = payload.tenantId;
|
|
41
|
+
if (resolve && changedTenantId) {
|
|
42
|
+
await invalidateWebhookSubscriptionCacheFor(resolve, changedTenantId);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
31
46
|
if (shouldSkipOutboundDispatch(eventId)) return;
|
|
32
47
|
const tenantId = payload.tenantId;
|
|
33
48
|
const organizationId = payload.organizationId;
|
|
34
49
|
if (!tenantId) return;
|
|
35
50
|
if (eventId.startsWith("webhooks.")) return;
|
|
36
51
|
if (eventId.startsWith("query_index.")) return;
|
|
37
|
-
const resolve = "resolve" in ctx && typeof ctx.resolve === "function" ? ctx.resolve : "container" in ctx && ctx.container && typeof ctx.container.resolve === "function" ? ctx.container.resolve.bind(ctx.container) : null;
|
|
38
52
|
if (!resolve) return;
|
|
53
|
+
const cacheTtlMs = getWebhookSubscriptionCacheTtlMs();
|
|
54
|
+
const subscriptionCache = cacheTtlMs > 0 ? resolveWebhookSubscriptionCache(resolve) : null;
|
|
55
|
+
const cachedActiveWebhooks = subscriptionCache ? await getCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null) : null;
|
|
56
|
+
let matchingIds = null;
|
|
57
|
+
if (cachedActiveWebhooks) {
|
|
58
|
+
matchingIds = cachedActiveWebhooks.filter((entry) => matchAnyWebhookEventPattern(eventId, entry.subscribedEvents)).map((entry) => entry.id);
|
|
59
|
+
if (!matchingIds.length) return;
|
|
60
|
+
}
|
|
39
61
|
const em = resolve("em").fork();
|
|
40
62
|
const webhooks = await findWithDecryption(
|
|
41
63
|
em,
|
|
@@ -44,11 +66,21 @@ async function handler(payload, ctx) {
|
|
|
44
66
|
isActive: true,
|
|
45
67
|
deletedAt: null,
|
|
46
68
|
tenantId,
|
|
47
|
-
...organizationId ? { organizationId } : {}
|
|
69
|
+
...organizationId ? { organizationId } : {},
|
|
70
|
+
...matchingIds ? { id: { $in: matchingIds } } : {}
|
|
48
71
|
},
|
|
49
72
|
{},
|
|
50
73
|
{ tenantId, organizationId: organizationId ?? null }
|
|
51
74
|
);
|
|
75
|
+
if (!matchingIds && subscriptionCache) {
|
|
76
|
+
const cacheable = webhooks.map((webhook) => ({
|
|
77
|
+
id: webhook.id,
|
|
78
|
+
tenantId: webhook.tenantId,
|
|
79
|
+
organizationId: webhook.organizationId ?? null,
|
|
80
|
+
subscribedEvents: webhook.subscribedEvents
|
|
81
|
+
}));
|
|
82
|
+
await setCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null, cacheable, cacheTtlMs);
|
|
83
|
+
}
|
|
52
84
|
if (!webhooks.length) return;
|
|
53
85
|
const matchingWebhooks = webhooks.filter(
|
|
54
86
|
(webhook) => matchAnyWebhookEventPattern(eventId, webhook.subscribedEvents)
|
|
@@ -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 { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createWebhookDelivery } from '../lib/delivery'\nimport { enqueueWebhookDelivery } from '../lib/queue'\nimport { isWebhookIntegrationEnabled } from '../lib/integration-state'\n\nconst logger = createLogger('webhooks')\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
|
|
5
|
-
"mappings": "AAEA,SAAS,uBAAuB,qBAAqB;AACrD,SAAS,oBAAoB,6BAA6B;AAC1D,SAAS,mCAAmC;AAC5C,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,mCAAmC;
|
|
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 { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createWebhookDelivery } from '../lib/delivery'\nimport { enqueueWebhookDelivery } from '../lib/queue'\nimport { isWebhookIntegrationEnabled } from '../lib/integration-state'\nimport {\n getCachedActiveWebhooks,\n getWebhookSubscriptionCacheTtlMs,\n invalidateWebhookSubscriptionCacheFor,\n resolveWebhookSubscriptionCache,\n setCachedActiveWebhooks,\n type CachedActiveWebhook,\n} from '../lib/subscription-cache'\n\nconst logger = createLogger('webhooks')\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\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 (eventId.startsWith('webhooks.webhook.')) {\n const changedTenantId = payload.tenantId as string | undefined\n if (resolve && changedTenantId) {\n await invalidateWebhookSubscriptionCacheFor(resolve, changedTenantId)\n }\n return\n }\n\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 if (!resolve) return\n\n const cacheTtlMs = getWebhookSubscriptionCacheTtlMs()\n const subscriptionCache = cacheTtlMs > 0 ? resolveWebhookSubscriptionCache(resolve) : null\n const cachedActiveWebhooks = subscriptionCache\n ? await getCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null)\n : null\n\n let matchingIds: string[] | null = null\n\n if (cachedActiveWebhooks) {\n matchingIds = cachedActiveWebhooks\n .filter((entry) => matchAnyWebhookEventPattern(eventId, entry.subscribedEvents))\n .map((entry) => entry.id)\n\n if (!matchingIds.length) return\n }\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 ...(matchingIds ? { id: { $in: matchingIds } } : {}),\n },\n {},\n { tenantId, organizationId: organizationId ?? null },\n )\n\n if (!matchingIds && subscriptionCache) {\n const cacheable: CachedActiveWebhook[] = webhooks.map((webhook) => ({\n id: webhook.id,\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId ?? null,\n subscribedEvents: webhook.subscribedEvents,\n }))\n await setCachedActiveWebhooks(subscriptionCache, tenantId, organizationId ?? null, cacheable, cacheTtlMs)\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 logger.error('Failed to enqueue outbound delivery', {\n webhookId: webhook.id,\n eventId,\n tenantId,\n organizationId: organizationId ?? webhook.organizationId,\n err: error,\n })\n }\n }),\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -9,7 +9,7 @@ const metadata = {
|
|
|
9
9
|
async function handler(job, ctx) {
|
|
10
10
|
const em = ctx.resolve("em").fork();
|
|
11
11
|
try {
|
|
12
|
-
await processWebhookDeliveryJob(em, job.payload);
|
|
12
|
+
await processWebhookDeliveryJob(em, job.payload, { resolver: ctx.resolve });
|
|
13
13
|
} catch (error) {
|
|
14
14
|
logger.error("Job processing failed", {
|
|
15
15
|
deliveryId: job.payload?.deliveryId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/webhooks/workers/webhook-delivery.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { processWebhookDeliveryJob, type WebhookDeliveryJob } from '../lib/delivery'\n\nconst logger = createLogger('webhooks').child({ component: 'delivery' })\n\nexport const metadata = {\n queue: 'webhook-deliveries',\n id: 'webhooks:delivery-worker',\n concurrency: 10,\n}\n\nexport default async function handler(\n job: { payload: WebhookDeliveryJob },\n ctx: { resolve: <T = unknown>(name: string) => T },\n) {\n const em = (ctx.resolve('em') as EntityManager).fork()\n try {\n await processWebhookDeliveryJob(em, job.payload)\n } catch (error) {\n logger.error('Job processing failed', {\n deliveryId: job.payload?.deliveryId,\n tenantId: job.payload?.tenantId,\n err: error,\n })\n throw error\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,iCAA0D;AAEnE,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,WAAW,CAAC;AAEhE,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,QACL,KACA,KACA;AACA,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,MAAI;AACF,UAAM,0BAA0B,IAAI,IAAI,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { processWebhookDeliveryJob, type WebhookDeliveryJob } from '../lib/delivery'\n\nconst logger = createLogger('webhooks').child({ component: 'delivery' })\n\nexport const metadata = {\n queue: 'webhook-deliveries',\n id: 'webhooks:delivery-worker',\n concurrency: 10,\n}\n\nexport default async function handler(\n job: { payload: WebhookDeliveryJob },\n ctx: { resolve: <T = unknown>(name: string) => T },\n) {\n const em = (ctx.resolve('em') as EntityManager).fork()\n try {\n await processWebhookDeliveryJob(em, job.payload, { resolver: ctx.resolve })\n } catch (error) {\n logger.error('Job processing failed', {\n deliveryId: job.payload?.deliveryId,\n tenantId: job.payload?.tenantId,\n err: error,\n })\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,iCAA0D;AAEnE,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,WAAW,CAAC;AAEhE,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAEA,eAAO,QACL,KACA,KACA;AACA,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,MAAI;AACF,UAAM,0BAA0B,IAAI,IAAI,SAAS,EAAE,UAAU,IAAI,QAAQ,CAAC;AAAA,EAC5E,SAAS,OAAO;AACd,WAAO,MAAM,yBAAyB;AAAA,MACpC,YAAY,IAAI,SAAS;AAAA,MACzB,UAAU,IAAI,SAAS;AAAA,MACvB,KAAK;AAAA,IACP,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/webhooks",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7237.1.635faf2c7a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
|
|
6
6
|
"type": "module",
|
|
@@ -70,19 +70,20 @@
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
|
-
"@open-mercato/
|
|
74
|
-
"@open-mercato/
|
|
75
|
-
"@open-mercato/
|
|
73
|
+
"@open-mercato/cache": "0.8.1-develop.7237.1.635faf2c7a",
|
|
74
|
+
"@open-mercato/core": "0.8.1-develop.7237.1.635faf2c7a",
|
|
75
|
+
"@open-mercato/queue": "0.8.1-develop.7237.1.635faf2c7a",
|
|
76
|
+
"@open-mercato/ui": "0.8.1-develop.7237.1.635faf2c7a",
|
|
76
77
|
"svix": "^1.99.1"
|
|
77
78
|
},
|
|
78
79
|
"peerDependencies": {
|
|
79
80
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
80
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
81
|
+
"@open-mercato/shared": "0.8.1-develop.7237.1.635faf2c7a",
|
|
81
82
|
"react": "^19.0.0",
|
|
82
83
|
"react-dom": "^19.0.0"
|
|
83
84
|
},
|
|
84
85
|
"devDependencies": {
|
|
85
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
86
|
+
"@open-mercato/shared": "0.8.1-develop.7237.1.635faf2c7a",
|
|
86
87
|
"@types/jest": "^30.0.0",
|
|
87
88
|
"@types/react": "^19.2.18",
|
|
88
89
|
"@types/react-dom": "^19.2.5",
|
package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
|
|
4
|
+
import {
|
|
5
|
+
getCachedActiveWebhooks,
|
|
6
|
+
setCachedActiveWebhooks,
|
|
7
|
+
} from '../../../../lib/subscription-cache'
|
|
8
|
+
|
|
9
|
+
const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174071'
|
|
10
|
+
|
|
11
|
+
const webhookRecord = {
|
|
12
|
+
id: WEBHOOK_ID,
|
|
13
|
+
name: 'Hook',
|
|
14
|
+
description: null,
|
|
15
|
+
url: 'https://example.com/hook',
|
|
16
|
+
subscribedEvents: ['catalog.product.created'],
|
|
17
|
+
httpMethod: 'POST',
|
|
18
|
+
isActive: true,
|
|
19
|
+
organizationId: 'org-1',
|
|
20
|
+
tenantId: 'tenant-1',
|
|
21
|
+
updatedAt: new Date('2026-06-01T10:00:00.000Z'),
|
|
22
|
+
deletedAt: null as Date | null,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mockEm = {
|
|
26
|
+
fork: jest.fn(() => mockEm),
|
|
27
|
+
flush: jest.fn(async () => undefined),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cache: CacheStrategy
|
|
31
|
+
|
|
32
|
+
const mockContainer = {
|
|
33
|
+
resolve: (token: string) => {
|
|
34
|
+
if (token === 'em') return mockEm
|
|
35
|
+
if (token === 'cache') return cache
|
|
36
|
+
if (token === 'commandOptimisticLockGuardService') throw new Error('not registered')
|
|
37
|
+
return null
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
jest.mock('../../../../events', () => ({
|
|
42
|
+
emitWebhooksEvent: jest.fn(async () => undefined),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
jest.mock('../../../helpers', () => ({
|
|
46
|
+
json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
|
|
47
|
+
new Response(JSON.stringify(payload), {
|
|
48
|
+
...init,
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
}),
|
|
51
|
+
resolveWebhookRequestScope: jest.fn(async () => ({
|
|
52
|
+
container: mockContainer,
|
|
53
|
+
em: mockEm,
|
|
54
|
+
tenantId: 'tenant-1',
|
|
55
|
+
organizationId: 'org-1',
|
|
56
|
+
})),
|
|
57
|
+
findScopedWebhook: jest.fn(async () => webhookRecord),
|
|
58
|
+
serializeWebhookDetail: (item: { id: string }) => ({ id: item.id }),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
62
|
+
resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
import { PUT, DELETE } from '../route'
|
|
66
|
+
|
|
67
|
+
function request(method: string, body?: unknown) {
|
|
68
|
+
return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
|
|
69
|
+
method,
|
|
70
|
+
headers: { 'content-type': 'application/json' },
|
|
71
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
|
|
76
|
+
|
|
77
|
+
async function seedCache(): Promise<void> {
|
|
78
|
+
await setCachedActiveWebhooks(
|
|
79
|
+
cache,
|
|
80
|
+
'tenant-1',
|
|
81
|
+
'org-1',
|
|
82
|
+
[{ id: WEBHOOK_ID, tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] }],
|
|
83
|
+
60_000,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('webhook endpoint PUT/DELETE inline subscription-cache invalidation', () => {
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
jest.clearAllMocks()
|
|
90
|
+
webhookRecord.deletedAt = null
|
|
91
|
+
cache = createCacheService({ strategy: 'memory' })
|
|
92
|
+
await seedCache()
|
|
93
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('PUT drops the tenant subscription cache synchronously with the write', async () => {
|
|
97
|
+
const res = await PUT(request('PUT', { isActive: false }), context)
|
|
98
|
+
expect(res.status).toBe(200)
|
|
99
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('DELETE drops the tenant subscription cache synchronously with the soft delete', async () => {
|
|
103
|
+
const res = await DELETE(request('DELETE'), context)
|
|
104
|
+
expect(res.status).toBe(200)
|
|
105
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -6,6 +6,7 @@ import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDe
|
|
|
6
6
|
import { webhookUpdateSchema } from '../../../data/validators'
|
|
7
7
|
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
8
8
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
9
|
+
import { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'
|
|
9
10
|
|
|
10
11
|
export const metadata = {
|
|
11
12
|
GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
|
|
@@ -106,6 +107,8 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
|
|
|
106
107
|
|
|
107
108
|
await em.flush()
|
|
108
109
|
|
|
110
|
+
await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)
|
|
111
|
+
|
|
109
112
|
await emitWebhooksEvent('webhooks.webhook.updated', {
|
|
110
113
|
webhookId: webhook.id,
|
|
111
114
|
organizationId: webhook.organizationId,
|
|
@@ -143,6 +146,8 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
|
|
|
143
146
|
webhook.deletedAt = new Date()
|
|
144
147
|
await em.flush()
|
|
145
148
|
|
|
149
|
+
await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)
|
|
150
|
+
|
|
146
151
|
await emitWebhooksEvent('webhooks.webhook.deleted', {
|
|
147
152
|
webhookId: webhook.id,
|
|
148
153
|
organizationId: webhook.organizationId,
|
|
@@ -85,7 +85,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
|
|
|
85
85
|
deliveryId: delivery.id,
|
|
86
86
|
tenantId: delivery.tenantId,
|
|
87
87
|
organizationId: delivery.organizationId,
|
|
88
|
-
}, { scheduleRetries: false })
|
|
88
|
+
}, { scheduleRetries: false, resolver: scope.container })
|
|
89
89
|
|
|
90
90
|
await em.refresh(delivery)
|
|
91
91
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
|
|
4
|
+
import {
|
|
5
|
+
getCachedActiveWebhooks,
|
|
6
|
+
setCachedActiveWebhooks,
|
|
7
|
+
} from '../../../lib/subscription-cache'
|
|
8
|
+
|
|
9
|
+
type CapturedHooks = {
|
|
10
|
+
afterCreate?: (entity: unknown, ctx: unknown) => Promise<void> | void
|
|
11
|
+
afterUpdate?: (entity: unknown, ctx: unknown) => Promise<void> | void
|
|
12
|
+
afterDelete?: (id: string, ctx: unknown) => Promise<void> | void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const captured: { hooks: CapturedHooks | null } = { hooks: null }
|
|
16
|
+
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/crud/factory', () => ({
|
|
18
|
+
makeCrudRoute: jest.fn((options: { hooks?: CapturedHooks }) => {
|
|
19
|
+
captured.hooks = options.hooks ?? null
|
|
20
|
+
return {
|
|
21
|
+
metadata: {},
|
|
22
|
+
GET: jest.fn(),
|
|
23
|
+
POST: jest.fn(),
|
|
24
|
+
PUT: jest.fn(),
|
|
25
|
+
DELETE: jest.fn(),
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
31
|
+
resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
import '../route'
|
|
35
|
+
|
|
36
|
+
const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174072'
|
|
37
|
+
|
|
38
|
+
describe('webhooks CRUD route inline subscription-cache invalidation', () => {
|
|
39
|
+
let cache: CacheStrategy
|
|
40
|
+
let container: { resolve: (token: string) => unknown }
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
cache = createCacheService({ strategy: 'memory' })
|
|
44
|
+
container = { resolve: (token: string) => (token === 'cache' ? cache : null) }
|
|
45
|
+
await setCachedActiveWebhooks(
|
|
46
|
+
cache,
|
|
47
|
+
'tenant-1',
|
|
48
|
+
'org-1',
|
|
49
|
+
[{ id: WEBHOOK_ID, tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['catalog.product.created'] }],
|
|
50
|
+
60_000,
|
|
51
|
+
)
|
|
52
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('registers afterCreate, afterUpdate and afterDelete hooks', () => {
|
|
56
|
+
expect(typeof captured.hooks?.afterCreate).toBe('function')
|
|
57
|
+
expect(typeof captured.hooks?.afterUpdate).toBe('function')
|
|
58
|
+
expect(typeof captured.hooks?.afterDelete).toBe('function')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('afterCreate invalidates the tenant subscription cache', async () => {
|
|
62
|
+
await captured.hooks?.afterCreate?.(
|
|
63
|
+
{ id: WEBHOOK_ID, tenantId: 'tenant-1' },
|
|
64
|
+
{ container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
|
|
65
|
+
)
|
|
66
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('afterUpdate invalidates the tenant subscription cache', async () => {
|
|
70
|
+
await captured.hooks?.afterUpdate?.(
|
|
71
|
+
{ id: WEBHOOK_ID, tenantId: 'tenant-1' },
|
|
72
|
+
{ container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
|
|
73
|
+
)
|
|
74
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('afterDelete invalidates the tenant subscription cache from the auth scope', async () => {
|
|
78
|
+
await captured.hooks?.afterDelete?.(
|
|
79
|
+
WEBHOOK_ID,
|
|
80
|
+
{ container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
|
|
81
|
+
)
|
|
82
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('leaves another tenant cache entry intact', async () => {
|
|
86
|
+
await setCachedActiveWebhooks(cache, 'tenant-2', 'org-1', [], 60_000)
|
|
87
|
+
await captured.hooks?.afterUpdate?.(
|
|
88
|
+
{ id: WEBHOOK_ID, tenantId: 'tenant-1' },
|
|
89
|
+
{ container, auth: { tenantId: 'tenant-1', orgId: 'org-1' } },
|
|
90
|
+
)
|
|
91
|
+
expect(await getCachedActiveWebhooks(cache, 'tenant-2', 'org-1')).not.toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -10,6 +10,7 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
|
10
10
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
11
11
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
12
12
|
import type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'
|
|
13
|
+
import { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'
|
|
13
14
|
|
|
14
15
|
type WebhookCrudCtx = CrudCtx & {
|
|
15
16
|
__webhookSecret?: string
|
|
@@ -68,6 +69,12 @@ const webhookDetailResponseSchema = webhookListItemSchema.extend({
|
|
|
68
69
|
const deleteResponseSchema = z.object({ success: z.literal(true) })
|
|
69
70
|
const errorSchema = z.object({ error: z.string() })
|
|
70
71
|
|
|
72
|
+
function resolveTenantIdForInvalidation(entity: unknown, ctx: CrudCtx): string | null {
|
|
73
|
+
const entityTenantId = (entity as { tenantId?: unknown } | null)?.tenantId
|
|
74
|
+
if (typeof entityTenantId === 'string' && entityTenantId.length > 0) return entityTenantId
|
|
75
|
+
return ctx.auth?.tenantId ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeof webhookListQuerySchema>>({
|
|
72
79
|
metadata: {
|
|
73
80
|
GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
|
|
@@ -220,6 +227,13 @@ const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeo
|
|
|
220
227
|
if (scopedCtx.__webhookSecret) {
|
|
221
228
|
;(entity as WebhookEntity & { __revealSecret?: string }).__revealSecret = scopedCtx.__webhookSecret
|
|
222
229
|
}
|
|
230
|
+
await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))
|
|
231
|
+
},
|
|
232
|
+
afterUpdate: async (entity, ctx) => {
|
|
233
|
+
await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))
|
|
234
|
+
},
|
|
235
|
+
afterDelete: async (_id, ctx) => {
|
|
236
|
+
await invalidateWebhookSubscriptionCacheFor(ctx.container, ctx.auth?.tenantId ?? null)
|
|
223
237
|
},
|
|
224
238
|
beforeDelete: async (id, ctx) => {
|
|
225
239
|
const auth = ctx.auth
|