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