bulltrackers-module 1.0.539 → 1.0.541
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.
- package/functions/alert-system/helpers/alert_helpers.js +7 -10
- package/functions/fetch-popular-investors/helpers/fetch_helpers.js +75 -17
- package/functions/generic-api/user-api/helpers/core/user_status_helpers.js +133 -18
- package/functions/generic-api/user-api/helpers/fetch/on_demand_fetch_helpers.js +9 -35
- package/functions/generic-api/user-api/helpers/search/pi_search_helpers.js +119 -30
- package/functions/generic-api/user-api/helpers/watchlist/watchlist_generation_helpers.js +13 -30
- package/package.json +1 -1
|
@@ -186,20 +186,17 @@ function shouldTriggerAlert(subscription, alertTypeId) {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* Get PI username from
|
|
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
|
|
194
|
-
const
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
196
|
+
const existingInvestors = masterListDoc.exists ? (masterListDoc.data().investors || {}) : {};
|
|
197
|
+
const investorsToUpdate = {};
|
|
198
|
+
let newInvestorsCount = 0;
|
|
199
|
+
let updatedInvestorsCount = 0;
|
|
196
200
|
|
|
197
|
-
//
|
|
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 (!
|
|
208
|
+
if (!existingInvestors[cid]) {
|
|
205
209
|
// New PI discovered
|
|
206
|
-
|
|
210
|
+
investorsToUpdate[cid] = {
|
|
207
211
|
cid: cid,
|
|
208
212
|
username: username,
|
|
209
|
-
firstSeenAt:
|
|
210
|
-
lastSeenAt:
|
|
213
|
+
firstSeenAt: now,
|
|
214
|
+
lastSeenAt: now
|
|
211
215
|
};
|
|
216
|
+
newInvestorsCount++;
|
|
212
217
|
} else {
|
|
213
|
-
// Existing PI -
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
const
|
|
48
|
-
const
|
|
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 (!
|
|
164
|
+
if (!isPI) {
|
|
51
165
|
return null;
|
|
52
166
|
}
|
|
53
167
|
|
|
54
|
-
|
|
55
|
-
const
|
|
168
|
+
// Get username from master list
|
|
169
|
+
const username = await getPIUsernameFromMasterList(db, userCid, collectionRegistry, logger);
|
|
56
170
|
|
|
57
|
-
if (!
|
|
171
|
+
if (!username) {
|
|
58
172
|
return null;
|
|
59
173
|
}
|
|
60
174
|
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
505
|
-
* Uses
|
|
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
|
-
//
|
|
510
|
-
const {
|
|
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
|
|
513
|
-
const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
|
|
513
|
+
const username = await getPIUsernameFromMasterList(db, piCid, collectionRegistry, logger);
|
|
514
514
|
|
|
515
|
-
if (
|
|
516
|
-
logger.log('
|
|
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
|
|
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,7 +3,7 @@
|
|
|
3
3
|
* Handles PI search endpoints
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { getPIMasterList } = require('../core/user_status_helpers');
|
|
7
7
|
const { tryDecompress } = require('../core/compression_helpers');
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -19,41 +19,130 @@ async function searchPopularInvestors(req, res, dependencies, config) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
try {
|
|
22
|
-
|
|
23
|
-
const
|
|
22
|
+
// Use master list instead of rankings (single source of truth)
|
|
23
|
+
const collectionRegistry = dependencies.collectionRegistry || null;
|
|
24
|
+
const investors = await getPIMasterList(db, collectionRegistry, logger);
|
|
24
25
|
|
|
25
|
-
if (
|
|
26
|
-
return res.status(404).json({ error: "
|
|
26
|
+
if (Object.keys(investors).length === 0) {
|
|
27
|
+
return res.status(404).json({ error: "Popular investor data not available" });
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
const
|
|
30
|
+
// Search by username (case-insensitive, partial match)
|
|
31
|
+
const searchQuery = query.toLowerCase().trim();
|
|
32
|
+
const matches = [];
|
|
33
|
+
const rankingsCollection = config.popularInvestorRankingsCollection || process.env.FIRESTORE_COLLECTION_PI_RANKINGS || 'popular_investor_rankings';
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
+
// Cache rankings data by date to avoid fetching the same date multiple times
|
|
36
|
+
const rankingsCache = new Map();
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
38
|
+
/**
|
|
39
|
+
* Helper to get rankings data for a specific date
|
|
40
|
+
*/
|
|
41
|
+
const getRankingsForDate = async (dateStr) => {
|
|
42
|
+
if (rankingsCache.has(dateStr)) {
|
|
43
|
+
return rankingsCache.get(dateStr);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const rankingsRef = db.collection(rankingsCollection).doc(dateStr);
|
|
48
|
+
const rankingsDoc = await rankingsRef.get();
|
|
49
|
+
|
|
50
|
+
if (!rankingsDoc.exists) {
|
|
51
|
+
rankingsCache.set(dateStr, null);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rawRankingsData = rankingsDoc.data();
|
|
56
|
+
const rankingsData = tryDecompress(rawRankingsData);
|
|
57
|
+
const rankingsItems = rankingsData.Items || [];
|
|
58
|
+
|
|
59
|
+
// Create a map for quick lookup by CID
|
|
60
|
+
const rankingsMap = new Map();
|
|
61
|
+
for (const item of rankingsItems) {
|
|
62
|
+
if (item.CustomerId) {
|
|
63
|
+
rankingsMap.set(String(item.CustomerId), item);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rankingsCache.set(dateStr, rankingsMap);
|
|
68
|
+
return rankingsMap;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (logger) {
|
|
71
|
+
logger.log('WARN', `[searchPopularInvestors] Failed to fetch rankings for date ${dateStr}: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
rankingsCache.set(dateStr, null);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
40
77
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Convert timestamp to date string (YYYY-MM-DD)
|
|
80
|
+
*/
|
|
81
|
+
const timestampToDateStr = (timestamp) => {
|
|
82
|
+
if (!timestamp) return null;
|
|
83
|
+
|
|
84
|
+
// Handle Firestore Timestamp
|
|
85
|
+
if (timestamp.toDate && typeof timestamp.toDate === 'function') {
|
|
86
|
+
return timestamp.toDate().toISOString().split('T')[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle Date object
|
|
90
|
+
if (timestamp instanceof Date) {
|
|
91
|
+
return timestamp.toISOString().split('T')[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle number (milliseconds since epoch)
|
|
95
|
+
if (typeof timestamp === 'number') {
|
|
96
|
+
return new Date(timestamp).toISOString().split('T')[0];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const [cid, investor] of Object.entries(investors)) {
|
|
103
|
+
if (investor.username) {
|
|
104
|
+
const username = investor.username.toLowerCase();
|
|
105
|
+
if (username.includes(searchQuery)) {
|
|
106
|
+
// Get ranking metrics from rankings collection using lastSeenAt
|
|
107
|
+
let aum = null;
|
|
108
|
+
let riskScore = null;
|
|
109
|
+
let gain = null;
|
|
110
|
+
let copiers = null;
|
|
111
|
+
|
|
112
|
+
if (investor.lastSeenAt) {
|
|
113
|
+
const rankingsDate = timestampToDateStr(investor.lastSeenAt);
|
|
114
|
+
|
|
115
|
+
if (rankingsDate) {
|
|
116
|
+
const rankingsMap = await getRankingsForDate(rankingsDate);
|
|
117
|
+
|
|
118
|
+
if (rankingsMap) {
|
|
119
|
+
const rankingEntry = rankingsMap.get(cid);
|
|
120
|
+
|
|
121
|
+
if (rankingEntry) {
|
|
122
|
+
aum = rankingEntry.AUMValue || null;
|
|
123
|
+
riskScore = rankingEntry.RiskScore || null;
|
|
124
|
+
gain = rankingEntry.Gain || null;
|
|
125
|
+
copiers = rankingEntry.Copiers || null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
matches.push({
|
|
132
|
+
cid: Number(cid),
|
|
133
|
+
username: investor.username,
|
|
134
|
+
aum: aum,
|
|
135
|
+
riskScore: riskScore,
|
|
136
|
+
gain: gain,
|
|
137
|
+
copiers: copiers
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (matches.length >= parseInt(limit)) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
57
146
|
|
|
58
147
|
return res.status(200).json({
|
|
59
148
|
results: matches,
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { FieldValue } = require('@google-cloud/firestore');
|
|
7
|
-
const { findLatestPortfolioDate, findLatestComputationDate
|
|
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
|
-
//
|
|
150
|
-
const
|
|
151
|
-
const
|
|
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
|
-
|
|
183
|
-
const username =
|
|
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
|
|
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),
|