@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.
- package/application/commands/integration-commands.js +34 -1
- package/generated/prisma-mongodb/edge.js +6 -4
- package/generated/prisma-mongodb/index-browser.js +3 -1
- package/generated/prisma-mongodb/index.d.ts +3 -1
- package/generated/prisma-mongodb/index.js +6 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +3 -1
- package/generated/prisma-mongodb/wasm.js +5 -3
- package/generated/prisma-postgresql/edge.js +6 -4
- package/generated/prisma-postgresql/index-browser.js +3 -1
- package/generated/prisma-postgresql/index.d.ts +3 -1
- package/generated/prisma-postgresql/index.js +6 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +3 -1
- package/generated/prisma-postgresql/wasm.js +5 -3
- package/handlers/backend-utils.js +2 -2
- package/integrations/integration-base.js +150 -35
- package/integrations/integration-router.js +2 -1
- package/integrations/repositories/config-patch-shared.js +43 -0
- package/integrations/repositories/integration-repository-documentdb.js +54 -1
- package/integrations/repositories/integration-repository-interface.js +15 -0
- package/integrations/repositories/integration-repository-mongo.js +48 -0
- package/integrations/repositories/integration-repository-postgres.js +28 -0
- package/integrations/tests/doubles/dummy-integration-class.js +8 -0
- package/integrations/tests/doubles/test-integration-repository.js +24 -1
- package/integrations/use-cases/create-integration.js +138 -6
- package/integrations/use-cases/delete-integration-for-user.js +19 -1
- package/integrations/use-cases/patch-integration-config.js +39 -0
- package/integrations/use-cases/update-integration-config.js +32 -0
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +3 -1
- package/prisma-postgresql/migrations/20260703000000_add_integration_status_in_creation_in_deletion/migration.sql +11 -0
- package/prisma-postgresql/migrations/20260703000001_integration_status_default_in_creation/migration.sql +8 -0
- package/prisma-postgresql/schema.prisma +3 -1
- package/reporting/README.md +86 -0
- package/reporting/reporting-router.js +29 -0
- 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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
43
|
-
"@friggframework/test": "2.0.0-next.
|
|
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": "
|
|
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(
|
|
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(
|
|
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
|
};
|