bulltrackers-module 1.0.516 → 1.0.518

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.
@@ -255,14 +255,129 @@ async function findLatestRankingsDate(db, rankingsCollection, maxDaysBack = 30)
255
255
  }
256
256
 
257
257
  /**
258
- * [NEW] Fetches Popular Investors from the daily rankings document.
259
- * UPDATED: Checks last 7 days of rankings data and builds a de-duplicated map.
260
- * This solves the problem of a single day where a given popular investor is not in the rankings.
258
+ * [NEW] Fetches Popular Investors from the master list and filters by last updated times.
259
+ * UPDATED: Uses the master list as single source of truth, then checks last updated timestamps
260
+ * for each data type (portfolio, tradeHistory, socialPosts) to determine who needs updating.
261
261
  * UPDATED: Uses collection registry for path resolution.
262
262
  */
263
263
  async function getPopularInvestorsToUpdate(dependencies, config) {
264
264
  const { db, logger, collectionRegistry } = dependencies;
265
265
 
266
+ // 24 hours threshold - users not updated in the last 24 hours need updating
267
+ const HOURS_THRESHOLD = 24;
268
+ const thresholdMs = HOURS_THRESHOLD * 60 * 60 * 1000;
269
+ const now = Date.now();
270
+
271
+ logger.log('INFO', `[Core Utils] Getting Popular Investors to update (checking master list and last updated times, threshold: ${HOURS_THRESHOLD} hours)...`);
272
+
273
+ try {
274
+ // Get the master list of Popular Investors
275
+ let masterListPath = 'system_state/popular_investor_master_list';
276
+
277
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
278
+ try {
279
+ masterListPath = collectionRegistry.getCollectionPath('system', 'popularInvestorMasterList', {});
280
+ } catch (err) {
281
+ logger.log('WARN', `[Core Utils] Failed to get master list path from registry, using default: ${err.message}`);
282
+ }
283
+ }
284
+
285
+ const masterListRef = db.doc(masterListPath);
286
+ const masterListDoc = await masterListRef.get();
287
+
288
+ if (!masterListDoc.exists) {
289
+ logger.log('WARN', `[Core Utils] Master list not found. Falling back to rankings-based approach.`);
290
+ // Fallback to old method if master list doesn't exist yet
291
+ return await getPopularInvestorsToUpdateLegacy(dependencies, config);
292
+ }
293
+
294
+ const masterListData = masterListDoc.data();
295
+ const investors = masterListData.investors || {};
296
+
297
+ if (Object.keys(investors).length === 0) {
298
+ logger.log('WARN', `[Core Utils] Master list is empty. Returning empty array.`);
299
+ return [];
300
+ }
301
+
302
+ logger.log('INFO', `[Core Utils] Found ${Object.keys(investors).length} Popular Investors in master list. Checking last updated times...`);
303
+
304
+ const targets = [];
305
+ let skippedCount = 0;
306
+
307
+ // Check each PI's last updated times for each data type
308
+ for (const [cid, piData] of Object.entries(investors)) {
309
+ const username = piData.username || String(cid);
310
+
311
+ // Get last updated document for this PI
312
+ let lastUpdatedPath;
313
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
314
+ try {
315
+ lastUpdatedPath = collectionRegistry.getCollectionPath('popularInvestors', 'lastUpdated', { piCid: cid });
316
+ } catch (err) {
317
+ logger.log('WARN', `[Core Utils] Failed to get lastUpdated path for ${cid}: ${err.message}`);
318
+ // Include this PI if we can't check (better to update than skip)
319
+ targets.push({ cid, username });
320
+ continue;
321
+ }
322
+ } else {
323
+ // Include this PI if registry not available
324
+ targets.push({ cid, username });
325
+ continue;
326
+ }
327
+
328
+ const lastUpdatedRef = db.doc(lastUpdatedPath);
329
+ const lastUpdatedDoc = await lastUpdatedRef.get();
330
+
331
+ let needsUpdate = false;
332
+
333
+ if (!lastUpdatedDoc.exists) {
334
+ // No last updated data - needs update
335
+ needsUpdate = true;
336
+ } else {
337
+ const lastUpdatedData = lastUpdatedDoc.data();
338
+
339
+ // Check each data type - if any is missing or older than threshold, needs update
340
+ const dataTypes = ['portfolio', 'tradeHistory', 'socialPosts'];
341
+ for (const dataType of dataTypes) {
342
+ const lastUpdated = lastUpdatedData[dataType];
343
+ if (!lastUpdated) {
344
+ needsUpdate = true;
345
+ break;
346
+ }
347
+
348
+ const lastUpdatedMs = lastUpdated.toDate?.()?.getTime() || lastUpdated.toMillis?.() || null;
349
+ if (lastUpdatedMs && (now - lastUpdatedMs) >= thresholdMs) {
350
+ needsUpdate = true;
351
+ break;
352
+ }
353
+ }
354
+ }
355
+
356
+ if (needsUpdate) {
357
+ targets.push({ cid, username });
358
+ } else {
359
+ skippedCount++;
360
+ }
361
+ }
362
+
363
+ logger.log('INFO', `[Core Utils] Found ${Object.keys(investors).length} Popular Investors in master list. Skipped ${skippedCount} recently updated. ${targets.length} will be updated.`);
364
+ return targets;
365
+
366
+ } catch (error) {
367
+ logger.log('ERROR', '[Core Utils] Error getting Popular Investors', { errorMessage: error.message });
368
+ // Fallback to legacy method on error
369
+ logger.log('INFO', '[Core Utils] Falling back to legacy rankings-based approach due to error.');
370
+ return await getPopularInvestorsToUpdateLegacy(dependencies, config);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Legacy method: Fetches Popular Investors from the daily rankings document.
376
+ * Used as fallback when master list is not available.
377
+ */
378
+ async function getPopularInvestorsToUpdateLegacy(dependencies, config) {
379
+ const { db, logger, collectionRegistry } = dependencies;
380
+
266
381
  // Get collection name from registry if available
267
382
  let collectionName = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
268
383
 
@@ -276,7 +391,7 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
276
391
  }
277
392
  }
