@friggframework/core 2.0.0-next.97 → 2.0.0-next.99

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 (37) hide show
  1. package/application/commands/integration-commands.js +34 -1
  2. package/generated/prisma-mongodb/edge.js +6 -4
  3. package/generated/prisma-mongodb/index-browser.js +3 -1
  4. package/generated/prisma-mongodb/index.d.ts +3 -1
  5. package/generated/prisma-mongodb/index.js +6 -4
  6. package/generated/prisma-mongodb/package.json +1 -1
  7. package/generated/prisma-mongodb/schema.prisma +3 -1
  8. package/generated/prisma-mongodb/wasm.js +5 -3
  9. package/generated/prisma-postgresql/edge.js +6 -4
  10. package/generated/prisma-postgresql/index-browser.js +3 -1
  11. package/generated/prisma-postgresql/index.d.ts +3 -1
  12. package/generated/prisma-postgresql/index.js +6 -4
  13. package/generated/prisma-postgresql/package.json +1 -1
  14. package/generated/prisma-postgresql/schema.prisma +3 -1
  15. package/generated/prisma-postgresql/wasm.js +5 -3
  16. package/handlers/backend-utils.js +2 -2
  17. package/integrations/integration-base.js +150 -35
  18. package/integrations/integration-router.js +2 -1
  19. package/integrations/repositories/config-patch-shared.js +43 -0
  20. package/integrations/repositories/integration-repository-documentdb.js +54 -1
  21. package/integrations/repositories/integration-repository-interface.js +15 -0
  22. package/integrations/repositories/integration-repository-mongo.js +48 -0
  23. package/integrations/repositories/integration-repository-postgres.js +28 -0
  24. package/integrations/tests/doubles/dummy-integration-class.js +8 -0
  25. package/integrations/tests/doubles/test-integration-repository.js +24 -1
  26. package/integrations/use-cases/create-integration.js +138 -6
  27. package/integrations/use-cases/delete-integration-for-user.js +19 -1
  28. package/integrations/use-cases/patch-integration-config.js +39 -0
  29. package/integrations/use-cases/update-integration-config.js +32 -0
  30. package/package.json +5 -5
  31. package/prisma-mongodb/schema.prisma +3 -1
  32. package/prisma-postgresql/migrations/20260703000000_add_integration_status_in_creation_in_deletion/migration.sql +11 -0
  33. package/prisma-postgresql/migrations/20260703000001_integration_status_default_in_creation/migration.sql +8 -0
  34. package/prisma-postgresql/schema.prisma +3 -1
  35. package/reporting/README.md +86 -0
  36. package/reporting/reporting-router.js +29 -0
  37. package/reporting/use-cases/list-integrations-report.js +6 -1
@@ -22,23 +22,158 @@ class CreateIntegration {
22
22
  }
23
23
 
24
24
  /**
25
- * Executes the integration creation process.
25
+ * Executes the integration creation process. Reuses an existing
26
+ * integration when one already exists for the same userId, config.type,
27
+ * and entity set, instead of creating a duplicate.
26
28
  * @async
27
29
  * @param {string[]} entities - Array of entity IDs to associate with the integration.
28
30
  * @param {string} userId - ID of the user creating the integration.
29
31
  * @param {Object} config - Configuration object for the integration.
30
32
  * @param {string} config.type - Type of integration to create.
31
- * @returns {Promise<Object>} The created integration DTO.
33
+ * @returns {Promise<Object>} The created or reused integration DTO.
32
34
  * @throws {Error} When integration class is not found for the specified type.
33
35
  */
34
36
  async execute(entities, userId, config) {
37
+ console.log(
38
+ `[Frigg] Creating ${config?.type} integration for user ${userId} with entities [${entities}]`
39
+ );
40
+
41
+ const existing = await this._findDuplicate(
42
+ userId,
43
+ config?.type,
44
+ entities
45
+ );
46
+ if (existing) {
47
+ console.log(
48
+ `[Frigg] Found existing integration ${existing.id} for the same user, type, and entities — reusing it`
49
+ );
50
+ return this._reuseExisting(existing, config);
51
+ }
52
+
35
53
  const integrationRecord =
36
54
  await this.integrationRepository.createIntegration(
37
55
  entities,
38
56
  userId,
39
57
  config
40
58
  );
59
+ console.log(
60
+ `[Frigg] Created integration ${integrationRecord.id} for user ${userId}`
61
+ );
62
+
63
+ // A concurrent request may have created a matching row between the
64
+ // lookup above and this insert; re-check before any side effects run.
65
+ const survivor = await this._findDuplicate(
66
+ userId,
67
+ config?.type,
68
+ entities
69
+ );
70
+ if (survivor && String(survivor.id) !== String(integrationRecord.id)) {
71
+ console.log(
72
+ `[Frigg] Concurrent create detected — deleting duplicate ${integrationRecord.id} and reusing ${survivor.id}`
73
+ );
74
+ await this.integrationRepository.deleteIntegrationById(
75
+ integrationRecord.id
76
+ );
77
+ return this._reuseExisting(survivor, config);
78
+ }
79
+
80
+ const integrationInstance = await this._buildInstance(
81
+ integrationRecord
82
+ );
83
+ console.log(
84
+ `[Frigg] Sending ON_CREATE for integration ${integrationRecord.id}`
85
+ );
86
+ await integrationInstance.send('ON_CREATE', {
87
+ integrationId: integrationRecord.id,
88
+ });
89
+
90
+ return mapIntegrationClassToIntegrationDTO(integrationInstance);
91
+ }
92
+
93
+ async _reuseExisting(integrationRecord, config) {
94
+ // The caller may be reconnecting with changed settings. Persist the
95
+ // requested config onto the reused row as a shallow patch, so new
96
+ // values take effect while keys added during the original create
97
+ // (webhook ids, secrets) survive. `type` is re-written to the same
98
+ // value it dedupe-matched on, which is a harmless no-op.
99
+ if (config && Object.keys(config).length > 0) {
100
+ integrationRecord =
101
+ await this.integrationRepository.patchIntegrationConfig(
102
+ integrationRecord.id,
103
+ config
104
+ );
105
+ }
106
+
107
+ const integrationInstance = await this._buildInstance(
108
+ integrationRecord
109
+ );
110
+
111
+ if (integrationInstance.status === 'IN_CREATION') {
112
+ // ON_CREATE never finished for this row (crashed mid-setup or is
113
+ // still running concurrently). Re-firing ON_CREATE here would
114
+ // risk re-registering a webhook a partially-completed attempt
115
+ // already created — unsafe without idempotent setup, which is a
116
+ // separate, larger change. Surfaced loudly rather than silently
117
+ // reused as if healthy, so it's diagnosable; testAuth below still
118
+ // runs and can at least surface bad credentials as ERROR.
119
+ console.warn(
120
+ `[Frigg] Integration ${integrationInstance.id} is still IN_CREATION on reuse — setup never completed`
121
+ );
122
+ }
123
+
124
+ // User is actively trying to reconnect; here if the integration is
125
+ // disabled, we can enable it again.
126
+ const authPassed = await integrationInstance.testAuth();
127
+ await integrationInstance.reconcileAuthStatus(authPassed);
128
+ if (integrationInstance.status === 'DISABLED') {
129
+ console.log(
130
+ `[Frigg] Integration ${integrationInstance.id} changed status from DISABLED to ENABLED`
131
+ );
132
+ await integrationInstance.persistStatus('ENABLED');
133
+ }
134
+
135
+ return mapIntegrationClassToIntegrationDTO(integrationInstance);
136
+ }
137
+
138
+ async _findDuplicate(userId, type, entityIds) {
139
+ const candidates =
140
+ await this.integrationRepository.findIntegrationsByUserId(userId);
141
+ const target = [...(entityIds ?? [])].map(String).sort();
41
142
 
143
+ const matches = candidates.filter((integration) => {
144
+ // An IN_DELETION row is either mid-teardown or a zombie left
145
+ // behind by a failed deleteIntegrationById call — never a live
146
+ // integration. Treating it as a duplicate would hand a reinstall
147
+ // attempt a row nothing will ever move out of IN_DELETION,
148
+ // silently discarded by the queue worker forever. Let a fresh
149
+ // create proceed instead; the zombie is cleaned up out-of-band,
150
+ // matching how teardown failures are already handled here.
151
+ if (integration.status === 'IN_DELETION') return false;
152
+ if (integration.config?.type !== type) return false;
153
+ const current = [...(integration.entitiesIds ?? [])]
154
+ .map(String)
155
+ .sort();
156
+ return (
157
+ current.length === target.length &&
158
+ current.every((id, index) => id === target[index])
159
+ );
160
+ });
161
+
162
+ return matches.length ? this._pickOldest(matches) : null;
163
+ }
164
+
165
+ _pickOldest(records) {
166
+ return [...records].sort((a, b) => {
167
+ const diff =
168
+ new Date(a.createdAt ?? 0) - new Date(b.createdAt ?? 0);
169
+ if (diff !== 0) return diff;
170
+ return String(a.id).localeCompare(String(b.id), 'en', {
171
+ numeric: true,
172
+ });
173
+ })[0];
174
+ }
175
+
176
+ async _buildInstance(integrationRecord) {
42
177
  const integrationClass = this.integrationClasses.find(
43
178
  (integrationClass) =>
44
179
  integrationClass.Definition.name ===
@@ -72,11 +207,8 @@ class CreateIntegration {
72
207
  });
73
208
 
74
209
  await integrationInstance.initialize();
75
- await integrationInstance.send('ON_CREATE', {
76
- integrationId: integrationRecord.id,
77
- });
78
210
 
79
- return mapIntegrationClassToIntegrationDTO(integrationInstance);
211
+ return integrationInstance;
80
212
  }
