bulltrackers-module 1.0.504 → 1.0.506

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 (49) hide show
  1. package/functions/generic-api/user-api/ADDING_LEGACY_ROUTES_GUIDE.md +345 -0
  2. package/functions/generic-api/user-api/CODE_REORGANIZATION_PLAN.md +320 -0
  3. package/functions/generic-api/user-api/COMPLETE_REFACTORING_PLAN.md +116 -0
  4. package/functions/generic-api/user-api/FIRESTORE_PATHS_INVENTORY.md +171 -0
  5. package/functions/generic-api/user-api/FIRESTORE_PATH_MIGRATION_REFERENCE.md +710 -0
  6. package/functions/generic-api/user-api/FIRESTORE_PATH_VALIDATION.md +109 -0
  7. package/functions/generic-api/user-api/MIGRATION_PLAN.md +499 -0
  8. package/functions/generic-api/user-api/README_MIGRATION.md +152 -0
  9. package/functions/generic-api/user-api/REFACTORING_COMPLETE.md +106 -0
  10. package/functions/generic-api/user-api/REFACTORING_STATUS.md +85 -0
  11. package/functions/generic-api/user-api/VERIFICATION_MIGRATION_NOTES.md +206 -0
  12. package/functions/generic-api/user-api/helpers/ORGANIZATION_COMPLETE.md +126 -0
  13. package/functions/generic-api/user-api/helpers/alerts/subscription_helpers.js +327 -0
  14. package/functions/generic-api/user-api/helpers/{test_alert_helpers.js → alerts/test_alert_helpers.js} +1 -1
  15. package/functions/generic-api/user-api/helpers/collection_helpers.js +23 -45
  16. package/functions/generic-api/user-api/helpers/core/compression_helpers.js +68 -0
  17. package/functions/generic-api/user-api/helpers/core/data_lookup_helpers.js +213 -0
  18. package/functions/generic-api/user-api/helpers/core/path_resolution_helpers.js +486 -0
  19. package/functions/generic-api/user-api/helpers/core/user_status_helpers.js +77 -0
  20. package/functions/generic-api/user-api/helpers/data/computation_helpers.js +299 -0
  21. package/functions/generic-api/user-api/helpers/data/instrument_helpers.js +55 -0
  22. package/functions/generic-api/user-api/helpers/data/portfolio_helpers.js +238 -0
  23. package/functions/generic-api/user-api/helpers/data/social_helpers.js +55 -0
  24. package/functions/generic-api/user-api/helpers/data_helpers.js +85 -2750
  25. package/functions/generic-api/user-api/helpers/{dev_helpers.js → dev/dev_helpers.js} +0 -1
  26. package/functions/generic-api/user-api/helpers/{on_demand_fetch_helpers.js → fetch/on_demand_fetch_helpers.js} +33 -115
  27. package/functions/generic-api/user-api/helpers/metrics/personalized_metrics_helpers.js +360 -0
  28. package/functions/generic-api/user-api/helpers/{notification_helpers.js → notifications/notification_helpers.js} +0 -1
  29. package/functions/generic-api/user-api/helpers/profile/pi_profile_helpers.js +200 -0
  30. package/functions/generic-api/user-api/helpers/profile/profile_view_helpers.js +125 -0
  31. package/functions/generic-api/user-api/helpers/profile/user_profile_helpers.js +178 -0
  32. package/functions/generic-api/user-api/helpers/recommendations/recommendation_helpers.js +65 -0
  33. package/functions/generic-api/user-api/helpers/{review_helpers.js → reviews/review_helpers.js} +23 -107
  34. package/functions/generic-api/user-api/helpers/search/pi_request_helpers.js +177 -0
  35. package/functions/generic-api/user-api/helpers/search/pi_search_helpers.js +70 -0
  36. package/functions/generic-api/user-api/helpers/{user_sync_helpers.js → sync/user_sync_helpers.js} +54 -127
  37. package/functions/generic-api/user-api/helpers/{verification_helpers.js → verification/verification_helpers.js} +4 -43
  38. package/functions/generic-api/user-api/helpers/watchlist/watchlist_analytics_helpers.js +95 -0
  39. package/functions/generic-api/user-api/helpers/watchlist/watchlist_data_helpers.js +139 -0
  40. package/functions/generic-api/user-api/helpers/watchlist/watchlist_generation_helpers.js +306 -0
  41. package/functions/generic-api/user-api/helpers/{watchlist_helpers.js → watchlist/watchlist_management_helpers.js} +62 -213
  42. package/functions/generic-api/user-api/index.js +9 -9
  43. package/functions/task-engine/handler_creator.js +7 -6
  44. package/package.json +1 -1
  45. package/functions/generic-api/API_MIGRATION_PLAN.md +0 -436
  46. package/functions/generic-api/user-api/helpers/FALLBACK_CONDITIONS.md +0 -98
  47. package/functions/generic-api/user-api/helpers/HISTORY_STORAGE_LOCATION.md +0 -66
  48. package/functions/generic-api/user-api/helpers/subscription_helpers.js +0 -512
  49. /package/functions/generic-api/user-api/helpers/{alert_helpers.js → alerts/alert_helpers.js} +0 -0
