@friggframework/core 2.0.0-next.94 → 2.0.0-next.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ const { createReportingRouter } = require('@friggframework/core');
2
+ const { createAppHandler } = require('./../app-handler-helpers');
3
+
4
+ const router = createReportingRouter();
5
+
6
+ // true → eager-connect Prisma; the reporting endpoints read the DB.
7
+ const handler = createAppHandler('HTTP Event: Reporting', router, true);
8
+
9
+ module.exports = { handler, router };
package/index.js CHANGED
@@ -71,6 +71,10 @@ const {
71
71
  getModulesDefinitionFromIntegrationClasses,
72
72
  LoadIntegrationContextUseCase,
73
73
  } = require('./integrations/index');
74
+ const {
75
+ createReportingRouter,
76
+ createReportingRepository,
77
+ } = require('./reporting/index');
74
78
  const { TimeoutCatcher } = require('./lambda/index');
75
79
  const { debug, initDebugLog, flushDebugLog } = require('./logs/index');
76
80
  const {
@@ -139,6 +143,10 @@ module.exports = {
139
143
  UpdateProcessMetrics,
140
144
  GetProcess,
141
145
 
146
+ // reporting
147
+ createReportingRouter,
148
+ createReportingRepository,
149
+
142
150
  // application - Command factories for integration developers
143
151
  application,
144
152
  createFriggCommands: application.createFriggCommands,
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.94",
4
+ "version": "2.0.0-next.95",
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.94",
42
- "@friggframework/prettier-config": "2.0.0-next.94",
43
- "@friggframework/test": "2.0.0-next.94",
41
+ "@friggframework/eslint-config": "2.0.0-next.95",
42
+ "@friggframework/prettier-config": "2.0.0-next.95",
43
+ "@friggframework/test": "2.0.0-next.95",
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": "c56f6a892e1861d5f495bf15d1008834a9258a1d"
83
+ "gitHead": "18d7e66ce7f397ad6e96d9006d81118cd96445ec"
84
84
  }
@@ -0,0 +1,17 @@
1
+ const { createReportingRouter } = require('./reporting-router');
2
+ const {
3
+ createReportingRepository,
4
+ ReportingRepositoryMongo,
5
+ ReportingRepositoryPostgres,
6
+ ReportingRepositoryDocumentDB,
7
+ } = require('./repositories/reporting-repository-factory');
8
+ const { ListIntegrationsReport } = require('./use-cases');
9
+
10
+ module.exports = {
11
+ createReportingRouter,
12
+ createReportingRepository,
13
+ ReportingRepositoryMongo,
14
+ ReportingRepositoryPostgres,
15
+ ReportingRepositoryDocumentDB,
16
+ ListIntegrationsReport,
17
+ };
@@ -0,0 +1,48 @@
1
+ const express = require('express');
2
+ const catchAsyncError = require('express-async-handler');
3
+ const {
4
+ createReportingRepository,
5
+ } = require('./repositories/reporting-repository-factory');
6
+ const { ListIntegrationsReport } = require('./use-cases/list-integrations-report');
7
+
8
+ function createReportingRouter() {
9
+ const reportingRepository = createReportingRepository();
10
+ const listIntegrationsReport = new ListIntegrationsReport({
11
+ reportingRepository,
12
+ });
13
+
14
+ const router = express.Router();
15
+ router.use(validateApiKey);
16
+
17
+ router.get('/api/v2/reports', (_req, res) => {
18
+ res.json({ service: 'frigg-core-api', reports: ['integrations'] });
19
+ });
20
+
21
+ router.get(
22
+ '/api/v2/reports/integrations',
23
+ catchAsyncError(async (req, res) => {
24
+ const { status, type, userId } = req.query;
25
+ res.json(
26
+ await listIntegrationsReport.execute({ status, type, userId })
27
+ );
28
+ })
29
+ );
30
+
31
+ return router;
32
+ }
33
+
34
+ function validateApiKey(req, res, next) {
35
+ const apiKey = req.headers['x-frigg-reporting-api-key'];
36
+
37
+ if (!apiKey || apiKey !== process.env.REPORTING_API_KEY) {
38
+ console.error('Unauthorized access attempt to reporting endpoint');
39
+ return res.status(401).json({
40
+ status: 'error',
41
+ message: 'Unauthorized - x-frigg-reporting-api-key header required',
42
+ });
43
+ }
44
+
45
+ next();
46
+ }
47
+
48
+ module.exports = { createReportingRouter, validateApiKey };
@@ -0,0 +1,127 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const { toObjectId, fromObjectId } = require('../../database/documentdb-utils');
3
+ const {
4
+ ReportingRepositoryInterface,
5
+ } = require('./reporting-repository-interface');
6
+
7
+ const DRAIN_BATCH_SIZE = 1000;
8
+ const MAX_BATCHES = 100000;
9
+
10
+ // Drains cursors via getMore rather than reusing documentdb-utils.findMany/
11
+ // aggregate, which return only the first batch (~101 docs) and would silently
12
+ // truncate a deployment-wide report.
13
+ class ReportingRepositoryDocumentDB extends ReportingRepositoryInterface {
14
+ constructor() {
15
+ super();
16
+ this.prisma = prisma;
17
+ }
18
+
19
+ async findIntegrationsForReport({ status, userId } = {}) {
20
+ const filter = {};
21
+ if (status) filter.status = status;
22
+ if (userId !== undefined && userId !== null) {
23
+ const objectId = toObjectId(userId);
24
+ // An invalid userId means no matches — must not fall through to an
25
+ // unfiltered query that returns the whole deployment.
26
+ if (!objectId) return [];
27
+ filter.userId = objectId;
28
+ }
29
+
30
+ const docs = await this._findDrained('Integration', filter);
31
+
32
+ return docs.map((doc) => {
33
+ const errors = this._extractErrors(doc);
34
+ return {
35
+ id: fromObjectId(doc?._id),
36
+ type: doc?.config?.type ?? null,
37
+ status: doc?.status ?? null,
38
+ userId: fromObjectId(doc?.userId) ?? null,
39
+ version: doc?.version ?? null,
40
+ errorCount: Array.isArray(errors) ? errors.length : 0,
41
+ moduleCount: Array.isArray(doc?.entityIds)
42
+ ? doc.entityIds.length
43
+ : 0,
44
+ createdAt: doc?.createdAt ?? null,
45
+ updatedAt: doc?.updatedAt ?? null,
46
+ };
47
+ });
48
+ }
49
+
50
+ async countMappingsByIntegrationIds(ids = []) {
51
+ const counts = new Map();
52
+ if (!ids || ids.length === 0) return counts;
53
+
54
+ // IntegrationMapping.integrationId is stored as a string in DocumentDB,
55
+ // so match by string — an ObjectId $in would never match (always 0).
56
+ const stringIds = ids.map(String);
57
+
58
+ const rows = await this._aggregateDrained('IntegrationMapping', [
59
+ { $match: { integrationId: { $in: stringIds } } },
60
+ { $group: { _id: '$integrationId', count: { $sum: 1 } } },
61
+ ]);
62
+
63
+ for (const row of rows) {
64
+ counts.set(String(row?._id), row?.count ?? 0);
65
+ }
66
+ return counts;
67
+ }
68
+
69
+ async _findDrained(collection, filter) {
70
+ const first = await this.prisma.$runCommandRaw({
71
+ find: collection,
72
+ filter,
73
+ batchSize: DRAIN_BATCH_SIZE,
74
+ });
75
+ return this._drain(collection, first);
76
+ }
77
+
78
+ async _aggregateDrained(collection, pipeline) {
79
+ const first = await this.prisma.$runCommandRaw({
80
+ aggregate: collection,
81
+ pipeline,
82
+ cursor: { batchSize: DRAIN_BATCH_SIZE },
83
+ });
84
+ return this._drain(collection, first);
85
+ }
86
+
87
+ async _drain(collection, firstResult) {
88
+ const cursor = firstResult?.cursor || {};
89
+ const docs = [...(cursor.firstBatch || [])];
90
+ let cursorId = cursor.id;
91
+ let batches = 0;
92
+
93
+ while (this._cursorOpen(cursorId) && batches < MAX_BATCHES) {
94
+ batches += 1;
95
+ const next = await this.prisma.$runCommandRaw({
96
+ getMore: cursorId,
97
+ collection,
98
+ batchSize: DRAIN_BATCH_SIZE,
99
+ });
100
+ const nextCursor = next?.cursor || {};
101
+ const nextBatch = nextCursor.nextBatch || [];
102
+ docs.push(...nextBatch);
103
+ cursorId = nextCursor.id;
104
+ if (nextBatch.length === 0) break;
105
+ }
106
+ return docs;
107
+ }
108
+
109
+ _cursorOpen(id) {
110
+ if (id === undefined || id === null) return false;
111
+ if (typeof id === 'number') return id !== 0;
112
+ if (typeof id === 'bigint') return id !== 0n;
113
+ // Extended JSON can surface a 64-bit cursor id as { $numberLong: "..." }.
114
+ if (typeof id === 'object' && id.$numberLong !== undefined) {
115
+ return id.$numberLong !== '0';
116
+ }
117
+ return String(id) !== '0';
118
+ }
119
+
120
+ _extractErrors(doc) {
121
+ if (Array.isArray(doc?.errors)) return doc.errors;
122
+ if (Array.isArray(doc?.messages?.errors)) return doc.messages.errors;
123
+ return [];
124
+ }
125
+ }
126
+
127
+ module.exports = { ReportingRepositoryDocumentDB };
@@ -0,0 +1,35 @@
1
+ const { ReportingRepositoryMongo } = require('./reporting-repository-mongo');
2
+ const {
3
+ ReportingRepositoryPostgres,
4
+ } = require('./reporting-repository-postgres');
5
+ const {
6
+ ReportingRepositoryDocumentDB,
7
+ } = require('./reporting-repository-documentdb');
8
+ const config = require('../../database/config');
9
+
10
+ function createReportingRepository() {
11
+ const dbType = config.DB_TYPE;
12
+
13
+ switch (dbType) {
14
+ case 'mongodb':
15
+ return new ReportingRepositoryMongo();
16
+
17
+ case 'postgresql':
18
+ return new ReportingRepositoryPostgres();
19
+
20
+ case 'documentdb':
21
+ return new ReportingRepositoryDocumentDB();
22
+
23
+ default:
24
+ throw new Error(
25
+ `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'`
26
+ );
27
+ }
28
+ }
29
+
30
+ module.exports = {
31
+ createReportingRepository,
32
+ ReportingRepositoryMongo,
33
+ ReportingRepositoryPostgres,
34
+ ReportingRepositoryDocumentDB,
35
+ };
@@ -0,0 +1,16 @@
1
+ class ReportingRepositoryInterface {
2
+ // returns: [{ id, type, status, userId, version, errorCount, moduleCount, createdAt, updatedAt }]
3
+ async findIntegrationsForReport(filter) {
4
+ throw new Error(
5
+ 'Method findIntegrationsForReport must be implemented by subclass'
6
+ );
7
+ }
8
+
9
+ async countMappingsByIntegrationIds(ids) {
10
+ throw new Error(
11
+ 'Method countMappingsByIntegrationIds must be implemented by subclass'
12
+ );
13
+ }
14
+ }
15
+
16
+ module.exports = { ReportingRepositoryInterface };
@@ -0,0 +1,54 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const {
3
+ ReportingRepositoryInterface,
4
+ } = require('./reporting-repository-interface');
5
+
6
+ class ReportingRepositoryMongo extends ReportingRepositoryInterface {
7
+ constructor() {
8
+ super();
9
+ this.prisma = prisma;
10
+ }
11
+
12
+ async findIntegrationsForReport({ status, userId } = {}) {
13
+ const where = {};
14
+ if (status) where.status = status;
15
+ if (userId !== undefined && userId !== null) where.userId = userId;
16
+
17
+ const integrations = await this.prisma.integration.findMany({
18
+ where,
19
+ include: { entities: { select: { id: true } } },
20
+ });
21
+
22
+ return integrations.map((integration) => ({
23
+ id: integration.id,
24
+ type: integration.config?.type ?? null,
25
+ status: integration.status ?? null,
26
+ userId: integration.userId ?? null,
27
+ version: integration.version ?? null,
28
+ errorCount: Array.isArray(integration.errors)
29
+ ? integration.errors.length
30
+ : 0,
31
+ moduleCount: integration.entities?.length ?? 0,
32
+ createdAt: integration.createdAt ?? null,
33
+ updatedAt: integration.updatedAt ?? null,
34
+ }));
35
+ }
36
+
37
+ async countMappingsByIntegrationIds(ids = []) {
38
+ const counts = new Map();
39
+ if (!ids || ids.length === 0) return counts;
40
+
41
+ const groups = await this.prisma.integrationMapping.groupBy({
42
+ by: ['integrationId'],
43
+ where: { integrationId: { in: ids } },
44
+ _count: { _all: true },
45
+ });
46
+
47
+ for (const group of groups) {
48
+ counts.set(group.integrationId, group._count._all);
49
+ }
50
+ return counts;
51
+ }
52
+ }
53
+
54
+ module.exports = { ReportingRepositoryMongo };
@@ -0,0 +1,70 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const {
3
+ ReportingRepositoryInterface,
4
+ } = require('./reporting-repository-interface');
5
+
6
+ class ReportingRepositoryPostgres extends ReportingRepositoryInterface {
7
+ constructor() {
8
+ super();
9
+ this.prisma = prisma;
10
+ }
11
+
12
+ async findIntegrationsForReport({ status, userId } = {}) {
13
+ const where = {};
14
+ if (status) where.status = status;
15
+ if (userId !== undefined && userId !== null) {
16
+ where.userId = this._convertId(userId);
17
+ }
18
+
19
+ const integrations = await this.prisma.integration.findMany({
20
+ where,
21
+ include: { entities: { select: { id: true } } },
22
+ });
23
+
24
+ return integrations.map((integration) => ({
25
+ id: integration.id?.toString(),
26
+ type: integration.config?.type ?? null,
27
+ status: integration.status ?? null,
28
+ userId: integration.userId?.toString() ?? null,
29
+ version: integration.version ?? null,
30
+ errorCount: Array.isArray(integration.errors)
31
+ ? integration.errors.length
32
+ : 0,
33
+ moduleCount: integration.entities?.length ?? 0,
34
+ createdAt: integration.createdAt ?? null,
35
+ updatedAt: integration.updatedAt ?? null,
36
+ }));
37
+ }
38
+
39
+ async countMappingsByIntegrationIds(ids = []) {
40
+ const counts = new Map();
41
+ if (!ids || ids.length === 0) return counts;
42
+
43
+ const intIds = ids.map((id) => this._convertId(id));
44
+ const groups = await this.prisma.integrationMapping.groupBy({
45
+ by: ['integrationId'],
46
+ where: { integrationId: { in: intIds } },
47
+ _count: { _all: true },
48
+ });
49
+
50
+ for (const group of groups) {
51
+ counts.set(group.integrationId?.toString(), group._count._all);
52
+ }
53
+ return counts;
54
+ }
55
+
56
+ _convertId(id) {
57
+ if (id === null || id === undefined) return id;
58
+ // Reject anything that isn't an exact integer — parseInt would coerce
59
+ // '12abc'/'12.9' to 12 and return the wrong record.
60
+ const str = String(id).trim();
61
+ if (!/^-?\d+$/.test(str)) {
62
+ throw new TypeError(
63
+ `Invalid ID: ${id} cannot be converted to integer`
64
+ );
65
+ }
66
+ return Number.parseInt(str, 10);
67
+ }
68
+ }
69
+
70
+ module.exports = { ReportingRepositoryPostgres };
@@ -0,0 +1,6 @@
1
+ const {
2
+ ListIntegrationsReport,
3
+ SCHEMA_VERSION,
4
+ } = require('./list-integrations-report');
5
+
6
+ module.exports = { ListIntegrationsReport, SCHEMA_VERSION };
@@ -0,0 +1,137 @@
1
+ const Boom = require('@hapi/boom');
2
+
3
+ const SCHEMA_VERSION = 1;
4
+ const SERVICE = 'frigg-core-api';
5
+
6
+ // Seeded so every known status appears (even at 0); unknown values added to
7
+ // the schema later are still counted dynamically.
8
+ const KNOWN_STATUSES = [
9
+ 'ENABLED',
10
+ 'ERROR',
11
+ 'NEEDS_CONFIG',
12
+ 'PROCESSING',
13
+ 'DISABLED',
14
+ ];
15
+
16
+ class ListIntegrationsReport {
17
+ constructor({ reportingRepository } = {}) {
18
+ if (!reportingRepository) {
19
+ throw new Error('reportingRepository is required');
20
+ }
21
+ this.reportingRepository = reportingRepository;
22
+ }
23
+
24
+ async execute(query = {}) {
25
+ const { status, type, userId } = this._validateQuery(query);
26
+
27
+ const rows = await this.reportingRepository.findIntegrationsForReport({
28
+ status,
29
+ userId,
30
+ });
31
+
32
+ // type lives in config.type (a JSON path not portably groupable across
33
+ // DBs), so it is filtered here rather than in the repository query.
34
+ const filtered =
35
+ type === undefined
36
+ ? rows
37
+ : rows.filter((row) => (row.type ?? 'unknown') === type);
38
+
39
+ const ids = filtered.map((row) => row.id);
40
+ const mappingCounts = ids.length
41
+ ? await this.reportingRepository.countMappingsByIntegrationIds(ids)
42
+ : new Map();
43
+
44
+ const integrations = filtered.map((row) => ({
45
+ id: row.id,
46
+ type: row.type ?? 'unknown',
47
+ status: row.status ?? null,
48
+ userId: row.userId ?? null,
49
+ version: row.version ?? null,
50
+ moduleCount: row.moduleCount ?? 0,
51
+ errorCount: row.errorCount ?? 0,
52
+ mappedRecordCount: mappingCounts.get(row.id) ?? 0,
53
+ createdAt: toIso(row.createdAt),
54
+ updatedAt: toIso(row.updatedAt),
55
+ }));
56
+
57
+ const byStatus = emptyStatusCounts();
58
+ const byTypeMap = new Map();
59
+ for (const integration of integrations) {
60
+ // sentinel so a missing status never becomes a literal "null" key
61
+ const statusKey = integration.status ?? 'UNKNOWN';
62
+ byStatus[statusKey] = (byStatus[statusKey] ?? 0) + 1;
63
+
64
+ if (!byTypeMap.has(integration.type)) {
65
+ byTypeMap.set(integration.type, {
66
+ type: integration.type,
67
+ total: 0,
68
+ byStatus: emptyStatusCounts(),
69
+ });
70
+ }
71
+ const bucket = byTypeMap.get(integration.type);
72
+ bucket.total += 1;
73
+ bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
74
+ }
75
+
76
+ return {
77
+ schemaVersion: SCHEMA_VERSION,
78
+ service: SERVICE,
79
+ generatedAt: new Date().toISOString(),
80
+ filters: {
81
+ status: status ?? null,
82
+ type: type ?? null,
83
+ userId: userId ?? null,
84
+ },
85
+ metrics: {
86
+ total: integrations.length,
87
+ byStatus,
88
+ byType: Array.from(byTypeMap.values()),
89
+ integrations,
90
+ },
91
+ };
92
+ }
93
+
94
+ _validateQuery({ status, type, userId } = {}) {
95
+ for (const [key, value] of Object.entries({ status, type, userId })) {
96
+ if (value !== undefined && value !== null && typeof value !== 'string') {
97
+ throw Boom.badRequest(
98
+ `Invalid query parameter '${key}': expected a string`
99
+ );
100
+ }
101
+ }
102
+ const normalize = (value) => value || undefined;
103
+ const normalized = {
104
+ status: normalize(status),
105
+ type: normalize(type),
106
+ userId: normalize(userId),
107
+ };
108
+ if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
109
+ throw Boom.badRequest(
110
+ `Invalid status '${normalized.status}'. Expected one of: ${KNOWN_STATUSES.join(
111
+ ', '
112
+ )}`
113
+ );
114
+ }
115
+ return normalized;
116
+ }
117
+ }
118
+
119
+ function emptyStatusCounts() {
120
+ return KNOWN_STATUSES.reduce((acc, status) => {
121
+ acc[status] = 0;
122
+ return acc;
123
+ }, {});
124
+ }
125
+
126
+ function toIso(value) {
127
+ if (!value) return null;
128
+ if (value instanceof Date) return value.toISOString();
129
+ // DocumentDB raw reads surface dates as { $date: ... }
130
+ if (typeof value === 'object' && value.$date) {
131
+ const date = new Date(value.$date);
132
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
133
+ }
134
+ return String(value);
135
+ }
136
+
137
+ module.exports = { ListIntegrationsReport, SCHEMA_VERSION };