81
213
  }
82
214
 
@@ -92,7 +92,25 @@ class DeleteIntegrationForUser {
92
92
 
93
93
  // Complete async initialization (load dynamic actions, register handlers)
94
94
  await integrationInstance.initialize();
95
- await integrationInstance.send('ON_DELETE');
95
+
96
+ await integrationInstance.persistStatus('IN_DELETION');
97
+ try {
98
+ await integrationInstance.send('ON_DELETE');
99
+ } catch (error) {
100
+ const reason = error?.message ?? String(error);
101
+ console.error(
102
+ `[Integration Deletion] onDelete failed for integration ${integrationId}, leaving it IN_DELETION:`,
103
+ reason
104
+ );
105
+ await integrationInstance.updateIntegrationMessages.execute(
106
+ integrationId,
107
+ 'errors',
108
+ 'Integration Deletion Error',
109
+ `Deletion did not complete: ${reason}`,
110
+ Date.now()
111
+ );
112
+ throw error;
113
+ }
96
114
 
97
115
  await this.integrationRepository.deleteIntegrationById(integrationId);
98
116
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Use case for atomically merging a partial update into an integration's
3
+ * configuration, leaving keys not present in the patch untouched.
4
+ *
5
+ * The merge is shallow: only top-level config keys are affected, and each
6
+ * key in the patch replaces its existing value wholesale (a nested object is
7
+ * overwritten as a block, not deep-merged). To change one field inside a
8
+ * nested object without dropping its siblings, pass the whole updated object
9
+ * as that key's value. A `null` value sets the key to null (clears the field
10
+ * without removing it); to remove a key entirely, use a full replace via
11
+ * UpdateIntegrationConfig instead.
12
+ * @class PatchIntegrationConfig
13
+ */
14
+ class PatchIntegrationConfig {
15
+ /**
16
+ * Creates a new PatchIntegrationConfig instance.
17
+ * @param {Object} params - Configuration parameters.
18
+ * @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository - Repository for integration data operations.
19
+ */
20
+ constructor({ integrationRepository }) {
21
+ this.integrationRepository = integrationRepository;
22
+ }
23
+
24
+ /**
25
+ * Executes the config patch.
26
+ * @async
27
+ * @param {string} integrationId - ID of the integration to update.
28
+ * @param {Object} patch - Keys to merge into the existing config.
29
+ * @returns {Promise<Object>} The updated integration record.
30
+ */
31
+ async execute(integrationId, patch) {
32
+ return this.integrationRepository.patchIntegrationConfig(
33
+ integrationId,
34
+ patch
35
+ );
36
+ }
37
+ }
38
+
39
+ module.exports = { PatchIntegrationConfig };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Use case for replacing an integration's entire configuration. Keys not
3
+ * present in the new config are deleted — use PatchIntegrationConfig to
4
+ * merge a partial update without losing untouched keys.
5
+ * @class UpdateIntegrationConfig
6
+ */
7
+ class UpdateIntegrationConfig {
8
+ /**
9
+ * Creates a new UpdateIntegrationConfig instance.
10
+ * @param {Object} params - Configuration parameters.
11
+ * @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository - Repository for integration data operations.
12
+ */
13
+ constructor({ integrationRepository }) {
14
+ this.integrationRepository = integrationRepository;
15
+ }
16
+
17
+ /**
18
+ * Executes the full config replace.
19
+ * @async
20
+ * @param {string} integrationId - ID of the integration to update.
21
+ * @param {Object} config - The new configuration object.
22
+ * @returns {Promise<Object>} The updated integration record.
23
+ */
24
+ async execute(integrationId, config) {
25
+ return this.integrationRepository.updateIntegrationConfig(
26
+ integrationId,
27
+ config
28
+ );
29
+ }
30
+ }
31
+
32
+ module.exports = { UpdateIntegrationConfig };
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-next.97",
4
+ "version": "2.0.0-next.99",
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-next.97",
42
- "@friggframework/prettier-config": "2.0.0-next.97",
43
- "@friggframework/test": "2.0.0-next.97",
41
+ "@friggframework/eslint-config": "2.0.0-next.99",
42
+ "@friggframework/prettier-config": "2.0.0-next.99",
43
+ "@friggframework/test": "2.0.0-next.99",
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": "5595f797e6f65bb5a66dc96bfa1154f0d4c39504"
83
+ "gitHead": "a1f801371a7b6a06fc04819bfe08408a5165b75d"
84
84
  }
