@hed-hog/core 0.0.295 → 0.0.296
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/auth.controller.d.ts +4 -4
- package/dist/auth/auth.service.d.ts +4 -4
- package/dist/challenge/challenge.service.d.ts +2 -2
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +4 -1
- package/dist/core.module.js.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +2 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/index.d.ts +4 -0
- package/dist/integration/index.d.ts.map +1 -0
- package/dist/integration/index.js +20 -0
- package/dist/integration/index.js.map +1 -0
- package/dist/integration/integration-api.validation.d.ts +2 -0
- package/dist/integration/integration-api.validation.d.ts.map +1 -0
- package/dist/integration/integration-api.validation.js +126 -0
- package/dist/integration/integration-api.validation.js.map +1 -0
- package/dist/integration/integration.module.d.ts +3 -0
- package/dist/integration/integration.module.d.ts.map +1 -0
- package/dist/integration/integration.module.js +54 -0
- package/dist/integration/integration.module.js.map +1 -0
- package/dist/integration/services/domain-event.publisher.d.ts +31 -0
- package/dist/integration/services/domain-event.publisher.d.ts.map +1 -0
- package/dist/integration/services/domain-event.publisher.js +79 -0
- package/dist/integration/services/domain-event.publisher.js.map +1 -0
- package/dist/integration/services/event-subscriber.registry.d.ts +37 -0
- package/dist/integration/services/event-subscriber.registry.d.ts.map +1 -0
- package/dist/integration/services/event-subscriber.registry.js +86 -0
- package/dist/integration/services/event-subscriber.registry.js.map +1 -0
- package/dist/integration/services/inbox.service.d.ts +55 -0
- package/dist/integration/services/inbox.service.d.ts.map +1 -0
- package/dist/integration/services/inbox.service.js +173 -0
- package/dist/integration/services/inbox.service.js.map +1 -0
- package/dist/integration/services/index.d.ts +12 -0
- package/dist/integration/services/index.d.ts.map +1 -0
- package/dist/integration/services/index.js +28 -0
- package/dist/integration/services/index.js.map +1 -0
- package/dist/integration/services/integration-developer-api.service.d.ts +30 -0
- package/dist/integration/services/integration-developer-api.service.d.ts.map +1 -0
- package/dist/integration/services/integration-developer-api.service.js +55 -0
- package/dist/integration/services/integration-developer-api.service.js.map +1 -0
- package/dist/integration/services/integration-link.service.d.ts +52 -0
- package/dist/integration/services/integration-link.service.d.ts.map +1 -0
- package/dist/integration/services/integration-link.service.js +128 -0
- package/dist/integration/services/integration-link.service.js.map +1 -0
- package/dist/integration/services/integration-settings.service.d.ts +23 -0
- package/dist/integration/services/integration-settings.service.d.ts.map +1 -0
- package/dist/integration/services/integration-settings.service.js +81 -0
- package/dist/integration/services/integration-settings.service.js.map +1 -0
- package/dist/integration/services/outbox-polling.coordinator.d.ts +45 -0
- package/dist/integration/services/outbox-polling.coordinator.d.ts.map +1 -0
- package/dist/integration/services/outbox-polling.coordinator.js +143 -0
- package/dist/integration/services/outbox-polling.coordinator.js.map +1 -0
- package/dist/integration/services/outbox.notifier.d.ts +30 -0
- package/dist/integration/services/outbox.notifier.d.ts.map +1 -0
- package/dist/integration/services/outbox.notifier.js +57 -0
- package/dist/integration/services/outbox.notifier.js.map +1 -0
- package/dist/integration/services/outbox.processor.d.ts +42 -0
- package/dist/integration/services/outbox.processor.d.ts.map +1 -0
- package/dist/integration/services/outbox.processor.job.d.ts +43 -0
- package/dist/integration/services/outbox.processor.job.d.ts.map +1 -0
- package/dist/integration/services/outbox.processor.job.js +100 -0
- package/dist/integration/services/outbox.processor.job.js.map +1 -0
- package/dist/integration/services/outbox.processor.js +208 -0
- package/dist/integration/services/outbox.processor.js.map +1 -0
- package/dist/integration/services/outbox.service.d.ts +53 -0
- package/dist/integration/services/outbox.service.d.ts.map +1 -0
- package/dist/integration/services/outbox.service.js +149 -0
- package/dist/integration/services/outbox.service.js.map +1 -0
- package/dist/integration/types/event.types.d.ts +88 -0
- package/dist/integration/types/event.types.d.ts.map +1 -0
- package/dist/integration/types/event.types.js +35 -0
- package/dist/integration/types/event.types.js.map +1 -0
- package/dist/integration/types/index.d.ts +3 -0
- package/dist/integration/types/index.d.ts.map +1 -0
- package/dist/integration/types/index.js +19 -0
- package/dist/integration/types/index.js.map +1 -0
- package/dist/integration/types/subscriber.types.d.ts +31 -0
- package/dist/integration/types/subscriber.types.d.ts.map +1 -0
- package/dist/integration/types/subscriber.types.js +3 -0
- package/dist/integration/types/subscriber.types.js.map +1 -0
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +3 -3
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/profile/profile.controller.d.ts +3 -3
- package/dist/profile/profile.service.d.ts +3 -3
- package/dist/setting/setting.controller.d.ts +12 -8
- package/dist/setting/setting.controller.d.ts.map +1 -1
- package/dist/setting/setting.service.d.ts +12 -8
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +21 -1
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/user/user.controller.d.ts +4 -4
- package/dist/user/user.service.d.ts +9 -9
- package/hedhog/data/dashboard_component_role.yaml +223 -223
- package/hedhog/data/dashboard_role.yaml +18 -18
- package/hedhog/data/route.yaml +2 -0
- package/hedhog/data/setting_group.yaml +955 -470
- package/hedhog/data/setting_subgroup.yaml +303 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +44 -18
- package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +134 -27
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +84 -23
- package/hedhog/frontend/app/dashboard/components/widgets/permissions-chart.tsx.ejs +62 -62
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +29 -29
- package/hedhog/frontend/app/preferences/page.tsx.ejs +2 -5
- package/hedhog/table/inbox_event.yaml +40 -0
- package/hedhog/table/integration_link.yaml +33 -0
- package/hedhog/table/outbox_event.yaml +45 -0
- package/hedhog/table/setting.yaml +7 -0
- package/hedhog/table/setting_subgroup.yaml +19 -0
- package/package.json +8 -8
- package/src/ai/ai.service.ts +3 -3
- package/src/auth/auth.controller.ts +11 -11
- package/src/auth/auth.service.ts +8 -8
- package/src/core.module.ts +4 -1
- package/src/index.ts +15 -0
- package/src/integration/README.md +397 -0
- package/src/integration/USAGE_EXAMPLE.md +279 -0
- package/src/integration/index.ts +4 -0
- package/src/integration/integration-api.validation.ts +154 -0
- package/src/integration/integration.module.ts +53 -0
- package/src/integration/services/domain-event.publisher.ts +136 -0
- package/src/integration/services/event-subscriber.registry.ts +89 -0
- package/src/integration/services/inbox.service.ts +218 -0
- package/src/integration/services/index.ts +12 -0
- package/src/integration/services/integration-developer-api.service.ts +96 -0
- package/src/integration/services/integration-link.service.ts +154 -0
- package/src/integration/services/integration-settings.service.ts +128 -0
- package/src/integration/services/outbox-polling.coordinator.ts +146 -0
- package/src/integration/services/outbox.notifier.ts +48 -0
- package/src/integration/services/outbox.processor.job.ts +97 -0
- package/src/integration/services/outbox.processor.ts +266 -0
- package/src/integration/services/outbox.service.ts +209 -0
- package/src/integration/types/event.types.ts +93 -0
- package/src/integration/types/index.ts +3 -0
- package/src/integration/types/subscriber.types.ts +37 -0
- package/src/oauth/oauth.controller.ts +17 -17
- package/src/oauth/oauth.service.ts +20 -20
- package/src/setting/setting.service.ts +27 -2
- package/src/task/task.service.ts +5 -5
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXAMPLE: How a Module Uses the Integration Foundation
|
|
3
|
+
*
|
|
4
|
+
* This example shows how the finance or operations module would use
|
|
5
|
+
* the core integration infrastructure to publish events and handle
|
|
6
|
+
* events from other modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// STEP 1: Publish a Domain Event from Finance Module
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import { Injectable } from '@nestjs/common';
|
|
14
|
+
import { IntegrationDeveloperApiService } from '@hed-hog/core';
|
|
15
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class InvoiceService {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly prisma: PrismaService,
|
|
21
|
+
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async createInvoice(data: any): Promise<any> {
|
|
25
|
+
// Create the invoice
|
|
26
|
+
const invoice = await this.prisma.invoice.create({ data });
|
|
27
|
+
|
|
28
|
+
// Publish event that other modules can subscribe to
|
|
29
|
+
await this.integrationApi.publishEvent({
|
|
30
|
+
eventName: 'invoice.created',
|
|
31
|
+
sourceModule: 'finance',
|
|
32
|
+
aggregateType: 'Invoice',
|
|
33
|
+
aggregateId: invoice.id,
|
|
34
|
+
payload: {
|
|
35
|
+
invoiceId: invoice.id,
|
|
36
|
+
amount: invoice.amount,
|
|
37
|
+
customerId: invoice.customerId,
|
|
38
|
+
},
|
|
39
|
+
metadata: {
|
|
40
|
+
producer: 'finance',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return invoice;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async cancelInvoice(invoiceId: string): Promise<void> {
|
|
48
|
+
await this.prisma.invoice.update({
|
|
49
|
+
where: { id: invoiceId },
|
|
50
|
+
data: { status: 'CANCELLED' },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Notify subscribers
|
|
54
|
+
await this.integrationApi.publishEvent({
|
|
55
|
+
eventName: 'invoice.cancelled',
|
|
56
|
+
sourceModule: 'finance',
|
|
57
|
+
aggregateType: 'Invoice',
|
|
58
|
+
aggregateId: invoiceId,
|
|
59
|
+
payload: { invoiceId },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// STEP 2: Register Event Handlers in Operations Module
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
import { Injectable, OnModuleInit} from '@nestjs/common';
|
|
69
|
+
import {
|
|
70
|
+
IntegrationDeveloperApiService,
|
|
71
|
+
DomainEvent,
|
|
72
|
+
SubscriberContext,
|
|
73
|
+
LinkType,
|
|
74
|
+
} from '@hed-hog/core';
|
|
75
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
76
|
+
|
|
77
|
+
@Injectable()
|
|
78
|
+
export class OperationsEventHandlers implements OnModuleInit {
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly prisma: PrismaService,
|
|
81
|
+
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
onModuleInit(): void {
|
|
85
|
+
// Register handler for invoice.created event
|
|
86
|
+
this.integrationApi.subscribe({
|
|
87
|
+
eventName: 'invoice.created',
|
|
88
|
+
consumerName: 'operations-module',
|
|
89
|
+
priority: 10,
|
|
90
|
+
handler: async (event: DomainEvent, context: SubscriberContext) => {
|
|
91
|
+
await this.onInvoiceCreated(event, context);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Register handler for invoice.cancelled event
|
|
96
|
+
this.integrationApi.subscribe({
|
|
97
|
+
eventName: 'invoice.cancelled',
|
|
98
|
+
consumerName: 'operations-module',
|
|
99
|
+
priority: 10,
|
|
100
|
+
handler: async (event: DomainEvent, context: SubscriberContext) => {
|
|
101
|
+
await this.onInvoiceCancelled(event, context);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle invoice.created event
|
|
108
|
+
* This might trigger operational workflows, create tasks, etc.
|
|
109
|
+
*/
|
|
110
|
+
private async onInvoiceCreated(
|
|
111
|
+
event: DomainEvent,
|
|
112
|
+
context: SubscriberContext,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
context.logger.log(`Processing invoice creation: ${event.aggregateId}`);
|
|
115
|
+
|
|
116
|
+
const { invoiceId, customerId, amount } = event.payload;
|
|
117
|
+
|
|
118
|
+
// Create an operational task
|
|
119
|
+
const task = await this.prisma.task.create({
|
|
120
|
+
data: {
|
|
121
|
+
title: `Process invoice ${invoiceId}`,
|
|
122
|
+
description: `Invoice for amount ${amount} from customer ${customerId}`,
|
|
123
|
+
status: 'PENDING',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Create an integration link between the finance invoice and operations task
|
|
128
|
+
await context.linkService.createLink({
|
|
129
|
+
sourceModule: 'finance',
|
|
130
|
+
sourceEntityType: 'Invoice',
|
|
131
|
+
sourceEntityId: invoiceId,
|
|
132
|
+
targetModule: 'operations',
|
|
133
|
+
targetEntityType: 'Task',
|
|
134
|
+
targetEntityId: task.id,
|
|
135
|
+
linkType: LinkType.CASCADE,
|
|
136
|
+
metadata: {
|
|
137
|
+
reason: 'workflow_trigger',
|
|
138
|
+
invoiceAmount: amount,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
context.logger.log(
|
|
143
|
+
`Created task ${task.id} for invoice ${invoiceId}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handle invoice.cancelled event
|
|
149
|
+
*/
|
|
150
|
+
private async onInvoiceCancelled(
|
|
151
|
+
event: DomainEvent,
|
|
152
|
+
context: SubscriberContext,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
context.logger.log(`Processing invoice cancellation: ${event.aggregateId}`);
|
|
155
|
+
|
|
156
|
+
const { invoiceId } = event.payload;
|
|
157
|
+
|
|
158
|
+
// Find linked tasks
|
|
159
|
+
const links = await context.linkService.findOutbound(
|
|
160
|
+
'finance',
|
|
161
|
+
'Invoice',
|
|
162
|
+
invoiceId,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
for (const link of links) {
|
|
166
|
+
// Cancel related tasks
|
|
167
|
+
if (link.targetModule === 'operations' && link.targetEntityType === 'Task') {
|
|
168
|
+
await this.prisma.task.update({
|
|
169
|
+
where: { id: link.targetEntityId },
|
|
170
|
+
data: { status: 'CANCELLED' },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
context.logger.log(
|
|
174
|
+
`Cancelled task ${link.targetEntityId} due to invoice cancellation`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// STEP 3: Register Handlers in Operations Module DI
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
import { Module } from '@nestjs/common';
|
|
186
|
+
|
|
187
|
+
@Module({
|
|
188
|
+
providers: [OperationsEventHandlers],
|
|
189
|
+
})
|
|
190
|
+
export class OperationsModule {
|
|
191
|
+
// OperationsEventHandlers will be instantiated and its onModuleInit()
|
|
192
|
+
// will be called automatically, registering event handlers
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// STEP 4: Query Integration Links to Find Related Entities
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
import { Injectable } from '@nestjs/common';
|
|
200
|
+
import { IntegrationDeveloperApiService, LinkType } from '@hed-hog/core';
|
|
201
|
+
|
|
202
|
+
@Injectable()
|
|
203
|
+
export class InvoiceQueryService {
|
|
204
|
+
constructor(
|
|
205
|
+
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
206
|
+
) {}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Find all operational tasks related to an invoice
|
|
210
|
+
*/
|
|
211
|
+
async getRelatedTasks(invoiceId: string): Promise<any[]> {
|
|
212
|
+
const links = await this.integrationApi.findLinksBySource({
|
|
213
|
+
module: 'finance',
|
|
214
|
+
entityType: 'Invoice',
|
|
215
|
+
entityId: invoiceId,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const taskLinks = links.filter(
|
|
219
|
+
(link) => link.targetModule === 'operations' && link.targetEntityType === 'Task',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return taskLinks.map((link) => link.targetEntityId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Find the invoice that triggered a task
|
|
227
|
+
*/
|
|
228
|
+
async getSourceInvoice(taskId: string): Promise<string | null> {
|
|
229
|
+
const links = await this.integrationApi.findLinksByTarget({
|
|
230
|
+
module: 'operations',
|
|
231
|
+
entityType: 'Task',
|
|
232
|
+
entityId: taskId,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const invoiceLink = links.find(
|
|
236
|
+
(link) => link.sourceModule === 'finance' && link.sourceEntityType === 'Invoice',
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return invoiceLink?.sourceEntityId || null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// ARCHITECTURE NOTES
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* KEY PRINCIPLES:
|
|
249
|
+
*
|
|
250
|
+
* 1. DURABLE OUTBOX
|
|
251
|
+
* - Events are written to the database outbox table first
|
|
252
|
+
* - In-memory notification wakes up processor (optional acceleration)
|
|
253
|
+
* - On crashes/restarts, processor catches up on pending events
|
|
254
|
+
* - No events are lost
|
|
255
|
+
*
|
|
256
|
+
* 2. IDEMPOTENT PROCESSING
|
|
257
|
+
* - Each consumer has an inbox entry per outbox event
|
|
258
|
+
* - Unique constraint prevents duplicate processing
|
|
259
|
+
* - If a handler crashes mid-execution, retry will recognize it was attempted
|
|
260
|
+
* - Handlers must be idempotent at the application logic level
|
|
261
|
+
*
|
|
262
|
+
* 3. CROSS-MODULE DECOUPLING
|
|
263
|
+
* - Modules publish events but don't depend on other modules' internals
|
|
264
|
+
* - Integration links are explicit, queryable, and metadata-rich
|
|
265
|
+
* - No direct FK relationships across module boundaries
|
|
266
|
+
* - Modules self-register handlers, core doesn't know about them
|
|
267
|
+
*
|
|
268
|
+
* 4. CONFIGURABILITY
|
|
269
|
+
* - All timing/retry/batch/scope settings are in the core SettingService
|
|
270
|
+
* - Operations/infrastructure teams can tune without code changes
|
|
271
|
+
* - Settings can be changed at runtime
|
|
272
|
+
*
|
|
273
|
+
* 5. FAILURE RECOVERY
|
|
274
|
+
* - Exponential backoff on handler failures
|
|
275
|
+
* - Configurable max attempts
|
|
276
|
+
* - Dead letter queue for poisoned events
|
|
277
|
+
* - Processing lease time prevents stuck events
|
|
278
|
+
* - Startup drain recovers pending events after restart
|
|
279
|
+
*/
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { DomainEventPublisher } from './services/domain-event.publisher';
|
|
3
|
+
import { EventSubscriberRegistry } from './services/event-subscriber.registry';
|
|
4
|
+
import { InboxService } from './services/inbox.service';
|
|
5
|
+
import { IntegrationLinkService } from './services/integration-link.service';
|
|
6
|
+
import { LinkType } from './types';
|
|
7
|
+
|
|
8
|
+
async function validatePublisher(): Promise<void> {
|
|
9
|
+
const createdEvent = {
|
|
10
|
+
id: 'evt-1',
|
|
11
|
+
eventName: 'operations.project.billing_ready',
|
|
12
|
+
sourceModule: 'operations',
|
|
13
|
+
aggregateType: 'project',
|
|
14
|
+
aggregateId: 'prj-1',
|
|
15
|
+
payload: { projectId: 'prj-1' },
|
|
16
|
+
status: 'pending',
|
|
17
|
+
attemptCount: 0,
|
|
18
|
+
lastError: null,
|
|
19
|
+
availableAt: new Date(),
|
|
20
|
+
processedAt: null,
|
|
21
|
+
createdAt: new Date(),
|
|
22
|
+
updatedAt: new Date(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const outboxService = {
|
|
26
|
+
createEvent: async () => createdEvent,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let notified = 0;
|
|
30
|
+
const notifier = {
|
|
31
|
+
notifyEventWritten: () => {
|
|
32
|
+
notified += 1;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const publisher = new DomainEventPublisher(
|
|
37
|
+
outboxService as any,
|
|
38
|
+
notifier as any,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const result = await publisher.publishEvent({
|
|
42
|
+
eventName: 'operations.project.billing_ready',
|
|
43
|
+
sourceModule: 'operations',
|
|
44
|
+
aggregateType: 'project',
|
|
45
|
+
aggregateId: 'prj-1',
|
|
46
|
+
payload: { projectId: 'prj-1' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
assert.equal(result.id, 'evt-1');
|
|
50
|
+
assert.equal(notified, 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function validateRegistry(): Promise<void> {
|
|
54
|
+
const registry = new EventSubscriberRegistry();
|
|
55
|
+
|
|
56
|
+
registry.subscribe(
|
|
57
|
+
'operations.contract.payable_generated',
|
|
58
|
+
'consumer-low',
|
|
59
|
+
async () => undefined,
|
|
60
|
+
1,
|
|
61
|
+
);
|
|
62
|
+
registry.subscribe(
|
|
63
|
+
'operations.contract.payable_generated',
|
|
64
|
+
'consumer-high',
|
|
65
|
+
async () => undefined,
|
|
66
|
+
10,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handlers = registry.getHandlers('operations.contract.payable_generated');
|
|
70
|
+
assert.equal(handlers.length, 2);
|
|
71
|
+
const firstHandler = handlers[0];
|
|
72
|
+
assert.ok(firstHandler);
|
|
73
|
+
assert.equal(firstHandler.consumerName, 'consumer-high');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function validateLinks(): Promise<void> {
|
|
77
|
+
const link = {
|
|
78
|
+
id: 'link-1',
|
|
79
|
+
sourceModule: 'operations',
|
|
80
|
+
sourceEntityType: 'project',
|
|
81
|
+
sourceEntityId: 'prj-1',
|
|
82
|
+
targetModule: 'finance',
|
|
83
|
+
targetEntityType: 'payable',
|
|
84
|
+
targetEntityId: 'pay-1',
|
|
85
|
+
linkType: LinkType.REFERENCE,
|
|
86
|
+
metadata: null,
|
|
87
|
+
createdAt: new Date(),
|
|
88
|
+
updatedAt: new Date(),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const prisma = {
|
|
92
|
+
integrationLink: {
|
|
93
|
+
create: async () => link,
|
|
94
|
+
findMany: async () => [link],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const service = new IntegrationLinkService(prisma as any);
|
|
99
|
+
const created = await service.createLink({
|
|
100
|
+
sourceModule: 'operations',
|
|
101
|
+
sourceEntityType: 'project',
|
|
102
|
+
sourceEntityId: 'prj-1',
|
|
103
|
+
targetModule: 'finance',
|
|
104
|
+
targetEntityType: 'payable',
|
|
105
|
+
targetEntityId: 'pay-1',
|
|
106
|
+
linkType: LinkType.REFERENCE,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const outbound = await service.findOutbound('operations', 'project', 'prj-1');
|
|
110
|
+
assert.equal(created.id, 'link-1');
|
|
111
|
+
assert.equal(outbound.length, 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function validateInboxIdempotency(): Promise<void> {
|
|
115
|
+
const existing = {
|
|
116
|
+
id: 1,
|
|
117
|
+
outbox_event_id: 1,
|
|
118
|
+
consumer_name: 'finance-module',
|
|
119
|
+
status: 'processed',
|
|
120
|
+
attempt_count: 1,
|
|
121
|
+
last_error: null,
|
|
122
|
+
processed_at: new Date(),
|
|
123
|
+
created_at: new Date(),
|
|
124
|
+
updated_at: new Date(),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const prisma = {
|
|
128
|
+
inbox_event: {
|
|
129
|
+
findFirst: async () => existing,
|
|
130
|
+
create: async () => {
|
|
131
|
+
throw new Error('create should not be called for existing inbox item');
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const inbox = new InboxService(prisma as any);
|
|
137
|
+
const result = await inbox.getOrCreate('1', 'finance-module');
|
|
138
|
+
assert.equal(result.id, '1');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main(): Promise<void> {
|
|
142
|
+
await validatePublisher();
|
|
143
|
+
await validateRegistry();
|
|
144
|
+
await validateLinks();
|
|
145
|
+
await validateInboxIdempotency();
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.log('Integration API validation passed');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main().catch((error) => {
|
|
151
|
+
// eslint-disable-next-line no-console
|
|
152
|
+
console.error('Integration API validation failed', error);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { PrismaModule } from '@hed-hog/api-prisma';
|
|
2
|
+
import { Global, Module, forwardRef } from '@nestjs/common';
|
|
3
|
+
import { ScheduleModule } from '@nestjs/schedule';
|
|
4
|
+
import { SettingModule } from '../setting/setting.module';
|
|
5
|
+
import {
|
|
6
|
+
DomainEventPublisher,
|
|
7
|
+
EventSubscriberRegistry,
|
|
8
|
+
InboxService,
|
|
9
|
+
IntegrationDeveloperApiService,
|
|
10
|
+
IntegrationLinkService,
|
|
11
|
+
IntegrationSettingsService,
|
|
12
|
+
OutboxNotifier,
|
|
13
|
+
OutboxPollingCoordinator,
|
|
14
|
+
OutboxProcessor,
|
|
15
|
+
OutboxProcessorJob,
|
|
16
|
+
OutboxService,
|
|
17
|
+
} from './services';
|
|
18
|
+
|
|
19
|
+
@Global()
|
|
20
|
+
@Module({
|
|
21
|
+
imports: [
|
|
22
|
+
ScheduleModule.forRoot(),
|
|
23
|
+
PrismaModule,
|
|
24
|
+
forwardRef(() => SettingModule),
|
|
25
|
+
],
|
|
26
|
+
providers: [
|
|
27
|
+
OutboxNotifier,
|
|
28
|
+
OutboxService,
|
|
29
|
+
InboxService,
|
|
30
|
+
IntegrationLinkService,
|
|
31
|
+
EventSubscriberRegistry,
|
|
32
|
+
IntegrationDeveloperApiService,
|
|
33
|
+
IntegrationSettingsService,
|
|
34
|
+
DomainEventPublisher,
|
|
35
|
+
OutboxProcessor,
|
|
36
|
+
OutboxPollingCoordinator,
|
|
37
|
+
OutboxProcessorJob,
|
|
38
|
+
],
|
|
39
|
+
exports: [
|
|
40
|
+
OutboxNotifier,
|
|
41
|
+
OutboxService,
|
|
42
|
+
InboxService,
|
|
43
|
+
IntegrationLinkService,
|
|
44
|
+
EventSubscriberRegistry,
|
|
45
|
+
IntegrationDeveloperApiService,
|
|
46
|
+
IntegrationSettingsService,
|
|
47
|
+
DomainEventPublisher,
|
|
48
|
+
OutboxProcessor,
|
|
49
|
+
OutboxPollingCoordinator,
|
|
50
|
+
OutboxProcessorJob,
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
export class IntegrationModule {}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { DomainEvent, OutboxEvent } from '../types';
|
|
3
|
+
import { OutboxNotifier } from './outbox.notifier';
|
|
4
|
+
import { OutboxEventPersistenceClient, OutboxService } from './outbox.service';
|
|
5
|
+
|
|
6
|
+
export interface PublishDomainEventInput {
|
|
7
|
+
eventName: string;
|
|
8
|
+
sourceModule: string;
|
|
9
|
+
aggregateType: string;
|
|
10
|
+
aggregateId: string;
|
|
11
|
+
payload?: Record<string, any>;
|
|
12
|
+
metadata?: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PublishDomainEventOptions {
|
|
16
|
+
persistenceClient?: OutboxEventPersistenceClient;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class DomainEventPublisher {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly outboxService: OutboxService,
|
|
23
|
+
private readonly outboxNotifier: OutboxNotifier,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Publish a domain event
|
|
28
|
+
* Writes to durable outbox and notifies processor for immediate wakeup
|
|
29
|
+
*/
|
|
30
|
+
async publishEvent(
|
|
31
|
+
input: PublishDomainEventInput,
|
|
32
|
+
options?: PublishDomainEventOptions,
|
|
33
|
+
): Promise<OutboxEvent>;
|
|
34
|
+
|
|
35
|
+
async publishEvent(
|
|
36
|
+
eventName: string,
|
|
37
|
+
sourceModule: string,
|
|
38
|
+
aggregateType: string,
|
|
39
|
+
aggregateId: string,
|
|
40
|
+
payload?: Record<string, any>,
|
|
41
|
+
metadata?: Record<string, any>,
|
|
42
|
+
options?: PublishDomainEventOptions,
|
|
43
|
+
): Promise<OutboxEvent>;
|
|
44
|
+
|
|
45
|
+
async publishEvent(
|
|
46
|
+
eventOrName: PublishDomainEventInput | string,
|
|
47
|
+
sourceModuleOrOptions?: string | PublishDomainEventOptions,
|
|
48
|
+
aggregateType?: string,
|
|
49
|
+
aggregateId?: string,
|
|
50
|
+
payload?: Record<string, any>,
|
|
51
|
+
metadata?: Record<string, any>,
|
|
52
|
+
options?: PublishDomainEventOptions,
|
|
53
|
+
): Promise<OutboxEvent> {
|
|
54
|
+
const payloadData = payload || {};
|
|
55
|
+
const input =
|
|
56
|
+
typeof eventOrName === 'string'
|
|
57
|
+
? {
|
|
58
|
+
eventName: eventOrName,
|
|
59
|
+
sourceModule: sourceModuleOrOptions as string,
|
|
60
|
+
aggregateType: aggregateType as string,
|
|
61
|
+
aggregateId: aggregateId as string,
|
|
62
|
+
payload: payloadData,
|
|
63
|
+
metadata,
|
|
64
|
+
}
|
|
65
|
+
: eventOrName;
|
|
66
|
+
|
|
67
|
+
const publishOptions =
|
|
68
|
+
typeof eventOrName === 'string'
|
|
69
|
+
? options
|
|
70
|
+
: (sourceModuleOrOptions as PublishDomainEventOptions | undefined);
|
|
71
|
+
|
|
72
|
+
const persistedPayload = this.enrichPayloadWithMetadata(
|
|
73
|
+
input.payload || {},
|
|
74
|
+
input.metadata,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Write to outbox (durable, authoritative)
|
|
78
|
+
const outboxEvent = await this.outboxService.createEvent(
|
|
79
|
+
{
|
|
80
|
+
eventName: input.eventName,
|
|
81
|
+
sourceModule: input.sourceModule,
|
|
82
|
+
aggregateType: input.aggregateType,
|
|
83
|
+
aggregateId: input.aggregateId,
|
|
84
|
+
payload: persistedPayload,
|
|
85
|
+
},
|
|
86
|
+
publishOptions?.persistenceClient,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Notify processor for immediate wakeup (best-effort, not guaranteed)
|
|
90
|
+
this.outboxNotifier.notifyEventWritten();
|
|
91
|
+
|
|
92
|
+
return outboxEvent;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Publish multiple events in sequence
|
|
97
|
+
*/
|
|
98
|
+
async publishEvents(
|
|
99
|
+
events: Array<DomainEvent | PublishDomainEventInput>,
|
|
100
|
+
options?: PublishDomainEventOptions,
|
|
101
|
+
): Promise<OutboxEvent[]> {
|
|
102
|
+
const published: OutboxEvent[] = [];
|
|
103
|
+
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
published.push(
|
|
106
|
+
await this.publishEvent(
|
|
107
|
+
{
|
|
108
|
+
eventName: event.eventName,
|
|
109
|
+
sourceModule: event.sourceModule,
|
|
110
|
+
aggregateType: event.aggregateType,
|
|
111
|
+
aggregateId: event.aggregateId,
|
|
112
|
+
payload: event.payload,
|
|
113
|
+
metadata: (event as PublishDomainEventInput).metadata,
|
|
114
|
+
},
|
|
115
|
+
options,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return published;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private enrichPayloadWithMetadata(
|
|
124
|
+
payload: Record<string, any>,
|
|
125
|
+
metadata?: Record<string, any>,
|
|
126
|
+
): Record<string, any> {
|
|
127
|
+
if (!metadata) {
|
|
128
|
+
return payload;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...payload,
|
|
133
|
+
_integrationMeta: metadata,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|