@friggframework/core 2.0.0-next.104 → 2.0.0-next.106

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.
Files changed (45) hide show
  1. package/admin-scripts/repositories/admin-script-execution-repository-interface.js +7 -2
  2. package/admin-scripts/repositories/admin-script-execution-repository-mongo.js +16 -6
  3. package/admin-scripts/repositories/admin-script-execution-repository-postgres.js +16 -6
  4. package/application/commands/admin-script-commands.js +2 -1
  5. package/application/commands/credential-commands.js +17 -0
  6. package/application/commands/integration-commands.js +14 -1
  7. package/application/commands/integration-mapping-commands.js +25 -0
  8. package/application/commands/report-commands.js +188 -0
  9. package/artifacts/repositories/artifact-repository-factory.js +19 -0
  10. package/artifacts/repositories/artifact-repository-interface.js +27 -0
  11. package/artifacts/repositories/artifact-repository-local.js +42 -0
  12. package/artifacts/repositories/artifact-repository-s3.js +61 -0
  13. package/credential/repositories/credential-active-type.js +32 -0
  14. package/credential/repositories/credential-repository-documentdb.js +51 -0
  15. package/credential/repositories/credential-repository-interface.js +15 -0
  16. package/credential/repositories/credential-repository-mongo.js +25 -0
  17. package/credential/repositories/credential-repository-postgres.js +25 -0
  18. package/database/documentdb-utils.js +56 -0
  19. package/handlers/app-definition-loader.js +3 -2
  20. package/index.js +8 -4
  21. package/integrations/integration-base.js +43 -5
  22. package/integrations/repositories/integration-mapping-repository-documentdb.js +23 -0
  23. package/integrations/repositories/integration-mapping-repository-interface.js +14 -0
  24. package/integrations/repositories/integration-mapping-repository-mongo.js +22 -0
  25. package/integrations/repositories/integration-mapping-repository-postgres.js +28 -0
  26. package/integrations/repositories/integration-repository-documentdb.js +38 -0
  27. package/integrations/repositories/integration-repository-interface.js +16 -0
  28. package/integrations/repositories/integration-repository-mongo.js +36 -0
  29. package/integrations/repositories/integration-repository-postgres.js +40 -0
  30. package/integrations/repositories/report-id.js +13 -0
  31. package/modules/requester/oauth-2.js +19 -4
  32. package/package.json +7 -5
  33. package/reporting/README.md +109 -48
  34. package/reporting/builtin-reports.js +6 -0
  35. package/reporting/index.js +9 -13
  36. package/reporting/report-base.js +49 -0
  37. package/reporting/{use-cases/list-integrations-report.js → reports/integrations-report.js} +69 -37
  38. package/handlers/routers/reporting.js +0 -9
  39. package/reporting/reporting-router.js +0 -84
  40. package/reporting/repositories/reporting-repository-documentdb.js +0 -127
  41. package/reporting/repositories/reporting-repository-factory.js +0 -35
  42. package/reporting/repositories/reporting-repository-interface.js +0 -16
  43. package/reporting/repositories/reporting-repository-mongo.js +0 -54
  44. package/reporting/repositories/reporting-repository-postgres.js +0 -70
  45. package/reporting/use-cases/index.js +0 -6
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  CredentialRepositoryInterface,
4
4
  } = require('./credential-repository-interface');
5
+ const { tallyActiveCredentialsByType } = require('./credential-active-type');
5
6
 
