fraim 2.0.179 → 2.0.182

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 (64) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/api/admin/payments.js +33 -0
  3. package/dist/src/api/admin/sales-leads.js +21 -0
  4. package/dist/src/api/payment/create-session.js +338 -0
  5. package/dist/src/api/payment/dashboard-link.js +149 -0
  6. package/dist/src/api/payment/session-details.js +31 -0
  7. package/dist/src/api/payment/webhook.js +587 -0
  8. package/dist/src/api/personas/me.js +29 -0
  9. package/dist/src/api/pricing/get-config.js +25 -0
  10. package/dist/src/api/sales/contact.js +44 -0
  11. package/dist/src/cli/commands/add-ide.js +9 -2
  12. package/dist/src/cli/commands/setup.js +14 -44
  13. package/dist/src/cli/distribution/marketplace-bundles.js +5 -1
  14. package/dist/src/cli/setup/ide-detector.js +7 -2
  15. package/dist/src/core/config-loader.js +10 -8
  16. package/dist/src/core/types.js +2 -1
  17. package/dist/src/db/payment-repository.js +61 -0
  18. package/dist/src/fraim/config-loader.js +11 -0
  19. package/dist/src/fraim/db-service.js +2387 -0
  20. package/dist/src/fraim/issues.js +152 -0
  21. package/dist/src/fraim/template-processor.js +184 -0
  22. package/dist/src/fraim/utils/request-utils.js +23 -0
  23. package/dist/src/middleware/auth.js +266 -0
  24. package/dist/src/middleware/cors-config.js +111 -0
  25. package/dist/src/middleware/logger.js +116 -0
  26. package/dist/src/middleware/rate-limit.js +110 -0
  27. package/dist/src/middleware/reject-query-api-key.js +45 -0
  28. package/dist/src/middleware/security-headers.js +41 -0
  29. package/dist/src/middleware/telemetry.js +134 -0
  30. package/dist/src/models/payment.js +2 -0
  31. package/dist/src/routes/analytics.js +1447 -0
  32. package/dist/src/routes/app-routes.js +32 -0
  33. package/dist/src/routes/auth-routes.js +505 -0
  34. package/dist/src/routes/oauth-routes.js +325 -0
  35. package/dist/src/routes/payment-routes.js +186 -0
  36. package/dist/src/routes/persona-catalog-routes.js +84 -0
  37. package/dist/src/services/admin-service.js +229 -0
  38. package/dist/src/services/audit-log-persistence.js +60 -0
  39. package/dist/src/services/audit-log.js +69 -0
  40. package/dist/src/services/cookie-service.js +129 -0
  41. package/dist/src/services/dashboard-access.js +27 -0
  42. package/dist/src/services/demo-seed-service.js +139 -0
  43. package/dist/src/services/email-code.js +23 -0
  44. package/dist/src/services/email-service-clean.js +782 -0
  45. package/dist/src/services/email-service.js +951 -0
  46. package/dist/src/services/installer-service.js +131 -0
  47. package/dist/src/services/mcp-oauth-store.js +33 -0
  48. package/dist/src/services/mcp-service.js +823 -0
  49. package/dist/src/services/oauth-helpers.js +127 -0
  50. package/dist/src/services/org-service.js +89 -0
  51. package/dist/src/services/persona-entitlement-service.js +288 -0
  52. package/dist/src/services/provider-service.js +215 -0
  53. package/dist/src/services/registry-service.js +628 -0
  54. package/dist/src/services/session-service.js +86 -0
  55. package/dist/src/services/trial-reminder-service.js +120 -0
  56. package/dist/src/services/usage-analytics-service.js +419 -0
  57. package/dist/src/services/workspace-identity.js +21 -0
  58. package/dist/src/types/analytics.js +2 -0
  59. package/dist/src/utils/payment-calculator.js +52 -0
  60. package/extensions/office-word/favicon.ico +0 -0
  61. package/extensions/office-word/icon-64.png +0 -0
  62. package/extensions/office-word/manifest.xml +33 -0
  63. package/extensions/office-word/taskpane.html +242 -0
  64. package/package.json +12 -2
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionManager = void 0;
4
+ class SessionManager {
5
+ constructor(dbService) {
6
+ this.sessions = new Map();
7
+ this.dbService = dbService;
8
+ // Default 1m, configurable for testing
9
+ this.FLUSH_INTERVAL_MS = process.env.FRAIM_TELEMETRY_FLUSH_INTERVAL
10
+ ? parseInt(process.env.FRAIM_TELEMETRY_FLUSH_INTERVAL)
11
+ : 60 * 1000;
12
+ // Flush on shutdown
13
+ const cleanup = async () => {
14
+ console.log('Flushing telemetry sessions before shutdown...');
15
+ // Race flush with a 2s timeout to guarantee exit
16
+ const timeout = new Promise(resolve => setTimeout(resolve, 2000));
17
+ await Promise.race([this.flushAll(), timeout]);
18
+ console.log('Shutdown flush complete (or timed out). Exiting.');
19
+ process.exit(0);
20
+ };
21
+ process.on('SIGTERM', cleanup);
22
+ process.on('SIGINT', cleanup);
23
+ }
24
+ registerSession(apiKey, sessionId) {
25
+ const now = Date.now();
26
+ this.sessions.set(this.getSessionKey(apiKey, sessionId), {
27
+ sessionId,
28
+ lastWrite: now,
29
+ lastActive: now
30
+ });
31
+ }
32
+ async updateActivity(apiKey, sessionId) {
33
+ let sessionKey = sessionId ? this.getSessionKey(apiKey, sessionId) : null;
34
+ let session = sessionKey ? this.sessions.get(sessionKey) : null;
35
+ const now = Date.now();
36
+ if (!session) {
37
+ // Re-hydration: find the exact session when sessionId is provided.
38
+ // Legacy fallback (no sessionId) resolves latest active session for API key.
39
+ try {
40
+ const dbSession = sessionId
41
+ ? await this.dbService.getSessionByApiKeyAndSessionId(apiKey, sessionId)
42
+ : await this.dbService.getActiveSessionByApiKey(apiKey);
43
+ if (dbSession) {
44
+ sessionKey = this.getSessionKey(apiKey, dbSession.sessionId);
45
+ console.log(`Re-hydrating session for API Key: ${apiKey}, sessionId: ${dbSession.sessionId}`);
46
+ session = {
47
+ sessionId: dbSession.sessionId,
48
+ lastWrite: now,
49
+ lastActive: now
50
+ };
51
+ this.sessions.set(sessionKey, session);
52
+ }
53
+ else {
54
+ return false;
55
+ }
56
+ }
57
+ catch (e) {
58
+ console.error('Failed to re-hydrate session from DB:', e);
59
+ return false;
60
+ }
61
+ }
62
+ session.lastActive = now;
63
+ const shouldFlushByInterval = now - session.lastWrite > this.FLUSH_INTERVAL_MS;
64
+ // Write-behind with deterministic fallback persistence:
65
+ // - Flush asynchronously on interval.
66
+ if (shouldFlushByInterval) {
67
+ const flushPromise = this.dbService.updateSessionActivity(session.sessionId, new Date(now));
68
+ flushPromise.catch((e) => {
69
+ console.error('Failed to flush session activity:', e);
70
+ });
71
+ session.lastWrite = now;
72
+ }
73
+ return true;
74
+ }
75
+ async flushAll() {
76
+ const promises = [];
77
+ for (const [_, data] of this.sessions) {
78
+ promises.push(this.dbService.updateSessionActivity(data.sessionId, new Date(data.lastActive)));
79
+ }
80
+ await Promise.allSettled(promises);
81
+ }
82
+ getSessionKey(apiKey, sessionId) {
83
+ return `${apiKey}::${sessionId}`;
84
+ }
85
+ }
86
+ exports.SessionManager = SessionManager;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TrialReminderService = void 0;
4
+ const email_service_1 = require("./email-service");
5
+ /**
6
+ * Trial Reminder Service
7
+ *
8
+ * Sends automated emails to trial users:
9
+ * - Day 7: Mid-trial check-in (7 days remaining)
10
+ * - Day 12: Trial expiring soon (2 days remaining)
11
+ * - Day 14: Trial expired
12
+ *
13
+ * This service should be run as a cron job once per day
14
+ */
15
+ class TrialReminderService {
16
+ constructor(dbService) {
17
+ this.dbService = dbService;
18
+ this.emailService = new email_service_1.EmailService();
19
+ }
20
+ /**
21
+ * Main cron job entry point
22
+ * Run this once per day (e.g., at 10:00 AM UTC)
23
+ */
24
+ async runDailyReminders() {
25
+ console.log('[TRIAL-REMINDER] Starting daily trial reminder check...');
26
+ try {
27
+ const now = new Date();
28
+ const oneDayMs = 24 * 60 * 60 * 1000;
29
+ // Get all active trial keys
30
+ const allKeys = await this.dbService.listApiKeys();
31
+ const trialKeys = allKeys.filter(key => key.tier === 'trial' &&
32
+ key.status === 'active' &&
33
+ key.expiresAt !== null);
34
+ console.log(`[TRIAL-REMINDER] Found ${trialKeys.length} active trial users`);
35
+ let day7Count = 0;
36
+ let day12Count = 0;
37
+ let expiredCount = 0;
38
+ for (const key of trialKeys) {
39
+ const expiresAt = key.expiresAt;
40
+ const msUntilExpiry = expiresAt.getTime() - now.getTime();
41
+ const daysUntilExpiry = Math.ceil(msUntilExpiry / oneDayMs);
42
+ try {
43
+ // Trial expired
44
+ if (daysUntilExpiry <= 0) {
45
+ await this.handleExpiredTrial(key);
46
+ expiredCount++;
47
+ }
48
+ // 2 days remaining (day 12 of trial)
49
+ else if (daysUntilExpiry === 2) {
50
+ await this.handleTrialExpiringSoon(key, daysUntilExpiry);
51
+ day12Count++;
52
+ }
53
+ // 7 days remaining (day 7 of trial)
54
+ else if (daysUntilExpiry === 7) {
55
+ await this.handleMidTrialCheckIn(key);
56
+ day7Count++;
57
+ }
58
+ }
59
+ catch (error) {
60
+ console.error(`[TRIAL-REMINDER] Error processing trial for ${key.userId}:`, error);
61
+ // Continue processing other users
62
+ }
63
+ }
64
+ console.log(`[TRIAL-REMINDER] Completed daily reminders:`, {
65
+ day7CheckIns: day7Count,
66
+ day12Warnings: day12Count,
67
+ expired: expiredCount,
68
+ total: trialKeys.length
69
+ });
70
+ }
71
+ catch (error) {
72
+ console.error('[TRIAL-REMINDER] Error in daily reminder job:', error);
73
+ throw error;
74
+ }
75
+ }
76
+ /**
77
+ * Send mid-trial check-in (day 7 - 7 days remaining)
78
+ */
79
+ async handleMidTrialCheckIn(key) {
80
+ console.log(`[TRIAL-REMINDER] Sending day 7 check-in to ${key.userId}`);
81
+ await this.emailService.sendTrialCheckIn(key.userId, key.expiresAt);
82
+ }
83
+ /**
84
+ * Send trial expiring soon warning (day 12 - 2 days remaining)
85
+ */
86
+ async handleTrialExpiringSoon(key, daysRemaining) {
87
+ console.log(`[TRIAL-REMINDER] Sending expiring warning to ${key.userId} (${daysRemaining} days left)`);
88
+ await this.emailService.sendTrialExpiring(key.userId, key.expiresAt, daysRemaining);
89
+ }
90
+ /**
91
+ * Handle expired trial (mark as expired, send notification)
92
+ */
93
+ async handleExpiredTrial(key) {
94
+ console.log(`[TRIAL-REMINDER] Processing expired trial for ${key.userId}`);
95
+ // Update key status to expired
96
+ await this.dbService.updateApiKey(key.key, {
97
+ status: 'expired'
98
+ });
99
+ // Send expired notification
100
+ await this.emailService.sendTrialExpired(key.userId, key.expiresAt);
101
+ console.log(`[TRIAL-REMINDER] Trial expired and notification sent to ${key.userId}`);
102
+ }
103
+ /**
104
+ * Manual trigger for testing (processes all trials regardless of timing)
105
+ */
106
+ async runTestMode() {
107
+ console.log('[TRIAL-REMINDER] Running in TEST MODE...');
108
+ const allKeys = await this.dbService.listApiKeys();
109
+ const trialKeys = allKeys.filter(key => key.tier === 'trial' &&
110
+ key.expiresAt !== null);
111
+ console.log(`[TRIAL-REMINDER] Found ${trialKeys.length} trial users (all statuses)`);
112
+ for (const key of trialKeys) {
113
+ const now = new Date();
114
+ const msUntilExpiry = key.expiresAt.getTime() - now.getTime();
115
+ const daysUntilExpiry = Math.ceil(msUntilExpiry / (24 * 60 * 60 * 1000));
116
+ console.log(`[TRIAL-REMINDER] ${key.userId}: ${daysUntilExpiry} days remaining, status: ${key.status}`);
117
+ }
118
+ }
119
+ }
120
+ exports.TrialReminderService = TrialReminderService;
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.UsageAnalyticsService = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ /**
40
+ * Service for handling usage analytics operations
41
+ * Provides high-level interface for tracking and analyzing usage patterns
42
+ */
43
+ class UsageAnalyticsService {
44
+ constructor(dbService) {
45
+ this.eventQueue = [];
46
+ this.flushInterval = null;
47
+ this.BATCH_SIZE = 100;
48
+ this.FLUSH_INTERVAL_MS = 5000; // 5 seconds
49
+ this.jobMap = null;
50
+ this.registryPath = null;
51
+ this.dbService = dbService;
52
+ this.startBatchProcessor();
53
+ }
54
+ /**
55
+ * Log a usage event (queued for batch processing)
56
+ */
57
+ async logUsage(input) {
58
+ // Add to queue for batch processing
59
+ this.eventQueue.push(input);
60
+ // Flush immediately if queue is full
61
+ if (this.eventQueue.length >= this.BATCH_SIZE) {
62
+ await this.flushQueue();
63
+ }
64
+ }
65
+ /**
66
+ * Log a usage event immediately (bypasses queue)
67
+ */
68
+ async logUsageImmediate(input) {
69
+ try {
70
+ // Enrich with category if missing and type is job
71
+ if (input.type === 'job' && (!input.category || input.category === 'uncategorized')) {
72
+ input.category = await this.resolveCategory(input.name);
73
+ }
74
+ await this.dbService.logUsageEvent(input);
75
+ }
76
+ catch (error) {
77
+ console.error('Failed to log usage event:', error);
78
+ // Don't throw - analytics failures shouldn't break main functionality
79
+ }
80
+ }
81
+ /**
82
+ * Resolve category for a job name
83
+ */
84
+ async resolveCategory(jobName) {
85
+ if (!this.jobMap) {
86
+ await this.loadJobMap();
87
+ }
88
+ return this.jobMap?.get(jobName) || 'uncategorized';
89
+ }
90
+ async resolveCanonicalCategory(name, type, fallbackCategory) {
91
+ if (type === 'job') {
92
+ const resolved = await this.resolveCategory(name);
93
+ if (resolved && resolved !== 'uncategorized') {
94
+ return resolved;
95
+ }
96
+ }
97
+ return fallbackCategory || undefined;
98
+ }
99
+ async loadJobMap() {
100
+ try {
101
+ this.jobMap = new Map();
102
+ // Find registry root - using process.cwd() for server-side resolution
103
+ const registryRoot = path.join(process.cwd(), 'registry', 'jobs');
104
+ if (!fs.existsSync(registryRoot)) {
105
+ console.warn(`[UsageAnalyticsService] Could not find registry/jobs directory at ${registryRoot}`);
106
+ return;
107
+ }
108
+ this.scanRegistryDir(registryRoot);
109
+ console.log(`[UsageAnalyticsService] Loaded ${this.jobMap.size} jobs from registry`);
110
+ }
111
+ catch (error) {
112
+ console.error('[UsageAnalyticsService] Failed to load job registry:', error);
113
+ }
114
+ }
115
+ scanRegistryDir(dir, base = '') {
116
+ if (!this.jobMap)
117
+ return;
118
+ try {
119
+ const items = fs.readdirSync(dir, { withFileTypes: true });
120
+ for (const item of items) {
121
+ const fullPath = path.join(dir, item.name);
122
+ const relPath = path.relative(path.join(process.cwd(), 'registry', 'jobs'), fullPath);
123
+ const parts = relPath.split(path.sep);
124
+ if (item.isDirectory()) {
125
+ this.scanRegistryDir(fullPath, path.join(base, item.name));
126
+ }
127
+ else if (item.name.endsWith('.md')) {
128
+ const jobName = item.name.replace('.md', '');
129
+ // Group ai-manager jobs into ai-management
130
+ // We check if "ai-manager" is in the path OR the name includes "manager" or "analyze-why"
131
+ const lowerName = jobName.toLowerCase();
132
+ if (parts.includes('ai-manager') || lowerName.includes('manager') || lowerName.includes('coaching') || lowerName.includes('analyze-why')) {
133
+ this.jobMap.set(jobName, 'ai-management');
134
+ }
135
+ else {
136
+ // Find category (one level up from filename)
137
+ const category = parts[parts.length - 2] || 'uncategorized';
138
+ this.jobMap.set(jobName, category);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ catch (error) {
144
+ console.error(`[UsageAnalyticsService] Error scanning directory ${dir}:`, error);
145
+ }
146
+ }
147
+ /**
148
+ * Get usage statistics for a time period
149
+ */
150
+ async getUsageStats(query) {
151
+ const timeWindow = this.resolveTimeWindow(query.period || '30d', query.startDate, query.endDate);
152
+ return await this.dbService.getUsageStats(timeWindow, query.types, query.userId, query.repoIdentifier);
153
+ }
154
+ /**
155
+ * Get top components by usage
156
+ */
157
+ async getTopComponents(limit = 10, period = '30d', types, userId, repoIdentifier, startDate, endDate) {
158
+ const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
159
+ const components = await this.dbService.getTopComponents(limit, timeWindow, types, userId, repoIdentifier);
160
+ return await Promise.all(components.map(async (component) => ({
161
+ ...component,
162
+ category: await this.resolveCanonicalCategory(component.name, component.type, component.category)
163
+ })));
164
+ }
165
+ /**
166
+ * Get usage pattern for a specific user
167
+ */
168
+ async getUserUsagePattern(userId) {
169
+ return await this.dbService.getUserUsagePattern(userId);
170
+ }
171
+ /**
172
+ * Get trend data for a component
173
+ */
174
+ async getComponentTrend(componentName, componentType, period = '30d', userId, dateUnit, repoIdentifier, startDate, endDate) {
175
+ const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
176
+ return await this.dbService.getComponentTrend(componentName, componentType, timeWindow, userId, dateUnit, repoIdentifier);
177
+ }
178
+ /**
179
+ * Get job timeline data for bubble chart
180
+ */
181
+ async getJobRunsOverTime(period = '30d', userId, dateUnit, repoIdentifier, startDate, endDate) {
182
+ const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
183
+ const timeline = await this.dbService.getJobRunsOverTime(timeWindow, userId, dateUnit, repoIdentifier);
184
+ return await Promise.all(timeline.map(async (item) => ({
185
+ ...item,
186
+ category: await this.resolveCanonicalCategory(item.name, 'job', item.category)
187
+ })));
188
+ }
189
+ async getDominantJobCategory(period = '30d', userId, repoIdentifier, startDate, endDate) {
190
+ const components = await this.getTopComponents(10000, period, ['job'], userId, repoIdentifier, startDate, endDate);
191
+ if (components.length === 0)
192
+ return null;
193
+ const totals = new Map();
194
+ for (const component of components) {
195
+ const category = component.category || 'uncategorized';
196
+ totals.set(category, (totals.get(category) || 0) + component.count);
197
+ }
198
+ let dominant = null;
199
+ for (const [category, count] of totals.entries()) {
200
+ if (!dominant || count > dominant.count) {
201
+ dominant = { category, count };
202
+ }
203
+ }
204
+ return dominant;
205
+ }
206
+ /**
207
+ * Export usage data as CSV
208
+ */
209
+ async exportUsageData(query) {
210
+ const stats = await this.getUsageStats(query);
211
+ const topComponents = await this.getTopComponents(query.limit || 50, query.period || '30d', query.types, query.userId, query.repoIdentifier, query.startDate, query.endDate);
212
+ // Create CSV content
213
+ let csv = 'Component Name,Type,Category,Usage Count,Success Rate,Last Used\n';
214
+ topComponents.forEach(component => {
215
+ csv += `"${component.name}","${component.type}","${component.category || 'N/A'}",${component.count},${(component.successRate || 0).toFixed(2)}%,"${component.lastUsed ? component.lastUsed.toISOString() : 'N/A'}"\n`;
216
+ });
217
+ return csv;
218
+ }
219
+ /**
220
+ * Clean up old usage events
221
+ */
222
+ async cleanupOldEvents(retentionDays = 90) {
223
+ return await this.dbService.cleanupOldUsageEvents(retentionDays);
224
+ }
225
+ /**
226
+ * Initialize the analytics system
227
+ */
228
+ async initialize() {
229
+ await this.dbService.initializeUsageEventsCollection();
230
+ }
231
+ /**
232
+ * Get unique repositories that have generated telemetry for a user
233
+ */
234
+ async getUniqueRepos(userId) {
235
+ return await this.dbService.getUniqueRepos(userId);
236
+ }
237
+ /**
238
+ * Gracefully shutdown the service
239
+ */
240
+ async shutdown() {
241
+ if (this.flushInterval) {
242
+ clearInterval(this.flushInterval);
243
+ this.flushInterval = null;
244
+ }
245
+ // Flush any remaining events
246
+ await this.flushQueue();
247
+ }
248
+ /**
249
+ * Create a usage event for job start (when get_fraim_job is called)
250
+ */
251
+ async logJobStart(jobId, jobName, userId, sessionId, issueNumber) {
252
+ const jobStartEvent = UsageAnalyticsService.createUsageEvent('job', jobName, userId, sessionId, true, {
253
+ action: 'start',
254
+ issueNumber,
255
+ jobId
256
+ }, await this.resolveCategory(jobName), jobId);
257
+ await this.logUsage(jobStartEvent);
258
+ }
259
+ /**
260
+ * Create a usage event for job completion (when seekMentoring returns nextPhase=null)
261
+ */
262
+ async logJobComplete(jobId, jobName, userId, sessionId, finalPhase) {
263
+ const jobCompleteEvent = UsageAnalyticsService.createUsageEvent('job', jobName, userId, sessionId, true, {
264
+ action: 'complete',
265
+ finalPhase,
266
+ jobId
267
+ }, await this.resolveCategory(jobName), jobId, finalPhase);
268
+ await this.logUsage(jobCompleteEvent);
269
+ }
270
+ /**
271
+ * Log a quality score for a job that produces quality assessments (e.g., process-interview-notes, triage-customer-needs).
272
+ * Writes to the dedicated fraim_quality_scores collection (no TTL — retained indefinitely).
273
+ */
274
+ async logQualityScore(userId, jobName, jobId, sessionId, scores, artifactPath, repoIdentifier, reviewContext) {
275
+ try {
276
+ await this.dbService.insertQualityScore({
277
+ userId,
278
+ jobName,
279
+ jobId,
280
+ sessionId,
281
+ scores,
282
+ reviewContext,
283
+ artifactPath,
284
+ repoIdentifier,
285
+ createdAt: new Date()
286
+ });
287
+ }
288
+ catch (error) {
289
+ console.error(`[UsageAnalyticsService] Failed to log quality score for ${jobName}:`, error);
290
+ }
291
+ }
292
+ /**
293
+ * Create a usage event for mentoring calls with job tracking
294
+ */
295
+ async logMentoringCall(jobId, jobName, userId, sessionId, currentPhase, status, nextPhase, success = true) {
296
+ const mentoringEvent = UsageAnalyticsService.createUsageEvent('mentoring', jobName, userId, sessionId, success, {
297
+ currentPhase,
298
+ status,
299
+ nextPhase,
300
+ jobId
301
+ }, await this.resolveCategory(jobName), jobId, currentPhase);
302
+ await this.logUsage(mentoringEvent);
303
+ }
304
+ /**
305
+ * Validate that a jobId exists for the user (check recent usage events)
306
+ */
307
+ async validateJobId(jobId, userId) {
308
+ try {
309
+ // Check if there's a recent job start event for this jobId and user
310
+ const recentEvents = await this.dbService.getDb();
311
+ const collection = recentEvents.collection('fraim_usage_events');
312
+ const jobStartEvent = await collection.findOne({
313
+ jobId,
314
+ userId,
315
+ type: 'job',
316
+ 'args.action': 'start',
317
+ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } // Within last 24 hours
318
+ });
319
+ return jobStartEvent !== null;
320
+ }
321
+ catch (error) {
322
+ console.error('Error validating jobId:', error);
323
+ return false;
324
+ }
325
+ }
326
+ /**
327
+ * Get job completion metrics using usage events
328
+ */
329
+ async getJobCompletionMetrics(period = '30d', jobName, userId, repoIdentifier, startDate, endDate) {
330
+ const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
331
+ return await this.dbService.getJobCompletionMetrics(timeWindow.startDate, jobName, userId, repoIdentifier, timeWindow.endDate);
332
+ }
333
+ /**
334
+ * Start the batch processor for queued events
335
+ */
336
+ startBatchProcessor() {
337
+ this.flushInterval = setInterval(async () => {
338
+ if (this.eventQueue.length > 0) {
339
+ await this.flushQueue();
340
+ }
341
+ }, this.FLUSH_INTERVAL_MS);
342
+ this.flushInterval.unref?.();
343
+ }
344
+ /**
345
+ * Flush queued events to database
346
+ */
347
+ async flushQueue() {
348
+ if (this.eventQueue.length === 0)
349
+ return;
350
+ // Skip flush if DB is not connected (e.g., during server shutdown)
351
+ if (!this.dbService.getClient())
352
+ return;
353
+ const eventsToFlush = this.eventQueue.splice(0, this.BATCH_SIZE);
354
+ try {
355
+ // Process events in parallel for better performance
356
+ await Promise.all(eventsToFlush.map(event => this.dbService.logUsageEvent(event)));
357
+ }
358
+ catch (error) {
359
+ // Only re-queue if the error is transient (not a disconnection)
360
+ if (error instanceof Error && error.message.includes('not connected')) {
361
+ return; // DB shut down — drop events
362
+ }
363
+ console.error('Failed to flush usage events:', error);
364
+ // Re-queue failed events (up to a limit to prevent infinite growth)
365
+ if (this.eventQueue.length < this.BATCH_SIZE * 2) {
366
+ this.eventQueue.unshift(...eventsToFlush);
367
+ }
368
+ }
369
+ }
370
+ /**
371
+ * Parse period string to days
372
+ */
373
+ parsePeriodToDays(period) {
374
+ const match = period.match(/^(\d+)([dwmy])$/);
375
+ if (!match)
376
+ return 30; // default to 30 days
377
+ const [, num, unit] = match;
378
+ const value = parseInt(num, 10);
379
+ switch (unit) {
380
+ case 'd': return value;
381
+ case 'w': return value * 7;
382
+ case 'm': return value * 30;
383
+ case 'y': return value * 365;
384
+ default: return 30;
385
+ }
386
+ }
387
+ resolveTimeWindow(period, startDateInput, endDateInput) {
388
+ const now = new Date();
389
+ if (startDateInput || endDateInput) {
390
+ const startDate = startDateInput ? new Date(startDateInput) : new Date(now);
391
+ const endDate = endDateInput ? new Date(endDateInput) : now;
392
+ const days = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1);
393
+ return { startDate, endDate, days };
394
+ }
395
+ const days = this.parsePeriodToDays(period);
396
+ return {
397
+ startDate: new Date(now.getTime() - days * 24 * 60 * 60 * 1000),
398
+ endDate: now,
399
+ days
400
+ };
401
+ }
402
+ /**
403
+ * Create a usage event input from parameters
404
+ */
405
+ static createUsageEvent(type, name, userId, sessionId, success = true, args, category, jobId, jobPhase) {
406
+ return {
407
+ type,
408
+ name,
409
+ userId,
410
+ sessionId,
411
+ success,
412
+ args,
413
+ category,
414
+ jobId,
415
+ jobPhase
416
+ };
417
+ }
418
+ }
419
+ exports.UsageAnalyticsService = UsageAnalyticsService;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeWorkspaceEmail = normalizeWorkspaceEmail;
4
+ exports.resolveWorkspaceId = resolveWorkspaceId;
5
+ function normalizeWorkspaceEmail(email) {
6
+ if (!email)
7
+ return null;
8
+ const trimmed = email.trim().toLowerCase();
9
+ return trimmed || null;
10
+ }
11
+ function resolveWorkspaceId(input) {
12
+ if (input.workspaceId)
13
+ return input.workspaceId;
14
+ if (input.stripeCustomerId)
15
+ return `cust:${input.stripeCustomerId}`;
16
+ const normalizedEmail = normalizeWorkspaceEmail(input.email);
17
+ if (normalizedEmail) {
18
+ return `user:${normalizedEmail}`;
19
+ }
20
+ return null;
21
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });