@friggframework/core 2.0.0--canary.606.7fde0eb.0 → 2.0.0--canary.608.03436383054a.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.
@@ -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
@@ -71,6 +71,10 @@ const {
71
71
  getModulesDefinitionFromIntegrationClasses,
72
72
  LoadIntegrationContextUseCase,
73
73
  } = require('./integrations/index');
74
+ const {
75
+ createReportingRouter,
76
+ createReportingRepository,
77
+ } = require('./reporting/index');
74
78
  const { TimeoutCatcher } = require('./lambda/index');
75
79
  const { debug, initDebugLog, flushDebugLog } = require('./logs/index');
76
80
  const {
@@ -87,6 +91,12 @@ const application = require('./application');
87
91
  const utils = require('./utils');
88
92
 
89
93
  const { QueuerUtil } = require('./queues');
94
+ const {
95
+ dispatchIntegrationEvent,
96
+ } = require('./integrations/use-cases/dispatch-integration-event');
97
+ const {
98
+ userActionQueueWorker,
99
+ } = require('./handlers/workers/user-action-worker');
90
100
 
91
101
  module.exports = {
92
102
  // assertions
@@ -133,12 +143,17 @@ module.exports = {
133
143
  checkRequiredParams,
134
144
  createIntegrationRouter,
135
145
  getModulesDefinitionFromIntegrationClasses,
146
+ dispatchIntegrationEvent,
136
147
  LoadIntegrationContextUseCase,
137
148
  CreateProcess,
138
149
  UpdateProcessState,
139
150
  UpdateProcessMetrics,
140
151
  GetProcess,
141
152
 
153
+ // reporting
154
+ createReportingRouter,
155
+ createReportingRepository,
156
+
142
157
  // application - Command factories for integration developers
143
158
  application,
144
159
  createFriggCommands: application.createFriggCommands,
@@ -172,6 +187,9 @@ module.exports = {
172
187
  // queues
173
188
  QueuerUtil,
174
189
 
190
+ // workers
191
+ userActionQueueWorker,
192
+
175
193
  // utils
176
194
  ...utils,
177
195
  };
@@ -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 {
@@ -521,6 +528,21 @@ class IntegrationBase {
521
528
  ...this.defaultEvents,
522
529
  ...this.events,
523
530
  };
531
+
532
+ // Apply Definition.eventDispatch onto the resolved handler entries. This
533
+ // lets a default lifecycle event (e.g. ON_UPDATE) be marked 'queue'
534
+ // without re-declaring its handler. An inline `dispatch` on the event
535
+ // entry always wins; unknown event names are ignored (a typo stays sync
536
+ // rather than crashing initialize()).
537
+ const eventDispatch = this.constructor.Definition?.eventDispatch || {};
538
+ for (const [eventName, mode] of Object.entries(eventDispatch)) {
539
+ if (this.on[eventName] && this.on[eventName].dispatch === undefined) {
540
+ this.on[eventName] = {
541
+ ...this.on[eventName],
542
+ dispatch: mode,
543
+ };
544
+ }
545
+ }
524
546
  }
525
547
 
526
548
  /**
@@ -608,6 +630,9 @@ class IntegrationBase {
608
630
  this.events[eventName] = {
609
631
  type: eventDef.type,
610
632
  handler: fn.bind(this),
633
+ ...(eventDef.dispatch !== undefined && {
634
+ dispatch: eventDef.dispatch,
635
+ }),
611
636
  };
612
637
  mergedByExtension.set(eventName, bindingName);
613
638
  }
@@ -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
- res.json(await integration.send(params.actionId, req.body));
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
 
@@ -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 integrationInstance.send('ON_CREATE', {
76
- integrationId: integrationRecord.id,
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
- throw new Error(
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
- throw new Error(
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
- throw new Error(
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 trigger update event
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 integrationInstance.send('ON_UPDATE', { config });
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.606.7fde0eb.0",
4
+ "version": "2.0.0--canary.608.03436383054a.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.606.7fde0eb.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.606.7fde0eb.0",
43
- "@friggframework/test": "2.0.0--canary.606.7fde0eb.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.608.03436383054a.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.608.03436383054a.0",
43
+ "@friggframework/test": "2.0.0--canary.608.03436383054a.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": "7fde0eb1c5ed7a7be35cb02b07dcba85ffef5b1c"
83
+ "gitHead": "03436383054a3ee70bd4de8f13a1907890a627da"
84
84
  }
@@ -90,10 +90,19 @@ const inspectBatchResult = (result, queueUrl, buffer) => {
90
90
  };
91
91
 
92
92
  const QueuerUtil = {
93
- send: async (message, queueUrl) => {
93
+ // `messageGroupId`/`messageDeduplicationId` are only used for FIFO queues.
94
+ // Standard sends omit them, so the command is byte-identical to before.
95
+ // FIFO queues require a deduplication id whenever ContentBasedDeduplication
96
+ // is off — we default to a uuid so every send is treated as distinct (two
97
+ // distinct requests with identical bodies must both run, not be dropped).
98
+ send: async (message, queueUrl, { messageGroupId, messageDeduplicationId } = {}) => {
94
99
  const command = new SendMessageCommand({
95
100
  MessageBody: JSON.stringify(message),
96
101
  QueueUrl: queueUrl,
102
+ ...(messageGroupId !== undefined && {
103
+ MessageGroupId: messageGroupId,
104
+ MessageDeduplicationId: messageDeduplicationId || uuid(),
105
+ }),
97
106
  });
98
107
  const result = await sqs.send(command);
99
108
  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,6 @@
1
+ const {
2
+ ListIntegrationsReport,
3
+ SCHEMA_VERSION,
4
+ } = require('./list-integrations-report');
5
+
6
+ module.exports = { ListIntegrationsReport, SCHEMA_VERSION };
@@ -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 };