6
7
  /**
7
8
  * MongoDB Credential Repository Adapter
@@ -231,6 +232,30 @@ class CredentialRepositoryMongo extends CredentialRepositoryInterface {
231
232
  };
232
233
  }
233
234
 
235
+ /**
236
+ * Count credentials active since a timestamp, grouped by integration type.
237
+ *
238
+ * @param {Object} params
239
+ * @param {Date} [params.since] - Lower bound on updatedAt
240
+ * @returns {Promise<Array<{ integrationType: string, count: number }>>}
241
+ */
242
+ async countActiveByType({ since } = {}) {
243
+ const where = {};
244
+ if (since) where.updatedAt = { gte: since };
245
+
246
+ // Type lives on the related Entity.moduleName, not on Credential. Select
247
+ // only that field (+ id) so the encrypted `data` JSON is never decrypted.
248
+ const credentials = await this.prisma.credential.findMany({
249
+ where,
250
+ select: {
251
+ id: true,
252
+ entities: { select: { moduleName: true } },
253
+ },
254
+ });
255
+
256
+ return tallyActiveCredentialsByType(credentials);
257
+ }
258
+
234
259
  /**
235
260
  * Convert identifiers to Prisma where clause
236
261
  * @private
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  CredentialRepositoryInterface,
4
4
  } = require('./credential-repository-interface');
5
+ const { tallyActiveCredentialsByType } = require('./credential-active-type');
5
6
 
6
7
  /**
7
8
  * PostgreSQL Credential Repository Adapter
@@ -248,6 +249,30 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
248
249
  };
249
250
  }
250
251
 
252
+ /**
253
+ * Count credentials active since a timestamp, grouped by integration type.
254
+ *
255
+ * @param {Object} params
256
+ * @param {Date} [params.since] - Lower bound on updatedAt
257
+ * @returns {Promise<Array<{ integrationType: string, count: number }>>}
258
+ */
259
+ async countActiveByType({ since } = {}) {
260
+ const where = {};
261
+ if (since) where.updatedAt = { gte: since };
262
+
263
+ // Select only the entity moduleName (+ id) so the encrypted `data` JSON
264
+ // is never read and the encryption extension has nothing to decrypt.
265
+ const credentials = await this.prisma.credential.findMany({
266
+ where,
267
+ select: {
268
+ id: true,
269
+ entities: { select: { moduleName: true } },
270
+ },
271
+ });
272
+
273
+ return tallyActiveCredentialsByType(credentials);
274
+ }
275
+
251
276
  /**
252
277
  * Convert identifiers to Prisma where clause (converting IDs to Int)
253
278
  * @private
@@ -121,6 +121,60 @@ async function aggregate(client, collection, pipeline) {
121
121
  return result?.cursor?.firstBatch || [];
122
122
  }
123
123
 
124
+ // findMany/aggregate return ONLY the first batch (~101 docs); the drained variants below follow the cursor to completion so full scans don't silently truncate.
125
+ const DRAIN_BATCH_SIZE = 1000;
126
+ const MAX_DRAIN_BATCHES = 100000;
127
+
128
+ function isCursorOpen(id) {
129
+ if (id === undefined || id === null) return false;
130
+ if (typeof id === 'number') return id !== 0;
131
+ if (typeof id === 'bigint') return id !== 0n;
132
+ // Extended JSON can surface a 64-bit cursor id as { $numberLong: "..." }.
133
+ if (typeof id === 'object' && id.$numberLong !== undefined) {
134
+ return id.$numberLong !== '0';
135
+ }
136
+ return String(id) !== '0';
137
+ }
138
+
139
+ async function drainCursor(client, collection, firstResult) {
140
+ const cursor = firstResult?.cursor || {};
141
+ const docs = [...(cursor.firstBatch || [])];
142
+ let cursorId = cursor.id;
143
+ let batches = 0;
144
+
145
+ while (isCursorOpen(cursorId) && batches < MAX_DRAIN_BATCHES) {
146
+ batches += 1;
147
+ const next = await client.$runCommandRaw({
148
+ getMore: cursorId,
149
+ collection,
150
+ batchSize: DRAIN_BATCH_SIZE,
151
+ });
152
+ const nextCursor = next?.cursor || {};
153
+ const nextBatch = nextCursor.nextBatch || [];
154
+ docs.push(...nextBatch);
155
+ cursorId = nextCursor.id;
156
+ if (nextBatch.length === 0) break;
157
+ }
158
+ return docs;
159
+ }
160
+
161
+ async function findManyDrained(client, collection, filter = {}, options = {}) {
162
+ const command = { find: collection, filter, batchSize: DRAIN_BATCH_SIZE };
163
+ if (options.projection) command.projection = options.projection;
164
+ if (options.sort) command.sort = options.sort;
165
+ const first = await client.$runCommandRaw(command);
166
+ return drainCursor(client, collection, first);
167
+ }
168
+
169
+ async function aggregateDrained(client, collection, pipeline) {
170
+ const first = await client.$runCommandRaw({
171
+ aggregate: collection,
172
+ pipeline,
173
+ cursor: { batchSize: DRAIN_BATCH_SIZE },
174
+ });
175
+ return drainCursor(client, collection, first);
176
+ }
177
+
124
178
  module.exports = {
125
179
  toObjectId,
126
180
  toObjectIdArray,
@@ -132,5 +186,7 @@ module.exports = {
132
186
  deleteOne,
133
187
  deleteMany,
134
188
  aggregate,
189
+ findManyDrained,
190
+ aggregateDrained,
135
191
  };
136
192
 
@@ -8,7 +8,7 @@ const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
8
8
  * @function loadAppDefinition
9
9
  * @description Searches for the nearest backend package.json, loads the corresponding index.js file,
10
10
  * and extracts the application definition containing integrations and user configuration.
11
- * @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, admin: object, telemetry: object}} An object containing the application definition.
11
+ * @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object}} An object containing the application definition.
12
12
  * @throws {Error} Throws error if backend package.json cannot be found.
13
13
  * @throws {Error} Throws error if index.js file cannot be found in the backend directory.
14
14
  * @example
@@ -34,6 +34,7 @@ function loadAppDefinition() {
34
34
  integrations = [],
35
35
  user: userConfig = null,
36
36
  adminScripts = [],
37
+ reports = [],
37
38
  admin = {},
38
39
  } = appDefinition;
39
40
 
@@ -58,7 +59,7 @@ function loadAppDefinition() {
58
59
  };
59
60
  }
60
61
 
61
- return { integrations, userConfig, adminScripts, admin, telemetry };
62
+ return { integrations, userConfig, adminScripts, reports, admin, telemetry };
62
63
  }
63
64
 
64
65
  module.exports = {
package/index.js CHANGED
@@ -66,8 +66,10 @@ const {
66
66
  LoadIntegrationContextUseCase,
67
67
  } = require('./integrations/index');
68
68
  const {
69
- createReportingRouter,
70
- createReportingRepository,
69
+ ReportBase,
70
+ IntegrationsReport,
71
+ BUILTIN_REPORTS,
72
+ createReportCommands,
71
73
  } = require('./reporting/index');
72
74
  const {
73
75
  createTelemetry,
@@ -144,8 +146,10 @@ module.exports = {
144
146
  GetProcess,
145
147
 
146
148
  // reporting
147
- createReportingRouter,
148
- createReportingRepository,
149
+ ReportBase,
150
+ IntegrationsReport,
151
+ BUILTIN_REPORTS,
152
+ createReportCommands,
149
153
 
150
154
  // telemetry
151
155
  createTelemetry,
@@ -383,10 +383,7 @@ class IntegrationBase {
383
383
  this.id,
384
384
  'errors',
385
385
  'Authentication Error',
386
- `There was an error with your ${this[
387
- module
388
- ].getName()} Entity.
389
- Please reconnect/re-authenticate, or reach out to Support for assistance.`,
386
+ this._authErrorMessage(this[module].getName()),
390
387
  Date.now()
391
388
  );
392
389
  }
@@ -395,6 +392,16 @@ class IntegrationBase {
395
392
  return didAuthPass;
396
393
  }
397
394
 
395
+ /**
396
+ * @param {string} [moduleName] - The module whose credentials failed.
397
+ * @param {number} [statusCode] - HTTP status the module rejected us with.
398
+ * @returns {string} A user-facing message.
399
+ */
400
+ _authErrorMessage(moduleName, statusCode) {
401
+ const status = statusCode ? ` (HTTP ${statusCode})` : '';
402
+ return `There was an error with your ${moduleName} Entity${status}. Please reconnect/re-authenticate, or reach out to Support for assistance.`;
403
+ }
404
+
398
405
  /**
399
406
  * Reconcile the auth-health axis (ERROR ↔ ENABLED) from a testAuth result.
400
407
  * On success it never clears DISABLED — a user pause is not an auth-health
@@ -848,6 +855,9 @@ class IntegrationBase {
848
855
  if (!this.id) return;
849
856
 
850
857
  if (delegateString === 'CREDENTIAL_INVALIDATED') {
858
+ if (this.status === 'ERROR') return;
859
+
860
+ const moduleName = notifier?.name;
851
861
  const detail =
852
862
  object?.reason || object?.statusCode
853
863
  ? ` (status ${object?.statusCode ?? '?'}: ${
@@ -856,11 +866,15 @@ class IntegrationBase {
856
866
  : '';
857
867
  console.log(
858
868
  `[Frigg] Module ${
859
- notifier?.name || '?'
869
+ moduleName || '?'
860
870
  } reported invalid credentials for integration ${
861
871
  this.id
862
872
  } — marking ERROR${detail}`
863
873
  );
874
+ await this._recordCredentialRejection(
875
+ moduleName,
876
+ object?.statusCode
877
+ );
864
878
  await this.persistStatus('ERROR');
865
879
  return;
866
880
  }
@@ -877,6 +891,30 @@ class IntegrationBase {
877
891
  await this.persistStatus('ENABLED');
878
892
  }
879
893
  }
894
+
895
+ /**
896
+ * Takes no `reason`: the delegate's is a FetchError message echoing the
897
+ * request, Authorization header included outside prod, and this is shown to
898
+ * end users. Best-effort so it cannot block the caller's status flip.
899
+ * @param {string} [moduleName] - The module that reported the rejection.
900
+ * @param {number} [statusCode] - HTTP status the module rejected us with.
901
+ */
902
+ async _recordCredentialRejection(moduleName, statusCode) {
903
+ try {
904
+ await this.updateIntegrationMessages.execute(
905
+ this.id,
906
+ 'errors',
907
+ 'Authentication Error',
908
+ this._authErrorMessage(moduleName, statusCode),
909
+ Date.now()
910
+ );
911
+ } catch (error) {
912
+ console.error(
913
+ `[Frigg] Failed to record credential rejection for integration ${this.id}:`,
914
+ error
915
+ );
916
+ }
917
+ }
880
918
  }
