bulltrackers-module 1.0.539 → 1.0.540

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.
@@ -186,20 +186,17 @@ function shouldTriggerAlert(subscription, alertTypeId) {
186
186
  }
187
187
 
188
188
  /**
189
- * Get PI username from rankings or subscriptions
189
+ * Get PI username from master list (single source of truth)
190
+ * Falls back to subscriptions if not in master list
190
191
  */
191
192
  async function getPIUsername(db, piCid) {
192
193
  try {
193
- // Try to get from rankings first
194
- const rankingsRef = db.collection('rankings')
195
- .doc('latest')
196
- .collection('popular_investors')
197
- .doc(String(piCid));
194
+ // Try to get from master list first (single source of truth)
195
+ const { getPIUsernameFromMasterList } = require('../../generic-api/user-api/helpers/core/user_status_helpers');
196
+ const username = await getPIUsernameFromMasterList(db, piCid, null, null);
198
197
 
199
- const rankingsDoc = await rankingsRef.get();
200
- if (rankingsDoc.exists) {
201
- const data = rankingsDoc.data();
202
- return data.username || `PI-${piCid}`;
198
+ if (username) {
199
+ return username;
203
200
  }
204
201
 
205
202
  // Fallback: try to get from any subscription
@@ -174,6 +174,7 @@ async function fetchAndStorePopularInvestors(config, dependencies) {
174
174
  logger.log('SUCCESS', `[PopularInvestorFetch] Stored ${data.TotalRows} rankings into ${finalRankingsCollectionName}/${today}${firestorePayload._compressed ? ' (compressed)' : ''}`);
175
175
 
176
176
  // Update the master list of Popular Investors
177
+ // Use batched writes to avoid 500 field transform limit
177
178
  try {
178
179
  const { FieldValue } = require('@google-cloud/firestore');
179
180
  let masterListPath = 'system_state/popular_investor_master_list';
@@ -192,40 +193,97 @@ async function fetchAndStorePopularInvestors(config, dependencies) {
192
193
  const masterListDoc = await masterListRef.get();
193
194
 
194
195
  const now = new Date();
195
- const investorsMap = masterListDoc.exists ? (masterListDoc.data().investors || {}) : {};
196
+ const existingInvestors = masterListDoc.exists ? (masterListDoc.data().investors || {}) : {};
197
+ const investorsToUpdate = {};
198
+ let newInvestorsCount = 0;
199
+ let updatedInvestorsCount = 0;
196
200
 
197
- // Update the master list with all PIs from this fetch
201
+ // Identify which investors need updating (only those in current fetch)
198
202
  for (const item of data.Items) {
199
203
  const cid = String(item.CustomerId);
200
204
  const username = item.UserName;
201
205
 
202
206
  if (!cid || !username) continue;
203
207
 
204
- if (!investorsMap[cid]) {
208
+ if (!existingInvestors[cid]) {
205
209
  // New PI discovered
206
- investorsMap[cid] = {
210
+ investorsToUpdate[cid] = {
207
211
  cid: cid,
208
212
  username: username,
209
- firstSeenAt: FieldValue.serverTimestamp(),
210
- lastSeenAt: FieldValue.serverTimestamp()
213
+ firstSeenAt: now,
214
+ lastSeenAt: now
211
215
  };
216
+ newInvestorsCount++;
212
217
  } else {
213
- // Existing PI - update lastSeenAt and username if changed
214
- investorsMap[cid].lastSeenAt = FieldValue.serverTimestamp();
215
- if (username && investorsMap[cid].username !== username) {
216
- investorsMap[cid].username = username;
218
+ // Existing PI - check if username changed or needs lastSeenAt update
219
+ const needsUpdate = existingInvestors[cid].username !== username;
220
+ if (needsUpdate) {
221
+ investorsToUpdate[cid] = {
222
+ ...existingInvestors[cid],
223
+ username: username,
224
+ lastSeenAt: now
225
+ };
226
+ updatedInvestorsCount++;
227
+ } else {
228
+ // Just update lastSeenAt timestamp
229
+ investorsToUpdate[cid] = {
230
+ ...existingInvestors[cid],
231
+ lastSeenAt: now
232
+ };
233
+ updatedInvestorsCount++;
217
234
  }
218
235
  }
219
236
  }
220
237
 
221
- // Write the updated master list
222
- await masterListRef.set({
223
- investors: investorsMap,
224
- lastUpdated: FieldValue.serverTimestamp(),
225
- totalInvestors: Object.keys(investorsMap).length
226
- }, { merge: true });
238
+ // Use batched writes to update only changed investors
239
+ // Since we're using regular Date objects (not serverTimestamp()), we avoid field transform limits
240
+ // But we still batch to handle large numbers of investors efficiently
241
+ const BATCH_SIZE = 450; // Firestore allows 500 operations per batch, leave room for metadata
242
+ const investorsToUpdateEntries = Object.entries(investorsToUpdate);
243
+ const totalBatches = Math.ceil(investorsToUpdateEntries.length / BATCH_SIZE);
227
244
 
228
- logger.log('SUCCESS', `[PopularInvestorFetch] Updated master list with ${data.Items.length} PIs. Total unique PIs: ${Object.keys(investorsMap).length}`);
245
+ // If document doesn't exist, create it with all investors in first batch
246
+ if (!masterListDoc.exists && investorsToUpdateEntries.length > 0) {
247
+ const batch = db.batch();
248
+ const finalInvestorsMap = { ...existingInvestors, ...investorsToUpdate };
249
+ batch.set(masterListRef, {
250
+ investors: finalInvestorsMap,
251
+ lastUpdated: FieldValue.serverTimestamp(),
252
+ totalInvestors: Object.keys(finalInvestorsMap).length
253
+ }, { merge: true });
254
+ await batch.commit();
255
+ logger.log('INFO', `[PopularInvestorFetch] Created master list with ${Object.keys(finalInvestorsMap).length} investors`);
256
+ } else if (investorsToUpdateEntries.length > 0) {
257
+ // Document exists - update only changed investors in batches
258
+ for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
259
+ const batch = db.batch();
260
+ const startIdx = batchIdx * BATCH_SIZE;
261
+ const endIdx = Math.min(startIdx + BATCH_SIZE, investorsToUpdateEntries.length);
262
+ const batchEntries = investorsToUpdateEntries.slice(startIdx, endIdx);
263
+
264
+ // Build update object with all investors in this batch
265
+ const batchUpdateData = {};
266
+ for (const [cid, investorData] of batchEntries) {
267
+ batchUpdateData[`investors.${cid}`] = investorData;
268
+ }
269
+
270
+ // Update metadata (lastUpdated, totalInvestors) only in the last batch
271
+ if (batchIdx === totalBatches - 1) {
272
+ const finalInvestorsMap = { ...existingInvestors, ...investorsToUpdate };
273
+ batchUpdateData.lastUpdated = FieldValue.serverTimestamp();
274
+ batchUpdateData.totalInvestors = Object.keys(finalInvestorsMap).length;
275
+ }
276
+
277
+ batch.update(masterListRef, batchUpdateData);
278
+ await batch.commit();
279
+
280
+ if (totalBatches > 1) {
281
+ logger.log('INFO', `[PopularInvestorFetch] Updated master list batch ${batchIdx + 1}/${totalBatches} (${batchEntries.length} investors)`);
282
+ }
283
+ }
284
+ }
285
+
286
+ logger.log('SUCCESS', `[PopularInvestorFetch] Updated master list: ${newInvestorsCount} new, ${updatedInvestorsCount} updated. Total unique PIs: ${Object.keys({ ...existingInvestors, ...investorsToUpdate }).length}`);
229
287
  } catch (masterListError) {
230
288
  logger.log('WARN', `[PopularInvestorFetch] Failed to update master list: ${masterListError.message}`);
231
289
  // Non-critical, continue
@@ -5,6 +5,120 @@
5
5
 
6
6
  const { findLatestRankingsDate } = require('./data_lookup_helpers');
7
7
 
8
+ /**
9
+ * Get Popular Investor master list from Firestore
10
+ * @param {Firestore} db - Firestore instance
11
+ * @param {object} collectionRegistry - Collection registry (optional)
12
+ * @param {object} logger - Logger instance (optional)
13
+ * @returns {Promise<object>} - Master list investors map { cid: { cid, username, firstSeenAt, lastSeenAt } }
14
+ */
15
+ async function getPIMasterList(db, collectionRegistry = null, logger = null) {
16
+ try {
17
+ let masterListPath = 'system_state/popular_investor_master_list';
18
+
19
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
20
+ try {
21
+ masterListPath = collectionRegistry.getCollectionPath('system', 'popularInvestorMasterList', {});
22
+ } catch (err) {
23
+ if (logger) {
24
+ logger.log('WARN', `[getPIMasterList] Failed to get master list path from registry, using default: ${err.message}`);
25
+ }
26
+ }
27
+ }
28
+
29
+ const masterListRef = db.doc(masterListPath);
30
+ const masterListDoc = await masterListRef.get();
31
+
32
+ if (!masterListDoc.exists) {
33
+ return {};
34
+ }
35
+
36
+ const masterListData = masterListDoc.data();
37
+ return masterListData.investors || {};
38
+ } catch (error) {
39
+ if (logger) {
40
+ logger.log('ERROR', `[getPIMasterList] Error loading master list: ${error.message}`);
41
+ }
42
+ return {};
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Get PI username from CID using master list
48
+ * @param {Firestore} db - Firestore instance
49
+ * @param {string|number} piCid - Popular Investor CID
50
+ * @param {object} collectionRegistry - Collection registry (optional)
51
+ * @param {object} logger - Logger instance (optional)
52
+ * @returns {Promise<string|null>} - Username or null if not found
53
+ */
54
+ async function getPIUsernameFromMasterList(db, piCid, collectionRegistry = null, logger = null) {
55
+ try {
56
+ const investors = await getPIMasterList(db, collectionRegistry, logger);
57
+ const cidStr = String(piCid);
58
+ const investor = investors[cidStr];
59
+
60
+ if (investor && investor.username) {
61
+ return investor.username;
62
+ }
63
+
64
+ return null;
65
+ } catch (error) {
66
+ if (logger) {
67
+ logger.log('ERROR', `[getPIUsernameFromMasterList] Error getting username for PI ${piCid}: ${error.message}`);
68
+ }
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Get PI CID from username using master list
75
+ * @param {Firestore} db - Firestore instance
76
+ * @param {string} username - Popular Investor username
77
+ * @param {object} collectionRegistry - Collection registry (optional)
78
+ * @param {object} logger - Logger instance (optional)
79
+ * @returns {Promise<string|null>} - CID or null if not found
80
+ */
81
+ async function getPICidFromMasterList(db, username, collectionRegistry = null, logger = null) {
82
+ try {
83
+ const investors = await getPIMasterList(db, collectionRegistry, logger);
84
+ const searchUsername = username.toLowerCase().trim();
85
+
86
+ for (const [cid, investor] of Object.entries(investors)) {
87
+ if (investor.username && investor.username.toLowerCase().trim() === searchUsername) {
88
+ return cid;
89
+ }
90
+ }
91
+
92
+ return null;
93
+ } catch (error) {
94
+ if (logger) {
95
+ logger.log('ERROR', `[getPICidFromMasterList] Error getting CID for username ${username}: ${error.message}`);
96
+ }
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Check if a CID is a Popular Investor using master list
103
+ * @param {Firestore} db - Firestore instance
104
+ * @param {string|number} cid - User CID
105
+ * @param {object} collectionRegistry - Collection registry (optional)
106
+ * @param {object} logger - Logger instance (optional)
107
+ * @returns {Promise<boolean>} - True if PI, false otherwise
108
+ */
109
+ async function isPopularInvestor(db, cid, collectionRegistry = null, logger = null) {
110
+ try {
111
+ const investors = await getPIMasterList(db, collectionRegistry, logger);
112
+ const cidStr = String(cid);
113
+ return investors.hasOwnProperty(cidStr);
114
+ } catch (error) {
115
+ if (logger) {
116
+ logger.log('ERROR', `[isPopularInvestor] Error checking if ${cid} is PI: ${error.message}`);
117
+ }
118
+ return false;
119
+ }
120
+ }
121
+
8
122
  /**
9
123
  * Check if a signed-in user is also a Popular Investor
10
124
  * Returns ranking entry if found, null otherwise
@@ -43,31 +157,28 @@ async function checkIfUserIsPI(db, userCid, config, logger = null) {
43
157
  return fakeRankEntry;
44
158
  }
45
159
 
46
- // Otherwise, check real rankings
47
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
48
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
160
+ // Otherwise, check master list (single source of truth)
161
+ const collectionRegistry = config.collectionRegistry || null;
162
+ const isPI = await isPopularInvestor(db, userCid, collectionRegistry, logger);
49
163
 
50
- if (!rankingsDate) {
164
+ if (!isPI) {
51
165
  return null;
52
166
  }
53
167
 
54
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
55
- const rankingsDoc = await rankingsRef.get();
168
+ // Get username from master list
169
+ const username = await getPIUsernameFromMasterList(db, userCid, collectionRegistry, logger);
56
170
 
57
- if (!rankingsDoc.exists) {
171
+ if (!username) {
58
172
  return null;
59
173
  }
60
174
 
61
- const rawRankingsData = rankingsDoc.data();
62
- // Decompress if needed
63
- const { tryDecompress } = require('./compression_helpers');
64
- const rankingsData = tryDecompress(rawRankingsData);
65
- const rankingsItems = rankingsData.Items || [];
66
-
67
- // Find user in rankings
68
- const userRankEntry = rankingsItems.find(item => String(item.CustomerId) === String(userCid));
69
-
70
- return userRankEntry || null;
175
+ // Return a ranking-like entry for compatibility (minimal data)
176
+ // Note: Full ranking data (AUM, copiers, etc.) is not in master list
177
+ // If full ranking data is needed, it should be fetched from rankings collection separately
178
+ return {
179
+ CustomerId: Number(userCid),
180
+ UserName: username
181
+ };
71
182
  } catch (error) {
72
183
  console.error('[checkIfUserIsPI] Error checking if user is PI:', error);
73
184
  return null;
@@ -75,6 +186,10 @@ async function checkIfUserIsPI(db, userCid, config, logger = null) {
75
186
  }
76
187
 
77
188
  module.exports = {
78
- checkIfUserIsPI
189
+ checkIfUserIsPI,
190
+ getPIMasterList,
191
+ getPIUsernameFromMasterList,
192
+ getPICidFromMasterList,
193
+ isPopularInvestor
79
194
  };
80
195
 
@@ -501,49 +501,23 @@ async function checkRateLimits(db, userCid, piCid, logger) {
501
501
  }
502
502
 
503
503
  /**
504
- * Get PI username from rankings
505
- * Uses latest available rankings date (with fallback)
504
+ * Get PI username from master list (single source of truth)
505
+ * Uses the popular investor master list instead of rankings
506
506
  */
507
507
  async function getPiUsername(db, piCid, config, logger) {
508
508
  try {
509
- // Import the helper function from data_helpers
510
- const { findLatestRankingsDate } = require('../data_helpers');
509
+ // Use master list helper instead of rankings
510
+ const { getPIUsernameFromMasterList } = require('../core/user_status_helpers');
511
+ const collectionRegistry = config.collectionRegistry || null;
511
512
 
512
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
513
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
513
+ const username = await getPIUsernameFromMasterList(db, piCid, collectionRegistry, logger);
514
514
 
515
- if (!rankingsDate) {
516
- logger.log('WARN', `[getPiUsername] No rankings data found (checked last 30 days) for PI ${piCid}`);
517
- return null;
518
- }
519
-
520
- // Fetch rankings document for the latest available date
521
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
522
- const rankingsDoc = await rankingsRef.get();
523
-
524
- if (!rankingsDoc.exists) {
525
- logger.log('WARN', `[getPiUsername] Rankings doc does not exist for date ${rankingsDate}`);
526
- return null;
527
- }
528
-
529
- const rawRankingsData = rankingsDoc.data();
530
- // Decompress if needed
531
- const rankingsData = tryDecompress(rawRankingsData);
532
- const rankingsItems = rankingsData.Items || [];
533
-
534
- // Search for the PI in the rankings items
535
- const piCidNum = Number(piCid);
536
- const rankingEntry = rankingsItems.find(item => Number(item.CustomerId) === piCidNum);
537
-
538
- if (rankingEntry) {
539
- const username = rankingEntry.UserName || rankingEntry.username || null;
540
- if (username) {
541
- logger.log('INFO', `[getPiUsername] Found username "${username}" for PI ${piCid} in rankings date ${rankingsDate}`);
542
- }
515
+ if (username) {
516
+ logger.log('INFO', `[getPiUsername] Found username "${username}" for PI ${piCid} from master list`);
543
517
  return username;
544
518
  }
545
519
 
546
- logger.log('WARN', `[getPiUsername] PI ${piCid} not found in rankings date ${rankingsDate}`);
520
+ logger.log('WARN', `[getPiUsername] PI ${piCid} not found in master list`);
547
521
  return null;
548
522
  } catch (error) {
549
523
  logger.log('ERROR', `[getPiUsername] Error fetching username for PI ${piCid}`, error);
@@ -3,8 +3,7 @@
3
3
  * Handles PI search endpoints
4
4
  */
5
5
 
6
- const { findLatestRankingsDate } = require('../core/data_lookup_helpers');
7
- const { tryDecompress } = require('../core/compression_helpers');
6
+ const { getPIMasterList } = require('../core/user_status_helpers');
8
7
 
9
8
  /**
10
9
  * GET /user/search/pis
@@ -19,41 +18,35 @@ async function searchPopularInvestors(req, res, dependencies, config) {
19
18
  }
20
19
 
21
20
  try {
22
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
23
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
21
+ // Use master list instead of rankings (single source of truth)
22
+ const collectionRegistry = dependencies.collectionRegistry || null;
23
+ const investors = await getPIMasterList(db, collectionRegistry, logger);
24
24
 
25
- if (!rankingsDate) {
26
- return res.status(404).json({ error: "Rankings data not available" });
25
+ if (Object.keys(investors).length === 0) {
26
+ return res.status(404).json({ error: "Popular investor data not available" });
27
27
  }
28
28
 
29
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
30
- const rankingsDoc = await rankingsRef.get();
31
-
32
- if (!rankingsDoc.exists) {
33
- return res.status(404).json({ error: "Rankings data not available" });
34
- }
35
-
36
- const rawRankingsData = rankingsDoc.data();
37
- // Decompress if needed
38
- const rankingsData = tryDecompress(rawRankingsData);
39
- const rankingsItems = rankingsData.Items || [];
40
-
41
29
  // Search by username (case-insensitive, partial match)
42
30
  const searchQuery = query.toLowerCase().trim();
43
- const matches = rankingsItems
44
- .filter(item => {
45
- const username = (item.UserName || '').toLowerCase();
46
- return username.includes(searchQuery);
47
- })
48
- .slice(0, parseInt(limit))
49
- .map(item => ({
50
- cid: item.CustomerId,
51
- username: item.UserName,
52
- aum: item.AUMValue,
53
- riskScore: item.RiskScore,
54
- gain: item.Gain,
55
- copiers: item.Copiers
56
- }));
31
+ const matches = [];
32
+
33
+ for (const [cid, investor] of Object.entries(investors)) {
34
+ if (investor.username) {
35
+ const username = investor.username.toLowerCase();
36
+ if (username.includes(searchQuery)) {
37
+ matches.push({
38
+ cid: Number(cid),
39
+ username: investor.username
40
+ // Note: AUM, riskScore, gain, copiers are not in master list
41
+ // If needed, they should be fetched from rankings collection separately
42
+ });
43
+
44
+ if (matches.length >= parseInt(limit)) {
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ }
57
50
 
58
51
  return res.status(200).json({
59
52
  results: matches,
@@ -4,10 +4,11 @@
4
4
  */
5
5
 
6
6
  const { FieldValue } = require('@google-cloud/firestore');
7
- const { findLatestPortfolioDate, findLatestComputationDate, findLatestRankingsDate } = require('../core/data_lookup_helpers');
7
+ const { findLatestPortfolioDate, findLatestComputationDate } = require('../core/data_lookup_helpers');
8
8
  const { tryDecompress } = require('../core/compression_helpers');
9
9
  const { getEffectiveCid, getCopiedPIsWithDevOverride } = require('../dev/dev_helpers');
10
10
  const { writeWithMigration } = require('../core/path_resolution_helpers');
11
+ const { getPIMasterList, getPIUsernameFromMasterList } = require('../core/user_status_helpers');
11
12
 
12
13
  /**
13
14
  * POST /user/me/watchlist/auto-generate
@@ -146,32 +147,9 @@ async function autoGenerateWatchlist(req, res, dependencies, config) {
146
147
  });
147
148
  }
148
149
 
149
- // Fetch rankings to get usernames
150
- const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
151
- const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
152
-
153
- if (!rankingsDate) {
154
- return res.status(404).json({ error: "Rankings data not available" });
155
- }
156
-
157
- const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
158
- const rankingsDoc = await rankingsRef.get();
159
-
160
- if (!rankingsDoc.exists) {
161
- return res.status(404).json({ error: "Rankings data not available" });
162
- }
163
-
164
- const rawRankingsData = rankingsDoc.data();
165
- // Decompress if needed
166
- const { tryDecompress } = require('../core/compression_helpers');
167
- const rankingsData = tryDecompress(rawRankingsData);
168
- const rankingsItems = rankingsData.Items || [];
169
- const rankingsMap = new Map();
170
- for (const item of rankingsItems) {
171
- if (item.CustomerId) {
172
- rankingsMap.set(String(item.CustomerId), item);
173
- }
174
- }
150
+ // Get usernames from master list (single source of truth)
151
+ const collectionRegistry = dependencies.collectionRegistry || null;
152
+ const investors = await getPIMasterList(db, collectionRegistry, logger);
175
153
 
176
154
  // Create watchlist items
177
155
  let matchedCount = 0;
@@ -179,10 +157,15 @@ async function autoGenerateWatchlist(req, res, dependencies, config) {
179
157
 
180
158
  for (const copiedPI of copiedPIs) {
181
159
  const cidStr = String(copiedPI.cid);
182
- const rankEntry = rankingsMap.get(cidStr);
183
- const username = rankEntry?.UserName || copiedPI.username || 'Unknown';
160
+ // Try to get username from master list
161
+ const username = await getPIUsernameFromMasterList(db, cidStr, collectionRegistry, logger)
162
+ || copiedPI.username
163
+ || 'Unknown';
184
164
 
185
- if (rankEntry) matchedCount++;
165
+ // Check if PI exists in master list
166
+ if (investors[cidStr]) {
167
+ matchedCount++;
168
+ }
186
169
 
187
170
  watchlistItems.push({
188
171
  cid: Number(cidStr),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.539",
3
+ "version": "1.0.540",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [