@open-mercato/webhooks 0.4.9-canary-8c762104f0
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/CLAUDE.md +1 -0
- package/build.mjs +69 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/webhooks/acl.js +14 -0
- package/dist/modules/webhooks/acl.js.map +7 -0
- package/dist/modules/webhooks/api/openapi.js +8 -0
- package/dist/modules/webhooks/api/openapi.js.map +7 -0
- package/dist/modules/webhooks/api/webhook-deliveries/route.js +118 -0
- package/dist/modules/webhooks/api/webhook-deliveries/route.js.map +7 -0
- package/dist/modules/webhooks/api/webhooks/route.js +268 -0
- package/dist/modules/webhooks/api/webhooks/route.js.map +7 -0
- package/dist/modules/webhooks/backend/webhooks/[id]/page.meta.js +17 -0
- package/dist/modules/webhooks/backend/webhooks/[id]/page.meta.js.map +7 -0
- package/dist/modules/webhooks/backend/webhooks/create/page.meta.js +17 -0
- package/dist/modules/webhooks/backend/webhooks/create/page.meta.js.map +7 -0
- package/dist/modules/webhooks/backend/webhooks/page.meta.js +24 -0
- package/dist/modules/webhooks/backend/webhooks/page.meta.js.map +7 -0
- package/dist/modules/webhooks/data/entities.js +196 -0
- package/dist/modules/webhooks/data/entities.js.map +7 -0
- package/dist/modules/webhooks/data/validators.js +39 -0
- package/dist/modules/webhooks/data/validators.js.map +7 -0
- package/dist/modules/webhooks/events.js +18 -0
- package/dist/modules/webhooks/events.js.map +7 -0
- package/dist/modules/webhooks/index.js +14 -0
- package/dist/modules/webhooks/index.js.map +7 -0
- package/dist/modules/webhooks/setup.js +12 -0
- package/dist/modules/webhooks/setup.js.map +7 -0
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js +67 -0
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +7 -0
- package/dist/modules/webhooks/workers/webhook-delivery.js +129 -0
- package/dist/modules/webhooks/workers/webhook-delivery.js.map +7 -0
- package/generated/entities/webhook_delivery_entity/index.ts +22 -0
- package/generated/entities/webhook_entity/index.ts +26 -0
- package/generated/entities.ids.generated.ts +12 -0
- package/generated/entity-fields-registry.ts +13 -0
- package/jest.config.cjs +20 -0
- package/package.json +77 -0
- package/src/index.ts +1 -0
- package/src/modules/webhooks/acl.ts +10 -0
- package/src/modules/webhooks/api/openapi.ts +5 -0
- package/src/modules/webhooks/api/webhook-deliveries/route.ts +131 -0
- package/src/modules/webhooks/api/webhooks/route.ts +288 -0
- package/src/modules/webhooks/backend/webhooks/[id]/page.meta.ts +13 -0
- package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +262 -0
- package/src/modules/webhooks/backend/webhooks/create/page.meta.ts +13 -0
- package/src/modules/webhooks/backend/webhooks/create/page.tsx +75 -0
- package/src/modules/webhooks/backend/webhooks/page.meta.ts +20 -0
- package/src/modules/webhooks/backend/webhooks/page.tsx +206 -0
- package/src/modules/webhooks/data/entities.ts +157 -0
- package/src/modules/webhooks/data/validators.ts +40 -0
- package/src/modules/webhooks/events.ts +15 -0
- package/src/modules/webhooks/i18n/en.json +73 -0
- package/src/modules/webhooks/index.ts +12 -0
- package/src/modules/webhooks/setup.ts +10 -0
- package/src/modules/webhooks/subscribers/outbound-dispatch.ts +79 -0
- package/src/modules/webhooks/workers/webhook-delivery.ts +158 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Entity, PrimaryKey, Property, Index } from '@mikro-orm/core'
|
|
2
|
+
|
|
3
|
+
@Entity({ tableName: 'webhooks' })
|
|
4
|
+
@Index({ properties: ['organizationId', 'tenantId', 'isActive'] })
|
|
5
|
+
@Index({ properties: ['organizationId', 'tenantId', 'deletedAt'] })
|
|
6
|
+
export class WebhookEntity {
|
|
7
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
8
|
+
id!: string
|
|
9
|
+
|
|
10
|
+
@Property({ type: 'text' })
|
|
11
|
+
name!: string
|
|
12
|
+
|
|
13
|
+
@Property({ name: 'description', type: 'text', nullable: true })
|
|
14
|
+
description?: string | null
|
|
15
|
+
|
|
16
|
+
@Property({ name: 'url', type: 'text' })
|
|
17
|
+
url!: string
|
|
18
|
+
|
|
19
|
+
@Property({ name: 'secret', type: 'text' })
|
|
20
|
+
secret!: string
|
|
21
|
+
|
|
22
|
+
@Property({ name: 'previous_secret', type: 'text', nullable: true })
|
|
23
|
+
previousSecret?: string | null
|
|
24
|
+
|
|
25
|
+
@Property({ name: 'previous_secret_set_at', type: Date, nullable: true })
|
|
26
|
+
previousSecretSetAt?: Date | null
|
|
27
|
+
|
|
28
|
+
@Property({ name: 'subscribed_events', type: 'json' })
|
|
29
|
+
subscribedEvents!: string[]
|
|
30
|
+
|
|
31
|
+
@Property({ name: 'http_method', type: 'text', default: "'POST'" })
|
|
32
|
+
httpMethod: string = 'POST'
|
|
33
|
+
|
|
34
|
+
@Property({ name: 'custom_headers', type: 'json', nullable: true })
|
|
35
|
+
customHeaders?: Record<string, string> | null
|
|
36
|
+
|
|
37
|
+
@Property({ name: 'is_active', type: 'boolean', default: true })
|
|
38
|
+
isActive: boolean = true
|
|
39
|
+
|
|
40
|
+
@Property({ name: 'delivery_strategy', type: 'text', default: "'http'" })
|
|
41
|
+
deliveryStrategy: string = 'http'
|
|
42
|
+
|
|
43
|
+
@Property({ name: 'strategy_config', type: 'json', nullable: true })
|
|
44
|
+
strategyConfig?: Record<string, unknown> | null
|
|
45
|
+
|
|
46
|
+
@Property({ name: 'max_retries', type: 'int', default: 10 })
|
|
47
|
+
maxRetries: number = 10
|
|
48
|
+
|
|
49
|
+
@Property({ name: 'timeout_ms', type: 'int', default: 15000 })
|
|
50
|
+
timeoutMs: number = 15000
|
|
51
|
+
|
|
52
|
+
@Property({ name: 'rate_limit_per_minute', type: 'int', default: 0 })
|
|
53
|
+
rateLimitPerMinute: number = 0
|
|
54
|
+
|
|
55
|
+
@Property({ name: 'consecutive_failures', type: 'int', default: 0 })
|
|
56
|
+
consecutiveFailures: number = 0
|
|
57
|
+
|
|
58
|
+
@Property({ name: 'auto_disable_threshold', type: 'int', default: 100 })
|
|
59
|
+
autoDisableThreshold: number = 100
|
|
60
|
+
|
|
61
|
+
@Property({ name: 'last_success_at', type: Date, nullable: true })
|
|
62
|
+
lastSuccessAt?: Date | null
|
|
63
|
+
|
|
64
|
+
@Property({ name: 'last_failure_at', type: Date, nullable: true })
|
|
65
|
+
lastFailureAt?: Date | null
|
|
66
|
+
|
|
67
|
+
@Property({ name: 'integration_id', type: 'text', nullable: true })
|
|
68
|
+
integrationId?: string | null
|
|
69
|
+
|
|
70
|
+
@Property({ name: 'organization_id', type: 'uuid' })
|
|
71
|
+
organizationId!: string
|
|
72
|
+
|
|
73
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
74
|
+
tenantId!: string
|
|
75
|
+
|
|
76
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
77
|
+
createdAt: Date = new Date()
|
|
78
|
+
|
|
79
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
80
|
+
updatedAt: Date = new Date()
|
|
81
|
+
|
|
82
|
+
@Property({ name: 'deleted_at', type: Date, nullable: true })
|
|
83
|
+
deletedAt?: Date | null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@Entity({ tableName: 'webhook_deliveries' })
|
|
87
|
+
@Index({ properties: ['webhookId', 'status'] })
|
|
88
|
+
@Index({ properties: ['organizationId', 'tenantId', 'createdAt'] })
|
|
89
|
+
@Index({ properties: ['webhookId', 'createdAt'] })
|
|
90
|
+
@Index({ properties: ['eventType', 'organizationId'] })
|
|
91
|
+
export class WebhookDeliveryEntity {
|
|
92
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
93
|
+
id!: string
|
|
94
|
+
|
|
95
|
+
@Property({ name: 'webhook_id', type: 'uuid' })
|
|
96
|
+
webhookId!: string
|
|
97
|
+
|
|
98
|
+
@Property({ name: 'event_type', type: 'text' })
|
|
99
|
+
eventType!: string
|
|
100
|
+
|
|
101
|
+
@Property({ name: 'message_id', type: 'text' })
|
|
102
|
+
messageId!: string
|
|
103
|
+
|
|
104
|
+
@Property({ name: 'payload', type: 'json' })
|
|
105
|
+
payload!: Record<string, unknown>
|
|
106
|
+
|
|
107
|
+
@Property({ name: 'status', type: 'text', default: "'pending'" })
|
|
108
|
+
status: string = 'pending'
|
|
109
|
+
|
|
110
|
+
@Property({ name: 'response_status', type: 'int', nullable: true })
|
|
111
|
+
responseStatus?: number | null
|
|
112
|
+
|
|
113
|
+
@Property({ name: 'response_body', type: 'text', nullable: true })
|
|
114
|
+
responseBody?: string | null
|
|
115
|
+
|
|
116
|
+
@Property({ name: 'response_headers', type: 'json', nullable: true })
|
|
117
|
+
responseHeaders?: Record<string, string> | null
|
|
118
|
+
|
|
119
|
+
@Property({ name: 'error_message', type: 'text', nullable: true })
|
|
120
|
+
errorMessage?: string | null
|
|
121
|
+
|
|
122
|
+
@Property({ name: 'attempt_number', type: 'int', default: 0 })
|
|
123
|
+
attemptNumber: number = 0
|
|
124
|
+
|
|
125
|
+
@Property({ name: 'max_attempts', type: 'int', default: 10 })
|
|
126
|
+
maxAttempts: number = 10
|
|
127
|
+
|
|
128
|
+
@Property({ name: 'next_retry_at', type: Date, nullable: true })
|
|
129
|
+
nextRetryAt?: Date | null
|
|
130
|
+
|
|
131
|
+
@Property({ name: 'duration_ms', type: 'int', nullable: true })
|
|
132
|
+
durationMs?: number | null
|
|
133
|
+
|
|
134
|
+
@Property({ name: 'target_url', type: 'text' })
|
|
135
|
+
targetUrl!: string
|
|
136
|
+
|
|
137
|
+
@Property({ name: 'enqueued_at', type: Date })
|
|
138
|
+
enqueuedAt: Date = new Date()
|
|
139
|
+
|
|
140
|
+
@Property({ name: 'last_attempt_at', type: Date, nullable: true })
|
|
141
|
+
lastAttemptAt?: Date | null
|
|
142
|
+
|
|
143
|
+
@Property({ name: 'delivered_at', type: Date, nullable: true })
|
|
144
|
+
deliveredAt?: Date | null
|
|
145
|
+
|
|
146
|
+
@Property({ name: 'organization_id', type: 'uuid' })
|
|
147
|
+
organizationId!: string
|
|
148
|
+
|
|
149
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
150
|
+
tenantId!: string
|
|
151
|
+
|
|
152
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
153
|
+
createdAt: Date = new Date()
|
|
154
|
+
|
|
155
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
156
|
+
updatedAt: Date = new Date()
|
|
157
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const webhookCreateSchema = z.object({
|
|
4
|
+
name: z.string().min(1).max(255),
|
|
5
|
+
description: z.string().max(1000).optional().nullable(),
|
|
6
|
+
url: z.string().url(),
|
|
7
|
+
subscribedEvents: z.array(z.string().min(1)).min(1),
|
|
8
|
+
httpMethod: z.enum(['POST', 'PUT', 'PATCH'] as const).default('POST'),
|
|
9
|
+
customHeaders: z.record(z.string(), z.string()).optional().nullable(),
|
|
10
|
+
deliveryStrategy: z.literal('http').default('http'),
|
|
11
|
+
strategyConfig: z.record(z.string(), z.unknown()).optional().nullable(),
|
|
12
|
+
maxRetries: z.number().int().min(0).max(30).default(10),
|
|
13
|
+
timeoutMs: z.number().int().min(1000).max(60000).default(15000),
|
|
14
|
+
rateLimitPerMinute: z.number().int().min(0).max(10000).default(0),
|
|
15
|
+
autoDisableThreshold: z.number().int().min(0).max(1000).default(100),
|
|
16
|
+
integrationId: z.string().optional().nullable(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export type WebhookCreateInput = z.infer<typeof webhookCreateSchema>
|
|
20
|
+
|
|
21
|
+
export const webhookUpdateSchema = webhookCreateSchema.partial().extend({
|
|
22
|
+
isActive: z.boolean().optional(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type WebhookUpdateInput = z.infer<typeof webhookUpdateSchema>
|
|
26
|
+
|
|
27
|
+
export const webhookListQuerySchema = z.object({
|
|
28
|
+
page: z.string().optional(),
|
|
29
|
+
pageSize: z.string().optional(),
|
|
30
|
+
search: z.string().optional(),
|
|
31
|
+
isActive: z.string().optional(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const webhookDeliveryQuerySchema = z.object({
|
|
35
|
+
page: z.coerce.number().min(1).default(1),
|
|
36
|
+
pageSize: z.coerce.number().min(1).max(100).default(50),
|
|
37
|
+
webhookId: z.string().uuid().optional(),
|
|
38
|
+
eventType: z.string().optional(),
|
|
39
|
+
status: z.enum(['pending', 'sending', 'delivered', 'failed', 'expired'] as const).optional(),
|
|
40
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createModuleEvents } from '@open-mercato/shared/modules/events'
|
|
2
|
+
|
|
3
|
+
const events = [
|
|
4
|
+
{ id: 'webhooks.webhook.created', label: 'Webhook Created', entity: 'webhook', category: 'crud' as const },
|
|
5
|
+
{ id: 'webhooks.webhook.updated', label: 'Webhook Updated', entity: 'webhook', category: 'crud' as const },
|
|
6
|
+
{ id: 'webhooks.webhook.deleted', label: 'Webhook Deleted', entity: 'webhook', category: 'crud' as const },
|
|
7
|
+
{ id: 'webhooks.delivery.succeeded', label: 'Webhook Delivery Succeeded', entity: 'delivery', category: 'lifecycle' as const },
|
|
8
|
+
{ id: 'webhooks.delivery.failed', label: 'Webhook Delivery Failed', entity: 'delivery', category: 'lifecycle' as const },
|
|
9
|
+
{ id: 'webhooks.webhook.disabled', label: 'Webhook Auto-Disabled', entity: 'webhook', category: 'lifecycle' as const },
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export const eventsConfig = createModuleEvents({ moduleId: 'webhooks', events })
|
|
13
|
+
export const emitWebhooksEvent = eventsConfig.emit
|
|
14
|
+
export type WebhooksEventId = typeof events[number]['id']
|
|
15
|
+
export default eventsConfig
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"webhooks.nav.title": "Webhooks",
|
|
3
|
+
"webhooks.nav.group": "Integrations",
|
|
4
|
+
"webhooks.nav.create": "Create Webhook",
|
|
5
|
+
|
|
6
|
+
"webhooks.list.title": "Webhooks",
|
|
7
|
+
"webhooks.list.description": "Manage webhook endpoints for receiving platform events.",
|
|
8
|
+
"webhooks.list.empty": "No webhooks configured yet.",
|
|
9
|
+
"webhooks.list.createFirst": "Create your first webhook to start receiving event notifications.",
|
|
10
|
+
"webhooks.list.columns.name": "Name",
|
|
11
|
+
"webhooks.list.columns.url": "URL",
|
|
12
|
+
"webhooks.list.columns.events": "Events",
|
|
13
|
+
"webhooks.list.columns.status": "Status",
|
|
14
|
+
"webhooks.list.columns.lastTriggered": "Last Triggered",
|
|
15
|
+
"webhooks.list.columns.successRate": "Health",
|
|
16
|
+
"webhooks.list.columns.createdAt": "Created",
|
|
17
|
+
"webhooks.list.status.active": "Active",
|
|
18
|
+
"webhooks.list.status.inactive": "Inactive",
|
|
19
|
+
"webhooks.list.actions.edit": "Edit",
|
|
20
|
+
"webhooks.list.actions.delete": "Delete",
|
|
21
|
+
"webhooks.list.actions.test": "Test",
|
|
22
|
+
"webhooks.list.confirmDelete": "Are you sure you want to delete this webhook? This action cannot be undone.",
|
|
23
|
+
"webhooks.list.deleteSuccess": "Webhook deleted successfully.",
|
|
24
|
+
"webhooks.list.deleteError": "Failed to delete webhook.",
|
|
25
|
+
|
|
26
|
+
"webhooks.form.title.create": "Create Webhook",
|
|
27
|
+
"webhooks.form.title.edit": "Edit Webhook",
|
|
28
|
+
"webhooks.form.name": "Name",
|
|
29
|
+
"webhooks.form.namePlaceholder": "My Webhook",
|
|
30
|
+
"webhooks.form.description": "Description",
|
|
31
|
+
"webhooks.form.descriptionPlaceholder": "Optional description for this webhook",
|
|
32
|
+
"webhooks.form.url": "Endpoint URL",
|
|
33
|
+
"webhooks.form.urlPlaceholder": "https://example.com/webhook",
|
|
34
|
+
"webhooks.form.urlHint": "The URL that will receive webhook payloads via HTTP POST.",
|
|
35
|
+
"webhooks.form.events": "Subscribed Events",
|
|
36
|
+
"webhooks.form.eventsHint": "Select which events should trigger this webhook.",
|
|
37
|
+
"webhooks.form.eventsPlaceholder": "Select events...",
|
|
38
|
+
"webhooks.form.httpMethod": "HTTP Method",
|
|
39
|
+
"webhooks.form.isActive": "Active",
|
|
40
|
+
"webhooks.form.maxRetries": "Max Retries",
|
|
41
|
+
"webhooks.form.maxRetriesHint": "Maximum number of delivery retry attempts (0-30).",
|
|
42
|
+
"webhooks.form.timeoutMs": "Timeout (ms)",
|
|
43
|
+
"webhooks.form.timeoutMsHint": "Request timeout in milliseconds (1000-60000).",
|
|
44
|
+
"webhooks.form.secret": "Signing Secret",
|
|
45
|
+
"webhooks.form.secretHint": "This secret is used to sign webhook payloads. Keep it safe.",
|
|
46
|
+
"webhooks.form.secretCopied": "Secret copied to clipboard.",
|
|
47
|
+
"webhooks.form.group.general": "General",
|
|
48
|
+
"webhooks.form.group.events": "Event Subscription",
|
|
49
|
+
"webhooks.form.group.delivery": "Delivery Settings",
|
|
50
|
+
"webhooks.form.createSuccess": "Webhook created successfully.",
|
|
51
|
+
"webhooks.form.createError": "Failed to create webhook.",
|
|
52
|
+
"webhooks.form.updateSuccess": "Webhook updated successfully.",
|
|
53
|
+
"webhooks.form.updateError": "Failed to update webhook.",
|
|
54
|
+
|
|
55
|
+
"webhooks.deliveries.title": "Delivery Log",
|
|
56
|
+
"webhooks.deliveries.empty": "No delivery attempts yet.",
|
|
57
|
+
"webhooks.deliveries.columns.event": "Event",
|
|
58
|
+
"webhooks.deliveries.columns.status": "Status",
|
|
59
|
+
"webhooks.deliveries.columns.responseStatus": "HTTP Status",
|
|
60
|
+
"webhooks.deliveries.columns.attempts": "Attempts",
|
|
61
|
+
"webhooks.deliveries.columns.duration": "Duration",
|
|
62
|
+
"webhooks.deliveries.columns.createdAt": "Time",
|
|
63
|
+
"webhooks.deliveries.status.pending": "Pending",
|
|
64
|
+
"webhooks.deliveries.status.sending": "Sending",
|
|
65
|
+
"webhooks.deliveries.status.delivered": "Delivered",
|
|
66
|
+
"webhooks.deliveries.status.failed": "Failed",
|
|
67
|
+
"webhooks.deliveries.status.expired": "Expired",
|
|
68
|
+
|
|
69
|
+
"webhooks.errors.tenantRequired": "Tenant context required.",
|
|
70
|
+
"webhooks.errors.orgRequired": "Organization context required.",
|
|
71
|
+
"webhooks.errors.notFound": "Webhook not found.",
|
|
72
|
+
"webhooks.errors.forbidden": "You do not have permission to perform this action."
|
|
73
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
|
|
3
|
+
export const metadata: ModuleInfo = {
|
|
4
|
+
name: 'webhooks',
|
|
5
|
+
title: 'Webhooks',
|
|
6
|
+
version: '0.1.0',
|
|
7
|
+
description: 'Standard Webhooks compliant outbound webhook delivery for platform events.',
|
|
8
|
+
author: 'Open Mercato Team',
|
|
9
|
+
license: 'Proprietary',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { features } from './acl'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
|
+
|
|
3
|
+
export const setup: ModuleSetupConfig = {
|
|
4
|
+
defaultRoleFeatures: {
|
|
5
|
+
admin: ['webhooks.*'],
|
|
6
|
+
employee: ['webhooks.view', 'webhooks.deliveries.view'],
|
|
7
|
+
},
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default setup
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { WebhookEntity } from '../data/entities'
|
|
3
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
event: '*',
|
|
7
|
+
persistent: true,
|
|
8
|
+
id: 'webhooks:outbound-dispatch',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default async function handler(
|
|
12
|
+
payload: Record<string, unknown>,
|
|
13
|
+
ctx: { container: { resolve: <T = unknown>(name: string) => T }; eventId?: string },
|
|
14
|
+
) {
|
|
15
|
+
const eventId = ctx.eventId ?? (payload.eventId as string) ?? (payload.type as string)
|
|
16
|
+
if (!eventId) return
|
|
17
|
+
|
|
18
|
+
const tenantId = payload.tenantId as string | undefined
|
|
19
|
+
const organizationId = payload.organizationId as string | undefined
|
|
20
|
+
if (!tenantId) return
|
|
21
|
+
|
|
22
|
+
if (eventId.startsWith('webhooks.')) return
|
|
23
|
+
|
|
24
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
25
|
+
|
|
26
|
+
const webhooks = await findWithDecryption(
|
|
27
|
+
em,
|
|
28
|
+
WebhookEntity,
|
|
29
|
+
{
|
|
30
|
+
isActive: true,
|
|
31
|
+
deletedAt: null,
|
|
32
|
+
tenantId,
|
|
33
|
+
...(organizationId ? { organizationId } : {}),
|
|
34
|
+
},
|
|
35
|
+
{},
|
|
36
|
+
{ tenantId, organizationId: organizationId ?? '' },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (!webhooks.length) return
|
|
40
|
+
|
|
41
|
+
const matchingWebhooks = webhooks.filter((webhook) =>
|
|
42
|
+
webhook.subscribedEvents.some((pattern) => eventMatchesPattern(eventId, pattern)),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if (!matchingWebhooks.length) return
|
|
46
|
+
|
|
47
|
+
const queue = ctx.container.resolve<{ enqueueJob: (data: unknown) => Promise<unknown> }>('queueService')
|
|
48
|
+
|
|
49
|
+
for (const webhook of matchingWebhooks) {
|
|
50
|
+
try {
|
|
51
|
+
await queue.enqueueJob({
|
|
52
|
+
queue: 'webhook-deliveries',
|
|
53
|
+
data: {
|
|
54
|
+
webhookId: webhook.id,
|
|
55
|
+
eventId,
|
|
56
|
+
payload,
|
|
57
|
+
tenantId,
|
|
58
|
+
organizationId: organizationId ?? webhook.organizationId,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
} catch {
|
|
62
|
+
// Don't fail the event pipeline if queue is unavailable
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function eventMatchesPattern(eventId: string, pattern: string): boolean {
|
|
68
|
+
if (pattern === '*') return true
|
|
69
|
+
if (pattern === eventId) return true
|
|
70
|
+
if (pattern.endsWith('.*')) {
|
|
71
|
+
const prefix = pattern.slice(0, -2)
|
|
72
|
+
return eventId.startsWith(prefix + '.')
|
|
73
|
+
}
|
|
74
|
+
if (pattern.endsWith('*')) {
|
|
75
|
+
const prefix = pattern.slice(0, -1)
|
|
76
|
+
return eventId.startsWith(prefix)
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { WebhookEntity, WebhookDeliveryEntity } from '../data/entities'
|
|
3
|
+
import { buildWebhookHeaders, generateMessageId } from '@open-mercato/shared/lib/webhooks'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
queue: 'webhook-deliveries',
|
|
8
|
+
id: 'webhooks:delivery-worker',
|
|
9
|
+
concurrency: 10,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WebhookDeliveryJob {
|
|
13
|
+
webhookId: string
|
|
14
|
+
eventId: string
|
|
15
|
+
payload: Record<string, unknown>
|
|
16
|
+
tenantId: string
|
|
17
|
+
organizationId: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function handler(
|
|
21
|
+
job: { data: WebhookDeliveryJob },
|
|
22
|
+
ctx: { resolve: <T = unknown>(name: string) => T },
|
|
23
|
+
) {
|
|
24
|
+
const { webhookId, eventId, payload, tenantId, organizationId } = job.data
|
|
25
|
+
const em = (ctx.resolve('em') as EntityManager).fork()
|
|
26
|
+
|
|
27
|
+
const webhook = await findOneWithDecryption(
|
|
28
|
+
em,
|
|
29
|
+
WebhookEntity,
|
|
30
|
+
{ id: webhookId, isActive: true, deletedAt: null },
|
|
31
|
+
{},
|
|
32
|
+
{ tenantId, organizationId },
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (!webhook) return
|
|
36
|
+
|
|
37
|
+
const messageId = generateMessageId()
|
|
38
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
39
|
+
const body = JSON.stringify({
|
|
40
|
+
type: eventId,
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
data: payload,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const now = new Date()
|
|
46
|
+
const delivery = em.create(WebhookDeliveryEntity, {
|
|
47
|
+
webhookId: webhook.id,
|
|
48
|
+
eventType: eventId,
|
|
49
|
+
messageId,
|
|
50
|
+
payload: JSON.parse(body),
|
|
51
|
+
status: 'sending',
|
|
52
|
+
attemptNumber: 0,
|
|
53
|
+
maxAttempts: webhook.maxRetries,
|
|
54
|
+
targetUrl: webhook.url,
|
|
55
|
+
organizationId: webhook.organizationId,
|
|
56
|
+
tenantId: webhook.tenantId,
|
|
57
|
+
enqueuedAt: now,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
await em.flush()
|
|
63
|
+
|
|
64
|
+
const headers = buildWebhookHeaders(
|
|
65
|
+
messageId,
|
|
66
|
+
timestamp,
|
|
67
|
+
body,
|
|
68
|
+
webhook.secret,
|
|
69
|
+
webhook.previousSecret,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const startTime = Date.now()
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const controller = new AbortController()
|
|
76
|
+
const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)
|
|
77
|
+
|
|
78
|
+
const response = await fetch(webhook.url, {
|
|
79
|
+
method: webhook.httpMethod,
|
|
80
|
+
headers: {
|
|
81
|
+
'content-type': 'application/json',
|
|
82
|
+
...headers,
|
|
83
|
+
...(webhook.customHeaders ?? {}),
|
|
84
|
+
},
|
|
85
|
+
body,
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
clearTimeout(timeoutId)
|
|
90
|
+
|
|
91
|
+
const durationMs = Date.now() - startTime
|
|
92
|
+
const responseBody = await response.text().catch(() => '')
|
|
93
|
+
|
|
94
|
+
delivery.responseStatus = response.status
|
|
95
|
+
delivery.responseBody = responseBody.slice(0, 4096)
|
|
96
|
+
delivery.durationMs = durationMs
|
|
97
|
+
delivery.lastAttemptAt = new Date()
|
|
98
|
+
delivery.attemptNumber += 1
|
|
99
|
+
|
|
100
|
+
if (response.ok) {
|
|
101
|
+
delivery.status = 'delivered'
|
|
102
|
+
delivery.deliveredAt = new Date()
|
|
103
|
+
webhook.consecutiveFailures = 0
|
|
104
|
+
webhook.lastSuccessAt = new Date()
|
|
105
|
+
} else {
|
|
106
|
+
const shouldRetry = shouldRetryStatus(response.status) && delivery.attemptNumber < delivery.maxAttempts
|
|
107
|
+
if (shouldRetry) {
|
|
108
|
+
delivery.status = 'pending'
|
|
109
|
+
delivery.nextRetryAt = calculateNextRetry(delivery.attemptNumber)
|
|
110
|
+
} else {
|
|
111
|
+
delivery.status = 'failed'
|
|
112
|
+
}
|
|
113
|
+
webhook.consecutiveFailures += 1
|
|
114
|
+
webhook.lastFailureAt = new Date()
|
|
115
|
+
|
|
116
|
+
if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {
|
|
117
|
+
webhook.isActive = false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const durationMs = Date.now() - startTime
|
|
122
|
+
delivery.durationMs = durationMs
|
|
123
|
+
delivery.lastAttemptAt = new Date()
|
|
124
|
+
delivery.attemptNumber += 1
|
|
125
|
+
delivery.errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
126
|
+
|
|
127
|
+
const shouldRetry = delivery.attemptNumber < delivery.maxAttempts
|
|
128
|
+
if (shouldRetry) {
|
|
129
|
+
delivery.status = 'pending'
|
|
130
|
+
delivery.nextRetryAt = calculateNextRetry(delivery.attemptNumber)
|
|
131
|
+
} else {
|
|
132
|
+
delivery.status = 'failed'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
webhook.consecutiveFailures += 1
|
|
136
|
+
webhook.lastFailureAt = new Date()
|
|
137
|
+
|
|
138
|
+
if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {
|
|
139
|
+
webhook.isActive = false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await em.flush()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function shouldRetryStatus(status: number): boolean {
|
|
147
|
+
if (status >= 200 && status < 300) return false
|
|
148
|
+
if (status === 408 || status === 429) return true
|
|
149
|
+
if (status >= 500) return true
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function calculateNextRetry(attemptNumber: number): Date {
|
|
154
|
+
const baseDelay = 1000
|
|
155
|
+
const delayMs = baseDelay * Math.pow(2, attemptNumber - 1)
|
|
156
|
+
const jitter = Math.random() * 1000
|
|
157
|
+
return new Date(Date.now() + delayMs + jitter)
|
|
158
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"extends": "../../tsconfig.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"noEmit": false,
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"],
|
|
12
|
+
"exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts"]
|
|
13
|
+
}
|
package/tsconfig.json
ADDED