@@ -1,2752 +1,87 @@
1
1
  /**
2
- * @fileoverview Helpers for Data Serving (Analytics, Watchlists).
3
- */
4
-
5
- const { FieldValue } = require('@google-cloud/firestore');
6
- const zlib = require('zlib');
7
-
8
- /**
9
- * Helper function to decompress computation results if they are stored as compressed byte strings
10
- * @param {object} data - The raw Firestore document data
11
- * @returns {object} The decompressed JSON object or original data if not compressed
12
- */
13
- function tryDecompress(data) {
14
- if (data && data._compressed === true && data.payload) {
15
- try {
16
- let buffer;
17
-
18
- // Handle different payload types from Firestore
19
- if (Buffer.isBuffer(data.payload)) {
20
- // Already a Buffer - use directly
21
- buffer = data.payload;
22
- } else if (typeof data.payload === 'string') {
23
- // If it's a string, try base64 first, then direct
24
- try {
25
- buffer = Buffer.from(data.payload, 'base64');
26
- } catch (e) {
27
- // If not base64, might already be decompressed JSON string
28
- try {
29
- const parsed = JSON.parse(data.payload);
30
- // If parsing succeeds, return it (data was already decompressed)
31
- return parsed;
32
- } catch (e2) {
33
- // Not JSON either, try as raw buffer
34
- buffer = Buffer.from(data.payload);
35
- }
36
- }
37
- } else {
38
- // Try to convert to Buffer
39
- buffer = Buffer.from(data.payload);
40
- }
41
-
42
- // Decompress the buffer
43
- const decompressed = zlib.gunzipSync(buffer);
44
- const jsonString = decompressed.toString('utf8');
45
-
46
- // Log first 200 chars to debug
47
- console.log('[tryDecompress] Decompressed JSON string (first 200 chars):', jsonString.substring(0, 200));
48
-
49
- // Parse the JSON string
50
- const parsed = JSON.parse(jsonString);
51
-
52
- // Verify it's an object, not a string
53
- if (typeof parsed === 'string') {
54
- console.log('[tryDecompress] WARNING: Parsed result is still a string! Attempting double parse...');
55
- // Might be double-encoded JSON string
56
- return JSON.parse(parsed);
57
- }
58
-
59
- console.log('[tryDecompress] Successfully decompressed. Type:', typeof parsed, 'Keys:', typeof parsed === 'object' && !Array.isArray(parsed) ? Object.keys(parsed).slice(0, 5) : 'N/A');
60
- return parsed;
61
- } catch (e) {
62
- console.error('[DataHelpers] Decompression failed:', e.message);
63
- console.error('[DataHelpers] Error stack:', e.stack);
64
- // Return empty object on failure to avoid crashing
65
- return {};
66
- }
67
- }
68
- return data;
69
- }
70
-
71
- /**
72
- * Helper function to find the latest available date for signed-in user portfolio data
73
- * Searches backwards from today up to 30 days
74
- */
75
- async function findLatestPortfolioDate(db, signedInUsersCollection, userCid, maxDaysBack = 30, collectionRegistry = null) {
76
- const CANARY_BLOCK_ID = '19M';
77
- const today = new Date();
78
-
79
- // Try new structure first if collectionRegistry is available
80
- if (collectionRegistry) {
81
- try {
82
- const { getRootDataPortfolioPath } = require('./collection_helpers');
83
-
84
- for (let i = 0; i < maxDaysBack; i++) {
85
- const checkDate = new Date(today);
86
- checkDate.setDate(checkDate.getDate() - i);
87
- const dateStr = checkDate.toISOString().split('T')[0];
88
-
89
- try {
90
- const rootPath = getRootDataPortfolioPath(collectionRegistry, dateStr, userCid);
91
- const rootDoc = await db.doc(rootPath).get();
92
-
93
- if (rootDoc.exists) {
94
- const data = rootDoc.data();
95
- if (data && (data.AggregatedPositions || data.AggregatedMirrors)) {
96
- return dateStr; // Found data in new structure
97
- }
98
- }
99
- } catch (error) {
100
- // Continue to next date if error
101
- continue;
102
- }
103
- }
104
- } catch (error) {
105
- // Fall through to legacy check
106
- }
107
- }
108
-
109
- // Fallback to legacy structure
110
- for (let i = 0; i < maxDaysBack; i++) {
111
- const checkDate = new Date(today);
112
- checkDate.setDate(checkDate.getDate() - i);
113
- const dateStr = checkDate.toISOString().split('T')[0];
114
-
115
- try {
116
- const partsRef = db.collection(signedInUsersCollection)
117
- .doc(CANARY_BLOCK_ID)
118
- .collection('snapshots')
119
- .doc(dateStr)
120
- .collection('parts');
121
-
122
- const partsSnapshot = await partsRef.get();
123
-
124
- // Check if user's CID exists in any part document
125
- for (const partDoc of partsSnapshot.docs) {
126
- const partData = partDoc.data();
127
- if (partData && partData[String(userCid)]) {
128
- return dateStr; // Found data for this date
129
- }
130
- }
131
- } catch (error) {
132
- // Continue to next date if error
133
- continue;
134
- }
135
- }
136
-
137
- return null; // No data found in the last maxDaysBack days
138
- }
139
-
140
- /**
141
- * Helper function to find the latest available date for computation results
142
- * Searches backwards from today up to 30 days
143
- */
144
- async function findLatestComputationDate(db, insightsCollection, resultsSub, compsSub, category, computationName, userCid, maxDaysBack = 30) {
145
- const today = new Date();
146
-
147
- // Log the path structure being checked
148
- console.log(`[findLatestComputationDate] Searching for: ${insightsCollection}/{date}/${resultsSub}/${category}/${compsSub}/${computationName}`);
149
-
150
- for (let i = 0; i < maxDaysBack; i++) {
151
- const checkDate = new Date(today);
152
- checkDate.setDate(checkDate.getDate() - i);
153
- const dateStr = checkDate.toISOString().split('T')[0];
154
-
155
- try {
156
- const computationRef = db.collection(insightsCollection)
157
- .doc(dateStr)
158
- .collection(resultsSub)
159
- .doc(category)
160
- .collection(compsSub)
161
- .doc(computationName);
162
-
163
- const computationDoc = await computationRef.get();
164
-
165
- // Log each date being checked
166
- if (i < 3) { // Only log first 3 to avoid spam
167
- console.log(`[findLatestComputationDate] Checking date ${dateStr}: exists=${computationDoc.exists}`);
168
- }
169
-
170
- // Just check if document exists - don't check for CID here
171
- // We'll check for CID in the calling function after decompression
172
- if (computationDoc.exists) {
173
- console.log(`[findLatestComputationDate] Found document at date: ${dateStr}`);
174
- return dateStr; // Found document for this date
175
- }
176
- } catch (error) {
177
- // Log errors for debugging
178
- console.error(`[findLatestComputationDate] Error checking date ${dateStr}:`, error.message);
179
- // Continue to next date if error
180
- continue;
181
- }
182
- }
183
-
184
- console.log(`[findLatestComputationDate] No document found in last ${maxDaysBack} days`);
185
- return null; // No document found in the last maxDaysBack days
186
- }
187
-
188
- /**
189
- * Check if a signed-in user is also a Popular Investor
190
- * Returns ranking entry if found, null otherwise
191
- * Checks dev overrides first for pretendToBePI flag
192
- */
193
- async function checkIfUserIsPI(db, userCid, config, logger = null) {
194
- try {
195
- // Check dev override first (for developer accounts)
196
- const { getDevOverride } = require('./dev_helpers');
197
- const devOverride = await getDevOverride(db, userCid, config, logger);
198
-
199
- if (devOverride && devOverride.enabled && devOverride.pretendToBePI) {
200
- // Generate fake ranking entry for dev testing
201
- const fakeRankEntry = {
202
- CustomerId: Number(userCid),
203
- UserName: 'Dev Test PI',
204
- AUMValue: 500000 + Math.floor(Math.random() * 1000000), // Random AUM between 500k-1.5M
205
- Copiers: 150 + Math.floor(Math.random() * 200), // Random copiers between 150-350
206
- RiskScore: 3 + Math.floor(Math.random() * 3), // Random risk score 3-5
207
- Gain: 25 + Math.floor(Math.random() * 50), // Random gain 25-75%
208
- WinRatio: 50 + Math.floor(Math.random() * 20), // Random win ratio 50-70%
209
- Trades: 500 + Math.floor(Math.random() * 1000) // Random trades 500-1500
210
- };
211
-
212
- if (logger && logger.log) {
213
- logger.log('INFO', `[checkIfUserIsPI] DEV OVERRIDE: User ${userCid} pretending to be PI with fake ranking data`);
214
- } else {
215
- console.log(`[checkIfUserIsPI] DEV OVERRIDE: User ${userCid} pretending to be PI with fake ranking data`);
216
- }
217
-
218
- return fakeRankEntry;
219
- }
220
-
221
- // Otherwise, check real rankings
222
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
223
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
224
-
225
- if (!rankingsDate) {
226
- return null;
227
- }
228
-
229
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
230
- const rankingsDoc = await rankingsRef.get();
231
-
232
- if (!rankingsDoc.exists) {
233
- return null;
234
- }
235
-
236
- const rankingsData = rankingsDoc.data();
237
- const rankingsItems = rankingsData.Items || [];
238
-
239
- // Find user in rankings
240
- const userRankEntry = rankingsItems.find(item => String(item.CustomerId) === String(userCid));
241
-
242
- return userRankEntry || null;
243
- } catch (error) {
244
- console.error('[checkIfUserIsPI] Error checking if user is PI:', error);
245
- return null;
246
- }
247
- }
248
-
249
- /**
250
- * Helper function to find the latest available date for Popular Investor rankings
251
- * Searches backwards from today up to 30 days
252
- */
253
- async function findLatestRankingsDate(db, rankingsCollection, maxDaysBack = 30) {
254
- const today = new Date();
255
-
256
- for (let i = 0; i < maxDaysBack; i++) {
257
- const checkDate = new Date(today);
258
- checkDate.setDate(checkDate.getDate() - i);
259
- const dateStr = checkDate.toISOString().split('T')[0];
260
-
261
- try {
262
- const rankingsRef = db.collection(rankingsCollection).doc(dateStr);
263
- const rankingsDoc = await rankingsRef.get();
264
-
265
- if (rankingsDoc.exists) {
266
- return dateStr; // Found rankings for this date
267
- }
268
- } catch (error) {
269
- // Continue to next date if error
270
- continue;
271
- }
272
- }
273
-
274
- return null; // No rankings found in the last maxDaysBack days
275
- }
276
-
277
- /**
278
- * Helper function to find the latest available date for Popular Investor portfolio data
279
- * Searches backwards from today up to 30 days
280
- */
281
- async function findLatestPiPortfolioDate(db, piPortfoliosCollection, userCid, maxDaysBack = 30) {
282
- const CANARY_BLOCK_ID = '19M';
283
- const today = new Date();
284
-
285
- for (let i = 0; i < maxDaysBack; i++) {
286
- const checkDate = new Date(today);
287
- checkDate.setDate(checkDate.getDate() - i);
288
- const dateStr = checkDate.toISOString().split('T')[0];
289
-
290
- try {
291
- const partsRef = db.collection(piPortfoliosCollection)
292
- .doc(CANARY_BLOCK_ID)
293
- .collection('snapshots')
294
- .doc(dateStr)
295
- .collection('parts');
296
-
297
- const partsSnapshot = await partsRef.get();
298
-
299
- // Check if user's CID exists in any part document
300
- for (const partDoc of partsSnapshot.docs) {
301
- const partData = partDoc.data();
302
- if (partData && partData[String(userCid)]) {
303
- return dateStr; // Found data for this date
304
- }
305
- }
306
- } catch (error) {
307
- // Continue to next date if error
308
- continue;
309
- }
310
- }
311
-
312
- return null; // No data found in the last maxDaysBack days
313
- }
314
-
315
- /**
316
- * Helper function to find the latest available date for Popular Investor history data
317
- * Searches backwards from today up to 30 days
318
- */
319
- async function findLatestPiHistoryDate(db, piHistoryCollection, userCid, maxDaysBack = 30) {
320
- const today = new Date();
321
-
322
- for (let i = 0; i < maxDaysBack; i++) {
323
- const checkDate = new Date(today);
324
- checkDate.setDate(checkDate.getDate() - i);
325
- const dateStr = checkDate.toISOString().split('T')[0];
326
-
327
- try {
328
- const historyRef = db.collection(piHistoryCollection)
329
- .doc(String(userCid))
330
- .collection('history')
331
- .doc(dateStr);
332
-
333
- const historyDoc = await historyRef.get();
334
-
335
- if (historyDoc.exists) {
336
- return dateStr; // Found history for this date
337
- }
338
- } catch (error) {
339
- // Continue to next date if error
340
- continue;
341
- }
342
- }
343
-
344
- return null; // No data found in the last maxDaysBack days
345
- }
346
-
347
- /**
348
- * GET /pi/{cid}/analytics
349
- * Fetches pre-computed analytics from the 'analytics_results' collection (Phase 3 Output).
350
- */
351
- async function getPiAnalytics(req, res, dependencies, config) {
352
- const { db } = dependencies;
353
- const { cid } = req.params;
354
- const { analyticsCollection } = config; // e.g., 'analytics_results'
355
-
356
- try {
357
- // Phase 3 stores results by Date -> Calculation Name.
358
- // We likely want the LATEST available data for this user.
359
- // Or, if Phase 3 updated a 'user_profiles' collection, we read that.
360
- // Assuming Phase 3 "ResultCommitter" updates a dedicated 'pi_analytics_summary' for fast read.
361
-
362
- const docRef = db.collection(config.piAnalyticsSummaryCollection || 'pi_analytics_summary').doc(String(cid));
363
- const doc = await docRef.get();
364
-
365
- if (!doc.exists) {
366
- return res.status(404).json({ error: "No analytics found for this user." });
367
- }
368
-
369
- return res.status(200).json(doc.data());
370
-
371
- } catch (error) {
372
- return res.status(500).json({ error: error.message });
373
- }
374
- }
375
-
376
- /**
377
- * GET /user/me/hedges (and /similar)
378
- * Returns personalized recommendations calculated in Phase 3.
379
- */
380
- async function getUserRecommendations(req, res, dependencies, config, type = 'hedges') {
381
- const { db } = dependencies;
382
- const { userCid } = req.query; // Passed via query or auth middleware
383
-
384
- if (!userCid) return res.status(400).json({ error: "Missing userCid" });
385
-
386
- try {
387
- // Recommendations are stored in signed_in_users/{cid}/recommendations/{type}
388
- // or aggregated in the user doc.
389
- const userDoc = await db.collection(config.signedInUsersCollection).doc(String(userCid)).get();
390
-
391
- if (!userDoc.exists) return res.status(404).json({ error: "User not found" });
392
-
393
- const data = userDoc.data();
394
- // Assuming structure: { recommendations: { hedges: [...], similar: [...] } }
395
- const recs = data.recommendations ? data.recommendations[type] : [];
396
-
397
- return res.status(200).json({ [type]: recs || [] });
398
-
399
- } catch (error) {
400
- return res.status(500).json({ error: error.message });
401
- }
402
- }
403
-
404
- /**
405
- * GET /user/me/data-status
406
- * Checks if signed-in user's portfolio data has been fetched and stored.
407
- * Returns status of portfolio and history data availability.
408
- * Falls back to latest available date if today's data doesn't exist.
409
- */
410
- async function getUserDataStatus(req, res, dependencies, config) {
411
- const { db, logger } = dependencies;
412
- const { userCid } = req.query; // Passed via query or auth middleware
413
-
414
- if (!userCid) {
415
- return res.status(400).json({ error: "Missing userCid" });
416
- }
417
-
418
- try {
419
- const { signedInUsersCollection, signedInHistoryCollection } = config;
420
- const CANARY_BLOCK_ID = '19M';
421
-
422
- // Get today's date in YYYY-MM-DD format
423
- const today = new Date().toISOString().split('T')[0];
424
-
425
- logger.log('INFO', `[getUserDataStatus] Checking data for CID: ${userCid}, Date: ${today}, Collection: ${signedInUsersCollection}`);
426
-
427
- // Try to find latest available date for portfolio (with fallback)
428
- const portfolioDate = await findLatestPortfolioDate(db, signedInUsersCollection, userCid, 30);
429
- const portfolioExists = !!portfolioDate;
430
- const isPortfolioFallback = portfolioDate && portfolioDate !== today;
431
-
432
- if (portfolioDate && isPortfolioFallback) {
433
- logger.log('INFO', `[getUserDataStatus] Using fallback portfolio date ${portfolioDate} for user ${userCid} (today: ${today})`);
434
- }
435
-
436
- // Check history data with fallback
437
- let historyExists = false;
438
- let historyDate = null;
439
- let isHistoryFallback = false;
440
-
441
- const historyCollection = signedInHistoryCollection || 'signed_in_user_history';
442
-
443
- // Check today first
444
- const todayHistoryRef = db.collection(historyCollection)
445
- .doc(CANARY_BLOCK_ID)
446
- .collection('snapshots')
447
- .doc(today)
448
- .collection('parts');
449
-
450
- const todayHistorySnapshot = await todayHistoryRef.get();
451
-
452
- if (!todayHistorySnapshot.empty) {
453
- for (const partDoc of todayHistorySnapshot.docs) {
454
- const partData = partDoc.data();
455
- if (partData && partData[String(userCid)]) {
456
- historyExists = true;
457
- historyDate = today;
458
- break;
459
- }
460
- }
461
- }
462
-
463
- // If not found today, search backwards
464
- if (!historyExists) {
465
- for (let i = 1; i <= 30; i++) {
466
- const checkDate = new Date();
467
- checkDate.setDate(checkDate.getDate() - i);
468
- const dateStr = checkDate.toISOString().split('T')[0];
469
-
470
- try {
471
- const historyPartsRef = db.collection(historyCollection)
472
- .doc(CANARY_BLOCK_ID)
473
- .collection('snapshots')
474
- .doc(dateStr)
475
- .collection('parts');
476
-
477
- const historyPartsSnapshot = await historyPartsRef.get();
478
-
479
- if (!historyPartsSnapshot.empty) {
480
- for (const partDoc of historyPartsSnapshot.docs) {
481
- const partData = partDoc.data();
482
- if (partData && partData[String(userCid)]) {
483
- historyExists = true;
484
- historyDate = dateStr;
485
- isHistoryFallback = true;
486
- logger.log('INFO', `[getUserDataStatus] Found history data in fallback date ${dateStr}`);
487
- break;
488
- }
489
- }
490
- }
491
-
492
- if (historyExists) break;
493
- } catch (error) {
494
- continue;
495
- }
496
- }
497
- }
498
-
499
- const result = {
500
- portfolioAvailable: portfolioExists,
501
- historyAvailable: historyExists,
502
- date: portfolioDate || today,
503
- portfolioDate: portfolioDate,
504
- historyDate: historyDate,
505
- isPortfolioFallback: isPortfolioFallback,
506
- isHistoryFallback: isHistoryFallback,
507
- requestedDate: today,
508
- userCid: String(userCid)
509
- };
510
-
511
- logger.log('INFO', `[getUserDataStatus] Result for CID ${userCid}:`, result);
512
-
513
- return res.status(200).json(result);
514
-
515
- } catch (error) {
516
- logger.log('ERROR', `[getUserDataStatus] Error checking data status`, error);
517
- return res.status(500).json({ error: error.message });
518
- }
519
- }
520
-
521
- /**
522
- * GET /user/me/watchlist
523
- * Fetches the user's watchlist
524
- */
525
- async function getWatchlist(req, res, dependencies, config) {
526
- const { db, logger } = dependencies;
527
- const { userCid } = req.query;
528
-
529
- if (!userCid) {
530
- return res.status(400).json({ error: "Missing userCid" });
531
- }
532
-
533
- try {
534
- // Check for dev override impersonation
535
- const { getEffectiveCid } = require('./dev_helpers');
536
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
537
-
538
- const watchlistsCollection = config.watchlistsCollection || 'watchlists';
539
- const watchlistRef = db.collection(watchlistsCollection).doc(String(effectiveCid));
540
- const watchlistDoc = await watchlistRef.get();
541
-
542
- if (!watchlistDoc.exists) {
543
- return res.status(200).json({ watchlist: {} });
544
- }
545
-
546
- const watchlistData = watchlistDoc.data();
547
- return res.status(200).json({
548
- watchlist: watchlistData,
549
- effectiveCid: effectiveCid,
550
- actualCid: Number(userCid)
551
- });
552
-
553
- } catch (error) {
554
- logger.log('ERROR', `[getWatchlist] Error fetching watchlist for ${userCid}`, error);
555
- return res.status(500).json({ error: error.message });
556
- }
557
- }
558
-
559
- /**
560
- * POST /watchlist
561
- * Adds/Updates a watchlist item.
562
- */
563
- async function updateWatchlist(req, res, dependencies, config) {
564
- const { db, logger } = dependencies;
565
- const { userCid, type, target, alertConfig } = req.body;
566
- // type: 'static' | 'dynamic'
567
- // target: CID (if static) OR MongoQuery (if dynamic)
568
- // alertConfig: { email: bool, push: bool, thresholds: {} }
569
-
570
- if (!userCid || !type || !target) return res.status(400).json({ error: "Invalid payload" });
571
-
572
- try {
573
- // Check for dev override impersonation
574
- const { getEffectiveCid } = require('./dev_helpers');
575
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
576
-
577
- const watchlistRef = db.collection(config.watchlistsCollection).doc(String(effectiveCid));
578
-
579
- // We store as an array or map in the user's watchlist doc
580
- // Here using a map key for ID/Hash to allow updates
581
- const entryId = type === 'static' ? `static_${target}` : `dyn_${Buffer.from(JSON.stringify(target)).toString('base64').slice(0,10)}`;
582
-
583
- await watchlistRef.set({
584
- [entryId]: {
585
- type,
586
- target,
587
- alertConfig: alertConfig || {},
588
- updatedAt: FieldValue.serverTimestamp()
589
- }
590
- }, { merge: true });
591
-
592
- return res.status(200).json({
593
- success: true,
594
- id: entryId,
595
- effectiveCid: effectiveCid,
596
- actualCid: Number(userCid)
597
- });
598
-
599
- } catch (error) {
600
- return res.status(500).json({ error: error.message });
601
- }
602
- }
603
-
604
- /**
605
- * GET /user/me/portfolio
606
- * Fetches the signed-in user's portfolio data from Firestore
607
- * Falls back to latest available date if today's data doesn't exist
608
- */
609
- async function getUserPortfolio(req, res, dependencies, config) {
610
- const { db, logger } = dependencies;
611
- const { userCid } = req.query;
612
-
613
- if (!userCid) {
614
- return res.status(400).json({ error: "Missing userCid" });
615
- }
616
-
617
- try {
618
- // Check for dev override impersonation
619
- const { getEffectiveCid, getDevOverride } = require('./dev_helpers');
620
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
621
- const devOverride = await getDevOverride(db, userCid, config, logger);
622
- const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(userCid);
623
-
624
- // If impersonating, check if the effective CID is a Popular Investor
625
- // PIs store data in pi_portfolios_overall, not signed_in_users
626
- if (isImpersonating) {
627
- const rankEntry = await checkIfUserIsPI(db, effectiveCid, config, logger);
628
- if (rankEntry) {
629
- // This is a PI being impersonated - they don't have signed_in_users portfolio data
630
- // Return a helpful error indicating they should use the PI profile endpoint instead
631
- return res.status(404).json({
632
- error: "Popular Investor account",
633
- message: `CID ${effectiveCid} is a Popular Investor. Use /user/me/pi-personalized-metrics or /user/pi/${effectiveCid}/profile instead.`,
634
- effectiveCid: effectiveCid,
635
- isImpersonating: true,
636
- isPopularInvestor: true,
637
- suggestedEndpoint: `/user/me/pi-personalized-metrics?userCid=${userCid}`
638
- });
639
- }
640
- }
641
-
642
- const { signedInUsersCollection } = config;
643
- const { collectionRegistry } = dependencies;
644
- const CANARY_BLOCK_ID = '19M';
645
- const today = new Date().toISOString().split('T')[0];
646
- const effectiveCidStr = String(effectiveCid);
647
-
648
- // Use effective CID for portfolio lookup
649
- const dataDate = await findLatestPortfolioDate(db, signedInUsersCollection, effectiveCid, 30, collectionRegistry);
650
-
651
- if (!dataDate) {
652
- return res.status(404).json({
653
- error: "Portfolio data not found for this user (checked last 30 days)",
654
- effectiveCid: effectiveCid,
655
- isImpersonating: isImpersonating || false
656
- });
657
- }
658
-
659
- const isFallback = dataDate !== today;
660
- if (isFallback) {
661
- logger.log('INFO', `[getUserPortfolio] Using fallback date ${dataDate} for effective CID ${effectiveCid} (today: ${today})`);
662
- }
663
-
664
- // Try new structures first, then fallback to legacy
665
- let portfolioData = null;
666
- let source = 'new';
667
-
668
- // 1. Try user-centric latest snapshot
669
- if (collectionRegistry) {
670
- try {
671
- const { getUserPortfolioPath, extractCollectionName: extractCollectionNameHelper } = require('./collection_helpers');
672
- const latestPath = getUserPortfolioPath(collectionRegistry, effectiveCid);
673
- const collectionName = extractCollectionNameHelper(latestPath);
674
-
675
- const latestDoc = await db.collection(collectionName)
676
- .doc(effectiveCidStr)
677
- .collection('portfolio')
678
- .doc('latest')
679
- .get();
680
-
681
- if (latestDoc.exists) {
682
- const data = latestDoc.data();
683
- // Data might be nested under CID or at root level
684
- portfolioData = data[effectiveCidStr] || data;
685
- if (portfolioData && (portfolioData.AggregatedPositions || portfolioData.AggregatedMirrors)) {
686
- source = 'user-centric';
687
- } else {
688
- portfolioData = null;
689
- }
690
- }
691
- } catch (newError) {
692
- logger.log('WARN', `[getUserPortfolio] User-centric structure failed: ${newError.message}`);
693
- }
694
- }
695
-
696
- // 2. Try root data structure (date-based)
697
- if (!portfolioData && collectionRegistry) {
698
- try {
699
- const { getRootDataPortfolioPath } = require('./collection_helpers');
700
- const rootPath = getRootDataPortfolioPath(collectionRegistry, dataDate, effectiveCid);
701
-
702
- // Path is: SignedInUserPortfolioData/{date}/{cid}
703
- // This is a document path, so use db.doc() directly
704
- const rootDoc = await db.doc(rootPath).get();
705
-
706
- if (rootDoc.exists) {
707
- const data = rootDoc.data();
708
- portfolioData = data[effectiveCidStr] || data;
709
- if (portfolioData && (portfolioData.AggregatedPositions || portfolioData.AggregatedMirrors)) {
710
- source = 'root-data';
711
- } else {
712
- portfolioData = null;
713
- }
714
- }
715
- } catch (rootError) {
716
- logger.log('WARN', `[getUserPortfolio] Root data structure failed: ${rootError.message}`);
717
- }
718
- }
719
-
720
- // 3. Fallback to legacy structure
721
- if (!portfolioData) {
722
- source = 'legacy';
723
- const partsRef = db.collection(signedInUsersCollection)
724
- .doc(CANARY_BLOCK_ID)
725
- .collection('snapshots')
726
- .doc(dataDate)
727
- .collection('parts');
728
-
729
- const partsSnapshot = await partsRef.get();
730
-
731
- // Search through all parts to find the user's portfolio (use effective CID)
732
- for (const partDoc of partsSnapshot.docs) {
733
- const partData = partDoc.data();
734
- if (partData && partData[effectiveCidStr]) {
735
- portfolioData = partData[effectiveCidStr];
736
- break;
737
- }
738
- }
739
- }
740
-
741
- if (!portfolioData) {
742
- return res.status(404).json({
743
- error: "Portfolio data not found in any structure",
744
- effectiveCid: effectiveCid,
745
- isImpersonating: isImpersonating || false,
746
- checkedStructures: ['user-centric', 'root-data', 'legacy']
747
- });
748
- }
749
-
750
- // Apply dev override to AggregatedMirrors if dev override is active
751
- // Note: devOverride is already available from earlier in the function (line 573)
752
- if (devOverride && devOverride.enabled && devOverride.fakeCopiedPIs.length > 0 && portfolioData.AggregatedMirrors) {
753
- logger.log('INFO', `[getUserPortfolio] Applying DEV OVERRIDE to AggregatedMirrors for user ${userCid}`);
754
-
755
- // Replace AggregatedMirrors with fake ones from dev override
756
- portfolioData.AggregatedMirrors = devOverride.fakeCopiedPIs.map(cid => ({
757
- ParentCID: Number(cid),
758
- ParentUsername: `PI-${cid}`, // Placeholder, will be resolved if needed
759
- Invested: 0,
760
- NetProfit: 0,
761
- Value: 0,
762
- PendingForClosure: false
763
- }));
764
- }
765
-
766
- return res.status(200).json({
767
- portfolio: portfolioData,
768
- date: dataDate,
769
- isFallback: isFallback,
770
- requestedDate: today,
771
- userCid: String(userCid),
772
- devOverrideActive: devOverride && devOverride.enabled,
773
- source // Indicate which structure was used
774
- });
775
-
776
- } catch (error) {
777
- logger.log('ERROR', `[getUserPortfolio] Error fetching portfolio for ${userCid}`, error);
778
- return res.status(500).json({ error: error.message });
779
- }
780
- }
781
-
782
- /**
783
- * GET /user/me/social-posts
784
- * Fetches the signed-in user's social posts
785
- */
786
- async function getUserSocialPosts(req, res, dependencies, config) {
787
- const { db, logger, collectionRegistry } = dependencies;
788
- const { userCid } = req.query;
789
-
790
- if (!userCid) {
791
- return res.status(400).json({ error: "Missing userCid" });
792
- }
793
-
794
- try {
795
- const { getUserSocialPostsPath, extractCollectionName } = require('./collection_helpers');
796
-
797
- // Try new user-centric structure first
798
- let posts = [];
799
- let source = 'new';
800
-
801
- try {
802
- const newPath = getUserSocialPostsPath(collectionRegistry, userCid);
803
- const collectionName = extractCollectionName(newPath);
804
- const cid = String(userCid);
805
-
806
- // Path is: signedInUsers/{cid}/posts
807
- const postsRef = db.collection(collectionName)
808
- .doc(cid)
809
- .collection('posts');
810
-
811
- const postsSnapshot = await postsRef
812
- .orderBy('fetchedAt', 'desc')
813
- .limit(50)
814
- .get();
815
-
816
- if (!postsSnapshot.empty) {
817
- postsSnapshot.forEach(doc => {
818
- posts.push({
819
- id: doc.id,
820
- ...doc.data()
821
- });
822
- });
823
- }
824
- } catch (newError) {
825
- logger.log('WARN', `[getUserSocialPosts] New structure failed, trying legacy: ${newError.message}`);
826
- }
827
-
828
- // Fallback to legacy structure if new structure returned no posts
829
- if (posts.length === 0) {
830
- source = 'legacy';
831
- const { signedInSocialCollection } = config;
832
- const socialCollection = signedInSocialCollection || 'signed_in_users_social';
833
-
834
- const postsRef = db.collection(socialCollection)
835
- .doc(String(userCid))
836
- .collection('posts');
837
-
838
- const postsSnapshot = await postsRef
839
- .orderBy('createdAt', 'desc')
840
- .limit(50)
841
- .get();
842
-
843
- postsSnapshot.forEach(doc => {
844
- posts.push({
845
- id: doc.id,
846
- ...doc.data()
847
- });
848
- });
849
- }
850
-
851
- return res.status(200).json({
852
- posts,
853
- count: posts.length,
854
- userCid: String(userCid),
855
- source // Indicate which structure was used
856
- });
857
-
858
- } catch (error) {
859
- logger.log('ERROR', `[getUserSocialPosts] Error fetching social posts for ${userCid}`, error);
860
- return res.status(500).json({ error: error.message });
861
- }
862
- }
863
-
864
- /**
865
- * GET /user/me/instrument-mappings
866
- * Fetches instrument ID to ticker and sector mappings for the frontend
867
- */
868
- async function getInstrumentMappings(req, res, dependencies, config) {
869
- const { db, logger } = dependencies;
870
-
871
- try {
872
- // Fetch from Firestore (same source as computation system)
873
- const [tickerToIdDoc, tickerToSectorDoc] = await Promise.all([
874
- db.collection('instrument_mappings').doc('etoro_to_ticker').get(),
875
- db.collection('instrument_sector_mappings').doc('sector_mappings').get()
876
- ]);
877
-
878
- if (!tickerToIdDoc.exists) {
879
- return res.status(404).json({ error: "Instrument mappings not found" });
880
- }
881
-
882
- const tickerToId = tickerToIdDoc.data();
883
- const tickerToSector = tickerToSectorDoc.exists ? tickerToSectorDoc.data() : {};
884
-
885
- // Convert to ID -> Ticker mapping (reverse the mapping)
886
- const idToTicker = {};
887
- const idToSector = {};
888
-
889
- for (const [id, ticker] of Object.entries(tickerToId)) {
890
- idToTicker[String(id)] = ticker;
891
- // Map ID -> Sector via ticker
892
- if (tickerToSector[ticker]) {
893
- idToSector[String(id)] = tickerToSector[ticker];
894
- }
895
- }
896
-
897
- return res.status(200).json({
898
- instrumentToTicker: idToTicker,
899
- instrumentToSector: idToSector,
900
- count: Object.keys(idToTicker).length,
901
- sectorCount: Object.keys(idToSector).length
902
- });
903
-
904
- } catch (error) {
905
- logger.log('ERROR', `[getInstrumentMappings] Error fetching mappings`, error);
906
- return res.status(500).json({ error: error.message });
907
- }
908
- }
909
-
910
- /**
911
- * GET /user/me/verification
912
- * Fetches the signed-in user's verification data (includes avatar URL)
913
- */
914
- async function getUserVerification(req, res, dependencies, config) {
915
- const { db, logger } = dependencies;
916
- const { userCid } = req.query;
917
-
918
- if (!userCid) {
919
- return res.status(400).json({ error: "Missing userCid" });
920
- }
921
-
922
- try {
923
- // Check for dev override impersonation
924
- const { getEffectiveCid, getDevOverride } = require('./dev_helpers');
925
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
926
- const devOverride = await getDevOverride(db, userCid, config, logger);
927
- const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(userCid);
928
-
929
- const { signedInUsersCollection } = config;
930
-
931
- // If impersonating a PI, try to get username from rankings
932
- if (isImpersonating) {
933
- const rankEntry = await checkIfUserIsPI(db, effectiveCid, config, logger);
934
- if (rankEntry) {
935
- // This is a PI - get username from rankings
936
- return res.status(200).json({
937
- avatar: null, // PIs don't have avatars in our system
938
- username: rankEntry.UserName || null,
939
- fullName: null,
940
- cid: effectiveCid,
941
- verifiedAt: null,
942
- isImpersonating: true,
943
- effectiveCid: effectiveCid,
944
- actualCid: Number(userCid)
945
- });
946
- }
947
- }
948
-
949
- // Fetch verification data from signed_in_users/{effectiveCid}
950
- const userDocRef = db.collection(signedInUsersCollection).doc(String(effectiveCid));
951
- const userDoc = await userDocRef.get();
952
-
953
- if (!userDoc.exists) {
954
- return res.status(404).json({ error: "User verification data not found" });
955
- }
956
-
957
- const data = userDoc.data();
958
-
959
- return res.status(200).json({
960
- avatar: data.avatar || null,
961
- username: data.username || null,
962
- fullName: data.fullName || null,
963
- cid: data.cid || Number(effectiveCid),
964
- verifiedAt: data.verifiedAt || null,
965
- isImpersonating: isImpersonating || false,
966
- effectiveCid: effectiveCid,
967
- actualCid: Number(userCid)
968
- });
969
-
970
- } catch (error) {
971
- logger.log('ERROR', `[getUserVerification] Error fetching verification for ${userCid}`, error);
972
- return res.status(500).json({ error: error.message });
973
- }
974
- }
975
-
976
- /**
977
- * GET /user/me/computations
978
- * Fetches computation results for a specific signed-in user
979
- * Supports filtering by computation name and date range
980
- * Falls back to latest available date if today's data doesn't exist
981
- */
982
- async function getUserComputations(req, res, dependencies, config) {
983
- const { db, logger } = dependencies;
984
- const { userCid, computation, mode = 'latest', limit = 30 } = req.query;
985
-
986
- if (!userCid) {
987
- return res.status(400).json({ error: "Missing userCid" });
988
- }
989
-
990
- try {
991
- // Check for dev override impersonation
992
- const { getEffectiveCid, getDevOverride } = require('./dev_helpers');
993
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
994
- const devOverride = await getDevOverride(db, userCid, config, logger);
995
- const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(userCid);
996
-
997
- const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
998
- const resultsSub = config.resultsSubcollection || 'results';
999
- const compsSub = config.computationsSubcollection || 'computations';
1000
-
1001
- // NOTE: ResultCommitter ignores category metadata if computation is in a non-core folder
1002
- // SignedInUserProfileMetrics is in popular-investor folder, so it's stored in popular-investor category
1003
- // For signed-in users, computations are stored in the 'popular-investor' category (not 'signed_in_user')
1004
- const category = 'popular-investor';
1005
- const today = new Date().toISOString().split('T')[0];
1006
-
1007
- const computationNames = computation ? computation.split(',') : [];
1008
-
1009
- // If no specific computation requested, we'll need to know which ones to fetch
1010
- // For now, let's support specific computation requests
1011
- if (computationNames.length === 0) {
1012
- return res.status(400).json({ error: "Please specify at least one computation name" });
1013
- }
1014
-
1015
- // Check for dev override (for developer accounts) - use actual userCid for override check
1016
- const isDevOverrideActive = devOverride && devOverride.enabled && devOverride.fakeCopiedPIs.length > 0;
1017
-
1018
- let datesToCheck = [today];
1019
-
1020
- // If mode is 'latest', try to find the latest available date for the first computation
1021
- // Use effectiveCid for computation lookup
1022
- // CRITICAL: Only look back 7 days as per requirements
1023
- if (mode === 'latest') {
1024
- const firstCompName = computationNames[0];
1025
- const latestDate = await findLatestComputationDate(
1026
- db,
1027
- insightsCollection,
1028
- resultsSub,
1029
- compsSub,
1030
- category,
1031
- firstCompName,
1032
- effectiveCid,
1033
- 7 // Only look back 7 days as per requirements
1034
- );
1035
-
1036
- if (latestDate) {
1037
- datesToCheck = [latestDate];
1038
- if (latestDate !== today) {
1039
- logger.log('INFO', `[getUserComputations] Using fallback date ${latestDate} for effective CID ${effectiveCid} (today: ${today})`);
1040
- }
1041
- } else {
1042
- // No data found after 7 days - return empty (frontend will use fallback)
1043
- logger.log('WARN', `[getUserComputations] No computation data found for CID ${effectiveCid} in last 7 days. Frontend will use fallback.`);
1044
- return res.status(200).json({
1045
- status: 'success',
1046
- userCid: String(effectiveCid),
1047
- mode,
1048
- computations: computationNames,
1049
- data: {},
1050
- isFallback: true, // Mark as fallback since no data found
1051
- requestedDate: today,
1052
- isImpersonating: isImpersonating || false,
1053
- actualCid: Number(userCid)
1054
- });
1055
- }
1056
- } else if (mode === 'series') {
1057
- // For series mode, get multiple dates
1058
- const limitNum = parseInt(limit) || 30;
1059
- datesToCheck = [];
1060
- for (let i = 0; i < limitNum; i++) {
1061
- const date = new Date();
1062
- date.setDate(date.getDate() - i);
1063
- datesToCheck.push(date.toISOString().split('T')[0]);
1064
- }
1065
- }
1066
-
1067
- const results = {};
1068
- let isFallback = false;
1069
-
1070
- // Fetch computation results for each date
1071
- for (const date of datesToCheck) {
1072
- results[date] = {};
1073
-
1074
- for (const compName of computationNames) {
1075
- try {
1076
- const docRef = db.collection(insightsCollection)
1077
- .doc(date)
1078
- .collection(resultsSub)
1079
- .doc(category)
1080
- .collection(compsSub)
1081
- .doc(compName);
1082
-
1083
- const doc = await docRef.get();
1084
-
1085
- if (doc.exists) {
1086
- // Decompress if needed (handles byte string storage)
1087
- const rawData = doc.data();
1088
- let data = tryDecompress(rawData);
1089
-
1090
- // Handle string decompression result
1091
- if (typeof data === 'string') {
1092
- try {
1093
- data = JSON.parse(data);
1094
- } catch (e) {
1095
- logger.log('WARN', `[getUserComputations] Failed to parse decompressed string for ${compName} on ${date}:`, e.message);
1096
- data = null;
1097
- }
1098
- }
1099
-
1100
- // Check if data is sharded
1101
- if (data && data._sharded === true && data._shardCount) {
1102
- // Data is stored in shards - read all shards and merge
1103
- const shardsCol = docRef.collection('_shards');
1104
- const shardsSnapshot = await shardsCol.get();
1105
-
1106
- if (!shardsSnapshot.empty) {
1107
- data = {};
1108
- for (const shardDoc of shardsSnapshot.docs) {
1109
- const shardData = shardDoc.data();
1110
- Object.assign(data, shardData);
1111
- }
1112
- } else {
1113
- data = null; // Sharded but no shards found
1114
- }
1115
- }
1116
-
1117
- // Filter by user CID - computation results are stored as { cid: result }
1118
- // Use effectiveCid for lookup
1119
- let userResult = data && typeof data === 'object' ? data[String(effectiveCid)] : null;
1120
-
1121
- // Apply dev override for computations that include copied PIs (use actual userCid for override check)
1122
- if (isDevOverrideActive && (compName === 'SignedInUserProfileMetrics' || compName === 'SignedInUserCopiedPIs')) {
1123
- if (compName === 'SignedInUserCopiedPIs') {
1124
- // Override the copied PIs list
1125
- userResult = {
1126
- current: devOverride.fakeCopiedPIs,
1127
- past: [],
1128
- all: devOverride.fakeCopiedPIs
1129
- };
1130
- logger.log('INFO', `[getUserComputations] Applied DEV OVERRIDE to SignedInUserCopiedPIs for user ${userCid}`);
1131
- } else if (compName === 'SignedInUserProfileMetrics' && userResult && userResult.copiedPIs) {
1132
- // Override the copiedPIs data in SignedInUserProfileMetrics
1133
- // We need to construct fake mirror data from the dev override PIs
1134
- const fakeMirrors = devOverride.fakeCopiedPIs.map(cid => ({
1135
- cid: Number(cid),
1136
- username: `PI-${cid}`, // Placeholder, will be resolved from rankings if available
1137
- invested: 0,
1138
- netProfit: 0,
1139
- value: 0,
1140
- pendingClosure: false,
1141
- isRanked: false
1142
- }));
1143
-
1144
- userResult = {
1145
- ...userResult,
1146
- copiedPIs: {
1147
- chartType: 'cards',
1148
- data: fakeMirrors
1149
- }
1150
- };
1151
- logger.log('INFO', `[getUserComputations] Applied DEV OVERRIDE to SignedInUserProfileMetrics.copiedPIs for user ${userCid}`);
1152
- }
1153
- }
1154
-
1155
- if (userResult) {
1156
- results[date][compName] = userResult;
1157
- }
1158
- }
1159
- } catch (err) {
1160
- logger.log('WARN', `[getUserComputations] Error fetching ${compName} for ${date}`, err);
1161
- }
1162
- }
1163
-
1164
- // If mode is 'latest' and we found data, break early
1165
- if (mode === 'latest' && Object.keys(results[date]).length > 0) {
1166
- isFallback = date !== today;
1167
- break;
1168
- }
1169
- }
1170
-
1171
- // Clean up empty dates
1172
- const cleanedResults = {};
1173
- for (const [date, data] of Object.entries(results)) {
1174
- if (Object.keys(data).length > 0) {
1175
- cleanedResults[date] = data;
1176
- }
1177
- }
1178
-
1179
- return res.status(200).json({
1180
- status: 'success',
1181
- userCid: String(effectiveCid),
1182
- mode,
1183
- computations: computationNames,
1184
- data: cleanedResults,
1185
- isFallback: isFallback,
1186
- requestedDate: today,
1187
- devOverrideActive: isDevOverrideActive,
1188
- isImpersonating: isImpersonating || false,
1189
- actualCid: Number(userCid)
1190
- });
1191
-
1192
- } catch (error) {
1193
- logger.log('ERROR', `[getUserComputations] Error fetching computations for ${userCid}`, error);
1194
- return res.status(500).json({ error: error.message });
1195
- }
1196
- }
1197
-
1198
- /**
1199
- * POST /user/me/watchlist/auto-generate
1200
- * Auto-generates watchlist based on copied PIs
1201
- * Primary: Uses SignedInUserCopiedPIs computation (cheaper/faster)
1202
- * Fallback: Reads portfolio AggregatedMirrors directly if computation not available
1203
- */
1204
- async function autoGenerateWatchlist(req, res, dependencies, config) {
1205
- const { db, logger } = dependencies;
1206
- const { userCid } = req.body;
1207
-
1208
- if (!userCid) {
1209
- return res.status(400).json({ error: "Missing userCid" });
1210
- }
1211
-
1212
- try {
1213
- // Check for dev override impersonation (for computation lookup)
1214
- const { getEffectiveCid, getCopiedPIsWithDevOverride } = require('./dev_helpers');
1215
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
1216
-
1217
- let copiedPIs = [];
1218
- let dataSource = 'unknown';
1219
- let isDevOverride = false;
1220
- const today = new Date().toISOString().split('T')[0];
1221
-
1222
- // === DEV OVERRIDE CHECK (for developer accounts) ===
1223
- // Note: Use actual userCid for dev override check (dev override is about what PIs the developer "copies")
1224
- const devResult = await getCopiedPIsWithDevOverride(db, userCid, config, logger);
1225
- if (devResult.isDevOverride) {
1226
- copiedPIs = devResult.copiedPIs;
1227
- dataSource = devResult.dataSource;
1228
- isDevOverride = true;
1229
- logger.log('INFO', `[autoGenerateWatchlist] Using DEV OVERRIDE for user ${userCid}, found ${copiedPIs.length} fake copied PIs`);
1230
- }
1231
-
1232
- // === PRIMARY: Try to fetch from computation (cheaper/faster) ===
1233
- // Only if dev override is not active
1234
- // Use effectiveCid for computation lookup (to support impersonation)
1235
- if (!isDevOverride) {
1236
- const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
1237
- const category = 'signed_in_user';
1238
- const computationName = 'SignedInUserCopiedPIs';
1239
-
1240
- // Try to find latest computation date (with fallback)
1241
- // Use effectiveCid for lookup (supports impersonation)
1242
- const resultsSub = config.resultsSubcollection || 'results';
1243
- const compsSub = config.computationsSubcollection || 'computations';
1244
- const computationDate = await findLatestComputationDate(
1245
- db,
1246
- insightsCollection,
1247
- resultsSub,
1248
- compsSub,
1249
- category,
1250
- computationName,
1251
- effectiveCid,
1252
- 30
1253
- );
1254
-
1255
- if (computationDate) {
1256
- const computationRef = db.collection(insightsCollection)
1257
- .doc(computationDate)
1258
- .collection('results')
1259
- .doc(category)
1260
- .collection('computations')
1261
- .doc(computationName);
1262
-
1263
- const computationDoc = await computationRef.get();
1264
-
1265
- if (computationDoc.exists) {
1266
- const computationData = computationDoc.data();
1267
- // Use effectiveCid for lookup
1268
- const userResult = computationData[String(effectiveCid)];
1269
-
1270
- if (userResult && userResult.current && userResult.current.length > 0) {
1271
- // Convert computation result to our format
1272
- copiedPIs = userResult.current.map(cid => ({
1273
- cid: Number(cid),
1274
- username: 'Unknown' // Username not in computation, will get from rankings
1275
- }));
1276
- dataSource = 'computation';
1277
- logger.log('INFO', `[autoGenerateWatchlist] Using computation data (date: ${computationDate}) for user ${userCid}, found ${copiedPIs.length} copied PIs`);
1278
- }
1279
- }
1280
- }
1281
- }
1282
-
1283
- // === FALLBACK: Read portfolio data directly if computation not available ===
1284
- // Only if dev override is not active and no computation data found
1285
- if (!isDevOverride && copiedPIs.length === 0) {
1286
- logger.log('INFO', `[autoGenerateWatchlist] Computation not available, falling back to direct portfolio read for user ${userCid}`);
1287
-
1288
- const { signedInUsersCollection } = config;
1289
- const CANARY_BLOCK_ID = '19M';
1290
-
1291
- // Find latest available portfolio date (with fallback)
1292
- const portfolioDate = await findLatestPortfolioDate(db, signedInUsersCollection, userCid, 30);
1293
-
1294
- if (!portfolioDate) {
1295
- logger.log('INFO', `[autoGenerateWatchlist] No portfolio data found for ${userCid} (checked last 30 days)`);
1296
- return res.status(200).json({
1297
- success: true,
1298
- generated: 0,
1299
- totalCopied: 0,
1300
- dataSource: 'none',
1301
- message: "No portfolio data found for this user"
1302
- });
1303
- }
1304
-
1305
- if (portfolioDate !== today) {
1306
- logger.log('INFO', `[autoGenerateWatchlist] Using fallback portfolio date ${portfolioDate} for user ${userCid} (today: ${today})`);
1307
- }
1308
-
1309
- // Fetch portfolio from signed_in_users/19M/snapshots/{date}/parts/part_X
1310
- const partsRef = db.collection(signedInUsersCollection)
1311
- .doc(CANARY_BLOCK_ID)
1312
- .collection('snapshots')
1313
- .doc(portfolioDate)
1314
- .collection('parts');
1315
-
1316
- const partsSnapshot = await partsRef.get();
1317
-
1318
- let portfolioData = null;
1319
-
1320
- // Search through all parts to find the user's portfolio
1321
- for (const partDoc of partsSnapshot.docs) {
1322
- const partData = partDoc.data();
1323
- if (partData && partData[String(userCid)]) {
1324
- portfolioData = partData[String(userCid)];
1325
- break;
1326
- }
1327
- }
1328
-
1329
- if (!portfolioData) {
1330
- logger.log('WARN', `[autoGenerateWatchlist] Portfolio data not found in parts for ${userCid}`);
1331
- return res.status(200).json({
1332
- success: true,
1333
- generated: 0,
1334
- totalCopied: 0,
1335
- dataSource: 'none',
1336
- message: "Portfolio data not found"
1337
- });
1338
- }
1339
-
1340
- // Extract copied PIs from AggregatedMirrors
1341
- const aggregatedMirrors = portfolioData.AggregatedMirrors || [];
1342
-
1343
- for (const mirror of aggregatedMirrors) {
1344
- const parentCID = mirror.ParentCID;
1345
- if (parentCID && parentCID > 0) {
1346
- copiedPIs.push({
1347
- cid: parentCID,
1348
- username: mirror.ParentUsername || 'Unknown'
1349
- });
1350
- }
1351
- }
1352
-
1353
- dataSource = 'portfolio';
1354
- logger.log('INFO', `[autoGenerateWatchlist] Using portfolio data (date: ${portfolioDate}) for user ${userCid}, found ${copiedPIs.length} copied PIs`);
1355
- }
1356
-
1357
- if (copiedPIs.length === 0) {
1358
- logger.log('INFO', `[autoGenerateWatchlist] No copied PIs found for user ${userCid} (source: ${dataSource})`);
1359
- return res.status(200).json({
1360
- success: true,
1361
- generated: 0,
1362
- totalCopied: 0,
1363
- dataSource: dataSource,
1364
- message: "User is not currently copying any PIs"
1365
- });
1366
- }
1367
-
1368
- logger.log('INFO', `[autoGenerateWatchlist] Found ${copiedPIs.length} copied PIs for user ${userCid} (source: ${dataSource}): ${copiedPIs.map(p => `${p.username} (${p.cid})`).join(', ')}`);
1369
-
1370
- // 2. Fetch latest rankings data (with fallback to latest available date)
1371
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
1372
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
1373
-
1374
- if (!rankingsDate) {
1375
- logger.log('WARN', `[autoGenerateWatchlist] No rankings data found (checked last 30 days)`);
1376
- return res.status(404).json({ error: "Rankings data not available" });
1377
- }
1378
-
1379
- if (rankingsDate !== today) {
1380
- logger.log('INFO', `[autoGenerateWatchlist] Using fallback rankings date ${rankingsDate} (today: ${today})`);
1381
- }
1382
-
1383
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
1384
- const rankingsDoc = await rankingsRef.get();
1385
-
1386
- if (!rankingsDoc.exists) {
1387
- logger.log('WARN', `[autoGenerateWatchlist] Rankings doc does not exist for ${rankingsDate}`);
1388
- return res.status(404).json({ error: "Rankings data not available" });
1389
- }
1390
-
1391
- const rankingsData = rankingsDoc.data();
1392
- const rankingsItems = rankingsData.Items || [];
1393
-
1394
- // Create a map of CID -> ranking entry for quick lookup
1395
- const rankingsMap = new Map();
1396
- for (const item of rankingsItems) {
1397
- if (item.CustomerId) {
1398
- rankingsMap.set(String(item.CustomerId), item);
1399
- }
1400
- }
1401
-
1402
- // 3. Create watchlist items from copied PIs
1403
- // Include ALL copied PIs, even if not in rankings (user is copying them, so they should be watched)
1404
- let matchedCount = 0;
1405
- const watchlistItems = [];
1406
-
1407
- for (const copiedPI of copiedPIs) {
1408
- const cidStr = String(copiedPI.cid);
1409
- const rankEntry = rankingsMap.get(cidStr);
1410
-
1411
- // Use ranking data if available, otherwise use data from portfolio/computation
1412
- const username = rankEntry?.UserName || copiedPI.username || 'Unknown';
1413
-
1414
- if (rankEntry) {
1415
- matchedCount++;
1416
- } else {
1417
- logger.log('INFO', `[autoGenerateWatchlist] Copied PI ${copiedPI.username} (${cidStr}) not found in rankings, but including in watchlist anyway`);
1418
- }
1419
-
1420
- watchlistItems.push({
1421
- cid: Number(cidStr),
1422
- username: username,
1423
- addedAt: new Date(), // Use regular Date (FieldValue.serverTimestamp() not allowed in arrays)
1424
- alertConfig: {
1425
- newPositions: true,
1426
- volatilityChanges: true,
1427
- increasedRisk: true,
1428
- newSector: true,
1429
- increasedPositionSize: true,
1430
- newSocialPost: true
1431
- }
1432
- });
1433
- }
1434
-
1435
- if (watchlistItems.length === 0) {
1436
- logger.log('INFO', `[autoGenerateWatchlist] No PIs matched in rankings for user ${userCid}`);
1437
- return res.status(200).json({
1438
- success: true,
1439
- generated: 0,
1440
- totalCopied: copiedPIs.length,
1441
- matchedInRankings: 0,
1442
- dataSource: dataSource,
1443
- message: "No copied PIs found in rankings"
1444
- });
1445
- }
1446
-
1447
- // 4. Create or update the auto-generated watchlist using new structure
1448
- // The auto-generated watchlist should always reflect the CURRENT state of copied PIs
1449
- const watchlistsCollection = config.watchlistsCollection || 'watchlists';
1450
- const userWatchlistsRef = db.collection(watchlistsCollection)
1451
- .doc(String(userCid))
1452
- .collection('lists');
1453
-
1454
- // Check if auto-generated watchlist already exists
1455
- const existingWatchlistsSnapshot = await userWatchlistsRef
1456
- .where('isAutoGenerated', '==', true)
1457
- .limit(1)
1458
- .get();
1459
-
1460
- let watchlistId;
1461
- let generatedCount = watchlistItems.length;
1462
-
1463
- if (!existingWatchlistsSnapshot.empty) {
1464
- // Update existing auto-generated watchlist
1465
- // Replace items entirely to match current copied PIs (sync, not append)
1466
- const existingDoc = existingWatchlistsSnapshot.docs[0];
1467
- watchlistId = existingDoc.id;
1468
- const existingData = existingDoc.data();
1469
- const existingItems = existingData.items || [];
1470
-
1471
- // Create a map of existing items by CID to preserve their addedAt timestamps
1472
- const existingItemsMap = new Map();
1473
- for (const item of existingItems) {
1474
- if (item.cid) {
1475
- existingItemsMap.set(item.cid, item);
1476
- }
1477
- }
1478
-
1479
- // Merge watchlist items, preserving addedAt for existing items
1480
- const syncedItems = watchlistItems.map(newItem => {
1481
- const existingItem = existingItemsMap.get(newItem.cid);
1482
- if (existingItem && existingItem.addedAt) {
1483
- // Preserve existing addedAt timestamp
1484
- return {
1485
- ...newItem,
1486
- addedAt: existingItem.addedAt
1487
- };
1488
- }
1489
- // New item, use current date
1490
- return {
1491
- ...newItem,
1492
- addedAt: new Date()
1493
- };
1494
- });
1495
-
1496
- // Calculate what changed
1497
- const existingCIDs = new Set(existingItems.map(item => item.cid));
1498
- const newCIDs = new Set(watchlistItems.map(item => item.cid));
1499
-
1500
- const added = watchlistItems.filter(item => !existingCIDs.has(item.cid));
1501
- const removed = existingItems.filter(item => !newCIDs.has(item.cid));
1502
-
1503
- // Replace entire items array to sync with current copied PIs
1504
- // Also ensure visibility is always private for auto-generated watchlists
1505
- await existingDoc.ref.update({
1506
- items: syncedItems, // Full replacement to match current state, preserving timestamps
1507
- visibility: 'private', // Always enforce private for auto-generated
1508
- updatedAt: FieldValue.serverTimestamp()
1509
- });
1510
-
1511
- logger.log('SUCCESS', `[autoGenerateWatchlist] Synced auto-generated watchlist ${watchlistId} for user ${userCid}: ${added.length} added, ${removed.length} removed, ${watchlistItems.length} total`);
1512
- } else {
1513
- // Create new auto-generated watchlist
1514
- const crypto = require('crypto');
1515
- watchlistId = `watchlist_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
1516
-
1517
- const watchlistData = {
1518
- id: watchlistId,
1519
- name: 'My Copy Watchlist', // Hardcoded name as requested
1520
- type: 'static',
1521
- visibility: 'private', // Always private for auto-generated watchlists
1522
- createdBy: Number(userCid),
1523
- createdAt: FieldValue.serverTimestamp(),
1524
- updatedAt: FieldValue.serverTimestamp(),
1525
- isAutoGenerated: true,
1526
- copyCount: 0,
1527
- items: watchlistItems
1528
- };
1529
-
1530
- await userWatchlistsRef.doc(watchlistId).set(watchlistData);
1531
- logger.log('SUCCESS', `[autoGenerateWatchlist] Created auto-generated watchlist ${watchlistId} with ${watchlistItems.length} items for user ${userCid}`);
1532
- }
1533
-
1534
- // 5. Create subscriptions for all items in the watchlist
1535
- // This will be handled by the subscription system, but we log it here
1536
- logger.log('INFO', `[autoGenerateWatchlist] Watchlist ${watchlistId} ready for subscription setup (${watchlistItems.length} items)`);
1537
-
1538
- return res.status(200).json({
1539
- success: true,
1540
- generated: generatedCount,
1541
- totalCopied: copiedPIs.length,
1542
- matchedInRankings: matchedCount,
1543
- dataSource: dataSource,
1544
- watchlistId: watchlistId,
1545
- message: `Generated ${generatedCount} watchlist entries from ${copiedPIs.length} copied PIs (${matchedCount} matched in rankings, source: ${dataSource})`
1546
- });
1547
-
1548
- } catch (error) {
1549
- logger.log('ERROR', `[autoGenerateWatchlist] Error auto-generating watchlist for ${userCid}`, error);
1550
- return res.status(500).json({ error: error.message });
1551
- }
1552
- }
1553
-
1554
- /**
1555
- * GET /user/search/pis
1556
- * Search Popular Investors by username (for autocomplete)
1557
- */
1558
- async function searchPopularInvestors(req, res, dependencies, config) {
1559
- const { db, logger } = dependencies;
1560
- const { query, limit = 20 } = req.query;
1561
-
1562
- if (!query || query.trim().length < 2) {
1563
- return res.status(400).json({ error: "Query must be at least 2 characters" });
1564
- }
1565
-
1566
- try {
1567
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
1568
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
1569
-
1570
- if (!rankingsDate) {
1571
- return res.status(404).json({ error: "Rankings data not available" });
1572
- }
1573
-
1574
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
1575
- const rankingsDoc = await rankingsRef.get();
1576
-
1577
- if (!rankingsDoc.exists) {
1578
- return res.status(404).json({ error: "Rankings data not available" });
1579
- }
1580
-
1581
- const rankingsData = rankingsDoc.data();
1582
- const rankingsItems = rankingsData.Items || [];
1583
-
1584
- // Search by username (case-insensitive, partial match)
1585
- const searchQuery = query.toLowerCase().trim();
1586
- const matches = rankingsItems
1587
- .filter(item => {
1588
- const username = (item.UserName || '').toLowerCase();
1589
- return username.includes(searchQuery);
1590
- })
1591
- .slice(0, parseInt(limit))
1592
- .map(item => ({
1593
- cid: item.CustomerId,
1594
- username: item.UserName,
1595
- aum: item.AUMValue,
1596
- riskScore: item.RiskScore,
1597
- gain: item.Gain,
1598
- copiers: item.Copiers
1599
- }));
1600
-
1601
- return res.status(200).json({
1602
- results: matches,
1603
- count: matches.length,
1604
- query: query
1605
- });
1606
-
1607
- } catch (error) {
1608
- logger.log('ERROR', `[searchPopularInvestors] Error searching PIs`, error);
1609
- return res.status(500).json({ error: error.message });
1610
- }
1611
- }
1612
-
1613
- /**
1614
- * POST /user/requests/pi-addition
1615
- * Request to add a Popular Investor to the database
1616
- */
1617
- async function requestPiAddition(req, res, dependencies, config) {
1618
- const { db, logger } = dependencies;
1619
- const { userCid, username, piUsername, piCid } = req.body;
1620
-
1621
- if (!userCid || !username || !piUsername) {
1622
- return res.status(400).json({ error: "Missing required fields: userCid, username, piUsername" });
1623
- }
1624
-
1625
- try {
1626
- const requestsCollection = config.requestsCollection || 'requests';
1627
- const requestId = `pi_add_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1628
-
1629
- const requestData = {
1630
- id: requestId,
1631
- type: 'popular_investor_addition',
1632
- requestedBy: {
1633
- userCid: Number(userCid),
1634
- username: username
1635
- },
1636
- popularInvestor: {
1637
- username: piUsername,
1638
- cid: piCid || null
1639
- },
1640
- status: 'pending',
1641
- requestedAt: FieldValue.serverTimestamp(),
1642
- processedAt: null,
1643
- notes: null
1644
- };
1645
-
1646
- const requestRef = db.collection(requestsCollection)
1647
- .doc('popular_investor_copy_additions')
1648
- .collection('requests')
1649
- .doc(requestId);
1650
-
1651
- await requestRef.set(requestData);
1652
-
1653
- logger.log('SUCCESS', `[requestPiAddition] User ${userCid} requested addition of PI ${piUsername} (CID: ${piCid || 'unknown'})`);
1654
-
1655
- return res.status(201).json({
1656
- success: true,
1657
- requestId: requestId,
1658
- message: "Request submitted successfully"
1659
- });
1660
-
1661
- } catch (error) {
1662
- logger.log('ERROR', `[requestPiAddition] Error submitting request`, error);
1663
- return res.status(500).json({ error: error.message });
1664
- }
1665
- }
1666
-
1667
- /**
1668
- * GET /user/me/watchlists/:id/trigger-counts
1669
- * Get alert trigger counts for PIs in a watchlist (last 7 days)
1670
- */
1671
- async function getWatchlistTriggerCounts(req, res, dependencies, config) {
1672
- const { db, logger } = dependencies;
1673
- const { userCid } = req.query;
1674
- const { id } = req.params;
1675
-
1676
- if (!userCid || !id) {
1677
- return res.status(400).json({ error: "Missing userCid or watchlist id" });
1678
- }
1679
-
1680
- try {
1681
- const watchlistsCollection = config.watchlistsCollection || 'watchlists';
1682
- const watchlistRef = db.collection(watchlistsCollection)
1683
- .doc(String(userCid))
1684
- .collection('lists')
1685
- .doc(id);
1686
-
1687
- const watchlistDoc = await watchlistRef.get();
1688
-
1689
- if (!watchlistDoc.exists) {
1690
- return res.status(404).json({ error: "Watchlist not found" });
1691
- }
1692
-
1693
- const watchlistData = watchlistDoc.data();
1694
- const items = watchlistData.items || [];
1695
-
1696
- // Calculate date 7 days ago (use Firestore Timestamp)
1697
- const { Timestamp } = require('@google-cloud/firestore');
1698
- const sevenDaysAgo = new Date();
1699
- sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
1700
- const cutoffTimestamp = Timestamp.fromDate(sevenDaysAgo);
1701
-
1702
- const triggerCounts = {};
1703
- const alertTriggersCollection = config.alertTriggersCollection || 'alert_triggers';
1704
-
1705
- // For each PI in the watchlist, count triggers in last 7 days
1706
- for (const item of items) {
1707
- const piCid = item.cid;
1708
- const triggersRef = db.collection(alertTriggersCollection)
1709
- .doc(String(userCid))
1710
- .collection('triggers')
1711
- .where('piCid', '==', piCid)
1712
- .where('triggeredAt', '>', cutoffTimestamp);
1713
-
1714
- const triggersSnapshot = await triggersRef.get();
1715
- triggerCounts[piCid] = triggersSnapshot.size;
1716
- }
1717
-
1718
- return res.status(200).json({
1719
- triggerCounts,
1720
- watchlistId: id,
1721
- period: '7days'
1722
- });
1723
-
1724
- } catch (error) {
1725
- logger.log('ERROR', `[getWatchlistTriggerCounts] Error fetching trigger counts`, error);
1726
- return res.status(500).json({ error: error.message });
1727
- }
1728
- }
1729
-
1730
- /**
1731
- * GET /user/me/watchlists/:id/rankings-check
1732
- * Check which PIs in a watchlist are in the latest available rankings
1733
- */
1734
- async function checkPisInRankings(req, res, dependencies, config) {
1735
- const { db, logger } = dependencies;
1736
- const { userCid } = req.query;
1737
- const { id } = req.params;
1738
-
1739
- if (!userCid || !id) {
1740
- return res.status(400).json({ error: "Missing userCid or watchlist id" });
1741
- }
1742
-
1743
- try {
1744
- const watchlistsCollection = config.watchlistsCollection || 'watchlists';
1745
- const watchlistRef = db.collection(watchlistsCollection)
1746
- .doc(String(userCid))
1747
- .collection('lists')
1748
- .doc(id);
1749
-
1750
- const watchlistDoc = await watchlistRef.get();
1751
-
1752
- if (!watchlistDoc.exists) {
1753
- return res.status(404).json({ error: "Watchlist not found" });
1754
- }
1755
-
1756
- const watchlistData = watchlistDoc.data();
1757
- const items = watchlistData.items || [];
1758
-
1759
- // Find latest available rankings date (with fallback)
1760
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
1761
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
1762
-
1763
- if (!rankingsDate) {
1764
- // No rankings data available, assume all PIs are not in rankings
1765
- const notInRankings = items.map(item => item.cid);
1766
- return res.status(200).json({
1767
- notInRankings,
1768
- rankingsDate: null,
1769
- message: "No rankings data available"
1770
- });
1771
- }
1772
-
1773
- // Fetch rankings data from latest available date
1774
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
1775
- const rankingsDoc = await rankingsRef.get();
1776
-
1777
- if (!rankingsDoc.exists) {
1778
- const notInRankings = items.map(item => item.cid);
1779
- return res.status(200).json({
1780
- notInRankings,
1781
- rankingsDate: null,
1782
- message: "Rankings document not found"
1783
- });
1784
- }
1785
-
1786
- const rankingsData = rankingsDoc.data();
1787
- const rankingsItems = rankingsData.Items || [];
1788
-
1789
- // Create a set of CIDs that exist in rankings
1790
- const rankingsCIDs = new Set();
1791
- for (const item of rankingsItems) {
1792
- if (item.CustomerId) {
1793
- rankingsCIDs.add(String(item.CustomerId));
1794
- }
1795
- }
1796
-
1797
- // Check which watchlist PIs are NOT in rankings
1798
- const notInRankings = [];
1799
- for (const item of items) {
1800
- const cidStr = String(item.cid);
1801
- if (!rankingsCIDs.has(cidStr)) {
1802
- notInRankings.push(item.cid);
1803
- }
1804
- }
1805
-
1806
- return res.status(200).json({
1807
- notInRankings,
1808
- rankingsDate: rankingsDate,
1809
- totalChecked: items.length,
1810
- inRankings: items.length - notInRankings.length
1811
- });
1812
-
1813
- } catch (error) {
1814
- logger.log('ERROR', `[checkPisInRankings] Error checking PIs in rankings`, error);
1815
- return res.status(500).json({ error: error.message });
1816
- }
1817
- }
1818
-
1819
- /**
1820
- * GET /pi/:cid/profile
1821
- * Fetches Popular Investor profile data from computation
1822
- * Falls back to latest available date if today's data doesn't exist
1823
- */
1824
- /**
1825
- * Helper function to fetch and check if a specific CID exists in a computation document for a given date
1826
- * Returns { found: boolean, profileData: object | null, computationData: object | null }
1827
- */
1828
- async function checkPiInComputationDate(db, insightsCollection, resultsSub, compsSub, category, computationName, dateStr, cidStr, logger) {
1829
- try {
1830
- const computationRef = db.collection(insightsCollection)
1831
- .doc(dateStr)
1832
- .collection(resultsSub)
1833
- .doc(category)
1834
- .collection(compsSub)
1835
- .doc(computationName);
1836
-
1837
- const computationDoc = await computationRef.get();
1838
-
1839
- if (!computationDoc.exists) {
1840
- return { found: false, profileData: null, computationData: null };
1841
- }
1842
-
1843
- const rawData = computationDoc.data();
1844
- let computationData = null;
1845
-
1846
- // Check if data is sharded
1847
- if (rawData._sharded === true && rawData._shardCount) {
1848
- // Data is stored in shards - read all shards and merge
1849
- const shardsCol = computationRef.collection('_shards');
1850
- const shardCount = rawData._shardCount;
1851
-
1852
- logger.log('INFO', `[checkPiInComputationDate] Reading ${shardCount} shards for date ${dateStr}`);
1853
-
1854
- computationData = {};
1855
-
1856
- // Read all shards (shard_0, shard_1, ..., shard_N-1)
1857
- for (let i = 0; i < shardCount; i++) {
1858
- const shardDoc = await shardsCol.doc(`shard_${i}`).get();
1859
- if (shardDoc.exists) {
1860
- const shardData = shardDoc.data();
1861
- // Merge shard data into computationData
1862
- Object.assign(computationData, shardData);
1863
- } else {
1864
- logger.log('WARN', `[checkPiInComputationDate] Shard shard_${i} missing for date ${dateStr}`);
1865
- }
1866
- }
1867
- } else {
1868
- // Data is in the main document (compressed or raw)
1869
- computationData = tryDecompress(rawData);
1870
-
1871
- // Handle string decompression result
1872
- if (typeof computationData === 'string') {
1873
- try {
1874
- computationData = JSON.parse(computationData);
1875
- } catch (e) {
1876
- logger.log('WARN', `[checkPiInComputationDate] Failed to parse decompressed string for date ${dateStr}:`, e.message);
1877
- return { found: false, profileData: null, computationData: null };
1878
- }
1879
- }
1880
- }
1881
-
1882
- // Check if CID exists in the computation data
1883
- if (computationData && typeof computationData === 'object' && !Array.isArray(computationData)) {
1884
- // Filter out metadata keys that start with underscore
1885
- const cids = Object.keys(computationData).filter(key => !key.startsWith('_'));
1886
-
1887
- // Check if the requested CID exists
1888
- const profileData = computationData[cidStr];
1889
- if (profileData) {
1890
- logger.log('INFO', `[checkPiInComputationDate] Found CID ${cidStr} in date ${dateStr} (total CIDs: ${cids.length})`);
1891
- return { found: true, profileData, computationData };
1892
- } else {
1893
- logger.log('INFO', `[checkPiInComputationDate] CID ${cidStr} not found in date ${dateStr}. Available CIDs: ${cids.slice(0, 10).join(', ')}${cids.length > 10 ? '...' : ''}`);
1894
- }
1895
- }
1896
-
1897
- return { found: false, profileData: null, computationData };
1898
-
1899
- } catch (error) {
1900
- logger.log('WARN', `[checkPiInComputationDate] Error checking date ${dateStr}:`, error.message);
1901
- return { found: false, profileData: null, computationData: null };
1902
- }
1903
- }
1904
-
1905
- async function getPiProfile(req, res, dependencies, config) {
1906
- const { db, logger } = dependencies;
1907
- const { cid } = req.params;
1908
-
1909
- if (!cid) {
1910
- return res.status(400).json({ error: "Missing PI CID" });
1911
- }
1912
-
1913
- try {
1914
- const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
1915
- const resultsSub = config.resultsSubcollection || 'results';
1916
- const compsSub = config.computationsSubcollection || 'computations';
1917
- const computationName = 'PopularInvestorProfileMetrics';
1918
- const category = 'popular-investor'; // Use hyphen to match Firestore path
1919
- const today = new Date().toISOString().split('T')[0];
1920
- const cidStr = String(cid);
1921
- const maxDaysBackForPi = 7; // Maximum days to search back for a specific PI
1922
-
1923
- logger.log('INFO', `[getPiProfile] Starting search for PI CID: ${cid}`);
1924
-
1925
- // Find latest available computation date (just check if document exists, not CID)
1926
- const latestDate = await findLatestComputationDate(
1927
- db,
1928
- insightsCollection,
1929
- resultsSub,
1930
- compsSub,
1931
- category,
1932
- computationName,
1933
- null, // Don't pass CID - just find if document exists
1934
- 30
1935
- );
1936
-
1937
- logger.log('INFO', `[getPiProfile] Latest computation date found: ${latestDate || 'NONE'}`);
1938
-
1939
- if (!latestDate) {
1940
- logger.log('WARN', `[getPiProfile] No computation document found for ${computationName} in last 30 days`);
1941
- return res.status(404).json({
1942
- error: "Profile data not available",
1943
- message: "No computation results found for this Popular Investor"
1944
- });
1945
- }
1946
-
1947
- // Try to find the PI starting from the latest date, then going back up to 7 days
1948
- let foundDate = null;
1949
- let profileData = null;
1950
- let checkedDates = [];
1951
-
1952
- // Parse the latest date to calculate earlier dates
1953
- const latestDateObj = new Date(latestDate + 'T00:00:00Z');
1954
-
1955
- for (let daysBack = 0; daysBack <= maxDaysBackForPi; daysBack++) {
1956
- const checkDate = new Date(latestDateObj);
1957
- checkDate.setUTCDate(latestDateObj.getUTCDate() - daysBack);
1958
- const dateStr = checkDate.toISOString().split('T')[0];
1959
- checkedDates.push(dateStr);
1960
-
1961
- logger.log('INFO', `[getPiProfile] Checking date ${dateStr} for CID ${cid} (${daysBack} days back from latest)`);
1962
-
1963
- const result = await checkPiInComputationDate(
1964
- db,
1965
- insightsCollection,
1966
- resultsSub,
1967
- compsSub,
1968
- category,
1969
- computationName,
1970
- dateStr,
1971
- cidStr,
1972
- logger
1973
- );
1974
-
1975
- if (result.found) {
1976
- foundDate = dateStr;
1977
- profileData = result.profileData;
1978
- logger.log('SUCCESS', `[getPiProfile] Found profile data for CID ${cid} in date ${dateStr} (${daysBack} days back from latest)`);
1979
- break;
1980
- } else {
1981
- logger.log('INFO', `[getPiProfile] CID ${cid} not found in date ${dateStr}`);
1982
- }
1983
- }
1984
-
1985
- // If not found in any checked date, return 404
1986
- if (!foundDate || !profileData) {
1987
- logger.log('WARN', `[getPiProfile] CID ${cid} not found in any checked dates: ${checkedDates.join(', ')}`);
1988
-
1989
- // Try to get sample data from the latest date to show what CIDs are available
1990
- const latestResult = await checkPiInComputationDate(
1991
- db,
1992
- insightsCollection,
1993
- resultsSub,
1994
- compsSub,
1995
- category,
1996
- computationName,
1997
- latestDate,
1998
- cidStr,
1999
- logger
2000
- );
2001
-
2002
- // Filter out metadata keys (those starting with _) when listing available CIDs
2003
- const allAvailableCids = latestResult.computationData && typeof latestResult.computationData === 'object' && !Array.isArray(latestResult.computationData)
2004
- ? Object.keys(latestResult.computationData)
2005
- .filter(key => !key.startsWith('_'))
2006
- .sort()
2007
- : [];
2008
-
2009
- return res.status(404).json({
2010
- error: "Profile data not found",
2011
- message: `Popular Investor ${cid} does not exist in computation results for the last ${maxDaysBackForPi + 1} days. This PI may not have been processed recently.`,
2012
- debug: {
2013
- searchedCid: cidStr,
2014
- checkedDates: checkedDates,
2015
- totalCidsInLatestDocument: allAvailableCids.length,
2016
- sampleAvailableCids: allAvailableCids.slice(0, 20), // First 20 for reference
2017
- latestDate: latestDate
2018
- }
2019
- });
2020
- }
2021
-
2022
- // Get username from rankings
2023
- const { getPiUsername } = require('./on_demand_fetch_helpers');
2024
- const username = await getPiUsername(db, cid, config, logger);
2025
-
2026
- logger.log('SUCCESS', `[getPiProfile] Returning profile data for CID ${cid} from date ${foundDate} (requested: ${today}, latest available: ${latestDate})`);
2027
-
2028
- return res.status(200).json({
2029
- status: 'success',
2030
- cid: cidStr,
2031
- username: username || null,
2032
- data: profileData,
2033
- isFallback: foundDate !== latestDate || foundDate !== today,
2034
- dataDate: foundDate,
2035
- latestComputationDate: latestDate,
2036
- requestedDate: today,
2037
- daysBackFromLatest: checkedDates.indexOf(foundDate)
2038
- });
2039
-
2040
- } catch (error) {
2041
- logger.log('ERROR', `[getPiProfile] Error fetching PI profile for ${cid}`, error);
2042
- return res.status(500).json({ error: error.message });
2043
- }
2044
- }
2045
-
2046
- /**
2047
- * GET /user/me/is-popular-investor
2048
- * Check if signed-in user is also a Popular Investor
2049
- * Supports dev override impersonation
2050
- */
2051
- async function checkIfUserIsPopularInvestor(req, res, dependencies, config) {
2052
- const { db, logger } = dependencies;
2053
- const { userCid } = req.query;
2054
-
2055
- if (!userCid) {
2056
- return res.status(400).json({ error: "Missing userCid" });
2057
- }
2058
-
2059
- try {
2060
- // Check for dev override impersonation
2061
- const { getEffectiveCid, getDevOverride } = require('./dev_helpers');
2062
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
2063
- const devOverride = await getDevOverride(db, userCid, config, logger);
2064
- const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(userCid);
2065
-
2066
- // Use effective CID (impersonated or actual) to check PI status
2067
- const rankEntry = await checkIfUserIsPI(db, effectiveCid, config, logger);
2068
-
2069
- if (!rankEntry) {
2070
- return res.status(200).json({
2071
- isPopularInvestor: false,
2072
- rankingData: null,
2073
- isImpersonating: isImpersonating || false,
2074
- effectiveCid: effectiveCid
2075
- });
2076
- }
2077
-
2078
- // Check if this is a dev override (pretendToBePI)
2079
- const isDevOverride = devOverride && devOverride.enabled && devOverride.pretendToBePI;
2080
-
2081
- // Return ranking data
2082
- return res.status(200).json({
2083
- isPopularInvestor: true,
2084
- rankingData: {
2085
- cid: rankEntry.CustomerId,
2086
- username: rankEntry.UserName,
2087
- aum: rankEntry.AUMValue || 0,
2088
- copiers: rankEntry.Copiers || 0,
2089
- riskScore: rankEntry.RiskScore || 0,
2090
- gain: rankEntry.Gain || 0,
2091
- winRatio: rankEntry.WinRatio || 0,
2092
- trades: rankEntry.Trades || 0
2093
- },
2094
- isDevOverride: isDevOverride || false,
2095
- isImpersonating: isImpersonating || false,
2096
- effectiveCid: effectiveCid,
2097
- actualCid: Number(userCid)
2098
- });
2099
-
2100
- } catch (error) {
2101
- logger.log('ERROR', `[checkIfUserIsPopularInvestor] Error checking PI status for ${userCid}:`, error);
2102
- return res.status(500).json({ error: error.message });
2103
- }
2104
- }
2105
-
2106
- /**
2107
- * Track profile page view for a Popular Investor
2108
- * Stores view data for analytics
2109
- */
2110
- async function trackProfileView(req, res, dependencies, config) {
2111
- const { db, logger } = dependencies;
2112
- const { piCid } = req.params;
2113
- const { viewerCid, viewerType = 'anonymous' } = req.body; // viewerType: 'anonymous', 'signed_in', 'copier'
2114
-
2115
- if (!piCid) {
2116
- return res.status(400).json({ error: "Missing piCid" });
2117
- }
2118
-
2119
- try {
2120
- const { getCollectionPath, writeDual } = require('./collection_helpers');
2121
- const { collectionRegistry } = dependencies;
2122
- const profileViewsCollection = config.profileViewsCollection || 'profile_views';
2123
- const today = new Date().toISOString().split('T')[0];
2124
-
2125
- // Get existing document to merge unique viewers properly
2126
- let existingData = {};
2127
- let existingUniqueViewers = [];
2128
-
2129
- if (collectionRegistry) {
2130
- try {
2131
- const newPath = getCollectionPath(collectionRegistry, 'popularInvestors', 'profileViews', {
2132
- piCid: String(piCid)
2133
- }) + `/${today}`;
2134
-
2135
- const existingDoc = await db.doc(newPath).get();
2136
- if (existingDoc.exists) {
2137
- existingData = existingDoc.data();
2138
- existingUniqueViewers = Array.isArray(existingData.uniqueViewers) ? existingData.uniqueViewers : [];
2139
- }
2140
- } catch (newError) {
2141
- // Continue to legacy check
2142
- }
2143
- }
2144
-
2145
- // Fallback to legacy check
2146
- let viewDocId = `${piCid}_${today}`;
2147
- let viewRef = null;
2148
-
2149
- if (existingUniqueViewers.length === 0) {
2150
- viewRef = db.collection(profileViewsCollection).doc(viewDocId);
2151
- const existingDoc = await viewRef.get();
2152
- if (existingDoc.exists) {
2153
- existingData = existingDoc.data();
2154
- existingUniqueViewers = Array.isArray(existingData.uniqueViewers) ? existingData.uniqueViewers : [];
2155
- }
2156
- }
2157
-
2158
- // Add new viewer if provided and not already in list
2159
- const updatedUniqueViewers = viewerCid && !existingUniqueViewers.includes(String(viewerCid))
2160
- ? [...existingUniqueViewers, String(viewerCid)]
2161
- : existingUniqueViewers;
2162
-
2163
- const viewData = {
2164
- piCid: Number(piCid),
2165
- date: today,
2166
- totalViews: FieldValue.increment(1),
2167
- uniqueViewers: updatedUniqueViewers, // Store as array, not using arrayUnion to avoid duplicates
2168
- lastUpdated: FieldValue.serverTimestamp()
2169
- };
2170
-
2171
- // Write to both new and legacy structures
2172
- if (collectionRegistry) {
2173
- const newPath = getCollectionPath(collectionRegistry, 'popularInvestors', 'profileViews', {
2174
- piCid: String(piCid)
2175
- }) + `/${today}`;
2176
-
2177
- const legacyPath = `${profileViewsCollection}/${viewDocId}`;
2178
-
2179
- await writeDual(db, newPath, legacyPath, viewData, { isCollection: false, merge: true });
2180
- } else {
2181
- if (!viewRef) {
2182
- viewRef = db.collection(profileViewsCollection).doc(viewDocId);
2183
- }
2184
- await viewRef.set(viewData, { merge: true });
2185
- }
2186
-
2187
- // Also track individual view for detailed analytics (optional, lightweight)
2188
- if (viewerCid) {
2189
- const individualViewId = `${piCid}_${viewerCid}_${Date.now()}`;
2190
- const individualViewData = {
2191
- piCid: Number(piCid),
2192
- viewerCid: Number(viewerCid),
2193
- viewerType: viewerType,
2194
- viewedAt: FieldValue.serverTimestamp(),
2195
- date: today
2196
- };
2197
-
2198
- if (collectionRegistry) {
2199
- const newViewPath = getCollectionPath(collectionRegistry, 'popularInvestors', 'individualViews', {
2200
- piCid: String(piCid)
2201
- }) + `/${individualViewId}`;
2202
-
2203
- const legacyViewPath = `${profileViewsCollection}/individual_views/views/${individualViewId}`;
2204
-
2205
- await writeDual(db, newViewPath, legacyViewPath, individualViewData);
2206
- } else {
2207
- await db.collection(profileViewsCollection)
2208
- .doc('individual_views')
2209
- .collection('views')
2210
- .doc(individualViewId)
2211
- .set(individualViewData, { merge: true });
2212
- }
2213
- }
2214
-
2215
- return res.status(200).json({ success: true, message: "View tracked" });
2216
-
2217
- } catch (error) {
2218
- logger.log('ERROR', `[trackProfileView] Error tracking view for PI ${piCid}:`, error);
2219
- // Don't fail the request if tracking fails
2220
- return res.status(200).json({ success: false, message: "View tracking failed but request succeeded" });
2221
- }
2222
- }
2223
-
2224
- /**
2225
- * Generate sample PI personalized metrics data for dev testing
2226
- * Creates realistic sample data that matches the computation structure 1:1
2227
- */
2228
- function generateSamplePIPersonalizedMetrics(userCid, rankEntry) {
2229
- const today = new Date().toISOString().split('T')[0];
2230
- const yesterday = new Date();
2231
- yesterday.setDate(yesterday.getDate() - 1);
2232
- const yesterdayStr = yesterday.toISOString().split('T')[0];
2233
-
2234
- // Generate sample base metrics (from PopularInvestorProfileMetrics)
2235
- const baseMetrics = {
2236
- socialEngagement: {
2237
- chartType: 'line',
2238
- data: Array.from({ length: 30 }, (_, i) => {
2239
- const date = new Date();
2240
- date.setDate(date.getDate() - (29 - i));
2241
- return {
2242
- date: date.toISOString().split('T')[0],
2243
- likes: Math.floor(Math.random() * 50) + 10,
2244
- comments: Math.floor(Math.random() * 20) + 5
2245
- };
2246
- })
2247
- },
2248
- profitablePositions: {
2249
- chartType: 'bar',
2250
- data: Array.from({ length: 30 }, (_, i) => {
2251
- const date = new Date();
2252
- date.setDate(date.getDate() - (29 - i));
2253
- return {
2254
- date: date.toISOString().split('T')[0],
2255
- profitableCount: Math.floor(Math.random() * 10) + 5,
2256
- totalCount: Math.floor(Math.random() * 15) + 10
2257
- };
2258
- })
2259
- },
2260
- topWinningPositions: {
2261
- chartType: 'table',
2262
- data: Array.from({ length: 10 }, (_, i) => ({
2263
- instrumentId: 1000 + i,
2264
- ticker: ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'NFLX', 'AMD', 'INTC'][i],
2265
- netProfit: Math.floor(Math.random() * 100) + 20,
2266
- invested: Math.floor(Math.random() * 5000) + 1000,
2267
- value: Math.floor(Math.random() * 6000) + 1000,
2268
- isCurrent: Math.random() > 0.5,
2269
- closeDate: Math.random() > 0.5 ? new Date().toISOString() : null
2270
- }))
2271
- },
2272
- sectorPerformance: {
2273
- bestSector: 'Technology',
2274
- worstSector: 'Energy',
2275
- bestSectorProfit: 15.5,
2276
- worstSectorProfit: -3.2
2277
- },
2278
- sectorExposure: {
2279
- chartType: 'pie',
2280
- data: {
2281
- 'Technology': 35.5,
2282
- 'Healthcare': 20.3,
2283
- 'Finance': 15.2,
2284
- 'Energy': 10.1,
2285
- 'Consumer': 18.9
2286
- }
2287
- },
2288
- assetExposure: {
2289
- chartType: 'pie',
2290
- data: {
2291
- 'AAPL': 12.5,
2292
- 'MSFT': 10.3,
2293
- 'GOOGL': 8.7,
2294
- 'AMZN': 7.2,
2295
- 'TSLA': 6.1
2296
- }
2297
- },
2298
- portfolioSummary: {
2299
- totalInvested: 850000,
2300
- totalProfit: 125000,
2301
- profitPercent: 14.7
2302
- },
2303
- rankingsData: {
2304
- aum: rankEntry.AUMValue || 0,
2305
- riskScore: rankEntry.RiskScore || 0,
2306
- gain: rankEntry.Gain || 0,
2307
- copiers: rankEntry.Copiers || 0,
2308
- winRatio: rankEntry.WinRatio || 0,
2309
- trades: rankEntry.Trades || 0
2310
- }
2311
- };
2312
-
2313
- // Generate sample review metrics
2314
- const reviewMetricsOverTime = Array.from({ length: 30 }, (_, i) => {
2315
- const date = new Date();
2316
- date.setDate(date.getDate() - (29 - i));
2317
- return {
2318
- date: date.toISOString().split('T')[0],
2319
- averageRating: Number((3.5 + Math.random() * 1.5).toFixed(2)),
2320
- count: Math.floor(Math.random() * 5) + 1
2321
- };
2322
- });
2323
-
2324
- const totalReviews = reviewMetricsOverTime.reduce((sum, r) => sum + r.count, 0);
2325
- const avgRating = reviewMetricsOverTime.reduce((sum, r) => sum + (r.averageRating * r.count), 0) / totalReviews;
2326
-
2327
- // Generate sample similar PIs
2328
- const similarPIs = Array.from({ length: 5 }, (_, i) => ({
2329
- cid: 1000000 + i,
2330
- username: `SimilarPI${i + 1}`,
2331
- similarityScore: Number((85 - (i * 5)).toFixed(2)),
2332
- aum: Math.floor(Math.random() * 500000) + 300000,
2333
- copiers: Math.floor(Math.random() * 200) + 100,
2334
- riskScore: Math.floor(Math.random() * 3) + 2,
2335
- gain: Number((20 + Math.random() * 30).toFixed(2))
2336
- }));
2337
-
2338
- // Calculate leaderboard position (sample)
2339
- const totalPIs = 1000;
2340
- const currentRank = Math.floor(Math.random() * 200) + 50; // Rank 50-250
2341
- const percentile = Number((100 - ((currentRank / totalPIs) * 100)).toFixed(2));
2342
-
2343
- // Generate sample asset investments
2344
- const topAssets = Array.from({ length: 10 }, (_, i) => ({
2345
- instrumentId: 1000 + i,
2346
- ticker: ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'NFLX', 'AMD', 'INTC'][i],
2347
- aum: Math.floor(Math.random() * 100000) + 50000,
2348
- percentage: Number((Math.random() * 15 + 5).toFixed(2))
2349
- }));
2350
-
2351
- // Generate sample profile views
2352
- const profileViewsOverTime = Array.from({ length: 30 }, (_, i) => {
2353
- const date = new Date();
2354
- date.setDate(date.getDate() - (29 - i));
2355
- return {
2356
- date: date.toISOString().split('T')[0],
2357
- views: Math.floor(Math.random() * 50) + 10,
2358
- uniqueViews: Math.floor(Math.random() * 30) + 5
2359
- };
2360
- });
2361
-
2362
- const totalViews = profileViewsOverTime.reduce((sum, v) => sum + v.views, 0);
2363
- const totalUniqueViews = new Set(profileViewsOverTime.flatMap(v => Array.from({ length: v.uniqueViews }, (_, i) => `viewer_${i}`))).size;
2364
- const averageDailyViews = Number((totalViews / 30).toFixed(2));
2365
-
2366
- // Calculate copier growth
2367
- const copiersToday = rankEntry.Copiers || 250;
2368
- const copiersYesterday = copiersToday - Math.floor(Math.random() * 20) + 5; // Random change
2369
- const diff = copiersToday - copiersYesterday;
2370
- const growthRate = copiersYesterday > 0 ? Number(((diff / copiersYesterday) * 100).toFixed(2)) : 0;
2371
- const trend = diff > 0 ? 'increasing' : diff < 0 ? 'decreasing' : 'stable';
2372
-
2373
- // Calculate engagement score
2374
- const reviewScore = avgRating * 20; // 0-100 scale
2375
- const viewScore = Math.min(100, (averageDailyViews / 10) * 100);
2376
- const copierScore = Math.min(100, (copiersToday / 1000) * 100);
2377
- const socialScore = Math.min(100, (baseMetrics.socialEngagement.data.length / 10) * 100);
2378
- const engagementScore = (reviewScore * 0.3 + viewScore * 0.2 + copierScore * 0.3 + socialScore * 0.2);
2379
-
2380
- return {
2381
- baseMetrics: baseMetrics,
2382
- reviewMetrics: {
2383
- overTime: reviewMetricsOverTime,
2384
- currentStats: {
2385
- averageRating: Number(avgRating.toFixed(2)),
2386
- totalReviews: totalReviews,
2387
- distribution: {
2388
- 1: Math.floor(totalReviews * 0.1),
2389
- 2: Math.floor(totalReviews * 0.15),
2390
- 3: Math.floor(totalReviews * 0.25),
2391
- 4: Math.floor(totalReviews * 0.3),
2392
- 5: Math.floor(totalReviews * 0.2)
2393
- }
2394
- },
2395
- sentimentTrend: []
2396
- },
2397
- similarPIs: similarPIs,
2398
- leaderboardPosition: {
2399
- currentRank: currentRank,
2400
- totalPIs: totalPIs,
2401
- percentile: percentile,
2402
- rankByAUM: currentRank + Math.floor(Math.random() * 50) - 25,
2403
- rankByCopiers: currentRank
2404
- },
2405
- assetInvestments: {
2406
- byAUM: topAssets.reduce((acc, asset) => {
2407
- acc[asset.instrumentId] = asset.aum;
2408
- return acc;
2409
- }, {}),
2410
- topAssets: topAssets
2411
- },
2412
- profileViews: {
2413
- overTime: profileViewsOverTime,
2414
- totalViews: totalViews,
2415
- totalUniqueViews: totalUniqueViews,
2416
- averageDailyViews: averageDailyViews
2417
- },
2418
- copierGrowth: {
2419
- overTime: [
2420
- { date: yesterdayStr, copiers: copiersYesterday },
2421
- { date: today, copiers: copiersToday }
2422
- ],
2423
- growthRate: growthRate,
2424
- diff: diff,
2425
- trend: trend
2426
- },
2427
- engagementScore: {
2428
- current: Number(engagementScore.toFixed(2)),
2429
- components: {
2430
- reviewScore: Number(reviewScore.toFixed(2)),
2431
- viewScore: Number(viewScore.toFixed(2)),
2432
- copierScore: Number(copierScore.toFixed(2)),
2433
- socialScore: Number(socialScore.toFixed(2))
2434
- }
2435
- }
2436
- };
2437
- }
2438
-
2439
- /**
2440
- * GET /user/me/pi-personalized-metrics
2441
- * Get personalized metrics for signed-in user who is a Popular Investor
2442
- * Includes review metrics, page views, similar PIs, leaderboard position, etc.
2443
- */
2444
- async function getSignedInUserPIPersonalizedMetrics(req, res, dependencies, config) {
2445
- const { db, logger } = dependencies;
2446
- const { userCid } = req.query;
2447
-
2448
- if (!userCid) {
2449
- return res.status(400).json({ error: "Missing userCid" });
2450
- }
2451
-
2452
- try {
2453
- // Check for dev override impersonation
2454
- const { getEffectiveCid, getDevOverride } = require('./dev_helpers');
2455
- const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
2456
- const devOverride = await getDevOverride(db, userCid, config, logger);
2457
- const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(userCid);
2458
-
2459
- // Use effective CID (impersonated or actual) to check PI status
2460
- const rankEntry = await checkIfUserIsPI(db, effectiveCid, config, logger);
2461
- if (!rankEntry) {
2462
- return res.status(404).json({
2463
- error: "Not a Popular Investor",
2464
- message: "This endpoint is only available for users who are Popular Investors",
2465
- effectiveCid: effectiveCid,
2466
- isImpersonating: isImpersonating || false
2467
- });
2468
- }
2469
-
2470
- // Check if this is a dev override (pretendToBePI)
2471
- const isDevOverride = devOverride && devOverride.enabled && devOverride.pretendToBePI;
2472
-
2473
- // If dev override (pretendToBePI), generate sample data
2474
- if (isDevOverride) {
2475
- logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] DEV OVERRIDE: Generating sample PI metrics for effective CID ${effectiveCid}`);
2476
- const sampleData = generateSamplePIPersonalizedMetrics(effectiveCid, rankEntry);
2477
- const today = new Date().toISOString().split('T')[0];
2478
-
2479
- return res.status(200).json({
2480
- status: 'success',
2481
- userCid: String(effectiveCid),
2482
- data: sampleData,
2483
- dataDate: today,
2484
- requestedDate: today,
2485
- isFallback: false,
2486
- isDevOverride: true,
2487
- isImpersonating: isImpersonating || false,
2488
- actualCid: Number(userCid)
2489
- });
2490
- }
2491
-
2492
- const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
2493
- const resultsSub = config.resultsSubcollection || 'results';
2494
- const compsSub = config.computationsSubcollection || 'computations';
2495
- const category = 'popular-investor';
2496
- const computationName = 'SignedInUserPIPersonalizedMetrics';
2497
- const today = new Date().toISOString().split('T')[0];
2498
-
2499
- // Try to find computation with user-specific fallback logic
2500
- // Use effectiveCid (impersonated or actual) for computation lookup
2501
- let foundDate = null;
2502
- let metricsData = null;
2503
- let checkedDates = [];
2504
-
2505
- // Step 1: Try today, then look back 30 days for computation document
2506
- const latestComputationDate = await findLatestComputationDate(
2507
- db,
2508
- insightsCollection,
2509
- resultsSub,
2510
- compsSub,
2511
- category,
2512
- computationName,
2513
- null, // Don't check for specific user yet
2514
- 30
2515
- );
2516
-
2517
- if (!latestComputationDate) {
2518
- // No computation exists at all - will fallback to frontend
2519
- logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] No computation document found, will use frontend fallback`);
2520
- return res.status(200).json({
2521
- status: 'fallback',
2522
- message: 'Computation not available, use frontend fallback',
2523
- data: null,
2524
- useFrontendFallback: true,
2525
- effectiveCid: effectiveCid,
2526
- isImpersonating: isImpersonating || false
2527
- });
2528
- }
2529
-
2530
- // Step 2: Check if user exists in latest computation, if not, look back 7 days
2531
- // Use effectiveCid for lookup
2532
- const latestDateObj = new Date(latestComputationDate + 'T00:00:00Z');
2533
-
2534
- for (let daysBack = 0; daysBack <= 7; daysBack++) {
2535
- const checkDate = new Date(latestDateObj);
2536
- checkDate.setUTCDate(latestDateObj.getUTCDate() - daysBack);
2537
- const dateStr = checkDate.toISOString().split('T')[0];
2538
- checkedDates.push(dateStr);
2539
-
2540
- const computationRef = db.collection(insightsCollection)
2541
- .doc(dateStr)
2542
- .collection(resultsSub)
2543
- .doc(category)
2544
- .collection(compsSub)
2545
- .doc(computationName);
2546
-
2547
- const computationDoc = await computationRef.get();
2548
-
2549
- if (computationDoc.exists) {
2550
- const rawData = computationDoc.data();
2551
- let computationData = tryDecompress(rawData);
2552
-
2553
- if (typeof computationData === 'string') {
2554
- try {
2555
- computationData = JSON.parse(computationData);
2556
- } catch (e) {
2557
- continue;
2558
- }
2559
- }
2560
-
2561
- // Check if user exists in this computation (use effectiveCid)
2562
- if (computationData && typeof computationData === 'object' && !Array.isArray(computationData)) {
2563
- const userData = computationData[String(effectiveCid)];
2564
- if (userData) {
2565
- foundDate = dateStr;
2566
- metricsData = userData;
2567
- logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] Found effective CID ${effectiveCid} in computation date ${dateStr}`);
2568
- break;
2569
- }
2570
- }
2571
- }
2572
- }
2573
-
2574
- // Step 3: If user not found in any computation, return fallback flag
2575
- if (!foundDate || !metricsData) {
2576
- logger.log('INFO', `[getSignedInUserPIPersonalizedMetrics] Effective CID ${effectiveCid} not found in any computation, will use frontend fallback`);
2577
- return res.status(200).json({
2578
- status: 'fallback',
2579
- message: 'User not found in computation, use frontend fallback',
2580
- data: null,
2581
- useFrontendFallback: true,
2582
- checkedDates: checkedDates,
2583
- effectiveCid: effectiveCid,
2584
- isImpersonating: isImpersonating || false
2585
- });
2586
- }
2587
-
2588
- // Step 4: Enhance with review metrics and page views from Firestore
2589
- // These require cross-collection queries that aren't in computation
2590
- // Use effectiveCid for queries
2591
-
2592
- // Fetch review metrics over time
2593
- const reviewsCollection = config.reviewsCollection || 'pi_reviews';
2594
- const reviewsSnapshot = await db.collection(reviewsCollection)
2595
- .where('piCid', '==', Number(effectiveCid))
2596
- .orderBy('createdAt', 'desc')
2597
- .limit(1000) // Get enough for time series
2598
- .get();
2599
-
2600
- const reviewTimeMap = new Map();
2601
- let totalReviews = 0;
2602
- let totalRating = 0;
2603
- const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
2604
-
2605
- reviewsSnapshot.forEach(doc => {
2606
- const review = doc.data();
2607
- const rating = review.rating || 0;
2608
- totalReviews++;
2609
- totalRating += rating;
2610
- ratingDistribution[rating] = (ratingDistribution[rating] || 0) + 1;
2611
-
2612
- if (review.createdAt) {
2613
- const reviewDate = review.createdAt.toDate ? review.createdAt.toDate().toISOString().split('T')[0] : review.createdAt;
2614
- const existing = reviewTimeMap.get(reviewDate) || { date: reviewDate, count: 0, totalRating: 0 };
2615
- existing.count++;
2616
- existing.totalRating += rating;
2617
- reviewTimeMap.set(reviewDate, existing);
2618
- }
2619
- });
2620
-
2621
- const reviewMetricsOverTime = Array.from(reviewTimeMap.values())
2622
- .map(entry => ({
2623
- date: entry.date,
2624
- averageRating: entry.count > 0 ? Number((entry.totalRating / entry.count).toFixed(2)) : 0,
2625
- count: entry.count
2626
- }))
2627
- .sort((a, b) => a.date.localeCompare(b.date));
2628
-
2629
- metricsData.reviewMetrics = {
2630
- overTime: reviewMetricsOverTime,
2631
- currentStats: {
2632
- averageRating: totalReviews > 0 ? Number((totalRating / totalReviews).toFixed(2)) : 0,
2633
- totalReviews: totalReviews,
2634
- distribution: ratingDistribution
2635
- },
2636
- sentimentTrend: [] // Could analyze review text sentiment if needed
2637
- };
2638
-
2639
- // Fetch profile views over time
2640
- const profileViewsCollection = config.profileViewsCollection || 'profile_views';
2641
- // Query without orderBy first, then sort in memory (to avoid index requirement)
2642
- // Use effectiveCid for queries
2643
- const viewsSnapshot = await db.collection(profileViewsCollection)
2644
- .where('piCid', '==', Number(effectiveCid))
2645
- .limit(90) // Last 90 days worth of documents
2646
- .get();
2647
-
2648
- const viewsOverTime = [];
2649
- let totalViews = 0;
2650
- let totalUniqueViews = 0;
2651
- const uniqueViewersSet = new Set();
2652
- const viewsByDate = new Map();
2653
-
2654
- viewsSnapshot.forEach(doc => {
2655
- const viewData = doc.data();
2656
- const date = viewData.date;
2657
- if (!date) return;
2658
-
2659
- const views = typeof viewData.totalViews === 'number' ? viewData.totalViews : 0;
2660
- const uniqueViewers = Array.isArray(viewData.uniqueViewers) ? viewData.uniqueViewers : [];
2661
-
2662
- // Aggregate by date (in case there are multiple docs per date)
2663
- const existing = viewsByDate.get(date) || { date, views: 0, uniqueViewers: [] };
2664
- existing.views += views;
2665
- existing.uniqueViewers = [...new Set([...existing.uniqueViewers, ...uniqueViewers])];
2666
- viewsByDate.set(date, existing);
2667
- });
2668
-
2669
- // Convert map to array and calculate totals
2670
- for (const [date, data] of viewsByDate.entries()) {
2671
- totalViews += data.views;
2672
- data.uniqueViewers.forEach(v => uniqueViewersSet.add(v));
2673
-
2674
- viewsOverTime.push({
2675
- date: date,
2676
- views: data.views,
2677
- uniqueViews: data.uniqueViewers.length
2678
- });
2679
- }
2680
-
2681
- totalUniqueViews = uniqueViewersSet.size;
2682
- const averageDailyViews = viewsOverTime.length > 0 ? Number((totalViews / viewsOverTime.length).toFixed(2)) : 0;
2683
-
2684
- metricsData.profileViews = {
2685
- overTime: viewsOverTime.sort((a, b) => a.date.localeCompare(b.date)),
2686
- totalViews: totalViews,
2687
- totalUniqueViews: totalUniqueViews,
2688
- averageDailyViews: averageDailyViews
2689
- };
2690
-
2691
- // Calculate engagement score
2692
- const reviewScore = metricsData.reviewMetrics.currentStats.averageRating * 20; // 0-100 scale
2693
- const viewScore = Math.min(100, (averageDailyViews / 10) * 100); // Cap at 100 for 10+ daily views
2694
- const copierScore = rankEntry.Copiers ? Math.min(100, (rankEntry.Copiers / 1000) * 100) : 0; // Cap at 100 for 1000+ copiers
2695
- const socialScore = metricsData.baseMetrics?.socialEngagement?.data?.length > 0 ? Math.min(100, (metricsData.baseMetrics.socialEngagement.data.length / 10) * 100) : 0;
2696
-
2697
- const engagementScore = (reviewScore * 0.3 + viewScore * 0.2 + copierScore * 0.3 + socialScore * 0.2);
2698
-
2699
- metricsData.engagementScore = {
2700
- current: Number(engagementScore.toFixed(2)),
2701
- components: {
2702
- reviewScore: Number(reviewScore.toFixed(2)),
2703
- viewScore: Number(viewScore.toFixed(2)),
2704
- copierScore: Number(copierScore.toFixed(2)),
2705
- socialScore: Number(socialScore.toFixed(2))
2706
- }
2707
- };
2708
-
2709
- logger.log('SUCCESS', `[getSignedInUserPIPersonalizedMetrics] Returning personalized metrics for effective CID ${effectiveCid} from date ${foundDate}`);
2710
-
2711
- return res.status(200).json({
2712
- status: 'success',
2713
- userCid: String(effectiveCid),
2714
- data: metricsData,
2715
- dataDate: foundDate,
2716
- requestedDate: today,
2717
- isFallback: foundDate !== today,
2718
- daysBackFromLatest: checkedDates.indexOf(foundDate),
2719
- isImpersonating: isImpersonating || false,
2720
- actualCid: Number(userCid)
2721
- });
2722
-
2723
- } catch (error) {
2724
- logger.log('ERROR', `[getSignedInUserPIPersonalizedMetrics] Error fetching personalized metrics for ${userCid}:`, error);
2725
- return res.status(500).json({ error: error.message });
2726
- }
2727
- }
2728
-
2
+ * @fileoverview Data Helpers - Re-export Hub
3
+ * This file re-exports all helper functions from organized modules for backward compatibility.
4
+ * All functions have been refactored into organized modules with migration support.
5
+ *
6
+ * New code should import directly from the organized modules:
7
+ * - core/ - Core utilities (compression, data lookup, user status, path resolution)
8
+ * - data/ - Data endpoints (portfolio, social, computation, instrument)
9
+ * - profile/ - Profile endpoints (PI profile, user profile, profile views)
10
+ * - watchlist/ - Watchlist endpoints (legacy, generation, analytics)
11
+ * - search/ - Search endpoints (PI search, PI requests)
12
+ * - recommendations/ - Recommendation endpoints
13
+ * - metrics/ - Metrics endpoints
14
+ */
15
+
16
+ // Import from organized modules
17
+ const portfolioHelpers = require('./data/portfolio_helpers');
18
+ const socialHelpers = require('./data/social_helpers');
19
+ const computationHelpers = require('./data/computation_helpers');
20
+ const instrumentHelpers = require('./data/instrument_helpers');
21
+ const piProfileHelpers = require('./profile/pi_profile_helpers');
22
+ const userProfileHelpers = require('./profile/user_profile_helpers');
23
+ const profileViewHelpers = require('./profile/profile_view_helpers');
24
+ const watchlistDataHelpers = require('./watchlist/watchlist_data_helpers');
25
+ const watchlistGenerationHelpers = require('./watchlist/watchlist_generation_helpers');
26
+ const watchlistAnalyticsHelpers = require('./watchlist/watchlist_analytics_helpers');
27
+ const piSearchHelpers = require('./search/pi_search_helpers');
28
+ const piRequestHelpers = require('./search/pi_request_helpers');
29
+ const recommendationHelpers = require('./recommendations/recommendation_helpers');
30
+ const metricsHelpers = require('./metrics/personalized_metrics_helpers');
31
+
32
+ // Import core helpers
33
+ const { tryDecompress } = require('./core/compression_helpers');
34
+ const {
35
+ findLatestRankingsDate,
36
+ findLatestPortfolioDate,
37
+ findLatestComputationDate,
38
+ findLatestPiPortfolioDate,
39
+ findLatestPiHistoryDate
40
+ } = require('./core/data_lookup_helpers');
41
+ const { checkIfUserIsPI } = require('./core/user_status_helpers');
42
+ const { checkPiInComputationDate } = require('./data/computation_helpers');
43
+
44
+ // Re-export all functions for backward compatibility
2729
45
  module.exports = {
2730
- getPiAnalytics,
2731
- getUserRecommendations,
2732
- getWatchlist,
2733
- updateWatchlist,
2734
- autoGenerateWatchlist,
2735
- getUserDataStatus,
2736
- getUserPortfolio,
2737
- getUserSocialPosts,
2738
- getUserComputations,
2739
- getUserVerification,
2740
- getInstrumentMappings,
2741
- searchPopularInvestors,
2742
- requestPiAddition,
2743
- getWatchlistTriggerCounts,
2744
- checkPisInRankings,
2745
- getPiProfile,
2746
- checkIfUserIsPopularInvestor,
2747
- checkIfUserIsPI, // Export for use in review_helpers
2748
- findLatestRankingsDate, // Export for use in on_demand_fetch_helpers
2749
- tryDecompress, // Export for use in on_demand_fetch_helpers
2750
- trackProfileView,
2751
- getSignedInUserPIPersonalizedMetrics
2752
- };
46
+ // Data helpers
47
+ getUserPortfolio: portfolioHelpers.getUserPortfolio,
48
+ getUserDataStatus: portfolioHelpers.getUserDataStatus,
49
+ getUserSocialPosts: socialHelpers.getUserSocialPosts,
50
+ getUserComputations: computationHelpers.getUserComputations,
51
+ getInstrumentMappings: instrumentHelpers.getInstrumentMappings,
52
+ checkPiInComputationDate,
53
+
54
+ // Profile helpers
55
+ getPiAnalytics: piProfileHelpers.getPiAnalytics,
56
+ getPiProfile: piProfileHelpers.getPiProfile,
57
+ getUserVerification: userProfileHelpers.getUserVerification,
58
+ checkIfUserIsPopularInvestor: userProfileHelpers.checkIfUserIsPopularInvestor,
59
+ trackProfileView: profileViewHelpers.trackProfileView,
60
+
61
+ // Watchlist helpers
62
+ getWatchlist: watchlistDataHelpers.getWatchlist,
63
+ updateWatchlist: watchlistDataHelpers.updateWatchlist,
64
+ autoGenerateWatchlist: watchlistGenerationHelpers.autoGenerateWatchlist,
65
+ getWatchlistTriggerCounts: watchlistAnalyticsHelpers.getWatchlistTriggerCounts,
66
+
67
+ // Search helpers
68
+ searchPopularInvestors: piSearchHelpers.searchPopularInvestors,
69
+ requestPiAddition: piRequestHelpers.requestPiAddition,
70
+ checkPisInRankings: piRequestHelpers.checkPisInRankings,
71
+
72
+ // Recommendations helpers
73
+ getUserRecommendations: recommendationHelpers.getUserRecommendations,
74
+
75
+ // Metrics helpers
76
+ getSignedInUserPIPersonalizedMetrics: metricsHelpers.getSignedInUserPIPersonalizedMetrics,
77
+ generateSamplePIPersonalizedMetrics: metricsHelpers.generateSamplePIPersonalizedMetrics,
78
+
79
+ // Core utilities (for use in other helpers)
80
+ tryDecompress,
81
+ findLatestRankingsDate,
82
+ findLatestPortfolioDate,
83
+ findLatestComputationDate,
84
+ findLatestPiPortfolioDate,
85
+ findLatestPiHistoryDate,
86
+ checkIfUserIsPI
87
+ };