fraim-framework 2.0.179 → 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.
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/api/admin/payments.js +33 -0
- package/dist/src/api/admin/sales-leads.js +21 -0
- package/dist/src/api/payment/create-session.js +338 -0
- package/dist/src/api/payment/dashboard-link.js +149 -0
- package/dist/src/api/payment/session-details.js +31 -0
- package/dist/src/api/payment/webhook.js +587 -0
- package/dist/src/api/personas/me.js +29 -0
- package/dist/src/api/pricing/get-config.js +25 -0
- package/dist/src/api/sales/contact.js +44 -0
- package/dist/src/cli/distribution/marketplace-bundles.js +5 -1
- package/dist/src/db/payment-repository.js +61 -0
- package/dist/src/fraim/config-loader.js +11 -0
- package/dist/src/fraim/db-service.js +2387 -0
- package/dist/src/fraim/issues.js +152 -0
- package/dist/src/fraim/template-processor.js +184 -0
- package/dist/src/fraim/utils/request-utils.js +23 -0
- package/dist/src/middleware/auth.js +266 -0
- package/dist/src/middleware/cors-config.js +111 -0
- package/dist/src/middleware/logger.js +116 -0
- package/dist/src/middleware/rate-limit.js +110 -0
- package/dist/src/middleware/reject-query-api-key.js +45 -0
- package/dist/src/middleware/security-headers.js +41 -0
- package/dist/src/middleware/telemetry.js +134 -0
- package/dist/src/models/payment.js +2 -0
- package/dist/src/routes/analytics.js +1447 -0
- package/dist/src/routes/app-routes.js +32 -0
- package/dist/src/routes/auth-routes.js +505 -0
- package/dist/src/routes/oauth-routes.js +325 -0
- package/dist/src/routes/payment-routes.js +186 -0
- package/dist/src/routes/persona-catalog-routes.js +84 -0
- package/dist/src/services/admin-service.js +229 -0
- package/dist/src/services/audit-log-persistence.js +60 -0
- package/dist/src/services/audit-log.js +69 -0
- package/dist/src/services/cookie-service.js +129 -0
- package/dist/src/services/dashboard-access.js +27 -0
- package/dist/src/services/demo-seed-service.js +139 -0
- package/dist/src/services/email-code.js +23 -0
- package/dist/src/services/email-service-clean.js +782 -0
- package/dist/src/services/email-service.js +951 -0
- package/dist/src/services/installer-service.js +131 -0
- package/dist/src/services/mcp-oauth-store.js +33 -0
- package/dist/src/services/mcp-service.js +823 -0
- package/dist/src/services/oauth-helpers.js +127 -0
- package/dist/src/services/org-service.js +89 -0
- package/dist/src/services/persona-entitlement-service.js +288 -0
- package/dist/src/services/provider-service.js +215 -0
- package/dist/src/services/registry-service.js +628 -0
- package/dist/src/services/session-service.js +86 -0
- package/dist/src/services/trial-reminder-service.js +120 -0
- package/dist/src/services/usage-analytics-service.js +419 -0
- package/dist/src/services/workspace-identity.js +21 -0
- package/dist/src/types/analytics.js +2 -0
- package/dist/src/utils/payment-calculator.js +52 -0
- package/extensions/office-word/favicon.ico +0 -0
- package/extensions/office-word/icon-64.png +0 -0
- package/extensions/office-word/manifest.xml +33 -0
- package/extensions/office-word/taskpane.html +242 -0
- package/package.json +12 -2
|
@@ -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;
|