278
393
 
279
- logger.log('INFO', `[Core Utils] Getting Popular Investors to update from ${collectionName} (checking last 7 days)...`);
394
+ logger.log('INFO', `[Core Utils] Using legacy method: Getting Popular Investors from ${collectionName} (checking last 7 days)...`);
280
395
 
281
396
  // Check last 7 days of rankings data
282
397
  const today = new Date();
@@ -331,66 +446,10 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
331
446
  }
332
447
 
333
448
  logger.log('INFO', `[Core Utils] Found ${piMap.size} unique Popular Investors across last 7 days of rankings.`);
334
-
335
- // Convert map to array and filter out PIs updated in the last 18 hours
336
- const allTargets = Array.from(piMap.values());
337
-
338
- // Filter out PIs updated in the last 18 hours
339
- const HOURS_THRESHOLD = 18;
340
- const thresholdMs = HOURS_THRESHOLD * 60 * 60 * 1000;
341
- const now = Date.now();
342
-
343
- const filteredTargets = [];
344
- let skippedCount = 0;
345
-
346
- // Check each PI's last sync time using user-centric path
347
- for (const target of allTargets) {
348
- // Use collection registry for sync status path if available
349
- let syncRef;
350
- if (collectionRegistry && collectionRegistry.getCollectionPath) {
351
- try {
352
- // syncStatus path is: SignedInUsers/{cid}/syncStatus/latest (document path)
353
- const syncStatusPath = collectionRegistry.getCollectionPath('signedInUsers', 'syncStatus', { cid: target.cid });
354
- syncRef = db.doc(syncStatusPath);
355
- } catch (err) {
356
- // Fallback to legacy path
357
- syncRef = db.collection('user_sync_requests')
358
- .doc(target.cid)
359
- .collection('global')
360
- .doc('latest');
361
- }
362
- } else {
363
- // Fallback to legacy path
364
- syncRef = db.collection('user_sync_requests')
365
- .doc(target.cid)
366
- .collection('global')
367
- .doc('latest');
368
- }
369
-
370
- const syncDoc = await syncRef.get();
371
- if (syncDoc.exists) {
372
- const syncData = syncDoc.data();
373
- const lastRequestedAt = syncData.lastRequestedAt?.toDate?.()?.getTime() ||
374
- syncData.lastRequestedAt?.toMillis?.() || null;
375
-
376
- if (lastRequestedAt && (now - lastRequestedAt) < thresholdMs) {
377
- skippedCount++;
378
- continue; // Skip this PI
379
- }
380
- }
381
-
382
- filteredTargets.push(target); // Include this PI
383
- }
384
-
385
- if (filteredTargets.length > 0) {
386
- logger.log('INFO', `[Core Utils Debug] First PI Target Mapped: ${JSON.stringify(filteredTargets[0])}`);
387
- }
388
-
389
- logger.log('INFO', `[Core Utils] Found ${allTargets.length} Popular Investors from last 7 days of rankings. Skipped ${skippedCount} recently updated. ${filteredTargets.length} will be updated.`);
390
- return filteredTargets;
449
+ return Array.from(piMap.values());
391
450
 
392
451
  } catch (error) {
393
- logger.log('ERROR', '[Core Utils] Error getting Popular Investors', { errorMessage: error.message });
452
+ logger.log('ERROR', '[Core Utils] Error getting Popular Investors (legacy)', { errorMessage: error.message });
394
453
  throw error;
395
454
  }
396
455
  }
@@ -398,7 +457,8 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
398
457
  /**
399
458
  * [NEW] Fetches Signed-In Users for daily update.
400
459
  * UPDATED: Uses user-centric collection model and collection registry.
401
- * Skips users updated in the last 18 hours to avoid duplicate refreshes.
460
+ * UPDATED: Checks last updated timestamps for each data type (portfolio, tradeHistory, socialPosts)
461
+ * to determine who needs updating. Users not updated in the last 24 hours need updating.
402
462
  */
403
463
  async function getSignedInUsersToUpdate(dependencies, config) {
404
464
  const { db, logger, collectionRegistry } = dependencies;
@@ -416,13 +476,12 @@ async function getSignedInUsersToUpdate(dependencies, config) {
416
476
  }
417
477
  }