881
919
 
882
920
  module.exports = { IntegrationBase };
@@ -8,6 +8,7 @@ const {
8
8
  updateOne,
9
9
  deleteOne,
10
10
  deleteMany,
11
+ aggregateDrained,
11
12
  } = require('../../database/documentdb-utils');
12
13
  const {
13
14
  IntegrationMappingRepositoryInterface,
@@ -15,6 +16,7 @@ const {
15
16
  const {
16
17
  DocumentDBEncryptionService,
17
18
  } = require('../../database/documentdb-encryption-service');
19
+
18
20
  class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositoryInterface {
19
21
  constructor() {
20
22
  super();
@@ -22,6 +24,27 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
22
24
  this.encryptionService = new DocumentDBEncryptionService();
23
25
  }
24
26
 
27
+ /**
28
+ * integrationId is stored as a string in DocumentDB, so ids are matched as
29
+ * strings (an ObjectId $in would never match). Drains the grouped cursor so
30
+ * a deployment-wide count is not truncated at the first batch.
31
+ */
32
+ async countByIntegrationIds(ids = []) {
33
+ const counts = new Map();
34
+ if (!ids || ids.length === 0) return counts;
35
+
36
+ const stringIds = ids.map(String);
37
+ const rows = await aggregateDrained(this.prisma, 'IntegrationMapping', [
38
+ { $match: { integrationId: { $in: stringIds } } },
39
+ { $group: { _id: '$integrationId', count: { $sum: 1 } } },
40
+ ]);
41
+
42
+ for (const row of rows) {
43
+ counts.set(String(row?._id), row?.count ?? 0);
44
+ }
45
+ return counts;
46
+ }
47
+
25
48
  async findMappingBy(integrationId, sourceId) {
26
49
  const filter = this._compositeFilter(integrationId, sourceId);
27
50
  const doc = await findOne(this.prisma, 'IntegrationMapping', filter);
@@ -77,6 +77,20 @@ class IntegrationMappingRepositoryInterface {
77
77
  );
78
78
  }
79
79
 
80
+ /**
81
+ * Count mappings grouped by integration id, for a bounded set of ids.
82
+ * Adapters must drain the full grouped result (a deployment can have more
83
+ * than one first-batch of distinct integration ids).
84
+ *
85
+ * @returns {Promise<Map<string, number>>} Map of integrationId (string) → count
86
+ * @abstract
87
+ */
88
+ async countByIntegrationIds(ids) {
89
+ throw new Error(
90
+ 'Method countByIntegrationIds must be implemented by subclass'
91
+ );
92
+ }
93
+
80
94
  /**
81
95
  * Find mapping by ID
82
96
  *
@@ -133,6 +133,28 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
133
133
  };
134
134
  }
135
135
 
136
+ /**
137
+ * Count mappings grouped by integration id for a bounded id set.
138
+ *
139
+ * @param {Array<string>} ids - Integration ids
140
+ * @returns {Promise<Map<string, number>>} integrationId (string) → count
141
+ */
142
+ async countByIntegrationIds(ids = []) {
143
+ const counts = new Map();
144
+ if (!ids || ids.length === 0) return counts;
145
+
146
+ const groups = await this.prisma.integrationMapping.groupBy({
147
+ by: ['integrationId'],
148
+ where: { integrationId: { in: ids } },
149
+ _count: { _all: true },
150
+ });
151
+
152
+ for (const group of groups) {
153
+ counts.set(String(group.integrationId), group._count._all);
154
+ }
155
+ return counts;
156
+ }
157
+
136
158
  /**
137
159
  * Find mapping by ID
138
160
  * @param {string} id - Mapping ID
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  IntegrationMappingRepositoryInterface,
4
4
  } = require('./integration-mapping-repository-interface');
5
+ const { strictIntId } = require('./report-id');
5
6
 
6
7
  /**
7
8
  * PostgreSQL Integration Mapping Repository Adapter
@@ -188,6 +189,33 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
188
189
  };
189
190
  }
190
191
 
192
+ /**
193
+ * Count mappings grouped by integration id for a bounded id set.
194
+ *
195
+ * @param {Array<string|number>} ids - Integration ids
196
+ * @returns {Promise<Map<string, number>>} integrationId (string) → count
197
+ */
198
+ async countByIntegrationIds(ids = []) {
199
+ const counts = new Map();
200
+ if (!ids || ids.length === 0) return counts;
201
+
202
+ // Strict (matches findAllForReport): reject partially-numeric ids instead of coercing.
203
+ const intIds = ids.map((id) => strictIntId(id));
204
+ const groups = await this.prisma.integrationMapping.groupBy({
205
+ by: ['integrationId'],
206
+ where: { integrationId: { in: intIds } },
207
+ _count: { _all: true },
208
+ });
209
+
210
+ for (const group of groups) {
211
+ counts.set(
212
+ this._intToString(group.integrationId),
213
+ group._count._all
214
+ );
215
+ }
216
+ return counts;
217
+ }
218
+
191
219
  /**
192
220
  * Find mapping by ID
193
221
  * @param {string} id - Mapping ID (string from application layer)
@@ -4,6 +4,7 @@ const {
4
4
  toObjectIdArray,
5
5
  fromObjectId,
6
6
  findMany,
7
+ findManyDrained,
7
8
  findOne,
8
9
  insertOne,
9
10
  updateOne,
@@ -254,6 +255,43 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
254
255
  return this._mapIntegration(updated);
255
256
  }
256
257
 
258
+ // Drain the full cursor so a deployment-wide report is never truncated.
259
+ async findAllForReport({ status, userId } = {}) {
260
+ const filter = {};
261
+ if (status) filter.status = status;
262
+ if (userId !== undefined && userId !== null) {
263
+ const objectId = toObjectId(userId);
264
+ // Invalid userId means no matches — don't fall through to an unfiltered whole-deployment query.
265
+ if (!objectId) return [];
266
+ filter.userId = objectId;
267
+ }
268
+
269
+ const docs = await findManyDrained(this.prisma, 'Integration', filter);
270
+
271
+ return docs.map((doc) => {
272
+ const errors = this._extractReportErrors(doc);
273
+ return {
274
+ id: fromObjectId(doc?._id),
275
+ type: doc?.config?.type ?? null,
276
+ status: doc?.status ?? null,
277
+ userId: fromObjectId(doc?.userId) ?? null,
278
+ version: doc?.version ?? null,
279
+ errorCount: Array.isArray(errors) ? errors.length : 0,
280
+ moduleCount: Array.isArray(doc?.entityIds)
281
+ ? doc.entityIds.length
282
+ : 0,
283
+ createdAt: doc?.createdAt ?? null,
284
+ updatedAt: doc?.updatedAt ?? null,
285
+ };
286
+ });
287
+ }
288
+
289
+ _extractReportErrors(doc) {
290
+ if (Array.isArray(doc?.errors)) return doc.errors;
291
+ if (Array.isArray(doc?.messages?.errors)) return doc.messages.errors;
292
+ return [];
293
+ }
294
+
257
295
  _mapIntegration(doc) {
258
296
  const messages = this._extractMessages(doc);
259
297
  return {
@@ -35,6 +35,22 @@ class IntegrationRepositoryInterface {
35
35
  throw new Error('Method findIntegrations must be implemented by subclass');
36
36
  }
37
37
 
38
+ /**
39
+ * Find every integration in a report-shaped projection, optionally
40
+ * filtered by status and/or owning user. Adapters must drain the full
41
+ * result set (no first-batch truncation) since this powers a
42
+ * deployment-wide scan.
43
+ *
44
+ * @param {Object} [filter={}]
45
+ * @param {string} [filter.status] - Integration status
46
+ * @param {string|number} [filter.userId] - Owning user ID
47
+ * @returns {Promise<Array<{id, type, status, userId, version, errorCount, moduleCount, createdAt, updatedAt}>>}
48
+ * @abstract
49
+ */
50
+ async findAllForReport(filter = {}) {
51
+ throw new Error('Method findAllForReport must be implemented by subclass');
52
+ }
53
+
38
54
  /**
39
55
  * Delete integration by ID
40
56
  *
@@ -89,6 +89,42 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
89
89
  }));
90
90
  }
91
91
 
92
+ /**
93
+ * Find every integration in a report-shaped projection.
94
+ *
95
+ * type lives in config.type (a JSON path not portably groupable across
96
+ * DBs); it is left in the row for the caller to bucket.
97
+ *
98
+ * @param {Object} [filter={}]
99
+ * @param {string} [filter.status] - Integration status
100
+ * @param {string} [filter.userId] - Owning user ID (ObjectId as string)
101
+ * @returns {Promise<Array>} Report-shaped integration rows
102
+ */
103
+ async findAllForReport({ status, userId } = {}) {
104
+ const where = {};
105
+ if (status) where.status = status;
106
+ if (userId !== undefined && userId !== null) where.userId = userId;
107
+
108
+ const integrations = await this.prisma.integration.findMany({
109
+ where,
110
+ include: { entities: { select: { id: true } } },
111
+ });
112
+
113
+ return integrations.map((integration) => ({
114
+ id: integration.id,
115
+ type: integration.config?.type ?? null,
116
+ status: integration.status ?? null,
117
+ userId: integration.userId ?? null,
118
+ version: integration.version ?? null,
119
+ errorCount: Array.isArray(integration.errors)
120
+ ? integration.errors.length
121
+ : 0,
122
+ moduleCount: integration.entities?.length ?? 0,
123
+ createdAt: integration.createdAt ?? null,
124
+ updatedAt: integration.updatedAt ?? null,
125
+ }));
126
+ }
127
+
92
128
  /**
93
129
  * Delete integration by ID
94
130
  * Replaces: IntegrationModel.deleteOne({ _id: integrationId })
@@ -3,6 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
+ const { strictIntId } = require('./report-id');
6
7
 
7
8
  /**
8
9
  * PostgreSQL Integration Repository Adapter
@@ -129,6 +130,45 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
129
130
  });
130
131
  }
131
132
 
133
+ /**
134
+ * Find every integration in a report-shaped projection.
135
+ *
136
+ * type lives in config.type (a JSON path not portably groupable across
137
+ * DBs); it is left in the row for the caller to bucket.
138
+ *
139
+ * @param {Object} [filter={}]
140
+ * @param {string} [filter.status] - Integration status
141
+ * @param {string|number} [filter.userId] - Owning user ID
142
+ * @returns {Promise<Array>} Report-shaped integration rows
143
+ */
144
+ async findAllForReport({ status, userId } = {}) {
145
+ const where = {};
146
+ if (status) where.status = status;
147
+ if (userId !== undefined && userId !== null) {
148
+ // Strict: parseInt would coerce '12abc'/'12.9' to 12 and read the wrong user.
149
+ where.userId = strictIntId(userId);
150
+ }
151
+
152
+ const integrations = await this.prisma.integration.findMany({
153
+ where,
154
+ include: { entities: { select: { id: true } } },
155
+ });
156
+
157
+ return integrations.map((integration) => ({
158
+ id: integration.id?.toString(),
159
+ type: integration.config?.type ?? null,
160
+ status: integration.status ?? null,
161
+ userId: integration.userId?.toString() ?? null,
162
+ version: integration.version ?? null,
163
+ errorCount: Array.isArray(integration.errors)
164
+ ? integration.errors.length
165
+ : 0,
166
+ moduleCount: integration.entities?.length ?? 0,
167
+ createdAt: integration.createdAt ?? null,
168
+ updatedAt: integration.updatedAt ?? null,
169
+ }));
170
+ }
171
+
132
172
  /**
133
173
  * Delete integration by ID
134
174
  *
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Coerces an id to an integer, rejecting partially-numeric input ('12abc',
3
+ * '12.9') that parseInt would silently truncate to 12 and read the wrong record.
4
+ */
5
+ function strictIntId(id) {
6
+ const str = String(id).trim();
7
+ if (!/^-?\d+$/.test(str)) {
8
+ throw new TypeError(`Invalid ID: ${id} cannot be converted to integer`);
9
+ }
10
+ return Number.parseInt(str, 10);
11
+ }
12
+
13
+ module.exports = { strictIntId };
@@ -322,6 +322,11 @@ class OAuth2Requester extends Requester {
322
322
  response_status: error?.response?.status,
323
323
  response_data: error?.response?.data,
324
324
  });
325
+ // Status only: the refresh body carries client_secret, and
326
+ // FetchError embeds the body in its message outside prod.
327
+ await this.notify(this.DLGT_INVALID_AUTH, {
328
+ statusCode: error?.statusCode,
329
+ });
325
330
  return false;
326
331
  }
327
332
  }
