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,2387 @@
|
|
|
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.FraimDbService = void 0;
|
|
37
|
+
const mongodb_1 = require("mongodb");
|
|
38
|
+
const git_utils_1 = require("../core/utils/git-utils");
|
|
39
|
+
const quality_evidence_1 = require("../core/quality-evidence");
|
|
40
|
+
function sanitizeSessionRepoContext(repo) {
|
|
41
|
+
if (!repo || typeof repo !== 'object') {
|
|
42
|
+
return repo;
|
|
43
|
+
}
|
|
44
|
+
const sanitizedUrl = (0, git_utils_1.sanitizeRepoIdentifier)(typeof repo.url === 'string' ? repo.url : undefined);
|
|
45
|
+
const sanitizedRepo = {
|
|
46
|
+
...repo,
|
|
47
|
+
url: sanitizedUrl
|
|
48
|
+
};
|
|
49
|
+
if ((!sanitizedRepo.name || sanitizedRepo.name === 'unknown') && sanitizedUrl) {
|
|
50
|
+
sanitizedRepo.name = sanitizedUrl.startsWith('https://')
|
|
51
|
+
? sanitizedRepo.name
|
|
52
|
+
: sanitizedUrl;
|
|
53
|
+
}
|
|
54
|
+
return sanitizedRepo;
|
|
55
|
+
}
|
|
56
|
+
function normalizeUserIds(userIds) {
|
|
57
|
+
return [...new Set(userIds.filter(Boolean).map((value) => value.toLowerCase().trim()).filter(Boolean))];
|
|
58
|
+
}
|
|
59
|
+
function normalizeLabels(labels) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const normalized = [];
|
|
62
|
+
for (const label of labels) {
|
|
63
|
+
const cleaned = label.trim();
|
|
64
|
+
if (!cleaned)
|
|
65
|
+
continue;
|
|
66
|
+
const key = cleaned.toLowerCase();
|
|
67
|
+
if (seen.has(key))
|
|
68
|
+
continue;
|
|
69
|
+
seen.add(key);
|
|
70
|
+
normalized.push(cleaned);
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
function escapeRegex(value) {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
76
|
+
}
|
|
77
|
+
function buildLabelMatch(label) {
|
|
78
|
+
return { labels: { $regex: `^${escapeRegex(label)}$`, $options: 'i' } };
|
|
79
|
+
}
|
|
80
|
+
function mergeLabels(existing, labelsToAdd) {
|
|
81
|
+
const merged = normalizeLabels(existing || []);
|
|
82
|
+
const seen = new Set(merged.map((label) => label.toLowerCase()));
|
|
83
|
+
for (const label of labelsToAdd) {
|
|
84
|
+
const key = label.toLowerCase();
|
|
85
|
+
if (!seen.has(key)) {
|
|
86
|
+
merged.push(label);
|
|
87
|
+
seen.add(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
function removeLabels(existing, labelsToRemove) {
|
|
93
|
+
const removeSet = new Set(labelsToRemove.map((label) => label.toLowerCase()));
|
|
94
|
+
return normalizeLabels(existing || []).filter((label) => !removeSet.has(label.toLowerCase()));
|
|
95
|
+
}
|
|
96
|
+
class FraimDbService {
|
|
97
|
+
constructor() {
|
|
98
|
+
this.usageEventsCollectionName = 'fraim_usage_events';
|
|
99
|
+
const url = process.env.MONGODB_URI || process.env.MONGO_DATABASE_URL;
|
|
100
|
+
if (!url) {
|
|
101
|
+
throw new Error('MONGODB_URI or MONGO_DATABASE_URL not found in environment');
|
|
102
|
+
}
|
|
103
|
+
this.uri = url;
|
|
104
|
+
this.dbName = (0, git_utils_1.determineDatabaseName)();
|
|
105
|
+
}
|
|
106
|
+
async connect() {
|
|
107
|
+
FraimDbService.refCount++;
|
|
108
|
+
if (FraimDbService.connectionPromise) {
|
|
109
|
+
await FraimDbService.connectionPromise;
|
|
110
|
+
this.client = FraimDbService.client;
|
|
111
|
+
this.db = FraimDbService.db;
|
|
112
|
+
// Ensure per-instance collection handles are initialized when reusing shared connection.
|
|
113
|
+
await this.initializeCollections();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
FraimDbService.connectionPromise = (async () => {
|
|
117
|
+
try {
|
|
118
|
+
if (!FraimDbService.client) {
|
|
119
|
+
FraimDbService.client = new mongodb_1.MongoClient(this.uri);
|
|
120
|
+
await FraimDbService.client.connect();
|
|
121
|
+
FraimDbService.db = FraimDbService.client.db(this.dbName);
|
|
122
|
+
}
|
|
123
|
+
this.client = FraimDbService.client;
|
|
124
|
+
this.db = FraimDbService.db;
|
|
125
|
+
console.log(`[FRAIM] Connected to MongoDB: ${this.uri.replace(/:([^:@]+)@/, ':****@')}, Database: ${this.dbName}`);
|
|
126
|
+
// Initialize collections and indexes
|
|
127
|
+
await this.initializeCollections();
|
|
128
|
+
await this.initializeIndexes();
|
|
129
|
+
await this.initializeUsageEventsCollection();
|
|
130
|
+
console.log(`✅ Connected to Fraim DB: ${this.dbName}`);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
FraimDbService.connectionPromise = undefined;
|
|
134
|
+
console.error('❌ Failed to connect to MongoDB:', error);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
await FraimDbService.connectionPromise;
|
|
139
|
+
}
|
|
140
|
+
async initializeCollections() {
|
|
141
|
+
if (!this.db)
|
|
142
|
+
throw new Error('DB not connected during collection initialization');
|
|
143
|
+
this.keysCollection = this.db.collection('fraim_api_keys');
|
|
144
|
+
this.sessionsCollection = this.db.collection('fraim_telemetry_sessions');
|
|
145
|
+
this.signupsCollection = this.db.collection('fraim_website_signups');
|
|
146
|
+
this.salesCollection = this.db.collection('fraim_sales_inquiries');
|
|
147
|
+
this.accessTokensCollection = this.db.collection('fraim_access_tokens');
|
|
148
|
+
this.pendingVerificationsCollection = this.db.collection('fraim_pending_verifications');
|
|
149
|
+
this.webhookEventsCollection = this.db.collection('fraim_webhook_events');
|
|
150
|
+
this.partnerDiscountsCollection = this.db.collection('fraim_partner_discounts');
|
|
151
|
+
this.managerTeamsCollection = this.db.collection('fraim_manager_teams');
|
|
152
|
+
this.aiHubManagerAssignmentsCollection = this.db.collection('fraim_hub_manager_assignments');
|
|
153
|
+
this.qualityScoresCollection = this.db.collection('fraim_quality_scores');
|
|
154
|
+
this.personaEntitlementsCollection = this.db.collection('fraim_persona_entitlements');
|
|
155
|
+
// Issue #359 — OAuth-first login.
|
|
156
|
+
this.authSessionsCollection = this.db.collection('fraim_auth_sessions');
|
|
157
|
+
this.pendingOAuthCollection = this.db.collection('fraim_pending_oauth');
|
|
158
|
+
this.auditLogCollection = this.db.collection('fraim_audit_log');
|
|
159
|
+
// Issue #563 — shared organization context (FRAIM-cloud backend).
|
|
160
|
+
this.orgArtifactsCollection = this.db.collection('fraim_org_artifacts');
|
|
161
|
+
this.orgAuditCollection = this.db.collection('fraim_org_audit');
|
|
162
|
+
}
|
|
163
|
+
async initializeIndexes() {
|
|
164
|
+
if (!this.db)
|
|
165
|
+
throw new Error('DB not connected during index initialization');
|
|
166
|
+
await this.keysCollection.createIndex({ key: 1 }, { unique: true });
|
|
167
|
+
await this.keysCollection.createIndex({ userId: 1 });
|
|
168
|
+
await this.keysCollection.createIndex({ stripeSubscriptionId: 1 });
|
|
169
|
+
await this.keysCollection.createIndex({ status: 1 });
|
|
170
|
+
await this.keysCollection.createIndex({ expiresAt: 1 });
|
|
171
|
+
await this.sessionsCollection.createIndex({ sessionId: 1 }, { unique: true });
|
|
172
|
+
await this.sessionsCollection.createIndex({ userId: 1 });
|
|
173
|
+
await this.sessionsCollection.createIndex({ lastActive: -1 });
|
|
174
|
+
await this.signupsCollection.createIndex({ email: 1 }, { unique: true });
|
|
175
|
+
await this.signupsCollection.createIndex({ timestamp: -1 });
|
|
176
|
+
await this.salesCollection.createIndex({ email: 1 });
|
|
177
|
+
await this.salesCollection.createIndex({ timestamp: -1 });
|
|
178
|
+
await this.accessTokensCollection.createIndex({ token: 1 }, { unique: true });
|
|
179
|
+
// TTL index — createIndex is a no-op if the index already exists with the same options.
|
|
180
|
+
// Avoid drop+recreate: Cosmos DB PITR-restored accounts block index drops on restored collections.
|
|
181
|
+
await this.accessTokensCollection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
182
|
+
await this.pendingVerificationsCollection.createIndex({ token: 1 }, { unique: true });
|
|
183
|
+
// Issue #563 — org artifacts are unique per (orgId, relativePath); audit reads newest-first.
|
|
184
|
+
// Index creation must never crash startup: Azure Cosmos DB (Mongo API) can
|
|
185
|
+
// reject a unique compound index, and connect() re-throws on failure. Logical
|
|
186
|
+
// uniqueness is already guaranteed by upsertOrgArtifact's replaceOne filter on
|
|
187
|
+
// { orgId, relativePath }, so the index is an optimization, not a correctness
|
|
188
|
+
// requirement — swallow failures like the other Cosmos-sensitive indexes above.
|
|
189
|
+
await this.orgArtifactsCollection.createIndex({ orgId: 1, relativePath: 1 }, { unique: true }).catch(() => { });
|
|
190
|
+
await this.orgAuditCollection.createIndex({ orgId: 1, at: -1 }).catch(() => { });
|
|
191
|
+
await this.pendingVerificationsCollection.createIndex({ email: 1 });
|
|
192
|
+
// Compound index covers `findOne({email}, { sort: { createdAt: -1 } })` —
|
|
193
|
+
// the request-access flow's lookup of the most-recent pending row per
|
|
194
|
+
// email. On real MongoDB the sort falls back to in-memory; on Cosmos
|
|
195
|
+
// MongoDB API it errors with "The index path corresponding to the
|
|
196
|
+
// specified order-by item is excluded" when `/createdAt` isn't in the
|
|
197
|
+
// indexing policy. Adding this index here makes a freshly restored
|
|
198
|
+
// Cosmos account functionally equivalent to the source for this flow,
|
|
199
|
+
// which is what the backup-validate Tier 2 drill caught (run
|
|
200
|
+
// 24962804320, 2026-04-26).
|
|
201
|
+
await this.pendingVerificationsCollection.createIndex({ email: 1, createdAt: -1 });
|
|
202
|
+
await this.pendingVerificationsCollection.createIndex({ email: 1, codeHash: 1 });
|
|
203
|
+
// TTL index — same pattern: create is idempotent, drop is not safe on restored accounts.
|
|
204
|
+
await this.pendingVerificationsCollection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
205
|
+
await this.webhookEventsCollection.createIndex({ eventId: 1 }, { unique: true });
|
|
206
|
+
await this.webhookEventsCollection.createIndex({ type: 1 });
|
|
207
|
+
await this.webhookEventsCollection.createIndex({ receivedAt: -1 });
|
|
208
|
+
await this.partnerDiscountsCollection.createIndex({ type: 1, match: 1 }, { unique: true });
|
|
209
|
+
await this.partnerDiscountsCollection.createIndex({ active: 1 });
|
|
210
|
+
try {
|
|
211
|
+
await this.managerTeamsCollection.createIndex({ managerId: 1, memberId: 1 }, { unique: true });
|
|
212
|
+
}
|
|
213
|
+
catch (e) { /* already exists */ }
|
|
214
|
+
await this.managerTeamsCollection.createIndex({ memberId: 1 }).catch(() => { });
|
|
215
|
+
// Issue #540: manager-to-persona seat assignments.
|
|
216
|
+
await this.aiHubManagerAssignmentsCollection.createIndex({ workspaceId: 1, userKey: 1, personaKey: 1 }, { unique: true }).catch(() => { });
|
|
217
|
+
await this.aiHubManagerAssignmentsCollection.createIndex({ workspaceId: 1, personaKey: 1 }).catch(() => { });
|
|
218
|
+
// Quality scores collection — no TTL (retained for full program duration)
|
|
219
|
+
await this.qualityScoresCollection.createIndex({ userId: 1, jobName: 1, createdAt: -1 }).catch(() => { });
|
|
220
|
+
await this.qualityScoresCollection.createIndex({ jobName: 1, createdAt: -1 }).catch(() => { });
|
|
221
|
+
// [OPTIMIZATION] Covers getQualityScorecard per-stage queries:
|
|
222
|
+
// find({ userId, stageCategory, createdAt: {$gte:...} }).sort({ createdAt: 1 })
|
|
223
|
+
// Without this index each stage loop hit a full collection scan, causing N+1
|
|
224
|
+
// fan-out timeouts when loading 13 team members' scorecards in parallel.
|
|
225
|
+
await this.qualityScoresCollection.createIndex({ userId: 1, stageCategory: 1, createdAt: -1 }).catch(() => { });
|
|
226
|
+
await this.personaEntitlementsCollection.createIndex({ workspaceId: 1, personaKey: 1, hireMode: 1 }, { unique: true }).catch(() => { });
|
|
227
|
+
await this.personaEntitlementsCollection.createIndex({ stripeCustomerId: 1, status: 1 }).catch(() => { });
|
|
228
|
+
await this.personaEntitlementsCollection.createIndex({ stripeSubscriptionId: 1 }).catch(() => { });
|
|
229
|
+
await this.personaEntitlementsCollection.createIndex({ userId: 1, status: 1 }).catch(() => { });
|
|
230
|
+
// Issue #359 — OAuth-first login indices.
|
|
231
|
+
await this.authSessionsCollection.createIndex({ sessionId: 1 }, { unique: true }).catch(() => { });
|
|
232
|
+
await this.authSessionsCollection.createIndex({ userId: 1, revoked: 1 }).catch(() => { });
|
|
233
|
+
// TTL — sessions auto-expire 30d after creation. createIndex is idempotent.
|
|
234
|
+
await this.authSessionsCollection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => { });
|
|
235
|
+
await this.pendingOAuthCollection.createIndex({ pendingId: 1 }, { unique: true }).catch(() => { });
|
|
236
|
+
// TTL — 10-min round-trip window.
|
|
237
|
+
await this.pendingOAuthCollection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => { });
|
|
238
|
+
await this.auditLogCollection.createIndex({ sequence: 1 }).catch(() => { });
|
|
239
|
+
await this.auditLogCollection.createIndex({ userId: 1, ts: -1 }).catch(() => { });
|
|
240
|
+
await this.auditLogCollection.createIndex({ ts: -1 }).catch(() => { });
|
|
241
|
+
}
|
|
242
|
+
async createSession(session) {
|
|
243
|
+
if (!this.sessionsCollection)
|
|
244
|
+
throw new Error('DB not connected');
|
|
245
|
+
await this.sessionsCollection.replaceOne({ sessionId: session.sessionId }, {
|
|
246
|
+
...session,
|
|
247
|
+
repo: sanitizeSessionRepoContext(session.repo)
|
|
248
|
+
}, { upsert: true });
|
|
249
|
+
}
|
|
250
|
+
async updateSessionActivity(sessionId, lastActive) {
|
|
251
|
+
if (!this.sessionsCollection)
|
|
252
|
+
return; // Fail silently
|
|
253
|
+
const updateDoc = { $set: { lastActive } };
|
|
254
|
+
await this.sessionsCollection.updateOne({ sessionId }, updateDoc);
|
|
255
|
+
}
|
|
256
|
+
// ─── Issue #563: shared organization context (FRAIM-cloud backend) ──────
|
|
257
|
+
async getOrgArtifacts(orgId) {
|
|
258
|
+
if (!this.orgArtifactsCollection)
|
|
259
|
+
throw new Error('DB not connected');
|
|
260
|
+
return await this.orgArtifactsCollection.find({ orgId }).toArray();
|
|
261
|
+
}
|
|
262
|
+
async upsertOrgArtifact(record) {
|
|
263
|
+
if (!this.orgArtifactsCollection)
|
|
264
|
+
throw new Error('DB not connected');
|
|
265
|
+
const { _id, ...replacement } = record;
|
|
266
|
+
await this.orgArtifactsCollection.replaceOne({ orgId: record.orgId, relativePath: record.relativePath }, replacement, { upsert: true });
|
|
267
|
+
}
|
|
268
|
+
async appendOrgAuditEntry(entry) {
|
|
269
|
+
if (!this.orgAuditCollection)
|
|
270
|
+
throw new Error('DB not connected');
|
|
271
|
+
await this.orgAuditCollection.insertOne(entry);
|
|
272
|
+
}
|
|
273
|
+
async getOrgAuditEntries(orgId) {
|
|
274
|
+
if (!this.orgAuditCollection)
|
|
275
|
+
throw new Error('DB not connected');
|
|
276
|
+
return await this.orgAuditCollection.find({ orgId }).sort({ at: -1 }).toArray();
|
|
277
|
+
}
|
|
278
|
+
async verifyApiKey(key) {
|
|
279
|
+
if (!this.keysCollection)
|
|
280
|
+
throw new Error('DB not connected');
|
|
281
|
+
// Return the key regardless of isActive/status — the auth middleware
|
|
282
|
+
// is responsible for checking expiry and returning the right HTTP status.
|
|
283
|
+
return await this.keysCollection.findOne({ key });
|
|
284
|
+
}
|
|
285
|
+
async getActiveSessionByApiKey(key) {
|
|
286
|
+
if (!this.keysCollection || !this.sessionsCollection)
|
|
287
|
+
throw new Error('DB not connected');
|
|
288
|
+
// 1. Get user for this key
|
|
289
|
+
const apiKeyData = await this.verifyApiKey(key);
|
|
290
|
+
if (!apiKeyData)
|
|
291
|
+
return null;
|
|
292
|
+
// 2. Get latest session for this user (within last 24h)
|
|
293
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
294
|
+
const session = await this.sessionsCollection.findOne({ userId: apiKeyData.userId, lastActive: { $gt: dayAgo } }, { sort: { lastActive: -1 } });
|
|
295
|
+
return session;
|
|
296
|
+
}
|
|
297
|
+
async getSessionByApiKeyAndSessionId(key, sessionId) {
|
|
298
|
+
if (!this.keysCollection || !this.sessionsCollection)
|
|
299
|
+
throw new Error('DB not connected');
|
|
300
|
+
const apiKeyData = await this.verifyApiKey(key);
|
|
301
|
+
if (!apiKeyData)
|
|
302
|
+
return null;
|
|
303
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
304
|
+
const session = await this.sessionsCollection.findOne({
|
|
305
|
+
sessionId,
|
|
306
|
+
userId: apiKeyData.userId,
|
|
307
|
+
lastActive: { $gt: dayAgo }
|
|
308
|
+
});
|
|
309
|
+
return session;
|
|
310
|
+
}
|
|
311
|
+
async createAuthSession(input) {
|
|
312
|
+
if (!this.authSessionsCollection)
|
|
313
|
+
throw new Error('DB not connected');
|
|
314
|
+
const now = new Date();
|
|
315
|
+
const session = {
|
|
316
|
+
sessionId: input.sessionId,
|
|
317
|
+
userId: input.userId,
|
|
318
|
+
authMethod: input.authMethod,
|
|
319
|
+
createdAt: now,
|
|
320
|
+
lastSeenAt: now,
|
|
321
|
+
expiresAt: new Date(now.getTime() + FraimDbService.AUTH_SESSION_TTL_MS),
|
|
322
|
+
revoked: false,
|
|
323
|
+
revokedAt: null,
|
|
324
|
+
userAgent: input.userAgent,
|
|
325
|
+
ip: input.ip,
|
|
326
|
+
};
|
|
327
|
+
await this.authSessionsCollection.insertOne(session);
|
|
328
|
+
return session;
|
|
329
|
+
}
|
|
330
|
+
async getAuthSession(sessionId) {
|
|
331
|
+
if (!this.authSessionsCollection)
|
|
332
|
+
throw new Error('DB not connected');
|
|
333
|
+
return await this.authSessionsCollection.findOne({ sessionId });
|
|
334
|
+
}
|
|
335
|
+
async touchAuthSession(sessionId) {
|
|
336
|
+
if (!this.authSessionsCollection)
|
|
337
|
+
return;
|
|
338
|
+
await this.authSessionsCollection.updateOne({ sessionId, revoked: false }, { $set: { lastSeenAt: new Date() } });
|
|
339
|
+
}
|
|
340
|
+
async revokeAuthSession(sessionId) {
|
|
341
|
+
if (!this.authSessionsCollection)
|
|
342
|
+
throw new Error('DB not connected');
|
|
343
|
+
const result = await this.authSessionsCollection.updateOne({ sessionId, revoked: false }, { $set: { revoked: true, revokedAt: new Date() } });
|
|
344
|
+
return result.modifiedCount > 0;
|
|
345
|
+
}
|
|
346
|
+
async revokeAllAuthSessionsForUser(userId, exceptSessionId) {
|
|
347
|
+
if (!this.authSessionsCollection)
|
|
348
|
+
throw new Error('DB not connected');
|
|
349
|
+
const filter = { userId, revoked: false };
|
|
350
|
+
if (exceptSessionId)
|
|
351
|
+
filter.sessionId = { $ne: exceptSessionId };
|
|
352
|
+
const result = await this.authSessionsCollection.updateMany(filter, { $set: { revoked: true, revokedAt: new Date() } });
|
|
353
|
+
return result.modifiedCount;
|
|
354
|
+
}
|
|
355
|
+
async listActiveAuthSessionsForUser(userId) {
|
|
356
|
+
if (!this.authSessionsCollection)
|
|
357
|
+
throw new Error('DB not connected');
|
|
358
|
+
const now = new Date();
|
|
359
|
+
return await this.authSessionsCollection
|
|
360
|
+
.find({ userId, revoked: false, expiresAt: { $gt: now } })
|
|
361
|
+
.sort({ lastSeenAt: -1 })
|
|
362
|
+
.toArray();
|
|
363
|
+
}
|
|
364
|
+
async countTotalAuthSessionsForUser(userId) {
|
|
365
|
+
if (!this.authSessionsCollection)
|
|
366
|
+
throw new Error('DB not connected');
|
|
367
|
+
return await this.authSessionsCollection.countDocuments({ userId });
|
|
368
|
+
}
|
|
369
|
+
async createPendingOAuth(input) {
|
|
370
|
+
if (!this.pendingOAuthCollection)
|
|
371
|
+
throw new Error('DB not connected');
|
|
372
|
+
const now = new Date();
|
|
373
|
+
await this.pendingOAuthCollection.insertOne({
|
|
374
|
+
...input,
|
|
375
|
+
createdAt: now,
|
|
376
|
+
expiresAt: new Date(now.getTime() + FraimDbService.PENDING_OAUTH_TTL_MS),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async consumePendingOAuth(pendingId) {
|
|
380
|
+
if (!this.pendingOAuthCollection)
|
|
381
|
+
throw new Error('DB not connected');
|
|
382
|
+
const now = new Date();
|
|
383
|
+
const result = await this.pendingOAuthCollection.findOneAndDelete({
|
|
384
|
+
pendingId,
|
|
385
|
+
expiresAt: { $gt: now },
|
|
386
|
+
});
|
|
387
|
+
return result?.value ?? result ?? null;
|
|
388
|
+
}
|
|
389
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
390
|
+
// Issue #359 — Audit log persistence
|
|
391
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
392
|
+
async appendAuditRecord(record) {
|
|
393
|
+
if (!this.auditLogCollection)
|
|
394
|
+
throw new Error('DB not connected');
|
|
395
|
+
await this.auditLogCollection.insertOne(record);
|
|
396
|
+
}
|
|
397
|
+
async listAuditRecordsForUser(userId, sinceMs, limit) {
|
|
398
|
+
if (!this.auditLogCollection)
|
|
399
|
+
throw new Error('DB not connected');
|
|
400
|
+
const since = new Date(Date.now() - sinceMs);
|
|
401
|
+
return await this.auditLogCollection
|
|
402
|
+
.find({ userId, ts: { $gte: since } })
|
|
403
|
+
.sort({ ts: -1 })
|
|
404
|
+
.limit(limit)
|
|
405
|
+
.toArray();
|
|
406
|
+
}
|
|
407
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
408
|
+
// Issue #359 — Identity helpers
|
|
409
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
410
|
+
async rotateApiKey(currentKey, newKey) {
|
|
411
|
+
if (!this.keysCollection)
|
|
412
|
+
throw new Error('DB not connected');
|
|
413
|
+
const existing = await this.keysCollection.findOne({ key: currentKey });
|
|
414
|
+
if (!existing)
|
|
415
|
+
return false;
|
|
416
|
+
await this.keysCollection.insertOne({
|
|
417
|
+
...existing,
|
|
418
|
+
_id: undefined,
|
|
419
|
+
key: newKey,
|
|
420
|
+
createdAt: new Date(),
|
|
421
|
+
isActive: true,
|
|
422
|
+
});
|
|
423
|
+
await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired' } });
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
async listApiKeys() {
|
|
427
|
+
if (!this.keysCollection)
|
|
428
|
+
throw new Error('DB not connected');
|
|
429
|
+
return await this.keysCollection.find({}).toArray();
|
|
430
|
+
}
|
|
431
|
+
async createApiKey(data) {
|
|
432
|
+
if (!this.keysCollection)
|
|
433
|
+
throw new Error('DB not connected');
|
|
434
|
+
await this.keysCollection.insertOne({
|
|
435
|
+
...data,
|
|
436
|
+
isActive: true,
|
|
437
|
+
createdAt: new Date()
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
async revokeApiKey(key) {
|
|
441
|
+
if (!this.keysCollection)
|
|
442
|
+
throw new Error('DB not connected');
|
|
443
|
+
const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false } });
|
|
444
|
+
return result.matchedCount > 0;
|
|
445
|
+
}
|
|
446
|
+
buildApiKeyTargetQuery(keys, userIds, labels = []) {
|
|
447
|
+
const uniqueKeys = [...new Set(keys.filter(Boolean))];
|
|
448
|
+
const uniqueUserIds = normalizeUserIds(userIds);
|
|
449
|
+
const uniqueLabels = normalizeLabels(labels);
|
|
450
|
+
const queryFilters = [];
|
|
451
|
+
if (uniqueKeys.length) {
|
|
452
|
+
queryFilters.push({ key: { $in: uniqueKeys } });
|
|
453
|
+
}
|
|
454
|
+
if (uniqueUserIds.length) {
|
|
455
|
+
queryFilters.push({ userId: { $in: uniqueUserIds } });
|
|
456
|
+
}
|
|
457
|
+
if (uniqueLabels.length) {
|
|
458
|
+
queryFilters.push({ $or: uniqueLabels.map(buildLabelMatch) });
|
|
459
|
+
}
|
|
460
|
+
if (queryFilters.length === 0) {
|
|
461
|
+
return { query: { _id: { $exists: false } }, uniqueKeys, uniqueUserIds, uniqueLabels };
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
query: queryFilters.length === 1 ? queryFilters[0] : { $or: queryFilters },
|
|
465
|
+
uniqueKeys,
|
|
466
|
+
uniqueUserIds,
|
|
467
|
+
uniqueLabels
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
async updateApiKeyExpirations(keys, userIds, expiresAt, labels = []) {
|
|
471
|
+
if (!this.keysCollection)
|
|
472
|
+
throw new Error('DB not connected');
|
|
473
|
+
const { query, uniqueKeys, uniqueUserIds, uniqueLabels } = this.buildApiKeyTargetQuery(keys, userIds, labels);
|
|
474
|
+
const existingKeys = await this.keysCollection
|
|
475
|
+
.find(query)
|
|
476
|
+
.toArray();
|
|
477
|
+
const updatedKeys = [];
|
|
478
|
+
const updatedUserIds = new Set();
|
|
479
|
+
const foundKeys = new Set(existingKeys.map((record) => record.key));
|
|
480
|
+
const foundUserIds = new Set(existingKeys.map((record) => record.userId.toLowerCase()));
|
|
481
|
+
const missingKeys = uniqueKeys.filter((key) => !foundKeys.has(key));
|
|
482
|
+
const missingUserIds = uniqueUserIds.filter((userId) => !foundUserIds.has(userId));
|
|
483
|
+
const nextStatus = expiresAt && expiresAt.getTime() <= Date.now() ? 'expired' : 'active';
|
|
484
|
+
for (const record of existingKeys) {
|
|
485
|
+
const status = record.status === 'suspended' ? 'suspended' : nextStatus;
|
|
486
|
+
const update = {
|
|
487
|
+
expiresAt,
|
|
488
|
+
status
|
|
489
|
+
};
|
|
490
|
+
if (record.tier === 'paid-subscription') {
|
|
491
|
+
update.currentPeriodEnd = expiresAt;
|
|
492
|
+
}
|
|
493
|
+
await this.keysCollection.updateOne({ key: record.key }, { $set: update });
|
|
494
|
+
updatedKeys.push(record.key);
|
|
495
|
+
updatedUserIds.add(record.userId);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
updatedKeys,
|
|
499
|
+
missingKeys,
|
|
500
|
+
updatedUserIds: [...updatedUserIds],
|
|
501
|
+
missingUserIds,
|
|
502
|
+
matchedLabels: uniqueLabels
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async updateApiKeyLabels(keys, userIds, labels, operation) {
|
|
506
|
+
if (!this.keysCollection)
|
|
507
|
+
throw new Error('DB not connected');
|
|
508
|
+
const normalizedLabels = normalizeLabels(labels);
|
|
509
|
+
if (normalizedLabels.length === 0) {
|
|
510
|
+
throw new Error('At least one label is required');
|
|
511
|
+
}
|
|
512
|
+
const { query, uniqueKeys, uniqueUserIds } = this.buildApiKeyTargetQuery(keys, userIds);
|
|
513
|
+
const existingKeys = await this.keysCollection.find(query).toArray();
|
|
514
|
+
const updatedKeys = [];
|
|
515
|
+
const updatedUserIds = new Set();
|
|
516
|
+
const foundKeys = new Set(existingKeys.map((record) => record.key));
|
|
517
|
+
const foundUserIds = new Set(existingKeys.map((record) => record.userId.toLowerCase()));
|
|
518
|
+
const missingKeys = uniqueKeys.filter((key) => !foundKeys.has(key));
|
|
519
|
+
const missingUserIds = uniqueUserIds.filter((userId) => !foundUserIds.has(userId));
|
|
520
|
+
for (const record of existingKeys) {
|
|
521
|
+
const nextLabels = operation === 'add'
|
|
522
|
+
? mergeLabels(record.labels, normalizedLabels)
|
|
523
|
+
: removeLabels(record.labels, normalizedLabels);
|
|
524
|
+
await this.keysCollection.updateOne({ key: record.key }, { $set: { labels: nextLabels } });
|
|
525
|
+
updatedKeys.push(record.key);
|
|
526
|
+
updatedUserIds.add(record.userId);
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
updatedKeys,
|
|
530
|
+
missingKeys,
|
|
531
|
+
updatedUserIds: [...updatedUserIds],
|
|
532
|
+
missingUserIds,
|
|
533
|
+
labels: normalizedLabels
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Generates a cryptographically secure API key
|
|
538
|
+
*/
|
|
539
|
+
generateApiKey(userId, orgId) {
|
|
540
|
+
const crypto = require('crypto');
|
|
541
|
+
const prefix = 'fraim_';
|
|
542
|
+
// 128-bit entropy keeps keys strong while being shorter/easier to handle.
|
|
543
|
+
const random = crypto.randomBytes(16).toString('hex'); // 32 characters
|
|
544
|
+
return `${prefix}${random}`;
|
|
545
|
+
}
|
|
546
|
+
async createWebsiteSignup(signup) {
|
|
547
|
+
if (!this.signupsCollection)
|
|
548
|
+
throw new Error('DB not connected');
|
|
549
|
+
try {
|
|
550
|
+
await this.signupsCollection.insertOne(signup);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (error.code === 11000) {
|
|
554
|
+
throw new Error('Email already registered');
|
|
555
|
+
}
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async getWebsiteSignups(limit = 100) {
|
|
560
|
+
if (!this.signupsCollection)
|
|
561
|
+
throw new Error('DB not connected');
|
|
562
|
+
return await this.signupsCollection.find({})
|
|
563
|
+
.sort({ timestamp: -1 })
|
|
564
|
+
.limit(limit)
|
|
565
|
+
.toArray();
|
|
566
|
+
}
|
|
567
|
+
async getSignupByEmail(email) {
|
|
568
|
+
if (!this.signupsCollection)
|
|
569
|
+
throw new Error('DB not connected');
|
|
570
|
+
return await this.signupsCollection.findOne({ email });
|
|
571
|
+
}
|
|
572
|
+
/** Get existing API key by userId (email for self-serve) */
|
|
573
|
+
async getApiKeyByUserId(userId, activeOnly = true) {
|
|
574
|
+
if (!this.db)
|
|
575
|
+
throw new Error('DB not connected');
|
|
576
|
+
const query = { userId };
|
|
577
|
+
if (activeOnly) {
|
|
578
|
+
// Match active status OR legacy isActive:true with no status field.
|
|
579
|
+
query.$or = [
|
|
580
|
+
{ status: 'active' },
|
|
581
|
+
{ status: { $exists: false }, isActive: true }
|
|
582
|
+
];
|
|
583
|
+
}
|
|
584
|
+
return await this.db.collection('fraim_api_keys').findOne(query);
|
|
585
|
+
}
|
|
586
|
+
async createAccessToken(data) {
|
|
587
|
+
if (!this.accessTokensCollection)
|
|
588
|
+
throw new Error('DB not connected');
|
|
589
|
+
await this.accessTokensCollection.insertOne(data);
|
|
590
|
+
}
|
|
591
|
+
async getAccessToken(token) {
|
|
592
|
+
if (!this.accessTokensCollection)
|
|
593
|
+
throw new Error('DB not connected');
|
|
594
|
+
const doc = await this.accessTokensCollection.findOne({
|
|
595
|
+
token,
|
|
596
|
+
expiresAt: { $gt: new Date() }
|
|
597
|
+
});
|
|
598
|
+
return doc;
|
|
599
|
+
}
|
|
600
|
+
async markAccessTokenUsedForInstaller(token) {
|
|
601
|
+
if (!this.accessTokensCollection)
|
|
602
|
+
throw new Error('DB not connected');
|
|
603
|
+
const result = await this.accessTokensCollection.updateOne({ token }, { $set: { usedForInstaller: true } });
|
|
604
|
+
return result.matchedCount > 0;
|
|
605
|
+
}
|
|
606
|
+
async createSalesInquiry(inquiry) {
|
|
607
|
+
if (!this.salesCollection)
|
|
608
|
+
throw new Error('DB not connected');
|
|
609
|
+
await this.salesCollection.insertOne(inquiry);
|
|
610
|
+
}
|
|
611
|
+
async getSalesInquiries(limit = 100) {
|
|
612
|
+
if (!this.salesCollection)
|
|
613
|
+
throw new Error('DB not connected');
|
|
614
|
+
return await this.salesCollection.find({})
|
|
615
|
+
.sort({ timestamp: -1 })
|
|
616
|
+
.limit(limit)
|
|
617
|
+
.toArray();
|
|
618
|
+
}
|
|
619
|
+
// ========== Pending Verification Methods (Email Code / Verification Flow) ==========
|
|
620
|
+
async createPendingVerification(data) {
|
|
621
|
+
if (!this.pendingVerificationsCollection)
|
|
622
|
+
throw new Error('DB not connected');
|
|
623
|
+
await this.pendingVerificationsCollection.insertOne(data);
|
|
624
|
+
}
|
|
625
|
+
async getPendingVerification(token) {
|
|
626
|
+
if (!this.pendingVerificationsCollection)
|
|
627
|
+
throw new Error('DB not connected');
|
|
628
|
+
return await this.pendingVerificationsCollection.findOne({
|
|
629
|
+
token,
|
|
630
|
+
expiresAt: { $gt: new Date() },
|
|
631
|
+
verified: false
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Return the pending-verification row regardless of verified/expired state.
|
|
636
|
+
*/
|
|
637
|
+
async getPendingVerificationRaw(token) {
|
|
638
|
+
if (!this.pendingVerificationsCollection)
|
|
639
|
+
throw new Error('DB not connected');
|
|
640
|
+
return await this.pendingVerificationsCollection.findOne({ token });
|
|
641
|
+
}
|
|
642
|
+
async markVerificationUsed(token) {
|
|
643
|
+
if (!this.pendingVerificationsCollection)
|
|
644
|
+
throw new Error('DB not connected');
|
|
645
|
+
const result = await this.pendingVerificationsCollection.updateOne({ token }, {
|
|
646
|
+
$set: {
|
|
647
|
+
verified: true,
|
|
648
|
+
usedAt: new Date()
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
return result.matchedCount > 0;
|
|
652
|
+
}
|
|
653
|
+
async consumePendingVerificationByCode(email, codeHash) {
|
|
654
|
+
if (!this.pendingVerificationsCollection)
|
|
655
|
+
throw new Error('DB not connected');
|
|
656
|
+
const now = new Date();
|
|
657
|
+
const row = await this.pendingVerificationsCollection.findOne({
|
|
658
|
+
email: email.toLowerCase(),
|
|
659
|
+
codeHash,
|
|
660
|
+
expiresAt: { $gt: now },
|
|
661
|
+
verified: false
|
|
662
|
+
});
|
|
663
|
+
if (!row)
|
|
664
|
+
return null;
|
|
665
|
+
const result = await this.pendingVerificationsCollection.updateOne({ token: row.token, verified: false }, {
|
|
666
|
+
$set: {
|
|
667
|
+
verified: true,
|
|
668
|
+
usedAt: new Date()
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
return result.matchedCount > 0 ? row : null;
|
|
672
|
+
}
|
|
673
|
+
// ========== API Key Extended Methods (Expiration, Suspension, etc.) ==========
|
|
674
|
+
async getApiKeyByKey(key) {
|
|
675
|
+
if (!this.keysCollection)
|
|
676
|
+
throw new Error('DB not connected');
|
|
677
|
+
return await this.keysCollection.findOne({ key });
|
|
678
|
+
}
|
|
679
|
+
async updateApiKey(key, update) {
|
|
680
|
+
if (!this.keysCollection)
|
|
681
|
+
throw new Error('DB not connected');
|
|
682
|
+
const result = await this.keysCollection.updateOne({ key }, { $set: update });
|
|
683
|
+
return result.matchedCount > 0;
|
|
684
|
+
}
|
|
685
|
+
async updateApiKeyLastUsed(key) {
|
|
686
|
+
if (!this.keysCollection)
|
|
687
|
+
throw new Error('DB not connected');
|
|
688
|
+
await this.keysCollection.updateOne({ key }, {
|
|
689
|
+
$set: { lastUsedAt: new Date() },
|
|
690
|
+
$inc: { apiCallCount: 1 }
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async getApiKeyByStripeSubscriptionId(subscriptionId) {
|
|
694
|
+
if (!this.keysCollection)
|
|
695
|
+
throw new Error('DB not connected');
|
|
696
|
+
return await this.keysCollection.findOne({ stripeSubscriptionId: subscriptionId });
|
|
697
|
+
}
|
|
698
|
+
async getApiKeyByStripeCustomerId(customerId) {
|
|
699
|
+
if (!this.keysCollection)
|
|
700
|
+
throw new Error('DB not connected');
|
|
701
|
+
return await this.keysCollection.findOne({ stripeCustomerId: customerId });
|
|
702
|
+
}
|
|
703
|
+
async getApiKeyByWorkspaceId(workspaceId, activeOnly = true) {
|
|
704
|
+
if (!this.keysCollection)
|
|
705
|
+
throw new Error('DB not connected');
|
|
706
|
+
const query = { workspaceId };
|
|
707
|
+
if (activeOnly) {
|
|
708
|
+
query.$or = [
|
|
709
|
+
{ status: 'active' },
|
|
710
|
+
{ status: { $exists: false }, isActive: true }
|
|
711
|
+
];
|
|
712
|
+
}
|
|
713
|
+
return await this.keysCollection.findOne(query);
|
|
714
|
+
}
|
|
715
|
+
async getPersonaEntitlement(workspaceId, personaKey, hireMode) {
|
|
716
|
+
if (!this.personaEntitlementsCollection)
|
|
717
|
+
throw new Error('DB not connected');
|
|
718
|
+
const query = { workspaceId, personaKey };
|
|
719
|
+
if (hireMode) {
|
|
720
|
+
query.hireMode = hireMode;
|
|
721
|
+
}
|
|
722
|
+
return await this.personaEntitlementsCollection.findOne(query, { sort: { updatedAt: -1 } });
|
|
723
|
+
}
|
|
724
|
+
async getPersonaEntitlementsByWorkspaceId(workspaceId, activeOnly = true) {
|
|
725
|
+
if (!this.personaEntitlementsCollection)
|
|
726
|
+
throw new Error('DB not connected');
|
|
727
|
+
const query = { workspaceId };
|
|
728
|
+
if (activeOnly) {
|
|
729
|
+
query.status = 'active';
|
|
730
|
+
}
|
|
731
|
+
return await this.personaEntitlementsCollection.find(query).sort({ personaKey: 1, hireMode: 1 }).toArray();
|
|
732
|
+
}
|
|
733
|
+
async listPersonaEntitlementsByStripeCustomerId(customerId) {
|
|
734
|
+
if (!this.personaEntitlementsCollection)
|
|
735
|
+
throw new Error('DB not connected');
|
|
736
|
+
return await this.personaEntitlementsCollection.find({ stripeCustomerId: customerId }).sort({ updatedAt: -1 }).toArray();
|
|
737
|
+
}
|
|
738
|
+
async getPersonaEntitlementsByUserId(userId, activeOnly = true) {
|
|
739
|
+
if (!this.personaEntitlementsCollection)
|
|
740
|
+
throw new Error('DB not connected');
|
|
741
|
+
const query = { userId };
|
|
742
|
+
if (activeOnly) {
|
|
743
|
+
query.status = 'active';
|
|
744
|
+
}
|
|
745
|
+
return await this.personaEntitlementsCollection.find(query).sort({ personaKey: 1, hireMode: 1 }).toArray();
|
|
746
|
+
}
|
|
747
|
+
async upsertPersonaEntitlement(entitlement) {
|
|
748
|
+
if (!this.personaEntitlementsCollection)
|
|
749
|
+
throw new Error('DB not connected');
|
|
750
|
+
const now = new Date();
|
|
751
|
+
await this.personaEntitlementsCollection.updateOne({
|
|
752
|
+
workspaceId: entitlement.workspaceId,
|
|
753
|
+
personaKey: entitlement.personaKey,
|
|
754
|
+
hireMode: entitlement.hireMode
|
|
755
|
+
}, {
|
|
756
|
+
$set: {
|
|
757
|
+
...entitlement,
|
|
758
|
+
updatedAt: now
|
|
759
|
+
},
|
|
760
|
+
$setOnInsert: {
|
|
761
|
+
createdAt: now
|
|
762
|
+
}
|
|
763
|
+
}, { upsert: true });
|
|
764
|
+
const stored = await this.getPersonaEntitlement(entitlement.workspaceId, entitlement.personaKey, entitlement.hireMode);
|
|
765
|
+
if (!stored) {
|
|
766
|
+
throw new Error(`Failed to upsert persona entitlement for ${entitlement.workspaceId}/${entitlement.personaKey}/${entitlement.hireMode}`);
|
|
767
|
+
}
|
|
768
|
+
return stored;
|
|
769
|
+
}
|
|
770
|
+
async consumePersonaJobCredit(workspaceId, personaKey) {
|
|
771
|
+
if (!this.personaEntitlementsCollection)
|
|
772
|
+
throw new Error('DB not connected');
|
|
773
|
+
const result = await this.personaEntitlementsCollection.updateOne({
|
|
774
|
+
workspaceId,
|
|
775
|
+
personaKey,
|
|
776
|
+
hireMode: 'job',
|
|
777
|
+
status: 'active',
|
|
778
|
+
jobCreditsRemaining: { $gt: 0 }
|
|
779
|
+
}, {
|
|
780
|
+
$inc: { jobCreditsRemaining: -1 },
|
|
781
|
+
$set: { updatedAt: new Date() }
|
|
782
|
+
});
|
|
783
|
+
return result.modifiedCount > 0;
|
|
784
|
+
}
|
|
785
|
+
// ========== Stripe Webhook Event Tracking (Idempotency) ==========
|
|
786
|
+
async recordWebhookEvent(event) {
|
|
787
|
+
if (!this.webhookEventsCollection)
|
|
788
|
+
throw new Error('DB not connected');
|
|
789
|
+
try {
|
|
790
|
+
await this.webhookEventsCollection.insertOne(event);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
if (error.code === 11000) {
|
|
794
|
+
// Duplicate event ID - already processed
|
|
795
|
+
throw new Error('Webhook event already processed');
|
|
796
|
+
}
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async isWebhookEventProcessed(eventId) {
|
|
801
|
+
if (!this.webhookEventsCollection)
|
|
802
|
+
throw new Error('DB not connected');
|
|
803
|
+
const event = await this.webhookEventsCollection.findOne({ eventId, processed: true });
|
|
804
|
+
return event !== null;
|
|
805
|
+
}
|
|
806
|
+
async markWebhookEventProcessed(eventId) {
|
|
807
|
+
if (!this.webhookEventsCollection)
|
|
808
|
+
throw new Error('DB not connected');
|
|
809
|
+
const result = await this.webhookEventsCollection.updateOne({ eventId }, {
|
|
810
|
+
$set: {
|
|
811
|
+
processed: true,
|
|
812
|
+
processedAt: new Date()
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
return result.matchedCount > 0;
|
|
816
|
+
}
|
|
817
|
+
async markWebhookEventFailed(eventId, error) {
|
|
818
|
+
if (!this.webhookEventsCollection)
|
|
819
|
+
throw new Error('DB not connected');
|
|
820
|
+
const result = await this.webhookEventsCollection.updateOne({ eventId }, {
|
|
821
|
+
$set: {
|
|
822
|
+
lastError: error
|
|
823
|
+
},
|
|
824
|
+
$inc: {
|
|
825
|
+
retryCount: 1
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
return result.matchedCount > 0;
|
|
829
|
+
}
|
|
830
|
+
// ── Partner Discounts ──────────────────────────────────────────────────────
|
|
831
|
+
/** Look up an active partner discount for the given email.
|
|
832
|
+
* Email-level match takes priority over domain-level. */
|
|
833
|
+
async lookupPartnerDiscount(email) {
|
|
834
|
+
if (!this.partnerDiscountsCollection)
|
|
835
|
+
throw new Error('DB not connected');
|
|
836
|
+
const lower = email.toLowerCase();
|
|
837
|
+
const domain = lower.split('@')[1];
|
|
838
|
+
// Email-level first
|
|
839
|
+
const emailMatch = await this.partnerDiscountsCollection.findOne({ type: 'email', match: lower, active: true });
|
|
840
|
+
if (emailMatch)
|
|
841
|
+
return emailMatch;
|
|
842
|
+
// Domain-level fallback
|
|
843
|
+
if (domain) {
|
|
844
|
+
return this.partnerDiscountsCollection.findOne({ type: 'domain', match: domain, active: true });
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
async createPartnerDiscount(entry) {
|
|
849
|
+
if (!this.partnerDiscountsCollection)
|
|
850
|
+
throw new Error('DB not connected');
|
|
851
|
+
const result = await this.partnerDiscountsCollection.insertOne(entry);
|
|
852
|
+
return { ...entry, _id: result.insertedId };
|
|
853
|
+
}
|
|
854
|
+
async listPartnerDiscounts() {
|
|
855
|
+
if (!this.partnerDiscountsCollection)
|
|
856
|
+
throw new Error('DB not connected');
|
|
857
|
+
return this.partnerDiscountsCollection.find({}).sort({ createdAt: -1 }).toArray();
|
|
858
|
+
}
|
|
859
|
+
async updatePartnerDiscount(id, update) {
|
|
860
|
+
if (!this.partnerDiscountsCollection)
|
|
861
|
+
throw new Error('DB not connected');
|
|
862
|
+
const { ObjectId } = await Promise.resolve().then(() => __importStar(require('mongodb')));
|
|
863
|
+
const result = await this.partnerDiscountsCollection.updateOne({ _id: new ObjectId(id) }, { $set: update });
|
|
864
|
+
return result.matchedCount > 0;
|
|
865
|
+
}
|
|
866
|
+
async incrementPartnerDiscountUseCount(id) {
|
|
867
|
+
if (!this.partnerDiscountsCollection)
|
|
868
|
+
throw new Error('DB not connected');
|
|
869
|
+
await this.partnerDiscountsCollection.updateOne({ _id: id }, { $inc: { useCount: 1 } });
|
|
870
|
+
}
|
|
871
|
+
// ===== USAGE ANALYTICS METHODS =====
|
|
872
|
+
/**
|
|
873
|
+
* Log a usage event for analytics tracking
|
|
874
|
+
*/
|
|
875
|
+
async logUsageEvent(event) {
|
|
876
|
+
if (!this.db)
|
|
877
|
+
throw new Error('DB not connected');
|
|
878
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
879
|
+
// Sanitize repository identifier for compliance (C1)
|
|
880
|
+
const repoIdentifier = (0, git_utils_1.sanitizeRepoIdentifier)(event.repoIdentifier);
|
|
881
|
+
const usageEvent = {
|
|
882
|
+
...event,
|
|
883
|
+
repoIdentifier,
|
|
884
|
+
createdAt: new Date()
|
|
885
|
+
};
|
|
886
|
+
await collection.insertOne(usageEvent);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Get usage statistics for a given time range and optional filters
|
|
890
|
+
*/
|
|
891
|
+
async getUsageStats(timeWindow, types, userId, repoIdentifier) {
|
|
892
|
+
if (!this.db)
|
|
893
|
+
throw new Error('DB not connected');
|
|
894
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
895
|
+
const { startDate, endDate } = this.resolveTimeWindow(timeWindow);
|
|
896
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
897
|
+
const matchStage = {
|
|
898
|
+
createdAt: this.buildCreatedAtMatch(timeWindow)
|
|
899
|
+
};
|
|
900
|
+
if (types && types.length > 0) {
|
|
901
|
+
matchStage.type = { $in: types };
|
|
902
|
+
}
|
|
903
|
+
if (userId) {
|
|
904
|
+
matchStage.userId = userId;
|
|
905
|
+
}
|
|
906
|
+
if (sanitizedRepo) {
|
|
907
|
+
matchStage.repoIdentifier = sanitizedRepo;
|
|
908
|
+
}
|
|
909
|
+
const pipeline = [
|
|
910
|
+
{ $match: matchStage },
|
|
911
|
+
{
|
|
912
|
+
$group: {
|
|
913
|
+
_id: null,
|
|
914
|
+
totalEvents: { $sum: 1 },
|
|
915
|
+
successfulEvents: { $sum: { $cond: ['$success', 1, 0] } },
|
|
916
|
+
lastEventAt: { $max: '$createdAt' },
|
|
917
|
+
eventsByType: {
|
|
918
|
+
$push: {
|
|
919
|
+
type: '$type',
|
|
920
|
+
success: '$success'
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
];
|
|
926
|
+
const result = await collection.aggregate(pipeline).toArray();
|
|
927
|
+
if (result.length === 0) {
|
|
928
|
+
return {
|
|
929
|
+
totalEvents: 0,
|
|
930
|
+
eventsByType: {},
|
|
931
|
+
successRate: 0,
|
|
932
|
+
timeRange: { start: startDate, end: endDate },
|
|
933
|
+
lastEventAt: null
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
const data = result[0];
|
|
937
|
+
const eventsByType = {
|
|
938
|
+
job: 0,
|
|
939
|
+
skill: 0,
|
|
940
|
+
rule: 0,
|
|
941
|
+
mentoring: 0,
|
|
942
|
+
session: 0
|
|
943
|
+
};
|
|
944
|
+
// Count events by type
|
|
945
|
+
data.eventsByType.forEach((event) => {
|
|
946
|
+
eventsByType[event.type] = (eventsByType[event.type] || 0) + 1;
|
|
947
|
+
});
|
|
948
|
+
return {
|
|
949
|
+
totalEvents: data.totalEvents,
|
|
950
|
+
eventsByType,
|
|
951
|
+
successRate: data.totalEvents > 0 ? (data.successfulEvents / data.totalEvents) * 100 : 0,
|
|
952
|
+
timeRange: { start: startDate, end: endDate },
|
|
953
|
+
lastEventAt: data.lastEventAt ?? null
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
async getLatestUsageEventAt(userId, types, repoIdentifier) {
|
|
957
|
+
if (!this.db)
|
|
958
|
+
throw new Error('DB not connected');
|
|
959
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
960
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
961
|
+
const match = { userId };
|
|
962
|
+
if (types && types.length > 0) {
|
|
963
|
+
match.type = { $in: types };
|
|
964
|
+
}
|
|
965
|
+
if (sanitizedRepo) {
|
|
966
|
+
match.repoIdentifier = sanitizedRepo;
|
|
967
|
+
}
|
|
968
|
+
const latest = await collection.findOne(match, {
|
|
969
|
+
sort: { createdAt: -1 },
|
|
970
|
+
projection: { createdAt: 1 }
|
|
971
|
+
});
|
|
972
|
+
return latest?.createdAt ?? null;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Get top components by usage count
|
|
976
|
+
*/
|
|
977
|
+
async getTopComponents(limit, timeWindow, types, userId, repoIdentifier) {
|
|
978
|
+
if (!this.db)
|
|
979
|
+
throw new Error('DB not connected');
|
|
980
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
981
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
982
|
+
const matchStage = {
|
|
983
|
+
createdAt: this.buildCreatedAtMatch(timeWindow),
|
|
984
|
+
...(userId && { userId }),
|
|
985
|
+
...(sanitizedRepo && { repoIdentifier: sanitizedRepo })
|
|
986
|
+
};
|
|
987
|
+
if (types && types.length > 0) {
|
|
988
|
+
matchStage.type = { $in: types };
|
|
989
|
+
}
|
|
990
|
+
// When counting job events, exclude explicit complete events so each run is
|
|
991
|
+
// counted once. A completed run emits both start and complete; without this
|
|
992
|
+
// filter the dashboard would show double the runs count vs the runs list.
|
|
993
|
+
// Old-path events (no args.action field) pass through because $ne includes
|
|
994
|
+
// documents where the field is absent.
|
|
995
|
+
if (!types || types.includes('job')) {
|
|
996
|
+
matchStage['args.action'] = { $ne: 'complete' };
|
|
997
|
+
}
|
|
998
|
+
const pipeline = [
|
|
999
|
+
{
|
|
1000
|
+
$match: matchStage
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
$group: {
|
|
1004
|
+
_id: { name: '$name', type: '$type' },
|
|
1005
|
+
category: { $first: '$category' },
|
|
1006
|
+
count: { $sum: 1 },
|
|
1007
|
+
successfulEvents: { $sum: { $cond: ['$success', 1, 0] } },
|
|
1008
|
+
lastUsed: { $max: '$createdAt' }
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
{ $sort: { count: -1 } },
|
|
1012
|
+
{ $limit: limit }
|
|
1013
|
+
];
|
|
1014
|
+
const results = await collection.aggregate(pipeline).toArray();
|
|
1015
|
+
return results.map(result => ({
|
|
1016
|
+
name: result._id.name,
|
|
1017
|
+
type: result._id.type,
|
|
1018
|
+
category: result.category,
|
|
1019
|
+
count: result.count,
|
|
1020
|
+
successRate: result.count > 0 ? (result.successfulEvents / result.count) * 100 : 0,
|
|
1021
|
+
lastUsed: result.lastUsed
|
|
1022
|
+
}));
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Get usage pattern for a specific user (API key)
|
|
1026
|
+
*/
|
|
1027
|
+
async getUserUsagePattern(userId) {
|
|
1028
|
+
if (!this.db)
|
|
1029
|
+
throw new Error('DB not connected');
|
|
1030
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1031
|
+
const pipeline = [
|
|
1032
|
+
{ $match: { userId } },
|
|
1033
|
+
{
|
|
1034
|
+
$group: {
|
|
1035
|
+
_id: null,
|
|
1036
|
+
totalEvents: { $sum: 1 },
|
|
1037
|
+
usageByType: {
|
|
1038
|
+
$push: '$type'
|
|
1039
|
+
},
|
|
1040
|
+
lastActivity: { $max: '$createdAt' },
|
|
1041
|
+
components: {
|
|
1042
|
+
$push: {
|
|
1043
|
+
name: '$name',
|
|
1044
|
+
type: '$type',
|
|
1045
|
+
success: '$success'
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
];
|
|
1051
|
+
const result = await collection.aggregate(pipeline).toArray();
|
|
1052
|
+
if (result.length === 0) {
|
|
1053
|
+
return {
|
|
1054
|
+
userId,
|
|
1055
|
+
totalEvents: 0,
|
|
1056
|
+
favoriteComponents: [],
|
|
1057
|
+
usageByType: {},
|
|
1058
|
+
lastActivity: new Date()
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
const data = result[0];
|
|
1062
|
+
// Count usage by type
|
|
1063
|
+
const usageByType = {
|
|
1064
|
+
job: 0,
|
|
1065
|
+
skill: 0,
|
|
1066
|
+
rule: 0,
|
|
1067
|
+
mentoring: 0,
|
|
1068
|
+
session: 0
|
|
1069
|
+
};
|
|
1070
|
+
data.usageByType.forEach((type) => {
|
|
1071
|
+
usageByType[type] = (usageByType[type] || 0) + 1;
|
|
1072
|
+
});
|
|
1073
|
+
// Get favorite components (top 5)
|
|
1074
|
+
const componentCounts = {};
|
|
1075
|
+
data.components.forEach((comp) => {
|
|
1076
|
+
const key = `${comp.name}:${comp.type}`;
|
|
1077
|
+
if (!componentCounts[key]) {
|
|
1078
|
+
componentCounts[key] = {
|
|
1079
|
+
name: comp.name,
|
|
1080
|
+
type: comp.type,
|
|
1081
|
+
count: 0,
|
|
1082
|
+
successRate: 0,
|
|
1083
|
+
lastUsed: new Date()
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
componentCounts[key].count++;
|
|
1087
|
+
if (comp.success)
|
|
1088
|
+
componentCounts[key].successRate++;
|
|
1089
|
+
});
|
|
1090
|
+
// Calculate averages and sort
|
|
1091
|
+
const favoriteComponents = Object.values(componentCounts)
|
|
1092
|
+
.map(comp => ({
|
|
1093
|
+
...comp,
|
|
1094
|
+
successRate: comp.count > 0 ? (comp.successRate / comp.count) * 100 : 0
|
|
1095
|
+
}))
|
|
1096
|
+
.sort((a, b) => b.count - a.count)
|
|
1097
|
+
.slice(0, 5);
|
|
1098
|
+
return {
|
|
1099
|
+
userId,
|
|
1100
|
+
totalEvents: data.totalEvents,
|
|
1101
|
+
favoriteComponents,
|
|
1102
|
+
usageByType,
|
|
1103
|
+
lastActivity: data.lastActivity
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Clean up old usage events based on retention policy
|
|
1108
|
+
*/
|
|
1109
|
+
async cleanupOldUsageEvents(retentionDays) {
|
|
1110
|
+
if (!this.db)
|
|
1111
|
+
throw new Error('DB not connected');
|
|
1112
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1113
|
+
const cutoffDate = new Date();
|
|
1114
|
+
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
1115
|
+
const result = await collection.deleteMany({
|
|
1116
|
+
createdAt: { $lt: cutoffDate }
|
|
1117
|
+
});
|
|
1118
|
+
return result.deletedCount || 0;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Get aggregated job runs over time for timeline bubble chart
|
|
1122
|
+
*/
|
|
1123
|
+
async getJobRunsOverTime(timeWindow, userId, dateUnit, repoIdentifier) {
|
|
1124
|
+
if (!this.db)
|
|
1125
|
+
throw new Error('DB not connected');
|
|
1126
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1127
|
+
const { days } = this.resolveTimeWindow(timeWindow);
|
|
1128
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1129
|
+
const matchStage = {
|
|
1130
|
+
type: 'job',
|
|
1131
|
+
// Exclude explicit complete events so each run is counted once (at start).
|
|
1132
|
+
// Old-path events have no args.action and are included by $ne.
|
|
1133
|
+
'args.action': { $ne: 'complete' },
|
|
1134
|
+
createdAt: this.buildCreatedAtMatch(timeWindow)
|
|
1135
|
+
};
|
|
1136
|
+
if (userId) {
|
|
1137
|
+
matchStage.userId = userId;
|
|
1138
|
+
}
|
|
1139
|
+
if (sanitizedRepo) {
|
|
1140
|
+
matchStage.repoIdentifier = sanitizedRepo;
|
|
1141
|
+
}
|
|
1142
|
+
// Determine group format based on dateUnit or auto-rollup
|
|
1143
|
+
let format = '%Y-%m-%d';
|
|
1144
|
+
if (dateUnit === 'week') {
|
|
1145
|
+
format = '%Y-%U';
|
|
1146
|
+
}
|
|
1147
|
+
else if (dateUnit === 'month') {
|
|
1148
|
+
format = '%Y-%m';
|
|
1149
|
+
}
|
|
1150
|
+
else if (!dateUnit) {
|
|
1151
|
+
// Auto-rollup logic
|
|
1152
|
+
if (days > 60) {
|
|
1153
|
+
format = '%Y-%m';
|
|
1154
|
+
}
|
|
1155
|
+
else if (days > 14) {
|
|
1156
|
+
format = '%Y-%U';
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const pipeline = [
|
|
1160
|
+
{ $match: matchStage },
|
|
1161
|
+
{
|
|
1162
|
+
$group: {
|
|
1163
|
+
_id: {
|
|
1164
|
+
date: { $dateToString: { format, date: '$createdAt' } },
|
|
1165
|
+
name: '$name'
|
|
1166
|
+
},
|
|
1167
|
+
category: { $first: '$category' },
|
|
1168
|
+
count: { $sum: 1 },
|
|
1169
|
+
successRate: { $avg: { $cond: ['$success', 100, 0] } },
|
|
1170
|
+
minDate: { $min: '$createdAt' },
|
|
1171
|
+
maxDate: { $max: '$createdAt' },
|
|
1172
|
+
avgInputTokens: { $avg: { $ifNull: ['$tokenSnapshot.inputTokens', null] } },
|
|
1173
|
+
avgOutputTokens: { $avg: { $ifNull: ['$tokenSnapshot.outputTokens', null] } },
|
|
1174
|
+
avgCostUsd: { $avg: { $ifNull: ['$tokenSnapshot.costUsd', null] } }
|
|
1175
|
+
}
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
$project: {
|
|
1179
|
+
_id: 0,
|
|
1180
|
+
// Use the latest activity timestamp within the rolled-up bucket so
|
|
1181
|
+
// timeline labels line up with "last active" and top-jobs data.
|
|
1182
|
+
date: '$maxDate',
|
|
1183
|
+
name: '$_id.name',
|
|
1184
|
+
category: 1,
|
|
1185
|
+
count: 1,
|
|
1186
|
+
successRate: 1,
|
|
1187
|
+
avgDurationMs: {
|
|
1188
|
+
$cond: [
|
|
1189
|
+
{ $and: [{ $gt: ['$count', 1] }, { $ne: ['$maxDate', null] }] },
|
|
1190
|
+
{ $divide: [{ $subtract: ['$maxDate', '$minDate'] }, '$count'] },
|
|
1191
|
+
null
|
|
1192
|
+
]
|
|
1193
|
+
},
|
|
1194
|
+
avgInputTokens: 1,
|
|
1195
|
+
avgOutputTokens: 1,
|
|
1196
|
+
avgCostUsd: 1
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
{ $sort: { date: 1 } }
|
|
1200
|
+
];
|
|
1201
|
+
return await collection.aggregate(pipeline).toArray();
|
|
1202
|
+
}
|
|
1203
|
+
async getComponentTrend(componentName, componentType, timeWindow, userId, dateUnit, repoIdentifier) {
|
|
1204
|
+
if (!this.db)
|
|
1205
|
+
throw new Error('DB not connected');
|
|
1206
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1207
|
+
const { days } = this.resolveTimeWindow(timeWindow);
|
|
1208
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1209
|
+
const matchStage = {
|
|
1210
|
+
name: componentName,
|
|
1211
|
+
type: componentType,
|
|
1212
|
+
createdAt: this.buildCreatedAtMatch(timeWindow)
|
|
1213
|
+
};
|
|
1214
|
+
if (userId) {
|
|
1215
|
+
matchStage.userId = userId;
|
|
1216
|
+
}
|
|
1217
|
+
if (sanitizedRepo) {
|
|
1218
|
+
matchStage.repoIdentifier = sanitizedRepo;
|
|
1219
|
+
}
|
|
1220
|
+
// Determine group format based on dateUnit or auto-rollup
|
|
1221
|
+
let format = '%Y-%m-%d';
|
|
1222
|
+
if (dateUnit === 'week') {
|
|
1223
|
+
format = '%Y-%U';
|
|
1224
|
+
}
|
|
1225
|
+
else if (dateUnit === 'month') {
|
|
1226
|
+
format = '%Y-%m';
|
|
1227
|
+
}
|
|
1228
|
+
else if (!dateUnit) {
|
|
1229
|
+
// Auto-rollup logic
|
|
1230
|
+
if (days > 60) {
|
|
1231
|
+
format = '%Y-%m';
|
|
1232
|
+
}
|
|
1233
|
+
else if (days > 14) {
|
|
1234
|
+
format = '%Y-%U';
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const pipeline = [
|
|
1238
|
+
{ $match: matchStage },
|
|
1239
|
+
{
|
|
1240
|
+
$group: {
|
|
1241
|
+
_id: { $dateToString: { format, date: '$createdAt' } },
|
|
1242
|
+
count: { $sum: 1 },
|
|
1243
|
+
successfulEvents: { $sum: { $cond: ['$success', 1, 0] } }
|
|
1244
|
+
}
|
|
1245
|
+
},
|
|
1246
|
+
{ $sort: { _id: 1 } }
|
|
1247
|
+
];
|
|
1248
|
+
const results = await collection.aggregate(pipeline).toArray();
|
|
1249
|
+
const dataPoints = results.map(result => ({
|
|
1250
|
+
date: new Date(result._id),
|
|
1251
|
+
count: result.count,
|
|
1252
|
+
successRate: result.count > 0 ? (result.successfulEvents / result.count) * 100 : 0
|
|
1253
|
+
}));
|
|
1254
|
+
// Calculate growth rate
|
|
1255
|
+
const firstCount = dataPoints.length > 0 ? dataPoints[0].count : 0;
|
|
1256
|
+
const lastCount = dataPoints.length > 0 ? dataPoints[dataPoints.length - 1].count : 0;
|
|
1257
|
+
const totalGrowth = firstCount > 0 ? ((lastCount - firstCount) / firstCount) * 100 : 0;
|
|
1258
|
+
return {
|
|
1259
|
+
name: componentName,
|
|
1260
|
+
type: componentType,
|
|
1261
|
+
dataPoints,
|
|
1262
|
+
totalGrowth
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Initialize usage events collection with proper indexes
|
|
1267
|
+
*/
|
|
1268
|
+
async initializeUsageEventsCollection() {
|
|
1269
|
+
if (!this.db)
|
|
1270
|
+
return;
|
|
1271
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1272
|
+
// Create TTL index for automatic cleanup (90 days default)
|
|
1273
|
+
await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: 90 * 24 * 60 * 60 } // 90 days in seconds
|
|
1274
|
+
);
|
|
1275
|
+
// Standard indexes
|
|
1276
|
+
await collection.createIndex({ userId: 1, createdAt: -1 });
|
|
1277
|
+
await collection.createIndex({ type: 1, createdAt: -1 });
|
|
1278
|
+
await collection.createIndex({ name: 1, type: 1, createdAt: -1 });
|
|
1279
|
+
await collection.createIndex({ sessionId: 1 });
|
|
1280
|
+
// [OPTIMIZATION] Added for dashboard performance
|
|
1281
|
+
await collection.createIndex({ userId: 1, name: 1, type: 1, createdAt: -1 });
|
|
1282
|
+
await collection.createIndex({ userId: 1, success: 1, createdAt: -1 });
|
|
1283
|
+
// [OPTIMIZATION] Covers heatmap + analytics type-filtered queries:
|
|
1284
|
+
// getUsageForHeatmap({ userId, type: {$in:[...]}, createdAt: {$gte:...} })
|
|
1285
|
+
// getUsageStats / getTopComponents when filtering by userId + type + createdAt
|
|
1286
|
+
await collection.createIndex({ userId: 1, type: 1, createdAt: -1 });
|
|
1287
|
+
// [OPTIMIZATION] Covers job-level timeline + token-usage queries:
|
|
1288
|
+
// getJobRunTimeline({ jobId }) and getTokenUsageByJob({ jobId })
|
|
1289
|
+
await collection.createIndex({ jobId: 1 });
|
|
1290
|
+
// [OPTIMIZATION] Added for repo-level analytics performance
|
|
1291
|
+
await collection.createIndex({ repoIdentifier: 1, createdAt: -1 });
|
|
1292
|
+
await collection.createIndex({ userId: 1, repoIdentifier: 1, createdAt: -1 });
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Get unique repositories that have generated telemetry for a user
|
|
1296
|
+
*/
|
|
1297
|
+
async getUniqueRepos(userId) {
|
|
1298
|
+
if (!this.db)
|
|
1299
|
+
throw new Error('DB not connected');
|
|
1300
|
+
const usageCollection = this.db.collection(this.usageEventsCollectionName);
|
|
1301
|
+
const qualityCollection = this.db.collection('fraim_quality_scores');
|
|
1302
|
+
const [usageRepos, qualityRepos] = await Promise.all([
|
|
1303
|
+
usageCollection.distinct('repoIdentifier', {
|
|
1304
|
+
userId,
|
|
1305
|
+
repoIdentifier: { $type: 'string' }
|
|
1306
|
+
}),
|
|
1307
|
+
qualityCollection.distinct('repoIdentifier', {
|
|
1308
|
+
userId,
|
|
1309
|
+
repoIdentifier: { $type: 'string' }
|
|
1310
|
+
})
|
|
1311
|
+
]);
|
|
1312
|
+
const all = [...usageRepos, ...qualityRepos];
|
|
1313
|
+
return Array.from(new Set(all
|
|
1314
|
+
.map((repo) => (0, git_utils_1.sanitizeRepoIdentifier)(repo))
|
|
1315
|
+
.filter((r) => typeof r === 'string'))).sort();
|
|
1316
|
+
}
|
|
1317
|
+
resolveTimeWindow(timeWindow) {
|
|
1318
|
+
if (typeof timeWindow === 'number') {
|
|
1319
|
+
const endDate = new Date();
|
|
1320
|
+
const startDate = new Date(endDate.getTime() - timeWindow * 24 * 60 * 60 * 1000);
|
|
1321
|
+
return { startDate, endDate, days: timeWindow };
|
|
1322
|
+
}
|
|
1323
|
+
const startDate = new Date(timeWindow.startDate);
|
|
1324
|
+
const endDate = timeWindow.endDate ? new Date(timeWindow.endDate) : new Date();
|
|
1325
|
+
const days = timeWindow.days ?? Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1);
|
|
1326
|
+
return { startDate, endDate, days };
|
|
1327
|
+
}
|
|
1328
|
+
buildCreatedAtMatch(timeWindow) {
|
|
1329
|
+
const { startDate, endDate } = this.resolveTimeWindow(timeWindow);
|
|
1330
|
+
return {
|
|
1331
|
+
$gte: startDate,
|
|
1332
|
+
...(endDate ? { $lte: endDate } : {})
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
buildQualityCreatedAtFilter(periodDays, startDate, endDate) {
|
|
1336
|
+
if (startDate || endDate) {
|
|
1337
|
+
const effectiveStart = startDate ?? new Date(0);
|
|
1338
|
+
const effectiveEnd = endDate ?? new Date();
|
|
1339
|
+
return { createdAt: { $gte: effectiveStart, $lte: effectiveEnd } };
|
|
1340
|
+
}
|
|
1341
|
+
if (typeof periodDays === 'number') {
|
|
1342
|
+
return { createdAt: { $gte: new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000) } };
|
|
1343
|
+
}
|
|
1344
|
+
return {};
|
|
1345
|
+
}
|
|
1346
|
+
async close() {
|
|
1347
|
+
FraimDbService.refCount = Math.max(0, FraimDbService.refCount - 1);
|
|
1348
|
+
if (FraimDbService.refCount === 0 && FraimDbService.client) {
|
|
1349
|
+
await FraimDbService.client.close();
|
|
1350
|
+
FraimDbService.client = undefined;
|
|
1351
|
+
FraimDbService.db = undefined;
|
|
1352
|
+
FraimDbService.connectionPromise = undefined;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
getClient() {
|
|
1356
|
+
return FraimDbService.client;
|
|
1357
|
+
}
|
|
1358
|
+
async getDb() {
|
|
1359
|
+
if (!this.db) {
|
|
1360
|
+
await this.connect();
|
|
1361
|
+
}
|
|
1362
|
+
if (!this.db) {
|
|
1363
|
+
throw new Error('DB not connected');
|
|
1364
|
+
}
|
|
1365
|
+
return this.db;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Aggregate usage events for the personal brain heatmap.
|
|
1369
|
+
* Returns per-component invocation counts within the optional time window.
|
|
1370
|
+
*/
|
|
1371
|
+
async getUsageForHeatmap(userId, windowDays) {
|
|
1372
|
+
if (!this.db)
|
|
1373
|
+
throw new Error('DB not connected');
|
|
1374
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1375
|
+
const matchStage = {
|
|
1376
|
+
userId,
|
|
1377
|
+
type: { $in: ['skill', 'job', 'mentoring'] },
|
|
1378
|
+
};
|
|
1379
|
+
if (windowDays !== null) {
|
|
1380
|
+
const startDate = new Date();
|
|
1381
|
+
startDate.setDate(startDate.getDate() - windowDays);
|
|
1382
|
+
matchStage.createdAt = { $gte: startDate };
|
|
1383
|
+
}
|
|
1384
|
+
const pipeline = [
|
|
1385
|
+
{ $match: matchStage },
|
|
1386
|
+
{
|
|
1387
|
+
$group: {
|
|
1388
|
+
_id: {
|
|
1389
|
+
id: '$name',
|
|
1390
|
+
type: '$type',
|
|
1391
|
+
category: { $ifNull: ['$args.category', 'unknown'] }
|
|
1392
|
+
},
|
|
1393
|
+
invocationCount: { $sum: 1 },
|
|
1394
|
+
lastInvokedAt: { $max: '$createdAt' }
|
|
1395
|
+
}
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
$project: {
|
|
1399
|
+
_id: 0,
|
|
1400
|
+
id: '$_id.id',
|
|
1401
|
+
type: '$_id.type',
|
|
1402
|
+
category: '$_id.category',
|
|
1403
|
+
invocationCount: 1,
|
|
1404
|
+
lastInvokedAt: 1
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
];
|
|
1408
|
+
return await collection.aggregate(pipeline).toArray();
|
|
1409
|
+
}
|
|
1410
|
+
// ===== MANAGER TEAMS METHODS =====
|
|
1411
|
+
async addTeamMember(managerId, memberId) {
|
|
1412
|
+
if (!this.db)
|
|
1413
|
+
throw new Error('DB not connected');
|
|
1414
|
+
await this.db.collection('fraim_manager_teams').updateOne({ managerId, memberId }, { $setOnInsert: { managerId, memberId, createdAt: new Date() } }, { upsert: true });
|
|
1415
|
+
}
|
|
1416
|
+
async removeTeamMember(managerId, memberId) {
|
|
1417
|
+
if (!this.db)
|
|
1418
|
+
throw new Error('DB not connected');
|
|
1419
|
+
await this.db.collection('fraim_manager_teams').deleteOne({ managerId, memberId });
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Convert a memberId/pattern to a MongoDB $regex string.
|
|
1423
|
+
* Wildcards: '*@ezcorp.com' → '^.*@ezcorp\.com$', '*' → '^.*$'
|
|
1424
|
+
* Literal: 'alice@example.com' → null (no regex needed)
|
|
1425
|
+
*/
|
|
1426
|
+
memberIdToRegex(pattern) {
|
|
1427
|
+
if (!pattern.includes('*'))
|
|
1428
|
+
return null;
|
|
1429
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
1430
|
+
return `^${escaped}$`;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Returns all resolved team members for a manager.
|
|
1434
|
+
* Wildcard patterns (memberId containing '*') are expanded by querying
|
|
1435
|
+
* fraim_api_keys for all active users whose userId matches the pattern.
|
|
1436
|
+
* The manager themselves is never included in the result.
|
|
1437
|
+
*/
|
|
1438
|
+
async getTeamMembers(managerId) {
|
|
1439
|
+
if (!this.db)
|
|
1440
|
+
throw new Error('DB not connected');
|
|
1441
|
+
const records = await this.db.collection('fraim_manager_teams').find({ managerId }).toArray();
|
|
1442
|
+
const result = [];
|
|
1443
|
+
const seen = new Set();
|
|
1444
|
+
for (const record of records) {
|
|
1445
|
+
const regex = this.memberIdToRegex(record.memberId);
|
|
1446
|
+
if (!regex) {
|
|
1447
|
+
// Literal member — include as-is (excluding manager themselves)
|
|
1448
|
+
if (record.memberId !== managerId && !seen.has(record.memberId)) {
|
|
1449
|
+
seen.add(record.memberId);
|
|
1450
|
+
result.push(record);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
// Wildcard pattern — expand against all API keys (any status)
|
|
1455
|
+
const matched = await this.db.collection('fraim_api_keys')
|
|
1456
|
+
.find({ userId: { $regex: regex, $options: 'i' } })
|
|
1457
|
+
.toArray();
|
|
1458
|
+
for (const key of matched) {
|
|
1459
|
+
if (key.userId !== managerId && !seen.has(key.userId)) {
|
|
1460
|
+
seen.add(key.userId);
|
|
1461
|
+
result.push({ ...record, memberId: key.userId });
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const memberIds = result.map((record) => record.memberId);
|
|
1467
|
+
if (memberIds.length === 0)
|
|
1468
|
+
return result;
|
|
1469
|
+
const keyRecords = await this.db.collection('fraim_api_keys')
|
|
1470
|
+
.find({ userId: { $in: memberIds } }, { projection: { userId: 1, labels: 1 } })
|
|
1471
|
+
.toArray();
|
|
1472
|
+
const labelsByUserId = new Map(keyRecords.map((record) => [record.userId, normalizeLabels(record.labels || [])]));
|
|
1473
|
+
return result.map((record) => ({
|
|
1474
|
+
...record,
|
|
1475
|
+
labels: labelsByUserId.get(record.memberId) || []
|
|
1476
|
+
}));
|
|
1477
|
+
}
|
|
1478
|
+
async getManagersForMember(memberId) {
|
|
1479
|
+
if (!this.db)
|
|
1480
|
+
throw new Error('DB not connected');
|
|
1481
|
+
// Exact-match records
|
|
1482
|
+
const explicit = await this.db.collection('fraim_manager_teams')
|
|
1483
|
+
.find({ memberId }).toArray();
|
|
1484
|
+
// Pattern records (memberId contains '*')
|
|
1485
|
+
const patterns = await this.db.collection('fraim_manager_teams')
|
|
1486
|
+
.find({ memberId: { $regex: '\\*' } }).toArray();
|
|
1487
|
+
const managerIds = new Set(explicit.map(r => r.managerId));
|
|
1488
|
+
for (const record of patterns) {
|
|
1489
|
+
const regex = this.memberIdToRegex(record.memberId);
|
|
1490
|
+
if (regex && new RegExp(regex, 'i').test(memberId)) {
|
|
1491
|
+
managerIds.add(record.managerId);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return Array.from(managerIds);
|
|
1495
|
+
}
|
|
1496
|
+
// ===== AI HUB MANAGER ASSIGNMENTS (Issue #540) =====
|
|
1497
|
+
async getHubManagerAssignments(workspaceId, userKey) {
|
|
1498
|
+
if (!this.aiHubManagerAssignmentsCollection)
|
|
1499
|
+
return [];
|
|
1500
|
+
return this.aiHubManagerAssignmentsCollection.find({ workspaceId, userKey }).toArray();
|
|
1501
|
+
}
|
|
1502
|
+
async addHubManagerAssignment(workspaceId, userKey, personaKey) {
|
|
1503
|
+
if (!this.aiHubManagerAssignmentsCollection)
|
|
1504
|
+
throw new Error('DB not connected');
|
|
1505
|
+
const doc = { workspaceId, userKey, personaKey, assignedAt: new Date() };
|
|
1506
|
+
await this.aiHubManagerAssignmentsCollection.updateOne({ workspaceId, userKey, personaKey }, { $setOnInsert: doc }, { upsert: true });
|
|
1507
|
+
const saved = await this.aiHubManagerAssignmentsCollection.findOne({ workspaceId, userKey, personaKey });
|
|
1508
|
+
return saved;
|
|
1509
|
+
}
|
|
1510
|
+
async removeHubManagerAssignment(workspaceId, userKey, personaKey) {
|
|
1511
|
+
if (!this.aiHubManagerAssignmentsCollection)
|
|
1512
|
+
return;
|
|
1513
|
+
await this.aiHubManagerAssignmentsCollection.deleteOne({ workspaceId, userKey, personaKey });
|
|
1514
|
+
}
|
|
1515
|
+
async countHubManagerAssignments(workspaceId, personaKey) {
|
|
1516
|
+
if (!this.aiHubManagerAssignmentsCollection)
|
|
1517
|
+
return 0;
|
|
1518
|
+
return this.aiHubManagerAssignmentsCollection.countDocuments({ workspaceId, personaKey });
|
|
1519
|
+
}
|
|
1520
|
+
async hasFraimSetup(userId) {
|
|
1521
|
+
if (!this.db)
|
|
1522
|
+
throw new Error('DB not connected');
|
|
1523
|
+
const record = await this.db.collection('fraim_api_keys').findOne({ userId }, { projection: { _id: 1 } });
|
|
1524
|
+
return !!record;
|
|
1525
|
+
}
|
|
1526
|
+
// ===== Quality Scores (Issue #251) =====
|
|
1527
|
+
async insertQualityScore(record) {
|
|
1528
|
+
if (!this.db)
|
|
1529
|
+
throw new Error('DB not connected');
|
|
1530
|
+
const stageCategory = record.stageCategory ?? quality_evidence_1.QUALITY_REGISTRY[record.jobName]?.stage ?? undefined;
|
|
1531
|
+
const reviewContext = record.reviewContext
|
|
1532
|
+
? {
|
|
1533
|
+
...record.reviewContext,
|
|
1534
|
+
repoIdentifier: (0, git_utils_1.sanitizeRepoIdentifier)(record.reviewContext.repoIdentifier)
|
|
1535
|
+
}
|
|
1536
|
+
: undefined;
|
|
1537
|
+
const repoIdentifier = (0, git_utils_1.sanitizeRepoIdentifier)(record.repoIdentifier ?? reviewContext?.repoIdentifier);
|
|
1538
|
+
await this.db.collection('fraim_quality_scores').insertOne({
|
|
1539
|
+
...record,
|
|
1540
|
+
reviewContext,
|
|
1541
|
+
repoIdentifier,
|
|
1542
|
+
stageCategory,
|
|
1543
|
+
createdAt: record.createdAt ?? new Date()
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
async getQualityScores(userId, jobName, limit, repoIdentifier) {
|
|
1547
|
+
if (!this.db)
|
|
1548
|
+
throw new Error('DB not connected');
|
|
1549
|
+
const filter = { userId };
|
|
1550
|
+
if (jobName)
|
|
1551
|
+
filter.jobName = jobName;
|
|
1552
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1553
|
+
if (sanitizedRepo)
|
|
1554
|
+
filter.repoIdentifier = sanitizedRepo;
|
|
1555
|
+
return this.db.collection('fraim_quality_scores')
|
|
1556
|
+
.find(filter)
|
|
1557
|
+
.sort({ createdAt: 1 }) // chronological for trajectory charts
|
|
1558
|
+
.limit(limit ?? 100)
|
|
1559
|
+
.toArray();
|
|
1560
|
+
}
|
|
1561
|
+
async getLatestQualityScore(userId, jobName, repoIdentifier) {
|
|
1562
|
+
if (!this.db)
|
|
1563
|
+
throw new Error('DB not connected');
|
|
1564
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1565
|
+
return this.db.collection('fraim_quality_scores').findOne({ userId, jobName, ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}) }, { sort: { createdAt: -1 } });
|
|
1566
|
+
}
|
|
1567
|
+
async getQualityScoresByStage(userId, stageCategory, limit, repoIdentifier) {
|
|
1568
|
+
if (!this.db)
|
|
1569
|
+
throw new Error('DB not connected');
|
|
1570
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1571
|
+
return this.db.collection('fraim_quality_scores')
|
|
1572
|
+
.find({ userId, stageCategory, ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}) })
|
|
1573
|
+
.sort({ createdAt: 1 })
|
|
1574
|
+
.limit(limit ?? 100)
|
|
1575
|
+
.toArray();
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Build the quality scorecard: one summary per stage for a given user.
|
|
1579
|
+
* Returns all stages (including those with no data) so the UI can show grey tiles.
|
|
1580
|
+
*/
|
|
1581
|
+
async getQualityScorecard(userId, repoIdentifier, periodDays, startDate, endDate) {
|
|
1582
|
+
if (!this.db)
|
|
1583
|
+
throw new Error('DB not connected');
|
|
1584
|
+
const collection = this.db.collection('fraim_quality_scores');
|
|
1585
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1586
|
+
const createdAtFilter = this.buildQualityCreatedAtFilter(periodDays, startDate, endDate);
|
|
1587
|
+
// All stage queries + the gate findOne start concurrently in a single Promise.all.
|
|
1588
|
+
const [stageResults, gateScore] = await Promise.all([
|
|
1589
|
+
Promise.all(quality_evidence_1.ALL_STAGE_CATEGORIES.map(stageCategory => collection
|
|
1590
|
+
.find({ userId, stageCategory, ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter })
|
|
1591
|
+
.sort({ createdAt: 1 })
|
|
1592
|
+
.limit(100)
|
|
1593
|
+
.toArray()
|
|
1594
|
+
.then(scores => ({ stageCategory, scores })))),
|
|
1595
|
+
collection.findOne({ userId, jobName: 'triage-customer-needs', ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter }, { sort: { createdAt: -1 } }),
|
|
1596
|
+
]);
|
|
1597
|
+
const stages = stageResults.map(({ stageCategory, scores }) => {
|
|
1598
|
+
// Filter to records that have an actual composite score — some records
|
|
1599
|
+
// (e.g., triage-customer-needs gate decisions) intentionally omit composite
|
|
1600
|
+
// and should not drive the tile's latest-composite rendering.
|
|
1601
|
+
const scoredRecords = scores.filter(s => typeof s.scores?.composite === 'number');
|
|
1602
|
+
if (scoredRecords.length === 0) {
|
|
1603
|
+
return {
|
|
1604
|
+
stageCategory,
|
|
1605
|
+
stageName: quality_evidence_1.STAGE_DISPLAY_NAMES[stageCategory] ?? stageCategory,
|
|
1606
|
+
latestComposite: null,
|
|
1607
|
+
trend: null,
|
|
1608
|
+
lastAssessedAt: null,
|
|
1609
|
+
assessmentCount: 0,
|
|
1610
|
+
sparkline: [],
|
|
1611
|
+
latestCoaching: null,
|
|
1612
|
+
latestGate: null,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
const latest = scoredRecords[scoredRecords.length - 1];
|
|
1616
|
+
const composites = scoredRecords.map(s => s.scores.composite);
|
|
1617
|
+
const sparkline = composites.slice(-10); // last 10 scores
|
|
1618
|
+
// Compute trend
|
|
1619
|
+
let trend = 'stable';
|
|
1620
|
+
if (composites.length >= 3) {
|
|
1621
|
+
const midpoint = Math.floor(composites.length / 2);
|
|
1622
|
+
const firstAvg = composites.slice(0, midpoint).reduce((a, b) => a + b, 0) / midpoint;
|
|
1623
|
+
const secondAvg = composites.slice(midpoint).reduce((a, b) => a + b, 0) / (composites.length - midpoint);
|
|
1624
|
+
if (secondAvg > firstAvg + 0.5)
|
|
1625
|
+
trend = 'improving';
|
|
1626
|
+
else if (secondAvg < firstAvg - 0.5)
|
|
1627
|
+
trend = 'declining';
|
|
1628
|
+
}
|
|
1629
|
+
// Attach gate decision for customer-development stage (fetched above in parallel)
|
|
1630
|
+
let latestGate = null;
|
|
1631
|
+
if (stageCategory === 'customer-development' && gateScore?.scores?.gateDecision) {
|
|
1632
|
+
latestGate = {
|
|
1633
|
+
decision: gateScore.scores.gateDecision,
|
|
1634
|
+
gaps: gateScore.scores.gaps ?? [],
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
stageCategory,
|
|
1639
|
+
stageName: quality_evidence_1.STAGE_DISPLAY_NAMES[stageCategory] ?? stageCategory,
|
|
1640
|
+
latestComposite: latest.scores.composite ?? null,
|
|
1641
|
+
trend,
|
|
1642
|
+
lastAssessedAt: latest.createdAt,
|
|
1643
|
+
assessmentCount: scoredRecords.length,
|
|
1644
|
+
sparkline,
|
|
1645
|
+
latestCoaching: latest.scores.coaching ?? null,
|
|
1646
|
+
latestGate,
|
|
1647
|
+
};
|
|
1648
|
+
});
|
|
1649
|
+
return stages;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Get full assessment history for a single stage (tile click detail view).
|
|
1653
|
+
*/
|
|
1654
|
+
async getQualityStageDetail(userId, stageCategory, repoIdentifier, periodDays, startDate, endDate) {
|
|
1655
|
+
if (!this.db)
|
|
1656
|
+
throw new Error('DB not connected');
|
|
1657
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1658
|
+
const createdAtFilter = this.buildQualityCreatedAtFilter(periodDays, startDate, endDate);
|
|
1659
|
+
const scores = await this.db.collection('fraim_quality_scores')
|
|
1660
|
+
.find({ userId, stageCategory, ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter })
|
|
1661
|
+
.sort({ createdAt: 1 })
|
|
1662
|
+
.limit(100)
|
|
1663
|
+
.toArray();
|
|
1664
|
+
const composites = scores.map(s => s.scores.composite ?? 0);
|
|
1665
|
+
const avgComposite = composites.length > 0
|
|
1666
|
+
? Math.round(composites.reduce((a, b) => a + b, 0) / composites.length * 10) / 10
|
|
1667
|
+
: 0;
|
|
1668
|
+
let trend = 'stable';
|
|
1669
|
+
if (composites.length >= 3) {
|
|
1670
|
+
const midpoint = Math.floor(composites.length / 2);
|
|
1671
|
+
const firstAvg = composites.slice(0, midpoint).reduce((a, b) => a + b, 0) / midpoint;
|
|
1672
|
+
const secondAvg = composites.slice(midpoint).reduce((a, b) => a + b, 0) / (composites.length - midpoint);
|
|
1673
|
+
if (secondAvg > firstAvg + 0.5)
|
|
1674
|
+
trend = 'improving';
|
|
1675
|
+
else if (secondAvg < firstAvg - 0.5)
|
|
1676
|
+
trend = 'declining';
|
|
1677
|
+
}
|
|
1678
|
+
return {
|
|
1679
|
+
stageCategory,
|
|
1680
|
+
assessments: scores.map(s => ({
|
|
1681
|
+
jobName: s.jobName,
|
|
1682
|
+
jobId: s.jobId,
|
|
1683
|
+
composite: s.scores.composite ?? 0,
|
|
1684
|
+
scores: s.scores,
|
|
1685
|
+
coaching: s.scores.coaching ?? '',
|
|
1686
|
+
artifactPath: s.artifactPath,
|
|
1687
|
+
createdAt: s.createdAt,
|
|
1688
|
+
})),
|
|
1689
|
+
count: scores.length,
|
|
1690
|
+
avgComposite,
|
|
1691
|
+
trend,
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
async getDominantCategory(userId, days) {
|
|
1695
|
+
if (!this.db)
|
|
1696
|
+
throw new Error('DB not connected');
|
|
1697
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1698
|
+
const startDate = new Date();
|
|
1699
|
+
startDate.setDate(startDate.getDate() - days);
|
|
1700
|
+
const pipeline = [
|
|
1701
|
+
{ $match: { userId, createdAt: { $gte: startDate }, type: 'job', category: { $exists: true, $nin: [null, 'uncategorized'] } } },
|
|
1702
|
+
{ $group: { _id: '$category', count: { $sum: 1 } } },
|
|
1703
|
+
{ $sort: { count: -1 } },
|
|
1704
|
+
{ $limit: 1 }
|
|
1705
|
+
];
|
|
1706
|
+
const result = await collection.aggregate(pipeline).toArray();
|
|
1707
|
+
if (result.length === 0)
|
|
1708
|
+
return null;
|
|
1709
|
+
return { category: result[0]._id, count: result[0].count };
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Get job completion metrics aggregated from usage events.
|
|
1713
|
+
* Uses explicit type:'job' start/complete events for accurate tracking.
|
|
1714
|
+
*/
|
|
1715
|
+
async getJobCompletionMetrics(startDate, jobName, userId, repoIdentifier, endDate) {
|
|
1716
|
+
if (!this.db)
|
|
1717
|
+
throw new Error('DB not connected');
|
|
1718
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1719
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1720
|
+
const createdAtMatch = { $gte: startDate, ...(endDate ? { $lte: endDate } : {}) };
|
|
1721
|
+
// Check if we have explicit job lifecycle events
|
|
1722
|
+
const explicitMatch = { type: 'job', 'args.action': 'start', createdAt: createdAtMatch };
|
|
1723
|
+
if (jobName)
|
|
1724
|
+
explicitMatch.name = jobName;
|
|
1725
|
+
if (userId)
|
|
1726
|
+
explicitMatch.userId = userId;
|
|
1727
|
+
if (sanitizedRepo)
|
|
1728
|
+
explicitMatch.repoIdentifier = sanitizedRepo;
|
|
1729
|
+
const hasExplicit = await collection.countDocuments(explicitMatch) > 0;
|
|
1730
|
+
// Use explicit events if available, fall back to mentoring events for legacy data
|
|
1731
|
+
const matchStage = hasExplicit
|
|
1732
|
+
? { type: 'job', 'args.action': { $in: ['start', 'complete'] }, createdAt: createdAtMatch }
|
|
1733
|
+
: { type: 'mentoring', createdAt: createdAtMatch };
|
|
1734
|
+
if (jobName)
|
|
1735
|
+
matchStage.name = jobName;
|
|
1736
|
+
if (userId)
|
|
1737
|
+
matchStage.userId = userId;
|
|
1738
|
+
if (sanitizedRepo)
|
|
1739
|
+
matchStage.repoIdentifier = sanitizedRepo;
|
|
1740
|
+
const pipeline = [
|
|
1741
|
+
{ $match: matchStage },
|
|
1742
|
+
{ $sort: { createdAt: 1 } },
|
|
1743
|
+
{
|
|
1744
|
+
$group: {
|
|
1745
|
+
_id: { jobName: '$name', jobId: '$jobId' },
|
|
1746
|
+
startTime: { $min: '$createdAt' },
|
|
1747
|
+
completeTime: {
|
|
1748
|
+
$max: {
|
|
1749
|
+
$cond: [
|
|
1750
|
+
{ $or: [
|
|
1751
|
+
{ $eq: ['$args.action', 'complete'] },
|
|
1752
|
+
{ $eq: ['$args.nextPhase', null] }
|
|
1753
|
+
] },
|
|
1754
|
+
'$createdAt', null
|
|
1755
|
+
]
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
hasStart: { $first: { $cond: [true, true, false] } },
|
|
1759
|
+
hasComplete: {
|
|
1760
|
+
$max: {
|
|
1761
|
+
$cond: [
|
|
1762
|
+
{ $or: [
|
|
1763
|
+
{ $eq: ['$args.action', 'complete'] },
|
|
1764
|
+
{ $eq: ['$args.nextPhase', null] }
|
|
1765
|
+
] },
|
|
1766
|
+
true, false
|
|
1767
|
+
]
|
|
1768
|
+
}
|
|
1769
|
+
},
|
|
1770
|
+
eventCount: { $sum: 1 }
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
$group: {
|
|
1775
|
+
_id: '$_id.jobName',
|
|
1776
|
+
totalStarted: { $sum: { $cond: ['$hasStart', 1, 0] } },
|
|
1777
|
+
totalCompleted: { $sum: { $cond: ['$hasComplete', 1, 0] } },
|
|
1778
|
+
completionDurations: {
|
|
1779
|
+
$push: {
|
|
1780
|
+
$cond: [
|
|
1781
|
+
{ $and: ['$hasStart', '$hasComplete', { $ne: ['$startTime', null] }, { $ne: ['$completeTime', null] }] },
|
|
1782
|
+
{ $subtract: ['$completeTime', '$startTime'] },
|
|
1783
|
+
null
|
|
1784
|
+
]
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
// Track last phase for abandoned jobs (started but not completed)
|
|
1788
|
+
abandonedPhases: {
|
|
1789
|
+
$push: {
|
|
1790
|
+
$cond: [
|
|
1791
|
+
{ $and: ['$hasStart', { $not: '$hasComplete' }] },
|
|
1792
|
+
'$finalPhase',
|
|
1793
|
+
null
|
|
1794
|
+
]
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
$project: {
|
|
1801
|
+
jobName: '$_id',
|
|
1802
|
+
totalStarted: 1,
|
|
1803
|
+
totalCompleted: 1,
|
|
1804
|
+
totalAbandoned: { $subtract: ['$totalStarted', '$totalCompleted'] },
|
|
1805
|
+
completionRate: {
|
|
1806
|
+
$cond: [
|
|
1807
|
+
{ $gt: ['$totalStarted', 0] },
|
|
1808
|
+
{ $divide: ['$totalCompleted', '$totalStarted'] },
|
|
1809
|
+
0
|
|
1810
|
+
]
|
|
1811
|
+
},
|
|
1812
|
+
avgCompletionMs: {
|
|
1813
|
+
$cond: [
|
|
1814
|
+
{ $gt: [{ $size: '$completionDurations' }, 0] },
|
|
1815
|
+
{ $avg: '$completionDurations' },
|
|
1816
|
+
null
|
|
1817
|
+
]
|
|
1818
|
+
},
|
|
1819
|
+
medianIdx: {
|
|
1820
|
+
$cond: [
|
|
1821
|
+
{ $gt: [{ $size: '$completionDurations' }, 0] },
|
|
1822
|
+
{ $floor: { $divide: [{ $size: '$completionDurations' }, 2] } },
|
|
1823
|
+
null
|
|
1824
|
+
]
|
|
1825
|
+
},
|
|
1826
|
+
completionDurations: 1
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
];
|
|
1830
|
+
const results = await collection.aggregate(pipeline).toArray();
|
|
1831
|
+
return results.map(result => {
|
|
1832
|
+
// Calculate median from sorted durations
|
|
1833
|
+
const durations = (result.completionDurations || []).filter((d) => typeof d === 'number').sort((a, b) => a - b);
|
|
1834
|
+
const medianMs = durations.length > 0 ? durations[Math.floor(durations.length / 2)] : undefined;
|
|
1835
|
+
return {
|
|
1836
|
+
jobName: result.jobName,
|
|
1837
|
+
totalRequested: result.totalStarted,
|
|
1838
|
+
totalStarted: result.totalStarted,
|
|
1839
|
+
totalCompleted: result.totalCompleted,
|
|
1840
|
+
totalAbandoned: result.totalStarted - result.totalCompleted,
|
|
1841
|
+
startRate: 1.0,
|
|
1842
|
+
completionRate: result.completionRate,
|
|
1843
|
+
overallRate: result.completionRate,
|
|
1844
|
+
averageCompletionTime: result.avgCompletionMs ?? undefined,
|
|
1845
|
+
medianCompletionTime: medianMs,
|
|
1846
|
+
commonAbandonmentPhases: [],
|
|
1847
|
+
timeRange: { start: startDate, end: new Date() }
|
|
1848
|
+
};
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Get timeline of all events for a specific jobId, ordered chronologically.
|
|
1853
|
+
*/
|
|
1854
|
+
async getJobRunTimeline(jobId, userId, jobName) {
|
|
1855
|
+
if (!this.db)
|
|
1856
|
+
throw new Error('DB not connected');
|
|
1857
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1858
|
+
let match;
|
|
1859
|
+
if (jobId.startsWith('sess_')) {
|
|
1860
|
+
// Old-path run: jobId is actually a synthetic sess_<sessionId> key.
|
|
1861
|
+
// Query by sessionId so the events (which have no jobId) are found.
|
|
1862
|
+
const sessionId = jobId.slice(5);
|
|
1863
|
+
match = { sessionId };
|
|
1864
|
+
if (userId)
|
|
1865
|
+
match.userId = userId;
|
|
1866
|
+
if (jobName)
|
|
1867
|
+
match.name = jobName;
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
match = { jobId };
|
|
1871
|
+
if (userId)
|
|
1872
|
+
match.userId = userId;
|
|
1873
|
+
}
|
|
1874
|
+
return await collection.aggregate([
|
|
1875
|
+
{ $match: match },
|
|
1876
|
+
{ $sort: { createdAt: 1 } },
|
|
1877
|
+
{
|
|
1878
|
+
$project: {
|
|
1879
|
+
_id: 0,
|
|
1880
|
+
type: 1,
|
|
1881
|
+
name: 1,
|
|
1882
|
+
jobPhase: 1,
|
|
1883
|
+
createdAt: 1,
|
|
1884
|
+
success: 1,
|
|
1885
|
+
args: 1,
|
|
1886
|
+
tokenSnapshot: 1,
|
|
1887
|
+
},
|
|
1888
|
+
},
|
|
1889
|
+
]).toArray();
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Get recent job runs with their start/complete status for a job name.
|
|
1893
|
+
* Falls back to deriving runs from mentoring events for legacy data without explicit job start/complete events.
|
|
1894
|
+
*/
|
|
1895
|
+
async getJobRuns(jobName, startDate, userId, limit = 50, repoIdentifier) {
|
|
1896
|
+
if (!this.db)
|
|
1897
|
+
throw new Error('DB not connected');
|
|
1898
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1899
|
+
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1900
|
+
// Match all job lifecycle events (including old-path events without args.action)
|
|
1901
|
+
// and mentoring events. Old-path events from usage-collector.ts have args: { job: '<name>' }
|
|
1902
|
+
// with no args.action and no jobId — they must be included so every invocation appears.
|
|
1903
|
+
const match = {
|
|
1904
|
+
$or: [
|
|
1905
|
+
{ type: 'job' },
|
|
1906
|
+
{ type: 'mentoring' },
|
|
1907
|
+
],
|
|
1908
|
+
name: jobName,
|
|
1909
|
+
createdAt: { $gte: startDate },
|
|
1910
|
+
};
|
|
1911
|
+
if (userId)
|
|
1912
|
+
match.userId = userId;
|
|
1913
|
+
if (sanitizedRepo)
|
|
1914
|
+
match.repoIdentifier = sanitizedRepo;
|
|
1915
|
+
const pipeline = [
|
|
1916
|
+
{ $match: match },
|
|
1917
|
+
{ $sort: { createdAt: 1 } },
|
|
1918
|
+
{
|
|
1919
|
+
$group: {
|
|
1920
|
+
// Old-path events have no jobId. Use sess_<sessionId> as a synthetic key so
|
|
1921
|
+
// each session forms its own run instead of collapsing all null-jobId events
|
|
1922
|
+
// from every session into one mega-bucket.
|
|
1923
|
+
_id: { $ifNull: ['$jobId', { $concat: ['sess_', { $ifNull: ['$sessionId', { $toString: '$_id' }] }] }] },
|
|
1924
|
+
jobName: { $first: '$name' },
|
|
1925
|
+
startedAt: { $min: '$createdAt' },
|
|
1926
|
+
completedAt: {
|
|
1927
|
+
$max: {
|
|
1928
|
+
$cond: [
|
|
1929
|
+
{ $or: [
|
|
1930
|
+
{ $eq: ['$args.action', 'complete'] },
|
|
1931
|
+
{ $eq: ['$args.nextPhase', null] }
|
|
1932
|
+
] },
|
|
1933
|
+
'$createdAt', null
|
|
1934
|
+
]
|
|
1935
|
+
}
|
|
1936
|
+
},
|
|
1937
|
+
isCompleted: {
|
|
1938
|
+
$max: {
|
|
1939
|
+
$cond: [
|
|
1940
|
+
{ $or: [
|
|
1941
|
+
{ $eq: ['$args.action', 'complete'] },
|
|
1942
|
+
{ $eq: ['$args.nextPhase', null] }
|
|
1943
|
+
] },
|
|
1944
|
+
true, false
|
|
1945
|
+
]
|
|
1946
|
+
}
|
|
1947
|
+
},
|
|
1948
|
+
lastPhase: { $last: '$args.currentPhase' },
|
|
1949
|
+
eventCount: { $sum: 1 },
|
|
1950
|
+
mentoringCount: {
|
|
1951
|
+
$sum: { $cond: [{ $eq: ['$type', 'mentoring'] }, 1, 0] }
|
|
1952
|
+
},
|
|
1953
|
+
}
|
|
1954
|
+
},
|
|
1955
|
+
{ $sort: { startedAt: -1 } },
|
|
1956
|
+
{ $limit: limit },
|
|
1957
|
+
{
|
|
1958
|
+
$project: {
|
|
1959
|
+
jobId: '$_id',
|
|
1960
|
+
jobName: 1,
|
|
1961
|
+
startedAt: 1,
|
|
1962
|
+
completedAt: 1,
|
|
1963
|
+
isCompleted: 1,
|
|
1964
|
+
lastPhase: 1,
|
|
1965
|
+
eventCount: 1,
|
|
1966
|
+
mentoringCount: 1,
|
|
1967
|
+
durationMs: {
|
|
1968
|
+
$cond: [
|
|
1969
|
+
{ $and: ['$isCompleted', { $ne: ['$startedAt', null] }, { $ne: ['$completedAt', null] }] },
|
|
1970
|
+
{ $subtract: ['$completedAt', '$startedAt'] },
|
|
1971
|
+
null
|
|
1972
|
+
]
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
];
|
|
1977
|
+
return await collection.aggregate(pipeline).toArray();
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Get per-job token usage by computing delta between first and last
|
|
1981
|
+
* cumulative Prometheus snapshots for a given jobId.
|
|
1982
|
+
*
|
|
1983
|
+
* Groups by claudeSessionId and uses the session with the most snapshots
|
|
1984
|
+
* to avoid cross-session contamination.
|
|
1985
|
+
*/
|
|
1986
|
+
async getTokenUsageByJob(jobId, userId) {
|
|
1987
|
+
if (!this.db)
|
|
1988
|
+
throw new Error('DB not connected');
|
|
1989
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1990
|
+
const filter = { jobId, tokenSnapshot: { $exists: true, $ne: null } };
|
|
1991
|
+
if (userId)
|
|
1992
|
+
filter.userId = userId;
|
|
1993
|
+
// Find all events for this job that have token snapshots
|
|
1994
|
+
const events = await collection
|
|
1995
|
+
.find(filter)
|
|
1996
|
+
.sort({ createdAt: 1 })
|
|
1997
|
+
.toArray();
|
|
1998
|
+
if (events.length === 0)
|
|
1999
|
+
return null;
|
|
2000
|
+
// Group by claudeSessionId and pick the session with most snapshots
|
|
2001
|
+
const bySession = new Map();
|
|
2002
|
+
for (const event of events) {
|
|
2003
|
+
const sid = event.tokenSnapshot?.claudeSessionId || 'unknown';
|
|
2004
|
+
if (!bySession.has(sid))
|
|
2005
|
+
bySession.set(sid, []);
|
|
2006
|
+
bySession.get(sid).push(event);
|
|
2007
|
+
}
|
|
2008
|
+
let bestSession = events; // fallback: all events
|
|
2009
|
+
let bestCount = 0;
|
|
2010
|
+
for (const [, sessionEvents] of bySession) {
|
|
2011
|
+
if (sessionEvents.length > bestCount) {
|
|
2012
|
+
bestCount = sessionEvents.length;
|
|
2013
|
+
bestSession = sessionEvents;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
const first = bestSession[0].tokenSnapshot;
|
|
2017
|
+
const last = bestSession[bestSession.length - 1].tokenSnapshot;
|
|
2018
|
+
if (bestSession.length === 1) {
|
|
2019
|
+
return {
|
|
2020
|
+
jobId,
|
|
2021
|
+
inputTokens: first.inputTokens,
|
|
2022
|
+
outputTokens: first.outputTokens,
|
|
2023
|
+
cacheReadTokens: first.cacheReadTokens,
|
|
2024
|
+
cacheCreationTokens: first.cacheCreationTokens,
|
|
2025
|
+
costUsd: first.costUsd,
|
|
2026
|
+
model: first.model,
|
|
2027
|
+
isPartial: true,
|
|
2028
|
+
snapshotCount: 1,
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
// Compute deltas — negative means counter reset, return null
|
|
2032
|
+
const delta = (end, start) => {
|
|
2033
|
+
const d = end - start;
|
|
2034
|
+
return d < 0 ? null : d;
|
|
2035
|
+
};
|
|
2036
|
+
const costDelta = last.costUsd - first.costUsd;
|
|
2037
|
+
return {
|
|
2038
|
+
jobId,
|
|
2039
|
+
inputTokens: delta(last.inputTokens, first.inputTokens),
|
|
2040
|
+
outputTokens: delta(last.outputTokens, first.outputTokens),
|
|
2041
|
+
cacheReadTokens: delta(last.cacheReadTokens, first.cacheReadTokens),
|
|
2042
|
+
cacheCreationTokens: delta(last.cacheCreationTokens, first.cacheCreationTokens),
|
|
2043
|
+
costUsd: costDelta < 0 ? null : costDelta,
|
|
2044
|
+
model: last.model,
|
|
2045
|
+
isPartial: false,
|
|
2046
|
+
snapshotCount: bestSession.length,
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Get aggregated token usage summary across all jobs for a user.
|
|
2051
|
+
*/
|
|
2052
|
+
async getTokenUsageSummary(userId, startDate) {
|
|
2053
|
+
if (!this.db)
|
|
2054
|
+
throw new Error('DB not connected');
|
|
2055
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
2056
|
+
const pipeline = [
|
|
2057
|
+
{
|
|
2058
|
+
$match: {
|
|
2059
|
+
userId,
|
|
2060
|
+
createdAt: { $gte: startDate },
|
|
2061
|
+
'tokenSnapshot': { $exists: true },
|
|
2062
|
+
}
|
|
2063
|
+
},
|
|
2064
|
+
{ $sort: { createdAt: 1 } },
|
|
2065
|
+
{
|
|
2066
|
+
$group: {
|
|
2067
|
+
_id: '$jobId',
|
|
2068
|
+
jobName: { $first: '$name' },
|
|
2069
|
+
snapshotCount: { $sum: 1 },
|
|
2070
|
+
firstInputTokens: { $first: '$tokenSnapshot.inputTokens' },
|
|
2071
|
+
lastInputTokens: { $last: '$tokenSnapshot.inputTokens' },
|
|
2072
|
+
firstOutputTokens: { $first: '$tokenSnapshot.outputTokens' },
|
|
2073
|
+
lastOutputTokens: { $last: '$tokenSnapshot.outputTokens' },
|
|
2074
|
+
firstCostUsd: { $first: '$tokenSnapshot.costUsd' },
|
|
2075
|
+
lastCostUsd: { $last: '$tokenSnapshot.costUsd' },
|
|
2076
|
+
model: { $last: '$tokenSnapshot.model' },
|
|
2077
|
+
startedAt: { $min: '$createdAt' },
|
|
2078
|
+
completedAt: { $max: '$createdAt' },
|
|
2079
|
+
}
|
|
2080
|
+
},
|
|
2081
|
+
{
|
|
2082
|
+
$project: {
|
|
2083
|
+
jobId: '$_id',
|
|
2084
|
+
jobName: 1,
|
|
2085
|
+
snapshotCount: 1,
|
|
2086
|
+
model: 1,
|
|
2087
|
+
startedAt: 1,
|
|
2088
|
+
completedAt: 1,
|
|
2089
|
+
inputTokens: {
|
|
2090
|
+
$cond: [
|
|
2091
|
+
{ $gte: [{ $subtract: ['$lastInputTokens', '$firstInputTokens'] }, 0] },
|
|
2092
|
+
{ $subtract: ['$lastInputTokens', '$firstInputTokens'] },
|
|
2093
|
+
null,
|
|
2094
|
+
]
|
|
2095
|
+
},
|
|
2096
|
+
outputTokens: {
|
|
2097
|
+
$cond: [
|
|
2098
|
+
{ $gte: [{ $subtract: ['$lastOutputTokens', '$firstOutputTokens'] }, 0] },
|
|
2099
|
+
{ $subtract: ['$lastOutputTokens', '$firstOutputTokens'] },
|
|
2100
|
+
null,
|
|
2101
|
+
]
|
|
2102
|
+
},
|
|
2103
|
+
costUsd: {
|
|
2104
|
+
$cond: [
|
|
2105
|
+
{ $gte: [{ $subtract: ['$lastCostUsd', '$firstCostUsd'] }, 0] },
|
|
2106
|
+
{ $subtract: ['$lastCostUsd', '$firstCostUsd'] },
|
|
2107
|
+
null,
|
|
2108
|
+
]
|
|
2109
|
+
},
|
|
2110
|
+
}
|
|
2111
|
+
},
|
|
2112
|
+
{ $sort: { completedAt: -1 } },
|
|
2113
|
+
];
|
|
2114
|
+
return await collection.aggregate(pipeline).toArray();
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Issue #330 / R2.1 — aggregate token + cost usage for a user across a
|
|
2118
|
+
* time window with optional repo filter. Computes per-job deltas, then
|
|
2119
|
+
* sums them. Also rolls up by agent and by model so the dashboard can
|
|
2120
|
+
* show "Cost by Agent" without a second round-trip.
|
|
2121
|
+
*/
|
|
2122
|
+
async getUsageAggregate(opts) {
|
|
2123
|
+
if (!this.db)
|
|
2124
|
+
throw new Error('DB not connected');
|
|
2125
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
2126
|
+
const dateFilter = { $gte: opts.startDate };
|
|
2127
|
+
if (opts.endDate)
|
|
2128
|
+
dateFilter.$lte = opts.endDate;
|
|
2129
|
+
const baseMatch = {
|
|
2130
|
+
userId: opts.userId,
|
|
2131
|
+
createdAt: dateFilter,
|
|
2132
|
+
jobId: { $exists: true, $ne: null },
|
|
2133
|
+
};
|
|
2134
|
+
const sanitizedRepo = (0, git_utils_1.sanitizeRepoIdentifier)(opts.repoIdentifier);
|
|
2135
|
+
if (sanitizedRepo)
|
|
2136
|
+
baseMatch.repoIdentifier = sanitizedRepo;
|
|
2137
|
+
// Per-jobId delta computation. Each jobId emits one row carrying the
|
|
2138
|
+
// delta + the most-recent agent/model attribution.
|
|
2139
|
+
const perJobPipeline = [
|
|
2140
|
+
{ $match: baseMatch },
|
|
2141
|
+
{ $sort: { createdAt: 1 } },
|
|
2142
|
+
{
|
|
2143
|
+
$group: {
|
|
2144
|
+
_id: '$jobId',
|
|
2145
|
+
agent: { $last: { $ifNull: ['$agentName', 'unknown'] } },
|
|
2146
|
+
model: { $last: { $ifNull: ['$agentModel', '$tokenSnapshot.model'] } },
|
|
2147
|
+
snapshotCount: {
|
|
2148
|
+
$sum: { $cond: [{ $ifNull: ['$tokenSnapshot', false] }, 1, 0] },
|
|
2149
|
+
},
|
|
2150
|
+
firstInputTokens: { $first: '$tokenSnapshot.inputTokens' },
|
|
2151
|
+
lastInputTokens: { $last: '$tokenSnapshot.inputTokens' },
|
|
2152
|
+
firstOutputTokens: { $first: '$tokenSnapshot.outputTokens' },
|
|
2153
|
+
lastOutputTokens: { $last: '$tokenSnapshot.outputTokens' },
|
|
2154
|
+
firstCacheReadTokens: { $first: '$tokenSnapshot.cacheReadTokens' },
|
|
2155
|
+
lastCacheReadTokens: { $last: '$tokenSnapshot.cacheReadTokens' },
|
|
2156
|
+
firstCacheCreationTokens: { $first: '$tokenSnapshot.cacheCreationTokens' },
|
|
2157
|
+
lastCacheCreationTokens: { $last: '$tokenSnapshot.cacheCreationTokens' },
|
|
2158
|
+
firstCostUsd: { $first: '$tokenSnapshot.costUsd' },
|
|
2159
|
+
lastCostUsd: { $last: '$tokenSnapshot.costUsd' },
|
|
2160
|
+
firstUnavailableReason: { $first: '$tokenCaptureUnavailableReason' },
|
|
2161
|
+
},
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
$project: {
|
|
2165
|
+
jobId: '$_id',
|
|
2166
|
+
agent: 1,
|
|
2167
|
+
model: 1,
|
|
2168
|
+
hasUsage: { $gt: ['$snapshotCount', 0] },
|
|
2169
|
+
unavailableReason: '$firstUnavailableReason',
|
|
2170
|
+
inputTokens: nonNegativeDelta('$firstInputTokens', '$lastInputTokens'),
|
|
2171
|
+
outputTokens: nonNegativeDelta('$firstOutputTokens', '$lastOutputTokens'),
|
|
2172
|
+
cacheReadTokens: nonNegativeDelta('$firstCacheReadTokens', '$lastCacheReadTokens'),
|
|
2173
|
+
cacheCreationTokens: nonNegativeDelta('$firstCacheCreationTokens', '$lastCacheCreationTokens'),
|
|
2174
|
+
costUsd: nonNegativeDelta('$firstCostUsd', '$lastCostUsd'),
|
|
2175
|
+
},
|
|
2176
|
+
},
|
|
2177
|
+
];
|
|
2178
|
+
const perJob = await collection.aggregate(perJobPipeline).toArray();
|
|
2179
|
+
let totalInputTokens = 0;
|
|
2180
|
+
let totalOutputTokens = 0;
|
|
2181
|
+
let totalCacheReadTokens = 0;
|
|
2182
|
+
let totalCacheCreationTokens = 0;
|
|
2183
|
+
let totalCostUsd = 0;
|
|
2184
|
+
let jobsWithUsageData = 0;
|
|
2185
|
+
let jobsWithoutUsageData = 0;
|
|
2186
|
+
const unavailableReasons = {};
|
|
2187
|
+
const agentRollup = new Map();
|
|
2188
|
+
const modelRollup = new Map();
|
|
2189
|
+
for (const row of perJob) {
|
|
2190
|
+
if (row.hasUsage) {
|
|
2191
|
+
jobsWithUsageData++;
|
|
2192
|
+
totalInputTokens += row.inputTokens || 0;
|
|
2193
|
+
totalOutputTokens += row.outputTokens || 0;
|
|
2194
|
+
totalCacheReadTokens += row.cacheReadTokens || 0;
|
|
2195
|
+
totalCacheCreationTokens += row.cacheCreationTokens || 0;
|
|
2196
|
+
const cost = row.costUsd || 0;
|
|
2197
|
+
totalCostUsd += cost;
|
|
2198
|
+
const agentKey = row.agent || 'unknown';
|
|
2199
|
+
const aRow = agentRollup.get(agentKey) || {
|
|
2200
|
+
agent: agentKey, costUsd: 0, inputTokens: 0, outputTokens: 0, jobCount: 0,
|
|
2201
|
+
};
|
|
2202
|
+
aRow.costUsd += cost;
|
|
2203
|
+
aRow.inputTokens += row.inputTokens || 0;
|
|
2204
|
+
aRow.outputTokens += row.outputTokens || 0;
|
|
2205
|
+
aRow.jobCount += 1;
|
|
2206
|
+
agentRollup.set(agentKey, aRow);
|
|
2207
|
+
const modelKey = `${agentKey}::${row.model || 'unknown'}`;
|
|
2208
|
+
const mRow = modelRollup.get(modelKey) || {
|
|
2209
|
+
agent: agentKey, model: row.model || 'unknown', costUsd: 0, jobCount: 0,
|
|
2210
|
+
};
|
|
2211
|
+
mRow.costUsd += cost;
|
|
2212
|
+
mRow.jobCount += 1;
|
|
2213
|
+
modelRollup.set(modelKey, mRow);
|
|
2214
|
+
}
|
|
2215
|
+
else {
|
|
2216
|
+
jobsWithoutUsageData++;
|
|
2217
|
+
const reason = row.unavailableReason || 'unknown';
|
|
2218
|
+
unavailableReasons[reason] = (unavailableReasons[reason] || 0) + 1;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return {
|
|
2222
|
+
totalInputTokens,
|
|
2223
|
+
totalOutputTokens,
|
|
2224
|
+
totalCacheReadTokens,
|
|
2225
|
+
totalCacheCreationTokens,
|
|
2226
|
+
totalCostUsd,
|
|
2227
|
+
jobsWithUsageData,
|
|
2228
|
+
jobsWithoutUsageData,
|
|
2229
|
+
byAgent: [...agentRollup.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
2230
|
+
byModel: [...modelRollup.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
2231
|
+
unavailableReasons: Object.keys(unavailableReasons).length > 0 ? unavailableReasons : undefined,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* Issue #330 / R2.8 — per-job-name agent split for the per-job page.
|
|
2236
|
+
*/
|
|
2237
|
+
async getJobAgentSummary(opts) {
|
|
2238
|
+
const aggregate = await this.getUsageAggregateInternal({
|
|
2239
|
+
userId: opts.userId,
|
|
2240
|
+
startDate: opts.startDate,
|
|
2241
|
+
endDate: opts.endDate,
|
|
2242
|
+
repoIdentifier: opts.repoIdentifier,
|
|
2243
|
+
jobName: opts.jobName,
|
|
2244
|
+
});
|
|
2245
|
+
return {
|
|
2246
|
+
jobName: opts.jobName,
|
|
2247
|
+
byAgent: aggregate.byAgent,
|
|
2248
|
+
byModel: aggregate.byModel,
|
|
2249
|
+
jobsWithUsageData: aggregate.jobsWithUsageData,
|
|
2250
|
+
jobsWithoutUsageData: aggregate.jobsWithoutUsageData,
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Internal: shared aggregate that optionally filters by jobName for R2.8.
|
|
2255
|
+
*/
|
|
2256
|
+
async getUsageAggregateInternal(opts) {
|
|
2257
|
+
if (!opts.jobName) {
|
|
2258
|
+
return this.getUsageAggregate(opts);
|
|
2259
|
+
}
|
|
2260
|
+
// Add a name filter and reuse the existing implementation by inlining.
|
|
2261
|
+
// Simpler than parameterizing the public API.
|
|
2262
|
+
const original = this.getUsageAggregate.bind(this);
|
|
2263
|
+
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
2264
|
+
const dateFilter = { $gte: opts.startDate };
|
|
2265
|
+
if (opts.endDate)
|
|
2266
|
+
dateFilter.$lte = opts.endDate;
|
|
2267
|
+
const sanitizedRepo = (0, git_utils_1.sanitizeRepoIdentifier)(opts.repoIdentifier);
|
|
2268
|
+
const baseMatch = {
|
|
2269
|
+
userId: opts.userId,
|
|
2270
|
+
createdAt: dateFilter,
|
|
2271
|
+
jobId: { $exists: true, $ne: null },
|
|
2272
|
+
name: opts.jobName,
|
|
2273
|
+
};
|
|
2274
|
+
if (sanitizedRepo)
|
|
2275
|
+
baseMatch.repoIdentifier = sanitizedRepo;
|
|
2276
|
+
// Re-run the same per-job pipeline but with the name filter applied.
|
|
2277
|
+
const perJobPipeline = [
|
|
2278
|
+
{ $match: baseMatch },
|
|
2279
|
+
{ $sort: { createdAt: 1 } },
|
|
2280
|
+
{
|
|
2281
|
+
$group: {
|
|
2282
|
+
_id: '$jobId',
|
|
2283
|
+
agent: { $last: { $ifNull: ['$agentName', 'unknown'] } },
|
|
2284
|
+
model: { $last: { $ifNull: ['$agentModel', '$tokenSnapshot.model'] } },
|
|
2285
|
+
snapshotCount: {
|
|
2286
|
+
$sum: { $cond: [{ $ifNull: ['$tokenSnapshot', false] }, 1, 0] },
|
|
2287
|
+
},
|
|
2288
|
+
firstInputTokens: { $first: '$tokenSnapshot.inputTokens' },
|
|
2289
|
+
lastInputTokens: { $last: '$tokenSnapshot.inputTokens' },
|
|
2290
|
+
firstOutputTokens: { $first: '$tokenSnapshot.outputTokens' },
|
|
2291
|
+
lastOutputTokens: { $last: '$tokenSnapshot.outputTokens' },
|
|
2292
|
+
firstCacheReadTokens: { $first: '$tokenSnapshot.cacheReadTokens' },
|
|
2293
|
+
lastCacheReadTokens: { $last: '$tokenSnapshot.cacheReadTokens' },
|
|
2294
|
+
firstCacheCreationTokens: { $first: '$tokenSnapshot.cacheCreationTokens' },
|
|
2295
|
+
lastCacheCreationTokens: { $last: '$tokenSnapshot.cacheCreationTokens' },
|
|
2296
|
+
firstCostUsd: { $first: '$tokenSnapshot.costUsd' },
|
|
2297
|
+
lastCostUsd: { $last: '$tokenSnapshot.costUsd' },
|
|
2298
|
+
firstUnavailableReason: { $first: '$tokenCaptureUnavailableReason' },
|
|
2299
|
+
},
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
$project: {
|
|
2303
|
+
jobId: '$_id',
|
|
2304
|
+
agent: 1,
|
|
2305
|
+
model: 1,
|
|
2306
|
+
hasUsage: { $gt: ['$snapshotCount', 0] },
|
|
2307
|
+
unavailableReason: '$firstUnavailableReason',
|
|
2308
|
+
inputTokens: nonNegativeDelta('$firstInputTokens', '$lastInputTokens'),
|
|
2309
|
+
outputTokens: nonNegativeDelta('$firstOutputTokens', '$lastOutputTokens'),
|
|
2310
|
+
cacheReadTokens: nonNegativeDelta('$firstCacheReadTokens', '$lastCacheReadTokens'),
|
|
2311
|
+
cacheCreationTokens: nonNegativeDelta('$firstCacheCreationTokens', '$lastCacheCreationTokens'),
|
|
2312
|
+
costUsd: nonNegativeDelta('$firstCostUsd', '$lastCostUsd'),
|
|
2313
|
+
},
|
|
2314
|
+
},
|
|
2315
|
+
];
|
|
2316
|
+
const perJob = await collection.aggregate(perJobPipeline).toArray();
|
|
2317
|
+
const agentRollup = new Map();
|
|
2318
|
+
const modelRollup = new Map();
|
|
2319
|
+
let jobsWithUsageData = 0, jobsWithoutUsageData = 0;
|
|
2320
|
+
let totalInputTokens = 0, totalOutputTokens = 0, totalCacheReadTokens = 0, totalCacheCreationTokens = 0, totalCostUsd = 0;
|
|
2321
|
+
for (const row of perJob) {
|
|
2322
|
+
if (row.hasUsage) {
|
|
2323
|
+
jobsWithUsageData++;
|
|
2324
|
+
const cost = row.costUsd || 0;
|
|
2325
|
+
totalInputTokens += row.inputTokens || 0;
|
|
2326
|
+
totalOutputTokens += row.outputTokens || 0;
|
|
2327
|
+
totalCacheReadTokens += row.cacheReadTokens || 0;
|
|
2328
|
+
totalCacheCreationTokens += row.cacheCreationTokens || 0;
|
|
2329
|
+
totalCostUsd += cost;
|
|
2330
|
+
const agentKey = row.agent || 'unknown';
|
|
2331
|
+
const aRow = agentRollup.get(agentKey) || {
|
|
2332
|
+
agent: agentKey, costUsd: 0, inputTokens: 0, outputTokens: 0, jobCount: 0,
|
|
2333
|
+
};
|
|
2334
|
+
aRow.costUsd += cost;
|
|
2335
|
+
aRow.inputTokens += row.inputTokens || 0;
|
|
2336
|
+
aRow.outputTokens += row.outputTokens || 0;
|
|
2337
|
+
aRow.jobCount += 1;
|
|
2338
|
+
agentRollup.set(agentKey, aRow);
|
|
2339
|
+
const modelKey = `${agentKey}::${row.model || 'unknown'}`;
|
|
2340
|
+
const mRow = modelRollup.get(modelKey) || {
|
|
2341
|
+
agent: agentKey, model: row.model || 'unknown', costUsd: 0, jobCount: 0,
|
|
2342
|
+
};
|
|
2343
|
+
mRow.costUsd += cost;
|
|
2344
|
+
mRow.jobCount += 1;
|
|
2345
|
+
modelRollup.set(modelKey, mRow);
|
|
2346
|
+
}
|
|
2347
|
+
else {
|
|
2348
|
+
jobsWithoutUsageData++;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
return {
|
|
2352
|
+
totalInputTokens,
|
|
2353
|
+
totalOutputTokens,
|
|
2354
|
+
totalCacheReadTokens,
|
|
2355
|
+
totalCacheCreationTokens,
|
|
2356
|
+
totalCostUsd,
|
|
2357
|
+
jobsWithUsageData,
|
|
2358
|
+
jobsWithoutUsageData,
|
|
2359
|
+
byAgent: [...agentRollup.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
2360
|
+
byModel: [...modelRollup.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
exports.FraimDbService = FraimDbService;
|
|
2365
|
+
FraimDbService.refCount = 0;
|
|
2366
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
2367
|
+
// Issue #359 — OAuth-first login: HTTP auth sessions
|
|
2368
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
2369
|
+
FraimDbService.AUTH_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30d
|
|
2370
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
2371
|
+
// Issue #359 — Pending OAuth round-trip state
|
|
2372
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
2373
|
+
FraimDbService.PENDING_OAUTH_TTL_MS = 10 * 60 * 1000;
|
|
2374
|
+
/**
|
|
2375
|
+
* Helper for monotonic-counter delta projection — returns the difference
|
|
2376
|
+
* (last - first) when non-negative, else 0. Mirrors getTokenUsageSummary
|
|
2377
|
+
* but defaults to 0 instead of null so the totals math stays simple.
|
|
2378
|
+
*/
|
|
2379
|
+
function nonNegativeDelta(firstField, lastField) {
|
|
2380
|
+
return {
|
|
2381
|
+
$cond: [
|
|
2382
|
+
{ $gte: [{ $subtract: [lastField, firstField] }, 0] },
|
|
2383
|
+
{ $subtract: [lastField, firstField] },
|
|
2384
|
+
0,
|
|
2385
|
+
],
|
|
2386
|
+
};
|
|
2387
|
+
}
|