418
478
 
419
- // 18 hours threshold - skip users updated more recently than this
420
- const HOURS_THRESHOLD = 18;
479
+ // 24 hours threshold - users not updated in the last 24 hours need updating
480
+ const HOURS_THRESHOLD = 24;
421
481
  const thresholdMs = HOURS_THRESHOLD * 60 * 60 * 1000;
422
482
  const now = Date.now();
423
- const today = new Date().toISOString().split('T')[0];
424
483
 
425
- logger.log('INFO', `[Core Utils] Getting Signed-In Users to update from ${signedInUsersCollection} (skipping users updated in last ${HOURS_THRESHOLD} hours)...`);
484
+ logger.log('INFO', `[Core Utils] Getting Signed-In Users to update from ${signedInUsersCollection} (threshold: ${HOURS_THRESHOLD} hours)...`);
426
485
 
427
486
  try {
428
487
  // Get all signed-in users from the main collection
@@ -434,7 +493,7 @@ async function getSignedInUsersToUpdate(dependencies, config) {
434
493
  return [];
435
494
  }
436
495
 
437
- const targets = [];
496
+ const allUsers = [];
438
497
 
439
498
  // Extract CIDs from user documents
440
499
  snapshot.forEach(doc => {
@@ -444,84 +503,71 @@ async function getSignedInUsersToUpdate(dependencies, config) {
444
503
 
445
504
  if (!cid) return;
446
505
 
447
- targets.push({ cid: String(cid), username });
506
+ allUsers.push({ cid: String(cid), username });
448
507
  });
449
508
 
450
- logger.log('INFO', `[Core Utils] Found ${targets.length} Signed-In Users. Checking update status...`);
509
+ logger.log('INFO', `[Core Utils] Found ${allUsers.length} Signed-In Users. Checking last updated times...`);
451
510
 
452
511
  // Filter out users who were updated recently
453
- const filteredTargets = [];
512
+ const targets = [];
454
513
  let skippedCount = 0;
455
514
 
456
- const checkPromises = targets.map(async (target) => {
457
- // Check if user has portfolio data for today (using root data format)
458
- let hasTodayData = false;
459
-
515
+ const checkPromises = allUsers.map(async (target) => {
516
+ // Get last updated document for this user
517
+ let lastUpdatedPath;
460
518
  if (collectionRegistry && collectionRegistry.getCollectionPath) {
461
519
  try {
462
- // Check root data portfolio for today
463
- const portfolioPath = collectionRegistry.getCollectionPath('rootData', 'signedInUserPortfolio', {
464
- date: today,
465
- cid: target.cid
466
- });
467
- const portfolioRef = db.doc(portfolioPath);
468
- const portfolioDoc = await portfolioRef.get();
469
-
470
- if (portfolioDoc.exists) {
471
- hasTodayData = true;
472
- }
520
+ lastUpdatedPath = collectionRegistry.getCollectionPath('signedInUsers', 'lastUpdated', { cid: target.cid });
473
521
  } catch (err) {
474
- logger.log('WARN', `[Core Utils] Error checking portfolio for ${target.cid}: ${err.message}`);
475
- }
476
- }
477
-
478
- // Also check sync status using user-centric path
479
- let syncRef;
480
- if (collectionRegistry && collectionRegistry.getCollectionPath) {
481
- try {
482
- // syncStatus path is: SignedInUsers/{cid}/syncStatus/latest (document path)
483
- const syncStatusPath = collectionRegistry.getCollectionPath('signedInUsers', 'syncStatus', { cid: target.cid });
484
- syncRef = db.doc(syncStatusPath);
485
- } catch (err) {
486
- // Fallback to legacy path
487
- syncRef = db.collection('user_sync_requests')
488
- .doc(target.cid)
489
- .collection('global')
490
- .doc('latest');
522
+ logger.log('WARN', `[Core Utils] Failed to get lastUpdated path for ${target.cid}: ${err.message}`);
523
+ // Include this user if we can't check (better to update than skip)
524
+ return target;
491
525
  }
492
526
  } else {
493
- // Fallback to legacy path
494
- syncRef = db.collection('user_sync_requests')
495
- .doc(target.cid)
496
- .collection('global')
497
- .doc('latest');
527
+ // Include this user if registry not available
528
+ return target;
498
529
  }
499
530
 
500
- const syncDoc = await syncRef.get();
501
- if (syncDoc.exists) {
502
- const syncData = syncDoc.data();
503
- const lastRequestedAt = syncData.lastRequestedAt?.toDate?.()?.getTime() ||
504
- syncData.lastRequestedAt?.toMillis?.() || null;
531
+ const lastUpdatedRef = db.doc(lastUpdatedPath);
532
+ const lastUpdatedDoc = await lastUpdatedRef.get();
533
+
534
+ let needsUpdate = false;
535
+
536
+ if (!lastUpdatedDoc.exists) {
537
+ // No last updated data - needs update
538
+ needsUpdate = true;
539
+ } else {
540
+ const lastUpdatedData = lastUpdatedDoc.data();
505
541
 
506
- if (lastRequestedAt && (now - lastRequestedAt) < thresholdMs) {
507
- skippedCount++;
508
- return null; // Skip this user - recently updated
542
+ // Check each data type - if any is missing or older than threshold, needs update
543
+ const dataTypes = ['portfolio', 'tradeHistory', 'socialPosts'];
544
+ for (const dataType of dataTypes) {
545
+ const lastUpdated = lastUpdatedData[dataType];
546
+ if (!lastUpdated) {
547
+ needsUpdate = true;
548
+ break;
549
+ }
550
+
551
+ const lastUpdatedMs = lastUpdated.toDate?.()?.getTime() || lastUpdated.toMillis?.() || null;
552
+ if (lastUpdatedMs && (now - lastUpdatedMs) >= thresholdMs) {
553
+ needsUpdate = true;
554
+ break;
555
+ }
509
556
  }
510
557
  }
511
558
 
512
- // If user has data for today, skip them (they're already up-to-date)
513
- if (hasTodayData) {
559
+ if (needsUpdate) {
560
+ return target;
561
+ } else {
514
562
  skippedCount++;
515
- return null; // Skip this user - already updated today
563
+ return null;
516
564
  }
517
-
518
- return target; // Include this user - needs update
519
565
  });
520
566
 
521
567
  const results = await Promise.all(checkPromises);
522
568
  const finalTargets = results.filter(t => t !== null);
523
569
 
524
- logger.log('INFO', `[Core Utils] Found ${targets.length} Signed-In Users. Skipped ${skippedCount} recently updated. ${finalTargets.length} will be updated.`);
570
+ logger.log('INFO', `[Core Utils] Found ${allUsers.length} Signed-In Users. Skipped ${skippedCount} recently updated. ${finalTargets.length} will be updated.`);
525
571
  return finalTargets;
526
572
 
527
573
  } catch (error) {
@@ -129,6 +129,64 @@ async function fetchAndStorePopularInvestors(config, dependencies) {
129
129
 
130
130
  logger.log('SUCCESS', `[PopularInvestorFetch] Stored ${data.TotalRows} rankings into ${finalRankingsCollectionName}/${today}`);
131
131
 
132
+ // Update the master list of Popular Investors
133
+ try {
134
+ const { FieldValue } = require('@google-cloud/firestore');
135
+ let masterListPath = 'system_state/popular_investor_master_list';
136
+
137
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
138
+ try {
139
+ // Get the path from registry
140
+ const registryPath = collectionRegistry.getCollectionPath('system', 'popularInvestorMasterList', {});
141
+ masterListPath = registryPath;
142
+ } catch (e) {
143
+ logger.log('WARN', `[PopularInvestorFetch] Failed to get master list path from registry, using default: ${e.message}`);
144
+ }
145
+ }
146
+
147
+ const masterListRef = db.doc(masterListPath);
148
+ const masterListDoc = await masterListRef.get();
149
+
150
+ const now = new Date();
151
+ const investorsMap = masterListDoc.exists ? (masterListDoc.data().investors || {}) : {};
152
+
153
+ // Update the master list with all PIs from this fetch
154
+ for (const item of data.Items) {
155
+ const cid = String(item.CustomerId);
156
+ const username = item.UserName;
157
+
158
+ if (!cid || !username) continue;
159
+
160
+ if (!investorsMap[cid]) {
161
+ // New PI discovered
162
+ investorsMap[cid] = {
163
+ cid: cid,
164
+ username: username,
165
+ firstSeenAt: FieldValue.serverTimestamp(),
166
+ lastSeenAt: FieldValue.serverTimestamp()
167
+ };
168
+ } else {
169
+ // Existing PI - update lastSeenAt and username if changed
170
+ investorsMap[cid].lastSeenAt = FieldValue.serverTimestamp();
171
+ if (username && investorsMap[cid].username !== username) {
172
+ investorsMap[cid].username = username;
173
+ }
174
+ }
175
+ }
176
+
177
+ // Write the updated master list
178
+ await masterListRef.set({
179
+ investors: investorsMap,
180
+ lastUpdated: FieldValue.serverTimestamp(),
181
+ totalInvestors: Object.keys(investorsMap).length
182
+ }, { merge: true });
183
+
184
+ logger.log('SUCCESS', `[PopularInvestorFetch] Updated master list with ${data.Items.length} PIs. Total unique PIs: ${Object.keys(investorsMap).length}`);
185
+ } catch (masterListError) {
186
+ logger.log('WARN', `[PopularInvestorFetch] Failed to update master list: ${masterListError.message}`);
187
+ // Non-critical, continue
188
+ }
189
+
132
190
  // Update root data indexer for today's date after rankings data is stored
133
191
  try {
134
192
  const { runRootDataIndexer } = require('../../root-data-indexer/index');
@@ -424,22 +424,102 @@ async function getUserSyncStatus(req, res, dependencies, config) {
424
424
  // No request found, check if user can request
425
425
  let canRequest = true;
426
426
  let rateLimitInfo = null;
427
+ let lastUpdatedInfo = null;
427
428
 
428
- if (requestingUserCid) {
429
+ // Check last updated times for the target user
430
+ try {
431
+ const { collectionRegistry } = dependencies;
432
+ const HOURS_THRESHOLD = 24;
433
+ const thresholdMs = HOURS_THRESHOLD * 60 * 60 * 1000;
434
+ const now = Date.now();
435
+
436
+ // Determine user type (PI or signed-in user)
437
+ const referrer = req.headers.referer || req.headers.referrer || '';
438
+ const sourcePage = req.query?.sourcePage || '';
439
+ const isPI = referrer.includes('/popular-investors/') || sourcePage === 'popular-investor';
440
+
441
+ let lastUpdatedPath;
442
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
443
+ try {
444
+ if (isPI) {
445
+ lastUpdatedPath = collectionRegistry.getCollectionPath('popularInvestors', 'lastUpdated', { piCid: String(targetCidNum) });
446
+ } else {
447
+ lastUpdatedPath = collectionRegistry.getCollectionPath('signedInUsers', 'lastUpdated', { cid: String(targetCidNum) });
448
+ }
449
+ } catch (err) {
450
+ logger.log('WARN', `[getUserSyncStatus] Failed to get lastUpdated path: ${err.message}`);
451
+ }
452
+ }
453
+
454
+ if (lastUpdatedPath) {
455
+ const lastUpdatedRef = db.doc(lastUpdatedPath);
456
+ const lastUpdatedDoc = await lastUpdatedRef.get();
457
+
458
+ if (lastUpdatedDoc.exists) {
459
+ const lastUpdatedData = lastUpdatedDoc.data();
460
+ const dataTypes = ['portfolio', 'tradeHistory', 'socialPosts'];
461
+ const allRecent = dataTypes.every(dataType => {
462
+ const lastUpdated = lastUpdatedData[dataType];
463
+ if (!lastUpdated) return false;
464
+
465
+ const lastUpdatedMs = lastUpdated.toDate?.()?.getTime() || lastUpdated.toMillis?.() || null;
466
+ return lastUpdatedMs && (now - lastUpdatedMs) < thresholdMs;
467
+ });
468
+
469
+ if (allRecent) {
470
+ // All data types were updated recently - disable sync
471
+ canRequest = false;
472
+ const oldestUpdate = Math.min(...dataTypes.map(dataType => {
473
+ const lastUpdated = lastUpdatedData[dataType];
474
+ const lastUpdatedMs = lastUpdated.toDate?.()?.getTime() || lastUpdated.toMillis?.() || null;
475
+ return lastUpdatedMs || 0;
476
+ }).filter(ms => ms > 0));
477
+
478
+ const canRequestAgainAt = new Date(oldestUpdate + thresholdMs).toISOString();
479
+ lastUpdatedInfo = {
480
+ allDataTypesRecent: true,
481
+ canRequestAgainAt,
482
+ lastUpdated: {
483
+ portfolio: lastUpdatedData.portfolio?.toDate?.()?.toISOString() || null,
484
+ tradeHistory: lastUpdatedData.tradeHistory?.toDate?.()?.toISOString() || null,
485
+ socialPosts: lastUpdatedData.socialPosts?.toDate?.()?.toISOString() || null
486
+ }
487
+ };
488
+ } else {
489
+ // Some data types need updating - allow sync
490
+ lastUpdatedInfo = {
491
+ allDataTypesRecent: false,
492
+ lastUpdated: {
493
+ portfolio: lastUpdatedData.portfolio?.toDate?.()?.toISOString() || null,
494
+ tradeHistory: lastUpdatedData.tradeHistory?.toDate?.()?.toISOString() || null,
495
+ socialPosts: lastUpdatedData.socialPosts?.toDate?.()?.toISOString() || null
496
+ }
497
+ };
498
+ }
499
+ }
500
+ }
501
+ } catch (lastUpdatedError) {
502
+ logger.log('WARN', `[getUserSyncStatus] Error checking last updated times: ${lastUpdatedError.message}`);
503
+ // Non-critical, continue with rate limit check
504
+ }
505
+
506
+ if (requestingUserCid) {
429
507
  // Check if this is a developer account (bypass rate limits for developers)
430
508
  const { isDeveloperAccount } = require('../dev/dev_helpers');
431
509
  const isDeveloper = isDeveloperAccount(requestingUserCid);
432
510
 
433
511
  if (isDeveloper) {
434
- // Developer accounts bypass rate limits
435
- canRequest = true;
512
+ // Developer accounts bypass rate limits (but still respect last updated check)
436
513
  rateLimitInfo = {
437
514
  bypassed: true,
438
515
  reason: 'developer_account'
439
516
  };
440
517
  } else {
441
518
  const rateLimitCheck = await checkRateLimits(db, targetCidNum, logger);
442
- canRequest = rateLimitCheck.allowed;
519
+ // Combine rate limit and last updated checks - both must pass
520
+ if (!rateLimitCheck.allowed) {
521
+ canRequest = false;
522
+ }
443
523
  rateLimitInfo = {
444
524
  canRequestAgainAt: rateLimitCheck.canRequestAgainAt
445
525
  };
@@ -450,7 +530,8 @@ async function getUserSyncStatus(req, res, dependencies, config) {
450
530
  success: true,
451
531
  status: 'not_requested',
452
532
  canRequest,
453
- rateLimit: rateLimitInfo
533
+ rateLimit: rateLimitInfo,
534
+ lastUpdated: lastUpdatedInfo
454
535
  });
455
536
 
456
537
  } catch (error) {
@@ -228,22 +228,23 @@ async function handleRequest(message, context, configObj, dependencies) {
228
228
  await handleUpdate(data, 'single-update', dependencies, config);
229
229
  break;
230
230
  case 'POPULAR_INVESTOR_UPDATE':
231
- // For POPULAR_INVESTOR_UPDATE, the entire payload IS the task data
232
- // (not wrapped in a 'data' field like other task types)
233
- // Extract task data from payload, excluding 'type'
234
- const taskData = data || {
235
- cid: payload.cid,
236
- username: payload.username,
237
- requestId: payload.requestId,
238
- source: payload.source,
239
- requestedBy: payload.requestedBy,
240
- actualRequestedBy: payload.actualRequestedBy,
241
- metadata: payload.metadata,
242
- priority: payload.priority
231
+ // For POPULAR_INVESTOR_UPDATE, fields are at the payload root level
232
+ // Merge data object with payload root fields
233
+ const taskData = {
234
+ cid: payload.cid || data?.cid,
235
+ username: payload.username || data?.username,
236
+ requestId: payload.requestId || data?.requestId,
237
+ source: payload.source || data?.source,
238
+ requestedBy: payload.requestedBy || data?.requestedBy,
239
+ effectiveRequestedBy: payload.effectiveRequestedBy || data?.effectiveRequestedBy,
240
+ metadata: payload.metadata || data?.metadata || {},
241
+ priority: payload.priority || data?.priority,
242
+ // Merge nested data object if it exists
243
+ ...(data || {})
243
244
  };
244
245
 
245
- if (!taskData || (!taskData.cid && !taskData.username)) {
246
- logger.log('ERROR', '[TaskEngine] POPULAR_INVESTOR_UPDATE missing required fields (cid or username)', { payload, taskData });
246
+ if (!taskData.cid && !taskData.username) {
247
+ logger.log('ERROR', '[TaskEngine] POPULAR_INVESTOR_UPDATE missing required fields (cid or username)', { payload, data, taskData });
247
248
  return;
248
249
  }
249
250
 
@@ -27,6 +27,50 @@ const {
27
27
  storeSignedInUserSocialPosts
28
28
  } = require('./data_storage_helpers');
29
29
 
30
+ /**
31
+ * Helper function to update last updated timestamp for a user's data type
32
+ * @param {object} db - Firestore database instance
33
+ * @param {object} collectionRegistry - Collection registry helper
34
+ * @param {string} cid - User CID
35
+ * @param {string} userType - 'popularInvestor' or 'signedInUser'
36
+ * @param {string} dataType - 'portfolio', 'tradeHistory', or 'socialPosts'
37
+ * @param {object} logger - Logger instance
38
+ */
39
+ async function updateLastUpdatedTimestamp(db, collectionRegistry, cid, userType, dataType, logger) {
40
+ try {
41
+ const { FieldValue } = require('@google-cloud/firestore');
42
+ let lastUpdatedPath;
43
+
44
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
45
+ try {
46
+ if (userType === 'popularInvestor') {
47
+ lastUpdatedPath = collectionRegistry.getCollectionPath('popularInvestors', 'lastUpdated', { piCid: cid });
48
+ } else {
49
+ lastUpdatedPath = collectionRegistry.getCollectionPath('signedInUsers', 'lastUpdated', { cid: cid });
50
+ }
51
+ } catch (e) {
52
+ logger.log('WARN', `[LastUpdated] Failed to get path from registry: ${e.message}`);
53
+ return;
54
+ }
55
+ } else {
56
+ logger.log('WARN', '[LastUpdated] Collection registry not available');
57
+ return;
58
+ }
59
+
60
+ const lastUpdatedRef = db.doc(lastUpdatedPath);
61
+ const updateData = {
62
+ [dataType]: FieldValue.serverTimestamp(),
63
+ updatedAt: FieldValue.serverTimestamp()
64
+ };
65
+
66
+ await lastUpdatedRef.set(updateData, { merge: true });
67
+ logger.log('INFO', `[LastUpdated] Updated ${dataType} timestamp for ${userType} ${cid}`);
68
+ } catch (error) {
69
+ logger.log('WARN', `[LastUpdated] Failed to update timestamp for ${userType} ${cid} ${dataType}: ${error.message}`);
70
+ // Non-critical, continue
71
+ }
72
+ }
73
+
30
74
  async function handlePopularInvestorUpdate(taskData, config, dependencies) {
31
75
  const { logger, proxyManager, batchManager, headerManager, db, pubsub, collectionRegistry } = dependencies;
32
76
 
@@ -37,9 +81,13 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
37
81
  }
38
82
 
39
83
  const { cid, username, requestId, source, metadata } = taskData;
84
+ const data = taskData.data || {}; // Extract data object
40
85
  // Extract targetCid from metadata if present (for optimization)
41
86
  const targetCid = metadata?.targetCid || cid;
42
87
 
88
+ // Always fetch social data (for both on-demand and scheduled updates)
89
+ const includeSocial = data.includeSocial !== false; // Default to true, only skip if explicitly false
90
+
43
91
  // Validate required fields
44
92
  if (!cid && !username) {
45
93
  logger.log('ERROR', '[PI Update] Missing required fields: cid or username', { taskData });
@@ -192,6 +240,9 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
192
240
  portfolioData
193
241
  });
194
242
 
243
+ // Update last updated timestamp for portfolio
244
+ await updateLastUpdatedTimestamp(db, collectionRegistry, cid, 'popularInvestor', 'portfolio', logger);
245
+
195
246
  // Mark success for header reporting (at least partial)
196
247
  fetchSuccess = true;
197
248
 
@@ -354,7 +405,99 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
354
405
  logger.log('WARN', `[PI Update] History fetch failed for ${username}. Status: ${historyRes?.status || 'unknown'}`);
355
406
  }
356
407
 
357
- logger.log('SUCCESS', `[PI Update] Completed full update for ${username}`);
408
+ // Fetch social data (always for all PI updates)
409
+ let socialFetched = false;
410
+ if (includeSocial && username) {
411
+ try {
412
+ logger.log('INFO', `[PI Update] Fetching social data for ${username} (${cid})...`);
413
+
414
+ // Import social helper functions
415
+ const { getGcidForUser } = require('../../social-task-handler/helpers/handler_helpers');
416
+ const { FieldValue } = require('@google-cloud/firestore');
417
+
418
+ // Get GCID for user
419
+ const gcid = await getGcidForUser(dependencies, config.social || {}, cid, username);
420
+
421
+ // Fetch social posts
422
+ const userFeedApiUrl = config.social?.userFeedApiUrl || 'https://www.etoro.com/api/edm-streams/v1/feed/user/top/';
423
+ const fetchUrl = `${userFeedApiUrl}${gcid}?take=10&offset=0&reactionsPageSize=20&badgesExperimentIsEnabled=false&client_request_id=${uuid}`;
424
+
425
+ const socialResponse = await proxyManager.fetch(fetchUrl, requestOptions);
426
+ if (socialResponse.ok) {
427
+ const socialData = await socialResponse.json();
428
+ const discussions = socialData?.discussions || [];
429
+
430
+ // Process and store social posts using new structure
431
+ const processedPostsCollection = config.social?.processedPostsCollectionName || 'social_processed_registry';
432
+ const processedRef = db.collection(processedPostsCollection);
433
+
434
+ const MAX_POSTS = 30;
435
+ const postsToStore = [];
436
+
437
+ for (const discussion of discussions.slice(0, MAX_POSTS)) {
438
+ const post = discussion.post;
439
+ if (!post || !post.id) continue;
440
+
441
+ // Check if already processed
442
+ const existing = await processedRef.doc(post.id).get();
443
+ if (existing.exists) continue;
444
+
445
+ // Prepare post data
446
+ const postData = {
447
+ id: post.id,
448
+ postId: post.id,
449
+ text: post.message?.text || '',
450
+ ownerId: post.owner?.id,
451
+ username: post.owner?.username,
452
+ createdAt: post.created,
453
+ fetchedAt: FieldValue.serverTimestamp(),
454
+ snippet: (post.message?.text || '').substring(0, 500),
455
+ stats: {
456
+ likes: discussion.emotionsData?.like?.paging?.totalCount || 0,
457
+ comments: discussion.summary?.totalCommentsAndReplies || 0
458
+ },
459
+ aiAnalysis: {
460
+ overallSentiment: "Neutral",
461
+ topics: [],
462
+ isSpam: false,
463
+ qualityScore: 0.5
464
+ },
465
+ tags: post.tags?.map(t => t.market?.symbolName).filter(Boolean) || []
466
+ };
467
+
468
+ postsToStore.push(postData);
469
+
470
+ // Mark as processed
471
+ await processedRef.doc(post.id).set({ processedAt: FieldValue.serverTimestamp() });
472
+ }
473
+
474
+ // Store posts using new structure
475
+ if (postsToStore.length > 0) {
476
+ await storePopularInvestorSocialPosts({
477
+ db,
478
+ logger,
479
+ collectionRegistry,
480
+ cid: String(cid),
481
+ date: today,
482
+ posts: postsToStore
483
+ });
484
+
485
+ // Update last updated timestamp for social posts
486
+ await updateLastUpdatedTimestamp(db, collectionRegistry, String(cid), 'popularInvestor', 'socialPosts', logger);
487
+
488
+ socialFetched = true;
489
+ logger.log('INFO', `[PI Update] Fetched and stored ${postsToStore.length} social posts for ${username}`);
490
+ }
491
+ } else {
492
+ logger.log('WARN', `[PI Update] Social fetch failed for ${username} (status: ${socialResponse.status})`);
493
+ }
494
+ } catch (socialError) {
495
+ logger.log('WARN', `[PI Update] Social fetch failed for ${username}`, socialError);
496
+ // Continue - social failure shouldn't block portfolio/history processing
497
+ }
498
+ }
499
+
500
+ logger.log('SUCCESS', `[PI Update] Completed full update for ${username} (Portfolio: ✓, History: ✓, Social: ${socialFetched ? '✓' : '✗'})`);
358
501
 
359
502
  // Update request status and trigger computation if this is an on-demand request (PI fetch or sync)
360
503
  if (requestId && (source === 'on_demand' || source === 'on_demand_sync') && db) {
@@ -395,6 +538,10 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
395
538
  // Get rootDataIndexer config from injected config object
396
539
  const rootDataIndexerConfig = config.rootDataIndexer || {};
397
540
 
541
+ // Determine which data types ran
542
+ const dataTypesRun = ['piPortfolios', 'piHistory'];
543
+ if (socialFetched) dataTypesRun.push('piSocial');
544
+
398
545
  const wasIndexed = await conditionallyRunRootDataIndexer({
399
546
  db,
400
547
  logger,
@@ -402,7 +549,7 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
402
549
  rootDataIndexerConfig,
403
550
  dependencies,
404
551
  counterRef: null, // No counter for on-demand PI updates
405
- dataTypesRun: ['piPortfolios', 'piHistory']
552
+ dataTypesRun: dataTypesRun
406
553
  });
407
554
 
408
555
  // Update status to indicate indexing is done (or skipped), computation is being triggered
@@ -522,7 +669,8 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
522
669
  async function handleOnDemandUserUpdate(taskData, config, dependencies) {
523
670
  const { cid, username, requestId, source, metadata } = taskData;
524
671
  const data = taskData.data || {}; // Extract data object
525
- const includeSocial = data.includeSocial === true; // Check if social should be fetched
672
+ // All on-demand requests should fetch social data
673
+ const includeSocial = data.includeSocial !== false; // Default to true, only skip if explicitly false
526
674
  const since = data.since || new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString(); // Default to 7 days
527
675
  const portfolioOnly = data.portfolioOnly === false ? false : true; // Default to true (fetch portfolio)
528
676
 
@@ -701,6 +849,7 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
701
849
  date: today,
702
850
  portfolioData
703
851
  });
852
+ await updateLastUpdatedTimestamp(db, collectionRegistry, String(cid), 'popularInvestor', 'portfolio', logger);
704
853
  } else {
705
854
  await storeSignedInUserPortfolio({
706
855
  db,
@@ -710,6 +859,7 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
710
859
  date: today,
711
860
  portfolioData
712
861
  });
862
+ await updateLastUpdatedTimestamp(db, collectionRegistry, String(cid), 'signedInUser', 'portfolio', logger);
713
863
  }
714
864
  } catch (batchErr) {
715
865
  const errorMsg = `Failed to store portfolio data: ${batchErr.message}`;
@@ -783,14 +933,17 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
783
933
  try {
784
934
  // Use correct storage function based on user type
785
935
  if (userType === 'POPULAR_INVESTOR') {
786
- await storePopularInvestorTradeHistory({
787
- db,
788
- logger,
789
- collectionRegistry,
790
- cid: String(cid),
791
- date: today,
792
- historyData
793
- });
936
+ await storePopularInvestorTradeHistory({
937
+ db,
938
+ logger,
939
+ collectionRegistry,
940
+ cid: String(cid),
941
+ date: today,
942
+ historyData
943
+ });
944
+
945
+ // Update last updated timestamp for trade history
946
+ await updateLastUpdatedTimestamp(db, collectionRegistry, String(cid), 'popularInvestor', 'tradeHistory', logger);
794
947
  } else {
795
948
  await storeSignedInUserTradeHistory({
796
949
  db,
@@ -800,6 +953,7 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
800
953
  date: today,
801
954
  historyData
802
955
  });
956
+ await updateLastUpdatedTimestamp(db, collectionRegistry, String(cid), 'signedInUser', 'tradeHistory', logger);
803
957
  }
804
958
  historyFetched = true;
805
959
  } catch (batchErr) {
@@ -832,9 +986,9 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
832
986
  logger.log('INFO', `[On-Demand Update] Skipping history fetch (portfolioOnly=false) for ${username}`);
833
987
  }
834
988
 
835
- // Fetch social data - always for on-demand syncs
989
+ // Fetch social data for on-demand syncs
836
990
  let socialFetched = false;
837
- if (username) {
991
+ if (includeSocial && username) {
838
992
  try {
839
993
  logger.log('INFO', `[On-Demand Update] Fetching social data for ${username} (${cid})...`);
840
994
 
@@ -919,6 +1073,9 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
919
1073
  date: today,
920
1074
  posts: postsToStore
921
1075
  });
1076
+
1077
+ // Update last updated timestamp for social posts
1078
+ await updateLastUpdatedTimestamp(db, collectionRegistry, String(cid), 'signedInUser', 'socialPosts', logger);
922
1079
  }
923
1080
 
924
1081
  // Update date tracking document (for root data indexer)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.516",
3
+ "version": "1.0.518",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [