@friggframework/core 2.0.0-next.103 → 2.0.0-next.105

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 (47) 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/database/use-cases/resolve-migration-via-worker-use-case.js +49 -0
  20. package/database/utils/prisma-runner.js +16 -2
  21. package/handlers/app-definition-loader.js +3 -2
  22. package/handlers/routers/db-migration.js +36 -18
  23. package/handlers/workers/db-migration.js +75 -0
  24. package/index.js +8 -4
  25. package/integrations/repositories/integration-mapping-repository-documentdb.js +23 -0
  26. package/integrations/repositories/integration-mapping-repository-interface.js +14 -0
  27. package/integrations/repositories/integration-mapping-repository-mongo.js +22 -0
  28. package/integrations/repositories/integration-mapping-repository-postgres.js +28 -0
  29. package/integrations/repositories/integration-repository-documentdb.js +38 -0
  30. package/integrations/repositories/integration-repository-interface.js +16 -0
  31. package/integrations/repositories/integration-repository-mongo.js +36 -0
  32. package/integrations/repositories/integration-repository-postgres.js +40 -0
  33. package/integrations/repositories/report-id.js +13 -0
  34. package/package.json +7 -5
  35. package/reporting/README.md +109 -48
  36. package/reporting/builtin-reports.js +6 -0
  37. package/reporting/index.js +9 -13
  38. package/reporting/report-base.js +49 -0
  39. package/reporting/{use-cases/list-integrations-report.js → reports/integrations-report.js} +69 -37
  40. package/handlers/routers/reporting.js +0 -9
  41. package/reporting/reporting-router.js +0 -84
  42. package/reporting/repositories/reporting-repository-documentdb.js +0 -127
  43. package/reporting/repositories/reporting-repository-factory.js +0 -35
  44. package/reporting/repositories/reporting-repository-interface.js +0 -16
  45. package/reporting/repositories/reporting-repository-mongo.js +0 -54
  46. package/reporting/repositories/reporting-repository-postgres.js +0 -70
  47. package/reporting/use-cases/index.js +0 -6
@@ -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 };
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.103",
4
+ "version": "2.0.0-next.105",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
8
8
  "@aws-sdk/client-lambda": "^3.714.0",
9
+ "@aws-sdk/client-s3": "^3.588.0",
9
10
  "@aws-sdk/client-sqs": "^3.588.0",
10
11
  "@aws-sdk/client-ssm": "^3.588.0",
12
+ "@aws-sdk/s3-request-presigner": "^3.588.0",
11
13
  "@hapi/boom": "^10.0.1",
12
14
  "@opentelemetry/api": "^1.9.1",
13
15
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -46,9 +48,9 @@
46
48
  }
47
49
  },
48
50
  "devDependencies": {
49
- "@friggframework/eslint-config": "2.0.0-next.103",
50
- "@friggframework/prettier-config": "2.0.0-next.103",
51
- "@friggframework/test": "2.0.0-next.103",
51
+ "@friggframework/eslint-config": "2.0.0-next.105",
52
+ "@friggframework/prettier-config": "2.0.0-next.105",
53
+ "@friggframework/test": "2.0.0-next.105",
52
54
  "@prisma/client": "^6.19.3",
53
55
  "@types/lodash": "4.17.15",
54
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -88,5 +90,5 @@
88
90
  "publishConfig": {
89
91
  "access": "public"
90
92
  },
91
- "gitHead": "9fc434b0c6e1323bd16be165d311b6880a23de12"
93
+ "gitHead": "d200bd6b1385a33736b67f48e3aa5ac3f5e167c8"
92
94
  }
@@ -1,27 +1,94 @@
1
1
  # Reporting
2
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.
3
+ Deployment-wide reports for `@friggframework/core`, modelled as **admin
4
+ operations** (ADR-010) a sibling of the Admin Script Runner (ADR-005) on the
5
+ same primitives: one registry, the shared admin API key, sync/async execution,
6
+ the isolated admin execution store, and EventBridge scheduling.
7
+
8
+ A report is a definition (`ReportBase`) whose output is its payload. **Core
9
+ ships built-in reports; adopters register their own** — both are discovered,
10
+ listed, and executed through the same runner. This package provides `ReportBase`,
11
+ the built-in reports, and `createReportCommands()`. The HTTP router, runner, and
12
+ executor live in `@friggframework/admin-scripts`; the infrastructure (queue,
13
+ executor Lambda, artifact bucket, scheduler) is generated by the devtools
14
+ `AdminScriptBuilder`.
15
+
16
+ ## Registering reports
17
+
18
+ ```js
19
+ // app definition — same shape as adminScripts
20
+ const Definition = {
21
+ name: 'my-app',
22
+ integrations: [HubSpotIntegration, SalesforceIntegration],
23
+ reports: [ConnectedAccountsActivity], // adopter-defined
24
+ admin: { includeBuiltinReports: true }, // + core built-ins (integrations, ...)
25
+ };
26
+ ```
27
+
28
+ A report extends `ReportBase` and reads only through the injected admin command
29
+ bundle (`frigg` / `this.context.commands`), never a repository directly:
30
+
31
+ ```js
32
+ const { ReportBase } = require('@friggframework/core');
33
+
34
+ class ConnectedAccountsActivity extends ReportBase {
35
+ static Definition = {
36
+ name: 'connected-accounts-activity',
37
+ version: '1.0.0',
38
+ runModes: ['snapshot', 'recorded', 'live'], // first is the default
39
+ inputSchema: { type: 'object', properties: {
40
+ windowDays: { type: 'integer', enum: [30, 60, 90], default: 30 } } },
41
+ output: { format: 'json' }, // 'csv'|'pdf'|'zip' → artifact storage
42
+ schedule: { enabled: true, cron: 'cron(0 6 * * ? *)', mode: 'snapshot' }, // default mode only; activate via PUT
43
+ };
44
+
45
+ async execute(frigg, params) {
46
+ const since = daysAgo(params.windowDays ?? 30);
47
+ const byType = await frigg.credentials.countActiveByType({ since });
48
+ return { windowDays: params.windowDays ?? 30, byType };
49
+ }
50
+ }
51
+ ```
6
52
 
7
53
  ## Auth
8
54
 
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`.
55
+ All report endpoints use the **shared admin API key** (ADR-005): the
56
+ `x-frigg-admin-api-key` header, validated against `ADMIN_API_KEY`. The dedicated
57
+ reporting key (`x-frigg-reporting-api-key` / `REPORTING_API_KEY`) is **retired**.
58
+
59
+ ## Run modes
60
+
61
+ Each invocation picks a mode; persistence follows from it. All three run through
62
+ the one runner and the isolated admin execution store (`AdminScriptExecution`,
63
+ `type: 'REPORT'` — no user/integration FK, so a user-scoped query can never
64
+ return a report record).
65
+
66
+ | Mode | Persists | Use it for |
67
+ | --- | --- | --- |
68
+ | `live` | nothing — computes and returns inline | cheap, always-fresh queries |
69
+ | `recorded` | an execution record (input + results + logs) | audit trail, async polling, expensive scans |
70
+ | `snapshot` | a recorded run tagged into a named series | trends over time |
71
+
72
+ Non-JSON `output.format` (`csv`/`pdf`/`zip`) is written to artifact storage (S3,
73
+ private + signed URL) instead of inline; JSON stays inline.
11
74
 
12
75
  ## Endpoints
13
76
 
14
77
  | Method | Path | Description |
15
78
  | --- | --- | --- |
16
- | `GET` | `/api/v2/reports` | Index of available reports. |
17
- | `GET` | `/api/v2/reports/integrations` | Integrations report (see below). |
79
+ | `GET` | `/api/v2/reports` | List registered report definitions. |
80
+ | `GET` | `/api/v2/reports/:name` | Report definition detail (run modes, input schema, schedule). |
81
+ | `POST` | `/api/v2/reports/:name/run` | Run `{ mode, params }`. `live` → `200` inline; `recorded`/`snapshot` → `202 { executionId }` (queued). |
82
+ | `GET` | `/api/v2/reports/:name/snapshots?from=&to=` | A report's snapshot series (trend); each point carries a signed `artifactUrl` when the run produced a non-JSON artifact. |
83
+ | `GET` | `/api/v2/reports/executions/:id` | Fetch one report execution (guarded to `type: 'REPORT'`); a stored artifact is returned as a signed `results.artifactUrl`. |
84
+ | `GET`/`PUT`/`DELETE` | `/api/v2/reports/:name/schedule` | Manage a report's recurring schedule. |
85
+ | `GET` | `/api/v2/reports/integrations` | Deprecated back-compat for PR #607 — runs the built-in `integrations` report in `live` mode and returns its payload directly. Prefer `POST /:name/run`. |
18
86
 
19
- ### `GET /api/v2/reports/integrations`
87
+ ## Built-in: `integrations`
20
88
 
21
- Optional query params (all strings): `status` (an `IntegrationStatus`), `type`
22
- (`config.type` slug), `userId`.
23
-
24
- ## Response shape
89
+ Integrations by status and type, with per-type usage columns. Input query params
90
+ (all strings): `status` (an `IntegrationStatus`), `type` (`config.type` slug),
91
+ `userId`. Response shape (`schemaVersion: 1`):
25
92
 
26
93
  ```jsonc