@@ -353,8 +358,13 @@ class OAuth2Requester extends Requester {
353
358
 
354
359
  await this.setTokens(tokenRes);
355
360
  return tokenRes;
356
- } catch {
357
- await this.notify(this.DLGT_INVALID_AUTH);
361
+ } catch (error) {
362
+ // Status only. This request's body holds the password or client
363
+ // secret, and FetchError embeds the body in its message outside
364
+ // prod, so forwarding the error itself would log the credential.
365
+ await this.notify(this.DLGT_INVALID_AUTH, {
366
+ statusCode: error?.statusCode,
367
+ });
358
368
  }
359
369
  }
360
370
 
@@ -387,8 +397,13 @@ class OAuth2Requester extends Requester {
387
397
 
388
398
  await this.setTokens(tokenRes);
389
399
  return tokenRes;
390
- } catch {
391
- await this.notify(this.DLGT_INVALID_AUTH);
400
+ } catch (error) {
401
+ // Status only. This request's body holds the password or client
402
+ // secret, and FetchError embeds the body in its message outside
403
+ // prod, so forwarding the error itself would log the credential.
404
+ await this.notify(this.DLGT_INVALID_AUTH, {
405
+ statusCode: error?.statusCode,
406
+ });
392
407
  }
393
408
  }
394
409
  }