fraim 2.0.361 → 2.0.362-next.2
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.
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setAnalyticsDatabase = setAnalyticsDatabase;
|
|
6
7
|
const license_presentation_1 = require("../fraim/license-presentation");
|
|
7
8
|
const express_1 = __importDefault(require("express"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
@@ -11,12 +12,59 @@ const db_service_js_1 = require("../fraim/db-service.js");
|
|
|
11
12
|
const mcp_service_js_1 = require("../services/mcp-service.js");
|
|
12
13
|
const git_utils_js_1 = require("../core/utils/git-utils.js");
|
|
13
14
|
const job_phases_1 = require("../core/job-phases");
|
|
15
|
+
const analytics_read_1 = require("../services/analytics-read");
|
|
14
16
|
const router = express_1.default.Router();
|
|
17
|
+
const readQueue = new analytics_read_1.AnalyticsReadQueue();
|
|
18
|
+
let analyticsDatabase;
|
|
19
|
+
let sharedAnalyticsService;
|
|
20
|
+
function setAnalyticsDatabase(database) {
|
|
21
|
+
analyticsDatabase = database;
|
|
22
|
+
sharedAnalyticsService = undefined;
|
|
23
|
+
}
|
|
24
|
+
function getAnalyticsDatabase() {
|
|
25
|
+
if (!analyticsDatabase)
|
|
26
|
+
throw new Error('Analytics database was not initialized at server startup');
|
|
27
|
+
return analyticsDatabase;
|
|
28
|
+
}
|
|
29
|
+
function getAnalyticsService() {
|
|
30
|
+
return sharedAnalyticsService ??= new usage_analytics_service_js_1.UsageAnalyticsService(getAnalyticsDatabase());
|
|
31
|
+
}
|
|
32
|
+
function analyticsGet(route, handler) {
|
|
33
|
+
router.get(route, async (req, res) => {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
try {
|
|
36
|
+
await readQueue.run(async () => {
|
|
37
|
+
if (req.destroyed || res.destroyed)
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
return await handler(req, res);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!['MongoNetworkError', 'MongoServerSelectionError', 'MongoNotConnectedError', 'MongoTopologyClosedError'].includes(error.name))
|
|
44
|
+
throw error;
|
|
45
|
+
await getAnalyticsDatabase().recoverConnection();
|
|
46
|
+
return handler(req, res);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (res.headersSent || res.destroyed)
|
|
52
|
+
return;
|
|
53
|
+
const { status, headers, body } = (0, analytics_read_1.analyticsReadErrorResponse)(error);
|
|
54
|
+
if (status === 500)
|
|
55
|
+
console.error('[analytics] Read failed:', error);
|
|
56
|
+
res.set(headers).status(status).json(body);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
console.info(`[analytics] endpoint=${route} aggregation_ms=${Date.now() - started} status=${res.statusCode}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
15
63
|
/**
|
|
16
64
|
* GET /api/analytics/stats
|
|
17
65
|
* Get usage statistics for a time period
|
|
18
66
|
*/
|
|
19
|
-
|
|
67
|
+
analyticsGet('/stats', async (req, res) => {
|
|
20
68
|
let dbService;
|
|
21
69
|
let analyticsService;
|
|
22
70
|
try {
|
|
@@ -26,10 +74,9 @@ router.get('/stats', async (req, res) => {
|
|
|
26
74
|
return res.status(401).json({ error: 'Authentication required' });
|
|
27
75
|
}
|
|
28
76
|
// Create database connection
|
|
29
|
-
dbService =
|
|
30
|
-
await dbService.connect();
|
|
77
|
+
dbService = getAnalyticsDatabase();
|
|
31
78
|
// Create analytics service
|
|
32
|
-
analyticsService =
|
|
79
|
+
analyticsService = getAnalyticsService();
|
|
33
80
|
const { type, apiKey, repoIdentifier } = req.query;
|
|
34
81
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
35
82
|
const types = type ? (Array.isArray(type) ? type : [type]) : undefined;
|
|
@@ -59,20 +106,14 @@ router.get('/stats', async (req, res) => {
|
|
|
59
106
|
res.json(stats);
|
|
60
107
|
}
|
|
61
108
|
catch (error) {
|
|
62
|
-
|
|
63
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
64
|
-
}
|
|
65
|
-
finally {
|
|
66
|
-
if (dbService) {
|
|
67
|
-
await dbService.close();
|
|
68
|
-
}
|
|
109
|
+
throw error;
|
|
69
110
|
}
|
|
70
111
|
});
|
|
71
112
|
/**
|
|
72
113
|
* GET /api/analytics/components/top
|
|
73
114
|
* Get top components by usage count
|
|
74
115
|
*/
|
|
75
|
-
|
|
116
|
+
analyticsGet('/components/top', async (req, res) => {
|
|
76
117
|
let dbService;
|
|
77
118
|
let analyticsService;
|
|
78
119
|
try {
|
|
@@ -82,10 +123,9 @@ router.get('/components/top', async (req, res) => {
|
|
|
82
123
|
return res.status(401).json({ error: 'Authentication required' });
|
|
83
124
|
}
|
|
84
125
|
// Create database connection
|
|
85
|
-
dbService =
|
|
86
|
-
await dbService.connect();
|
|
126
|
+
dbService = getAnalyticsDatabase();
|
|
87
127
|
// Create analytics service
|
|
88
|
-
analyticsService =
|
|
128
|
+
analyticsService = getAnalyticsService();
|
|
89
129
|
const { limit = '10', apiKey, type, repoIdentifier } = req.query;
|
|
90
130
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
91
131
|
const types = type ? (Array.isArray(type) ? type : [type]) : undefined;
|
|
@@ -108,20 +148,14 @@ router.get('/components/top', async (req, res) => {
|
|
|
108
148
|
res.json(components);
|
|
109
149
|
}
|
|
110
150
|
catch (error) {
|
|
111
|
-
|
|
112
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
113
|
-
}
|
|
114
|
-
finally {
|
|
115
|
-
if (dbService) {
|
|
116
|
-
await dbService.close();
|
|
117
|
-
}
|
|
151
|
+
throw error;
|
|
118
152
|
}
|
|
119
153
|
});
|
|
120
154
|
/**
|
|
121
155
|
* GET /api/analytics/trends
|
|
122
156
|
* Get trend data for a specific component
|
|
123
157
|
*/
|
|
124
|
-
|
|
158
|
+
analyticsGet('/trends', async (req, res) => {
|
|
125
159
|
let dbService;
|
|
126
160
|
let analyticsService;
|
|
127
161
|
try {
|
|
@@ -136,10 +170,9 @@ router.get('/trends', async (req, res) => {
|
|
|
136
170
|
return res.status(400).json({ error: 'Component name and type are required' });
|
|
137
171
|
}
|
|
138
172
|
// Create database connection
|
|
139
|
-
dbService =
|
|
140
|
-
await dbService.connect();
|
|
173
|
+
dbService = getAnalyticsDatabase();
|
|
141
174
|
// Create analytics service
|
|
142
|
-
analyticsService =
|
|
175
|
+
analyticsService = getAnalyticsService();
|
|
143
176
|
let targetUserId;
|
|
144
177
|
if (apiKey && typeof apiKey === 'string') {
|
|
145
178
|
const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
|
|
@@ -159,20 +192,14 @@ router.get('/trends', async (req, res) => {
|
|
|
159
192
|
res.json(trend);
|
|
160
193
|
}
|
|
161
194
|
catch (error) {
|
|
162
|
-
|
|
163
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
164
|
-
}
|
|
165
|
-
finally {
|
|
166
|
-
if (dbService) {
|
|
167
|
-
await dbService.close();
|
|
168
|
-
}
|
|
195
|
+
throw error;
|
|
169
196
|
}
|
|
170
197
|
});
|
|
171
198
|
/**
|
|
172
199
|
* GET /api/analytics/jobs/timeline
|
|
173
200
|
* Get job execution timeline for the bubble chart
|
|
174
201
|
*/
|
|
175
|
-
|
|
202
|
+
analyticsGet('/jobs/timeline', async (req, res) => {
|
|
176
203
|
let dbService;
|
|
177
204
|
let analyticsService;
|
|
178
205
|
try {
|
|
@@ -182,9 +209,8 @@ router.get('/jobs/timeline', async (req, res) => {
|
|
|
182
209
|
}
|
|
183
210
|
const { apiKey, dateUnit, repoIdentifier } = req.query;
|
|
184
211
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
185
|
-
dbService =
|
|
186
|
-
|
|
187
|
-
analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
|
|
212
|
+
dbService = getAnalyticsDatabase();
|
|
213
|
+
analyticsService = getAnalyticsService();
|
|
188
214
|
let targetUserId;
|
|
189
215
|
if (apiKey && typeof apiKey === 'string') {
|
|
190
216
|
const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
|
|
@@ -204,20 +230,14 @@ router.get('/jobs/timeline', async (req, res) => {
|
|
|
204
230
|
res.json(timeline);
|
|
205
231
|
}
|
|
206
232
|
catch (error) {
|
|
207
|
-
|
|
208
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
209
|
-
}
|
|
210
|
-
finally {
|
|
211
|
-
if (dbService) {
|
|
212
|
-
await dbService.close();
|
|
213
|
-
}
|
|
233
|
+
throw error;
|
|
214
234
|
}
|
|
215
235
|
});
|
|
216
236
|
/**
|
|
217
237
|
* GET /api/analytics/jobs/completion
|
|
218
238
|
* Get job completion metrics for analytics dashboard
|
|
219
239
|
*/
|
|
220
|
-
|
|
240
|
+
analyticsGet('/jobs/completion', async (req, res) => {
|
|
221
241
|
let dbService;
|
|
222
242
|
let analyticsService;
|
|
223
243
|
try {
|
|
@@ -227,9 +247,8 @@ router.get('/jobs/completion', async (req, res) => {
|
|
|
227
247
|
}
|
|
228
248
|
const { jobName, apiKey, repoIdentifier } = req.query;
|
|
229
249
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
230
|
-
dbService =
|
|
231
|
-
|
|
232
|
-
analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
|
|
250
|
+
dbService = getAnalyticsDatabase();
|
|
251
|
+
analyticsService = getAnalyticsService();
|
|
233
252
|
let targetUserId;
|
|
234
253
|
if (apiKey && typeof apiKey === 'string') {
|
|
235
254
|
const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
|
|
@@ -250,33 +269,26 @@ router.get('/jobs/completion', async (req, res) => {
|
|
|
250
269
|
res.json(metrics);
|
|
251
270
|
}
|
|
252
271
|
catch (error) {
|
|
253
|
-
|
|
254
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
255
|
-
}
|
|
256
|
-
finally {
|
|
257
|
-
if (dbService) {
|
|
258
|
-
await dbService.close();
|
|
259
|
-
}
|
|
272
|
+
throw error;
|
|
260
273
|
}
|
|
261
274
|
});
|
|
262
275
|
/**
|
|
263
276
|
* GET /api/analytics/jobs/runs
|
|
264
277
|
* Get recent job runs for a specific job name (for clickable table rows)
|
|
265
278
|
*/
|
|
266
|
-
|
|
279
|
+
analyticsGet('/jobs/runs', async (req, res) => {
|
|
267
280
|
let dbService;
|
|
268
281
|
try {
|
|
269
282
|
const apiKeyData = req.apiKeyData;
|
|
270
283
|
if (!apiKeyData) {
|
|
271
284
|
return res.status(401).json({ error: 'Authentication required' });
|
|
272
285
|
}
|
|
273
|
-
const {
|
|
286
|
+
const { jobName, limit = '50', repoIdentifier } = req.query;
|
|
274
287
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
275
288
|
if (!jobName || typeof jobName !== 'string') {
|
|
276
289
|
return res.status(400).json({ error: 'jobName query parameter is required' });
|
|
277
290
|
}
|
|
278
|
-
dbService =
|
|
279
|
-
await dbService.connect();
|
|
291
|
+
dbService = getAnalyticsDatabase();
|
|
280
292
|
const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
|
|
281
293
|
const timeRangeDays = parsePeriodDays(timeframe.period);
|
|
282
294
|
const startDate = timeframe.startDate ?? new Date(Date.now() - timeRangeDays * 24 * 60 * 60 * 1000);
|
|
@@ -284,24 +296,14 @@ router.get('/jobs/runs', async (req, res) => {
|
|
|
284
296
|
res.json(runs);
|
|
285
297
|
}
|
|
286
298
|
catch (error) {
|
|
287
|
-
|
|
288
|
-
return res.status(403).json({ error: error.message });
|
|
289
|
-
if (error.status === 404)
|
|
290
|
-
return res.status(404).json({ error: error.message });
|
|
291
|
-
console.error('Error fetching job runs:', error);
|
|
292
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
293
|
-
}
|
|
294
|
-
finally {
|
|
295
|
-
if (dbService) {
|
|
296
|
-
await dbService.close();
|
|
297
|
-
}
|
|
299
|
+
throw error;
|
|
298
300
|
}
|
|
299
301
|
});
|
|
300
302
|
/**
|
|
301
303
|
* GET /api/analytics/jobs/run/:jobId/timeline
|
|
302
304
|
* Get phase-by-phase timeline for a specific job run
|
|
303
305
|
*/
|
|
304
|
-
|
|
306
|
+
analyticsGet('/jobs/run/:jobId/timeline', async (req, res) => {
|
|
305
307
|
let dbService;
|
|
306
308
|
try {
|
|
307
309
|
const apiKeyData = req.apiKeyData;
|
|
@@ -312,8 +314,7 @@ router.get('/jobs/run/:jobId/timeline', async (req, res) => {
|
|
|
312
314
|
if (!jobId) {
|
|
313
315
|
return res.status(400).json({ error: 'jobId parameter is required' });
|
|
314
316
|
}
|
|
315
|
-
dbService =
|
|
316
|
-
await dbService.connect();
|
|
317
|
+
dbService = getAnalyticsDatabase();
|
|
317
318
|
const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
|
|
318
319
|
const jobName = req.query.jobName;
|
|
319
320
|
const events = await dbService.getJobRunTimeline(jobId, targetUserId, jobName);
|
|
@@ -330,24 +331,14 @@ router.get('/jobs/run/:jobId/timeline', async (req, res) => {
|
|
|
330
331
|
res.json(timeline);
|
|
331
332
|
}
|
|
332
333
|
catch (error) {
|
|
333
|
-
|
|
334
|
-
return res.status(403).json({ error: error.message });
|
|
335
|
-
if (error.status === 404)
|
|
336
|
-
return res.status(404).json({ error: error.message });
|
|
337
|
-
console.error('Error fetching job run timeline:', error);
|
|
338
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
339
|
-
}
|
|
340
|
-
finally {
|
|
341
|
-
if (dbService) {
|
|
342
|
-
await dbService.close();
|
|
343
|
-
}
|
|
334
|
+
throw error;
|
|
344
335
|
}
|
|
345
336
|
});
|
|
346
337
|
/**
|
|
347
338
|
* GET /api/analytics/jobs/:jobName/phases
|
|
348
339
|
* Get the declared FRAIM phase sequence for a job in registry order.
|
|
349
340
|
*/
|
|
350
|
-
|
|
341
|
+
analyticsGet('/jobs/:jobName/phases', async (req, res) => {
|
|
351
342
|
try {
|
|
352
343
|
const apiKeyData = req.apiKeyData;
|
|
353
344
|
if (!apiKeyData) {
|
|
@@ -365,8 +356,7 @@ router.get('/jobs/:jobName/phases', async (req, res) => {
|
|
|
365
356
|
res.json({ jobName, phases });
|
|
366
357
|
}
|
|
367
358
|
catch (error) {
|
|
368
|
-
|
|
369
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
359
|
+
throw error;
|
|
370
360
|
}
|
|
371
361
|
});
|
|
372
362
|
function formatDuration(ms) {
|
|
@@ -382,7 +372,7 @@ function formatDuration(ms) {
|
|
|
382
372
|
* GET /api/analytics/jobs/:jobId/tokens
|
|
383
373
|
* Get per-job token usage (delta computed from cumulative snapshots)
|
|
384
374
|
*/
|
|
385
|
-
|
|
375
|
+
analyticsGet('/jobs/:jobId/tokens', async (req, res) => {
|
|
386
376
|
let dbService;
|
|
387
377
|
try {
|
|
388
378
|
const apiKeyData = req.apiKeyData;
|
|
@@ -393,8 +383,7 @@ router.get('/jobs/:jobId/tokens', async (req, res) => {
|
|
|
393
383
|
if (!jobId) {
|
|
394
384
|
return res.status(400).json({ error: 'jobId parameter is required' });
|
|
395
385
|
}
|
|
396
|
-
dbService =
|
|
397
|
-
await dbService.connect();
|
|
386
|
+
dbService = getAnalyticsDatabase();
|
|
398
387
|
const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
|
|
399
388
|
const result = await dbService.getTokenUsageByJob(jobId, targetUserId);
|
|
400
389
|
if (!result) {
|
|
@@ -413,17 +402,7 @@ router.get('/jobs/:jobId/tokens', async (req, res) => {
|
|
|
413
402
|
res.json(result);
|
|
414
403
|
}
|
|
415
404
|
catch (error) {
|
|
416
|
-
|
|
417
|
-
return res.status(403).json({ error: error.message });
|
|
418
|
-
if (error.status === 404)
|
|
419
|
-
return res.status(404).json({ error: error.message });
|
|
420
|
-
console.error('Error fetching job token usage:', error);
|
|
421
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
422
|
-
}
|
|
423
|
-
finally {
|
|
424
|
-
if (dbService) {
|
|
425
|
-
await dbService.close();
|
|
426
|
-
}
|
|
405
|
+
throw error;
|
|
427
406
|
}
|
|
428
407
|
});
|
|
429
408
|
/**
|
|
@@ -431,7 +410,7 @@ router.get('/jobs/:jobId/tokens', async (req, res) => {
|
|
|
431
410
|
* Issue #330 / R2.1 — token + cost aggregate for the You / Team accordion.
|
|
432
411
|
* Honors apiKey admin-gate (C2) and rejects local-path repoIdentifier (C3).
|
|
433
412
|
*/
|
|
434
|
-
|
|
413
|
+
analyticsGet('/usage/aggregate', async (req, res) => {
|
|
435
414
|
let dbService;
|
|
436
415
|
try {
|
|
437
416
|
const apiKeyData = req.apiKeyData;
|
|
@@ -444,8 +423,7 @@ router.get('/usage/aggregate', async (req, res) => {
|
|
|
444
423
|
if (repoIdentifier && typeof repoIdentifier === 'string' && repoIdentifier.length > 0 && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier)) {
|
|
445
424
|
return res.status(400).json({ error: 'Invalid repoIdentifier — local paths are not permitted' });
|
|
446
425
|
}
|
|
447
|
-
dbService =
|
|
448
|
-
await dbService.connect();
|
|
426
|
+
dbService = getAnalyticsDatabase();
|
|
449
427
|
// C2 — admin-only when querying another user's data.
|
|
450
428
|
let targetUserId;
|
|
451
429
|
if (apiKey && typeof apiKey === 'string') {
|
|
@@ -471,20 +449,14 @@ router.get('/usage/aggregate', async (req, res) => {
|
|
|
471
449
|
res.json(aggregate);
|
|
472
450
|
}
|
|
473
451
|
catch (error) {
|
|
474
|
-
|
|
475
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
476
|
-
}
|
|
477
|
-
finally {
|
|
478
|
-
if (dbService) {
|
|
479
|
-
await dbService.close();
|
|
480
|
-
}
|
|
452
|
+
throw error;
|
|
481
453
|
}
|
|
482
454
|
});
|
|
483
455
|
/**
|
|
484
456
|
* GET /api/analytics/jobs/:jobName/agent-summary
|
|
485
457
|
* Issue #330 / R2.8 — per-job-name agent split for the per-job page.
|
|
486
458
|
*/
|
|
487
|
-
|
|
459
|
+
analyticsGet('/jobs/:jobName/agent-summary', async (req, res) => {
|
|
488
460
|
let dbService;
|
|
489
461
|
try {
|
|
490
462
|
const apiKeyData = req.apiKeyData;
|
|
@@ -500,8 +472,7 @@ router.get('/jobs/:jobName/agent-summary', async (req, res) => {
|
|
|
500
472
|
if (repoIdentifier && typeof repoIdentifier === 'string' && repoIdentifier.length > 0 && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier)) {
|
|
501
473
|
return res.status(400).json({ error: 'Invalid repoIdentifier — local paths are not permitted' });
|
|
502
474
|
}
|
|
503
|
-
dbService =
|
|
504
|
-
await dbService.connect();
|
|
475
|
+
dbService = getAnalyticsDatabase();
|
|
505
476
|
const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
|
|
506
477
|
const summary = await dbService.getJobAgentSummary({
|
|
507
478
|
jobName,
|
|
@@ -513,55 +484,35 @@ router.get('/jobs/:jobName/agent-summary', async (req, res) => {
|
|
|
513
484
|
res.json(summary);
|
|
514
485
|
}
|
|
515
486
|
catch (error) {
|
|
516
|
-
|
|
517
|
-
return res.status(403).json({ error: error.message });
|
|
518
|
-
if (error.status === 404)
|
|
519
|
-
return res.status(404).json({ error: error.message });
|
|
520
|
-
console.error('Error fetching job agent summary:', error);
|
|
521
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
522
|
-
}
|
|
523
|
-
finally {
|
|
524
|
-
if (dbService) {
|
|
525
|
-
await dbService.close();
|
|
526
|
-
}
|
|
487
|
+
throw error;
|
|
527
488
|
}
|
|
528
489
|
});
|
|
529
490
|
/**
|
|
530
491
|
* GET /api/analytics/jobs/token-summary
|
|
531
492
|
* Aggregated token usage across all jobs for a user
|
|
532
493
|
*/
|
|
533
|
-
|
|
494
|
+
analyticsGet('/jobs/token-summary', async (req, res) => {
|
|
534
495
|
let dbService;
|
|
535
496
|
try {
|
|
536
497
|
const apiKeyData = req.apiKeyData;
|
|
537
498
|
if (!apiKeyData) {
|
|
538
499
|
return res.status(401).json({ error: 'Authentication required' });
|
|
539
500
|
}
|
|
540
|
-
const
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
const startDate = new Date();
|
|
544
|
-
startDate.setDate(startDate.getDate() - days);
|
|
545
|
-
dbService = new db_service_js_1.FraimDbService();
|
|
546
|
-
await dbService.connect();
|
|
501
|
+
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
502
|
+
const startDate = timeframe.startDate ?? new Date(Date.now() - parsePeriodDays(timeframe.period) * 86400_000);
|
|
503
|
+
dbService = getAnalyticsDatabase();
|
|
547
504
|
const result = await dbService.getTokenUsageSummary(apiKeyData.userId, startDate);
|
|
548
505
|
res.json(result);
|
|
549
506
|
}
|
|
550
507
|
catch (error) {
|
|
551
|
-
|
|
552
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
553
|
-
}
|
|
554
|
-
finally {
|
|
555
|
-
if (dbService) {
|
|
556
|
-
await dbService.close();
|
|
557
|
-
}
|
|
508
|
+
throw error;
|
|
558
509
|
}
|
|
559
510
|
});
|
|
560
511
|
/**
|
|
561
512
|
* GET /api/analytics/export
|
|
562
513
|
* Export usage data as CSV
|
|
563
514
|
*/
|
|
564
|
-
|
|
515
|
+
analyticsGet('/export', async (req, res) => {
|
|
565
516
|
let dbService;
|
|
566
517
|
let analyticsService;
|
|
567
518
|
try {
|
|
@@ -576,10 +527,9 @@ router.get('/export', async (req, res) => {
|
|
|
576
527
|
return res.status(400).json({ error: 'Only CSV format is currently supported' });
|
|
577
528
|
}
|
|
578
529
|
// Create database connection
|
|
579
|
-
dbService =
|
|
580
|
-
await dbService.connect();
|
|
530
|
+
dbService = getAnalyticsDatabase();
|
|
581
531
|
// Create analytics service
|
|
582
|
-
analyticsService =
|
|
532
|
+
analyticsService = getAnalyticsService();
|
|
583
533
|
let targetUserId;
|
|
584
534
|
if (apiKey && typeof apiKey === 'string') {
|
|
585
535
|
const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
|
|
@@ -608,20 +558,14 @@ router.get('/export', async (req, res) => {
|
|
|
608
558
|
res.send(csvData);
|
|
609
559
|
}
|
|
610
560
|
catch (error) {
|
|
611
|
-
|
|
612
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
613
|
-
}
|
|
614
|
-
finally {
|
|
615
|
-
if (dbService) {
|
|
616
|
-
await dbService.close();
|
|
617
|
-
}
|
|
561
|
+
throw error;
|
|
618
562
|
}
|
|
619
563
|
});
|
|
620
564
|
/**
|
|
621
565
|
* GET /api/analytics/users
|
|
622
566
|
* Get list of users with their API keys and event counts (admin only)
|
|
623
567
|
*/
|
|
624
|
-
|
|
568
|
+
analyticsGet('/users', async (req, res) => {
|
|
625
569
|
let dbService;
|
|
626
570
|
try {
|
|
627
571
|
// Get API key data from middleware
|
|
@@ -635,8 +579,7 @@ router.get('/users', async (req, res) => {
|
|
|
635
579
|
return res.status(403).json({ error: 'Admin access required' });
|
|
636
580
|
}
|
|
637
581
|
// Create database connection
|
|
638
|
-
dbService =
|
|
639
|
-
await dbService.connect();
|
|
582
|
+
dbService = getAnalyticsDatabase();
|
|
640
583
|
// Get all API keys
|
|
641
584
|
const apiKeys = await dbService.listApiKeys();
|
|
642
585
|
// Get event counts for each API key
|
|
@@ -663,20 +606,14 @@ router.get('/users', async (req, res) => {
|
|
|
663
606
|
res.json(usersWithCounts);
|
|
664
607
|
}
|
|
665
608
|
catch (error) {
|
|
666
|
-
|
|
667
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
668
|
-
}
|
|
669
|
-
finally {
|
|
670
|
-
if (dbService) {
|
|
671
|
-
await dbService.close();
|
|
672
|
-
}
|
|
609
|
+
throw error;
|
|
673
610
|
}
|
|
674
611
|
});
|
|
675
612
|
/**
|
|
676
613
|
* GET /api/analytics/repos
|
|
677
614
|
* Get unique repositories that have generated telemetry for a user
|
|
678
615
|
*/
|
|
679
|
-
|
|
616
|
+
analyticsGet('/repos', async (req, res) => {
|
|
680
617
|
let dbService;
|
|
681
618
|
let analyticsService;
|
|
682
619
|
try {
|
|
@@ -684,20 +621,13 @@ router.get('/repos', async (req, res) => {
|
|
|
684
621
|
if (!apiKeyData) {
|
|
685
622
|
return res.status(401).json({ error: 'Authentication required' });
|
|
686
623
|
}
|
|
687
|
-
dbService =
|
|
688
|
-
|
|
689
|
-
analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
|
|
624
|
+
dbService = getAnalyticsDatabase();
|
|
625
|
+
analyticsService = getAnalyticsService();
|
|
690
626
|
const repos = await analyticsService.getUniqueRepos(apiKeyData.userId);
|
|
691
627
|
res.json(repos);
|
|
692
628
|
}
|
|
693
629
|
catch (error) {
|
|
694
|
-
|
|
695
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
696
|
-
}
|
|
697
|
-
finally {
|
|
698
|
-
if (dbService) {
|
|
699
|
-
await dbService.close();
|
|
700
|
-
}
|
|
630
|
+
throw error;
|
|
701
631
|
}
|
|
702
632
|
});
|
|
703
633
|
/**
|
|
@@ -875,7 +805,7 @@ router.post('/quality-score', async (req, res) => {
|
|
|
875
805
|
* GET /api/analytics/me
|
|
876
806
|
* Get current user information
|
|
877
807
|
*/
|
|
878
|
-
|
|
808
|
+
analyticsGet('/me', async (req, res) => {
|
|
879
809
|
try {
|
|
880
810
|
// Get API key data from middleware
|
|
881
811
|
const apiKeyData = req.apiKeyData;
|
|
@@ -889,8 +819,7 @@ router.get('/me', async (req, res) => {
|
|
|
889
819
|
});
|
|
890
820
|
}
|
|
891
821
|
catch (error) {
|
|
892
|
-
|
|
893
|
-
res.status(500).json({ error: 'Failed to get user information' });
|
|
822
|
+
throw error;
|
|
894
823
|
}
|
|
895
824
|
});
|
|
896
825
|
// ===== TEAM ROUTES =====
|
|
@@ -913,7 +842,9 @@ function parseDateParam(value, endOfDay = false) {
|
|
|
913
842
|
return date;
|
|
914
843
|
}
|
|
915
844
|
function resolveAnalyticsTimeframe(query) {
|
|
916
|
-
const
|
|
845
|
+
const requestedPeriod = typeof query.period === 'string' && query.period ? query.period : '7d';
|
|
846
|
+
const period = requestedPeriod === '365d' ? '90d'
|
|
847
|
+
: ['7d', '30d', '90d'].includes(requestedPeriod) ? requestedPeriod : '7d';
|
|
917
848
|
const startDate = parseDateParam(query.startDate);
|
|
918
849
|
const endDate = parseDateParam(query.endDate, true);
|
|
919
850
|
if ((query.startDate || query.endDate) && (!startDate || !endDate)) {
|
|
@@ -926,7 +857,11 @@ function resolveAnalyticsTimeframe(query) {
|
|
|
926
857
|
err.status = 400;
|
|
927
858
|
throw err;
|
|
928
859
|
}
|
|
929
|
-
|
|
860
|
+
if (startDate && endDate) {
|
|
861
|
+
const retainedStart = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
862
|
+
return { period: 'custom', startDate: startDate < retainedStart ? retainedStart : startDate, endDate };
|
|
863
|
+
}
|
|
864
|
+
return { period };
|
|
930
865
|
}
|
|
931
866
|
async function resolveManagerMember(dbService, managerId, memberId) {
|
|
932
867
|
const members = await dbService.getTeamMembers(managerId);
|
|
@@ -976,30 +911,46 @@ async function resolveTargetUserIdFromQuery(dbService, apiKeyData, query) {
|
|
|
976
911
|
* Returns team member IDs only — fast, no stats queries.
|
|
977
912
|
* Used by the dashboard to render the member list immediately before lazy-loading stats.
|
|
978
913
|
*/
|
|
979
|
-
|
|
914
|
+
analyticsGet('/team/members/list', async (req, res) => {
|
|
980
915
|
let dbService;
|
|
981
916
|
try {
|
|
982
917
|
const apiKeyData = req.apiKeyData;
|
|
983
918
|
if (!apiKeyData)
|
|
984
919
|
return res.status(401).json({ error: 'Authentication required' });
|
|
985
|
-
dbService =
|
|
986
|
-
await dbService.connect();
|
|
920
|
+
dbService = getAnalyticsDatabase();
|
|
987
921
|
const members = await dbService.getTeamMembers(apiKeyData.userId);
|
|
988
922
|
res.json(members.map(m => ({ memberId: m.memberId, labels: m.labels || [] })));
|
|
989
923
|
}
|
|
990
924
|
catch (error) {
|
|
991
|
-
|
|
992
|
-
}
|
|
993
|
-
finally {
|
|
994
|
-
if (dbService)
|
|
995
|
-
await dbService.close();
|
|
925
|
+
throw error;
|
|
996
926
|
}
|
|
997
927
|
});
|
|
928
|
+
analyticsGet('/team/members/summaries', async (req, res) => {
|
|
929
|
+
const apiKeyData = req.apiKeyData;
|
|
930
|
+
if (!apiKeyData)
|
|
931
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
932
|
+
const ids = typeof req.query.ids === 'string' ? req.query.ids.split(',') : [];
|
|
933
|
+
if (!ids.length || ids.some(id => !id.trim() || id !== id.trim() || id.length > 320) || ids.length > 20) {
|
|
934
|
+
return res.status(400).json({ error: 'ids must contain between 1 and 20 member IDs' });
|
|
935
|
+
}
|
|
936
|
+
const memberIds = [...new Set(ids)];
|
|
937
|
+
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
938
|
+
const database = getAnalyticsDatabase();
|
|
939
|
+
const members = await database.getTeamMembers(apiKeyData.userId);
|
|
940
|
+
const authorized = new Set(members.map(member => member.memberId));
|
|
941
|
+
if (memberIds.some(id => !authorized.has(id)))
|
|
942
|
+
return res.status(403).json({ error: 'Not authorized to view this member' });
|
|
943
|
+
const repoIdentifier = typeof req.query.repoIdentifier === 'string' ? req.query.repoIdentifier : undefined;
|
|
944
|
+
if (repoIdentifier && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier))
|
|
945
|
+
return res.status(400).json({ error: 'Invalid repoIdentifier' });
|
|
946
|
+
const summaries = await getAnalyticsService().getTeamMemberSummaries(timeframe.period, memberIds, repoIdentifier, timeframe.startDate, timeframe.endDate);
|
|
947
|
+
return res.json(summaries);
|
|
948
|
+
});
|
|
998
949
|
/**
|
|
999
950
|
* GET /api/analytics/team/members/:memberId/summary?period=30d
|
|
1000
951
|
* Returns stats for a single member. Called lazily per-member after the list renders.
|
|
1001
952
|
*/
|
|
1002
|
-
|
|
953
|
+
analyticsGet('/team/members/:memberId/summary', async (req, res) => {
|
|
1003
954
|
let dbService;
|
|
1004
955
|
try {
|
|
1005
956
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1007,17 +958,20 @@ router.get('/team/members/:memberId/summary', async (req, res) => {
|
|
|
1007
958
|
return res.status(401).json({ error: 'Authentication required' });
|
|
1008
959
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1009
960
|
const memberId = req.params.memberId;
|
|
1010
|
-
dbService =
|
|
1011
|
-
await dbService.connect();
|
|
961
|
+
dbService = getAnalyticsDatabase();
|
|
1012
962
|
await resolveManagerMember(dbService, apiKeyData.userId, memberId); // auth check
|
|
1013
|
-
const analyticsService =
|
|
1014
|
-
const
|
|
963
|
+
const analyticsService = getAnalyticsService();
|
|
964
|
+
const summaryResults = await Promise.allSettled([
|
|
1015
965
|
analyticsService.getDominantJobCategory(timeframe.period, memberId, undefined, timeframe.startDate, timeframe.endDate),
|
|
1016
966
|
analyticsService.getUsageStats({ period: timeframe.period, startDate: timeframe.startDate, endDate: timeframe.endDate, types: ['job'], userId: memberId }),
|
|
1017
967
|
dbService.getLatestUsageEventAt(memberId, ['job']),
|
|
1018
968
|
dbService.hasFraimSetup(memberId),
|
|
1019
969
|
dbService.getApiKeyByUserId(memberId, false),
|
|
1020
970
|
]);
|
|
971
|
+
const summaryFailure = summaryResults.find(result => result.status === 'rejected');
|
|
972
|
+
if (summaryFailure?.status === 'rejected')
|
|
973
|
+
throw summaryFailure.reason;
|
|
974
|
+
const [dominant, stats, allTimeLastEventAt, hasFraimSetup, keyLifecycle] = summaryResults;
|
|
1021
975
|
const lastEventAt = stats.status === 'fulfilled' ? stats.value.lastEventAt ?? null : null;
|
|
1022
976
|
const historicalLastEventAt = allTimeLastEventAt.status === 'fulfilled' ? allTimeLastEventAt.value ?? null : null;
|
|
1023
977
|
const lifecycle = keyLifecycle.status === 'fulfilled'
|
|
@@ -1036,11 +990,7 @@ router.get('/team/members/:memberId/summary', async (req, res) => {
|
|
|
1036
990
|
});
|
|
1037
991
|
}
|
|
1038
992
|
catch (error) {
|
|
1039
|
-
|
|
1040
|
-
}
|
|
1041
|
-
finally {
|
|
1042
|
-
if (dbService)
|
|
1043
|
-
await dbService.close();
|
|
993
|
+
throw error;
|
|
1044
994
|
}
|
|
1045
995
|
});
|
|
1046
996
|
/**
|
|
@@ -1048,56 +998,34 @@ router.get('/team/members/:memberId/summary', async (req, res) => {
|
|
|
1048
998
|
* Returns team members with dominant category (manager view).
|
|
1049
999
|
* Kept for backwards compatibility — prefer /list + /summary for non-blocking UI.
|
|
1050
1000
|
*/
|
|
1051
|
-
|
|
1001
|
+
analyticsGet('/team/members', async (req, res) => {
|
|
1052
1002
|
let dbService;
|
|
1053
1003
|
try {
|
|
1054
1004
|
const apiKeyData = req.apiKeyData;
|
|
1055
1005
|
if (!apiKeyData)
|
|
1056
1006
|
return res.status(401).json({ error: 'Authentication required' });
|
|
1057
1007
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1058
|
-
dbService =
|
|
1059
|
-
|
|
1060
|
-
const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
|
|
1008
|
+
dbService = getAnalyticsDatabase();
|
|
1009
|
+
const analyticsService = getAnalyticsService();
|
|
1061
1010
|
const members = await dbService.getTeamMembers(apiKeyData.userId);
|
|
1062
1011
|
if (members.length === 0)
|
|
1063
1012
|
return res.json([]);
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
const lastEventAt = stats.status === 'fulfilled' ? stats.value.lastEventAt ?? null : null;
|
|
1072
|
-
const historicalLastEventAt = allTimeLastEventAt.status === 'fulfilled' ? allTimeLastEventAt.value ?? null : null;
|
|
1073
|
-
return {
|
|
1074
|
-
memberId: m.memberId,
|
|
1075
|
-
labels: m.labels || [],
|
|
1076
|
-
dominantCategory: dominant.status === 'fulfilled' ? dominant.value?.category ?? null : null,
|
|
1077
|
-
dominantCategoryCount: dominant.status === 'fulfilled' ? dominant.value?.count ?? 0 : 0,
|
|
1078
|
-
totalEvents: stats.status === 'fulfilled' ? stats.value.totalEvents : 0,
|
|
1079
|
-
hasFraimSetup: hasFraimSetup.status === 'fulfilled' ? hasFraimSetup.value : false,
|
|
1080
|
-
lastEventAt,
|
|
1081
|
-
allTimeLastEventAt: historicalLastEventAt,
|
|
1082
|
-
lastActiveAt: lastEventAt
|
|
1083
|
-
};
|
|
1084
|
-
}));
|
|
1085
|
-
res.json(results
|
|
1086
|
-
.filter(r => r.status === 'fulfilled')
|
|
1087
|
-
.map(r => r.value));
|
|
1013
|
+
const summaries = await analyticsService.getTeamMemberSummaries(timeframe.period, members.map(member => member.memberId), undefined, timeframe.startDate, timeframe.endDate);
|
|
1014
|
+
// Historical activity remains available for callers of the legacy endpoint,
|
|
1015
|
+
// but is not part of the default overview request.
|
|
1016
|
+
const historical = await dbService.getLatestUsageEventsByUser(members.map(member => member.memberId), ['job']);
|
|
1017
|
+
const labels = new Map(members.map(member => [member.memberId, member.labels || []]));
|
|
1018
|
+
res.json(summaries.map(summary => ({ ...summary, labels: labels.get(summary.memberId),
|
|
1019
|
+
allTimeLastEventAt: historical.get(summary.memberId) ?? null })));
|
|
1088
1020
|
}
|
|
1089
1021
|
catch (error) {
|
|
1090
|
-
|
|
1091
|
-
}
|
|
1092
|
-
finally {
|
|
1093
|
-
if (dbService)
|
|
1094
|
-
await dbService.close();
|
|
1022
|
+
throw error;
|
|
1095
1023
|
}
|
|
1096
1024
|
});
|
|
1097
1025
|
/**
|
|
1098
1026
|
* GET /api/analytics/team/member/:memberId/stats
|
|
1099
1027
|
*/
|
|
1100
|
-
|
|
1028
|
+
analyticsGet('/team/member/:memberId/stats', async (req, res) => {
|
|
1101
1029
|
let dbService;
|
|
1102
1030
|
try {
|
|
1103
1031
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1106,10 +1034,9 @@ router.get('/team/member/:memberId/stats', async (req, res) => {
|
|
|
1106
1034
|
const { memberId } = req.params;
|
|
1107
1035
|
const { repoIdentifier } = req.query;
|
|
1108
1036
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1109
|
-
dbService =
|
|
1110
|
-
await dbService.connect();
|
|
1037
|
+
dbService = getAnalyticsDatabase();
|
|
1111
1038
|
const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
|
|
1112
|
-
const analyticsService =
|
|
1039
|
+
const analyticsService = getAnalyticsService();
|
|
1113
1040
|
const stats = await analyticsService.getUsageStats({
|
|
1114
1041
|
period: timeframe.period,
|
|
1115
1042
|
startDate: timeframe.startDate,
|
|
@@ -1121,11 +1048,7 @@ router.get('/team/member/:memberId/stats', async (req, res) => {
|
|
|
1121
1048
|
res.json(stats);
|
|
1122
1049
|
}
|
|
1123
1050
|
catch (error) {
|
|
1124
|
-
|
|
1125
|
-
}
|
|
1126
|
-
finally {
|
|
1127
|
-
if (dbService)
|
|
1128
|
-
await dbService.close();
|
|
1051
|
+
throw error;
|
|
1129
1052
|
}
|
|
1130
1053
|
});
|
|
1131
1054
|
/**
|
|
@@ -1133,7 +1056,7 @@ router.get('/team/member/:memberId/stats', async (req, res) => {
|
|
|
1133
1056
|
* Issue #330 / R2.5 — token + cost aggregate for a team member, scoped
|
|
1134
1057
|
* to the calling manager's permission set via resolveManagerMember.
|
|
1135
1058
|
*/
|
|
1136
|
-
|
|
1059
|
+
analyticsGet('/team/member/:memberId/usage/aggregate', async (req, res) => {
|
|
1137
1060
|
let dbService;
|
|
1138
1061
|
try {
|
|
1139
1062
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1145,8 +1068,7 @@ router.get('/team/member/:memberId/usage/aggregate', async (req, res) => {
|
|
|
1145
1068
|
if (repoIdentifier && typeof repoIdentifier === 'string' && repoIdentifier.length > 0 && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier)) {
|
|
1146
1069
|
return res.status(400).json({ error: 'Invalid repoIdentifier — local paths are not permitted' });
|
|
1147
1070
|
}
|
|
1148
|
-
dbService =
|
|
1149
|
-
await dbService.connect();
|
|
1071
|
+
dbService = getAnalyticsDatabase();
|
|
1150
1072
|
const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
|
|
1151
1073
|
const aggregate = await dbService.getUsageAggregate({
|
|
1152
1074
|
userId: memberUserId,
|
|
@@ -1157,17 +1079,13 @@ router.get('/team/member/:memberId/usage/aggregate', async (req, res) => {
|
|
|
1157
1079
|
res.json(aggregate);
|
|
1158
1080
|
}
|
|
1159
1081
|
catch (error) {
|
|
1160
|
-
|
|
1161
|
-
}
|
|
1162
|
-
finally {
|
|
1163
|
-
if (dbService)
|
|
1164
|
-
await dbService.close();
|
|
1082
|
+
throw error;
|
|
1165
1083
|
}
|
|
1166
1084
|
});
|
|
1167
1085
|
/**
|
|
1168
1086
|
* GET /api/analytics/team/member/:memberId/components/top
|
|
1169
1087
|
*/
|
|
1170
|
-
|
|
1088
|
+
analyticsGet('/team/member/:memberId/components/top', async (req, res) => {
|
|
1171
1089
|
let dbService;
|
|
1172
1090
|
try {
|
|
1173
1091
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1176,25 +1094,20 @@ router.get('/team/member/:memberId/components/top', async (req, res) => {
|
|
|
1176
1094
|
const { memberId } = req.params;
|
|
1177
1095
|
const { limit = '10', repoIdentifier } = req.query;
|
|
1178
1096
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1179
|
-
dbService =
|
|
1180
|
-
await dbService.connect();
|
|
1097
|
+
dbService = getAnalyticsDatabase();
|
|
1181
1098
|
const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
|
|
1182
|
-
const analyticsService =
|
|
1099
|
+
const analyticsService = getAnalyticsService();
|
|
1183
1100
|
const components = await analyticsService.getTopComponents(parseInt(limit, 10), timeframe.period, ['job'], memberUserId, repoIdentifier, timeframe.startDate, timeframe.endDate);
|
|
1184
1101
|
res.json(components);
|
|
1185
1102
|
}
|
|
1186
1103
|
catch (error) {
|
|
1187
|
-
|
|
1188
|
-
}
|
|
1189
|
-
finally {
|
|
1190
|
-
if (dbService)
|
|
1191
|
-
await dbService.close();
|
|
1104
|
+
throw error;
|
|
1192
1105
|
}
|
|
1193
1106
|
});
|
|
1194
1107
|
/**
|
|
1195
1108
|
* GET /api/analytics/team/member/:memberId/jobs/timeline
|
|
1196
1109
|
*/
|
|
1197
|
-
|
|
1110
|
+
analyticsGet('/team/member/:memberId/jobs/timeline', async (req, res) => {
|
|
1198
1111
|
let dbService;
|
|
1199
1112
|
try {
|
|
1200
1113
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1203,64 +1116,49 @@ router.get('/team/member/:memberId/jobs/timeline', async (req, res) => {
|
|
|
1203
1116
|
const { memberId } = req.params;
|
|
1204
1117
|
const { dateUnit, repoIdentifier } = req.query;
|
|
1205
1118
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1206
|
-
dbService =
|
|
1207
|
-
await dbService.connect();
|
|
1119
|
+
dbService = getAnalyticsDatabase();
|
|
1208
1120
|
const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
|
|
1209
|
-
const analyticsService =
|
|
1121
|
+
const analyticsService = getAnalyticsService();
|
|
1210
1122
|
const timeline = await analyticsService.getJobRunsOverTime(timeframe.period, memberUserId, dateUnit, repoIdentifier, timeframe.startDate, timeframe.endDate);
|
|
1211
1123
|
res.json(timeline);
|
|
1212
1124
|
}
|
|
1213
1125
|
catch (error) {
|
|
1214
|
-
|
|
1215
|
-
}
|
|
1216
|
-
finally {
|
|
1217
|
-
if (dbService)
|
|
1218
|
-
await dbService.close();
|
|
1126
|
+
throw error;
|
|
1219
1127
|
}
|
|
1220
1128
|
});
|
|
1221
|
-
|
|
1129
|
+
analyticsGet('/team/member/:memberId/repos', async (req, res) => {
|
|
1222
1130
|
let dbService;
|
|
1223
1131
|
try {
|
|
1224
1132
|
const apiKeyData = req.apiKeyData;
|
|
1225
1133
|
if (!apiKeyData)
|
|
1226
1134
|
return res.status(401).json({ error: 'Authentication required' });
|
|
1227
1135
|
const { memberId } = req.params;
|
|
1228
|
-
dbService =
|
|
1229
|
-
await dbService.connect();
|
|
1136
|
+
dbService = getAnalyticsDatabase();
|
|
1230
1137
|
const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
|
|
1231
|
-
const analyticsService =
|
|
1138
|
+
const analyticsService = getAnalyticsService();
|
|
1232
1139
|
const repos = await analyticsService.getUniqueRepos(memberUserId);
|
|
1233
1140
|
res.json(repos);
|
|
1234
1141
|
}
|
|
1235
1142
|
catch (error) {
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
finally {
|
|
1239
|
-
if (dbService)
|
|
1240
|
-
await dbService.close();
|
|
1143
|
+
throw error;
|
|
1241
1144
|
}
|
|
1242
1145
|
});
|
|
1243
1146
|
/**
|
|
1244
1147
|
* GET /api/analytics/team/my-managers
|
|
1245
1148
|
* Returns list of manager emails for the current user (for transparency notice)
|
|
1246
1149
|
*/
|
|
1247
|
-
|
|
1150
|
+
analyticsGet('/team/my-managers', async (req, res) => {
|
|
1248
1151
|
let dbService;
|
|
1249
1152
|
try {
|
|
1250
1153
|
const apiKeyData = req.apiKeyData;
|
|
1251
1154
|
if (!apiKeyData)
|
|
1252
1155
|
return res.status(401).json({ error: 'Authentication required' });
|
|
1253
|
-
dbService =
|
|
1254
|
-
await dbService.connect();
|
|
1156
|
+
dbService = getAnalyticsDatabase();
|
|
1255
1157
|
const managers = await dbService.getManagersForMember(apiKeyData.userId);
|
|
1256
1158
|
res.json(managers);
|
|
1257
1159
|
}
|
|
1258
1160
|
catch (error) {
|
|
1259
|
-
|
|
1260
|
-
}
|
|
1261
|
-
finally {
|
|
1262
|
-
if (dbService)
|
|
1263
|
-
await dbService.close();
|
|
1161
|
+
throw error;
|
|
1264
1162
|
}
|
|
1265
1163
|
});
|
|
1266
1164
|
/**
|
|
@@ -1268,7 +1166,7 @@ router.get('/team/my-managers', async (req, res) => {
|
|
|
1268
1166
|
* Returns per-interview quality scores and latest gate decision for a team member.
|
|
1269
1167
|
* Used by the manager dashboard Quality tab (Issue #251).
|
|
1270
1168
|
*/
|
|
1271
|
-
|
|
1169
|
+
analyticsGet('/team/member/:memberId/quality/interviews', async (req, res) => {
|
|
1272
1170
|
let dbService;
|
|
1273
1171
|
try {
|
|
1274
1172
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1276,8 +1174,7 @@ router.get('/team/member/:memberId/quality/interviews', async (req, res) => {
|
|
|
1276
1174
|
return res.status(401).json({ error: 'Authentication required' });
|
|
1277
1175
|
const { memberId } = req.params;
|
|
1278
1176
|
const { repoIdentifier } = req.query;
|
|
1279
|
-
dbService =
|
|
1280
|
-
await dbService.connect();
|
|
1177
|
+
dbService = getAnalyticsDatabase();
|
|
1281
1178
|
// Privacy check: manager must have active relationship with member
|
|
1282
1179
|
await resolveManagerMember(dbService, apiKeyData.userId, memberId);
|
|
1283
1180
|
// Fetch interview quality scores (chronological)
|
|
@@ -1330,11 +1227,7 @@ router.get('/team/member/:memberId/quality/interviews', async (req, res) => {
|
|
|
1330
1227
|
});
|
|
1331
1228
|
}
|
|
1332
1229
|
catch (error) {
|
|
1333
|
-
|
|
1334
|
-
}
|
|
1335
|
-
finally {
|
|
1336
|
-
if (dbService)
|
|
1337
|
-
await dbService.close();
|
|
1230
|
+
throw error;
|
|
1338
1231
|
}
|
|
1339
1232
|
});
|
|
1340
1233
|
/**
|
|
@@ -1377,7 +1270,7 @@ router.post('/quality', async (req, res) => {
|
|
|
1377
1270
|
* Returns quality scorecard: one summary per founder journey stage.
|
|
1378
1271
|
* Used by the quality tile grid in both personal and manager views.
|
|
1379
1272
|
*/
|
|
1380
|
-
|
|
1273
|
+
analyticsGet('/quality/scorecard/:userId', async (req, res) => {
|
|
1381
1274
|
let dbService;
|
|
1382
1275
|
try {
|
|
1383
1276
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1387,8 +1280,7 @@ router.get('/quality/scorecard/:userId', async (req, res) => {
|
|
|
1387
1280
|
const { repoIdentifier } = req.query;
|
|
1388
1281
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1389
1282
|
const periodDays = timeframe.startDate ? undefined : parsePeriodDays(timeframe.period);
|
|
1390
|
-
dbService =
|
|
1391
|
-
await dbService.connect();
|
|
1283
|
+
dbService = getAnalyticsDatabase();
|
|
1392
1284
|
// If requesting another user's data, verify manager-member relationship
|
|
1393
1285
|
if (apiKeyData.userId !== userId) {
|
|
1394
1286
|
await resolveManagerMember(dbService, apiKeyData.userId, userId);
|
|
@@ -1397,18 +1289,14 @@ router.get('/quality/scorecard/:userId', async (req, res) => {
|
|
|
1397
1289
|
res.json({ stages });
|
|
1398
1290
|
}
|
|
1399
1291
|
catch (error) {
|
|
1400
|
-
|
|
1401
|
-
}
|
|
1402
|
-
finally {
|
|
1403
|
-
if (dbService)
|
|
1404
|
-
await dbService.close();
|
|
1292
|
+
throw error;
|
|
1405
1293
|
}
|
|
1406
1294
|
});
|
|
1407
1295
|
/**
|
|
1408
1296
|
* GET /api/analytics/quality/stage/:userId/:stageCategory
|
|
1409
1297
|
* Returns full assessment history for a single stage (tile click detail view).
|
|
1410
1298
|
*/
|
|
1411
|
-
|
|
1299
|
+
analyticsGet('/quality/stage/:userId/:stageCategory', async (req, res) => {
|
|
1412
1300
|
let dbService;
|
|
1413
1301
|
try {
|
|
1414
1302
|
const apiKeyData = req.apiKeyData;
|
|
@@ -1418,8 +1306,7 @@ router.get('/quality/stage/:userId/:stageCategory', async (req, res) => {
|
|
|
1418
1306
|
const { repoIdentifier } = req.query;
|
|
1419
1307
|
const timeframe = resolveAnalyticsTimeframe(req.query);
|
|
1420
1308
|
const periodDays = timeframe.startDate ? undefined : parsePeriodDays(timeframe.period);
|
|
1421
|
-
dbService =
|
|
1422
|
-
await dbService.connect();
|
|
1309
|
+
dbService = getAnalyticsDatabase();
|
|
1423
1310
|
// If requesting another user's data, verify manager-member relationship
|
|
1424
1311
|
if (apiKeyData.userId !== userId) {
|
|
1425
1312
|
await resolveManagerMember(dbService, apiKeyData.userId, userId);
|
|
@@ -1428,11 +1315,7 @@ router.get('/quality/stage/:userId/:stageCategory', async (req, res) => {
|
|
|
1428
1315
|
res.json(detail);
|
|
1429
1316
|
}
|
|
1430
1317
|
catch (error) {
|
|
1431
|
-
|
|
1432
|
-
}
|
|
1433
|
-
finally {
|
|
1434
|
-
if (dbService)
|
|
1435
|
-
await dbService.close();
|
|
1318
|
+
throw error;
|
|
1436
1319
|
}
|
|
1437
1320
|
});
|
|
1438
1321
|
/**
|