fraim 2.0.177 → 2.0.180

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 (77) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/ai-hub/server.js +50 -1
  3. package/dist/src/api/admin/payments.js +33 -0
  4. package/dist/src/api/admin/sales-leads.js +21 -0
  5. package/dist/src/api/payment/create-session.js +338 -0
  6. package/dist/src/api/payment/dashboard-link.js +149 -0
  7. package/dist/src/api/payment/session-details.js +31 -0
  8. package/dist/src/api/payment/webhook.js +587 -0
  9. package/dist/src/api/personas/me.js +29 -0
  10. package/dist/src/api/pricing/get-config.js +25 -0
  11. package/dist/src/api/sales/contact.js +44 -0
  12. package/dist/src/cli/commands/add-provider.js +74 -61
  13. package/dist/src/cli/commands/add-surface.js +128 -0
  14. package/dist/src/cli/commands/login.js +5 -69
  15. package/dist/src/cli/commands/setup.js +27 -347
  16. package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
  17. package/dist/src/cli/fraim.js +2 -0
  18. package/dist/src/cli/mcp/ide-formats.js +5 -3
  19. package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
  20. package/dist/src/cli/providers/local-provider-registry.js +2 -3
  21. package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
  22. package/dist/src/cli/setup/ide-detector.js +34 -14
  23. package/dist/src/config/persona-capability-bundles.js +17 -13
  24. package/dist/src/db/payment-repository.js +61 -0
  25. package/dist/src/first-run/session-service.js +2 -2
  26. package/dist/src/fraim/config-loader.js +11 -0
  27. package/dist/src/fraim/db-service.js +2387 -0
  28. package/dist/src/fraim/issues.js +152 -0
  29. package/dist/src/fraim/template-processor.js +184 -0
  30. package/dist/src/fraim/utils/request-utils.js +23 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +28 -4
  32. package/dist/src/local-mcp-server/usage-collector.js +24 -0
  33. package/dist/src/middleware/auth.js +266 -0
  34. package/dist/src/middleware/cors-config.js +111 -0
  35. package/dist/src/middleware/logger.js +116 -0
  36. package/dist/src/middleware/rate-limit.js +110 -0
  37. package/dist/src/middleware/reject-query-api-key.js +45 -0
  38. package/dist/src/middleware/security-headers.js +41 -0
  39. package/dist/src/middleware/telemetry.js +134 -0
  40. package/dist/src/models/payment.js +2 -0
  41. package/dist/src/routes/analytics.js +1447 -0
  42. package/dist/src/routes/app-routes.js +32 -0
  43. package/dist/src/routes/auth-routes.js +505 -0
  44. package/dist/src/routes/oauth-routes.js +325 -0
  45. package/dist/src/routes/payment-routes.js +186 -0
  46. package/dist/src/routes/persona-catalog-routes.js +84 -0
  47. package/dist/src/services/admin-service.js +229 -0
  48. package/dist/src/services/audit-log-persistence.js +60 -0
  49. package/dist/src/services/audit-log.js +69 -0
  50. package/dist/src/services/cookie-service.js +129 -0
  51. package/dist/src/services/dashboard-access.js +27 -0
  52. package/dist/src/services/demo-seed-service.js +139 -0
  53. package/dist/src/services/email-code.js +23 -0
  54. package/dist/src/services/email-service-clean.js +782 -0
  55. package/dist/src/services/email-service.js +951 -0
  56. package/dist/src/services/installer-service.js +131 -0
  57. package/dist/src/services/mcp-oauth-store.js +33 -0
  58. package/dist/src/services/mcp-service.js +823 -0
  59. package/dist/src/services/oauth-helpers.js +127 -0
  60. package/dist/src/services/org-service.js +89 -0
  61. package/dist/src/services/persona-entitlement-service.js +288 -0
  62. package/dist/src/services/provider-service.js +215 -0
  63. package/dist/src/services/registry-service.js +628 -0
  64. package/dist/src/services/session-service.js +86 -0
  65. package/dist/src/services/trial-reminder-service.js +120 -0
  66. package/dist/src/services/usage-analytics-service.js +419 -0
  67. package/dist/src/services/workspace-identity.js +21 -0
  68. package/dist/src/types/analytics.js +2 -0
  69. package/dist/src/utils/payment-calculator.js +52 -0
  70. package/extensions/office-word/favicon.ico +0 -0
  71. package/extensions/office-word/icon-64.png +0 -0
  72. package/extensions/office-word/manifest.xml +33 -0
  73. package/extensions/office-word/taskpane.html +242 -0
  74. package/package.json +14 -3
  75. package/public/ai-hub/index.html +14 -2
  76. package/public/ai-hub/script.js +340 -66
  77. package/public/ai-hub/styles.css +83 -0
