@hiliosai/sdk 0.1.4 → 0.1.6
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/package.json +1 -1
- package/src/configs/constants.ts +13 -0
- package/src/datasources/base.datasource.ts +37 -5
- package/src/datasources/extensions/index.ts +11 -0
- package/src/datasources/extensions/retry.extension.ts +91 -0
- package/src/datasources/extensions/soft-delete.extension.ts +113 -0
- package/src/datasources/extensions/tenant.extension.ts +104 -0
- package/src/datasources/index.ts +2 -0
- package/src/datasources/prisma.datasource.ts +325 -0
- package/src/mixins/datasource.mixin.ts +13 -1
- package/src/service/define-integration.ts +142 -83
- package/src/service/define-service.ts +6 -2
- package/src/types/channels.ts +60 -0
- package/src/types/context.ts +22 -2
- package/src/types/index.ts +1 -0
- package/src/types/integration.ts +0 -1
- package/src/types/message.ts +2 -3
- package/src/types/service.ts +34 -31
- package/src/types/user.ts +2 -2
- package/INTEGRATION_SERVICE.md +0 -394
- package/src/service/example-user/datasources/index.ts +0 -8
- package/src/service/example-user/datasources/user.datasource.ts +0 -7
- package/src/service/example-user/user.service.ts +0 -23
- package/src/service/example-user/utils.ts +0 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import {isDev, isTest} from '../env';
|
|
3
|
+
import type {AppContext} from '../types/context';
|
|
4
|
+
import {AbstractDatasource} from './base.datasource';
|
|
5
|
+
|
|
6
|
+
// Generic Prisma Client interface - will be provided by generated client
|
|
7
|
+
interface PrismaClientLike {
|
|
8
|
+
$connect(): Promise<void>;
|
|
9
|
+
$disconnect(): Promise<void>;
|
|
10
|
+
$queryRaw(
|
|
11
|
+
query: TemplateStringsArray,
|
|
12
|
+
...values: unknown[]
|
|
13
|
+
): Promise<unknown>;
|
|
14
|
+
$transaction<T>(
|
|
15
|
+
fn: (client: PrismaClientLike) => Promise<T>,
|
|
16
|
+
options?: {
|
|
17
|
+
maxWait?: number;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
}
|
|
20
|
+
): Promise<T>;
|
|
21
|
+
$extends<T>(extension: T): PrismaClientLike & T;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Extension interfaces for common production patterns
|
|
26
|
+
export interface SoftDeleteExtension {
|
|
27
|
+
// Adds soft delete functionality to models
|
|
28
|
+
softDelete: {
|
|
29
|
+
[model: string]: {
|
|
30
|
+
delete: (args: any) => Promise<any>;
|
|
31
|
+
deleteMany: (args: any) => Promise<any>;
|
|
32
|
+
restore: (args: any) => Promise<any>;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuditTrailExtension {
|
|
38
|
+
// Adds audit trail functionality
|
|
39
|
+
$auditTrail: {
|
|
40
|
+
getHistory: (model: string, id: string) => Promise<any[]>;
|
|
41
|
+
getChanges: (
|
|
42
|
+
model: string,
|
|
43
|
+
id: string,
|
|
44
|
+
from?: Date,
|
|
45
|
+
to?: Date
|
|
46
|
+
) => Promise<any[]>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TenantExtension {
|
|
51
|
+
// Adds multi-tenant filtering
|
|
52
|
+
$tenant: {
|
|
53
|
+
setContext: (tenantId: string) => void;
|
|
54
|
+
getCurrentTenant: () => string | null;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Global singleton pattern for Prisma Client
|
|
59
|
+
declare global {
|
|
60
|
+
var __prisma: PrismaClientLike | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Prisma datasource that follows singleton pattern and best practices
|
|
65
|
+
* Supports both provided client instances and automatic initialization
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* // Option 1: Let datasource create singleton client
|
|
70
|
+
* datasources: {
|
|
71
|
+
* prisma: PrismaDatasource
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* // Option 2: Provide custom client
|
|
75
|
+
* const customClient = new PrismaClient({...});
|
|
76
|
+
* datasources: {
|
|
77
|
+
* prisma: () => new PrismaDatasource(customClient)
|
|
78
|
+
* }
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export class PrismaDatasource<
|
|
82
|
+
TPrismaClient extends PrismaClientLike = PrismaClientLike,
|
|
83
|
+
TContext = AppContext
|
|
84
|
+
> extends AbstractDatasource<TContext> {
|
|
85
|
+
readonly name = 'prisma';
|
|
86
|
+
private _client: TPrismaClient | null = null;
|
|
87
|
+
private providedClient: TPrismaClient | null = null;
|
|
88
|
+
|
|
89
|
+
constructor(prismaClient?: TPrismaClient) {
|
|
90
|
+
super();
|
|
91
|
+
this.providedClient = prismaClient ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get Prisma client instance (singleton pattern)
|
|
96
|
+
*/
|
|
97
|
+
get client(): TPrismaClient {
|
|
98
|
+
this._client ??= this.initializePrismaClient();
|
|
99
|
+
|
|
100
|
+
// Update tenant context from current service context
|
|
101
|
+
this.updateTenantFromContext();
|
|
102
|
+
|
|
103
|
+
return this._client;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initialize Prisma client using singleton pattern or provided instance
|
|
108
|
+
*/
|
|
109
|
+
private initializePrismaClient(): TPrismaClient {
|
|
110
|
+
// Option 1: Use provided client instance
|
|
111
|
+
if (this.providedClient) {
|
|
112
|
+
this.broker.logger.info('Using provided PrismaClient instance');
|
|
113
|
+
return this.providedClient;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Option 2: Use global singleton (recommended for most cases)
|
|
117
|
+
if (!globalThis.__prisma) {
|
|
118
|
+
this.broker.logger.info('Creating new PrismaClient singleton');
|
|
119
|
+
const baseClient = this.createClient();
|
|
120
|
+
globalThis.__prisma = this.applyExtensions(baseClient);
|
|
121
|
+
} else {
|
|
122
|
+
this.broker.logger.info('Using existing PrismaClient singleton');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return globalThis.__prisma as TPrismaClient;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a new Prisma client instance
|
|
130
|
+
* Override this method in subclasses to provide the actual PrismaClient
|
|
131
|
+
*/
|
|
132
|
+
protected createClient(): TPrismaClient {
|
|
133
|
+
throw new Error(
|
|
134
|
+
'createClient() must be implemented by subclass or provide PrismaClient in constructor. ' +
|
|
135
|
+
'Example: class MyPrismaDataSource extends PrismaDatasource { ' +
|
|
136
|
+
'protected createClient() { return new PrismaClient(); } }'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Apply extensions to the Prisma client
|
|
142
|
+
* Override this method to add production extensions like soft delete, audit trails, etc.
|
|
143
|
+
*/
|
|
144
|
+
protected applyExtensions(client: TPrismaClient): TPrismaClient {
|
|
145
|
+
// Base implementation returns client as-is
|
|
146
|
+
// Override in subclass to add extensions:
|
|
147
|
+
// return client.$extends(softDeleteExtension).$extends(auditExtension)
|
|
148
|
+
return client;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get extended client with all applied extensions
|
|
153
|
+
*/
|
|
154
|
+
get extendedClient(): TPrismaClient {
|
|
155
|
+
return this.applyExtensions(this.client);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Initialize datasource - called after broker injection
|
|
160
|
+
*/
|
|
161
|
+
async init(): Promise<void> {
|
|
162
|
+
this.broker.logger.info('Initializing Prisma datasource');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Called automatically when context is injected
|
|
167
|
+
* Sets tenant context from service context if available
|
|
168
|
+
*/
|
|
169
|
+
private updateTenantFromContext(): void {
|
|
170
|
+
const tenantId = (this.context as any)?.meta?.tenantId;
|
|
171
|
+
if (tenantId && typeof tenantId === 'string') {
|
|
172
|
+
this.setTenantContext(tenantId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Connect to database - called when service starts
|
|
178
|
+
*/
|
|
179
|
+
async connect(): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
this.broker.logger.info('Connecting to database via Prisma');
|
|
182
|
+
await this.client.$connect();
|
|
183
|
+
this.broker.logger.info('Successfully connected to database');
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.broker.logger.error('Failed to connect to database:', error);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Disconnect from database - called when service stops
|
|
192
|
+
*/
|
|
193
|
+
async disconnect(): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
this.broker.logger.info('Disconnecting from database');
|
|
196
|
+
await this.client.$disconnect();
|
|
197
|
+
this.broker.logger.info('Successfully disconnected from database');
|
|
198
|
+
} catch (error) {
|
|
199
|
+
this.broker.logger.error('Error disconnecting from database:', error);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Health check - verify database connectivity
|
|
206
|
+
*/
|
|
207
|
+
async healthCheck(): Promise<boolean> {
|
|
208
|
+
try {
|
|
209
|
+
this.broker.logger.info('Running Prisma health check');
|
|
210
|
+
|
|
211
|
+
// Simple connectivity test
|
|
212
|
+
await this.client.$queryRaw`SELECT 1`;
|
|
213
|
+
|
|
214
|
+
this.broker.logger.info('Prisma health check passed');
|
|
215
|
+
return true;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.broker.logger.error('Prisma health check failed:', error);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Clear/reset data - useful for testing
|
|
224
|
+
*/
|
|
225
|
+
async clear(): Promise<void> {
|
|
226
|
+
if (isTest || isDev) {
|
|
227
|
+
this.broker.logger.warn(
|
|
228
|
+
'Clearing database (only allowed in test/dev mode)'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Get all model names dynamically
|
|
232
|
+
const modelNames = Object.keys(this.client).filter(
|
|
233
|
+
(key) => !key.startsWith('_') && !key.startsWith('$')
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Delete all data in reverse order (to handle foreign keys)
|
|
237
|
+
for (const modelName of modelNames.reverse()) {
|
|
238
|
+
try {
|
|
239
|
+
const model = this.client[modelName as keyof TPrismaClient] as {
|
|
240
|
+
deleteMany?: () => Promise<unknown>;
|
|
241
|
+
};
|
|
242
|
+
if (model.deleteMany) {
|
|
243
|
+
await model.deleteMany();
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Some models might not support deleteMany, skip them
|
|
247
|
+
this.broker.logger.debug(`Could not clear ${modelName}:`, error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
throw new Error(
|
|
252
|
+
'Database clear is only allowed in test/development environments'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Transaction wrapper with proper error handling
|
|
259
|
+
*/
|
|
260
|
+
async transaction<T>(
|
|
261
|
+
fn: (tx: TPrismaClient) => Promise<T>,
|
|
262
|
+
options?: {
|
|
263
|
+
maxWait?: number;
|
|
264
|
+
timeout?: number;
|
|
265
|
+
}
|
|
266
|
+
): Promise<T> {
|
|
267
|
+
try {
|
|
268
|
+
return await this.client.$transaction(
|
|
269
|
+
(tx) => fn(tx as TPrismaClient),
|
|
270
|
+
options
|
|
271
|
+
);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.broker.logger.error('Transaction failed:', error);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Set tenant context for multi-tenant applications
|
|
280
|
+
* Requires tenant extension to be applied
|
|
281
|
+
*/
|
|
282
|
+
setTenantContext(tenantId: string): void {
|
|
283
|
+
const tenantClient = this.client as any;
|
|
284
|
+
if (tenantClient.$setTenant) {
|
|
285
|
+
tenantClient.$setTenant(tenantId);
|
|
286
|
+
this.broker.logger.debug('Tenant context set:', {tenantId});
|
|
287
|
+
} else {
|
|
288
|
+
this.broker.logger.warn(
|
|
289
|
+
'Tenant extension not available - setTenantContext ignored'
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get current tenant context
|
|
296
|
+
*/
|
|
297
|
+
getCurrentTenant(): string | null {
|
|
298
|
+
const tenantClient = this.client as any;
|
|
299
|
+
if (tenantClient.$getCurrentTenant) {
|
|
300
|
+
return tenantClient.$getCurrentTenant();
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Execute with tenant context (automatically restores previous context)
|
|
307
|
+
*/
|
|
308
|
+
async withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
|
|
309
|
+
const previousTenant = this.getCurrentTenant();
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
this.setTenantContext(tenantId);
|
|
313
|
+
return await fn();
|
|
314
|
+
} finally {
|
|
315
|
+
if (previousTenant) {
|
|
316
|
+
this.setTenantContext(previousTenant);
|
|
317
|
+
} else {
|
|
318
|
+
const tenantClient = this.client as any;
|
|
319
|
+
if (tenantClient.$clearTenant) {
|
|
320
|
+
tenantClient.$clearTenant();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -59,6 +59,11 @@ export function DatasourceMixin(
|
|
|
59
59
|
* Initialize datasources and store on service
|
|
60
60
|
*/
|
|
61
61
|
async created() {
|
|
62
|
+
// Inject broker into datasources
|
|
63
|
+
for (const [, datasource] of Object.entries(datasourceInstances)) {
|
|
64
|
+
(datasource as any).broker = this.broker;
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
// Call init() on datasources that have it
|
|
63
68
|
for (const [, datasource] of Object.entries(datasourceInstances)) {
|
|
64
69
|
if (typeof (datasource as any).init === 'function') {
|
|
@@ -102,8 +107,15 @@ export function DatasourceMixin(
|
|
|
102
107
|
hooks: {
|
|
103
108
|
before: {
|
|
104
109
|
'*': function injectDatasources(ctx) {
|
|
110
|
+
const datasources = (this as any).$datasources ?? {};
|
|
111
|
+
|
|
112
|
+
// Inject current context into all datasources
|
|
113
|
+
for (const [, datasource] of Object.entries(datasources)) {
|
|
114
|
+
(datasource as any).context = ctx;
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
// Inject datasources into the context
|
|
106
|
-
(ctx as AppContext).datasources =
|
|
118
|
+
(ctx as AppContext).datasources = datasources;
|
|
107
119
|
},
|
|
108
120
|
},
|
|
109
121
|
},
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {CHANNEL_CONFIG, INTEGRATION_CHANNELS} from '../configs/constants';
|
|
4
|
+
import type {DatasourceConstructorRegistry} from '../middlewares/datasource.middleware';
|
|
5
|
+
import {
|
|
6
|
+
type IntegrationMessageFailedPayload,
|
|
7
|
+
type IntegrationMessageReceivedPayload,
|
|
8
|
+
type IntegrationMessageSentPayload,
|
|
9
|
+
} from '../types/channels';
|
|
4
10
|
import type {AppContext} from '../types/context';
|
|
5
11
|
import type {IntegrationConfig} from '../types/integration';
|
|
6
12
|
import type {
|
|
7
13
|
NormalizedMessage,
|
|
8
|
-
PlatformMessage,
|
|
9
14
|
SendResult,
|
|
10
15
|
WebhookEvent,
|
|
11
16
|
} from '../types/message';
|
|
17
|
+
import type {DatasourceInstanceTypes} from '../types/datasource';
|
|
12
18
|
import type {
|
|
13
19
|
IntegrationServiceConfig,
|
|
14
20
|
IntegrationServiceSchema,
|
|
15
21
|
} from '../types/service';
|
|
22
|
+
import {defineService} from './define-service';
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Security helpers for webhook validation
|
|
@@ -38,13 +45,6 @@ const SecurityHelpers = {
|
|
|
38
45
|
validateTimestamp(timestamp: number, maxAgeMs = 5 * 60 * 1000): boolean {
|
|
39
46
|
return Date.now() - timestamp <= maxAgeMs;
|
|
40
47
|
},
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Generate correlation ID for request tracking
|
|
44
|
-
*/
|
|
45
|
-
generateCorrelationId(): string {
|
|
46
|
-
return crypto.randomUUID();
|
|
47
|
-
},
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -76,37 +76,52 @@ async function executeWithRetry<T>(
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function defineIntegration<
|
|
79
|
-
TPlatformMessage extends PlatformMessage = PlatformMessage,
|
|
80
79
|
TSettings = unknown,
|
|
81
|
-
|
|
80
|
+
TDatasourceConstructors extends DatasourceConstructorRegistry = DatasourceConstructorRegistry
|
|
82
81
|
>(
|
|
83
|
-
config: IntegrationServiceConfig<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
): IntegrationServiceSchema<TPlatformMessage, TSettings> {
|
|
82
|
+
config: IntegrationServiceConfig<TSettings, TDatasourceConstructors>
|
|
83
|
+
): IntegrationServiceSchema<TSettings> {
|
|
84
|
+
// Type alias for inferred datasource instances
|
|
85
|
+
type TDatasources = DatasourceInstanceTypes<TDatasourceConstructors>;
|
|
86
|
+
|
|
89
87
|
// Create actions from integration methods
|
|
90
88
|
const actions = {
|
|
91
89
|
i_receiveWebhook: {
|
|
92
90
|
rest: {
|
|
93
91
|
method: 'POST' as const,
|
|
94
|
-
path: '/:
|
|
92
|
+
path: '/:channelId',
|
|
95
93
|
},
|
|
96
94
|
params: {
|
|
97
|
-
|
|
95
|
+
channelId: 'string',
|
|
98
96
|
payload: 'object',
|
|
99
97
|
headers: 'object',
|
|
100
98
|
timestamp: 'number',
|
|
101
99
|
},
|
|
102
100
|
async handler(
|
|
103
|
-
ctx: AppContext<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
ctx: AppContext<
|
|
102
|
+
TDatasources,
|
|
103
|
+
{
|
|
104
|
+
channelId: string;
|
|
105
|
+
payload: object;
|
|
106
|
+
headers: object;
|
|
107
|
+
timestamp: number;
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
): Promise<{success: boolean; messages: number; failed: number}> {
|
|
111
|
+
const {channelId, payload, headers, timestamp} = ctx.params;
|
|
112
|
+
|
|
113
|
+
// TODO: Look up channel configuration by channelId
|
|
114
|
+
// const channel = await getChannelConfig(channelId);
|
|
115
|
+
// For now, we'll construct webhook from meta
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
const webhook: WebhookEvent = {
|
|
118
|
+
tenantId: ctx.meta.tenantId ?? 'unknown', // Should come from channel lookup
|
|
119
|
+
channelId,
|
|
120
|
+
platform: config.spec.platform,
|
|
121
|
+
payload, // Raw webhook payload from gateway
|
|
122
|
+
headers: headers as Record<string, string>,
|
|
123
|
+
timestamp,
|
|
124
|
+
};
|
|
110
125
|
|
|
111
126
|
// Validate timestamp to prevent replay attacks
|
|
112
127
|
if (!SecurityHelpers.validateTimestamp(webhook.timestamp)) {
|
|
@@ -130,25 +145,49 @@ export function defineIntegration<
|
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
// Normalize the message
|
|
133
|
-
const normalizedMessages
|
|
134
|
-
|
|
148
|
+
const normalizedMessages = await config.normalize(webhook);
|
|
149
|
+
|
|
150
|
+
// Process each message with reliable delivery (parallel)
|
|
151
|
+
const results = await Promise.allSettled(
|
|
152
|
+
normalizedMessages.map(async (message) => {
|
|
153
|
+
const payload: IntegrationMessageReceivedPayload = {
|
|
154
|
+
tenantId: webhook.tenantId,
|
|
155
|
+
channelId: webhook.channelId,
|
|
156
|
+
platform: webhook.platform,
|
|
157
|
+
message,
|
|
158
|
+
timestamp: Date.now(),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Send to reliable message channel (NATS JetStream)
|
|
162
|
+
return ctx.broker.sendToChannel(
|
|
163
|
+
INTEGRATION_CHANNELS.MESSAGE_RECEIVED,
|
|
164
|
+
payload,
|
|
165
|
+
CHANNEL_CONFIG.HIGH_PRIORITY
|
|
166
|
+
);
|
|
167
|
+
})
|
|
135
168
|
);
|
|
136
169
|
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
170
|
+
// Count successful vs failed messages
|
|
171
|
+
const successful = results.filter(
|
|
172
|
+
(result) => result.status === 'fulfilled'
|
|
173
|
+
).length;
|
|
174
|
+
const failed = results.filter(
|
|
175
|
+
(result) => result.status === 'rejected'
|
|
176
|
+
).length;
|
|
177
|
+
|
|
178
|
+
// Log failures for monitoring (but don't fail the entire webhook)
|
|
179
|
+
if (failed > 0) {
|
|
180
|
+
const failures = results
|
|
181
|
+
.filter((result) => result.status === 'rejected')
|
|
182
|
+
.map((result) => (result as PromiseRejectedResult).reason);
|
|
183
|
+
|
|
184
|
+
ctx.broker.logger.warn(
|
|
185
|
+
`Webhook processing partial failure: ${failed}/${normalizedMessages.length} messages failed`,
|
|
186
|
+
{failures}
|
|
187
|
+
);
|
|
149
188
|
}
|
|
150
189
|
|
|
151
|
-
return {success: true, messages:
|
|
190
|
+
return {success: true, messages: successful, failed};
|
|
152
191
|
},
|
|
153
192
|
},
|
|
154
193
|
|
|
@@ -159,20 +198,26 @@ export function defineIntegration<
|
|
|
159
198
|
},
|
|
160
199
|
params: {
|
|
161
200
|
message: 'object',
|
|
162
|
-
|
|
201
|
+
channelId: 'string',
|
|
163
202
|
},
|
|
164
|
-
async handler(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
203
|
+
async handler(
|
|
204
|
+
ctx: AppContext<
|
|
205
|
+
TDatasources,
|
|
206
|
+
{
|
|
207
|
+
message: NormalizedMessage;
|
|
208
|
+
channelId: string;
|
|
209
|
+
}
|
|
210
|
+
>
|
|
211
|
+
): Promise<SendResult> {
|
|
212
|
+
const {message, channelId} = ctx.params;
|
|
169
213
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
214
|
+
// Load channel configuration
|
|
215
|
+
const integrationConfig = await config.getChannelConfig(ctx, channelId);
|
|
216
|
+
if (!integrationConfig) {
|
|
217
|
+
throw new Error(`Channel configuration not found: ${channelId}`);
|
|
173
218
|
}
|
|
174
219
|
|
|
175
|
-
// Send the message with retry mechanism
|
|
220
|
+
// Send the message with retry mechanism (transformation happens inside sendMessage)
|
|
176
221
|
const result: SendResult = await executeWithRetry(
|
|
177
222
|
() => config.sendMessage(ctx, message, integrationConfig),
|
|
178
223
|
3,
|
|
@@ -180,18 +225,49 @@ export function defineIntegration<
|
|
|
180
225
|
`Send message via ${config.spec.platform}`
|
|
181
226
|
);
|
|
182
227
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
228
|
+
// Send reliable events based on result (with error handling)
|
|
229
|
+
try {
|
|
230
|
+
if (result.success) {
|
|
231
|
+
const sentPayload: IntegrationMessageSentPayload = {
|
|
232
|
+
tenantId: integrationConfig.tenantId,
|
|
233
|
+
channelId,
|
|
234
|
+
platform: config.spec.platform,
|
|
235
|
+
messageId: result.messageId,
|
|
236
|
+
metadata: result.metadata,
|
|
237
|
+
timestamp: Date.now(),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
await ctx.broker.sendToChannel(
|
|
241
|
+
INTEGRATION_CHANNELS.MESSAGE_SENT,
|
|
242
|
+
sentPayload,
|
|
243
|
+
CHANNEL_CONFIG.DEFAULTS
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
const failedPayload: IntegrationMessageFailedPayload = {
|
|
247
|
+
tenantId: integrationConfig.tenantId,
|
|
248
|
+
channelId,
|
|
249
|
+
platform: config.spec.platform,
|
|
250
|
+
error: result.error?.message ?? 'Unknown error',
|
|
251
|
+
message,
|
|
252
|
+
timestamp: Date.now(),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
await ctx.broker.sendToChannel(
|
|
256
|
+
INTEGRATION_CHANNELS.MESSAGE_FAILED,
|
|
257
|
+
failedPayload,
|
|
258
|
+
CHANNEL_CONFIG.DEFAULTS
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} catch (channelError: unknown) {
|
|
262
|
+
// Log channel error but don't fail the send operation
|
|
263
|
+
const err =
|
|
264
|
+
channelError instanceof Error
|
|
265
|
+
? channelError
|
|
266
|
+
: new Error(String(channelError));
|
|
267
|
+
ctx.broker.logger.warn('Failed to send message event to channel', {
|
|
268
|
+
error: err.message,
|
|
186
269
|
messageId: result.messageId,
|
|
187
270
|
platform: config.spec.platform,
|
|
188
|
-
metadata: result.metadata,
|
|
189
|
-
});
|
|
190
|
-
} else {
|
|
191
|
-
ctx.emit('integration.message.failed', {
|
|
192
|
-
error: result.error,
|
|
193
|
-
platform: config.spec.platform,
|
|
194
|
-
message,
|
|
195
271
|
});
|
|
196
272
|
}
|
|
197
273
|
|
|
@@ -267,23 +343,6 @@ export function defineIntegration<
|
|
|
267
343
|
},
|
|
268
344
|
},
|
|
269
345
|
|
|
270
|
-
i_status: {
|
|
271
|
-
rest: {
|
|
272
|
-
method: 'GET' as const,
|
|
273
|
-
path: '/status',
|
|
274
|
-
},
|
|
275
|
-
handler(): object {
|
|
276
|
-
return {
|
|
277
|
-
id: config.spec.id,
|
|
278
|
-
name: config.spec.name,
|
|
279
|
-
platform: config.spec.platform,
|
|
280
|
-
version: config.spec.version,
|
|
281
|
-
status: config.spec.status,
|
|
282
|
-
capabilities: config.spec.capabilities,
|
|
283
|
-
};
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
|
|
287
346
|
i_validateCredentials: {
|
|
288
347
|
params: {
|
|
289
348
|
credentials: 'object',
|
|
@@ -305,7 +364,7 @@ export function defineIntegration<
|
|
|
305
364
|
}
|
|
306
365
|
|
|
307
366
|
// Use defineService to get all the mixins and proper setup
|
|
308
|
-
const baseService = defineService<
|
|
367
|
+
const baseService = defineService<TSettings, TDatasourceConstructors>({
|
|
309
368
|
name: config.name,
|
|
310
369
|
version: config.version,
|
|
311
370
|
settings: config.settings as TSettings,
|
|
@@ -320,12 +379,12 @@ export function defineIntegration<
|
|
|
320
379
|
methods: {
|
|
321
380
|
...(config.methods ?? {}),
|
|
322
381
|
// Add integration-specific methods to the service methods (filter out undefined)
|
|
323
|
-
//
|
|
382
|
+
// Required methods - no need for conditional
|
|
324
383
|
normalize: config.normalize,
|
|
325
|
-
|
|
326
|
-
...(config.validateWebhook && {validateWebhook: config.validateWebhook}),
|
|
327
|
-
// sendMessage is required, no need for conditional
|
|
384
|
+
getChannelConfig: config.getChannelConfig,
|
|
328
385
|
sendMessage: config.sendMessage,
|
|
386
|
+
// Optional methods
|
|
387
|
+
...(config.validateWebhook && {validateWebhook: config.validateWebhook}),
|
|
329
388
|
...(config.verifyWebhook && {verifyWebhook: config.verifyWebhook}),
|
|
330
389
|
...(config.checkHealth && {checkHealth: config.checkHealth}),
|
|
331
390
|
...(config.validateCredentials && {
|
|
@@ -345,5 +404,5 @@ export function defineIntegration<
|
|
|
345
404
|
...baseService,
|
|
346
405
|
// Only add the integration spec
|
|
347
406
|
spec: config.spec,
|
|
348
|
-
} as IntegrationServiceSchema<
|
|
407
|
+
} as IntegrationServiceSchema<TSettings>;
|
|
349
408
|
}
|