27
94
  {
@@ -37,7 +104,8 @@ Optional query params (all strings): `status` (an `IntegrationStatus`), `type`
37
104
  "type": "hubspot",
38
105
  "label": "HubSpot CRM",
39
106
  "total": 5,
40
- "byStatus": { "ENABLED": 4, "ERROR": 1, "NEEDS_CONFIG": 0, "PROCESSING": 0, "DISABLED": 0 }
107
+ "byStatus": { "ENABLED": 4, "ERROR": 1, "NEEDS_CONFIG": 0, "PROCESSING": 0, "DISABLED": 0 },
108
+ "usage": { "records.synced": 4200, "webhooks.received": 118 }
41
109
  }
42
110
  ],
43
111
  "typeLabels": { "hubspot": "HubSpot CRM" },
@@ -46,7 +114,7 @@ Optional query params (all strings): `status` (an `IntegrationStatus`), `type`
46
114
  }
47
115
  ```
48
116
 
49
- ## Field reference
117
+ ### Field reference
50
118
 
51
119
  - `schemaVersion` — contract version; branch on it. Additive changes do **not** bump it.
52
120
  - `filters` — echoes the applied filters (nulls when omitted).
@@ -54,40 +122,33 @@ Optional query params (all strings): `status` (an `IntegrationStatus`), `type`
54
122
  - `metrics.byStatus` — counts keyed by `IntegrationStatus` value.
55
123
  - `metrics.byType[]` — per `type` breakdown: `{ type, label, total, byStatus, usage? }`.
56
124
  - `type` — the `config.type` slug. Integrations with no type bucket as `"unknown"`.
57
- - `usage` — **additive** (ADR-011): present only when the deployment has the
58
- usage store configured. A map of canonical counter → total for that type,
59
- e.g. `{ "records.synced": 4200, "webhooks.received": 118, "api.requests": 9004 }`
60
- (`0` when a type has no data). Read only from the Frigg usage store, never an
61
- external APM; a usage-store read failure never fails the structural report.
62
- Populated by the counters an integration opts into via `Definition.usage` —
63
- see [`../telemetry/README.md`](../telemetry/README.md).
64
- - `label` — human-readable name from the integration class's
65
- `Definition.display.label`. Falls back to the `type` slug when no registered
66
- class supplies a label (e.g. an integration that was removed, or run on an
67
- older app that doesn't register it). Additive — `type` is unchanged.
68
- - `metrics.typeLabels` map of `type` slug → human-readable label, so callers can
69
- label `integrations[].type` rows without bloating each row. Contains only types
70
- whose registered class supplies a non-default `display.label` (classes still
71
- carrying the IntegrationBase default `'Integration Name'` are excluded).
72
- - `metrics.integrations[]` — lightweight per-integration rows (`id`, `type`,
73
- `status`, `userId`, `version`, `moduleCount`, `errorCount`, `mappedRecordCount`,
74
- `createdAt`, `updatedAt`). These rows carry `type` only resolve display names
75
- via `metrics.typeLabels`.
76
-
77
- ## How labels are sourced
78
-
79
- `createReportingRouter()` loads the app's registered integration classes via
80
- `loadAppDefinition()` and builds a `{ slug → label }` map from each class's
81
- `Definition.display.label`. Loading is wrapped in try/catch, so reporting still
82
- works (labels fall back to slugs) if the app definition can't be loaded. The
83
- `ListIntegrationsReport` use case stays storage-agnostic — it just reads the
84
- injected `typeLabels` map, so all database adapters (PostgreSQL, MongoDB,
85
- DocumentDB) get labels with zero adapter changes.
125
+ - `usage` — **additive** (ADR-011): present only when the deployment has the usage
126
+ store configured. A map of canonical counter → total for that type (`0` when a
127
+ type has no data). Read only from the Frigg usage store; a usage-store read
128
+ failure never fails the structural report. Populated by the counters an
129
+ integration opts into via `Definition.usage` see [`../telemetry/README.md`](../telemetry/README.md).
130
+ - `label` human-readable name from the integration class's `Definition.display.label`,
131
+ falling back to the `type` slug when no registered class supplies one.
132
+ - `metrics.typeLabels` — map of `type` slug label, so callers label
133
+ `integrations[].type` rows without bloating each row.
134
+ - `metrics.integrations[]` lightweight per-integration rows (`id`, `type`, `status`,
135
+ `userId`, `version`, `moduleCount`, `errorCount`, `mappedRecordCount`, `createdAt`,
136
+ `updatedAt`). Resolve display names via `metrics.typeLabels`.
137
+
138
+ The built-in report reads its data through the admin command bundle
139
+ (`frigg.integrations.listForReport`, `frigg.integrationMappings.countByIntegrationIds`,
140
+ `frigg.usage.getTotalsByDimension`)the retired reporting repository triad's
141
+ logic now lives in the canonical integration/mapping repositories, so all
142
+ database adapters (PostgreSQL, MongoDB, DocumentDB) are served through one path.
86
143
 
87
144
  ## Caveats
88
145
 
89
- - The response is sensitive (exposes integration types, counts, user IDs, versions).
90
- Treat the reporting key as an admin secret.
91
- - Read-only no mutation endpoints.
92
- - Labels only appear once the deployment runs a `@friggframework/core` version that
93
- includes this field and the app registers the integration classes.
146
+ - Report output is sensitive (integration types, counts, user IDs, versions).
147
+ Treat the admin API key as an admin secret.
148
+ - The `integrations` back-compat alias is transitional; migrate to
149
+ `POST /api/v2/reports/integrations/run { "mode": "live" }`.
150
+ - Labels only appear once the app registers the integration classes.
151
+ - A `schedule` block in a report's Definition only supplies the default scheduled
152
+ **mode**. It does not create a recurring trigger on its own — activate the
153
+ schedule with `PUT /api/v2/reports/:name/schedule`; the DB schedule
154
+ (`ScriptSchedule`) is the single source of truth.
@@ -0,0 +1,6 @@
1
+ const { IntegrationsReport } = require('./reports/integrations-report');
2
+
3
+ // Registered by the admin-scripts bootstrap when the app definition sets `admin.includeBuiltinReports`.
4
+ const BUILTIN_REPORTS = [IntegrationsReport];
5
+
6
+ module.exports = { BUILTIN_REPORTS };
@@ -1,17 +1,13 @@
1
- const { createReportingRouter } = require('./reporting-router');
1
+ const { ReportBase } = require('./report-base');
2
+ const { IntegrationsReport } = require('./reports/integrations-report');
3
+ const { BUILTIN_REPORTS } = require('./builtin-reports');
2
4
  const {
3
- createReportingRepository,
4
- ReportingRepositoryMongo,
5
- ReportingRepositoryPostgres,
6
- ReportingRepositoryDocumentDB,
7
- } = require('./repositories/reporting-repository-factory');
8
- const { ListIntegrationsReport } = require('./use-cases');
5
+ createReportCommands,
6
+ } = require('../application/commands/report-commands');
9
7
 
10
8
  module.exports = {
11
- createReportingRouter,
12
- createReportingRepository,
13
- ReportingRepositoryMongo,
14
- ReportingRepositoryPostgres,
15
- ReportingRepositoryDocumentDB,
16
- ListIntegrationsReport,
9
+ ReportBase,
10
+ IntegrationsReport,
11
+ BUILTIN_REPORTS,
12
+ createReportCommands,
17
13
  };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ReportBase — a report is an admin operation whose output is its payload.
3
+ *
4
+ * A report Definition declares:
5
+ * - runModes: allowed modes; the first is the default.
6
+ * 'live' — compute and return inline; persist nothing.
7
+ * 'recorded' — persist an execution record (input + results + logs).
8
+ * 'snapshot' — a recorded run tagged as a point in a named series.
9
+ * - output.format: 'json' returns inline; 'csv'|'pdf'|'zip' go to artifact storage.
10
+ * - schedule: optional recurring run via EventBridge.
11
+ *
12
+ * execute(frigg, params): `frigg` is the admin command bundle
13
+ * (=== this.context.commands); do data reads through it, never a repository.
14
+ */
15
+ class ReportBase {
16
+ static Definition = {
17
+ name: 'Report Name',
18
+ version: '0.0.0',
19
+ description: 'What this report computes',
20
+ source: 'USER_DEFINED', // 'BUILTIN' | 'USER_DEFINED'
21
+
22
+ runModes: ['live', 'recorded', 'snapshot'],
23
+ inputSchema: null,
24
+ outputSchema: null,
25
+ output: { format: 'json' },
26
+ schedule: { enabled: false, cron: null, mode: 'snapshot' },
27
+
28
+ config: {
29
+ timeout: 300000,
30
+ },
31
+
32
+ display: {
33
+ category: 'reporting',
34
+ icon: null,
35
+ },
36
+ };
37
+
38
+ constructor(params = {}) {
39
+ this.context = params.context || null;
40
+ this.executionId = params.executionId || null;
41
+ this.integrationFactory = params.integrationFactory || null;
42
+ }
43
+
44
+ async execute(frigg, params) {
45
+ throw new Error('ReportBase.execute() must be implemented by subclass');
46
+ }
47
+ }
48
+
49
+ module.exports = { ReportBase };
@@ -1,11 +1,11 @@
1
- const Boom = require('@hapi/boom');
1
+ const { ReportBase } = require('../report-base');
2
2
  const { CANONICAL_COUNTERS } = require('../../telemetry/canonical-counters');
3
+ const { loadAppDefinition } = require('../../handlers/app-definition-loader');
3
4
 
4
5
  const SCHEMA_VERSION = 1;
5
6
  const SERVICE = 'frigg-core-api';
6
7
 
7
- // Seeded so every known status appears (even at 0); unknown values added to
8
- // the schema later are still counted dynamically.
8
+ // Seeded so every known status appears in output even at count 0.
9
9
  const KNOWN_STATUSES = [
10
10
  'IN_CREATION',
11
11
  'ENABLED',
@@ -16,33 +16,37 @@ const KNOWN_STATUSES = [
16
16
  'DISABLED',
17
17
  ];
18
18
 
19
- class ListIntegrationsReport {
20
- constructor({
21
- reportingRepository,
22
- usageRepository,
23
- typeLabels = {},
24
- } = {}) {
25
- if (!reportingRepository) {
26
- throw new Error('reportingRepository is required');
27
- }
28
- if (!usageRepository) {
29
- throw new Error('usageRepository is required');
30
- }
31
- this.reportingRepository = reportingRepository;
32
- this.usageRepository = usageRepository;
33
- this.typeLabels = typeLabels;
34
- }
19
+ // IntegrationBase.Definition default — skip it so the slug is used instead.
20
+ const PLACEHOLDER_DISPLAY_NAME = 'Integration Name';
21
+
22
+ class IntegrationsReport extends ReportBase {
23
+ static Definition = {
24
+ name: 'integrations',
25
+ version: '1.0.0',
26
+ description: 'Integrations by status and type, with per-type usage counts',
27
+ source: 'BUILTIN',
28
+ runModes: ['live', 'recorded', 'snapshot'],
29
+ inputSchema: {
30
+ type: 'object',
31
+ additionalProperties: false,
32
+ properties: {
33
+ status: { type: 'string' },
34
+ type: { type: 'string' },
35
+ userId: { type: 'string' },
36
+ },
37
+ },
38
+ output: { format: 'json' },
39
+ schedule: { enabled: false, cron: null, mode: 'snapshot' },
40
+ display: { category: 'reporting', icon: null },
41
+ };
35
42
 
36
- async execute(query = {}) {
37
- const { status, type, userId } = this._validateQuery(query);
43
+ async execute(frigg, params = {}) {
44
+ const { status, type, userId } = this._validateQuery(params);
45
+ const typeLabels = buildTypeLabels();
38
46
 
39
- const rows = await this.reportingRepository.findIntegrationsForReport({
40
- status,
41
- userId,
42
- });
47
+ const rows = await frigg.integrations.listForReport({ status, userId });
43
48
 
44
- // type lives in config.type (a JSON path not portably groupable across
45
- // DBs), so it is filtered here rather than in the repository query.
49
+ // type lives in config.type, a JSON path not portably groupable across DBs, so filter here not in the query.
46
50
  const filtered =
47
51
  type === undefined
48
52
  ? rows
@@ -50,7 +54,7 @@ class ListIntegrationsReport {
50
54
 
51
55
  const ids = filtered.map((row) => row.id);
52
56
  const mappingCounts = ids.length
53
- ? await this.reportingRepository.countMappingsByIntegrationIds(ids)
57
+ ? await frigg.integrationMappings.countByIntegrationIds(ids)
54
58
  : new Map();
55
59
 
56
60
  const integrations = filtered.map((row) => ({
@@ -76,8 +80,7 @@ class ListIntegrationsReport {
76
80
  if (!byTypeMap.has(integration.type)) {
77
81
  byTypeMap.set(integration.type, {
78
82
  type: integration.type,
79
- label:
80
- this.typeLabels[integration.type] || integration.type,
83
+ label: typeLabels[integration.type] || integration.type,
81
84
  total: 0,
82
85
  byStatus: emptyStatusCounts(),
83
86
  });
@@ -87,7 +90,7 @@ class ListIntegrationsReport {
87
90
  bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
88
91
  }
89
92
 
90
- await this._attachUsageColumns(byTypeMap);
93
+ await this._attachUsageColumns(byTypeMap, frigg);
91
94
 
92
95
  return {
93
96
  schemaVersion: SCHEMA_VERSION,
@@ -102,18 +105,18 @@ class ListIntegrationsReport {
102
105
  total: integrations.length,
103
106
  byStatus,
104
107
  byType: Array.from(byTypeMap.values()),
105
- typeLabels: { ...this.typeLabels },
108
+ typeLabels: { ...typeLabels },
106
109
  integrations,
107
110
  },
108
111
  };
109
112
  }
110
113
 
111
- async _attachUsageColumns(byTypeMap) {
114
+ async _attachUsageColumns(byTypeMap, frigg) {
112
115
  const metrics = Object.keys(CANONICAL_COUNTERS);
113
116
  try {
114
117
  const totalsByMetric = await Promise.all(
115
118
  metrics.map(async (metric) => {
116
- const totals = await this.usageRepository.getTotalsByDimension({
119
+ const totals = await frigg.usage.getTotalsByDimension({
117
120
  metric,
118
121
  groupBy: 'integrationType',
119
122
  });
@@ -146,7 +149,7 @@ class ListIntegrationsReport {
146
149
  value !== null &&
147
150
  typeof value !== 'string'
148
151
  ) {
149
- throw Boom.badRequest(
152
+ throw invalidInput(
150
153
  `Invalid query parameter '${key}': expected a string`
151
154
  );
152
155
  }
@@ -158,7 +161,7 @@ class ListIntegrationsReport {
158
161
  userId: normalize(userId),
159
162
  };
160
163
  if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
161
- throw Boom.badRequest(
164
+ throw invalidInput(
162
165
  `Invalid status '${
163
166
  normalized.status
164
167
  }'. Expected one of: ${KNOWN_STATUSES.join(', ')}`
@@ -168,6 +171,13 @@ class ListIntegrationsReport {
168
171
  }
169
172
  }
170
173
 
174
+ // INVALID_INPUT keeps the report protocol-agnostic; the runner/router map it to a 400.
175
+ function invalidInput(message) {
176
+ const error = new Error(message);
177
+ error.code = 'INVALID_INPUT';
178
+ return error;
179
+ }
180
+
171
181
  function emptyStatusCounts() {
172
182
  return KNOWN_STATUSES.reduce((acc, status) => {
173
183
  acc[status] = 0;
@@ -186,4 +196,26 @@ function toIso(value) {
186
196
  return String(value);
187
197
  }
188
198
 
189
- module.exports = { ListIntegrationsReport, SCHEMA_VERSION };
199
+ function buildTypeLabels() {
200
+ try {
201
+ const { integrations = [] } = loadAppDefinition();
202
+ const labels = {};
203
+ for (const IntegrationClass of integrations) {
204
+ const def = IntegrationClass?.Definition;
205
+ if (!def?.name) continue;
206
+ const label = def.display?.label;
207
+ if (label && label !== PLACEHOLDER_DISPLAY_NAME) {
208
+ labels[def.name] = label;
209
+ }
210
+ }
211
+ return labels;
212
+ } catch (error) {
213
+ console.error(
214
+ 'Reporting: failed to load integration labels:',
215
+ error.message
216
+ );
217
+ return {};
218
+ }
219
+ }
220
+
221
+ module.exports = { IntegrationsReport, SCHEMA_VERSION };
@@ -1,9 +0,0 @@
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 };