@friggframework/core 2.0.0--canary.606.7fde0eb.0 → 2.0.0--canary.608.ba60ba6.0
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/handlers/routers/reporting.js +9 -0
- package/handlers/workers/user-action-worker.js +155 -0
- package/index.js +21 -9
- package/integrations/integration-base.js +47 -8
- package/integrations/integration-router.js +30 -3
- package/integrations/use-cases/create-integration.js +18 -2
- package/integrations/use-cases/dispatch-integration-event.js +76 -0
- package/integrations/use-cases/get-integration-instance.js +12 -3
- package/integrations/use-cases/update-integration.js +20 -2
- package/package.json +5 -5
- package/queues/queuer-util.js +19 -2
- package/reporting/index.js +17 -0
- package/reporting/reporting-router.js +48 -0
- package/reporting/repositories/reporting-repository-documentdb.js +127 -0
- package/reporting/repositories/reporting-repository-factory.js +35 -0
- package/reporting/repositories/reporting-repository-interface.js +16 -0
- package/reporting/repositories/reporting-repository-mongo.js +54 -0
- package/reporting/repositories/reporting-repository-postgres.js +70 -0
- package/reporting/use-cases/index.js +6 -0
- package/reporting/use-cases/list-integrations-report.js +137 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const { createReportingRouter } = require('@friggframework/core');
|
|
2
|
+
const { createAppHandler } = require('./../app-handler-helpers');
|
|
3
|
+
|
|
4
|
+
const router = createReportingRouter();
|
|
5
|
+
|
|
6
|
+
// true → eager-connect Prisma; the reporting endpoints read the DB.
|
|
7
|
+
const handler = createAppHandler('HTTP Event: Reporting', router, true);
|
|
8
|
+
|
|
9
|
+
module.exports = { handler, router };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const { Worker } = require('../../core/Worker');
|
|
2
|
+
const {
|
|
3
|
+
GetIntegrationInstance,
|
|
4
|
+
} = require('../../integrations/use-cases/get-integration-instance');
|
|
5
|
+
const { ModuleFactory } = require('../../modules/module-factory');
|
|
6
|
+
const {
|
|
7
|
+
createIntegrationRepository,
|
|
8
|
+
} = require('../../integrations/repositories/integration-repository-factory');
|
|
9
|
+
const {
|
|
10
|
+
createModuleRepository,
|
|
11
|
+
} = require('../../modules/repositories/module-repository-factory');
|
|
12
|
+
const {
|
|
13
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
14
|
+
} = require('../../integrations/utils/map-integration-dto');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* App-level worker for events dispatched with `dispatch: 'queue'`.
|
|
18
|
+
*
|
|
19
|
+
* A single Lambda serves every integration: messages arrive on the FIFO queue
|
|
20
|
+
* serialized per `MessageGroupId = integrationId`, so concurrent mutations for
|
|
21
|
+
* one integration run one-at-a-time. The worker re-hydrates the integration by
|
|
22
|
+
* id + owning user and runs `instance.send(event, data)` — byte-identical to
|
|
23
|
+
* the in-process (sync) path it replaces.
|
|
24
|
+
*
|
|
25
|
+
* Failure handling:
|
|
26
|
+
* - missing ids / un-hydratable / id mismatch → HaltError (discard, no retry)
|
|
27
|
+
* - handler throw → record ERROR status + a warning message, then rethrow so
|
|
28
|
+
* SQS retries and ultimately routes to the FIFO DLQ.
|
|
29
|
+
*
|
|
30
|
+
* DISABLED/ERROR integrations are NOT discarded — these are user-initiated
|
|
31
|
+
* mutations (including ERROR recovery), unlike webhook/cron traffic.
|
|
32
|
+
*/
|
|
33
|
+
class UserActionWorker extends Worker {
|
|
34
|
+
constructor({ getIntegrationInstance } = {}) {
|
|
35
|
+
super();
|
|
36
|
+
this._getIntegrationInstance = getIntegrationInstance || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_validateParams(params) {
|
|
40
|
+
this._verifyParamExists(params, 'event');
|
|
41
|
+
this._verifyParamExists(params, 'data');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_resolveGetIntegrationInstance() {
|
|
45
|
+
if (this._getIntegrationInstance) return this._getIntegrationInstance;
|
|
46
|
+
|
|
47
|
+
const { loadAppDefinition } = require('../app-definition-loader');
|
|
48
|
+
const { integrations: integrationClasses } = loadAppDefinition();
|
|
49
|
+
const integrationRepository = createIntegrationRepository();
|
|
50
|
+
const moduleRepository = createModuleRepository();
|
|
51
|
+
const moduleFactory = new ModuleFactory({
|
|
52
|
+
moduleRepository,
|
|
53
|
+
moduleDefinitions:
|
|
54
|
+
getModulesDefinitionFromIntegrationClasses(integrationClasses),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this._getIntegrationInstance = new GetIntegrationInstance({
|
|
58
|
+
integrationRepository,
|
|
59
|
+
integrationClasses,
|
|
60
|
+
moduleFactory,
|
|
61
|
+
});
|
|
62
|
+
return this._getIntegrationInstance;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async _run(params) {
|
|
66
|
+
// Routing metadata is at the envelope top level; `data` is the exact
|
|
67
|
+
// handler payload (byte-identical to the sync path).
|
|
68
|
+
const { event, data = {}, integrationId, userId, requestId } = params;
|
|
69
|
+
const logCtx = { event, integrationId, userId, requestId };
|
|
70
|
+
|
|
71
|
+
if (!integrationId || !userId) {
|
|
72
|
+
const err = new Error(
|
|
73
|
+
'[UserActionWorker] message missing integrationId/userId'
|
|
74
|
+
);
|
|
75
|
+
err.isHaltError = true;
|
|
76
|
+
console.error(err.message, logCtx);
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const getIntegrationInstance = this._resolveGetIntegrationInstance();
|
|
81
|
+
|
|
82
|
+
let instance;
|
|
83
|
+
try {
|
|
84
|
+
instance = await getIntegrationInstance.execute(
|
|
85
|
+
integrationId,
|
|
86
|
+
userId
|
|
87
|
+
);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// Discard ONLY terminal failures (gone / not owned / unknown class)
|
|
90
|
+
// — no retry can ever succeed. Transient failures (DB blip, Prisma
|
|
91
|
+
// timeout, KMS throttle during credential decrypt) are NOT marked
|
|
92
|
+
// terminal, so they retry and ultimately reach the FIFO DLQ instead
|
|
93
|
+
// of being silently dropped.
|
|
94
|
+
if (error.isTerminal) {
|
|
95
|
+
error.isHaltError = true;
|
|
96
|
+
console.warn(
|
|
97
|
+
`[UserActionWorker] integration ${integrationId} gone/invalid — discarding`,
|
|
98
|
+
{ ...logCtx, reason: error.message }
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
console.error(
|
|
102
|
+
`[UserActionWorker] transient hydration failure for ${integrationId} — will retry`,
|
|
103
|
+
{ ...logCtx, reason: error.message }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (String(instance.id) !== String(integrationId)) {
|
|
110
|
+
const err = new Error(
|
|
111
|
+
`[UserActionWorker] hydrated id ${instance.id} != message integrationId ${integrationId}`
|
|
112
|
+
);
|
|
113
|
+
err.isHaltError = true;
|
|
114
|
+
console.error(err.message, logCtx);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
console.log(`[UserActionWorker] dispatching ${event}`, logCtx);
|
|
120
|
+
const result = await instance.send(event, data);
|
|
121
|
+
console.log(`[UserActionWorker] ${event} ok`, logCtx);
|
|
122
|
+
return result;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
try {
|
|
125
|
+
await instance.updateIntegrationStatus.execute(
|
|
126
|
+
integrationId,
|
|
127
|
+
'ERROR'
|
|
128
|
+
);
|
|
129
|
+
await instance.updateIntegrationMessages.execute(
|
|
130
|
+
integrationId,
|
|
131
|
+
'warnings',
|
|
132
|
+
`Queued ${event} failed`,
|
|
133
|
+
error.message,
|
|
134
|
+
new Date().toISOString()
|
|
135
|
+
);
|
|
136
|
+
} catch (statusErr) {
|
|
137
|
+
console.error(
|
|
138
|
+
'[UserActionWorker] failed to record error status/messages',
|
|
139
|
+
{ ...logCtx, statusErr: statusErr.message }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
console.error(`[UserActionWorker] ${event} failed`, {
|
|
143
|
+
...logCtx,
|
|
144
|
+
error: error.message,
|
|
145
|
+
});
|
|
146
|
+
throw error; // → SQS retry → FIFO DLQ
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function userActionQueueWorker(event, context) {
|
|
152
|
+
return new UserActionWorker().run(event, context);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { UserActionWorker, userActionQueueWorker };
|
package/index.js
CHANGED
|
@@ -30,9 +30,7 @@ const {
|
|
|
30
30
|
const {
|
|
31
31
|
GetUserFromAdopterJwt,
|
|
32
32
|
} = require('./user/use-cases/get-user-from-adopter-jwt');
|
|
33
|
-
const {
|
|
34
|
-
AuthenticateUser,
|
|
35
|
-
} = require('./user/use-cases/authenticate-user');
|
|
33
|
+
const { AuthenticateUser } = require('./user/use-cases/authenticate-user');
|
|
36
34
|
|
|
37
35
|
const {
|
|
38
36
|
CredentialRepository,
|
|
@@ -43,18 +41,14 @@ const {
|
|
|
43
41
|
const {
|
|
44
42
|
IntegrationMappingRepository,
|
|
45
43
|
} = require('./integrations/repositories/integration-mapping-repository');
|
|
46
|
-
const {
|
|
47
|
-
CreateProcess,
|
|
48
|
-
} = require('./integrations/use-cases/create-process');
|
|
44
|
+
const { CreateProcess } = require('./integrations/use-cases/create-process');
|
|
49
45
|
const {
|
|
50
46
|
UpdateProcessState,
|
|
51
47
|
} = require('./integrations/use-cases/update-process-state');
|
|
52
48
|
const {
|
|
53
49
|
UpdateProcessMetrics,
|
|
54
50
|
} = require('./integrations/use-cases/update-process-metrics');
|
|
55
|
-
const {
|
|
56
|
-
GetProcess,
|
|
57
|
-
} = require('./integrations/use-cases/get-process');
|
|
51
|
+
const { GetProcess } = require('./integrations/use-cases/get-process');
|
|
58
52
|
const { Cryptor } = require('./encrypt');
|
|
59
53
|
const {
|
|
60
54
|
BaseError,
|
|
@@ -71,6 +65,10 @@ const {
|
|
|
71
65
|
getModulesDefinitionFromIntegrationClasses,
|
|
72
66
|
LoadIntegrationContextUseCase,
|
|
73
67
|
} = require('./integrations/index');
|
|
68
|
+
const {
|
|
69
|
+
createReportingRouter,
|
|
70
|
+
createReportingRepository,
|
|
71
|
+
} = require('./reporting/index');
|
|
74
72
|
const { TimeoutCatcher } = require('./lambda/index');
|
|
75
73
|
const { debug, initDebugLog, flushDebugLog } = require('./logs/index');
|
|
76
74
|
const {
|
|
@@ -87,6 +85,12 @@ const application = require('./application');
|
|
|
87
85
|
const utils = require('./utils');
|
|
88
86
|
|
|
89
87
|
const { QueuerUtil } = require('./queues');
|
|
88
|
+
const {
|
|
89
|
+
dispatchIntegrationEvent,
|
|
90
|
+
} = require('./integrations/use-cases/dispatch-integration-event');
|
|
91
|
+
const {
|
|
92
|
+
userActionQueueWorker,
|
|
93
|
+
} = require('./handlers/workers/user-action-worker');
|
|
90
94
|
|
|
91
95
|
module.exports = {
|
|
92
96
|
// assertions
|
|
@@ -133,12 +137,17 @@ module.exports = {
|
|
|
133
137
|
checkRequiredParams,
|
|
134
138
|
createIntegrationRouter,
|
|
135
139
|
getModulesDefinitionFromIntegrationClasses,
|
|
140
|
+
dispatchIntegrationEvent,
|
|
136
141
|
LoadIntegrationContextUseCase,
|
|
137
142
|
CreateProcess,
|
|
138
143
|
UpdateProcessState,
|
|
139
144
|
UpdateProcessMetrics,
|
|
140
145
|
GetProcess,
|
|
141
146
|
|
|
147
|
+
// reporting
|
|
148
|
+
createReportingRouter,
|
|
149
|
+
createReportingRepository,
|
|
150
|
+
|
|
142
151
|
// application - Command factories for integration developers
|
|
143
152
|
application,
|
|
144
153
|
createFriggCommands: application.createFriggCommands,
|
|
@@ -172,6 +181,9 @@ module.exports = {
|
|
|
172
181
|
// queues
|
|
173
182
|
QueuerUtil,
|
|
174
183
|
|
|
184
|
+
// workers
|
|
185
|
+
userActionQueueWorker,
|
|
186
|
+
|
|
175
187
|
// utils
|
|
176
188
|
...utils,
|
|
177
189
|
};
|
|
@@ -31,6 +31,13 @@ const constantsToBeMigrated = {
|
|
|
31
31
|
LIFE_CYCLE_EVENT: 'LIFE_CYCLE_EVENT',
|
|
32
32
|
USER_ACTION: 'USER_ACTION',
|
|
33
33
|
},
|
|
34
|
+
// Per-event dispatch mode. 'sync' (default) runs the handler in-process and
|
|
35
|
+
// returns its result; 'queue' routes the event through the framework-owned
|
|
36
|
+
// FIFO queue, serialized by integrationId, returning a 202 ack.
|
|
37
|
+
dispatch: {
|
|
38
|
+
SYNC: 'sync',
|
|
39
|
+
QUEUE: 'queue',
|
|
40
|
+
},
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
class IntegrationBase {
|
|
@@ -208,11 +215,11 @@ class IntegrationBase {
|
|
|
208
215
|
/**
|
|
209
216
|
* Returns the modules as object with keys as module names.
|
|
210
217
|
* Uses the keys from Definition.modules to attach modules correctly.
|
|
211
|
-
*
|
|
218
|
+
*
|
|
212
219
|
* Example:
|
|
213
220
|
* Definition.modules = { attio: {...}, quo: { definition: { getName: () => 'quo-attio' } } }
|
|
214
221
|
* Module with getName()='quo-attio' gets attached as this.quo (not this['quo-attio'])
|
|
215
|
-
*
|
|
222
|
+
*
|
|
216
223
|
* @private
|
|
217
224
|
* @param {Array} integrationModules - Array of module instances
|
|
218
225
|
* @returns {Object} The modules object
|
|
@@ -224,13 +231,16 @@ class IntegrationBase {
|
|
|
224
231
|
// e.g., 'quo-attio' → 'quo', 'attio' → 'attio'
|
|
225
232
|
const moduleNameToKey = {};
|
|
226
233
|
if (this.constructor.Definition?.modules) {
|
|
227
|
-
for (const [key, moduleConfig] of Object.entries(
|
|
234
|
+
for (const [key, moduleConfig] of Object.entries(
|
|
235
|
+
this.constructor.Definition.modules
|
|
236
|
+
)) {
|
|
228
237
|
const definition = moduleConfig.definition;
|
|
229
238
|
if (definition) {
|
|
230
239
|
// Use getName() if available, fallback to moduleName
|
|
231
|
-
const definitionName =
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
const definitionName =
|
|
241
|
+
typeof definition.getName === 'function'
|
|
242
|
+
? definition.getName()
|
|
243
|
+
: definition.moduleName;
|
|
234
244
|
if (definitionName) {
|
|
235
245
|
moduleNameToKey[definitionName] = key;
|
|
236
246
|
}
|
|
@@ -521,6 +531,24 @@ class IntegrationBase {
|
|
|
521
531
|
...this.defaultEvents,
|
|
522
532
|
...this.events,
|
|
523
533
|
};
|
|
534
|
+
|
|
535
|
+
// Apply Definition.eventDispatch onto the resolved handler entries. This
|
|
536
|
+
// lets a default lifecycle event (e.g. ON_UPDATE) be marked 'queue'
|
|
537
|
+
// without re-declaring its handler. An inline `dispatch` on the event
|
|
538
|
+
// entry always wins; unknown event names are ignored (a typo stays sync
|
|
539
|
+
// rather than crashing initialize()).
|
|
540
|
+
const eventDispatch = this.constructor.Definition?.eventDispatch || {};
|
|
541
|
+
for (const [eventName, mode] of Object.entries(eventDispatch)) {
|
|
542
|
+
if (
|
|
543
|
+
this.on[eventName] &&
|
|
544
|
+
this.on[eventName].dispatch === undefined
|
|
545
|
+
) {
|
|
546
|
+
this.on[eventName] = {
|
|
547
|
+
...this.on[eventName],
|
|
548
|
+
dispatch: mode,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
524
552
|
}
|
|
525
553
|
|
|
526
554
|
/**
|
|
@@ -608,6 +636,9 @@ class IntegrationBase {
|
|
|
608
636
|
this.events[eventName] = {
|
|
609
637
|
type: eventDef.type,
|
|
610
638
|
handler: fn.bind(this),
|
|
639
|
+
...(eventDef.dispatch !== undefined && {
|
|
640
|
+
dispatch: eventDef.dispatch,
|
|
641
|
+
}),
|
|
611
642
|
};
|
|
612
643
|
mergedByExtension.set(eventName, bindingName);
|
|
613
644
|
}
|
|
@@ -675,7 +706,11 @@ class IntegrationBase {
|
|
|
675
706
|
|
|
676
707
|
if (delegateString === 'CREDENTIAL_INVALIDATED') {
|
|
677
708
|
console.log(
|
|
678
|
-
`[Frigg] Module ${
|
|
709
|
+
`[Frigg] Module ${
|
|
710
|
+
notifier?.name || '?'
|
|
711
|
+
} reported invalid credentials for integration ${
|
|
712
|
+
this.id
|
|
713
|
+
} — marking ERROR`
|
|
679
714
|
);
|
|
680
715
|
await this.updateIntegrationStatus.execute(this.id, 'ERROR');
|
|
681
716
|
this.status = 'ERROR';
|
|
@@ -685,7 +720,11 @@ class IntegrationBase {
|
|
|
685
720
|
if (delegateString === 'CREDENTIAL_VALIDATED') {
|
|
686
721
|
if (this.status !== 'ERROR') return;
|
|
687
722
|
console.log(
|
|
688
|
-
`[Frigg] Module ${
|
|
723
|
+
`[Frigg] Module ${
|
|
724
|
+
notifier?.name || '?'
|
|
725
|
+
} reported valid credentials for integration ${
|
|
726
|
+
this.id
|
|
727
|
+
} — clearing ERROR → ENABLED`
|
|
689
728
|
);
|
|
690
729
|
await this.updateIntegrationStatus.execute(this.id, 'ENABLED');
|
|
691
730
|
this.status = 'ENABLED';
|
|
@@ -30,6 +30,9 @@ const {
|
|
|
30
30
|
GetIntegrationInstance,
|
|
31
31
|
} = require('./use-cases/get-integration-instance');
|
|
32
32
|
const { UpdateIntegration } = require('./use-cases/update-integration');
|
|
33
|
+
const {
|
|
34
|
+
dispatchIntegrationEvent,
|
|
35
|
+
} = require('./use-cases/dispatch-integration-event');
|
|
33
36
|
const {
|
|
34
37
|
getModulesDefinitionFromIntegrationClasses,
|
|
35
38
|
} = require('./utils/map-integration-dto');
|
|
@@ -293,6 +296,9 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
293
296
|
params.config
|
|
294
297
|
);
|
|
295
298
|
|
|
299
|
+
if (integration?.queued) {
|
|
300
|
+
return res.status(202).json(integration);
|
|
301
|
+
}
|
|
296
302
|
res.status(201).json(integration);
|
|
297
303
|
})
|
|
298
304
|
);
|
|
@@ -308,6 +314,9 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
308
314
|
userId,
|
|
309
315
|
params.config
|
|
310
316
|
);
|
|
317
|
+
if (integration?.queued) {
|
|
318
|
+
return res.status(202).json(integration);
|
|
319
|
+
}
|
|
311
320
|
res.json(integration);
|
|
312
321
|
})
|
|
313
322
|
);
|
|
@@ -425,7 +434,21 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
425
434
|
params.integrationId,
|
|
426
435
|
user.getId()
|
|
427
436
|
);
|
|
428
|
-
|
|
437
|
+
const outcome = await dispatchIntegrationEvent({
|
|
438
|
+
instance: integration,
|
|
439
|
+
event: params.actionId,
|
|
440
|
+
data: req.body,
|
|
441
|
+
userId: user.getId(),
|
|
442
|
+
});
|
|
443
|
+
if (outcome.queued) {
|
|
444
|
+
return res.status(202).json({
|
|
445
|
+
queued: true,
|
|
446
|
+
integrationId: integration.id,
|
|
447
|
+
messageId: outcome.messageId,
|
|
448
|
+
requestId: outcome.requestId,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
res.json(outcome.result);
|
|
429
452
|
})
|
|
430
453
|
);
|
|
431
454
|
|
|
@@ -536,7 +559,9 @@ function setEntityRoutes(router, authenticateUser, useCases) {
|
|
|
536
559
|
? Object.keys(params.data)
|
|
537
560
|
: [];
|
|
538
561
|
console.log(
|
|
539
|
-
`[Frigg] POST /api/authorize userId=${userId} entityType=${
|
|
562
|
+
`[Frigg] POST /api/authorize userId=${userId} entityType=${
|
|
563
|
+
params.entityType
|
|
564
|
+
} dataKeys=${JSON.stringify(dataKeys)}`
|
|
540
565
|
);
|
|
541
566
|
|
|
542
567
|
try {
|
|
@@ -554,7 +579,9 @@ function setEntityRoutes(router, authenticateUser, useCases) {
|
|
|
554
579
|
res.json(entityDetails);
|
|
555
580
|
} catch (err) {
|
|
556
581
|
console.error(
|
|
557
|
-
`[Frigg] POST /api/authorize failed userId=${userId} entityType=${
|
|
582
|
+
`[Frigg] POST /api/authorize failed userId=${userId} entityType=${
|
|
583
|
+
params.entityType
|
|
584
|
+
} error=${err?.message || err}`
|
|
558
585
|
);
|
|
559
586
|
throw err;
|
|
560
587
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const {
|
|
3
3
|
mapIntegrationClassToIntegrationDTO,
|
|
4
4
|
} = require('../utils/map-integration-dto');
|
|
5
|
+
const { dispatchIntegrationEvent } = require('./dispatch-integration-event');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Use case for creating a new integration instance.
|
|
@@ -71,11 +72,26 @@ class CreateIntegration {
|
|
|
71
72
|
modules,
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
// ON_CREATE runs in-process by default. Routed through the dispatch
|
|
76
|
+
// helper so an integration can opt into dispatch:'queue' if desired;
|
|
77
|
+
// when queued, an ack is returned instead of the DTO.
|
|
74
78
|
await integrationInstance.initialize();
|
|
75
|
-
await
|
|
76
|
-
|
|
79
|
+
const outcome = await dispatchIntegrationEvent({
|
|
80
|
+
instance: integrationInstance,
|
|
81
|
+
event: 'ON_CREATE',
|
|
82
|
+
data: { integrationId: integrationRecord.id },
|
|
83
|
+
userId,
|
|
77
84
|
});
|
|
78
85
|
|
|
86
|
+
if (outcome.queued) {
|
|
87
|
+
return {
|
|
88
|
+
queued: true,
|
|
89
|
+
integrationId: integrationInstance.id,
|
|
90
|
+
messageId: outcome.messageId,
|
|
91
|
+
requestId: outcome.requestId,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
return mapIntegrationClassToIntegrationDTO(integrationInstance);
|
|
80
96
|
}
|
|
81
97
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { v4: uuid } = require('uuid');
|
|
2
|
+
const { QueuerUtil } = require('../../queues');
|
|
3
|
+
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
// Default lifecycle events that MUTATE and may therefore be routed to the queue.
|
|
7
|
+
// Read-shaped defaults (GET_*/REFRESH_*/WEBHOOK_RECEIVED) must never be queued —
|
|
8
|
+
// a queued read would return a 202 ack instead of the data the caller expects.
|
|
9
|
+
const MUTATING_DEFAULT_EVENTS = new Set([
|
|
10
|
+
'ON_CREATE',
|
|
11
|
+
'ON_UPDATE',
|
|
12
|
+
'ON_DELETE',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Custom user actions (events not present in defaultEvents) are mutating by
|
|
16
|
+
// intent; only default events are filtered against the allowlist above.
|
|
17
|
+
function isMutatingEvent(instance, event) {
|
|
18
|
+
if (!instance.defaultEvents || !instance.defaultEvents[event]) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return MUTATING_DEFAULT_EVENTS.has(event);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Central producer decision for dispatching an integration event.
|
|
26
|
+
*
|
|
27
|
+
* If the event opts into `dispatch: 'queue'`, is a mutating event, and the
|
|
28
|
+
* framework queue is configured, the event is enqueued on the app-level FIFO
|
|
29
|
+
* queue (serialized per integrationId) and a `{ queued }` ack is returned.
|
|
30
|
+
* Otherwise the handler runs in-process and its result is returned. When the
|
|
31
|
+
* queue URL is missing we degrade gracefully to in-process execution.
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} args
|
|
34
|
+
* @param {Object} args.instance - Initialized integration instance (has `on`, `id`, `send`).
|
|
35
|
+
* @param {string} args.event - Event name to dispatch.
|
|
36
|
+
* @param {Object} args.data - Payload passed to the handler / placed in the envelope.
|
|
37
|
+
* @param {string} args.userId - Owning user id (used by the worker to re-hydrate).
|
|
38
|
+
* @returns {Promise<{queued:true, messageId:string, requestId:string} | {result:any}>}
|
|
39
|
+
*/
|
|
40
|
+
async function dispatchIntegrationEvent({ instance, event, data, userId }) {
|
|
41
|
+
const mode = instance.on?.[event]?.dispatch;
|
|
42
|
+
const queueUrl = process.env.USER_ACTION_QUEUE_URL;
|
|
43
|
+
|
|
44
|
+
const eligible = mode === 'queue' && isMutatingEvent(instance, event);
|
|
45
|
+
|
|
46
|
+
if (eligible && !queueUrl) {
|
|
47
|
+
console.warn(
|
|
48
|
+
`[dispatchIntegrationEvent] event "${event}" requested dispatch:'queue' ` +
|
|
49
|
+
`but USER_ACTION_QUEUE_URL is not set — running in-process (sync)`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (eligible && queueUrl) {
|
|
54
|
+
const requestId = uuid();
|
|
55
|
+
// Routing metadata lives at the envelope top level (alongside event),
|
|
56
|
+
// NOT inside `data`. `data` stays the exact handler payload the sync
|
|
57
|
+
// path passes, so the worker's send(event, data) is byte-identical.
|
|
58
|
+
const envelope = {
|
|
59
|
+
schemaVersion: SCHEMA_VERSION,
|
|
60
|
+
event,
|
|
61
|
+
integrationId: instance.id,
|
|
62
|
+
userId,
|
|
63
|
+
requestId,
|
|
64
|
+
data,
|
|
65
|
+
};
|
|
66
|
+
const result = await QueuerUtil.send(envelope, queueUrl, {
|
|
67
|
+
messageGroupId: instance.id,
|
|
68
|
+
messageDeduplicationId: requestId,
|
|
69
|
+
});
|
|
70
|
+
return { queued: true, messageId: result?.MessageId, requestId };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { result: await instance.send(event, data) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { dispatchIntegrationEvent, SCHEMA_VERSION };
|
|
@@ -31,9 +31,12 @@ class GetIntegrationInstance {
|
|
|
31
31
|
await this.integrationRepository.findIntegrationById(integrationId);
|
|
32
32
|
|
|
33
33
|
if (!integrationRecord) {
|
|
34
|
-
|
|
34
|
+
const error = new Error(
|
|
35
35
|
`No integration found by the ID of ${integrationId}`
|
|
36
36
|
);
|
|
37
|
+
// Terminal: the integration does not exist — no retry can succeed.
|
|
38
|
+
error.isTerminal = true;
|
|
39
|
+
throw error;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
const integrationClass = this.integrationClasses.find(
|
|
@@ -43,15 +46,21 @@ class GetIntegrationInstance {
|
|
|
43
46
|
);
|
|
44
47
|
|
|
45
48
|
if (!integrationClass) {
|
|
46
|
-
|
|
49
|
+
const error = new Error(
|
|
47
50
|
`No integration class found for type: ${integrationRecord.config.type}`
|
|
48
51
|
);
|
|
52
|
+
// Terminal: the integration type is not registered — no retry helps.
|
|
53
|
+
error.isTerminal = true;
|
|
54
|
+
throw error;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
if (integrationRecord.userId !== userId) {
|
|
52
|
-
|
|
58
|
+
const error = new Error(
|
|
53
59
|
`Integration ${integrationId} does not belong to User ${userId}`
|
|
54
60
|
);
|
|
61
|
+
// Terminal: ownership mismatch — no retry can succeed.
|
|
62
|
+
error.isTerminal = true;
|
|
63
|
+
throw error;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
const modules = [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const {
|
|
2
2
|
mapIntegrationClassToIntegrationDTO,
|
|
3
3
|
} = require('../utils/map-integration-dto');
|
|
4
|
+
const { dispatchIntegrationEvent } = require('./dispatch-integration-event');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Use case for updating a single integration by ID and user.
|
|
@@ -81,9 +82,26 @@ class UpdateIntegration {
|
|
|
81
82
|
modules,
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
// 5. Complete async initialization and
|
|
85
|
+
// 5. Complete async initialization and dispatch the update event.
|
|
86
|
+
// When ON_UPDATE is marked dispatch:'queue', the update is enqueued
|
|
87
|
+
// (serialized per integration) and an ack is returned; otherwise it
|
|
88
|
+
// runs in-process and the updated DTO is returned (unchanged behavior).
|
|
85
89
|
await integrationInstance.initialize();
|
|
86
|
-
await
|
|
90
|
+
const outcome = await dispatchIntegrationEvent({
|
|
91
|
+
instance: integrationInstance,
|
|
92
|
+
event: 'ON_UPDATE',
|
|
93
|
+
data: { config },
|
|
94
|
+
userId,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (outcome.queued) {
|
|
98
|
+
return {
|
|
99
|
+
queued: true,
|
|
100
|
+
integrationId: integrationInstance.id,
|
|
101
|
+
messageId: outcome.messageId,
|
|
102
|
+
requestId: outcome.requestId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
87
105
|
|
|
88
106
|
return mapIntegrationClassToIntegrationDTO(integrationInstance);
|
|
89
107
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.608.ba60ba6.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
43
|
-
"@friggframework/test": "2.0.0--canary.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0--canary.608.ba60ba6.0",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0--canary.608.ba60ba6.0",
|
|
43
|
+
"@friggframework/test": "2.0.0--canary.608.ba60ba6.0",
|
|
44
44
|
"@prisma/client": "^6.19.3",
|
|
45
45
|
"@types/lodash": "4.17.15",
|
|
46
46
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +80,5 @@
|
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public"
|
|
82
82
|
},
|
|
83
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "ba60ba64c680647ce1fdb2e5453b29acc38d6f49"
|
|
84
84
|
}
|
package/queues/queuer-util.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const { v4: uuid } = require('uuid');
|
|
2
|
-
const {
|
|
2
|
+
const {
|
|
3
|
+
SQSClient,
|
|
4
|
+
SendMessageCommand,
|
|
5
|
+
SendMessageBatchCommand,
|
|
6
|
+
} = require('@aws-sdk/client-sqs');
|
|
3
7
|
|
|
4
8
|
const awsConfigOptions = () => {
|
|
5
9
|
const config = {};
|
|
@@ -90,10 +94,23 @@ const inspectBatchResult = (result, queueUrl, buffer) => {
|
|
|
90
94
|
};
|
|
91
95
|
|
|
92
96
|
const QueuerUtil = {
|
|
93
|
-
|
|
97
|
+
// `messageGroupId`/`messageDeduplicationId` are only used for FIFO queues.
|
|
98
|
+
// Standard sends omit them, so the command is byte-identical to before.
|
|
99
|
+
// FIFO queues require a deduplication id whenever ContentBasedDeduplication
|
|
100
|
+
// is off — we default to a uuid so every send is treated as distinct (two
|
|
101
|
+
// distinct requests with identical bodies must both run, not be dropped).
|
|
102
|
+
send: async (
|
|
103
|
+
message,
|
|
104
|
+
queueUrl,
|
|
105
|
+
{ messageGroupId, messageDeduplicationId } = {}
|
|
106
|
+
) => {
|
|
94
107
|
const command = new SendMessageCommand({
|
|
95
108
|
MessageBody: JSON.stringify(message),
|
|
96
109
|
QueueUrl: queueUrl,
|
|
110
|
+
...(messageGroupId !== undefined && {
|
|
111
|
+
MessageGroupId: messageGroupId,
|
|
112
|
+
MessageDeduplicationId: messageDeduplicationId || uuid(),
|
|
113
|
+
}),
|
|
97
114
|
});
|
|
98
115
|
const result = await sqs.send(command);
|
|
99
116
|
console.log(
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const { createReportingRouter } = require('./reporting-router');
|
|
2
|
+
const {
|
|
3
|
+
createReportingRepository,
|
|
4
|
+
ReportingRepositoryMongo,
|
|
5
|
+
ReportingRepositoryPostgres,
|
|
6
|
+
ReportingRepositoryDocumentDB,
|
|
7
|
+
} = require('./repositories/reporting-repository-factory');
|
|
8
|
+
const { ListIntegrationsReport } = require('./use-cases');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
createReportingRouter,
|
|
12
|
+
createReportingRepository,
|
|
13
|
+
ReportingRepositoryMongo,
|
|
14
|
+
ReportingRepositoryPostgres,
|
|
15
|
+
ReportingRepositoryDocumentDB,
|
|
16
|
+
ListIntegrationsReport,
|
|
17
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const catchAsyncError = require('express-async-handler');
|
|
3
|
+
const {
|
|
4
|
+
createReportingRepository,
|
|
5
|
+
} = require('./repositories/reporting-repository-factory');
|
|
6
|
+
const { ListIntegrationsReport } = require('./use-cases/list-integrations-report');
|
|
7
|
+
|
|
8
|
+
function createReportingRouter() {
|
|
9
|
+
const reportingRepository = createReportingRepository();
|
|
10
|
+
const listIntegrationsReport = new ListIntegrationsReport({
|
|
11
|
+
reportingRepository,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const router = express.Router();
|
|
15
|
+
router.use(validateApiKey);
|
|
16
|
+
|
|
17
|
+
router.get('/api/v2/reports', (_req, res) => {
|
|
18
|
+
res.json({ service: 'frigg-core-api', reports: ['integrations'] });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
router.get(
|
|
22
|
+
'/api/v2/reports/integrations',
|
|
23
|
+
catchAsyncError(async (req, res) => {
|
|
24
|
+
const { status, type, userId } = req.query;
|
|
25
|
+
res.json(
|
|
26
|
+
await listIntegrationsReport.execute({ status, type, userId })
|
|
27
|
+
);
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return router;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateApiKey(req, res, next) {
|
|
35
|
+
const apiKey = req.headers['x-frigg-reporting-api-key'];
|
|
36
|
+
|
|
37
|
+
if (!apiKey || apiKey !== process.env.REPORTING_API_KEY) {
|
|
38
|
+
console.error('Unauthorized access attempt to reporting endpoint');
|
|
39
|
+
return res.status(401).json({
|
|
40
|
+
status: 'error',
|
|
41
|
+
message: 'Unauthorized - x-frigg-reporting-api-key header required',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { createReportingRouter, validateApiKey };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const { toObjectId, fromObjectId } = require('../../database/documentdb-utils');
|
|
3
|
+
const {
|
|
4
|
+
ReportingRepositoryInterface,
|
|
5
|
+
} = require('./reporting-repository-interface');
|
|
6
|
+
|
|
7
|
+
const DRAIN_BATCH_SIZE = 1000;
|
|
8
|
+
const MAX_BATCHES = 100000;
|
|
9
|
+
|
|
10
|
+
// Drains cursors via getMore rather than reusing documentdb-utils.findMany/
|
|
11
|
+
// aggregate, which return only the first batch (~101 docs) and would silently
|
|
12
|
+
// truncate a deployment-wide report.
|
|
13
|
+
class ReportingRepositoryDocumentDB extends ReportingRepositoryInterface {
|
|
14
|
+
constructor() {
|
|
15
|
+
super();
|
|
16
|
+
this.prisma = prisma;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findIntegrationsForReport({ status, userId } = {}) {
|
|
20
|
+
const filter = {};
|
|
21
|
+
if (status) filter.status = status;
|
|
22
|
+
if (userId !== undefined && userId !== null) {
|
|
23
|
+
const objectId = toObjectId(userId);
|
|
24
|
+
// An invalid userId means no matches — must not fall through to an
|
|
25
|
+
// unfiltered query that returns the whole deployment.
|
|
26
|
+
if (!objectId) return [];
|
|
27
|
+
filter.userId = objectId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const docs = await this._findDrained('Integration', filter);
|
|
31
|
+
|
|
32
|
+
return docs.map((doc) => {
|
|
33
|
+
const errors = this._extractErrors(doc);
|
|
34
|
+
return {
|
|
35
|
+
id: fromObjectId(doc?._id),
|
|
36
|
+
type: doc?.config?.type ?? null,
|
|
37
|
+
status: doc?.status ?? null,
|
|
38
|
+
userId: fromObjectId(doc?.userId) ?? null,
|
|
39
|
+
version: doc?.version ?? null,
|
|
40
|
+
errorCount: Array.isArray(errors) ? errors.length : 0,
|
|
41
|
+
moduleCount: Array.isArray(doc?.entityIds)
|
|
42
|
+
? doc.entityIds.length
|
|
43
|
+
: 0,
|
|
44
|
+
createdAt: doc?.createdAt ?? null,
|
|
45
|
+
updatedAt: doc?.updatedAt ?? null,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async countMappingsByIntegrationIds(ids = []) {
|
|
51
|
+
const counts = new Map();
|
|
52
|
+
if (!ids || ids.length === 0) return counts;
|
|
53
|
+
|
|
54
|
+
// IntegrationMapping.integrationId is stored as a string in DocumentDB,
|
|
55
|
+
// so match by string — an ObjectId $in would never match (always 0).
|
|
56
|
+
const stringIds = ids.map(String);
|
|
57
|
+
|
|
58
|
+
const rows = await this._aggregateDrained('IntegrationMapping', [
|
|
59
|
+
{ $match: { integrationId: { $in: stringIds } } },
|
|
60
|
+
{ $group: { _id: '$integrationId', count: { $sum: 1 } } },
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
counts.set(String(row?._id), row?.count ?? 0);
|
|
65
|
+
}
|
|
66
|
+
return counts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _findDrained(collection, filter) {
|
|
70
|
+
const first = await this.prisma.$runCommandRaw({
|
|
71
|
+
find: collection,
|
|
72
|
+
filter,
|
|
73
|
+
batchSize: DRAIN_BATCH_SIZE,
|
|
74
|
+
});
|
|
75
|
+
return this._drain(collection, first);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async _aggregateDrained(collection, pipeline) {
|
|
79
|
+
const first = await this.prisma.$runCommandRaw({
|
|
80
|
+
aggregate: collection,
|
|
81
|
+
pipeline,
|
|
82
|
+
cursor: { batchSize: DRAIN_BATCH_SIZE },
|
|
83
|
+
});
|
|
84
|
+
return this._drain(collection, first);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _drain(collection, firstResult) {
|
|
88
|
+
const cursor = firstResult?.cursor || {};
|
|
89
|
+
const docs = [...(cursor.firstBatch || [])];
|
|
90
|
+
let cursorId = cursor.id;
|
|
91
|
+
let batches = 0;
|
|
92
|
+
|
|
93
|
+
while (this._cursorOpen(cursorId) && batches < MAX_BATCHES) {
|
|
94
|
+
batches += 1;
|
|
95
|
+
const next = await this.prisma.$runCommandRaw({
|
|
96
|
+
getMore: cursorId,
|
|
97
|
+
collection,
|
|
98
|
+
batchSize: DRAIN_BATCH_SIZE,
|
|
99
|
+
});
|
|
100
|
+
const nextCursor = next?.cursor || {};
|
|
101
|
+
const nextBatch = nextCursor.nextBatch || [];
|
|
102
|
+
docs.push(...nextBatch);
|
|
103
|
+
cursorId = nextCursor.id;
|
|
104
|
+
if (nextBatch.length === 0) break;
|
|
105
|
+
}
|
|
106
|
+
return docs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_cursorOpen(id) {
|
|
110
|
+
if (id === undefined || id === null) return false;
|
|
111
|
+
if (typeof id === 'number') return id !== 0;
|
|
112
|
+
if (typeof id === 'bigint') return id !== 0n;
|
|
113
|
+
// Extended JSON can surface a 64-bit cursor id as { $numberLong: "..." }.
|
|
114
|
+
if (typeof id === 'object' && id.$numberLong !== undefined) {
|
|
115
|
+
return id.$numberLong !== '0';
|
|
116
|
+
}
|
|
117
|
+
return String(id) !== '0';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_extractErrors(doc) {
|
|
121
|
+
if (Array.isArray(doc?.errors)) return doc.errors;
|
|
122
|
+
if (Array.isArray(doc?.messages?.errors)) return doc.messages.errors;
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { ReportingRepositoryDocumentDB };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { ReportingRepositoryMongo } = require('./reporting-repository-mongo');
|
|
2
|
+
const {
|
|
3
|
+
ReportingRepositoryPostgres,
|
|
4
|
+
} = require('./reporting-repository-postgres');
|
|
5
|
+
const {
|
|
6
|
+
ReportingRepositoryDocumentDB,
|
|
7
|
+
} = require('./reporting-repository-documentdb');
|
|
8
|
+
const config = require('../../database/config');
|
|
9
|
+
|
|
10
|
+
function createReportingRepository() {
|
|
11
|
+
const dbType = config.DB_TYPE;
|
|
12
|
+
|
|
13
|
+
switch (dbType) {
|
|
14
|
+
case 'mongodb':
|
|
15
|
+
return new ReportingRepositoryMongo();
|
|
16
|
+
|
|
17
|
+
case 'postgresql':
|
|
18
|
+
return new ReportingRepositoryPostgres();
|
|
19
|
+
|
|
20
|
+
case 'documentdb':
|
|
21
|
+
return new ReportingRepositoryDocumentDB();
|
|
22
|
+
|
|
23
|
+
default:
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
createReportingRepository,
|
|
32
|
+
ReportingRepositoryMongo,
|
|
33
|
+
ReportingRepositoryPostgres,
|
|
34
|
+
ReportingRepositoryDocumentDB,
|
|
35
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class ReportingRepositoryInterface {
|
|
2
|
+
// returns: [{ id, type, status, userId, version, errorCount, moduleCount, createdAt, updatedAt }]
|
|
3
|
+
async findIntegrationsForReport(filter) {
|
|
4
|
+
throw new Error(
|
|
5
|
+
'Method findIntegrationsForReport must be implemented by subclass'
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async countMappingsByIntegrationIds(ids) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'Method countMappingsByIntegrationIds must be implemented by subclass'
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { ReportingRepositoryInterface };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
ReportingRepositoryInterface,
|
|
4
|
+
} = require('./reporting-repository-interface');
|
|
5
|
+
|
|
6
|
+
class ReportingRepositoryMongo extends ReportingRepositoryInterface {
|
|
7
|
+
constructor() {
|
|
8
|
+
super();
|
|
9
|
+
this.prisma = prisma;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async findIntegrationsForReport({ status, userId } = {}) {
|
|
13
|
+
const where = {};
|
|
14
|
+
if (status) where.status = status;
|
|
15
|
+
if (userId !== undefined && userId !== null) where.userId = userId;
|
|
16
|
+
|
|
17
|
+
const integrations = await this.prisma.integration.findMany({
|
|
18
|
+
where,
|
|
19
|
+
include: { entities: { select: { id: true } } },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return integrations.map((integration) => ({
|
|
23
|
+
id: integration.id,
|
|
24
|
+
type: integration.config?.type ?? null,
|
|
25
|
+
status: integration.status ?? null,
|
|
26
|
+
userId: integration.userId ?? null,
|
|
27
|
+
version: integration.version ?? null,
|
|
28
|
+
errorCount: Array.isArray(integration.errors)
|
|
29
|
+
? integration.errors.length
|
|
30
|
+
: 0,
|
|
31
|
+
moduleCount: integration.entities?.length ?? 0,
|
|
32
|
+
createdAt: integration.createdAt ?? null,
|
|
33
|
+
updatedAt: integration.updatedAt ?? null,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async countMappingsByIntegrationIds(ids = []) {
|
|
38
|
+
const counts = new Map();
|
|
39
|
+
if (!ids || ids.length === 0) return counts;
|
|
40
|
+
|
|
41
|
+
const groups = await this.prisma.integrationMapping.groupBy({
|
|
42
|
+
by: ['integrationId'],
|
|
43
|
+
where: { integrationId: { in: ids } },
|
|
44
|
+
_count: { _all: true },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const group of groups) {
|
|
48
|
+
counts.set(group.integrationId, group._count._all);
|
|
49
|
+
}
|
|
50
|
+
return counts;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { ReportingRepositoryMongo };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
ReportingRepositoryInterface,
|
|
4
|
+
} = require('./reporting-repository-interface');
|
|
5
|
+
|
|
6
|
+
class ReportingRepositoryPostgres extends ReportingRepositoryInterface {
|
|
7
|
+
constructor() {
|
|
8
|
+
super();
|
|
9
|
+
this.prisma = prisma;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async findIntegrationsForReport({ status, userId } = {}) {
|
|
13
|
+
const where = {};
|
|
14
|
+
if (status) where.status = status;
|
|
15
|
+
if (userId !== undefined && userId !== null) {
|
|
16
|
+
where.userId = this._convertId(userId);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const integrations = await this.prisma.integration.findMany({
|
|
20
|
+
where,
|
|
21
|
+
include: { entities: { select: { id: true } } },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return integrations.map((integration) => ({
|
|
25
|
+
id: integration.id?.toString(),
|
|
26
|
+
type: integration.config?.type ?? null,
|
|
27
|
+
status: integration.status ?? null,
|
|
28
|
+
userId: integration.userId?.toString() ?? null,
|
|
29
|
+
version: integration.version ?? null,
|
|
30
|
+
errorCount: Array.isArray(integration.errors)
|
|
31
|
+
? integration.errors.length
|
|
32
|
+
: 0,
|
|
33
|
+
moduleCount: integration.entities?.length ?? 0,
|
|
34
|
+
createdAt: integration.createdAt ?? null,
|
|
35
|
+
updatedAt: integration.updatedAt ?? null,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async countMappingsByIntegrationIds(ids = []) {
|
|
40
|
+
const counts = new Map();
|
|
41
|
+
if (!ids || ids.length === 0) return counts;
|
|
42
|
+
|
|
43
|
+
const intIds = ids.map((id) => this._convertId(id));
|
|
44
|
+
const groups = await this.prisma.integrationMapping.groupBy({
|
|
45
|
+
by: ['integrationId'],
|
|
46
|
+
where: { integrationId: { in: intIds } },
|
|
47
|
+
_count: { _all: true },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
counts.set(group.integrationId?.toString(), group._count._all);
|
|
52
|
+
}
|
|
53
|
+
return counts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_convertId(id) {
|
|
57
|
+
if (id === null || id === undefined) return id;
|
|
58
|
+
// Reject anything that isn't an exact integer — parseInt would coerce
|
|
59
|
+
// '12abc'/'12.9' to 12 and return the wrong record.
|
|
60
|
+
const str = String(id).trim();
|
|
61
|
+
if (!/^-?\d+$/.test(str)) {
|
|
62
|
+
throw new TypeError(
|
|
63
|
+
`Invalid ID: ${id} cannot be converted to integer`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return Number.parseInt(str, 10);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { ReportingRepositoryPostgres };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
|
|
3
|
+
const SCHEMA_VERSION = 1;
|
|
4
|
+
const SERVICE = 'frigg-core-api';
|
|
5
|
+
|
|
6
|
+
// Seeded so every known status appears (even at 0); unknown values added to
|
|
7
|
+
// the schema later are still counted dynamically.
|
|
8
|
+
const KNOWN_STATUSES = [
|
|
9
|
+
'ENABLED',
|
|
10
|
+
'ERROR',
|
|
11
|
+
'NEEDS_CONFIG',
|
|
12
|
+
'PROCESSING',
|
|
13
|
+
'DISABLED',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
class ListIntegrationsReport {
|
|
17
|
+
constructor({ reportingRepository } = {}) {
|
|
18
|
+
if (!reportingRepository) {
|
|
19
|
+
throw new Error('reportingRepository is required');
|
|
20
|
+
}
|
|
21
|
+
this.reportingRepository = reportingRepository;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async execute(query = {}) {
|
|
25
|
+
const { status, type, userId } = this._validateQuery(query);
|
|
26
|
+
|
|
27
|
+
const rows = await this.reportingRepository.findIntegrationsForReport({
|
|
28
|
+
status,
|
|
29
|
+
userId,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// type lives in config.type (a JSON path not portably groupable across
|
|
33
|
+
// DBs), so it is filtered here rather than in the repository query.
|
|
34
|
+
const filtered =
|
|
35
|
+
type === undefined
|
|
36
|
+
? rows
|
|
37
|
+
: rows.filter((row) => (row.type ?? 'unknown') === type);
|
|
38
|
+
|
|
39
|
+
const ids = filtered.map((row) => row.id);
|
|
40
|
+
const mappingCounts = ids.length
|
|
41
|
+
? await this.reportingRepository.countMappingsByIntegrationIds(ids)
|
|
42
|
+
: new Map();
|
|
43
|
+
|
|
44
|
+
const integrations = filtered.map((row) => ({
|
|
45
|
+
id: row.id,
|
|
46
|
+
type: row.type ?? 'unknown',
|
|
47
|
+
status: row.status ?? null,
|
|
48
|
+
userId: row.userId ?? null,
|
|
49
|
+
version: row.version ?? null,
|
|
50
|
+
moduleCount: row.moduleCount ?? 0,
|
|
51
|
+
errorCount: row.errorCount ?? 0,
|
|
52
|
+
mappedRecordCount: mappingCounts.get(row.id) ?? 0,
|
|
53
|
+
createdAt: toIso(row.createdAt),
|
|
54
|
+
updatedAt: toIso(row.updatedAt),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const byStatus = emptyStatusCounts();
|
|
58
|
+
const byTypeMap = new Map();
|
|
59
|
+
for (const integration of integrations) {
|
|
60
|
+
// sentinel so a missing status never becomes a literal "null" key
|
|
61
|
+
const statusKey = integration.status ?? 'UNKNOWN';
|
|
62
|
+
byStatus[statusKey] = (byStatus[statusKey] ?? 0) + 1;
|
|
63
|
+
|
|
64
|
+
if (!byTypeMap.has(integration.type)) {
|
|
65
|
+
byTypeMap.set(integration.type, {
|
|
66
|
+
type: integration.type,
|
|
67
|
+
total: 0,
|
|
68
|
+
byStatus: emptyStatusCounts(),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const bucket = byTypeMap.get(integration.type);
|
|
72
|
+
bucket.total += 1;
|
|
73
|
+
bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
schemaVersion: SCHEMA_VERSION,
|
|
78
|
+
service: SERVICE,
|
|
79
|
+
generatedAt: new Date().toISOString(),
|
|
80
|
+
filters: {
|
|
81
|
+
status: status ?? null,
|
|
82
|
+
type: type ?? null,
|
|
83
|
+
userId: userId ?? null,
|
|
84
|
+
},
|
|
85
|
+
metrics: {
|
|
86
|
+
total: integrations.length,
|
|
87
|
+
byStatus,
|
|
88
|
+
byType: Array.from(byTypeMap.values()),
|
|
89
|
+
integrations,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_validateQuery({ status, type, userId } = {}) {
|
|
95
|
+
for (const [key, value] of Object.entries({ status, type, userId })) {
|
|
96
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
97
|
+
throw Boom.badRequest(
|
|
98
|
+
`Invalid query parameter '${key}': expected a string`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const normalize = (value) => value || undefined;
|
|
103
|
+
const normalized = {
|
|
104
|
+
status: normalize(status),
|
|
105
|
+
type: normalize(type),
|
|
106
|
+
userId: normalize(userId),
|
|
107
|
+
};
|
|
108
|
+
if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
|
|
109
|
+
throw Boom.badRequest(
|
|
110
|
+
`Invalid status '${normalized.status}'. Expected one of: ${KNOWN_STATUSES.join(
|
|
111
|
+
', '
|
|
112
|
+
)}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return normalized;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function emptyStatusCounts() {
|
|
120
|
+
return KNOWN_STATUSES.reduce((acc, status) => {
|
|
121
|
+
acc[status] = 0;
|
|
122
|
+
return acc;
|
|
123
|
+
}, {});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toIso(value) {
|
|
127
|
+
if (!value) return null;
|
|
128
|
+
if (value instanceof Date) return value.toISOString();
|
|
129
|
+
// DocumentDB raw reads surface dates as { $date: ... }
|
|
130
|
+
if (typeof value === 'object' && value.$date) {
|
|
131
|
+
const date = new Date(value.$date);
|
|
132
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
133
|
+
}
|
|
134
|
+
return String(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { ListIntegrationsReport, SCHEMA_VERSION };
|