bulltrackers-module 1.0.592 → 1.0.593

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/functions/old-generic-api/admin-api/index.js +895 -0
  2. package/functions/old-generic-api/helpers/api_helpers.js +457 -0
  3. package/functions/old-generic-api/index.js +204 -0
  4. package/functions/old-generic-api/user-api/helpers/alerts/alert_helpers.js +355 -0
  5. package/functions/old-generic-api/user-api/helpers/alerts/subscription_helpers.js +327 -0
  6. package/functions/old-generic-api/user-api/helpers/alerts/test_alert_helpers.js +212 -0
  7. package/functions/old-generic-api/user-api/helpers/collection_helpers.js +193 -0
  8. package/functions/old-generic-api/user-api/helpers/core/compression_helpers.js +68 -0
  9. package/functions/old-generic-api/user-api/helpers/core/data_lookup_helpers.js +256 -0
  10. package/functions/old-generic-api/user-api/helpers/core/path_resolution_helpers.js +640 -0
  11. package/functions/old-generic-api/user-api/helpers/core/user_status_helpers.js +195 -0
  12. package/functions/old-generic-api/user-api/helpers/data/computation_helpers.js +503 -0
  13. package/functions/old-generic-api/user-api/helpers/data/instrument_helpers.js +55 -0
  14. package/functions/old-generic-api/user-api/helpers/data/portfolio_helpers.js +245 -0
  15. package/functions/old-generic-api/user-api/helpers/data/social_helpers.js +174 -0
  16. package/functions/old-generic-api/user-api/helpers/data_helpers.js +87 -0
  17. package/functions/old-generic-api/user-api/helpers/dev/dev_helpers.js +336 -0
  18. package/functions/old-generic-api/user-api/helpers/fetch/on_demand_fetch_helpers.js +615 -0
  19. package/functions/old-generic-api/user-api/helpers/metrics/personalized_metrics_helpers.js +231 -0
  20. package/functions/old-generic-api/user-api/helpers/notifications/notification_helpers.js +641 -0
  21. package/functions/old-generic-api/user-api/helpers/profile/pi_profile_helpers.js +182 -0
  22. package/functions/old-generic-api/user-api/helpers/profile/profile_view_helpers.js +137 -0
  23. package/functions/old-generic-api/user-api/helpers/profile/user_profile_helpers.js +190 -0
  24. package/functions/old-generic-api/user-api/helpers/recommendations/recommendation_helpers.js +66 -0
  25. package/functions/old-generic-api/user-api/helpers/reviews/review_helpers.js +550 -0
  26. package/functions/old-generic-api/user-api/helpers/rootdata/rootdata_aggregation_helpers.js +378 -0
  27. package/functions/old-generic-api/user-api/helpers/search/pi_request_helpers.js +295 -0
  28. package/functions/old-generic-api/user-api/helpers/search/pi_search_helpers.js +162 -0
  29. package/functions/old-generic-api/user-api/helpers/sync/user_sync_helpers.js +677 -0
  30. package/functions/old-generic-api/user-api/helpers/verification/verification_helpers.js +323 -0
  31. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_analytics_helpers.js +96 -0
  32. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_data_helpers.js +141 -0
  33. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_generation_helpers.js +310 -0
  34. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_management_helpers.js +829 -0
  35. package/functions/old-generic-api/user-api/index.js +109 -0
  36. package/package.json +2 -2
@@ -0,0 +1,895 @@
1
+ /**
2
+ * @fileoverview Admin API Router
3
+ * Sub-module for system observability, debugging, and visualization.
4
+ * Mounted at /admin within the Generic API.
5
+ * UPDATED: Added Stale Task Detection, Root Data Health, and SimHash Inspection.
6
+ */
7
+
8
+ const express = require('express');
9
+ const pLimit = require('p-limit');
10
+ const { getManifest } = require('../../computation-system/topology/ManifestLoader');
11
+ const { normalizeName } = require('../../computation-system/utils/utils');
12
+
13
+ /**
14
+ * Factory to create the Admin Router.
15
+ * @param {object} config - System configuration.
16
+ * @param {object} dependencies - { db, logger, ... }
17
+ * @param {object} unifiedCalculations - The injected calculations package.
18
+ */
19
+ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
20
+ const router = express.Router();
21
+ const { db, logger } = dependencies;
22
+
23
+ // Helper to get fresh manifest
24
+ const getFullManifest = () => getManifest([], unifiedCalculations, dependencies);
25
+
26
+ // --- 1. TOPOLOGY VISUALIZER ---
27
+ router.get('/topology', async (req, res) => {
28
+ try {
29
+ const manifest = getFullManifest();
30
+ const nodes = [];
31
+ const edges = [];
32
+
33
+ manifest.forEach(calc => {
34
+ nodes.push({
35
+ id: calc.name,
36
+ data: {
37
+ label: calc.name,
38
+ layer: calc.category,
39
+ pass: calc.pass,
40
+ isHistorical: calc.isHistorical,
41
+ type: calc.type
42
+ },
43
+ position: { x: 0, y: 0 }
44
+ });
45
+
46
+ if (calc.dependencies) {
47
+ calc.dependencies.forEach(dep => {
48
+ edges.push({
49
+ id: `e-${dep}-${calc.name}`,
50
+ source: normalizeName(dep),
51
+ target: calc.name,
52
+ type: 'smoothstep'
53
+ });
54
+ });
55
+ }
56
+
57
+ if (calc.rootDataDependencies) {
58
+ calc.rootDataDependencies.forEach(root => {
59
+ const rootId = `ROOT_${root.toUpperCase()}`;
60
+ if (!nodes.find(n => n.id === rootId)) {
61
+ nodes.push({
62
+ id: rootId,
63
+ type: 'input',
64
+ data: { label: `${root.toUpperCase()} DB` },
65
+ position: { x: 0, y: 0 },
66
+ style: { background: '#f0f0f0', border: '1px solid #777' }
67
+ });
68
+ }
69
+ edges.push({
70
+ id: `e-root-${root}-${calc.name}`,
71
+ source: rootId,
72
+ target: calc.name,
73
+ animated: true,
74
+ style: { stroke: '#ff0072' }
75
+ });
76
+ });
77
+ }
78
+ });
79
+
80
+ res.json({ summary: { totalNodes: nodes.length, totalEdges: edges.length }, nodes, edges });
81
+ } catch (e) {
82
+ logger.log('ERROR', '[AdminAPI] Topology build failed', e);
83
+ res.status(500).json({ error: e.message });
84
+ }
85
+ });
86
+
87
+ // --- 2. STATUS MATRIX (Calendar / State UI) ---
88
+ router.get('/matrix', async (req, res) => {
89
+ const { start, end } = req.query;
90
+ if (!start || !end) return res.status(400).json({ error: "Start and End dates required." });
91
+
92
+ try {
93
+ const startDate = new Date(String(start));
94
+ const endDate = new Date(String(end));
95
+ const dates = [];
96
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
97
+ dates.push(d.toISOString().slice(0, 10));
98
+ }
99
+
100
+ const manifest = getFullManifest();
101
+ const allCalcNames = new Set(manifest.map(c => c.name));
102
+
103
+ const limit = pLimit(20);
104
+ const matrix = {};
105
+
106
+ await Promise.all(dates.map(date => limit(async () => {
107
+ // Fetch Status and Root Data Availability
108
+ const [statusSnap, rootSnap] = await Promise.all([
109
+ db.collection('computation_status').doc(date).get(),
110
+ db.collection('system_root_data_index').doc(date).get()
111
+ ]);
112
+
113
+ const statusData = statusSnap.exists ? statusSnap.data() : {};
114
+ const rootData = rootSnap.exists ? rootSnap.data() : { status: { hasPortfolio: false } };
115
+
116
+ const dateStatus = {};
117
+
118
+ // Check every calculation in the Manifest
119
+ allCalcNames.forEach(calcName => {
120
+ const entry = statusData[calcName];
121
+
122
+ if (!entry) {
123
+ // If root data exists but calc is missing -> PENDING
124
+ // If no root data -> WAITING_DATA
125
+ dateStatus[calcName] = rootData.status?.hasPortfolio ? 'PENDING' : 'WAITING_DATA';
126
+ } else if (typeof entry === 'object') {
127
+ if (entry.hash && typeof entry.hash === 'string' && entry.hash.startsWith('IMPOSSIBLE')) {
128
+ dateStatus[calcName] = 'IMPOSSIBLE';
129
+ } else if (entry.hash === false) {
130
+ dateStatus[calcName] = 'BLOCKED';
131
+ } else {
132
+ dateStatus[calcName] = 'COMPLETED';
133
+ }
134
+ } else if (entry === 'IMPOSSIBLE') {
135
+ dateStatus[calcName] = 'IMPOSSIBLE';
136
+ } else {
137
+ dateStatus[calcName] = 'COMPLETED';
138
+ }
139
+ });
140
+
141
+ matrix[date] = {
142
+ dataAvailable: rootData.status || {},
143
+ calculations: dateStatus
144
+ };
145
+ })));
146
+
147
+ res.json(matrix);
148
+ } catch (e) {
149
+ logger.log('ERROR', '[AdminAPI] Matrix fetch failed', e);
150
+ res.status(500).json({ error: e.message });
151
+ }
152
+ });
153
+
154
+ // --- 3. PIPELINE STATE (Progress Bar) ---
155
+ router.get('/pipeline/state', async (req, res) => {
156
+ const { date } = req.query;
157
+ if (!date) return res.status(400).json({ error: "Date required" });
158
+
159
+ try {
160
+ const passes = ['1', '2', '3', '4', '5'];
161
+ const state = await Promise.all(passes.map(async (pass) => {
162
+ // We use the Audit Ledger which is the source of truth for execution state
163
+ const tasksSnap = await db.collection(`computation_audit_ledger/${date}/passes/${pass}/tasks`).get();
164
+
165
+ const stats = {
166
+ pending: 0,
167
+ inProgress: 0,
168
+ completed: 0,
169
+ failed: 0,
170
+ totalMemoryMB: 0,
171
+ avgDurationMs: 0
172
+ };
173
+
174
+ const durations = [];
175
+
176
+ tasksSnap.forEach(doc => {
177
+ const data = doc.data();
178
+ const s = (data.status || 'UNKNOWN').toLowerCase();
179
+ if (stats[s] !== undefined) stats[s]++;
180
+ else stats[s] = 1;
181
+
182
+ if (data.telemetry?.lastMemory?.rssMB) {
183
+ stats.totalMemoryMB += data.telemetry.lastMemory.rssMB;
184
+ }
185
+ if (data.completedAt && data.startedAt) {
186
+ durations.push(new Date(data.completedAt).getTime() - new Date(data.startedAt).getTime());
187
+ }
188
+ });
189
+
190
+ stats.avgDurationMs = durations.length ?
191
+ Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
192
+
193
+ return { pass, stats, totalTasks: tasksSnap.size };
194
+ }));
195
+
196
+ res.json({ date, passes: state });
197
+ } catch (e) {
198
+ res.status(500).json({ error: e.message });
199
+ }
200
+ });
201
+
202
+ // --- 4. DEPENDENCY TRACER (Blast Radius) ---
203
+ router.get('/trace/:calcName', async (req, res) => {
204
+ const { calcName } = req.params;
205
+ const mode = req.query.mode || 'downstream'; // 'upstream' or 'downstream'
206
+
207
+ try {
208
+ const manifest = getFullManifest();
209
+ const manifestMap = new Map(manifest.map(c => [c.name, c]));
210
+
211
+ if (!manifestMap.has(calcName)) return res.status(404).json({ error: 'Calculation not found' });
212
+
213
+ const trace = { root: calcName, chain: [] };
214
+
215
+ if (mode === 'upstream') {
216
+ // What does X depend on?
217
+ const visited = new Set();
218
+ const walk = (name, depth = 0) => {
219
+ if (visited.has(name) || depth > 10) return;
220
+ visited.add(name);
221
+ const calc = manifestMap.get(name);
222
+ if (!calc) return;
223
+
224
+ trace.chain.push({
225
+ name, depth, pass: calc.pass, type: calc.type
226
+ });
227
+
228
+ calc.dependencies?.forEach(dep => walk(dep, depth + 1));
229
+ };
230
+ walk(calcName);
231
+ } else {
232
+ // What depends on X? (Downstream / Impact)
233
+ const reverseGraph = new Map();
234
+ manifest.forEach(c => {
235
+ c.dependencies?.forEach(dep => {
236
+ const normDep = normalizeName(dep);
237
+ if (!reverseGraph.has(normDep)) reverseGraph.set(normDep, []);
238
+ reverseGraph.get(normDep).push(c.name);
239
+ });
240
+ });
241
+
242
+ const visited = new Set();
243
+ const walk = (name, depth = 0) => {
244
+ if (visited.has(name) || depth > 10) return;
245
+ visited.add(name);
246
+
247
+ const calc = manifestMap.get(name);
248
+ trace.chain.push({
249
+ name, depth, pass: calc?.pass
250
+ });
251
+
252
+ reverseGraph.get(name)?.forEach(child => walk(child, depth + 1));
253
+ };
254
+ walk(calcName);
255
+ }
256
+
257
+ res.json(trace);
258
+ } catch (e) {
259
+ res.status(500).json({ error: e.message });
260
+ }
261
+ });
262
+
263
+ // --- 5. CONTRACT VIOLATIONS (Quality Gate) ---
264
+ router.get('/violations', async (req, res) => {
265
+ const days = parseInt(String(req.query.days)) || 7;
266
+ const cutoff = new Date();
267
+ cutoff.setDate(cutoff.getDate() - days);
268
+
269
+ try {
270
+ // Check DLQ for Semantic Failures (Hard Violations)
271
+ const dlqSnap = await db.collection('computation_dead_letter_queue')
272
+ .where('finalAttemptAt', '>', cutoff)
273
+ .where('error.stage', '==', 'SEMANTIC_GATE')
274
+ .limit(50)
275
+ .get();
276
+
277
+ const violations = [];
278
+ dlqSnap.forEach(doc => {
279
+ const data = doc.data();
280
+ violations.push({
281
+ id: doc.id,
282
+ computation: data.originalData.computation,
283
+ date: data.originalData.date,
284
+ reason: data.error.message,
285
+ type: 'HARD_VIOLATION',
286
+ timestamp: data.finalAttemptAt
287
+ });
288
+ });
289
+
290
+ // Check Audit Logs for Soft Anomalies (Statistical warnings)
291
+ const anomalySnap = await db.collectionGroup('history')
292
+ .where('triggerTime', '>', cutoff.toISOString())
293
+ .where('anomalies', '!=', []) // Firestore != operator
294
+ .limit(50)
295
+ .get();
296
+
297
+ anomalySnap.forEach(doc => {
298
+ const data = doc.data();
299
+ data.anomalies?.forEach(anomaly => {
300
+ violations.push({
301
+ id: doc.id,
302
+ computation: data.computationName,
303
+ date: data.targetDate,
304
+ reason: anomaly,
305
+ type: 'SOFT_ANOMALY',
306
+ timestamp: data.triggerTime
307
+ });
308
+ });
309
+ });
310
+
311
+ // Sort by time desc
312
+ violations.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
313
+
314
+ res.json({ count: violations.length, violations });
315
+ } catch (e) {
316
+ res.status(500).json({ error: e.message });
317
+ }
318
+ });
319
+
320
+ // --- 6. MEMORY HOTSPOTS (Forensics) ---
321
+ router.get('/memory/hotspots', async (req, res) => {
322
+ const thresholdMB = parseInt(String(req.query.threshold)) || 1000; // 1GB default
323
+
324
+ try {
325
+ // Ledger tasks maintain 'telemetry.lastMemory'
326
+ // We use collectionGroup to search across all dates/passes
327
+ const snapshot = await db.collectionGroup('tasks')
328
+ .where('telemetry.lastMemory.rssMB', '>', thresholdMB)
329
+ .orderBy('telemetry.lastMemory.rssMB', 'desc')
330
+ .limit(20)
331
+ .get();
332
+
333
+ const hotspots = [];
334
+ snapshot.forEach(doc => {
335
+ const data = doc.data();
336
+ hotspots.push({
337
+ computation: data.computation,
338
+ rssMB: data.telemetry.lastMemory.rssMB,
339
+ heapMB: data.telemetry.lastMemory.heapUsedMB,
340
+ status: data.status,
341
+ worker: data.workerId,
342
+ date: doc.ref.parent.parent.parent.parent.id // traversing path to get date
343
+ });
344
+ });
345
+
346
+ res.json({ count: hotspots.length, hotspots });
347
+ } catch (e) {
348
+ res.status(500).json({ error: e.message });
349
+ }
350
+ });
351
+
352
+ // --- 7. FLIGHT RECORDER (Inspection) ---
353
+ router.get('/inspect/:date/:calcName', async (req, res) => {
354
+ const { date, calcName } = req.params;
355
+ try {
356
+ const passes = ['1', '2', '3', '4', '5'];
357
+ let executionRecord = null;
358
+
359
+ await Promise.all(passes.map(async (pass) => {
360
+ if (executionRecord) return;
361
+ const ref = db.doc(`computation_audit_ledger/${date}/passes/${pass}/tasks/${calcName}`);
362
+ const snap = await ref.get();
363
+ if (snap.exists) executionRecord = { pass, ...snap.data() };
364
+ }));
365
+
366
+ if (!executionRecord) return res.status(404).json({ status: 'NOT_FOUND' });
367
+
368
+ const contractSnap = await db.collection('system_contracts').doc(calcName).get();
369
+
370
+ res.json({
371
+ execution: executionRecord,
372
+ contract: contractSnap.exists ? contractSnap.data() : null
373
+ });
374
+ } catch (e) {
375
+ res.status(500).json({ error: e.message });
376
+ }
377
+ });
378
+
379
+ // --- 8. COST & RESOURCE ANALYSIS ---
380
+ router.get('/analytics/costs', async (req, res) => {
381
+ const { date, days } = req.query;
382
+ // Default to today if no date, or range if days provided
383
+ const targetDate = date || new Date().toISOString().slice(0, 10);
384
+
385
+ // Simple Cost Model (Estimates)
386
+ const COSTS = {
387
+ write: 0.18 / 100000,
388
+ read: 0.06 / 100000,
389
+ delete: 0.02 / 100000,
390
+ compute_std_sec: 0.000023, // 1vCPU 2GB (approx)
391
+ compute_high_sec: 0.000092 // 2vCPU 8GB (approx)
392
+ };
393
+
394
+ try {
395
+ const auditRef = db.collection('computation_audit_logs');
396
+ // We scan the 'history' subcollectionGroup for the given date(s)
397
+ // Note: This can be expensive. In prod, you'd want aggregate counters.
398
+ const query = db.collectionGroup('history').where('targetDate', '==', targetDate);
399
+ const snap = await query.get();
400
+
401
+ let totalCost = 0;
402
+ const byPass = {};
403
+ const byCalc = {};
404
+
405
+ snap.forEach(doc => {
406
+ const data = doc.data();
407
+ const ops = data.firestoreOps || { reads: 0, writes: 0, deletes: 0 };
408
+ const durationSec = (data.durationMs || 0) / 1000;
409
+ const tier = data.resourceTier || 'standard';
410
+
411
+ const ioCost = (ops.writes * COSTS.write) + (ops.reads * COSTS.read) + (ops.deletes * COSTS.delete);
412
+ const computeCost = durationSec * (tier === 'high-mem' ? COSTS.compute_high_sec : COSTS.compute_std_sec);
413
+ const itemCost = ioCost + computeCost;
414
+
415
+ totalCost += itemCost;
416
+
417
+ // Aggregations
418
+ const pass = data.pass || 'unknown';
419
+ if (!byPass[pass]) byPass[pass] = { cost: 0, runs: 0, duration: 0 };
420
+ byPass[pass].cost += itemCost;
421
+ byPass[pass].runs++;
422
+ byPass[pass].duration += durationSec;
423
+
424
+ const calc = data.computationName;
425
+ if (!byCalc[calc]) byCalc[calc] = { cost: 0, runs: 0, ops: { r:0, w:0 } };
426
+ byCalc[calc].cost += itemCost;
427
+ byCalc[calc].runs++;
428
+ byCalc[calc].ops.r += ops.reads;
429
+ byCalc[calc].ops.w += ops.writes;
430
+ });
431
+
432
+ // Top 10 Expensive Calcs
433
+ const topCalcs = Object.entries(byCalc)
434
+ .sort((a, b) => b[1].cost - a[1].cost)
435
+ .slice(0, 10)
436
+ .map(([name, stats]) => ({ name, ...stats }));
437
+
438
+ res.json({
439
+ date: targetDate,
440
+ totalCostUSD: totalCost,
441
+ breakdown: {
442
+ byPass,
443
+ topCalculations: topCalcs
444
+ },
445
+ meta: { model: COSTS }
446
+ });
447
+
448
+ } catch (e) { res.status(500).json({ error: e.message }); }
449
+ });
450
+
451
+ // --- 9. REROUTE (OOM) ANALYSIS ---
452
+ router.get('/analytics/reroutes', async (req, res) => {
453
+ const { date } = req.query;
454
+ if (!date) return res.status(400).json({ error: "Date required" });
455
+
456
+ try {
457
+ // Find all runs that used high-mem
458
+ const query = db.collectionGroup('history')
459
+ .where('targetDate', '==', date)
460
+ .where('resourceTier', '==', 'high-mem');
461
+
462
+ const snap = await query.get();
463
+ const reroutes = [];
464
+
465
+ snap.forEach(doc => {
466
+ const data = doc.data();
467
+ reroutes.push({
468
+ computation: data.computationName,
469
+ pass: data.pass,
470
+ trigger: data.trigger?.reason,
471
+ peakMemoryMB: data.peakMemoryMB,
472
+ durationMs: data.durationMs,
473
+ runId: data.runId
474
+ });
475
+ });
476
+
477
+ res.json({ count: reroutes.length, reroutes });
478
+ } catch (e) { res.status(500).json({ error: e.message }); }
479
+ });
480
+
481
+ // --- 10. LIVE DASHBOARD (Snapshot) ---
482
+ // Poll this endpoint to simulate a WebSocket feed
483
+ router.get('/live/dashboard', async (req, res) => {
484
+ const today = new Date().toISOString().slice(0, 10);
485
+ try {
486
+ // Query the Ledger for Active Tasks
487
+ // We look at all passes for today
488
+ const passes = ['1', '2', '3', '4', '5'];
489
+ const activeTasks = [];
490
+ const recentFailures = [];
491
+
492
+ await Promise.all(passes.map(async (pass) => {
493
+ const colRef = db.collection(`computation_audit_ledger/${today}/passes/${pass}/tasks`);
494
+
495
+ // Get Running
496
+ const runningSnap = await colRef.where('status', 'in', ['PENDING', 'IN_PROGRESS']).get();
497
+ runningSnap.forEach(doc => {
498
+ activeTasks.push({ pass, ...doc.data() });
499
+ });
500
+
501
+ // Get Recent Failures (last 10 mins?? hard to query without index, just grab failures)
502
+ const failSnap = await colRef.where('status', '==', 'FAILED').get();
503
+ failSnap.forEach(doc => {
504
+ recentFailures.push({ pass, ...doc.data() });
505
+ });
506
+ }));
507
+
508
+ // Get Pipeline Stage (which pass is active?)
509
+ // We infer this by seeing which pass has pending tasks
510
+ let currentStage = 'IDLE';
511
+ for (const p of passes) {
512
+ const hasActive = activeTasks.some(t => t.pass === p);
513
+ if (hasActive) { currentStage = `PASS_${p}`; break; }
514
+ }
515
+
516
+ res.json({
517
+ status: 'success',
518
+ timestamp: new Date(),
519
+ pipelineState: currentStage,
520
+ activeCount: activeTasks.length,
521
+ failureCount: recentFailures.length,
522
+ tasks: activeTasks,
523
+ failures: recentFailures
524
+ });
525
+
526
+ } catch (e) { res.status(500).json({ error: e.message }); }
527
+ });
528
+
529
+ // =========================================================================
530
+ // NEW DEBUGGING ROUTES (Added 2025-12-19)
531
+ // =========================================================================
532
+
533
+ // --- 11. STALE COMPUTATION DETECTOR ("Zombie Hunter") ---
534
+ router.get('/debug/stale', async (req, res) => {
535
+ const thresholdMins = parseInt(String(req.query.threshold)) || 15;
536
+ const cutoffTime = Date.now() - (thresholdMins * 60 * 1000);
537
+
538
+ try {
539
+ // Scanning collectionGroup 'tasks' is expensive, so we focus on 'IN_PROGRESS'
540
+ // and filter in memory if necessary, or rely on composite index if available.
541
+ const snapshot = await db.collectionGroup('tasks')
542
+ .where('status', '==', 'IN_PROGRESS')
543
+ .get();
544
+
545
+ const zombies = [];
546
+ snapshot.forEach(doc => {
547
+ const data = doc.data();
548
+
549
+ // Determine last activity (Heartbeat > StartedAt > 0)
550
+ const lastActivity = data.telemetry?.lastHeartbeat
551
+ ? new Date(data.telemetry.lastHeartbeat).getTime()
552
+ : (data.startedAt ? new Date(data.startedAt).getTime() : 0);
553
+
554
+ if (lastActivity < cutoffTime) {
555
+ zombies.push({
556
+ computation: data.computation || doc.id,
557
+ workerId: data.workerId || 'unknown',
558
+ startedAt: data.startedAt,
559
+ lastHeartbeat: data.telemetry?.lastHeartbeat,
560
+ stagnantMinutes: Math.floor((Date.now() - lastActivity) / 60000),
561
+ path: doc.ref.path // Useful for manual deletion
562
+ });
563
+ }
564
+ });
565
+
566
+ // Sort by most stagnant
567
+ zombies.sort((a, b) => b.stagnantMinutes - a.stagnantMinutes);
568
+
569
+ res.json({
570
+ count: zombies.length,
571
+ thresholdMins,
572
+ zombies
573
+ });
574
+ } catch (e) {
575
+ res.status(500).json({ error: e.message });
576
+ }
577
+ });
578
+
579
+ // --- 12. ROOT DATA HEALTH CHECK ---
580
+ // Debugs the "Available but marked missing" issue
581
+ router.get('/debug/root-data-health', async (req, res) => {
582
+ const { start, end } = req.query;
583
+ if (!start || !end) return res.status(400).json({ error: "Start and End dates required." });
584
+
585
+ try {
586
+ const startDate = new Date(String(start));
587
+ const endDate = new Date(String(end));
588
+ const dates = [];
589
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
590
+ dates.push(d.toISOString().slice(0, 10));
591
+ }
592
+
593
+ const report = {};
594
+ const limit = pLimit(10);
595
+
596
+ await Promise.all(dates.map(date => limit(async () => {
597
+ const rootDoc = await db.collection('system_root_data_index').doc(date).get();
598
+ if (!rootDoc.exists) {
599
+ report[date] = { exists: false, status: 'MISSING_INDEX' };
600
+ } else {
601
+ const data = rootDoc.data();
602
+ report[date] = {
603
+ exists: true,
604
+ status: 'OK',
605
+ flags: data.status, // hasHistory, hasPortfolio, etc.
606
+ lastUpdated: data.lastUpdated
607
+ };
608
+ }
609
+ })));
610
+
611
+ res.json(report);
612
+ } catch (e) {
613
+ res.status(500).json({ error: e.message });
614
+ }
615
+ });
616
+
617
+ // --- 13. SIMHASH INSPECTOR ---
618
+ // Debugs "Why did this run? Code Changed?"
619
+ router.get('/debug/simhash/:hash', async (req, res) => {
620
+ const { hash } = req.params;
621
+ try {
622
+ const doc = await db.collection('system_simhash_registry').doc(hash).get();
623
+ if (!doc.exists) return res.status(404).json({ error: "Hash not found in registry." });
624
+
625
+ res.json(doc.data());
626
+ } catch (e) {
627
+ res.status(500).json({ error: e.message });
628
+ }
629
+ });
630
+
631
+ // --- 10. DEVELOPER USER MANAGEMENT (Force Sync & Lookup) ---
632
+ // Only accessible to developer accounts
633
+
634
+ /**
635
+ * POST /admin/users/:cid/sync
636
+ * Force sync a signed-in user (developer only)
637
+ * Allows developers to trigger sync for any user to fix stuck accounts
638
+ */
639
+ router.post('/users/:cid/sync', async (req, res) => {
640
+ const { cid } = req.params;
641
+ const { userCid: developerCid } = req.query; // Developer's CID
642
+
643
+ if (!developerCid) {
644
+ return res.status(400).json({
645
+ success: false,
646
+ error: "Missing userCid",
647
+ message: "Please provide userCid query parameter (your developer CID)"
648
+ });
649
+ }
650
+
651
+ // SECURITY: Only allow developer accounts
652
+ const { isDeveloperAccount } = require('../user-api/helpers/dev/dev_helpers');
653
+ if (!isDeveloperAccount(Number(developerCid))) {
654
+ logger.log('WARN', `[AdminAPI] Unauthorized sync attempt by non-developer ${developerCid}`);
655
+ return res.status(403).json({
656
+ success: false,
657
+ error: "Forbidden",
658
+ message: "This endpoint is only available for developer accounts"
659
+ });
660
+ }
661
+
662
+ const targetCidNum = Number(cid);
663
+ if (isNaN(targetCidNum) || targetCidNum <= 0) {
664
+ return res.status(400).json({
665
+ success: false,
666
+ error: "Invalid CID",
667
+ message: "Please provide a valid user CID"
668
+ });
669
+ }
670
+
671
+ try {
672
+ // Import sync helper
673
+ const { requestUserSync } = require('../user-api/helpers/sync/user_sync_helpers');
674
+
675
+ // Create a mock request object with the target CID
676
+ const mockReq = {
677
+ params: { userCid: String(targetCidNum) },
678
+ query: { userCid: String(developerCid), sourcePage: 'admin' },
679
+ headers: {},
680
+ body: {}
681
+ };
682
+
683
+ // Call the sync function
684
+ await requestUserSync(mockReq, res, dependencies, config);
685
+
686
+ logger.log('INFO', `[AdminAPI] Developer ${developerCid} force-synced user ${targetCidNum}`);
687
+
688
+ } catch (error) {
689
+ logger.log('ERROR', `[AdminAPI] Error force-syncing user ${targetCidNum}`, error);
690
+ return res.status(500).json({
691
+ success: false,
692
+ error: "Internal server error",
693
+ message: error.message
694
+ });
695
+ }
696
+ });
697
+
698
+ /**
699
+ * GET /admin/users/:cid/lookup
700
+ * Look up user information by CID (developer only)
701
+ * Helps developers find users to sync
702
+ */
703
+ router.get('/users/:cid/lookup', async (req, res) => {
704
+ const { cid } = req.params;
705
+ const { userCid: developerCid } = req.query; // Developer's CID
706
+
707
+ if (!developerCid) {
708
+ return res.status(400).json({
709
+ success: false,
710
+ error: "Missing userCid",
711
+ message: "Please provide userCid query parameter (your developer CID)"
712
+ });
713
+ }
714
+
715
+ // SECURITY: Only allow developer accounts
716
+ const { isDeveloperAccount } = require('../user-api/helpers/dev/dev_helpers');
717
+ if (!isDeveloperAccount(Number(developerCid))) {
718
+ logger.log('WARN', `[AdminAPI] Unauthorized lookup attempt by non-developer ${developerCid}`);
719
+ return res.status(403).json({
720
+ success: false,
721
+ error: "Forbidden",
722
+ message: "This endpoint is only available for developer accounts"
723
+ });
724
+ }
725
+
726
+ const targetCidNum = Number(cid);
727
+ if (isNaN(targetCidNum) || targetCidNum <= 0) {
728
+ return res.status(400).json({
729
+ success: false,
730
+ error: "Invalid CID",
731
+ message: "Please provide a valid user CID"
732
+ });
733
+ }
734
+
735
+ try {
736
+ const signedInUsersCollection = config.signedInUsersCollection || 'signed_in_users';
737
+ const { checkIfUserIsPI } = require('../user-api/helpers/core/user_status_helpers');
738
+
739
+ // Check if user is a PI
740
+ const rankEntry = await checkIfUserIsPI(db, targetCidNum, config, logger);
741
+ const isPI = !!rankEntry;
742
+
743
+ // Get signed-in user data
744
+ const userDoc = await db.collection(signedInUsersCollection).doc(String(targetCidNum)).get();
745
+ const userData = userDoc.exists ? userDoc.data() : null;
746
+
747
+ // Get verification data
748
+ const verificationsCollection = config.verificationsCollection || 'verifications';
749
+ let verificationData = null;
750
+ if (userData && userData.username) {
751
+ const verificationDoc = await db.collection(verificationsCollection)
752
+ .doc(userData.username.toLowerCase())
753
+ .get();
754
+ if (verificationDoc.exists) {
755
+ verificationData = verificationDoc.data();
756
+ }
757
+ }
758
+
759
+ // Get latest sync request status
760
+ const syncRequestsRef = db.collection('user_sync_requests')
761
+ .doc(String(targetCidNum))
762
+ .collection('requests')
763
+ .orderBy('createdAt', 'desc')
764
+ .limit(1);
765
+
766
+ const latestSyncSnapshot = await syncRequestsRef.get();
767
+ let latestSync = null;
768
+ if (!latestSyncSnapshot.empty) {
769
+ latestSync = {
770
+ requestId: latestSyncSnapshot.docs[0].id,
771
+ ...latestSyncSnapshot.docs[0].data()
772
+ };
773
+ }
774
+
775
+ // Get data status directly (don't use the endpoint wrapper)
776
+ let dataStatus = null;
777
+ try {
778
+ const { signedInUsersCollection, signedInHistoryCollection } = config;
779
+ const { findLatestPortfolioDate } = require('../user-api/helpers/core/data_lookup_helpers');
780
+ const CANARY_BLOCK_ID = '19M';
781
+ const today = new Date().toISOString().split('T')[0];
782
+
783
+ // Check portfolio
784
+ const portfolioDate = await findLatestPortfolioDate(db, signedInUsersCollection, targetCidNum, 30);
785
+ const portfolioExists = !!portfolioDate;
786
+ const isPortfolioFallback = portfolioDate && portfolioDate !== today;
787
+
788
+ // Check history
789
+ let historyExists = false;
790
+ let historyDate = null;
791
+ let isHistoryFallback = false;
792
+
793
+ const historyCollection = signedInHistoryCollection || 'signed_in_user_history';
794
+ const todayHistoryRef = db.collection(historyCollection)
795
+ .doc(CANARY_BLOCK_ID)
796
+ .collection('snapshots')
797
+ .doc(today)
798
+ .collection('parts');
799
+
800
+ const todayHistorySnapshot = await todayHistoryRef.get();
801
+
802
+ if (!todayHistorySnapshot.empty) {
803
+ historyExists = true;
804
+ historyDate = today;
805
+ } else {
806
+ // Check fallback dates
807
+ for (let i = 1; i <= 30; i++) {
808
+ const checkDate = new Date(today);
809
+ checkDate.setDate(checkDate.getDate() - i);
810
+ const dateStr = checkDate.toISOString().split('T')[0];
811
+
812
+ const historyRef = db.collection(historyCollection)
813
+ .doc(CANARY_BLOCK_ID)
814
+ .collection('snapshots')
815
+ .doc(dateStr)
816
+ .collection('parts');
817
+
818
+ const snapshot = await historyRef.get();
819
+ if (!snapshot.empty) {
820
+ // Check if this user's data exists in any part
821
+ for (const partDoc of snapshot.docs) {
822
+ const partData = partDoc.data();
823
+ if (partData.users && partData.users[String(targetCidNum)]) {
824
+ historyExists = true;
825
+ historyDate = dateStr;
826
+ isHistoryFallback = true;
827
+ break;
828
+ }
829
+ }
830
+ if (historyExists) break;
831
+ }
832
+ }
833
+ }
834
+
835
+ dataStatus = {
836
+ portfolioAvailable: portfolioExists,
837
+ historyAvailable: historyExists,
838
+ date: portfolioDate || today,
839
+ portfolioDate: portfolioDate,
840
+ historyDate: historyDate,
841
+ isPortfolioFallback: isPortfolioFallback,
842
+ isHistoryFallback: isHistoryFallback,
843
+ requestedDate: today,
844
+ userCid: String(targetCidNum)
845
+ };
846
+ } catch (e) {
847
+ logger.log('WARN', `[AdminAPI] Error getting data status: ${e.message}`);
848
+ }
849
+
850
+ const result = {
851
+ success: true,
852
+ cid: targetCidNum,
853
+ isPI: isPI,
854
+ userData: userData ? {
855
+ username: userData.username,
856
+ fullName: userData.fullName,
857
+ avatar: userData.avatar,
858
+ verifiedAt: userData.verifiedAt,
859
+ lastLogin: userData.lastLogin,
860
+ isOptOut: userData.isOptOut
861
+ } : null,
862
+ piData: rankEntry ? {
863
+ username: rankEntry.UserName || rankEntry.username,
864
+ aum: rankEntry.AUMValue,
865
+ copiers: rankEntry.Copiers,
866
+ riskScore: rankEntry.RiskScore,
867
+ gain: rankEntry.Gain
868
+ } : null,
869
+ verification: verificationData ? {
870
+ status: verificationData.status,
871
+ verifiedAt: verificationData.verifiedAt,
872
+ isOptOut: verificationData.isOptOut
873
+ } : null,
874
+ latestSync: latestSync,
875
+ dataStatus: dataStatus
876
+ };
877
+
878
+ logger.log('INFO', `[AdminAPI] Developer ${developerCid} looked up user ${targetCidNum}`);
879
+
880
+ return res.status(200).json(result);
881
+
882
+ } catch (error) {
883
+ logger.log('ERROR', `[AdminAPI] Error looking up user ${targetCidNum}`, error);
884
+ return res.status(500).json({
885
+ success: false,
886
+ error: "Internal server error",
887
+ message: error.message
888
+ });
889
+ }
890
+ });
891
+
892
+ return router;
893
+ };
894
+
895
+ module.exports = createAdminRouter;