bulltrackers-module 1.0.592 → 1.0.593

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/functions/old-generic-api/admin-api/index.js +895 -0
  2. package/functions/old-generic-api/helpers/api_helpers.js +457 -0
  3. package/functions/old-generic-api/index.js +204 -0
  4. package/functions/old-generic-api/user-api/helpers/alerts/alert_helpers.js +355 -0
  5. package/functions/old-generic-api/user-api/helpers/alerts/subscription_helpers.js +327 -0
  6. package/functions/old-generic-api/user-api/helpers/alerts/test_alert_helpers.js +212 -0
  7. package/functions/old-generic-api/user-api/helpers/collection_helpers.js +193 -0
  8. package/functions/old-generic-api/user-api/helpers/core/compression_helpers.js +68 -0
  9. package/functions/old-generic-api/user-api/helpers/core/data_lookup_helpers.js +256 -0
  10. package/functions/old-generic-api/user-api/helpers/core/path_resolution_helpers.js +640 -0
  11. package/functions/old-generic-api/user-api/helpers/core/user_status_helpers.js +195 -0
  12. package/functions/old-generic-api/user-api/helpers/data/computation_helpers.js +503 -0
  13. package/functions/old-generic-api/user-api/helpers/data/instrument_helpers.js +55 -0
  14. package/functions/old-generic-api/user-api/helpers/data/portfolio_helpers.js +245 -0
  15. package/functions/old-generic-api/user-api/helpers/data/social_helpers.js +174 -0
  16. package/functions/old-generic-api/user-api/helpers/data_helpers.js +87 -0
  17. package/functions/old-generic-api/user-api/helpers/dev/dev_helpers.js +336 -0
  18. package/functions/old-generic-api/user-api/helpers/fetch/on_demand_fetch_helpers.js +615 -0
  19. package/functions/old-generic-api/user-api/helpers/metrics/personalized_metrics_helpers.js +231 -0
  20. package/functions/old-generic-api/user-api/helpers/notifications/notification_helpers.js +641 -0
  21. package/functions/old-generic-api/user-api/helpers/profile/pi_profile_helpers.js +182 -0
  22. package/functions/old-generic-api/user-api/helpers/profile/profile_view_helpers.js +137 -0
  23. package/functions/old-generic-api/user-api/helpers/profile/user_profile_helpers.js +190 -0
  24. package/functions/old-generic-api/user-api/helpers/recommendations/recommendation_helpers.js +66 -0
  25. package/functions/old-generic-api/user-api/helpers/reviews/review_helpers.js +550 -0
  26. package/functions/old-generic-api/user-api/helpers/rootdata/rootdata_aggregation_helpers.js +378 -0
  27. package/functions/old-generic-api/user-api/helpers/search/pi_request_helpers.js +295 -0
  28. package/functions/old-generic-api/user-api/helpers/search/pi_search_helpers.js +162 -0
  29. package/functions/old-generic-api/user-api/helpers/sync/user_sync_helpers.js +677 -0
  30. package/functions/old-generic-api/user-api/helpers/verification/verification_helpers.js +323 -0
  31. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_analytics_helpers.js +96 -0
  32. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_data_helpers.js +141 -0
  33. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_generation_helpers.js +310 -0
  34. package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_management_helpers.js +829 -0
  35. package/functions/old-generic-api/user-api/index.js +109 -0
  36. package/package.json +2 -2
