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