@@ -0,0 +1,1447 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const express_1 = __importDefault(require("express"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const usage_analytics_service_js_1 = require("../services/usage-analytics-service.js");
9
+ const db_service_js_1 = require("../fraim/db-service.js");
10
+ const mcp_service_js_1 = require("../services/mcp-service.js");
11
+ const git_utils_js_1 = require("../core/utils/git-utils.js");
12
+ const catalog_1 = require("../ai-hub/catalog");
13
+ const router = express_1.default.Router();
14
+ /**
15
+ * GET /api/analytics/stats
16
+ * Get usage statistics for a time period
17
+ */
18
+ router.get('/stats', async (req, res) => {
19
+ let dbService;
20
+ let analyticsService;
21
+ try {
22
+ // Get API key data from middleware
23
+ const apiKeyData = req.apiKeyData;
24
+ if (!apiKeyData) {
25
+ return res.status(401).json({ error: 'Authentication required' });
26
+ }
27
+ // Create database connection
28
+ dbService = new db_service_js_1.FraimDbService();
29
+ await dbService.connect();
30
+ // Create analytics service
31
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
32
+ const { type, apiKey, repoIdentifier } = req.query;
33
+ const timeframe = resolveAnalyticsTimeframe(req.query);
34
+ const types = type ? (Array.isArray(type) ? type : [type]) : undefined;
35
+ let targetUserId;
36
+ if (apiKey && typeof apiKey === 'string') {
37
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
38
+ if (!isAdmin) {
39
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
40
+ }
41
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
42
+ if (!targetApiKeyData) {
43
+ return res.status(404).json({ error: 'API key not found' });
44
+ }
45
+ targetUserId = targetApiKeyData.userId;
46
+ }
47
+ else {
48
+ targetUserId = apiKeyData.userId;
49
+ }
50
+ const stats = await analyticsService.getUsageStats({
51
+ period: timeframe.period,
52
+ startDate: timeframe.startDate,
53
+ endDate: timeframe.endDate,
54
+ types,
55
+ userId: targetUserId,
56
+ repoIdentifier: repoIdentifier
57
+ });
58
+ res.json(stats);
59
+ }
60
+ catch (error) {
61
+ console.error('Error fetching usage stats:', error);
62
+ res.status(500).json({ error: 'Internal server error' });
63
+ }
64
+ finally {
65
+ if (dbService) {
66
+ await dbService.close();
67
+ }
68
+ }
69
+ });
70
+ /**
71
+ * GET /api/analytics/components/top
72
+ * Get top components by usage count
73
+ */
74
+ router.get('/components/top', async (req, res) => {
75
+ let dbService;
76
+ let analyticsService;
77
+ try {
78
+ // Get API key data from middleware
79
+ const apiKeyData = req.apiKeyData;
80
+ if (!apiKeyData) {
81
+ return res.status(401).json({ error: 'Authentication required' });
82
+ }
83
+ // Create database connection
84
+ dbService = new db_service_js_1.FraimDbService();
85
+ await dbService.connect();
86
+ // Create analytics service
87
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
88
+ const { limit = '10', apiKey, type, repoIdentifier } = req.query;
89
+ const timeframe = resolveAnalyticsTimeframe(req.query);
90
+ const types = type ? (Array.isArray(type) ? type : [type]) : undefined;
91
+ let targetUserId;
92
+ if (apiKey && typeof apiKey === 'string') {
93
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
94
+ if (!isAdmin) {
95
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
96
+ }
97
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
98
+ if (!targetApiKeyData) {
99
+ return res.status(404).json({ error: 'API key not found' });
100
+ }
101
+ targetUserId = targetApiKeyData.userId;
102
+ }
103
+ else {
104
+ targetUserId = apiKeyData.userId;
105
+ }
106
+ const components = await analyticsService.getTopComponents(parseInt(limit, 10), timeframe.period, types, targetUserId, repoIdentifier, timeframe.startDate, timeframe.endDate);
107
+ res.json(components);
108
+ }
109
+ catch (error) {
110
+ console.error('Error fetching top components:', error);
111
+ res.status(500).json({ error: 'Internal server error' });
112
+ }
113
+ finally {
114
+ if (dbService) {
115
+ await dbService.close();
116
+ }
117
+ }
118
+ });
119
+ /**
120
+ * GET /api/analytics/trends
121
+ * Get trend data for a specific component
122
+ */
123
+ router.get('/trends', async (req, res) => {
124
+ let dbService;
125
+ let analyticsService;
126
+ try {
127
+ // Get API key data from middleware
128
+ const apiKeyData = req.apiKeyData;
129
+ if (!apiKeyData) {
130
+ return res.status(401).json({ error: 'Authentication required' });
131
+ }
132
+ const { component, type, apiKey, dateUnit, repoIdentifier } = req.query;
133
+ const timeframe = resolveAnalyticsTimeframe(req.query);
134
+ if (!component || !type) {
135
+ return res.status(400).json({ error: 'Component name and type are required' });
136
+ }
137
+ // Create database connection
138
+ dbService = new db_service_js_1.FraimDbService();
139
+ await dbService.connect();
140
+ // Create analytics service
141
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
142
+ let targetUserId;
143
+ if (apiKey && typeof apiKey === 'string') {
144
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
145
+ if (!isAdmin) {
146
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
147
+ }
148
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
149
+ if (!targetApiKeyData) {
150
+ return res.status(404).json({ error: 'API key not found' });
151
+ }
152
+ targetUserId = targetApiKeyData.userId;
153
+ }
154
+ else {
155
+ targetUserId = apiKeyData.userId;
156
+ }
157
+ const trend = await analyticsService.getComponentTrend(component, type, timeframe.period, targetUserId, dateUnit, repoIdentifier, timeframe.startDate, timeframe.endDate);
158
+ res.json(trend);
159
+ }
160
+ catch (error) {
161
+ console.error('Error fetching component trends:', error);
162
+ res.status(500).json({ error: 'Internal server error' });
163
+ }
164
+ finally {
165
+ if (dbService) {
166
+ await dbService.close();
167
+ }
168
+ }
169
+ });
170
+ /**
171
+ * GET /api/analytics/jobs/timeline
172
+ * Get job execution timeline for the bubble chart
173
+ */
174
+ router.get('/jobs/timeline', async (req, res) => {
175
+ let dbService;
176
+ let analyticsService;
177
+ try {
178
+ const apiKeyData = req.apiKeyData;
179
+ if (!apiKeyData) {
180
+ return res.status(401).json({ error: 'Authentication required' });
181
+ }
182
+ const { apiKey, dateUnit, repoIdentifier } = req.query;
183
+ const timeframe = resolveAnalyticsTimeframe(req.query);
184
+ dbService = new db_service_js_1.FraimDbService();
185
+ await dbService.connect();
186
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
187
+ let targetUserId;
188
+ if (apiKey && typeof apiKey === 'string') {
189
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
190
+ if (!isAdmin) {
191
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
192
+ }
193
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
194
+ if (!targetApiKeyData) {
195
+ return res.status(404).json({ error: 'API key not found' });
196
+ }
197
+ targetUserId = targetApiKeyData.userId;
198
+ }
199
+ else {
200
+ targetUserId = apiKeyData.userId;
201
+ }
202
+ const timeline = await analyticsService.getJobRunsOverTime(timeframe.period, targetUserId, dateUnit, repoIdentifier, timeframe.startDate, timeframe.endDate);
203
+ res.json(timeline);
204
+ }
205
+ catch (error) {
206
+ console.error('Error fetching job timeline:', error);
207
+ res.status(500).json({ error: 'Internal server error' });
208
+ }
209
+ finally {
210
+ if (dbService) {
211
+ await dbService.close();
212
+ }
213
+ }
214
+ });
215
+ /**
216
+ * GET /api/analytics/jobs/completion
217
+ * Get job completion metrics for analytics dashboard
218
+ */
219
+ router.get('/jobs/completion', async (req, res) => {
220
+ let dbService;
221
+ let analyticsService;
222
+ try {
223
+ const apiKeyData = req.apiKeyData;
224
+ if (!apiKeyData) {
225
+ return res.status(401).json({ error: 'Authentication required' });
226
+ }
227
+ const { jobName, apiKey, repoIdentifier } = req.query;
228
+ const timeframe = resolveAnalyticsTimeframe(req.query);
229
+ dbService = new db_service_js_1.FraimDbService();
230
+ await dbService.connect();
231
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
232
+ let targetUserId;
233
+ if (apiKey && typeof apiKey === 'string') {
234
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
235
+ if (!isAdmin) {
236
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
237
+ }
238
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
239
+ if (!targetApiKeyData) {
240
+ return res.status(404).json({ error: 'API key not found' });
241
+ }
242
+ targetUserId = targetApiKeyData.userId;
243
+ }
244
+ else {
245
+ targetUserId = apiKeyData.userId;
246
+ }
247
+ // Get completion metrics for all jobs or specific job
248
+ const metrics = await analyticsService.getJobCompletionMetrics(timeframe.period, jobName, targetUserId, repoIdentifier, timeframe.startDate, timeframe.endDate);
249
+ res.json(metrics);
250
+ }
251
+ catch (error) {
252
+ console.error('Error fetching job completion metrics:', error);
253
+ res.status(500).json({ error: 'Internal server error' });
254
+ }
255
+ finally {
256
+ if (dbService) {
257
+ await dbService.close();
258
+ }
259
+ }
260
+ });
261
+ /**
262
+ * GET /api/analytics/jobs/runs
263
+ * Get recent job runs for a specific job name (for clickable table rows)
264
+ */
265
+ router.get('/jobs/runs', async (req, res) => {
266
+ let dbService;
267
+ try {
268
+ const apiKeyData = req.apiKeyData;
269
+ if (!apiKeyData) {
270
+ return res.status(401).json({ error: 'Authentication required' });
271
+ }
272
+ const { period = '30d', jobName, limit = '50', repoIdentifier } = req.query;
273
+ const timeframe = resolveAnalyticsTimeframe(req.query);
274
+ if (!jobName || typeof jobName !== 'string') {
275
+ return res.status(400).json({ error: 'jobName query parameter is required' });
276
+ }
277
+ dbService = new db_service_js_1.FraimDbService();
278
+ await dbService.connect();
279
+ const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
280
+ const timeRangeDays = parsePeriodDays(timeframe.period);
281
+ const startDate = timeframe.startDate ?? new Date(Date.now() - timeRangeDays * 24 * 60 * 60 * 1000);
282
+ const runs = await dbService.getJobRuns(jobName, startDate, targetUserId, parseInt(limit, 10) || 50, repoIdentifier);
283
+ res.json(runs);
284
+ }
285
+ catch (error) {
286
+ if (error.status === 403)
287
+ return res.status(403).json({ error: error.message });
288
+ if (error.status === 404)
289
+ return res.status(404).json({ error: error.message });
290
+ console.error('Error fetching job runs:', error);
291
+ res.status(500).json({ error: 'Internal server error' });
292
+ }
293
+ finally {
294
+ if (dbService) {
295
+ await dbService.close();
296
+ }
297
+ }
298
+ });
299
+ /**
300
+ * GET /api/analytics/jobs/run/:jobId/timeline
301
+ * Get phase-by-phase timeline for a specific job run
302
+ */
303
+ router.get('/jobs/run/:jobId/timeline', async (req, res) => {
304
+ let dbService;
305
+ try {
306
+ const apiKeyData = req.apiKeyData;
307
+ if (!apiKeyData) {
308
+ return res.status(401).json({ error: 'Authentication required' });
309
+ }
310
+ const jobId = req.params.jobId;
311
+ if (!jobId) {
312
+ return res.status(400).json({ error: 'jobId parameter is required' });
313
+ }
314
+ dbService = new db_service_js_1.FraimDbService();
315
+ await dbService.connect();
316
+ const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
317
+ const jobName = req.query.jobName;
318
+ const events = await dbService.getJobRunTimeline(jobId, targetUserId, jobName);
319
+ // Calculate phase durations from consecutive events
320
+ const timeline = events.map((event, i) => {
321
+ const next = events[i + 1];
322
+ const durationMs = next ? new Date(next.createdAt).getTime() - new Date(event.createdAt).getTime() : null;
323
+ return {
324
+ ...event,
325
+ durationMs,
326
+ durationFormatted: durationMs !== null ? formatDuration(durationMs) : null
327
+ };
328
+ });
329
+ res.json(timeline);
330
+ }
331
+ catch (error) {
332
+ if (error.status === 403)
333
+ return res.status(403).json({ error: error.message });
334
+ if (error.status === 404)
335
+ return res.status(404).json({ error: error.message });
336
+ console.error('Error fetching job run timeline:', error);
337
+ res.status(500).json({ error: 'Internal server error' });
338
+ }
339
+ finally {
340
+ if (dbService) {
341
+ await dbService.close();
342
+ }
343
+ }
344
+ });
345
+ /**
346
+ * GET /api/analytics/jobs/:jobName/phases
347
+ * Get the declared FRAIM phase sequence for a job in registry order.
348
+ */
349
+ router.get('/jobs/:jobName/phases', async (req, res) => {
350
+ try {
351
+ const apiKeyData = req.apiKeyData;
352
+ if (!apiKeyData) {
353
+ return res.status(401).json({ error: 'Authentication required' });
354
+ }
355
+ const jobName = req.params.jobName;
356
+ if (!jobName) {
357
+ return res.status(400).json({ error: 'jobName parameter is required' });
358
+ }
359
+ const discriminant = typeof req.query.discriminant === 'string' ? req.query.discriminant : 'feature';
360
+ const phases = (0, catalog_1.loadJobPhases)(jobName, process.cwd(), discriminant).map(phase => ({
361
+ key: phase.id,
362
+ label: phase.label
363
+ }));
364
+ res.json({ jobName, phases });
365
+ }
366
+ catch (error) {
367
+ console.error('Error fetching job phases:', error);
368
+ res.status(500).json({ error: 'Internal server error' });
369
+ }
370
+ });
371
+ function formatDuration(ms) {
372
+ if (ms < 1000)
373
+ return `${ms}ms`;
374
+ if (ms < 60000)
375
+ return `${(ms / 1000).toFixed(1)}s`;
376
+ if (ms < 3600000)
377
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
378
+ return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`;
379
+ }
380
+ /**
381
+ * GET /api/analytics/jobs/:jobId/tokens
382
+ * Get per-job token usage (delta computed from cumulative snapshots)
383
+ */
384
+ router.get('/jobs/:jobId/tokens', async (req, res) => {
385
+ let dbService;
386
+ try {
387
+ const apiKeyData = req.apiKeyData;
388
+ if (!apiKeyData) {
389
+ return res.status(401).json({ error: 'Authentication required' });
390
+ }
391
+ const jobId = req.params.jobId;
392
+ if (!jobId) {
393
+ return res.status(400).json({ error: 'jobId parameter is required' });
394
+ }
395
+ dbService = new db_service_js_1.FraimDbService();
396
+ await dbService.connect();
397
+ const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
398
+ const result = await dbService.getTokenUsageByJob(jobId, targetUserId);
399
+ if (!result) {
400
+ return res.json({
401
+ jobId,
402
+ inputTokens: null,
403
+ outputTokens: null,
404
+ cacheReadTokens: null,
405
+ cacheCreationTokens: null,
406
+ costUsd: null,
407
+ model: null,
408
+ isPartial: false,
409
+ snapshotCount: 0,
410
+ });
411
+ }
412
+ res.json(result);
413
+ }
414
+ catch (error) {
415
+ if (error.status === 403)
416
+ return res.status(403).json({ error: error.message });
417
+ if (error.status === 404)
418
+ return res.status(404).json({ error: error.message });
419
+ console.error('Error fetching job token usage:', error);
420
+ res.status(500).json({ error: 'Internal server error' });
421
+ }
422
+ finally {
423
+ if (dbService) {
424
+ await dbService.close();
425
+ }
426
+ }
427
+ });
428
+ /**
429
+ * GET /api/analytics/usage/aggregate
430
+ * Issue #330 / R2.1 — token + cost aggregate for the You / Team accordion.
431
+ * Honors apiKey admin-gate (C2) and rejects local-path repoIdentifier (C3).
432
+ */
433
+ router.get('/usage/aggregate', async (req, res) => {
434
+ let dbService;
435
+ try {
436
+ const apiKeyData = req.apiKeyData;
437
+ if (!apiKeyData) {
438
+ return res.status(401).json({ error: 'Authentication required' });
439
+ }
440
+ const { apiKey, repoIdentifier } = req.query;
441
+ const timeframe = resolveAnalyticsTimeframe(req.query);
442
+ // C3 — reject explicit local-path repoIdentifier inputs early.
443
+ if (repoIdentifier && typeof repoIdentifier === 'string' && repoIdentifier.length > 0 && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier)) {
444
+ return res.status(400).json({ error: 'Invalid repoIdentifier — local paths are not permitted' });
445
+ }
446
+ dbService = new db_service_js_1.FraimDbService();
447
+ await dbService.connect();
448
+ // C2 — admin-only when querying another user's data.
449
+ let targetUserId;
450
+ if (apiKey && typeof apiKey === 'string') {
451
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
452
+ if (!isAdmin) {
453
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
454
+ }
455
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
456
+ if (!targetApiKeyData) {
457
+ return res.status(404).json({ error: 'API key not found' });
458
+ }
459
+ targetUserId = targetApiKeyData.userId;
460
+ }
461
+ else {
462
+ targetUserId = apiKeyData.userId;
463
+ }
464
+ const aggregate = await dbService.getUsageAggregate({
465
+ userId: targetUserId,
466
+ startDate: timeframe.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
467
+ endDate: timeframe.endDate,
468
+ repoIdentifier: typeof repoIdentifier === 'string' ? repoIdentifier : undefined,
469
+ });
470
+ res.json(aggregate);
471
+ }
472
+ catch (error) {
473
+ console.error('Error fetching usage aggregate:', error);
474
+ res.status(500).json({ error: 'Internal server error' });
475
+ }
476
+ finally {
477
+ if (dbService) {
478
+ await dbService.close();
479
+ }
480
+ }
481
+ });
482
+ /**
483
+ * GET /api/analytics/jobs/:jobName/agent-summary
484
+ * Issue #330 / R2.8 — per-job-name agent split for the per-job page.
485
+ */
486
+ router.get('/jobs/:jobName/agent-summary', async (req, res) => {
487
+ let dbService;
488
+ try {
489
+ const apiKeyData = req.apiKeyData;
490
+ if (!apiKeyData) {
491
+ return res.status(401).json({ error: 'Authentication required' });
492
+ }
493
+ const jobName = String(req.params.jobName || '');
494
+ if (!jobName) {
495
+ return res.status(400).json({ error: 'jobName parameter is required' });
496
+ }
497
+ const { repoIdentifier } = req.query;
498
+ const timeframe = resolveAnalyticsTimeframe(req.query);
499
+ if (repoIdentifier && typeof repoIdentifier === 'string' && repoIdentifier.length > 0 && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier)) {
500
+ return res.status(400).json({ error: 'Invalid repoIdentifier — local paths are not permitted' });
501
+ }
502
+ dbService = new db_service_js_1.FraimDbService();
503
+ await dbService.connect();
504
+ const targetUserId = await resolveTargetUserIdFromQuery(dbService, apiKeyData, req.query);
505
+ const summary = await dbService.getJobAgentSummary({
506
+ jobName,
507
+ userId: targetUserId,
508
+ startDate: timeframe.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
509
+ endDate: timeframe.endDate,
510
+ repoIdentifier: typeof repoIdentifier === 'string' ? repoIdentifier : undefined,
511
+ });
512
+ res.json(summary);
513
+ }
514
+ catch (error) {
515
+ if (error.status === 403)
516
+ return res.status(403).json({ error: error.message });
517
+ if (error.status === 404)
518
+ return res.status(404).json({ error: error.message });
519
+ console.error('Error fetching job agent summary:', error);
520
+ res.status(500).json({ error: 'Internal server error' });
521
+ }
522
+ finally {
523
+ if (dbService) {
524
+ await dbService.close();
525
+ }
526
+ }
527
+ });
528
+ /**
529
+ * GET /api/analytics/jobs/token-summary
530
+ * Aggregated token usage across all jobs for a user
531
+ */
532
+ router.get('/jobs/token-summary', async (req, res) => {
533
+ let dbService;
534
+ try {
535
+ const apiKeyData = req.apiKeyData;
536
+ if (!apiKeyData) {
537
+ return res.status(401).json({ error: 'Authentication required' });
538
+ }
539
+ const period = req.query.period || '30d';
540
+ const daysMatch = period.match(/^(\d+)d$/);
541
+ const days = daysMatch ? parseInt(daysMatch[1], 10) : 30;
542
+ const startDate = new Date();
543
+ startDate.setDate(startDate.getDate() - days);
544
+ dbService = new db_service_js_1.FraimDbService();
545
+ await dbService.connect();
546
+ const result = await dbService.getTokenUsageSummary(apiKeyData.userId, startDate);
547
+ res.json(result);
548
+ }
549
+ catch (error) {
550
+ console.error('Error fetching token usage summary:', error);
551
+ res.status(500).json({ error: 'Internal server error' });
552
+ }
553
+ finally {
554
+ if (dbService) {
555
+ await dbService.close();
556
+ }
557
+ }
558
+ });
559
+ /**
560
+ * GET /api/analytics/export
561
+ * Export usage data as CSV
562
+ */
563
+ router.get('/export', async (req, res) => {
564
+ let dbService;
565
+ let analyticsService;
566
+ try {
567
+ // Get API key data from middleware
568
+ const apiKeyData = req.apiKeyData;
569
+ if (!apiKeyData) {
570
+ return res.status(401).json({ error: 'Authentication required' });
571
+ }
572
+ const { format = 'csv', limit = '100', apiKey, repoIdentifier } = req.query;
573
+ const timeframe = resolveAnalyticsTimeframe(req.query);
574
+ if (format !== 'csv') {
575
+ return res.status(400).json({ error: 'Only CSV format is currently supported' });
576
+ }
577
+ // Create database connection
578
+ dbService = new db_service_js_1.FraimDbService();
579
+ await dbService.connect();
580
+ // Create analytics service
581
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
582
+ let targetUserId;
583
+ if (apiKey && typeof apiKey === 'string') {
584
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
585
+ if (!isAdmin) {
586
+ return res.status(403).json({ error: 'Admin access required to view other users data' });
587
+ }
588
+ const targetApiKeyData = await dbService.getApiKeyByKey(apiKey);
589
+ if (!targetApiKeyData) {
590
+ return res.status(404).json({ error: 'API key not found' });
591
+ }
592
+ targetUserId = targetApiKeyData.userId;
593
+ }
594
+ else {
595
+ targetUserId = apiKeyData.userId;
596
+ }
597
+ const csvData = await analyticsService.exportUsageData({
598
+ period: timeframe.period,
599
+ startDate: timeframe.startDate,
600
+ endDate: timeframe.endDate,
601
+ limit: parseInt(limit, 10),
602
+ userId: targetUserId,
603
+ repoIdentifier: repoIdentifier
604
+ });
605
+ res.setHeader('Content-Type', 'text/csv');
606
+ res.setHeader('Content-Disposition', `attachment; filename="usage-analytics-${timeframe.period}.csv"`);
607
+ res.send(csvData);
608
+ }
609
+ catch (error) {
610
+ console.error('Error exporting usage data:', error);
611
+ res.status(500).json({ error: 'Internal server error' });
612
+ }
613
+ finally {
614
+ if (dbService) {
615
+ await dbService.close();
616
+ }
617
+ }
618
+ });
619
+ /**
620
+ * GET /api/analytics/users
621
+ * Get list of users with their API keys and event counts (admin only)
622
+ */
623
+ router.get('/users', async (req, res) => {
624
+ let dbService;
625
+ try {
626
+ // Get API key data from middleware
627
+ const apiKeyData = req.apiKeyData;
628
+ if (!apiKeyData) {
629
+ return res.status(401).json({ error: 'Authentication required' });
630
+ }
631
+ // Check if user is admin (FRAIM builder)
632
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
633
+ if (!isAdmin) {
634
+ return res.status(403).json({ error: 'Admin access required' });
635
+ }
636
+ // Create database connection
637
+ dbService = new db_service_js_1.FraimDbService();
638
+ await dbService.connect();
639
+ // Get all API keys
640
+ const apiKeys = await dbService.listApiKeys();
641
+ // Get event counts for each API key
642
+ const db = await dbService.getDb();
643
+ const usageCollection = db.collection('fraim_usage_events');
644
+ const usersWithCounts = await Promise.all(apiKeys.map(async (apiKey) => {
645
+ const eventCount = await usageCollection.countDocuments({
646
+ userId: apiKey.userId
647
+ });
648
+ return {
649
+ key: apiKey.key,
650
+ userId: apiKey.userId,
651
+ eventCount,
652
+ status: apiKey.status || 'active'
653
+ };
654
+ }));
655
+ // Sort by event count (descending) then by userId
656
+ usersWithCounts.sort((a, b) => {
657
+ if (b.eventCount !== a.eventCount) {
658
+ return b.eventCount - a.eventCount;
659
+ }
660
+ return a.userId.localeCompare(b.userId);
661
+ });
662
+ res.json(usersWithCounts);
663
+ }
664
+ catch (error) {
665
+ console.error('Error fetching users:', error);
666
+ res.status(500).json({ error: 'Internal server error' });
667
+ }
668
+ finally {
669
+ if (dbService) {
670
+ await dbService.close();
671
+ }
672
+ }
673
+ });
674
+ /**
675
+ * GET /api/analytics/repos
676
+ * Get unique repositories that have generated telemetry for a user
677
+ */
678
+ router.get('/repos', async (req, res) => {
679
+ let dbService;
680
+ let analyticsService;
681
+ try {
682
+ const apiKeyData = req.apiKeyData;
683
+ if (!apiKeyData) {
684
+ return res.status(401).json({ error: 'Authentication required' });
685
+ }
686
+ dbService = new db_service_js_1.FraimDbService();
687
+ await dbService.connect();
688
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
689
+ const repos = await analyticsService.getUniqueRepos(apiKeyData.userId);
690
+ res.json(repos);
691
+ }
692
+ catch (error) {
693
+ console.error('Error fetching repositories:', error);
694
+ res.status(500).json({ error: 'Internal server error' });
695
+ }
696
+ finally {
697
+ if (dbService) {
698
+ await dbService.close();
699
+ }
700
+ }
701
+ });
702
+ /**
703
+ * POST /api/analytics/events
704
+ * Batch upload usage events (for telemetry integration)
705
+ */
706
+ router.post('/events', async (req, res) => {
707
+ let dbService;
708
+ let analyticsService;
709
+ try {
710
+ const apiKeyData = req.apiKeyData;
711
+ const { events } = req.body;
712
+ if (!Array.isArray(events)) {
713
+ return res.status(400).json({ error: 'Events must be an array' });
714
+ }
715
+ // Create database connection
716
+ dbService = new db_service_js_1.FraimDbService();
717
+ await dbService.connect();
718
+ // Create analytics service
719
+ analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
720
+ // Process events in batch
721
+ const processedEvents = events.map(event => ({
722
+ type: event.type,
723
+ name: event.name,
724
+ userId: apiKeyData.userId,
725
+ sessionId: event.sessionId,
726
+ success: event.success !== false, // default to true
727
+ category: event.category || 'uncategorized',
728
+ args: event.args && typeof event.args === 'object' ? event.args : undefined,
729
+ jobId: event.jobId,
730
+ jobPhase: event.jobPhase,
731
+ tokenSnapshot: event.tokenSnapshot && typeof event.tokenSnapshot === 'object'
732
+ ? event.tokenSnapshot
733
+ : undefined,
734
+ repoIdentifier: event.repoIdentifier,
735
+ // Issue #330: pass through agent attribution + capture-coverage signaling
736
+ agentName: typeof event.agentName === 'string' ? event.agentName.toLowerCase() : undefined,
737
+ agentModel: typeof event.agentModel === 'string' ? event.agentModel : undefined,
738
+ tokenCaptureUnavailableReason: typeof event.tokenCaptureUnavailableReason === 'string'
739
+ ? event.tokenCaptureUnavailableReason
740
+ : undefined,
741
+ }));
742
+ // Persist uploaded telemetry immediately. This endpoint already receives
743
+ // batches from the local proxy, so re-queueing inside a per-request
744
+ // service instance only adds nondeterministic timing.
745
+ if (!analyticsService) {
746
+ throw new Error('Analytics service not initialized');
747
+ }
748
+ await Promise.all(processedEvents.map(event => analyticsService.logUsageImmediate(event)));
749
+ res.json({
750
+ message: 'Events processed',
751
+ count: processedEvents.length
752
+ });
753
+ }
754
+ catch (error) {
755
+ console.error('Error processing events:', error);
756
+ res.status(500).json({ error: 'Internal server error' });
757
+ }
758
+ finally {
759
+ if (dbService) {
760
+ await dbService.close();
761
+ }
762
+ }
763
+ });
764
+ /**
765
+ * POST /api/analytics/quality-score
766
+ *
767
+ * Emit a quality score for a completed quality-producing job (Issue #251).
768
+ *
769
+ * Called by the local MCP proxy on the final seekMentoring completion of a
770
+ * quality-producing job. The local proxy short-circuits seekMentoring for
771
+ * performance and personalized-job support, so the server-side mcp-service
772
+ * enforcement in handleSeekMentoring is rarely reached in practice. This
773
+ * dedicated endpoint is the primary write path for `fraim_quality_scores`.
774
+ *
775
+ * Request body:
776
+ * {
777
+ * jobName: string // must be in QUALITY_PRODUCING_JOBS
778
+ * jobId: string
779
+ * sessionId: string
780
+ * quality: object // must validate per REQUIRED_QUALITY_FIELDS
781
+ * artifactPath: string
782
+ * repoIdentifier?: string
783
+ * reviewContext?: {
784
+ * subjectType?: string
785
+ * subjectLabel?: string
786
+ * reviewRef?: string
787
+ * scopeSummary?: string
788
+ * repoIdentifier?: string
789
+ * branchRef?: string
790
+ * }
791
+ * }
792
+ *
793
+ * Responses:
794
+ * 200 { message: "Quality score recorded" }
795
+ * 400 { error, details? } — validation failed
796
+ * 401 { error } — missing api key
797
+ */
798
+ router.post('/quality-score', async (req, res) => {
799
+ let dbService;
800
+ try {
801
+ const apiKeyData = req.apiKeyData;
802
+ if (!apiKeyData) {
803
+ return res.status(401).json({ error: 'Authentication required' });
804
+ }
805
+ const { jobName, jobId, sessionId, quality, artifactPath, repoIdentifier, reviewContext } = req.body || {};
806
+ if (typeof jobName !== 'string' || !jobName) {
807
+ return res.status(400).json({ error: 'jobName is required' });
808
+ }
809
+ if (!mcp_service_js_1.QUALITY_PRODUCING_JOBS.includes(jobName)) {
810
+ return res.status(400).json({
811
+ error: `jobName "${jobName}" is not a quality-producing job`,
812
+ allowed: mcp_service_js_1.QUALITY_PRODUCING_JOBS
813
+ });
814
+ }
815
+ if (typeof jobId !== 'string' || !jobId) {
816
+ return res.status(400).json({ error: 'jobId is required' });
817
+ }
818
+ if (typeof sessionId !== 'string' || !sessionId) {
819
+ return res.status(400).json({ error: 'sessionId is required' });
820
+ }
821
+ const qualityErrors = mcp_service_js_1.McpService.validateQualityEvidence(quality, jobName);
822
+ if (qualityErrors) {
823
+ return res.status(400).json({
824
+ error: 'evidence.quality failed validation',
825
+ details: qualityErrors
826
+ });
827
+ }
828
+ if (typeof artifactPath !== 'string' || !artifactPath.trim()) {
829
+ return res.status(400).json({ error: 'artifactPath is required' });
830
+ }
831
+ dbService = new db_service_js_1.FraimDbService();
832
+ await dbService.connect();
833
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
834
+ const normalizedReviewContext = reviewContext && typeof reviewContext === 'object' && !Array.isArray(reviewContext)
835
+ ? {
836
+ subjectType: typeof reviewContext.subjectType === 'string' ? reviewContext.subjectType : undefined,
837
+ subjectLabel: typeof reviewContext.subjectLabel === 'string' ? reviewContext.subjectLabel : undefined,
838
+ reviewRef: typeof reviewContext.reviewRef === 'string' ? reviewContext.reviewRef : undefined,
839
+ scopeSummary: typeof reviewContext.scopeSummary === 'string' ? reviewContext.scopeSummary : undefined,
840
+ repoIdentifier: typeof reviewContext.repoIdentifier === 'string' ? reviewContext.repoIdentifier : undefined,
841
+ branchRef: typeof reviewContext.branchRef === 'string' ? reviewContext.branchRef : undefined
842
+ }
843
+ : undefined;
844
+ let effectiveRepoIdentifier = typeof repoIdentifier === 'string' ? repoIdentifier : normalizedReviewContext?.repoIdentifier;
845
+ if (!effectiveRepoIdentifier && typeof apiKeyData.key === 'string') {
846
+ const session = await dbService.getSessionByApiKeyAndSessionId(apiKeyData.key, sessionId);
847
+ const sessionRepoUrl = typeof session?.repo?.url === 'string' ? session.repo.url : undefined;
848
+ effectiveRepoIdentifier = sessionRepoUrl;
849
+ }
850
+ await analyticsService.logQualityScore(apiKeyData.userId, jobName, jobId, sessionId, quality, typeof artifactPath === 'string' ? artifactPath : undefined, effectiveRepoIdentifier, normalizedReviewContext);
851
+ res.json({ message: 'Quality score recorded' });
852
+ }
853
+ catch (error) {
854
+ console.error('[quality-score] Error:', error);
855
+ res.status(500).json({ error: 'Failed to record quality score' });
856
+ }
857
+ finally {
858
+ if (dbService) {
859
+ try {
860
+ await dbService.close();
861
+ }
862
+ catch { /* ignore */ }
863
+ }
864
+ }
865
+ });
866
+ /**
867
+ * GET /api/analytics/me
868
+ * Get current user information
869
+ */
870
+ router.get('/me', async (req, res) => {
871
+ try {
872
+ // Get API key data from middleware
873
+ const apiKeyData = req.apiKeyData;
874
+ if (!apiKeyData) {
875
+ return res.status(401).json({ error: 'Authentication required' });
876
+ }
877
+ res.json({
878
+ userId: apiKeyData.userId,
879
+ isAdmin: apiKeyData.userId === 'sid.mathur@gmail.com',
880
+ ...serializeKeyLifecycle(apiKeyData),
881
+ });
882
+ }
883
+ catch (error) {
884
+ console.error('Error getting user info:', error);
885
+ res.status(500).json({ error: 'Failed to get user information' });
886
+ }
887
+ });
888
+ // ===== TEAM ROUTES =====
889
+ function parsePeriodDays(period) {
890
+ const n = parseInt(period);
891
+ return isNaN(n) ? 30 : n;
892
+ }
893
+ function parseDateParam(value, endOfDay = false) {
894
+ if (typeof value !== 'string' || !value.trim())
895
+ return undefined;
896
+ const date = new Date(value);
897
+ if (Number.isNaN(date.getTime()))
898
+ return undefined;
899
+ if (endOfDay) {
900
+ date.setHours(23, 59, 59, 999);
901
+ }
902
+ else {
903
+ date.setHours(0, 0, 0, 0);
904
+ }
905
+ return date;
906
+ }
907
+ function resolveAnalyticsTimeframe(query) {
908
+ const period = typeof query.period === 'string' && query.period ? query.period : '30d';
909
+ const startDate = parseDateParam(query.startDate);
910
+ const endDate = parseDateParam(query.endDate, true);
911
+ if ((query.startDate || query.endDate) && (!startDate || !endDate)) {
912
+ const err = new Error('Both startDate and endDate must be valid ISO date strings');
913
+ err.status = 400;
914
+ throw err;
915
+ }
916
+ if (startDate && endDate && startDate > endDate) {
917
+ const err = new Error('startDate must be on or before endDate');
918
+ err.status = 400;
919
+ throw err;
920
+ }
921
+ return startDate && endDate ? { period: 'custom', startDate, endDate } : { period };
922
+ }
923
+ function serializeKeyLifecycle(keyData) {
924
+ if (!keyData) {
925
+ return { status: null, expiresAt: null };
926
+ }
927
+ return {
928
+ status: keyData.status ?? 'active',
929
+ expiresAt: keyData.expiresAt instanceof Date ? keyData.expiresAt.toISOString() : null,
930
+ };
931
+ }
932
+ async function resolveManagerMember(dbService, managerId, memberId) {
933
+ const members = await dbService.getTeamMembers(managerId);
934
+ if (!members.find(m => m.memberId === memberId)) {
935
+ const err = new Error('Not authorized to view this member');
936
+ err.status = 403;
937
+ throw err;
938
+ }
939
+ return memberId;
940
+ }
941
+ /**
942
+ * Resolve the userId to query based on optional delegation params.
943
+ *
944
+ * Accepts ?userId=<email> (manager → team member, checked via resolveManagerMember)
945
+ * or the legacy ?apiKey=<key> (admin-only backward compat).
946
+ * Falls back to the authenticated caller's own userId.
947
+ */
948
+ async function resolveTargetUserIdFromQuery(dbService, apiKeyData, query) {
949
+ const targetUserIdParam = typeof query.userId === 'string' ? query.userId : undefined;
950
+ const targetApiKeyParam = typeof query.apiKey === 'string' ? query.apiKey : undefined;
951
+ if (targetUserIdParam && targetUserIdParam !== apiKeyData.userId) {
952
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
953
+ if (!isAdmin) {
954
+ await resolveManagerMember(dbService, apiKeyData.userId, targetUserIdParam);
955
+ }
956
+ return targetUserIdParam;
957
+ }
958
+ if (targetApiKeyParam) {
959
+ const isAdmin = apiKeyData.userId === 'sid.mathur@gmail.com';
960
+ if (!isAdmin) {
961
+ const err = new Error('Admin access required to view other users data');
962
+ err.status = 403;
963
+ throw err;
964
+ }
965
+ const targetApiKeyData = await dbService.getApiKeyByKey(targetApiKeyParam);
966
+ if (!targetApiKeyData) {
967
+ const err = new Error('API key not found');
968
+ err.status = 404;
969
+ throw err;
970
+ }
971
+ return targetApiKeyData.userId;
972
+ }
973
+ return apiKeyData.userId;
974
+ }
975
+ /**
976
+ * GET /api/analytics/team/members/list
977
+ * Returns team member IDs only — fast, no stats queries.
978
+ * Used by the dashboard to render the member list immediately before lazy-loading stats.
979
+ */
980
+ router.get('/team/members/list', async (req, res) => {
981
+ let dbService;
982
+ try {
983
+ const apiKeyData = req.apiKeyData;
984
+ if (!apiKeyData)
985
+ return res.status(401).json({ error: 'Authentication required' });
986
+ dbService = new db_service_js_1.FraimDbService();
987
+ await dbService.connect();
988
+ const members = await dbService.getTeamMembers(apiKeyData.userId);
989
+ res.json(members.map(m => ({ memberId: m.memberId, labels: m.labels || [] })));
990
+ }
991
+ catch (error) {
992
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load team' });
993
+ }
994
+ finally {
995
+ if (dbService)
996
+ await dbService.close();
997
+ }
998
+ });
999
+ /**
1000
+ * GET /api/analytics/team/members/:memberId/summary?period=30d
1001
+ * Returns stats for a single member. Called lazily per-member after the list renders.
1002
+ */
1003
+ router.get('/team/members/:memberId/summary', async (req, res) => {
1004
+ let dbService;
1005
+ try {
1006
+ const apiKeyData = req.apiKeyData;
1007
+ if (!apiKeyData)
1008
+ return res.status(401).json({ error: 'Authentication required' });
1009
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1010
+ const memberId = req.params.memberId;
1011
+ dbService = new db_service_js_1.FraimDbService();
1012
+ await dbService.connect();
1013
+ await resolveManagerMember(dbService, apiKeyData.userId, memberId); // auth check
1014
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
1015
+ const [dominant, stats, allTimeLastEventAt, hasFraimSetup, keyLifecycle] = await Promise.allSettled([
1016
+ analyticsService.getDominantJobCategory(timeframe.period, memberId, undefined, timeframe.startDate, timeframe.endDate),
1017
+ analyticsService.getUsageStats({ period: timeframe.period, startDate: timeframe.startDate, endDate: timeframe.endDate, types: ['job'], userId: memberId }),
1018
+ dbService.getLatestUsageEventAt(memberId, ['job']),
1019
+ dbService.hasFraimSetup(memberId),
1020
+ dbService.getApiKeyByUserId(memberId, false),
1021
+ ]);
1022
+ const lastEventAt = stats.status === 'fulfilled' ? stats.value.lastEventAt ?? null : null;
1023
+ const historicalLastEventAt = allTimeLastEventAt.status === 'fulfilled' ? allTimeLastEventAt.value ?? null : null;
1024
+ const lifecycle = keyLifecycle.status === 'fulfilled'
1025
+ ? serializeKeyLifecycle(keyLifecycle.value)
1026
+ : { status: null, expiresAt: null };
1027
+ res.json({
1028
+ memberId,
1029
+ dominantCategory: dominant.status === 'fulfilled' ? dominant.value?.category ?? null : null,
1030
+ dominantCategoryCount: dominant.status === 'fulfilled' ? dominant.value?.count ?? 0 : 0,
1031
+ totalEvents: stats.status === 'fulfilled' ? stats.value.totalEvents : 0,
1032
+ hasFraimSetup: hasFraimSetup.status === 'fulfilled' ? hasFraimSetup.value : false,
1033
+ lastEventAt,
1034
+ allTimeLastEventAt: historicalLastEventAt,
1035
+ lastActiveAt: lastEventAt,
1036
+ ...lifecycle,
1037
+ });
1038
+ }
1039
+ catch (error) {
1040
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load member summary' });
1041
+ }
1042
+ finally {
1043
+ if (dbService)
1044
+ await dbService.close();
1045
+ }
1046
+ });
1047
+ /**
1048
+ * GET /api/analytics/team/members?period=30d
1049
+ * Returns team members with dominant category (manager view).
1050
+ * Kept for backwards compatibility — prefer /list + /summary for non-blocking UI.
1051
+ */
1052
+ router.get('/team/members', async (req, res) => {
1053
+ let dbService;
1054
+ try {
1055
+ const apiKeyData = req.apiKeyData;
1056
+ if (!apiKeyData)
1057
+ return res.status(401).json({ error: 'Authentication required' });
1058
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1059
+ dbService = new db_service_js_1.FraimDbService();
1060
+ await dbService.connect();
1061
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
1062
+ const members = await dbService.getTeamMembers(apiKeyData.userId);
1063
+ if (members.length === 0)
1064
+ return res.json([]);
1065
+ const results = await Promise.allSettled(members.map(async (m) => {
1066
+ const [dominant, stats, allTimeLastEventAt, hasFraimSetup] = await Promise.allSettled([
1067
+ analyticsService.getDominantJobCategory(timeframe.period, m.memberId, undefined, timeframe.startDate, timeframe.endDate),
1068
+ analyticsService.getUsageStats({ period: timeframe.period, startDate: timeframe.startDate, endDate: timeframe.endDate, types: ['job'], userId: m.memberId }),
1069
+ dbService.getLatestUsageEventAt(m.memberId, ['job']),
1070
+ dbService.hasFraimSetup(m.memberId)
1071
+ ]);
1072
+ const lastEventAt = stats.status === 'fulfilled' ? stats.value.lastEventAt ?? null : null;
1073
+ const historicalLastEventAt = allTimeLastEventAt.status === 'fulfilled' ? allTimeLastEventAt.value ?? null : null;
1074
+ return {
1075
+ memberId: m.memberId,
1076
+ labels: m.labels || [],
1077
+ dominantCategory: dominant.status === 'fulfilled' ? dominant.value?.category ?? null : null,
1078
+ dominantCategoryCount: dominant.status === 'fulfilled' ? dominant.value?.count ?? 0 : 0,
1079
+ totalEvents: stats.status === 'fulfilled' ? stats.value.totalEvents : 0,
1080
+ hasFraimSetup: hasFraimSetup.status === 'fulfilled' ? hasFraimSetup.value : false,
1081
+ lastEventAt,
1082
+ allTimeLastEventAt: historicalLastEventAt,
1083
+ lastActiveAt: lastEventAt
1084
+ };
1085
+ }));
1086
+ res.json(results
1087
+ .filter(r => r.status === 'fulfilled')
1088
+ .map(r => r.value));
1089
+ }
1090
+ catch (error) {
1091
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load team' });
1092
+ }
1093
+ finally {
1094
+ if (dbService)
1095
+ await dbService.close();
1096
+ }
1097
+ });
1098
+ /**
1099
+ * GET /api/analytics/team/member/:memberId/stats
1100
+ */
1101
+ router.get('/team/member/:memberId/stats', async (req, res) => {
1102
+ let dbService;
1103
+ try {
1104
+ const apiKeyData = req.apiKeyData;
1105
+ if (!apiKeyData)
1106
+ return res.status(401).json({ error: 'Authentication required' });
1107
+ const { memberId } = req.params;
1108
+ const { repoIdentifier } = req.query;
1109
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1110
+ dbService = new db_service_js_1.FraimDbService();
1111
+ await dbService.connect();
1112
+ const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
1113
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
1114
+ const stats = await analyticsService.getUsageStats({
1115
+ period: timeframe.period,
1116
+ startDate: timeframe.startDate,
1117
+ endDate: timeframe.endDate,
1118
+ types: ['job'],
1119
+ userId: memberUserId,
1120
+ repoIdentifier: repoIdentifier
1121
+ });
1122
+ res.json(stats);
1123
+ }
1124
+ catch (error) {
1125
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load member stats' });
1126
+ }
1127
+ finally {
1128
+ if (dbService)
1129
+ await dbService.close();
1130
+ }
1131
+ });
1132
+ /**
1133
+ * GET /api/analytics/team/member/:memberId/usage/aggregate
1134
+ * Issue #330 / R2.5 — token + cost aggregate for a team member, scoped
1135
+ * to the calling manager's permission set via resolveManagerMember.
1136
+ */
1137
+ router.get('/team/member/:memberId/usage/aggregate', async (req, res) => {
1138
+ let dbService;
1139
+ try {
1140
+ const apiKeyData = req.apiKeyData;
1141
+ if (!apiKeyData)
1142
+ return res.status(401).json({ error: 'Authentication required' });
1143
+ const { memberId } = req.params;
1144
+ const { repoIdentifier } = req.query;
1145
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1146
+ if (repoIdentifier && typeof repoIdentifier === 'string' && repoIdentifier.length > 0 && (0, git_utils_js_1.isExplicitLocalRepoPath)(repoIdentifier)) {
1147
+ return res.status(400).json({ error: 'Invalid repoIdentifier — local paths are not permitted' });
1148
+ }
1149
+ dbService = new db_service_js_1.FraimDbService();
1150
+ await dbService.connect();
1151
+ const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
1152
+ const aggregate = await dbService.getUsageAggregate({
1153
+ userId: memberUserId,
1154
+ startDate: timeframe.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
1155
+ endDate: timeframe.endDate,
1156
+ repoIdentifier: typeof repoIdentifier === 'string' ? repoIdentifier : undefined,
1157
+ });
1158
+ res.json(aggregate);
1159
+ }
1160
+ catch (error) {
1161
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load member usage aggregate' });
1162
+ }
1163
+ finally {
1164
+ if (dbService)
1165
+ await dbService.close();
1166
+ }
1167
+ });
1168
+ /**
1169
+ * GET /api/analytics/team/member/:memberId/components/top
1170
+ */
1171
+ router.get('/team/member/:memberId/components/top', async (req, res) => {
1172
+ let dbService;
1173
+ try {
1174
+ const apiKeyData = req.apiKeyData;
1175
+ if (!apiKeyData)
1176
+ return res.status(401).json({ error: 'Authentication required' });
1177
+ const { memberId } = req.params;
1178
+ const { limit = '10', repoIdentifier } = req.query;
1179
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1180
+ dbService = new db_service_js_1.FraimDbService();
1181
+ await dbService.connect();
1182
+ const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
1183
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
1184
+ const components = await analyticsService.getTopComponents(parseInt(limit, 10), timeframe.period, ['job'], memberUserId, repoIdentifier, timeframe.startDate, timeframe.endDate);
1185
+ res.json(components);
1186
+ }
1187
+ catch (error) {
1188
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load member components' });
1189
+ }
1190
+ finally {
1191
+ if (dbService)
1192
+ await dbService.close();
1193
+ }
1194
+ });
1195
+ /**
1196
+ * GET /api/analytics/team/member/:memberId/jobs/timeline
1197
+ */
1198
+ router.get('/team/member/:memberId/jobs/timeline', async (req, res) => {
1199
+ let dbService;
1200
+ try {
1201
+ const apiKeyData = req.apiKeyData;
1202
+ if (!apiKeyData)
1203
+ return res.status(401).json({ error: 'Authentication required' });
1204
+ const { memberId } = req.params;
1205
+ const { dateUnit, repoIdentifier } = req.query;
1206
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1207
+ dbService = new db_service_js_1.FraimDbService();
1208
+ await dbService.connect();
1209
+ const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
1210
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
1211
+ const timeline = await analyticsService.getJobRunsOverTime(timeframe.period, memberUserId, dateUnit, repoIdentifier, timeframe.startDate, timeframe.endDate);
1212
+ res.json(timeline);
1213
+ }
1214
+ catch (error) {
1215
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load member timeline' });
1216
+ }
1217
+ finally {
1218
+ if (dbService)
1219
+ await dbService.close();
1220
+ }
1221
+ });
1222
+ router.get('/team/member/:memberId/repos', async (req, res) => {
1223
+ let dbService;
1224
+ try {
1225
+ const apiKeyData = req.apiKeyData;
1226
+ if (!apiKeyData)
1227
+ return res.status(401).json({ error: 'Authentication required' });
1228
+ const { memberId } = req.params;
1229
+ dbService = new db_service_js_1.FraimDbService();
1230
+ await dbService.connect();
1231
+ const memberUserId = await resolveManagerMember(dbService, apiKeyData.userId, memberId);
1232
+ const analyticsService = new usage_analytics_service_js_1.UsageAnalyticsService(dbService);
1233
+ const repos = await analyticsService.getUniqueRepos(memberUserId);
1234
+ res.json(repos);
1235
+ }
1236
+ catch (error) {
1237
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load member repositories' });
1238
+ }
1239
+ finally {
1240
+ if (dbService)
1241
+ await dbService.close();
1242
+ }
1243
+ });
1244
+ /**
1245
+ * GET /api/analytics/team/my-managers
1246
+ * Returns list of manager emails for the current user (for transparency notice)
1247
+ */
1248
+ router.get('/team/my-managers', async (req, res) => {
1249
+ let dbService;
1250
+ try {
1251
+ const apiKeyData = req.apiKeyData;
1252
+ if (!apiKeyData)
1253
+ return res.status(401).json({ error: 'Authentication required' });
1254
+ dbService = new db_service_js_1.FraimDbService();
1255
+ await dbService.connect();
1256
+ const managers = await dbService.getManagersForMember(apiKeyData.userId);
1257
+ res.json(managers);
1258
+ }
1259
+ catch (error) {
1260
+ res.status(500).json({ error: 'Failed to load managers' });
1261
+ }
1262
+ finally {
1263
+ if (dbService)
1264
+ await dbService.close();
1265
+ }
1266
+ });
1267
+ /**
1268
+ * GET /api/analytics/team/member/:memberId/quality/interviews
1269
+ * Returns per-interview quality scores and latest gate decision for a team member.
1270
+ * Used by the manager dashboard Quality tab (Issue #251).
1271
+ */
1272
+ router.get('/team/member/:memberId/quality/interviews', async (req, res) => {
1273
+ let dbService;
1274
+ try {
1275
+ const apiKeyData = req.apiKeyData;
1276
+ if (!apiKeyData)
1277
+ return res.status(401).json({ error: 'Authentication required' });
1278
+ const { memberId } = req.params;
1279
+ const { repoIdentifier } = req.query;
1280
+ dbService = new db_service_js_1.FraimDbService();
1281
+ await dbService.connect();
1282
+ // Privacy check: manager must have active relationship with member
1283
+ await resolveManagerMember(dbService, apiKeyData.userId, memberId);
1284
+ // Fetch interview quality scores (chronological)
1285
+ const interviewScores = await dbService.getQualityScores(memberId, 'process-interview-notes', undefined, repoIdentifier);
1286
+ // Fetch latest triage/gate decision
1287
+ const latestGate = await dbService.getLatestQualityScore(memberId, 'triage-customer-needs', repoIdentifier);
1288
+ // Compute trend from interview scores
1289
+ let trend = 'stable';
1290
+ if (interviewScores.length >= 3) {
1291
+ const midpoint = Math.floor(interviewScores.length / 2);
1292
+ const firstHalf = interviewScores.slice(0, midpoint);
1293
+ const secondHalf = interviewScores.slice(midpoint);
1294
+ const firstAvg = firstHalf.reduce((sum, s) => sum + (s.scores.composite ?? 0), 0) / firstHalf.length;
1295
+ const secondAvg = secondHalf.reduce((sum, s) => sum + (s.scores.composite ?? 0), 0) / secondHalf.length;
1296
+ if (secondAvg > firstAvg + 0.5)
1297
+ trend = 'improving';
1298
+ else if (secondAvg < firstAvg - 0.5)
1299
+ trend = 'declining';
1300
+ }
1301
+ // Compute average composite
1302
+ const avgComposite = interviewScores.length > 0
1303
+ ? interviewScores.reduce((sum, s) => sum + (s.scores.composite ?? 0), 0) / interviewScores.length
1304
+ : 0;
1305
+ // Format response
1306
+ const interviews = interviewScores.map(s => ({
1307
+ interviewee: s.scores.interviewee ?? s.scores.participant?.name ?? 'Unknown',
1308
+ company: s.scores.company ?? s.scores.participant?.company ?? '',
1309
+ composite: s.scores.composite ?? 0,
1310
+ participantFit: s.scores.participant?.fit ?? 0,
1311
+ evidenceQuality: s.scores.evidence?.quoteSpecificityAvg ?? 0,
1312
+ completeness: s.scores.completeness ?? 0,
1313
+ coaching: s.scores.coaching ?? '',
1314
+ createdAt: s.createdAt
1315
+ }));
1316
+ res.json({
1317
+ interviews,
1318
+ count: interviews.length,
1319
+ avgComposite: Math.round(avgComposite * 10) / 10,
1320
+ trend,
1321
+ latestGate: latestGate ? {
1322
+ decision: latestGate.scores.gateDecision,
1323
+ interviewsAnalyzed: latestGate.scores.interviewsAnalyzed,
1324
+ targetInterviews: latestGate.scores.targetInterviews,
1325
+ distinctPainPatterns: latestGate.scores.distinctPainPatterns,
1326
+ customersPerPattern: latestGate.scores.customersPerPattern,
1327
+ gaps: latestGate.scores.gaps ?? [],
1328
+ coaching: latestGate.scores.coaching ?? '',
1329
+ createdAt: latestGate.createdAt
1330
+ } : null
1331
+ });
1332
+ }
1333
+ catch (error) {
1334
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load quality data' });
1335
+ }
1336
+ finally {
1337
+ if (dbService)
1338
+ await dbService.close();
1339
+ }
1340
+ });
1341
+ /**
1342
+ * POST /api/analytics/quality
1343
+ * Ingest quality score events from the local proxy batch pipeline.
1344
+ */
1345
+ router.post('/quality', async (req, res) => {
1346
+ let dbService;
1347
+ try {
1348
+ const apiKeyData = req.apiKeyData;
1349
+ if (!apiKeyData)
1350
+ return res.status(401).json({ error: 'Authentication required' });
1351
+ const { userId, jobName, jobId, sessionId, scores, artifactPath } = req.body;
1352
+ if (!userId || !jobName || !jobId || !scores) {
1353
+ return res.status(400).json({ error: 'Missing required fields: userId, jobName, jobId, scores' });
1354
+ }
1355
+ dbService = new db_service_js_1.FraimDbService();
1356
+ await dbService.connect();
1357
+ await dbService.insertQualityScore({
1358
+ userId,
1359
+ jobName,
1360
+ jobId,
1361
+ sessionId: sessionId ?? 'unknown',
1362
+ scores,
1363
+ artifactPath,
1364
+ createdAt: new Date()
1365
+ });
1366
+ res.json({ success: true });
1367
+ }
1368
+ catch (error) {
1369
+ res.status(500).json({ error: error.message || 'Failed to ingest quality score' });
1370
+ }
1371
+ finally {
1372
+ if (dbService)
1373
+ await dbService.close();
1374
+ }
1375
+ });
1376
+ /**
1377
+ * GET /api/analytics/quality/scorecard/:userId
1378
+ * Returns quality scorecard: one summary per founder journey stage.
1379
+ * Used by the quality tile grid in both personal and manager views.
1380
+ */
1381
+ router.get('/quality/scorecard/:userId', async (req, res) => {
1382
+ let dbService;
1383
+ try {
1384
+ const apiKeyData = req.apiKeyData;
1385
+ if (!apiKeyData)
1386
+ return res.status(401).json({ error: 'Authentication required' });
1387
+ const { userId } = req.params;
1388
+ const { repoIdentifier } = req.query;
1389
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1390
+ const periodDays = timeframe.startDate ? undefined : parsePeriodDays(timeframe.period);
1391
+ dbService = new db_service_js_1.FraimDbService();
1392
+ await dbService.connect();
1393
+ // If requesting another user's data, verify manager-member relationship
1394
+ if (apiKeyData.userId !== userId) {
1395
+ await resolveManagerMember(dbService, apiKeyData.userId, userId);
1396
+ }
1397
+ const stages = await dbService.getQualityScorecard(userId, repoIdentifier, periodDays, timeframe.startDate, timeframe.endDate);
1398
+ res.json({ stages });
1399
+ }
1400
+ catch (error) {
1401
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load quality scorecard' });
1402
+ }
1403
+ finally {
1404
+ if (dbService)
1405
+ await dbService.close();
1406
+ }
1407
+ });
1408
+ /**
1409
+ * GET /api/analytics/quality/stage/:userId/:stageCategory
1410
+ * Returns full assessment history for a single stage (tile click detail view).
1411
+ */
1412
+ router.get('/quality/stage/:userId/:stageCategory', async (req, res) => {
1413
+ let dbService;
1414
+ try {
1415
+ const apiKeyData = req.apiKeyData;
1416
+ if (!apiKeyData)
1417
+ return res.status(401).json({ error: 'Authentication required' });
1418
+ const { userId, stageCategory } = req.params;
1419
+ const { repoIdentifier } = req.query;
1420
+ const timeframe = resolveAnalyticsTimeframe(req.query);
1421
+ const periodDays = timeframe.startDate ? undefined : parsePeriodDays(timeframe.period);
1422
+ dbService = new db_service_js_1.FraimDbService();
1423
+ await dbService.connect();
1424
+ // If requesting another user's data, verify manager-member relationship
1425
+ if (apiKeyData.userId !== userId) {
1426
+ await resolveManagerMember(dbService, apiKeyData.userId, userId);
1427
+ }
1428
+ const detail = await dbService.getQualityStageDetail(userId, stageCategory, repoIdentifier, periodDays, timeframe.startDate, timeframe.endDate);
1429
+ res.json(detail);
1430
+ }
1431
+ catch (error) {
1432
+ res.status(error.status || 500).json({ error: error.message || 'Failed to load stage detail' });
1433
+ }
1434
+ finally {
1435
+ if (dbService)
1436
+ await dbService.close();
1437
+ }
1438
+ });
1439
+ /**
1440
+ * GET /analytics/
1441
+ * Serve the analytics dashboard HTML
1442
+ */
1443
+ router.get('/', (req, res) => {
1444
+ const dashboardPath = path_1.default.join(__dirname, '../../public/analytics/index.html');
1445
+ res.sendFile(dashboardPath);
1446
+ });
1447
+ exports.default = router;