@@ -0,0 +1,615 @@
1
+ /**
2
+ * @fileoverview On-Demand Popular Investor Data Fetching
3
+ * Allows users to request missing PI data with rate limiting
4
+ */
5
+
6
+ const { FieldValue } = require('@google-cloud/firestore');
7
+ const crypto = require('crypto');
8
+ const { tryDecompress } = require('../data_helpers');
9
+
10
+ const RATE_LIMIT_HOURS = 1;
11
+ const RATE_LIMIT_MS = RATE_LIMIT_HOURS * 60 * 60 * 1000;
12
+
13
+ /**
14
+ * Request on-demand fetch for a Popular Investor
15
+ * POST /user/pi/:piCid/request-fetch
16
+ */
17
+ async function requestPiFetch(req, res, dependencies, config) {
18
+ const { db, logger, pubsub } = dependencies;
19
+ const { piCid } = req.params;
20
+
21
+ // Get userCid from query (same pattern as other endpoints)
22
+ const userCid = req.query?.userCid;
23
+
24
+ if (!userCid) {
25
+ return res.status(400).json({
26
+ success: false,
27
+ error: "Missing userCid",
28
+ message: "Please provide userCid as a query parameter"
29
+ });
30
+ }
31
+
32
+ // Check for dev override impersonation (same pattern as other endpoints)
33
+ const { getEffectiveCid, getDevOverride } = require('../dev/dev_helpers');
34
+ const effectiveCid = await getEffectiveCid(db, userCid, config, logger);
35
+ const devOverride = await getDevOverride(db, userCid, config, logger);
36
+ const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(userCid);
37
+
38
+ // Use effective CID for the request (impersonated or actual)
39
+ const requestUserCid = Number(effectiveCid);
40
+
41
+ const piCidNum = Number(piCid);
42
+ if (isNaN(piCidNum) || piCidNum <= 0) {
43
+ return res.status(400).json({
44
+ success: false,
45
+ error: "Invalid PI CID",
46
+ message: "Please provide a valid Popular Investor CID"
47
+ });
48
+ }
49
+
50
+ try {
51
+ // Check if this is a developer account (bypass rate limits for developers, even when impersonating)
52
+ const { isDeveloperAccount } = require('../dev/dev_helpers');
53
+ const isDeveloper = isDeveloperAccount(userCid);
54
+
55
+ let rateLimitCheck = { allowed: true }; // Default to allowed for developers
56
+
57
+ if (!isDeveloper) {
58
+ // Check rate limits (use effective CID for non-developers)
59
+ rateLimitCheck = await checkRateLimits(db, requestUserCid, piCidNum, logger);
60
+
61
+ if (!rateLimitCheck.allowed) {
62
+ return res.status(429).json({
63
+ success: false,
64
+ error: "Rate limit exceeded",
65
+ message: rateLimitCheck.message,
66
+ rateLimit: {
67
+ userCanRequestAgainAt: rateLimitCheck.userCanRequestAgainAt,
68
+ globalCanRequestAgainAt: rateLimitCheck.globalCanRequestAgainAt,
69
+ lastRequestedBy: rateLimitCheck.lastRequestedBy,
70
+ lastRequestedAt: rateLimitCheck.lastRequestedAt
71
+ }
72
+ });
73
+ }
74
+ } else {
75
+ logger.log('INFO', `[requestPiFetch] Developer account ${userCid} bypassing rate limits${isImpersonating ? ` (impersonating ${requestUserCid})` : ''}`);
76
+ }
77
+
78
+ // Get PI username from rankings (needed for task engine)
79
+ const piUsername = await getPiUsername(db, piCidNum, config, logger);
80
+ if (!piUsername) {
81
+ return res.status(404).json({
82
+ success: false,
83
+ error: "PI not found",
84
+ message: `Popular Investor ${piCidNum} not found in rankings. Cannot fetch data.`
85
+ });
86
+ }
87
+
88
+ // Create request document
89
+ const requestId = `req_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
90
+ const now = new Date();
91
+
92
+ // Store request
93
+ const requestRef = db.collection('pi_fetch_requests')
94
+ .doc(String(piCidNum))
95
+ .collection('requests')
96
+ .doc(requestId);
97
+
98
+ await requestRef.set({
99
+ requestId,
100
+ userCid: requestUserCid, // Use effective CID
101
+ actualUserCid: Number(userCid), // Track actual developer CID for audit
102
+ piCid: piCidNum,
103
+ piUsername,
104
+ status: 'queued',
105
+ isImpersonating: isImpersonating || false,
106
+ createdAt: FieldValue.serverTimestamp(),
107
+ updatedAt: FieldValue.serverTimestamp()
108
+ });
109
+
110
+ // Update user rate limit (use effective CID)
111
+ // Use new path: PopularInvestors/{piCid}/userFetchRequests/{userCid}
112
+ const userRequestRef = db.collection('PopularInvestors')
113
+ .doc(String(piCidNum))
114
+ .collection('userFetchRequests')
115
+ .doc(String(requestUserCid));
116
+
117
+ await userRequestRef.set({
118
+ userCid: requestUserCid,
119
+ piCid: piCidNum,
120
+ lastRequestedAt: FieldValue.serverTimestamp(),
121
+ requestCount: FieldValue.increment(1),
122
+ updatedAt: FieldValue.serverTimestamp()
123
+ }, { merge: true });
124
+
125
+ // Update global rate limit
126
+ const globalRef = db.collection('pi_fetch_requests')
127
+ .doc(String(piCidNum))
128
+ .collection('global')
129
+ .doc('latest');
130
+
131
+ await globalRef.set({
132
+ piCid: piCidNum,
133
+ lastRequestedAt: FieldValue.serverTimestamp(),
134
+ lastRequestedBy: requestUserCid, // Use effective CID
135
+ totalRequests: FieldValue.increment(1),
136
+ updatedAt: FieldValue.serverTimestamp()
137
+ }, { merge: true });
138
+
139
+ // Publish to task engine - use on-demand topic for API requests
140
+ const topicName = config.taskEngine?.PUBSUB_TOPIC_USER_FETCH_ONDEMAND ||
141
+ config.pubsubTopicUserFetchOnDemand ||
142
+ 'etoro-user-fetch-topic-ondemand';
143
+ const topic = pubsub.topic(topicName);
144
+
145
+ const message = {
146
+ type: 'POPULAR_INVESTOR_UPDATE',
147
+ cid: piCidNum,
148
+ username: piUsername,
149
+ priority: 'high',
150
+ source: 'on_demand',
151
+ requestId,
152
+ requestedBy: requestUserCid, // Use effective CID
153
+ actualRequestedBy: Number(userCid), // Track actual developer CID
154
+ userType: 'POPULAR_INVESTOR', // Explicitly set userType for task engine
155
+ metadata: {
156
+ onDemand: true,
157
+ targetCid: piCidNum, // Target specific user for optimization
158
+ requestedAt: now.toISOString(),
159
+ isImpersonating: isImpersonating || false,
160
+ userType: 'POPULAR_INVESTOR' // Also in metadata for consistency
161
+ }
162
+ };
163
+
164
+ const messageId = await topic.publishMessage({
165
+ data: Buffer.from(JSON.stringify(message))
166
+ });
167
+
168
+ // Update request with message ID
169
+ await requestRef.update({
170
+ taskMessageId: messageId,
171
+ updatedAt: FieldValue.serverTimestamp()
172
+ });
173
+
174
+ logger.log('INFO', `[requestPiFetch] User ${userCid}${isImpersonating ? ` (impersonating ${requestUserCid})` : ''} requested fetch for PI ${piCidNum} (${piUsername}). Request ID: ${requestId}`);
175
+
176
+ return res.status(200).json({
177
+ success: true,
178
+ requestId,
179
+ status: 'queued',
180
+ message: 'Request queued successfully. Data will be available shortly.',
181
+ estimatedTime: '2-5 minutes',
182
+ rateLimit: {
183
+ userCanRequestAgainAt: new Date(Date.now() + RATE_LIMIT_MS).toISOString(),
184
+ globalCanRequestAgainAt: new Date(Date.now() + RATE_LIMIT_MS).toISOString()
185
+ }
186
+ });
187
+
188
+ } catch (error) {
189
+ logger.log('ERROR', `[requestPiFetch] Error requesting fetch for PI ${piCidNum}`, error);
190
+ return res.status(500).json({
191
+ success: false,
192
+ error: "Internal server error",
193
+ message: error.message
194
+ });
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Check fetch status for a Popular Investor
200
+ * GET /user/pi/:piCid/fetch-status
201
+ */
202
+ async function getPiFetchStatus(req, res, dependencies, config) {
203
+ const { db, logger } = dependencies;
204
+ const { piCid } = req.params;
205
+ const userCid = req.query?.userCid; // Optional - for rate limit info
206
+
207
+ const piCidNum = Number(piCid);
208
+ if (isNaN(piCidNum) || piCidNum <= 0) {
209
+ return res.status(400).json({
210
+ success: false,
211
+ error: "Invalid PI CID"
212
+ });
213
+ }
214
+
215
+ try {
216
+ // First check if data exists by checking computation results directly
217
+ const insightsCollection = config.unifiedInsightsCollection || 'unified_insights';
218
+ const resultsSub = config.resultsSubcollection || 'results';
219
+ const compsSub = config.computationsSubcollection || 'computations';
220
+
221
+ // Check last 7 days for PI data
222
+ const today = new Date();
223
+ let latestDate = null;
224
+
225
+ for (let i = 0; i < 7; i++) {
226
+ const checkDate = new Date(today);
227
+ checkDate.setDate(checkDate.getDate() - i);
228
+ const dateStr = checkDate.toISOString().split('T')[0];
229
+
230
+ const docRef = db.collection(insightsCollection)
231
+ .doc(dateStr)
232
+ .collection(resultsSub)
233
+ .doc('popular-investor')
234
+ .collection(compsSub)
235
+ .doc('PopularInvestorProfileMetrics');
236
+
237
+ const doc = await docRef.get();
238
+ if (doc.exists) {
239
+ const { tryDecompress } = require('../data_helpers');
240
+ const data = tryDecompress(doc.data());
241
+
242
+ if (data && data[String(piCidNum)]) {
243
+ latestDate = dateStr;
244
+ break;
245
+ }
246
+ }
247
+ }
248
+
249
+ if (latestDate) {
250
+ // Data exists, check if it contains this PI
251
+ const docRef = db.collection(insightsCollection)
252
+ .doc(latestDate)
253
+ .collection(resultsSub)
254
+ .doc('popular-investor')
255
+ .collection(compsSub)
256
+ .doc('PopularInvestorProfileMetrics');
257
+
258
+ const doc = await docRef.get();
259
+ if (doc.exists) {
260
+ const data = tryDecompress(doc.data());
261
+
262
+ if (data && data[String(piCidNum)]) {
263
+ return res.status(200).json({
264
+ success: true,
265
+ dataAvailable: true,
266
+ latestDate,
267
+ message: "Data is available"
268
+ });
269
+ }
270
+ }
271
+ }
272
+
273
+ // Data doesn't exist, check for pending requests
274
+ const requestsRef = db.collection('pi_fetch_requests')
275
+ .doc(String(piCidNum))
276
+ .collection('requests')
277
+ .orderBy('createdAt', 'desc')
278
+ .limit(1);
279
+
280
+ const requestsSnapshot = await requestsRef.get();
281
+
282
+ if (!requestsSnapshot.empty) {
283
+ const latestRequest = requestsSnapshot.docs[0].data();
284
+ let status = latestRequest.status || 'queued';
285
+
286
+ // If status is 'indexing' or 'computing', check if computation results are now available
287
+ if (status === 'indexing' || status === 'computing') {
288
+ // Re-check if computation results exist
289
+ const checkDate = new Date();
290
+ for (let i = 0; i < 2; i++) { // Check today and yesterday
291
+ const dateStr = new Date(checkDate);
292
+ dateStr.setDate(checkDate.getDate() - i);
293
+ const dateStrFormatted = dateStr.toISOString().split('T')[0];
294
+
295
+ const docRef = db.collection(insightsCollection)
296
+ .doc(dateStrFormatted)
297
+ .collection(resultsSub)
298
+ .doc('popular-investor')
299
+ .collection(compsSub)
300
+ .doc('PopularInvestorProfileMetrics');
301
+
302
+ const doc = await docRef.get();
303
+ if (doc.exists) {
304
+ const docData = doc.data();
305
+ let mergedData = null;
306
+
307
+ // Check if data is sharded
308
+ if (docData._sharded === true && docData._shardCount) {
309
+ // Data is stored in shards - read all shards and merge
310
+ const shardsCol = docRef.collection('_shards');
311
+ const shardsSnapshot = await shardsCol.get();
312
+
313
+ if (!shardsSnapshot.empty) {
314
+ mergedData = {};
315
+ for (const shardDoc of shardsSnapshot.docs) {
316
+ const shardData = shardDoc.data();
317
+ Object.assign(mergedData, shardData);
318
+ }
319
+ }
320
+ } else {
321
+ // Data is in the main document (compressed or not)
322
+ mergedData = tryDecompress(docData);
323
+
324
+ // Handle string decompression result
325
+ if (typeof mergedData === 'string') {
326
+ try {
327
+ mergedData = JSON.parse(mergedData);
328
+ } catch (e) {
329
+ logger.log('WARN', `[getPiFetchStatus] Failed to parse decompressed string for date ${dateStrFormatted}:`, e.message);
330
+ mergedData = null;
331
+ }
332
+ }
333
+ }
334
+
335
+ if (mergedData && typeof mergedData === 'object' && mergedData[String(piCidNum)]) {
336
+ // Computation completed! Update status
337
+ status = 'completed';
338
+ await requestsSnapshot.docs[0].ref.update({
339
+ status: 'completed',
340
+ completedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp(),
341
+ updatedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
342
+ });
343
+ break;
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ // Check if request is stale (stuck in processing state for too long)
350
+ // Set to 2 minutes to prevent indefinite polling when computation system crashes
351
+ const { FieldValue } = require('@google-cloud/firestore');
352
+ const STALE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
353
+ const processingStates = ['processing', 'dispatched', 'indexing', 'computing', 'queued'];
354
+ const isProcessingState = processingStates.includes(status);
355
+
356
+ let isStale = false;
357
+ if (isProcessingState) {
358
+ const now = Date.now();
359
+ const createdAt = latestRequest.createdAt?.toDate?.()?.getTime() ||
360
+ latestRequest.createdAt?.toMillis?.() || null;
361
+ const dispatchedAt = latestRequest.dispatchedAt?.toDate?.()?.getTime() ||
362
+ latestRequest.dispatchedAt?.toMillis?.() || null;
363
+ const startedAt = latestRequest.startedAt?.toDate?.()?.getTime() ||
364
+ latestRequest.startedAt?.toMillis?.() || null;
365
+ const updatedAt = latestRequest.updatedAt?.toDate?.()?.getTime() ||
366
+ latestRequest.updatedAt?.toMillis?.() || null;
367
+
368
+ // Use the most recent timestamp to determine age
369
+ const referenceTime = startedAt || dispatchedAt || createdAt || updatedAt;
370
+
371
+ if (referenceTime && (now - referenceTime) > STALE_THRESHOLD_MS) {
372
+ // Before marking as stale, do one final check for computation results
373
+ const finalCheckDate = new Date();
374
+ let foundResults = false;
375
+ for (let i = 0; i < 2; i++) {
376
+ const dateStr = new Date(finalCheckDate);
377
+ dateStr.setDate(finalCheckDate.getDate() - i);
378
+ const dateStrFormatted = dateStr.toISOString().split('T')[0];
379
+
380
+ const docRef = db.collection(insightsCollection)
381
+ .doc(dateStrFormatted)
382
+ .collection(resultsSub)
383
+ .doc('popular-investor')
384
+ .collection(compsSub)
385
+ .doc('PopularInvestorProfileMetrics');
386
+
387
+ const doc = await docRef.get();
388
+ if (doc.exists) {
389
+ const { tryDecompress } = require('../data_helpers');
390
+ const data = tryDecompress(doc.data());
391
+ if (data && typeof data === 'object' && data[String(piCidNum)]) {
392
+ foundResults = true;
393
+ status = 'completed';
394
+ await requestsSnapshot.docs[0].ref.update({
395
+ status: 'completed',
396
+ completedAt: FieldValue.serverTimestamp(),
397
+ updatedAt: FieldValue.serverTimestamp()
398
+ });
399
+ logger.log('INFO', `[getPiFetchStatus] Found computation results on stale check, marked as completed`);
400
+ break;
401
+ }
402
+ }
403
+ }
404
+
405
+ if (!foundResults) {
406
+ isStale = true;
407
+ logger.log('WARN', `[getPiFetchStatus] Detected stale request ${latestRequest.requestId} for PI ${piCidNum}. Status: ${status}, Age: ${Math.round((now - referenceTime) / 60000)} minutes`);
408
+ }
409
+ }
410
+ }
411
+
412
+ // If stale, mark as failed to stop polling
413
+ if (isStale) {
414
+ status = 'failed';
415
+ const requestDocRef = requestsSnapshot.docs[0].ref;
416
+ try {
417
+ await requestDocRef.update({
418
+ status: 'failed',
419
+ error: 'Request timed out - task may have failed to process. Please try again.',
420
+ failedAt: FieldValue.serverTimestamp(),
421
+ updatedAt: FieldValue.serverTimestamp()
422
+ });
423
+ logger.log('INFO', `[getPiFetchStatus] Marked stale request ${latestRequest.requestId} as failed`);
424
+ } catch (updateErr) {
425
+ logger.log('WARN', `[getPiFetchStatus] Failed to update stale request status`, updateErr);
426
+ }
427
+ }
428
+
429
+ const response = {
430
+ success: true,
431
+ dataAvailable: status === 'completed',
432
+ status,
433
+ requestId: latestRequest.requestId,
434
+ startedAt: latestRequest.startedAt?.toDate?.()?.toISOString() || null,
435
+ createdAt: latestRequest.createdAt?.toDate?.()?.toISOString() || null,
436
+ estimatedCompletion: latestRequest.startedAt
437
+ ? new Date(new Date(latestRequest.startedAt.toDate()).getTime() + 10 * 60 * 1000).toISOString() // 10 min for computation
438
+ : null
439
+ };
440
+
441
+ // Include error details if status is failed
442
+ if (status === 'failed') {
443
+ response.error = isStale
444
+ ? 'Request timed out - task may have failed to process. Please try again.'
445
+ : (latestRequest.error || 'Unknown error occurred');
446
+ response.failedAt = latestRequest.failedAt?.toDate?.()?.toISOString() ||
447
+ (isStale ? new Date().toISOString() : null);
448
+ }
449
+
450
+ // Include raw data status if computing
451
+ if (status === 'computing') {
452
+ response.rawDataStoredAt = latestRequest.rawDataStoredAt?.toDate?.()?.toISOString() || null;
453
+ response.message = 'Raw data stored, computation in progress...';
454
+ }
455
+
456
+ return res.status(200).json(response);
457
+ }
458
+
459
+ // No request found, check if user can request
460
+ let canRequest = true;
461
+ let rateLimitInfo = null;
462
+
463
+ if (userCid) {
464
+ // Check if this is a developer account (bypass rate limits for developers)
465
+ const { isDeveloperAccount } = require('../dev/dev_helpers');
466
+ const isDeveloper = isDeveloperAccount(userCid);
467
+
468
+ if (isDeveloper) {
469
+ // Developer accounts bypass rate limits
470
+ canRequest = true;
471
+ rateLimitInfo = {
472
+ bypassed: true,
473
+ reason: 'developer_account'
474
+ };
475
+ } else {
476
+ const rateLimitCheck = await checkRateLimits(db, userCid, piCidNum, logger);
477
+ canRequest = rateLimitCheck.allowed;
478
+ rateLimitInfo = {
479
+ userCanRequestAgainAt: rateLimitCheck.userCanRequestAgainAt,
480
+ globalCanRequestAgainAt: rateLimitCheck.globalCanRequestAgainAt
481
+ };
482
+ }
483
+ }
484
+
485
+ return res.status(200).json({
486
+ success: true,
487
+ dataAvailable: false,
488
+ status: 'not_requested',
489
+ canRequest,
490
+ rateLimit: rateLimitInfo
491
+ });
492
+
493
+ } catch (error) {
494
+ logger.log('ERROR', `[getPiFetchStatus] Error checking fetch status for PI ${piCidNum}`, error);
495
+ return res.status(500).json({
496
+ success: false,
497
+ error: "Internal server error",
498
+ message: error.message
499
+ });
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Check rate limits for user and global
505
+ * NOTE: Developer accounts should bypass this check (check isDeveloperAccount before calling)
506
+ */
507
+ async function checkRateLimits(db, userCid, piCid, logger) {
508
+ const now = Date.now();
509
+
510
+ // Check user rate limit - use new path: PopularInvestors/{piCid}/userFetchRequests/{userCid}
511
+ const userRequestRef = db.collection('PopularInvestors')
512
+ .doc(String(piCid))
513
+ .collection('userFetchRequests')
514
+ .doc(String(userCid));
515
+
516
+ const userRequestDoc = await userRequestRef.get();
517
+ let userCanRequestAgainAt = null;
518
+ let userBlocked = false;
519
+
520
+ if (userRequestDoc.exists) {
521
+ const userData = userRequestDoc.data();
522
+ const lastRequestedAt = userData.lastRequestedAt?.toDate?.()?.getTime() || userData.lastRequestedAt?.toMillis?.() || null;
523
+
524
+ if (lastRequestedAt && (now - lastRequestedAt) < RATE_LIMIT_MS) {
525
+ userBlocked = true;
526
+ userCanRequestAgainAt = new Date(lastRequestedAt + RATE_LIMIT_MS).toISOString();
527
+ }
528
+ }
529
+
530
+ // Check global rate limit
531
+ const globalRef = db.collection('pi_fetch_requests')
532
+ .doc(String(piCid))
533
+ .collection('global')
534
+ .doc('latest');
535
+
536
+ const globalDoc = await globalRef.get();
537
+ let globalCanRequestAgainAt = null;
538
+ let globalBlocked = false;
539
+ let lastRequestedBy = null;
540
+ let lastRequestedAt = null;
541
+
542
+ if (globalDoc.exists) {
543
+ const globalData = globalDoc.data();
544
+ const globalLastRequestedAt = globalData.lastRequestedAt?.toDate?.()?.getTime() || globalData.lastRequestedAt?.toMillis?.() || null;
545
+
546
+ if (globalLastRequestedAt && (now - globalLastRequestedAt) < RATE_LIMIT_MS) {
547
+ globalBlocked = true;
548
+ globalCanRequestAgainAt = new Date(globalLastRequestedAt + RATE_LIMIT_MS).toISOString();
549
+ lastRequestedBy = globalData.lastRequestedBy;
550
+ lastRequestedAt = new Date(globalLastRequestedAt).toISOString();
551
+ }
552
+ }
553
+
554
+ // Global limit is more restrictive
555
+ if (globalBlocked) {
556
+ const minutesRemaining = Math.ceil((new Date(globalCanRequestAgainAt).getTime() - now) / (60 * 1000));
557
+ return {
558
+ allowed: false,
559
+ message: `This Popular Investor was recently requested. Please try again in ${minutesRemaining} minute${minutesRemaining !== 1 ? 's' : ''}.`,
560
+ userCanRequestAgainAt,
561
+ globalCanRequestAgainAt,
562
+ lastRequestedBy,
563
+ lastRequestedAt
564
+ };
565
+ }
566
+
567
+ if (userBlocked) {
568
+ const minutesRemaining = Math.ceil((new Date(userCanRequestAgainAt).getTime() - now) / (60 * 1000));
569
+ return {
570
+ allowed: false,
571
+ message: `You have recently requested this Popular Investor. Please try again in ${minutesRemaining} minute${minutesRemaining !== 1 ? 's' : ''}.`,
572
+ userCanRequestAgainAt,
573
+ globalCanRequestAgainAt,
574
+ lastRequestedBy,
575
+ lastRequestedAt
576
+ };
577
+ }
578
+
579
+ return {
580
+ allowed: true,
581
+ userCanRequestAgainAt: new Date(now + RATE_LIMIT_MS).toISOString(),
582
+ globalCanRequestAgainAt: new Date(now + RATE_LIMIT_MS).toISOString()
583
+ };
584
+ }
585
+
586
+ /**
587
+ * Get PI username from master list (single source of truth)
588
+ * Uses the popular investor master list instead of rankings
589
+ */
590
+ async function getPiUsername(db, piCid, config, logger) {
591
+ try {
592
+ // Use master list helper instead of rankings
593
+ const { getPIUsernameFromMasterList } = require('../core/user_status_helpers');
594
+ const collectionRegistry = config.collectionRegistry || null;
595
+
596
+ const username = await getPIUsernameFromMasterList(db, piCid, collectionRegistry, logger);
597
+
598
+ if (username) {
599
+ logger.log('INFO', `[getPiUsername] Found username "${username}" for PI ${piCid} from master list`);
600
+ return username;
601
+ }
602
+
603
+ logger.log('WARN', `[getPiUsername] PI ${piCid} not found in master list`);
604
+ return null;
605
+ } catch (error) {
606
+ logger.log('ERROR', `[getPiUsername] Error fetching username for PI ${piCid}`, error);
607
+ return null;
608
+ }
609
+ }
610
+
611
+ module.exports = {
612
+ requestPiFetch,
613
+ getPiFetchStatus,
614
+ getPiUsername
615
+ };