@open-mercato/webhooks 0.9.1-develop.7575.1.8480826a9b → 0.10.1-develop.7577.1.477f2486e5
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/dist/modules/webhooks/api/webhooks/[id]/route.js +2 -1
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/route.js +2 -1
- package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
- package/dist/modules/webhooks/lib/activation.js +8 -0
- package/dist/modules/webhooks/lib/activation.js.map +7 -0
- package/dist/modules/webhooks/workers/inbound-dispatch.js +5 -4
- package/dist/modules/webhooks/workers/inbound-dispatch.js.map +2 -2
- package/dist/modules/webhooks/workers/webhook-delivery.js +1 -0
- package/dist/modules/webhooks/workers/webhook-delivery.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/reactivation.test.ts +86 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +2 -1
- package/src/modules/webhooks/api/webhooks/route.ts +2 -1
- package/src/modules/webhooks/lib/__tests__/activation.test.ts +21 -0
- package/src/modules/webhooks/lib/activation.ts +9 -0
- package/src/modules/webhooks/workers/__tests__/inbound-dispatch.test.ts +142 -0
- package/src/modules/webhooks/workers/__tests__/webhook-delivery.test.ts +107 -14
- package/src/modules/webhooks/workers/inbound-dispatch.ts +13 -7
- package/src/modules/webhooks/workers/webhook-delivery.ts +9 -3
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:webhooks] found
|
|
1
|
+
[build:webhooks] found 77 entry points
|
|
2
2
|
[build:webhooks] built successfully
|
|
@@ -6,6 +6,7 @@ import { webhookUpdateSchema } from "../../../data/validators.js";
|
|
|
6
6
|
import { enforceCommandOptimisticLockWithGuards } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
|
|
7
7
|
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
8
8
|
import { invalidateWebhookSubscriptionCacheFor } from "../../../lib/subscription-cache.js";
|
|
9
|
+
import { applyWebhookActiveState } from "../../../lib/activation.js";
|
|
9
10
|
const metadata = {
|
|
10
11
|
GET: { requireAuth: true, requireFeatures: ["webhooks.view"] },
|
|
11
12
|
PUT: { requireAuth: true, requireFeatures: ["webhooks.manage"] },
|
|
@@ -85,7 +86,7 @@ async function PUT(request, context) {
|
|
|
85
86
|
if (input.rateLimitPerMinute !== void 0) webhook.rateLimitPerMinute = input.rateLimitPerMinute;
|
|
86
87
|
if (input.autoDisableThreshold !== void 0) webhook.autoDisableThreshold = input.autoDisableThreshold;
|
|
87
88
|
if (input.integrationId !== void 0) webhook.integrationId = input.integrationId;
|
|
88
|
-
if (input.isActive !== void 0) webhook
|
|
89
|
+
if (input.isActive !== void 0) applyWebhookActiveState(webhook, input.isActive);
|
|
89
90
|
await em.flush();
|
|
90
91
|
await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId);
|
|
91
92
|
await emitWebhooksEvent("webhooks.webhook.updated", {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/webhooks/api/webhooks/%5Bid%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n try {\n await enforceCommandOptimisticLockWithGuards(scope.container, {\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) webhook
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAElB,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,SAAS,mBAAmB,MAAM,4BAA4B,8BAA8B;AAC5F,SAAS,2BAA2B;AACpC,SAAS,8CAA8C;AACvD,SAAS,uBAAuB;AAChC,SAAS,6CAA6C;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'\nimport { applyWebhookActiveState } from '../../../lib/activation'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n try {\n await enforceCommandOptimisticLockWithGuards(scope.container, {\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) applyWebhookActiveState(webhook, input.isActive)\n\n await em.flush()\n\n await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)\n\n await emitWebhooksEvent('webhooks.webhook.updated', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function DELETE(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n const { translate } = await resolveTranslations()\n\n if (!webhook) {\n return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n }\n\n try {\n await enforceCommandOptimisticLockWithGuards(scope.container, {\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n webhook.deletedAt = new Date()\n await em.flush()\n\n await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)\n\n await emitWebhooksEvent('webhooks.webhook.deleted', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json({ success: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Get webhook detail',\n description: 'Returns a single webhook configuration for the current tenant scope.',\n methods: {\n GET: {\n summary: 'Get webhook',\n description: 'Returns webhook configuration, masked secret metadata, and delivery settings.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook detail', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates a single webhook configuration.',\n pathParams: z.object({ id: z.string().uuid() }),\n requestBody: {\n contentType: 'application/json',\n schema: webhookUpdateSchema,\n description: 'Webhook fields to update.',\n },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -7,6 +7,7 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
|
7
7
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
8
8
|
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
9
9
|
import { invalidateWebhookSubscriptionCacheFor } from "../../lib/subscription-cache.js";
|
|
10
|
+
import { applyWebhookActiveState } from "../../lib/activation.js";
|
|
10
11
|
function json(payload, init = { status: 200 }) {
|
|
11
12
|
return new Response(JSON.stringify(payload), {
|
|
12
13
|
...init,
|
|
@@ -119,7 +120,7 @@ const crud = makeCrudRoute({
|
|
|
119
120
|
if (input.rateLimitPerMinute !== void 0) entity.rateLimitPerMinute = input.rateLimitPerMinute;
|
|
120
121
|
if (input.autoDisableThreshold !== void 0) entity.autoDisableThreshold = input.autoDisableThreshold;
|
|
121
122
|
if (input.integrationId !== void 0) entity.integrationId = input.integrationId;
|
|
122
|
-
if (input.isActive !== void 0) entity
|
|
123
|
+
if (input.isActive !== void 0) applyWebhookActiveState(entity, input.isActive);
|
|
123
124
|
}
|
|
124
125
|
},
|
|
125
126
|
del: { idFrom: "query" },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/webhooks/api/webhooks/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { WebhookEntity } from '../../data/entities'\nimport { webhookCreateSchema, webhookUpdateSchema, webhookListQuerySchema } from '../../data/validators'\nimport { generateWebhookSecret } from '@open-mercato/shared/lib/webhooks'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'\n\ntype WebhookCrudCtx = CrudCtx & {\n __webhookSecret?: string\n}\n\nfunction json(payload: unknown, init: ResponseInit = { status: 200 }) {\n return new Response(JSON.stringify(payload), {\n ...init,\n headers: { 'content-type': 'application/json', ...(init.headers || {}) },\n })\n}\n\nconst webhookListItemSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n})\n\nconst webhookCollectionResponseSchema = z.object({\n items: z.array(webhookListItemSchema),\n total: z.number().int().nonnegative(),\n page: z.number().int().positive(),\n pageSize: z.number().int().positive(),\n totalPages: z.number().int().nonnegative(),\n})\n\nconst webhookCreateResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n url: z.string(),\n secret: z.string(),\n subscribedEvents: z.array(z.string()),\n isActive: z.boolean(),\n})\n\nconst webhookDetailResponseSchema = webhookListItemSchema.extend({\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n})\n\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\nconst errorSchema = z.object({ error: z.string() })\n\nfunction resolveTenantIdForInvalidation(entity: unknown, ctx: CrudCtx): string | null {\n const entityTenantId = (entity as { tenantId?: unknown } | null)?.tenantId\n if (typeof entityTenantId === 'string' && entityTenantId.length > 0) return entityTenantId\n return ctx.auth?.tenantId ?? null\n}\n\nconst crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeof webhookListQuerySchema>>({\n metadata: {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n POST: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n },\n orm: { entity: WebhookEntity, orgField: 'organizationId' },\n events: { module: 'webhooks', entity: 'webhook', persistent: true },\n list: { schema: webhookListQuerySchema },\n create: {\n schema: webhookCreateSchema,\n mapToEntity: (input, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n const secret = scopedCtx.__webhookSecret\n if (!secret) throw new Error('Webhook secret not prepared')\n return {\n name: input.name,\n description: input.description ?? null,\n url: input.url,\n secret,\n subscribedEvents: input.subscribedEvents,\n httpMethod: input.httpMethod ?? 'POST',\n customHeaders: input.customHeaders ?? null,\n deliveryStrategy: input.deliveryStrategy ?? 'http',\n strategyConfig: input.strategyConfig ?? null,\n maxRetries: input.maxRetries ?? 10,\n timeoutMs: input.timeoutMs ?? 15000,\n rateLimitPerMinute: input.rateLimitPerMinute ?? 0,\n autoDisableThreshold: input.autoDisableThreshold ?? 100,\n integrationId: input.integrationId ?? null,\n organizationId: ctx.auth?.orgId ?? '',\n tenantId: ctx.auth?.tenantId ?? '',\n }\n },\n response: (entity) => ({\n id: entity.id,\n name: entity.name,\n url: entity.url,\n secret: (entity as WebhookEntity & { __revealSecret?: string }).__revealSecret ?? '***',\n subscribedEvents: entity.subscribedEvents,\n isActive: entity.isActive,\n }),\n },\n update: {\n schema: webhookUpdateSchema,\n applyToEntity: (entity, input) => {\n if (input.name !== undefined) entity.name = input.name\n if (input.description !== undefined) entity.description = input.description\n if (input.url !== undefined) entity.url = input.url\n if (input.subscribedEvents !== undefined) entity.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) entity.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) entity.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) entity.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) entity.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) entity.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) entity.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) entity.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) entity.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) entity.integrationId = input.integrationId\n if (input.isActive !== undefined) entity.isActive = input.isActive\n },\n },\n del: { idFrom: 'query' },\n hooks: {\n beforeList: async (query, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const page = Math.max(parseInt(query.page ?? '1', 10) || 1, 1)\n const pageSize = Math.min(Math.max(parseInt(query.pageSize ?? '20', 10) || 20, 1), 100)\n const search = (query.search ?? '').trim().toLowerCase()\n\n const organizationIds = Array.isArray(ctx.organizationIds) ? ctx.organizationIds : null\n if (organizationIds && organizationIds.length === 0) {\n throw json({ items: [], total: 0, page, pageSize, totalPages: 0 })\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const qb = em.createQueryBuilder(WebhookEntity, 'w')\n qb.where({ deletedAt: null })\n qb.andWhere({ tenantId: auth.tenantId })\n\n if (organizationIds && organizationIds.length > 0) {\n qb.andWhere({ organizationId: { $in: organizationIds } })\n } else if (auth.orgId) {\n qb.andWhere({ organizationId: auth.orgId })\n }\n\n if (search) {\n const pattern = `%${escapeLikePattern(search)}%`\n qb.andWhere({\n $or: [\n { name: { $ilike: pattern } },\n { url: { $ilike: pattern } },\n ],\n })\n }\n\n if (query.isActive !== undefined && query.isActive !== '') {\n const active = parseBooleanToken(query.isActive)\n if (active !== null) {\n qb.andWhere({ isActive: active })\n }\n }\n\n qb.orderBy({ createdAt: 'desc' })\n qb.limit(pageSize).offset((page - 1) * pageSize)\n const [items, total] = await qb.getResultAndCount()\n\n const payload = {\n items: items.map((item) => ({\n id: item.id,\n name: item.name,\n description: item.description ?? null,\n url: item.url,\n subscribedEvents: item.subscribedEvents,\n httpMethod: item.httpMethod,\n isActive: item.isActive,\n deliveryStrategy: item.deliveryStrategy,\n maxRetries: item.maxRetries,\n consecutiveFailures: item.consecutiveFailures,\n lastSuccessAt: item.lastSuccessAt?.toISOString() ?? null,\n lastFailureAt: item.lastFailureAt?.toISOString() ?? null,\n createdAt: item.createdAt.toISOString(),\n updatedAt: item.updatedAt.toISOString(),\n })),\n total,\n page,\n pageSize,\n totalPages: Math.ceil(total / pageSize),\n }\n\n throw json(payload)\n },\n beforeCreate: async (input, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n if (!auth?.orgId) throw json({ error: translate('webhooks.errors.orgRequired', 'Organization context required') }, { status: 400 })\n\n const scopedCtx = ctx as WebhookCrudCtx\n scopedCtx.__webhookSecret = generateWebhookSecret()\n\n return input\n },\n afterCreate: async (entity, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n if (scopedCtx.__webhookSecret) {\n ;(entity as WebhookEntity & { __revealSecret?: string }).__revealSecret = scopedCtx.__webhookSecret\n }\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterUpdate: async (entity, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterDelete: async (_id, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, ctx.auth?.tenantId ?? null)\n },\n beforeDelete: async (id, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const em = ctx.container.resolve('em') as EntityManager\n const record = await em.findOne(WebhookEntity, { id, deletedAt: null, tenantId: auth.tenantId })\n if (!record) throw json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n\n const allowedIds = ctx.organizationScope?.allowedIds ?? null\n if (record.organizationId && Array.isArray(allowedIds) && allowedIds.length > 0) {\n if (!allowedIds.includes(record.organizationId)) {\n throw json({ error: translate('webhooks.errors.forbidden', 'Forbidden') }, { status: 403 })\n }\n }\n },\n },\n})\n\nexport const metadata = crud.metadata\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Manage webhooks',\n description: 'CRUD operations for webhook endpoints following the Standard Webhooks specification.',\n methods: {\n GET: {\n summary: 'List webhooks',\n description: 'Returns paginated webhooks for the current tenant and organization.',\n query: webhookListQuerySchema,\n responses: [{ status: 200, description: 'Webhook collection', schema: webhookCollectionResponseSchema }],\n errors: [\n { status: 400, description: 'Tenant context missing', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Create webhook',\n description: 'Creates a new webhook endpoint. A signing secret (whsec_ prefixed) is auto-generated and returned once.',\n requestBody: { contentType: 'application/json', schema: webhookCreateSchema, description: 'Webhook configuration.' },\n responses: [{ status: 201, description: 'Webhook created', schema: webhookCreateResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates an existing webhook configuration.',\n requestBody: { contentType: 'application/json', schema: webhookUpdateSchema, description: 'Fields to update.' },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 404, description: 'Not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n query: z.object({ id: z.string().uuid().describe('Webhook ID to delete') }),\n responses: [{ status: 200, description: 'Deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 404, description: 'Not found', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAGlB,SAAS,qBAAqB;AAE9B,SAAS,qBAAqB;AAC9B,SAAS,qBAAqB,qBAAqB,8BAA8B;AACjF,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAElC,SAAS,6CAA6C;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { WebhookEntity } from '../../data/entities'\nimport { webhookCreateSchema, webhookUpdateSchema, webhookListQuerySchema } from '../../data/validators'\nimport { generateWebhookSecret } from '@open-mercato/shared/lib/webhooks'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'\nimport { applyWebhookActiveState } from '../../lib/activation'\n\ntype WebhookCrudCtx = CrudCtx & {\n __webhookSecret?: string\n}\n\nfunction json(payload: unknown, init: ResponseInit = { status: 200 }) {\n return new Response(JSON.stringify(payload), {\n ...init,\n headers: { 'content-type': 'application/json', ...(init.headers || {}) },\n })\n}\n\nconst webhookListItemSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n})\n\nconst webhookCollectionResponseSchema = z.object({\n items: z.array(webhookListItemSchema),\n total: z.number().int().nonnegative(),\n page: z.number().int().positive(),\n pageSize: z.number().int().positive(),\n totalPages: z.number().int().nonnegative(),\n})\n\nconst webhookCreateResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n url: z.string(),\n secret: z.string(),\n subscribedEvents: z.array(z.string()),\n isActive: z.boolean(),\n})\n\nconst webhookDetailResponseSchema = webhookListItemSchema.extend({\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n})\n\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\nconst errorSchema = z.object({ error: z.string() })\n\nfunction resolveTenantIdForInvalidation(entity: unknown, ctx: CrudCtx): string | null {\n const entityTenantId = (entity as { tenantId?: unknown } | null)?.tenantId\n if (typeof entityTenantId === 'string' && entityTenantId.length > 0) return entityTenantId\n return ctx.auth?.tenantId ?? null\n}\n\nconst crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeof webhookListQuerySchema>>({\n metadata: {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n POST: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n },\n orm: { entity: WebhookEntity, orgField: 'organizationId' },\n events: { module: 'webhooks', entity: 'webhook', persistent: true },\n list: { schema: webhookListQuerySchema },\n create: {\n schema: webhookCreateSchema,\n mapToEntity: (input, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n const secret = scopedCtx.__webhookSecret\n if (!secret) throw new Error('Webhook secret not prepared')\n return {\n name: input.name,\n description: input.description ?? null,\n url: input.url,\n secret,\n subscribedEvents: input.subscribedEvents,\n httpMethod: input.httpMethod ?? 'POST',\n customHeaders: input.customHeaders ?? null,\n deliveryStrategy: input.deliveryStrategy ?? 'http',\n strategyConfig: input.strategyConfig ?? null,\n maxRetries: input.maxRetries ?? 10,\n timeoutMs: input.timeoutMs ?? 15000,\n rateLimitPerMinute: input.rateLimitPerMinute ?? 0,\n autoDisableThreshold: input.autoDisableThreshold ?? 100,\n integrationId: input.integrationId ?? null,\n organizationId: ctx.auth?.orgId ?? '',\n tenantId: ctx.auth?.tenantId ?? '',\n }\n },\n response: (entity) => ({\n id: entity.id,\n name: entity.name,\n url: entity.url,\n secret: (entity as WebhookEntity & { __revealSecret?: string }).__revealSecret ?? '***',\n subscribedEvents: entity.subscribedEvents,\n isActive: entity.isActive,\n }),\n },\n update: {\n schema: webhookUpdateSchema,\n applyToEntity: (entity, input) => {\n if (input.name !== undefined) entity.name = input.name\n if (input.description !== undefined) entity.description = input.description\n if (input.url !== undefined) entity.url = input.url\n if (input.subscribedEvents !== undefined) entity.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) entity.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) entity.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) entity.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) entity.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) entity.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) entity.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) entity.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) entity.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) entity.integrationId = input.integrationId\n if (input.isActive !== undefined) applyWebhookActiveState(entity, input.isActive)\n },\n },\n del: { idFrom: 'query' },\n hooks: {\n beforeList: async (query, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const page = Math.max(parseInt(query.page ?? '1', 10) || 1, 1)\n const pageSize = Math.min(Math.max(parseInt(query.pageSize ?? '20', 10) || 20, 1), 100)\n const search = (query.search ?? '').trim().toLowerCase()\n\n const organizationIds = Array.isArray(ctx.organizationIds) ? ctx.organizationIds : null\n if (organizationIds && organizationIds.length === 0) {\n throw json({ items: [], total: 0, page, pageSize, totalPages: 0 })\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const qb = em.createQueryBuilder(WebhookEntity, 'w')\n qb.where({ deletedAt: null })\n qb.andWhere({ tenantId: auth.tenantId })\n\n if (organizationIds && organizationIds.length > 0) {\n qb.andWhere({ organizationId: { $in: organizationIds } })\n } else if (auth.orgId) {\n qb.andWhere({ organizationId: auth.orgId })\n }\n\n if (search) {\n const pattern = `%${escapeLikePattern(search)}%`\n qb.andWhere({\n $or: [\n { name: { $ilike: pattern } },\n { url: { $ilike: pattern } },\n ],\n })\n }\n\n if (query.isActive !== undefined && query.isActive !== '') {\n const active = parseBooleanToken(query.isActive)\n if (active !== null) {\n qb.andWhere({ isActive: active })\n }\n }\n\n qb.orderBy({ createdAt: 'desc' })\n qb.limit(pageSize).offset((page - 1) * pageSize)\n const [items, total] = await qb.getResultAndCount()\n\n const payload = {\n items: items.map((item) => ({\n id: item.id,\n name: item.name,\n description: item.description ?? null,\n url: item.url,\n subscribedEvents: item.subscribedEvents,\n httpMethod: item.httpMethod,\n isActive: item.isActive,\n deliveryStrategy: item.deliveryStrategy,\n maxRetries: item.maxRetries,\n consecutiveFailures: item.consecutiveFailures,\n lastSuccessAt: item.lastSuccessAt?.toISOString() ?? null,\n lastFailureAt: item.lastFailureAt?.toISOString() ?? null,\n createdAt: item.createdAt.toISOString(),\n updatedAt: item.updatedAt.toISOString(),\n })),\n total,\n page,\n pageSize,\n totalPages: Math.ceil(total / pageSize),\n }\n\n throw json(payload)\n },\n beforeCreate: async (input, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n if (!auth?.orgId) throw json({ error: translate('webhooks.errors.orgRequired', 'Organization context required') }, { status: 400 })\n\n const scopedCtx = ctx as WebhookCrudCtx\n scopedCtx.__webhookSecret = generateWebhookSecret()\n\n return input\n },\n afterCreate: async (entity, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n if (scopedCtx.__webhookSecret) {\n ;(entity as WebhookEntity & { __revealSecret?: string }).__revealSecret = scopedCtx.__webhookSecret\n }\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterUpdate: async (entity, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterDelete: async (_id, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, ctx.auth?.tenantId ?? null)\n },\n beforeDelete: async (id, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const em = ctx.container.resolve('em') as EntityManager\n const record = await em.findOne(WebhookEntity, { id, deletedAt: null, tenantId: auth.tenantId })\n if (!record) throw json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n\n const allowedIds = ctx.organizationScope?.allowedIds ?? null\n if (record.organizationId && Array.isArray(allowedIds) && allowedIds.length > 0) {\n if (!allowedIds.includes(record.organizationId)) {\n throw json({ error: translate('webhooks.errors.forbidden', 'Forbidden') }, { status: 403 })\n }\n }\n },\n },\n})\n\nexport const metadata = crud.metadata\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Manage webhooks',\n description: 'CRUD operations for webhook endpoints following the Standard Webhooks specification.',\n methods: {\n GET: {\n summary: 'List webhooks',\n description: 'Returns paginated webhooks for the current tenant and organization.',\n query: webhookListQuerySchema,\n responses: [{ status: 200, description: 'Webhook collection', schema: webhookCollectionResponseSchema }],\n errors: [\n { status: 400, description: 'Tenant context missing', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Create webhook',\n description: 'Creates a new webhook endpoint. A signing secret (whsec_ prefixed) is auto-generated and returned once.',\n requestBody: { contentType: 'application/json', schema: webhookCreateSchema, description: 'Webhook configuration.' },\n responses: [{ status: 201, description: 'Webhook created', schema: webhookCreateResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates an existing webhook configuration.',\n requestBody: { contentType: 'application/json', schema: webhookUpdateSchema, description: 'Fields to update.' },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 404, description: 'Not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n query: z.object({ id: z.string().uuid().describe('Webhook ID to delete') }),\n responses: [{ status: 200, description: 'Deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 404, description: 'Not found', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/lib/activation.ts"],
|
|
4
|
+
"sourcesContent": ["import type { WebhookEntity } from '../data/entities'\n\nexport function applyWebhookActiveState(\n webhook: Pick<WebhookEntity, 'isActive' | 'consecutiveFailures'>,\n isActive: boolean,\n): void {\n if (isActive && !webhook.isActive) webhook.consecutiveFailures = 0\n webhook.isActive = isActive\n}\n"],
|
|
5
|
+
"mappings": "AAEO,SAAS,wBACd,SACA,UACM;AACN,MAAI,YAAY,CAAC,QAAQ,SAAU,SAAQ,sBAAsB;AACjE,UAAQ,WAAW;AACrB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -9,14 +9,15 @@ const metadata = {
|
|
|
9
9
|
async function handler(job, ctx) {
|
|
10
10
|
const em = ctx.resolve("em").fork();
|
|
11
11
|
try {
|
|
12
|
-
await processInboundDispatchJob(em, job.
|
|
12
|
+
await processInboundDispatchJob(em, job.payload, {
|
|
13
13
|
resolve: (name) => ctx.resolve(name)
|
|
14
14
|
});
|
|
15
15
|
} catch (error) {
|
|
16
16
|
logger.error("Inbound dispatch job processing failed", {
|
|
17
|
-
ingestionId: job.
|
|
18
|
-
sourceKey: job.
|
|
19
|
-
tenantId: job.
|
|
17
|
+
ingestionId: job.payload?.ingestionId,
|
|
18
|
+
sourceKey: job.payload?.sourceKey,
|
|
19
|
+
tenantId: job.payload?.tenantId,
|
|
20
|
+
organizationId: job.payload?.organizationId,
|
|
20
21
|
err: error
|
|
21
22
|
});
|
|
22
23
|
throw error;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/webhooks/workers/inbound-dispatch.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { processInboundDispatchJob, type InboundDispatchJob } from '../lib/inbound-dispatch'\n\nconst logger = createLogger('webhooks')\n\nexport const metadata = {\n queue: 'webhook-inbound-dispatch',\n id: 'webhooks:inbound-dispatch-worker',\n concurrency: 5,\n}\n\
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { processInboundDispatchJob, type InboundDispatchJob } from '../lib/inbound-dispatch'\n\nconst logger = createLogger('webhooks')\n\nexport const metadata: WorkerMeta = {\n queue: 'webhook-inbound-dispatch',\n id: 'webhooks:inbound-dispatch-worker',\n concurrency: 5,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport default async function handler(\n job: QueuedJob<InboundDispatchJob>,\n ctx: HandlerContext,\n) {\n const em = (ctx.resolve('em') as EntityManager).fork()\n try {\n await processInboundDispatchJob(em, job.payload, {\n resolve: <T,>(name: string) => ctx.resolve(name) as T,\n })\n } catch (error) {\n logger.error('Inbound dispatch job processing failed', {\n ingestionId: job.payload?.ingestionId,\n sourceKey: job.payload?.sourceKey,\n tenantId: job.payload?.tenantId,\n organizationId: job.payload?.organizationId,\n err: error,\n })\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,oBAAoB;AAC7B,SAAS,iCAA0D;AAEnE,MAAM,SAAS,aAAa,UAAU;AAE/B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAMA,eAAO,QACL,KACA,KACA;AACA,QAAM,KAAM,IAAI,QAAQ,IAAI,EAAoB,KAAK;AACrD,MAAI;AACF,UAAM,0BAA0B,IAAI,IAAI,SAAS;AAAA,MAC/C,SAAS,CAAK,SAAiB,IAAI,QAAQ,IAAI;AAAA,IACjD,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,0CAA0C;AAAA,MACrD,aAAa,IAAI,SAAS;AAAA,MAC1B,WAAW,IAAI,SAAS;AAAA,MACxB,UAAU,IAAI,SAAS;AAAA,MACvB,gBAAgB,IAAI,SAAS;AAAA,MAC7B,KAAK;AAAA,IACP,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -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\
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\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: WorkerMeta = {\n queue: 'webhook-deliveries',\n id: 'webhooks:delivery-worker',\n concurrency: 10,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport default async function handler(\n job: QueuedJob<WebhookDeliveryJob>,\n ctx: HandlerContext,\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 organizationId: job.payload?.organizationId,\n err: error,\n })\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,oBAAoB;AAC7B,SAAS,iCAA0D;AAEnE,MAAM,SAAS,aAAa,UAAU,EAAE,MAAM,EAAE,WAAW,WAAW,CAAC;AAEhE,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AAMA,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,gBAAgB,IAAI,SAAS;AAAA,MAC7B,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.
|
|
3
|
+
"version": "0.10.1-develop.7577.1.477f2486e5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
|
|
6
6
|
"type": "module",
|
|
@@ -70,20 +70,20 @@
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
|
-
"@open-mercato/cache": "0.
|
|
74
|
-
"@open-mercato/core": "0.
|
|
75
|
-
"@open-mercato/queue": "0.
|
|
76
|
-
"@open-mercato/ui": "0.
|
|
73
|
+
"@open-mercato/cache": "0.10.1-develop.7577.1.477f2486e5",
|
|
74
|
+
"@open-mercato/core": "0.10.1-develop.7577.1.477f2486e5",
|
|
75
|
+
"@open-mercato/queue": "0.10.1-develop.7577.1.477f2486e5",
|
|
76
|
+
"@open-mercato/ui": "0.10.1-develop.7577.1.477f2486e5",
|
|
77
77
|
"svix": "^1.99.1"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
80
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
81
|
-
"@open-mercato/shared": "0.
|
|
81
|
+
"@open-mercato/shared": "0.10.1-develop.7577.1.477f2486e5",
|
|
82
82
|
"react": "^19.0.0",
|
|
83
83
|
"react-dom": "^19.0.0"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
-
"@open-mercato/shared": "0.
|
|
86
|
+
"@open-mercato/shared": "0.10.1-develop.7577.1.477f2486e5",
|
|
87
87
|
"@types/jest": "^30.0.0",
|
|
88
88
|
"@types/react": "^19.2.18",
|
|
89
89
|
"@types/react-dom": "^19.2.5",
|
|
@@ -103,5 +103,5 @@
|
|
|
103
103
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
104
104
|
"directory": "packages/webhooks"
|
|
105
105
|
},
|
|
106
|
-
"stableVersion": "0.
|
|
106
|
+
"stableVersion": "0.10.0"
|
|
107
107
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174072'
|
|
4
|
+
|
|
5
|
+
const webhookRecord = {
|
|
6
|
+
id: WEBHOOK_ID,
|
|
7
|
+
isActive: false,
|
|
8
|
+
consecutiveFailures: 2,
|
|
9
|
+
autoDisableThreshold: 2,
|
|
10
|
+
organizationId: 'org-1',
|
|
11
|
+
tenantId: 'tenant-1',
|
|
12
|
+
updatedAt: new Date('2026-06-01T10:00:00.000Z'),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const mockEm = {
|
|
16
|
+
fork: jest.fn(() => mockEm),
|
|
17
|
+
flush: jest.fn(async () => undefined),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const mockContainer = {
|
|
21
|
+
resolve: (token: string) => {
|
|
22
|
+
if (token === 'em') return mockEm
|
|
23
|
+
if (token === 'commandOptimisticLockGuardService') throw new Error('not registered')
|
|
24
|
+
return null
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
jest.mock('../../../../events', () => ({
|
|
29
|
+
emitWebhooksEvent: jest.fn(async () => undefined),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
jest.mock('../../../../lib/subscription-cache', () => ({
|
|
33
|
+
invalidateWebhookSubscriptionCacheFor: jest.fn(async () => undefined),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
jest.mock('../../../helpers', () => ({
|
|
37
|
+
json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
|
|
38
|
+
new Response(JSON.stringify(payload), {
|
|
39
|
+
...init,
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
}),
|
|
42
|
+
resolveWebhookRequestScope: jest.fn(async () => ({
|
|
43
|
+
container: mockContainer,
|
|
44
|
+
em: mockEm,
|
|
45
|
+
tenantId: 'tenant-1',
|
|
46
|
+
organizationId: 'org-1',
|
|
47
|
+
})),
|
|
48
|
+
findScopedWebhook: jest.fn(async () => webhookRecord),
|
|
49
|
+
serializeWebhookDetail: (item: { id: string; isActive: boolean; consecutiveFailures: number }) => ({
|
|
50
|
+
id: item.id,
|
|
51
|
+
isActive: item.isActive,
|
|
52
|
+
consecutiveFailures: item.consecutiveFailures,
|
|
53
|
+
}),
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
import { PUT } from '../route'
|
|
57
|
+
|
|
58
|
+
const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
|
|
59
|
+
|
|
60
|
+
function putRequest(body: unknown) {
|
|
61
|
+
return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
|
|
62
|
+
method: 'PUT',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('PUT /api/webhooks/:id re-activation', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
webhookRecord.isActive = false
|
|
71
|
+
webhookRecord.consecutiveFailures = 2
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resets consecutiveFailures when an auto-disabled webhook is turned back on', async () => {
|
|
75
|
+
const res = await PUT(putRequest({ isActive: true }), context)
|
|
76
|
+
expect(res.status).toBe(200)
|
|
77
|
+
expect(await res.json()).toEqual({ id: WEBHOOK_ID, isActive: true, consecutiveFailures: 0 })
|
|
78
|
+
expect(mockEm.flush).toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps consecutiveFailures on an update that does not re-activate the webhook', async () => {
|
|
82
|
+
const res = await PUT(putRequest({ name: 'Renamed' }), context)
|
|
83
|
+
expect(res.status).toBe(200)
|
|
84
|
+
expect(await res.json()).toEqual({ id: WEBHOOK_ID, isActive: false, consecutiveFailures: 2 })
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -7,6 +7,7 @@ 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
9
|
import { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'
|
|
10
|
+
import { applyWebhookActiveState } from '../../../lib/activation'
|
|
10
11
|
|
|
11
12
|
export const metadata = {
|
|
12
13
|
GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
|
|
@@ -103,7 +104,7 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
|
|
|
103
104
|
if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute
|
|
104
105
|
if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold
|
|
105
106
|
if (input.integrationId !== undefined) webhook.integrationId = input.integrationId
|
|
106
|
-
if (input.isActive !== undefined) webhook
|
|
107
|
+
if (input.isActive !== undefined) applyWebhookActiveState(webhook, input.isActive)
|
|
107
108
|
|
|
108
109
|
await em.flush()
|
|
109
110
|
|
|
@@ -11,6 +11,7 @@ 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
13
|
import { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'
|
|
14
|
+
import { applyWebhookActiveState } from '../../lib/activation'
|
|
14
15
|
|
|
15
16
|
type WebhookCrudCtx = CrudCtx & {
|
|
16
17
|
__webhookSecret?: string
|
|
@@ -135,7 +136,7 @@ const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeo
|
|
|
135
136
|
if (input.rateLimitPerMinute !== undefined) entity.rateLimitPerMinute = input.rateLimitPerMinute
|
|
136
137
|
if (input.autoDisableThreshold !== undefined) entity.autoDisableThreshold = input.autoDisableThreshold
|
|
137
138
|
if (input.integrationId !== undefined) entity.integrationId = input.integrationId
|
|
138
|
-
if (input.isActive !== undefined) entity
|
|
139
|
+
if (input.isActive !== undefined) applyWebhookActiveState(entity, input.isActive)
|
|
139
140
|
},
|
|
140
141
|
},
|
|
141
142
|
del: { idFrom: 'query' },
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { applyWebhookActiveState } from '../activation'
|
|
2
|
+
|
|
3
|
+
describe('applyWebhookActiveState', () => {
|
|
4
|
+
it('resets the failure counter when an inactive webhook is re-activated', () => {
|
|
5
|
+
const webhook = { isActive: false, consecutiveFailures: 2 }
|
|
6
|
+
applyWebhookActiveState(webhook, true)
|
|
7
|
+
expect(webhook).toEqual({ isActive: true, consecutiveFailures: 0 })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('keeps the failure counter when an already active webhook is saved as active', () => {
|
|
11
|
+
const webhook = { isActive: true, consecutiveFailures: 1 }
|
|
12
|
+
applyWebhookActiveState(webhook, true)
|
|
13
|
+
expect(webhook).toEqual({ isActive: true, consecutiveFailures: 1 })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('keeps the failure counter when a webhook is deactivated', () => {
|
|
17
|
+
const webhook = { isActive: true, consecutiveFailures: 1 }
|
|
18
|
+
applyWebhookActiveState(webhook, false)
|
|
19
|
+
expect(webhook).toEqual({ isActive: false, consecutiveFailures: 1 })
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WebhookEntity } from '../data/entities'
|
|
2
|
+
|
|
3
|
+
export function applyWebhookActiveState(
|
|
4
|
+
webhook: Pick<WebhookEntity, 'isActive' | 'consecutiveFailures'>,
|
|
5
|
+
isActive: boolean,
|
|
6
|
+
): void {
|
|
7
|
+
if (isActive && !webhook.isActive) webhook.consecutiveFailures = 0
|
|
8
|
+
webhook.isActive = isActive
|
|
9
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { JobContext, QueuedJob } from '@open-mercato/queue'
|
|
3
|
+
import handler, { metadata } from '../inbound-dispatch'
|
|
4
|
+
import { WEBHOOK_INBOUND_DISPATCH_QUEUE } from '../../lib/queue'
|
|
5
|
+
import type { InboundDispatchJob } from '../../lib/inbound-dispatch'
|
|
6
|
+
|
|
7
|
+
const mockProcessInboundDispatchJob = jest.fn()
|
|
8
|
+
const mockLoggerError = jest.fn()
|
|
9
|
+
|
|
10
|
+
jest.mock('../../lib/inbound-dispatch', () => ({
|
|
11
|
+
processInboundDispatchJob: (...args: unknown[]) => mockProcessInboundDispatchJob(...args),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
15
|
+
const mocked = {
|
|
16
|
+
debug: jest.fn(),
|
|
17
|
+
info: jest.fn(),
|
|
18
|
+
warn: jest.fn(),
|
|
19
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
20
|
+
child: () => mocked,
|
|
21
|
+
}
|
|
22
|
+
return { createLogger: () => mocked }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
type DispatchDeps = { resolve: <T = unknown>(name: string) => T }
|
|
26
|
+
|
|
27
|
+
function makeEm() {
|
|
28
|
+
const forked = { id: 'forked-em' } as unknown as EntityManager
|
|
29
|
+
const em = { fork: jest.fn(() => forked) } as unknown as EntityManager
|
|
30
|
+
return { em, forked }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeCtx(em: EntityManager) {
|
|
34
|
+
const resolved: string[] = []
|
|
35
|
+
const ctx: JobContext & DispatchDeps & { resolved: string[] } = {
|
|
36
|
+
jobId: 'job-1',
|
|
37
|
+
attemptNumber: 1,
|
|
38
|
+
queueName: WEBHOOK_INBOUND_DISPATCH_QUEUE,
|
|
39
|
+
resolved,
|
|
40
|
+
resolve: <T,>(name: string): T => {
|
|
41
|
+
resolved.push(name)
|
|
42
|
+
return (name === 'em' ? em : `resolved:${name}`) as T
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
return ctx
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function depsFromLastCall(): DispatchDeps {
|
|
49
|
+
const call = mockProcessInboundDispatchJob.mock.calls[0] as [EntityManager, InboundDispatchJob, DispatchDeps]
|
|
50
|
+
return call[2]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('webhooks inbound dispatch worker', () => {
|
|
54
|
+
const payload: InboundDispatchJob = {
|
|
55
|
+
ingestionId: 'ingestion-1',
|
|
56
|
+
sourceKey: 'stripe',
|
|
57
|
+
eventType: 'payment.succeeded',
|
|
58
|
+
tenantId: 'tenant-1',
|
|
59
|
+
organizationId: 'org-1',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const job: QueuedJob<InboundDispatchJob> = {
|
|
63
|
+
id: 'job-1',
|
|
64
|
+
payload,
|
|
65
|
+
createdAt: '2026-10-07T00:00:00.000Z',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
jest.clearAllMocks()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('consumes the queue the dispatcher publishes to', () => {
|
|
73
|
+
expect(metadata.queue).toBe(WEBHOOK_INBOUND_DISPATCH_QUEUE)
|
|
74
|
+
expect(metadata.id).toBe('webhooks:inbound-dispatch-worker')
|
|
75
|
+
expect(metadata.concurrency).toBe(5)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reads the job payload off the queue envelope and forks the em', async () => {
|
|
79
|
+
const { em, forked } = makeEm()
|
|
80
|
+
mockProcessInboundDispatchJob.mockResolvedValue(null)
|
|
81
|
+
|
|
82
|
+
await handler(job, makeCtx(em))
|
|
83
|
+
|
|
84
|
+
expect(em.fork).toHaveBeenCalledTimes(1)
|
|
85
|
+
expect(mockProcessInboundDispatchJob).toHaveBeenCalledWith(
|
|
86
|
+
forked,
|
|
87
|
+
payload,
|
|
88
|
+
expect.objectContaining({ resolve: expect.any(Function) }),
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('hands the handler a resolver that delegates to the worker context', async () => {
|
|
93
|
+
const { em } = makeEm()
|
|
94
|
+
const ctx = makeCtx(em)
|
|
95
|
+
mockProcessInboundDispatchJob.mockResolvedValue(null)
|
|
96
|
+
|
|
97
|
+
await handler(job, ctx)
|
|
98
|
+
|
|
99
|
+
expect(depsFromLastCall().resolve('eventBus')).toBe('resolved:eventBus')
|
|
100
|
+
expect(ctx.resolved).toContain('eventBus')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('re-throws the original error so the queue can retry', async () => {
|
|
104
|
+
const { em } = makeEm()
|
|
105
|
+
mockProcessInboundDispatchJob.mockRejectedValue(new Error('DB connection lost'))
|
|
106
|
+
|
|
107
|
+
await expect(handler(job, makeCtx(em))).rejects.toThrow('DB connection lost')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('logs the failing job identifiers instead of masking the error', async () => {
|
|
111
|
+
const { em } = makeEm()
|
|
112
|
+
const cause = new Error('handler blew up')
|
|
113
|
+
mockProcessInboundDispatchJob.mockRejectedValue(cause)
|
|
114
|
+
|
|
115
|
+
await expect(handler(job, makeCtx(em))).rejects.toBe(cause)
|
|
116
|
+
|
|
117
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
118
|
+
'Inbound dispatch job processing failed',
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
ingestionId: 'ingestion-1',
|
|
121
|
+
sourceKey: 'stripe',
|
|
122
|
+
tenantId: 'tenant-1',
|
|
123
|
+
organizationId: 'org-1',
|
|
124
|
+
err: cause,
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('surfaces the original error even when the envelope carries no payload', async () => {
|
|
130
|
+
const { em } = makeEm()
|
|
131
|
+
const cause = new Error('handler blew up')
|
|
132
|
+
mockProcessInboundDispatchJob.mockRejectedValue(cause)
|
|
133
|
+
const payloadless = { id: 'job-1', createdAt: '2026-10-07T00:00:00.000Z' } as QueuedJob<InboundDispatchJob>
|
|
134
|
+
|
|
135
|
+
await expect(handler(payloadless, makeCtx(em))).rejects.toBe(cause)
|
|
136
|
+
|
|
137
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
138
|
+
'Inbound dispatch job processing failed',
|
|
139
|
+
expect.objectContaining({ ingestionId: undefined, err: cause }),
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -1,46 +1,139 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
-
import
|
|
2
|
+
import type { JobContext, QueuedJob } from '@open-mercato/queue'
|
|
3
|
+
import handler, { metadata } from '../webhook-delivery'
|
|
4
|
+
import { WEBHOOK_DELIVERIES_QUEUE } from '../../lib/queue'
|
|
5
|
+
import type { WebhookDeliveryJob } from '../../lib/delivery'
|
|
3
6
|
|
|
4
7
|
const mockProcessWebhookDeliveryJob = jest.fn()
|
|
8
|
+
const mockLoggerError = jest.fn()
|
|
5
9
|
|
|
6
10
|
jest.mock('../../lib/delivery', () => ({
|
|
7
11
|
processWebhookDeliveryJob: (...args: unknown[]) => mockProcessWebhookDeliveryJob(...args),
|
|
8
12
|
}))
|
|
9
13
|
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
15
|
+
const mocked = {
|
|
16
|
+
debug: jest.fn(),
|
|
17
|
+
info: jest.fn(),
|
|
18
|
+
warn: jest.fn(),
|
|
19
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
20
|
+
child: () => mocked,
|
|
21
|
+
}
|
|
22
|
+
return { createLogger: () => mocked }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
type DeliveryDeps = { resolver: <T = unknown>(name: string) => T }
|
|
26
|
+
|
|
27
|
+
function makeEm() {
|
|
28
|
+
const forked = { id: 'forked-em' } as unknown as EntityManager
|
|
29
|
+
const em = { fork: jest.fn(() => forked) } as unknown as EntityManager
|
|
30
|
+
return { em, forked }
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
function makeCtx(em: EntityManager) {
|
|
11
|
-
|
|
34
|
+
const resolved: string[] = []
|
|
35
|
+
const ctx: JobContext & { resolve: <T = unknown>(name: string) => T; resolved: string[] } = {
|
|
36
|
+
jobId: 'job-1',
|
|
37
|
+
attemptNumber: 1,
|
|
38
|
+
queueName: WEBHOOK_DELIVERIES_QUEUE,
|
|
39
|
+
resolved,
|
|
12
40
|
resolve: <T,>(name: string): T => {
|
|
13
|
-
|
|
14
|
-
|
|
41
|
+
resolved.push(name)
|
|
42
|
+
return (name === 'em' ? em : `resolved:${name}`) as T
|
|
15
43
|
},
|
|
16
44
|
}
|
|
45
|
+
return ctx
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function depsFromLastCall(): DeliveryDeps {
|
|
49
|
+
const call = mockProcessWebhookDeliveryJob.mock.calls[0] as [EntityManager, WebhookDeliveryJob, DeliveryDeps]
|
|
50
|
+
return call[2]
|
|
17
51
|
}
|
|
18
52
|
|
|
19
53
|
describe('webhooks delivery worker', () => {
|
|
20
|
-
const
|
|
54
|
+
const payload: WebhookDeliveryJob = {
|
|
55
|
+
deliveryId: 'delivery-1',
|
|
56
|
+
tenantId: 'tenant-1',
|
|
57
|
+
organizationId: 'org-1',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const job: QueuedJob<WebhookDeliveryJob> = {
|
|
61
|
+
id: 'job-1',
|
|
62
|
+
payload,
|
|
63
|
+
createdAt: '2026-10-07T00:00:00.000Z',
|
|
64
|
+
}
|
|
21
65
|
|
|
22
66
|
afterEach(() => {
|
|
23
67
|
jest.clearAllMocks()
|
|
24
68
|
})
|
|
25
69
|
|
|
26
|
-
it('
|
|
27
|
-
|
|
70
|
+
it('consumes the queue the dispatcher publishes to', () => {
|
|
71
|
+
expect(metadata.queue).toBe(WEBHOOK_DELIVERIES_QUEUE)
|
|
72
|
+
expect(metadata.id).toBe('webhooks:delivery-worker')
|
|
73
|
+
expect(metadata.concurrency).toBe(10)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reads the job payload off the queue envelope and forks the em', async () => {
|
|
77
|
+
const { em, forked } = makeEm()
|
|
28
78
|
mockProcessWebhookDeliveryJob.mockResolvedValue(null)
|
|
29
79
|
|
|
30
|
-
await handler(
|
|
80
|
+
await handler(job, makeCtx(em))
|
|
31
81
|
|
|
82
|
+
expect(em.fork).toHaveBeenCalledTimes(1)
|
|
32
83
|
expect(mockProcessWebhookDeliveryJob).toHaveBeenCalledWith(
|
|
33
|
-
|
|
34
|
-
|
|
84
|
+
forked,
|
|
85
|
+
payload,
|
|
35
86
|
expect.objectContaining({ resolver: expect.any(Function) }),
|
|
36
87
|
)
|
|
37
88
|
})
|
|
38
89
|
|
|
39
|
-
it('
|
|
40
|
-
const em =
|
|
41
|
-
const
|
|
90
|
+
it('hands the delivery a resolver that delegates to the worker context', async () => {
|
|
91
|
+
const { em } = makeEm()
|
|
92
|
+
const ctx = makeCtx(em)
|
|
93
|
+
mockProcessWebhookDeliveryJob.mockResolvedValue(null)
|
|
94
|
+
|
|
95
|
+
await handler(job, ctx)
|
|
96
|
+
|
|
97
|
+
expect(depsFromLastCall().resolver('eventBus')).toBe('resolved:eventBus')
|
|
98
|
+
expect(ctx.resolved).toContain('eventBus')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('re-throws the original error so the queue can retry', async () => {
|
|
102
|
+
const { em } = makeEm()
|
|
103
|
+
mockProcessWebhookDeliveryJob.mockRejectedValue(new Error('DB connection lost'))
|
|
104
|
+
|
|
105
|
+
await expect(handler(job, makeCtx(em))).rejects.toThrow('DB connection lost')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('logs the failing job identifiers instead of masking the error', async () => {
|
|
109
|
+
const { em } = makeEm()
|
|
110
|
+
const cause = new Error('delivery blew up')
|
|
111
|
+
mockProcessWebhookDeliveryJob.mockRejectedValue(cause)
|
|
112
|
+
|
|
113
|
+
await expect(handler(job, makeCtx(em))).rejects.toBe(cause)
|
|
114
|
+
|
|
115
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
116
|
+
'Job processing failed',
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
deliveryId: 'delivery-1',
|
|
119
|
+
tenantId: 'tenant-1',
|
|
120
|
+
organizationId: 'org-1',
|
|
121
|
+
err: cause,
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('surfaces the original error even when the envelope carries no payload', async () => {
|
|
127
|
+
const { em } = makeEm()
|
|
128
|
+
const cause = new Error('delivery blew up')
|
|
42
129
|
mockProcessWebhookDeliveryJob.mockRejectedValue(cause)
|
|
130
|
+
const payloadless = { id: 'job-1', createdAt: '2026-10-07T00:00:00.000Z' } as QueuedJob<WebhookDeliveryJob>
|
|
131
|
+
|
|
132
|
+
await expect(handler(payloadless, makeCtx(em))).rejects.toBe(cause)
|
|
43
133
|
|
|
44
|
-
|
|
134
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
135
|
+
'Job processing failed',
|
|
136
|
+
expect.objectContaining({ deliveryId: undefined, err: cause }),
|
|
137
|
+
)
|
|
45
138
|
})
|
|
46
139
|
})
|
|
@@ -1,29 +1,35 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
2
3
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
4
|
import { processInboundDispatchJob, type InboundDispatchJob } from '../lib/inbound-dispatch'
|
|
4
5
|
|
|
5
6
|
const logger = createLogger('webhooks')
|
|
6
7
|
|
|
7
|
-
export const metadata = {
|
|
8
|
+
export const metadata: WorkerMeta = {
|
|
8
9
|
queue: 'webhook-inbound-dispatch',
|
|
9
10
|
id: 'webhooks:inbound-dispatch-worker',
|
|
10
11
|
concurrency: 5,
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
type HandlerContext = JobContext & {
|
|
15
|
+
resolve: <T = unknown>(name: string) => T
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export default async function handler(
|
|
14
|
-
job:
|
|
15
|
-
ctx:
|
|
19
|
+
job: QueuedJob<InboundDispatchJob>,
|
|
20
|
+
ctx: HandlerContext,
|
|
16
21
|
) {
|
|
17
22
|
const em = (ctx.resolve('em') as EntityManager).fork()
|
|
18
23
|
try {
|
|
19
|
-
await processInboundDispatchJob(em, job.
|
|
24
|
+
await processInboundDispatchJob(em, job.payload, {
|
|
20
25
|
resolve: <T,>(name: string) => ctx.resolve(name) as T,
|
|
21
26
|
})
|
|
22
27
|
} catch (error) {
|
|
23
28
|
logger.error('Inbound dispatch job processing failed', {
|
|
24
|
-
ingestionId: job.
|
|
25
|
-
sourceKey: job.
|
|
26
|
-
tenantId: job.
|
|
29
|
+
ingestionId: job.payload?.ingestionId,
|
|
30
|
+
sourceKey: job.payload?.sourceKey,
|
|
31
|
+
tenantId: job.payload?.tenantId,
|
|
32
|
+
organizationId: job.payload?.organizationId,
|
|
27
33
|
err: error,
|
|
28
34
|
})
|
|
29
35
|
throw error
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
2
3
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
4
|
import { processWebhookDeliveryJob, type WebhookDeliveryJob } from '../lib/delivery'
|
|
4
5
|
|
|
5
6
|
const logger = createLogger('webhooks').child({ component: 'delivery' })
|
|
6
7
|
|
|
7
|
-
export const metadata = {
|
|
8
|
+
export const metadata: WorkerMeta = {
|
|
8
9
|
queue: 'webhook-deliveries',
|
|
9
10
|
id: 'webhooks:delivery-worker',
|
|
10
11
|
concurrency: 10,
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
type HandlerContext = JobContext & {
|
|
15
|
+
resolve: <T = unknown>(name: string) => T
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export default async function handler(
|
|
14
|
-
job:
|
|
15
|
-
ctx:
|
|
19
|
+
job: QueuedJob<WebhookDeliveryJob>,
|
|
20
|
+
ctx: HandlerContext,
|
|
16
21
|
) {
|
|
17
22
|
const em = (ctx.resolve('em') as EntityManager).fork()
|
|
18
23
|
try {
|
|
@@ -21,6 +26,7 @@ export default async function handler(
|
|
|
21
26
|
logger.error('Job processing failed', {
|
|
22
27
|
deliveryId: job.payload?.deliveryId,
|
|
23
28
|
tenantId: job.payload?.tenantId,
|
|
29
|
+
organizationId: job.payload?.organizationId,
|
|
24
30
|
err: error,
|
|
25
31
|
})
|
|
26
32
|
throw error
|