@@ -157,7 +157,7 @@ model Integration {
157
157
  id String @id @default(auto()) @map("_id") @db.ObjectId
158
158
  userId String? @db.ObjectId
159
159
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
160
- status IntegrationStatus @default(ENABLED)
160
+ status IntegrationStatus @default(IN_CREATION)
161
161
 
162
162
  // Configuration and version
163
163
  config Json? // Integration configuration object
@@ -193,6 +193,8 @@ enum IntegrationStatus {
193
193
  PROCESSING
194
194
  DISABLED
195
195
  ERROR
196
+ IN_CREATION
197
+ IN_DELETION
196
198
  }
197
199
 
198
200
  /// Integration-specific data mappings
@@ -0,0 +1,11 @@
1
+ -- Add IN_CREATION and IN_DELETION to the IntegrationStatus enum.
2
+ -- Kept separate from the default change (next migration) because Postgres does
3
+ -- not allow a newly added enum value to be referenced in the same transaction
4
+ -- that adds it. Requires Postgres >= 12 (ADD VALUE ... IF NOT EXISTS).
5
+ --
6
+ -- Rollback note: once any row is written with status IN_CREATION or
7
+ -- IN_DELETION, rolling the app back to a version whose Prisma client predates
8
+ -- this migration will fail to read that row (unknown enum value). Backfill
9
+ -- any such rows to a known status (e.g. ENABLED/ERROR) before rolling back.
10
+ ALTER TYPE "IntegrationStatus" ADD VALUE IF NOT EXISTS 'IN_CREATION';
11
+ ALTER TYPE "IntegrationStatus" ADD VALUE IF NOT EXISTS 'IN_DELETION';
@@ -0,0 +1,8 @@
1
+ -- New integrations are born IN_CREATION and transition to ENABLED / NEEDS_CONFIG
2
+ -- once onCreate completes. Metadata-only change; existing rows are untouched.
3
+ --
4
+ -- Deploy-order note: if this app runs migrations via a post-deploy step (e.g.
5
+ -- an explicit POST /db-migrate call) rather than pre-deploy, code that writes
6
+ -- IN_CREATION/IN_DELETION will error on the enum value until both migrations
7
+ -- in this pair have applied. Transient and retryable, not data-destructive.
8
+ ALTER TABLE "Integration" ALTER COLUMN "status" SET DEFAULT 'IN_CREATION';
@@ -150,7 +150,7 @@ model Integration {
150
150
  id Int @id @default(autoincrement())
151
151
  userId Int?
152
152
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
153
- status IntegrationStatus @default(ENABLED)
153
+ status IntegrationStatus @default(IN_CREATION)
154
154
 
155
155
  // Configuration and version
156
156
  config Json? // Integration configuration object
@@ -184,6 +184,8 @@ enum IntegrationStatus {
184
184
  PROCESSING
185
185
  DISABLED
186
186
  ERROR
187
+ IN_CREATION
188
+ IN_DELETION
187
189
  }
188
190
 
189
191
  /// Integration-specific data mappings
@@ -0,0 +1,86 @@
1
+ # Reporting
2
+
3
+ Read-only, deployment-wide reporting endpoints for `@friggframework/core`.
4
+ Gated by a dedicated reporting API key — **not** the per-user auth used by the
5
+ rest of the Management API.
6
+
7
+ ## Auth
8
+
9
+ Send the reporting key in the `x-frigg-reporting-api-key` header. It is validated
10
+ against the `REPORTING_API_KEY` environment variable. Missing or wrong key → `401`.
11
+
12
+ ## Endpoints
13
+
14
+ | Method | Path | Description |
15
+ | --- | --- | --- |
16
+ | `GET` | `/api/v2/reports` | Index of available reports. |
17
+ | `GET` | `/api/v2/reports/integrations` | Integrations report (see below). |
18
+
19
+ ### `GET /api/v2/reports/integrations`
20
+
21
+ Optional query params (all strings): `status` (an `IntegrationStatus`), `type`
22
+ (`config.type` slug), `userId`.
23
+
24
+ ## Response shape
25
+
26
+ ```jsonc
27
+ {
28
+ "schemaVersion": 1,
29
+ "service": "frigg-core-api",
30
+ "generatedAt": "2026-06-29T20:45:05.142Z",
31
+ "filters": { "status": null, "type": null, "userId": null },
32
+ "metrics": {
33
+ "total": 12,
34
+ "byStatus": { "ENABLED": 9, "ERROR": 2, "NEEDS_CONFIG": 1, "PROCESSING": 0, "DISABLED": 0 },
35
+ "byType": [
36
+ {
37
+ "type": "hubspot",
38
+ "label": "HubSpot CRM",
39
+ "total": 5,
40
+ "byStatus": { "ENABLED": 4, "ERROR": 1, "NEEDS_CONFIG": 0, "PROCESSING": 0, "DISABLED": 0 }
41
+ }
42
+ ],
43
+ "typeLabels": { "hubspot": "HubSpot CRM" },
44
+ "integrations": [ /* lightweight per-integration rows */ ]
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Field reference
50
+
51
+ - `schemaVersion` — contract version; branch on it. Additive changes do **not** bump it.
52
+ - `filters` — echoes the applied filters (nulls when omitted).
53
+ - `metrics.total` — count of integrations matching the filters.
54
+ - `metrics.byStatus` — counts keyed by `IntegrationStatus` value.
55
+ - `metrics.byType[]` — per `type` breakdown: `{ type, label, total, byStatus }`.
56
+ - `type` — the `config.type` slug. Integrations with no type bucket as `"unknown"`.
57
+ - `label` — human-readable name from the integration class's
58
+ `Definition.display.label`. Falls back to the `type` slug when no registered
59
+ class supplies a label (e.g. an integration that was removed, or run on an
60
+ older app that doesn't register it). Additive — `type` is unchanged.
61
+ - `metrics.typeLabels` — map of `type` slug → human-readable label, so callers can
62
+ label `integrations[].type` rows without bloating each row. Contains only types
63
+ whose registered class supplies a non-default `display.label` (classes still
64
+ carrying the IntegrationBase default `'Integration Name'` are excluded).
65
+ - `metrics.integrations[]` — lightweight per-integration rows (`id`, `type`,
66
+ `status`, `userId`, `version`, `moduleCount`, `errorCount`, `mappedRecordCount`,
67
+ `createdAt`, `updatedAt`). These rows carry `type` only — resolve display names
68
+ via `metrics.typeLabels`.
69
+
70
+ ## How labels are sourced
71
+
72
+ `createReportingRouter()` loads the app's registered integration classes via
73
+ `loadAppDefinition()` and builds a `{ slug → label }` map from each class's
74
+ `Definition.display.label`. Loading is wrapped in try/catch, so reporting still
75
+ works (labels fall back to slugs) if the app definition can't be loaded. The
76
+ `ListIntegrationsReport` use case stays storage-agnostic — it just reads the
77
+ injected `typeLabels` map, so all database adapters (PostgreSQL, MongoDB,
78
+ DocumentDB) get labels with zero adapter changes.
79
+
80
+ ## Caveats
81
+
82
+ - The response is sensitive (exposes integration types, counts, user IDs, versions).
83
+ Treat the reporting key as an admin secret.
84
+ - Read-only — no mutation endpoints.
85
+ - Labels only appear once the deployment runs a `@friggframework/core` version that
86
+ includes this field and the app registers the integration classes.
@@ -4,11 +4,40 @@ const {
4
4
  createReportingRepository,
5
5
  } = require('./repositories/reporting-repository-factory');
6
6
  const { ListIntegrationsReport } = require('./use-cases/list-integrations-report');
7
+ const { loadAppDefinition } = require('../handlers/app-definition-loader');
8
+
9
+ // IntegrationBase.Definition default — skip it so the slug is used instead.
10
+ const PLACEHOLDER_DISPLAY_NAME = 'Integration Name';
11
+
12
+ // Map each integration's config.type slug to its human-readable display label.
13
+ // Wrapped so reporting still works if the app definition fails to load.
14
+ function buildTypeLabels() {
15
+ try {
16
+ const { integrations = [] } = loadAppDefinition();
17
+ const labels = {};
18
+ for (const IntegrationClass of integrations) {
19
+ const def = IntegrationClass?.Definition;
20
+ if (!def?.name) continue;
21
+ const label = def.display?.label;
22
+ if (label && label !== PLACEHOLDER_DISPLAY_NAME) {
23
+ labels[def.name] = label;
24
+ }
25
+ }
26
+ return labels;
27
+ } catch (error) {
28
+ console.error(
29
+ 'Reporting: failed to load integration labels:',
30
+ error.message
31
+ );
32
+ return {};
33
+ }
34
+ }
7
35
 
8
36
  function createReportingRouter() {
9
37
  const reportingRepository = createReportingRepository();
10
38
  const listIntegrationsReport = new ListIntegrationsReport({
11
39
  reportingRepository,
40
+ typeLabels: buildTypeLabels(),
12
41
  });
13
42
 
14
43
  const router = express.Router();
@@ -6,19 +6,22 @@ const SERVICE = 'frigg-core-api';
6
6
  // Seeded so every known status appears (even at 0); unknown values added to
7
7
  // the schema later are still counted dynamically.
8
8
  const KNOWN_STATUSES = [
9
+ 'IN_CREATION',
9
10
  'ENABLED',
10
11
  'ERROR',
11
12
  'NEEDS_CONFIG',
12
13
  'PROCESSING',
14
+ 'IN_DELETION',
13
15
  'DISABLED',
14
16
  ];
15
17
 
16
18
  class ListIntegrationsReport {
17
- constructor({ reportingRepository } = {}) {
19
+ constructor({ reportingRepository, typeLabels = {} } = {}) {
18
20
  if (!reportingRepository) {
19
21
  throw new Error('reportingRepository is required');
20
22
  }
21
23
  this.reportingRepository = reportingRepository;
24
+ this.typeLabels = typeLabels;
22
25
  }
23
26
 
24
27
  async execute(query = {}) {
@@ -64,6 +67,7 @@ class ListIntegrationsReport {
64
67
  if (!byTypeMap.has(integration.type)) {
65
68
  byTypeMap.set(integration.type, {
66
69
  type: integration.type,
70
+ label: this.typeLabels[integration.type] || integration.type,
67
71
  total: 0,
68
72
  byStatus: emptyStatusCounts(),
69
73
  });
@@ -86,6 +90,7 @@ class ListIntegrationsReport {
86
90
  total: integrations.length,
87
91
  byStatus,
88
92
  byType: Array.from(byTypeMap.values()),
93
+ typeLabels: { ...this.typeLabels },
89
94
  integrations,
90
95
  },
91
96
  };