bulltrackers-module 1.0.451 → 1.0.453
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.
|
@@ -297,17 +297,47 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
|
|
|
297
297
|
const items = data.Items || [];
|
|
298
298
|
|
|
299
299
|
// Map to { cid, username }
|
|
300
|
-
const
|
|
300
|
+
const allTargets = items.map(item => ({
|
|
301
301
|
cid: String(item.CustomerId),
|
|
302
302
|
username: item.UserName
|
|
303
303
|
}));
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
// Filter out PIs updated in the last 18 hours
|
|
306
|
+
const HOURS_THRESHOLD = 18;
|
|
307
|
+
const thresholdMs = HOURS_THRESHOLD * 60 * 60 * 1000;
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
|
|
310
|
+
const filteredTargets = [];
|
|
311
|
+
let skippedCount = 0;
|
|
312
|
+
|
|
313
|
+
// Check each PI's last sync time
|
|
314
|
+
for (const target of allTargets) {
|
|
315
|
+
const syncRef = db.collection('user_sync_requests')
|
|
316
|
+
.doc(target.cid)
|
|
317
|
+
.collection('global')
|
|
318
|
+
.doc('latest');
|
|
319
|
+
|
|
320
|
+
const syncDoc = await syncRef.get();
|
|
321
|
+
if (syncDoc.exists) {
|
|
322
|
+
const syncData = syncDoc.data();
|
|
323
|
+
const lastRequestedAt = syncData.lastRequestedAt?.toDate?.()?.getTime() ||
|
|
324
|
+
syncData.lastRequestedAt?.toMillis?.() || null;
|
|
325
|
+
|
|
326
|
+
if (lastRequestedAt && (now - lastRequestedAt) < thresholdMs) {
|
|
327
|
+
skippedCount++;
|
|
328
|
+
continue; // Skip this PI
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
filteredTargets.push(target); // Include this PI
|
|
307
333
|
}
|
|
308
334
|
|
|
309
|
-
|
|
310
|
-
|
|
335
|
+
if (filteredTargets.length > 0) {
|
|
336
|
+
logger.log('INFO', `[Core Utils Debug] First PI Target Mapped: ${JSON.stringify(filteredTargets[0])}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
logger.log('INFO', `[Core Utils] Found ${allTargets.length} Popular Investors from ${rankingsDate} ranking${rankingsDate !== today ? ` (fallback from ${today})` : ''}. Skipped ${skippedCount} recently updated. ${filteredTargets.length} will be updated.`);
|
|
340
|
+
return filteredTargets;
|
|
311
341
|
|
|
312
342
|
} catch (error) {
|
|
313
343
|
logger.log('ERROR', '[Core Utils] Error getting Popular Investors', { errorMessage: error.message });
|
|
@@ -316,13 +346,19 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
|
|
|
316
346
|
}
|
|
317
347
|
|
|
318
348
|
/**
|
|
319
|
-
* [NEW] Fetches
|
|
349
|
+
* [NEW] Fetches Signed-In Users for daily update.
|
|
350
|
+
* Skips users updated in the last 18 hours to avoid duplicate refreshes.
|
|
320
351
|
*/
|
|
321
352
|
async function getSignedInUsersToUpdate(dependencies, config) {
|
|
322
353
|
const { db, logger } = dependencies;
|
|
323
354
|
const collectionName = config.signedInUsersCollection || process.env.FIRESTORE_COLLECTION_SIGNED_IN_USER_PORTFOLIOS || 'signed_in_users';
|
|
355
|
+
|
|
356
|
+
// 18 hours threshold - skip users updated more recently than this
|
|
357
|
+
const HOURS_THRESHOLD = 18;
|
|
358
|
+
const thresholdMs = HOURS_THRESHOLD * 60 * 60 * 1000;
|
|
359
|
+
const now = Date.now();
|
|
324
360
|
|
|
325
|
-
logger.log('INFO', `[Core Utils] Getting Signed-In Users to update from ${collectionName}...`);
|
|
361
|
+
logger.log('INFO', `[Core Utils] Getting Signed-In Users to update from ${collectionName} (skipping users updated in last ${HOURS_THRESHOLD} hours)...`);
|
|
326
362
|
|
|
327
363
|
try {
|
|
328
364
|
const snapshot = await db.collection(collectionName).get();
|
|
@@ -333,17 +369,63 @@ async function getSignedInUsersToUpdate(dependencies, config) {
|
|
|
333
369
|
}
|
|
334
370
|
|
|
335
371
|
const targets = [];
|
|
372
|
+
let skippedCount = 0;
|
|
373
|
+
|
|
336
374
|
snapshot.forEach(doc => {
|
|
337
375
|
const data = doc.data();
|
|
338
|
-
// Assuming the doc ID is the CID, or it's a field 'cid'
|
|
339
376
|
const cid = data.cid || doc.id;
|
|
340
377
|
const username = data.username || 'Unknown';
|
|
341
|
-
|
|
342
|
-
if
|
|
378
|
+
|
|
379
|
+
if (!cid) return;
|
|
380
|
+
|
|
381
|
+
// Check if user was recently updated (check sync requests and portfolio timestamps)
|
|
382
|
+
// First check sync requests for last update time
|
|
383
|
+
let shouldSkip = false;
|
|
384
|
+
|
|
385
|
+
// Check user_sync_requests for last completed sync
|
|
386
|
+
// We'll check this asynchronously, but for now we'll check portfolio data timestamps
|
|
387
|
+
// The portfolio data is stored in snapshots with dates, so we can check the latest snapshot date
|
|
388
|
+
const portfolioCollection = collectionName; // Same collection
|
|
389
|
+
const blockId = '19M';
|
|
390
|
+
const today = new Date().toISOString().split('T')[0];
|
|
391
|
+
|
|
392
|
+
// For efficiency, we'll check the sync requests collection for the last update time
|
|
393
|
+
// This is a synchronous check we can do here
|
|
394
|
+
// Actually, we need to make this async, so we'll do a batch check after
|
|
395
|
+
|
|
396
|
+
// For now, include all users - we'll filter in the dispatcher
|
|
397
|
+
targets.push({ cid: String(cid), username });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Now filter out users who were updated in the last 18 hours
|
|
401
|
+
// Check user_sync_requests for last completed sync
|
|
402
|
+
const filteredTargets = [];
|
|
403
|
+
const checkPromises = targets.map(async (target) => {
|
|
404
|
+
const syncRef = db.collection('user_sync_requests')
|
|
405
|
+
.doc(target.cid)
|
|
406
|
+
.collection('global')
|
|
407
|
+
.doc('latest');
|
|
408
|
+
|
|
409
|
+
const syncDoc = await syncRef.get();
|
|
410
|
+
if (syncDoc.exists) {
|
|
411
|
+
const syncData = syncDoc.data();
|
|
412
|
+
const lastRequestedAt = syncData.lastRequestedAt?.toDate?.()?.getTime() ||
|
|
413
|
+
syncData.lastRequestedAt?.toMillis?.() || null;
|
|
414
|
+
|
|
415
|
+
if (lastRequestedAt && (now - lastRequestedAt) < thresholdMs) {
|
|
416
|
+
skippedCount++;
|
|
417
|
+
return null; // Skip this user
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return target; // Include this user
|
|
343
422
|
});
|
|
423
|
+
|
|
424
|
+
const results = await Promise.all(checkPromises);
|
|
425
|
+
const finalTargets = results.filter(t => t !== null);
|
|
344
426
|
|
|
345
|
-
logger.log('INFO', `[Core Utils] Found ${targets.length} Signed-In Users.`);
|
|
346
|
-
return
|
|
427
|
+
logger.log('INFO', `[Core Utils] Found ${targets.length} Signed-In Users. Skipped ${skippedCount} recently updated. ${finalTargets.length} will be updated.`);
|
|
428
|
+
return finalTargets;
|
|
347
429
|
|
|
348
430
|
} catch (error) {
|
|
349
431
|
logger.log('ERROR', '[Core Utils] Error getting Signed-In Users', { errorMessage: error.message });
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview User Sync Request Helpers
|
|
3
|
+
* Allows users to request on-demand sync for their own profile or Popular Investor profiles
|
|
4
|
+
* Rate limiting: 1 update per user per 6 hours (global, not per-requester)
|
|
5
|
+
* Developer accounts bypass rate limits
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { FieldValue } = require('@google-cloud/firestore');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
const RATE_LIMIT_HOURS = 6;
|
|
12
|
+
const RATE_LIMIT_MS = RATE_LIMIT_HOURS * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Request on-demand sync for a user (signed-in user or Popular Investor)
|
|
16
|
+
* POST /user/:userCid/sync
|
|
17
|
+
*/
|
|
18
|
+
async function requestUserSync(req, res, dependencies, config) {
|
|
19
|
+
const { db, logger, pubsub } = dependencies;
|
|
20
|
+
const { userCid: targetUserCid } = req.params;
|
|
21
|
+
|
|
22
|
+
// Get requesting userCid from query
|
|
23
|
+
const requestingUserCid = req.query?.userCid;
|
|
24
|
+
|
|
25
|
+
if (!requestingUserCid) {
|
|
26
|
+
return res.status(400).json({
|
|
27
|
+
success: false,
|
|
28
|
+
error: "Missing userCid",
|
|
29
|
+
message: "Please provide userCid as a query parameter"
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for dev override impersonation
|
|
34
|
+
const { getEffectiveCid, getDevOverride } = require('./dev_helpers');
|
|
35
|
+
const effectiveCid = await getEffectiveCid(db, requestingUserCid, config, logger);
|
|
36
|
+
const devOverride = await getDevOverride(db, requestingUserCid, config, logger);
|
|
37
|
+
const isImpersonating = devOverride && devOverride.enabled && devOverride.impersonateCid && effectiveCid !== Number(requestingUserCid);
|
|
38
|
+
|
|
39
|
+
const targetCidNum = Number(targetUserCid);
|
|
40
|
+
if (isNaN(targetCidNum) || targetCidNum <= 0) {
|
|
41
|
+
return res.status(400).json({
|
|
42
|
+
success: false,
|
|
43
|
+
error: "Invalid user CID",
|
|
44
|
+
message: "Please provide a valid user CID"
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Check if this is a developer account (bypass rate limits for developers)
|
|
50
|
+
const { isDeveloperAccount } = require('./dev_helpers');
|
|
51
|
+
const isDeveloper = isDeveloperAccount(requestingUserCid);
|
|
52
|
+
|
|
53
|
+
let rateLimitCheck = { allowed: true }; // Default to allowed for developers
|
|
54
|
+
|
|
55
|
+
if (!isDeveloper) {
|
|
56
|
+
// Check global rate limit (applies to target user, not requester)
|
|
57
|
+
rateLimitCheck = await checkRateLimits(db, targetCidNum, logger);
|
|
58
|
+
|
|
59
|
+
if (!rateLimitCheck.allowed) {
|
|
60
|
+
return res.status(429).json({
|
|
61
|
+
success: false,
|
|
62
|
+
error: "Rate limit exceeded",
|
|
63
|
+
message: rateLimitCheck.message,
|
|
64
|
+
rateLimit: {
|
|
65
|
+
canRequestAgainAt: rateLimitCheck.canRequestAgainAt,
|
|
66
|
+
lastRequestedAt: rateLimitCheck.lastRequestedAt
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
logger.log('INFO', `[requestUserSync] Developer account ${requestingUserCid} bypassing rate limits${isImpersonating ? ` (impersonating ${effectiveCid})` : ''}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Determine user type (PI or signed-in user)
|
|
75
|
+
// Check if user is in rankings (PI) or has signed-in user data
|
|
76
|
+
const { checkIfUserIsPI } = require('./data_helpers');
|
|
77
|
+
const isPI = await checkIfUserIsPI(db, targetCidNum, config, logger);
|
|
78
|
+
|
|
79
|
+
// Get username - for PIs, get from rankings; for signed-in users, we'll need to fetch from their profile
|
|
80
|
+
let username = null;
|
|
81
|
+
if (isPI) {
|
|
82
|
+
// Get PI username from rankings
|
|
83
|
+
const { findLatestRankingsDate } = require('./data_helpers');
|
|
84
|
+
const rankingsCollection = config.popularInvestorRankingsCollection || 'popular_investor_rankings';
|
|
85
|
+
const rankingsDate = await findLatestRankingsDate(db, rankingsCollection, 30);
|
|
86
|
+
|
|
87
|
+
if (rankingsDate) {
|
|
88
|
+
const rankingsRef = db.collection(rankingsCollection).doc(rankingsDate);
|
|
89
|
+
const rankingsDoc = await rankingsRef.get();
|
|
90
|
+
if (rankingsDoc.exists) {
|
|
91
|
+
const rankingsData = rankingsDoc.data();
|
|
92
|
+
const rankingsItems = rankingsData.Items || [];
|
|
93
|
+
const rankingEntry = rankingsItems.find(item => Number(item.CustomerId) === targetCidNum);
|
|
94
|
+
if (rankingEntry) {
|
|
95
|
+
username = rankingEntry.UserName || rankingEntry.username || null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// For signed-in users, try to get username from verification data
|
|
101
|
+
const signedInUsersCollection = config.signedInUsersCollection || 'signed_in_users';
|
|
102
|
+
const userDoc = await db.collection(signedInUsersCollection).doc(String(targetCidNum)).get();
|
|
103
|
+
if (userDoc.exists) {
|
|
104
|
+
const userData = userDoc.data();
|
|
105
|
+
username = userData.username || userData.verification?.username || null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!username) {
|
|
110
|
+
logger.log('WARN', `[requestUserSync] Could not find username for user ${targetCidNum}, will use CID as fallback`);
|
|
111
|
+
username = String(targetCidNum);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Create request document
|
|
115
|
+
const requestId = `sync_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
116
|
+
const now = new Date();
|
|
117
|
+
|
|
118
|
+
// Store request
|
|
119
|
+
const requestRef = db.collection('user_sync_requests')
|
|
120
|
+
.doc(String(targetCidNum))
|
|
121
|
+
.collection('requests')
|
|
122
|
+
.doc(requestId);
|
|
123
|
+
|
|
124
|
+
await requestRef.set({
|
|
125
|
+
requestId,
|
|
126
|
+
targetUserCid: targetCidNum,
|
|
127
|
+
requestedBy: Number(requestingUserCid),
|
|
128
|
+
effectiveRequestedBy: effectiveCid,
|
|
129
|
+
username,
|
|
130
|
+
userType: isPI ? 'POPULAR_INVESTOR' : 'SIGNED_IN_USER',
|
|
131
|
+
status: 'queued',
|
|
132
|
+
isImpersonating: isImpersonating || false,
|
|
133
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
134
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Update global rate limit (for target user)
|
|
138
|
+
const globalRef = db.collection('user_sync_requests')
|
|
139
|
+
.doc(String(targetCidNum))
|
|
140
|
+
.collection('global')
|
|
141
|
+
.doc('latest');
|
|
142
|
+
|
|
143
|
+
await globalRef.set({
|
|
144
|
+
targetUserCid: targetCidNum,
|
|
145
|
+
lastRequestedAt: FieldValue.serverTimestamp(),
|
|
146
|
+
lastRequestedBy: Number(requestingUserCid),
|
|
147
|
+
totalRequests: FieldValue.increment(1),
|
|
148
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
149
|
+
}, { merge: true });
|
|
150
|
+
|
|
151
|
+
// Publish to task engine
|
|
152
|
+
const topicName = config.taskEngine?.PUBSUB_TOPIC_USER_FETCH || 'etoro-user-fetch-topic';
|
|
153
|
+
const topic = pubsub.topic(topicName);
|
|
154
|
+
|
|
155
|
+
const message = {
|
|
156
|
+
type: isPI ? 'POPULAR_INVESTOR_UPDATE' : 'ON_DEMAND_USER_UPDATE',
|
|
157
|
+
cid: targetCidNum,
|
|
158
|
+
username: username,
|
|
159
|
+
priority: 'high',
|
|
160
|
+
source: 'on_demand_sync',
|
|
161
|
+
requestId,
|
|
162
|
+
requestedBy: Number(requestingUserCid),
|
|
163
|
+
effectiveRequestedBy: effectiveCid,
|
|
164
|
+
metadata: {
|
|
165
|
+
onDemand: true,
|
|
166
|
+
requestedAt: now.toISOString(),
|
|
167
|
+
isImpersonating: isImpersonating || false
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const messageId = await topic.publishMessage({
|
|
172
|
+
data: Buffer.from(JSON.stringify(message))
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Update request with message ID
|
|
176
|
+
await requestRef.update({
|
|
177
|
+
taskMessageId: messageId,
|
|
178
|
+
status: 'dispatched',
|
|
179
|
+
dispatchedAt: FieldValue.serverTimestamp(),
|
|
180
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
logger.log('INFO', `[requestUserSync] User ${requestingUserCid}${isImpersonating ? ` (impersonating ${effectiveCid})` : ''} requested sync for ${isPI ? 'PI' : 'signed-in user'} ${targetCidNum} (${username}). Request ID: ${requestId}`);
|
|
184
|
+
|
|
185
|
+
return res.status(200).json({
|
|
186
|
+
success: true,
|
|
187
|
+
requestId,
|
|
188
|
+
status: 'dispatched',
|
|
189
|
+
message: 'Sync request queued successfully. Data will be updated shortly.',
|
|
190
|
+
estimatedTime: '2-5 minutes',
|
|
191
|
+
rateLimit: {
|
|
192
|
+
canRequestAgainAt: new Date(Date.now() + RATE_LIMIT_MS).toISOString()
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.log('ERROR', `[requestUserSync] Error requesting sync for user ${targetCidNum}`, error);
|
|
198
|
+
return res.status(500).json({
|
|
199
|
+
success: false,
|
|
200
|
+
error: "Internal server error",
|
|
201
|
+
message: error.message
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get sync status for a user
|
|
208
|
+
* GET /user/:userCid/sync-status
|
|
209
|
+
*/
|
|
210
|
+
async function getUserSyncStatus(req, res, dependencies, config) {
|
|
211
|
+
const { db, logger } = dependencies;
|
|
212
|
+
const { userCid: targetUserCid } = req.params;
|
|
213
|
+
const requestingUserCid = req.query?.userCid; // Optional - for rate limit info
|
|
214
|
+
|
|
215
|
+
const targetCidNum = Number(targetUserCid);
|
|
216
|
+
if (isNaN(targetCidNum) || targetCidNum <= 0) {
|
|
217
|
+
return res.status(400).json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: "Invalid user CID"
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Check for latest request
|
|
225
|
+
const requestsRef = db.collection('user_sync_requests')
|
|
226
|
+
.doc(String(targetCidNum))
|
|
227
|
+
.collection('requests')
|
|
228
|
+
.orderBy('createdAt', 'desc')
|
|
229
|
+
.limit(1);
|
|
230
|
+
|
|
231
|
+
const requestsSnapshot = await requestsRef.get();
|
|
232
|
+
|
|
233
|
+
if (!requestsSnapshot.empty) {
|
|
234
|
+
const latestRequest = requestsSnapshot.docs[0].data();
|
|
235
|
+
let status = latestRequest.status || 'queued';
|
|
236
|
+
|
|
237
|
+
const response = {
|
|
238
|
+
success: true,
|
|
239
|
+
status,
|
|
240
|
+
requestId: latestRequest.requestId,
|
|
241
|
+
startedAt: latestRequest.startedAt?.toDate?.()?.toISOString() || null,
|
|
242
|
+
createdAt: latestRequest.createdAt?.toDate?.()?.toISOString() || null,
|
|
243
|
+
dispatchedAt: latestRequest.dispatchedAt?.toDate?.()?.toISOString() || null,
|
|
244
|
+
completedAt: latestRequest.completedAt?.toDate?.()?.toISOString() || null,
|
|
245
|
+
estimatedCompletion: latestRequest.dispatchedAt
|
|
246
|
+
? new Date(new Date(latestRequest.dispatchedAt.toDate()).getTime() + 5 * 60 * 1000).toISOString() // 5 min estimate
|
|
247
|
+
: null
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Include error details if status is failed
|
|
251
|
+
if (status === 'failed') {
|
|
252
|
+
response.error = latestRequest.error || 'Unknown error occurred';
|
|
253
|
+
response.failedAt = latestRequest.failedAt?.toDate?.()?.toISOString() || null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Include raw data status if processing
|
|
257
|
+
if (status === 'processing' || status === 'indexing' || status === 'computing') {
|
|
258
|
+
response.rawDataStoredAt = latestRequest.rawDataStoredAt?.toDate?.()?.toISOString() || null;
|
|
259
|
+
response.message = status === 'indexing' ? 'Raw data stored, indexing data...' :
|
|
260
|
+
status === 'computing' ? 'Raw data stored, computation in progress...' :
|
|
261
|
+
'Processing data...';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return res.status(200).json(response);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// No request found, check if user can request
|
|
268
|
+
let canRequest = true;
|
|
269
|
+
let rateLimitInfo = null;
|
|
270
|
+
|
|
271
|
+
if (requestingUserCid) {
|
|
272
|
+
// Check if this is a developer account (bypass rate limits for developers)
|
|
273
|
+
const { isDeveloperAccount } = require('./dev_helpers');
|
|
274
|
+
const isDeveloper = isDeveloperAccount(requestingUserCid);
|
|
275
|
+
|
|
276
|
+
if (isDeveloper) {
|
|
277
|
+
// Developer accounts bypass rate limits
|
|
278
|
+
canRequest = true;
|
|
279
|
+
rateLimitInfo = {
|
|
280
|
+
bypassed: true,
|
|
281
|
+
reason: 'developer_account'
|
|
282
|
+
};
|
|
283
|
+
} else {
|
|
284
|
+
const rateLimitCheck = await checkRateLimits(db, targetCidNum, logger);
|
|
285
|
+
canRequest = rateLimitCheck.allowed;
|
|
286
|
+
rateLimitInfo = {
|
|
287
|
+
canRequestAgainAt: rateLimitCheck.canRequestAgainAt
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return res.status(200).json({
|
|
293
|
+
success: true,
|
|
294
|
+
status: 'not_requested',
|
|
295
|
+
canRequest,
|
|
296
|
+
rateLimit: rateLimitInfo
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logger.log('ERROR', `[getUserSyncStatus] Error checking sync status for user ${targetCidNum}`, error);
|
|
301
|
+
return res.status(500).json({
|
|
302
|
+
success: false,
|
|
303
|
+
error: "Internal server error",
|
|
304
|
+
message: error.message
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Check global rate limits for a user
|
|
311
|
+
* NOTE: Developer accounts should bypass this check (check isDeveloperAccount before calling)
|
|
312
|
+
*/
|
|
313
|
+
async function checkRateLimits(db, targetUserCid, logger) {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
|
|
316
|
+
// Check global rate limit (applies to target user, not requester)
|
|
317
|
+
const globalRef = db.collection('user_sync_requests')
|
|
318
|
+
.doc(String(targetUserCid))
|
|
319
|
+
.collection('global')
|
|
320
|
+
.doc('latest');
|
|
321
|
+
|
|
322
|
+
const globalDoc = await globalRef.get();
|
|
323
|
+
let canRequestAgainAt = null;
|
|
324
|
+
let blocked = false;
|
|
325
|
+
let lastRequestedAt = null;
|
|
326
|
+
|
|
327
|
+
if (globalDoc.exists) {
|
|
328
|
+
const globalData = globalDoc.data();
|
|
329
|
+
const globalLastRequestedAt = globalData.lastRequestedAt?.toDate?.()?.getTime() || globalData.lastRequestedAt?.toMillis?.() || null;
|
|
330
|
+
|
|
331
|
+
if (globalLastRequestedAt && (now - globalLastRequestedAt) < RATE_LIMIT_MS) {
|
|
332
|
+
blocked = true;
|
|
333
|
+
canRequestAgainAt = new Date(globalLastRequestedAt + RATE_LIMIT_MS).toISOString();
|
|
334
|
+
lastRequestedAt = new Date(globalLastRequestedAt).toISOString();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (blocked) {
|
|
339
|
+
const hoursRemaining = Math.ceil((new Date(canRequestAgainAt).getTime() - now) / (60 * 60 * 1000));
|
|
340
|
+
return {
|
|
341
|
+
allowed: false,
|
|
342
|
+
message: `This user was recently synced. Please try again in ${hoursRemaining} hour${hoursRemaining !== 1 ? 's' : ''}.`,
|
|
343
|
+
canRequestAgainAt,
|
|
344
|
+
lastRequestedAt
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
allowed: true,
|
|
350
|
+
canRequestAgainAt: new Date(now + RATE_LIMIT_MS).toISOString()
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
module.exports = {
|
|
355
|
+
requestUserSync,
|
|
356
|
+
getUserSyncStatus
|
|
357
|
+
};
|
|
358
|
+
|
|
@@ -11,6 +11,7 @@ const { subscribeToAlerts, updateSubscription, unsubscribeFromAlerts, getUserSub
|
|
|
11
11
|
const { setDevOverride, getDevOverrideStatus } = require('./helpers/dev_helpers');
|
|
12
12
|
const { getAlertTypes, getUserAlerts, getAlertCount, markAlertRead, markAllAlertsRead, deleteAlert } = require('./helpers/alert_helpers');
|
|
13
13
|
const { requestPiFetch, getPiFetchStatus } = require('./helpers/on_demand_fetch_helpers');
|
|
14
|
+
const { requestUserSync, getUserSyncStatus } = require('./helpers/user_sync_helpers');
|
|
14
15
|
|
|
15
16
|
module.exports = (dependencies, config) => {
|
|
16
17
|
const router = express.Router();
|
|
@@ -34,6 +35,10 @@ module.exports = (dependencies, config) => {
|
|
|
34
35
|
router.post('/pi/:piCid/request-fetch', (req, res) => requestPiFetch(req, res, dependencies, config));
|
|
35
36
|
router.get('/pi/:piCid/fetch-status', (req, res) => getPiFetchStatus(req, res, dependencies, config));
|
|
36
37
|
|
|
38
|
+
// --- User Sync (for profile pages) ---
|
|
39
|
+
router.post('/:userCid/sync', (req, res) => requestUserSync(req, res, dependencies, config));
|
|
40
|
+
router.get('/:userCid/sync-status', (req, res) => getUserSyncStatus(req, res, dependencies, config));
|
|
41
|
+
|
|
37
42
|
// Signed-In User Personalized Routes
|
|
38
43
|
// Note: Router is mounted at /user, so these routes should be /me/* not /user/me/*
|
|
39
44
|
router.get('/me/hedges', (req, res) => getUserRecommendations(req, res, dependencies, config, 'hedges'));
|
|
@@ -46,19 +46,37 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
|
|
|
46
46
|
|
|
47
47
|
logger.log('INFO', `[PI Update] Starting update for User: ${username || 'unknown'} (${cid || 'unknown'})${requestId ? ` [Request: ${requestId}]` : ''}`);
|
|
48
48
|
|
|
49
|
-
// Update request status if this is an on-demand request
|
|
50
|
-
if (requestId && source === 'on_demand' && db) {
|
|
49
|
+
// Update request status if this is an on-demand request (PI fetch or sync)
|
|
50
|
+
if (requestId && (source === 'on_demand' || source === 'on_demand_sync') && db) {
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// Check if it's a sync request first
|
|
53
|
+
const syncRequestRef = db.collection('user_sync_requests')
|
|
53
54
|
.doc(String(cid))
|
|
54
55
|
.collection('requests')
|
|
55
56
|
.doc(requestId);
|
|
56
57
|
|
|
57
|
-
await
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const syncRequestDoc = await syncRequestRef.get();
|
|
59
|
+
|
|
60
|
+
if (syncRequestDoc.exists) {
|
|
61
|
+
// This is a sync request
|
|
62
|
+
await syncRequestRef.update({
|
|
63
|
+
status: 'processing',
|
|
64
|
+
startedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp(),
|
|
65
|
+
updatedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
// This is a PI fetch request
|
|
69
|
+
const requestRef = db.collection('pi_fetch_requests')
|
|
70
|
+
.doc(String(cid))
|
|
71
|
+
.collection('requests')
|
|
72
|
+
.doc(requestId);
|
|
73
|
+
|
|
74
|
+
await requestRef.update({
|
|
75
|
+
status: 'processing',
|
|
76
|
+
startedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp(),
|
|
77
|
+
updatedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
|
|
78
|
+
});
|
|
79
|
+
}
|
|
62
80
|
} catch (err) {
|
|
63
81
|
logger.log('WARN', `[PI Update] Failed to update request status for ${requestId}`, err);
|
|
64
82
|
}
|
|
@@ -299,14 +317,31 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
|
|
|
299
317
|
|
|
300
318
|
logger.log('SUCCESS', `[PI Update] Completed full update for ${username}`);
|
|
301
319
|
|
|
302
|
-
// Update request status and trigger computation if this is an on-demand request
|
|
303
|
-
if (requestId && source === 'on_demand' && db) {
|
|
320
|
+
// Update request status and trigger computation if this is an on-demand request (PI fetch or sync)
|
|
321
|
+
if (requestId && (source === 'on_demand' || source === 'on_demand_sync') && db) {
|
|
304
322
|
try {
|
|
305
|
-
|
|
323
|
+
// Check if it's a sync request first
|
|
324
|
+
const syncRequestRef = db.collection('user_sync_requests')
|
|
306
325
|
.doc(String(cid))
|
|
307
326
|
.collection('requests')
|
|
308
327
|
.doc(requestId);
|
|
309
328
|
|
|
329
|
+
const syncRequestDoc = await syncRequestRef.get();
|
|
330
|
+
let requestRef = null;
|
|
331
|
+
let isSyncRequest = false;
|
|
332
|
+
|
|
333
|
+
if (syncRequestDoc.exists) {
|
|
334
|
+
// This is a sync request
|
|
335
|
+
requestRef = syncRequestRef;
|
|
336
|
+
isSyncRequest = true;
|
|
337
|
+
} else {
|
|
338
|
+
// This is a PI fetch request
|
|
339
|
+
requestRef = db.collection('pi_fetch_requests')
|
|
340
|
+
.doc(String(cid))
|
|
341
|
+
.collection('requests')
|
|
342
|
+
.doc(requestId);
|
|
343
|
+
}
|
|
344
|
+
|
|
310
345
|
// Update status to indicate raw data is stored, indexing and computation will be triggered
|
|
311
346
|
await requestRef.update({
|
|
312
347
|
status: 'indexing',
|
|
@@ -392,14 +427,29 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
|
|
|
392
427
|
} catch (error) {
|
|
393
428
|
logger.log('ERROR', `[PI Update] Failed for ${username}`, error);
|
|
394
429
|
|
|
395
|
-
// Update request status to failed if this is an on-demand request
|
|
396
|
-
if (requestId && source === 'on_demand' && db) {
|
|
430
|
+
// Update request status to failed if this is an on-demand request (PI fetch or sync)
|
|
431
|
+
if (requestId && (source === 'on_demand' || source === 'on_demand_sync') && db) {
|
|
397
432
|
try {
|
|
398
|
-
|
|
433
|
+
// Check if it's a sync request first
|
|
434
|
+
const syncRequestRef = db.collection('user_sync_requests')
|
|
399
435
|
.doc(String(cid))
|
|
400
436
|
.collection('requests')
|
|
401
437
|
.doc(requestId);
|
|
402
438
|
|
|
439
|
+
const syncRequestDoc = await syncRequestRef.get();
|
|
440
|
+
let requestRef = null;
|
|
441
|
+
|
|
442
|
+
if (syncRequestDoc.exists) {
|
|
443
|
+
// This is a sync request
|
|
444
|
+
requestRef = syncRequestRef;
|
|
445
|
+
} else {
|
|
446
|
+
// This is a PI fetch request
|
|
447
|
+
requestRef = db.collection('pi_fetch_requests')
|
|
448
|
+
.doc(String(cid))
|
|
449
|
+
.collection('requests')
|
|
450
|
+
.doc(requestId);
|
|
451
|
+
}
|
|
452
|
+
|
|
403
453
|
await requestRef.update({
|
|
404
454
|
status: 'failed',
|
|
405
455
|
error: error.message || 'Unknown error',
|
|
@@ -423,13 +473,40 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
|
|
|
423
473
|
* Handles the On-Demand update for a Signed-In User.
|
|
424
474
|
*/
|
|
425
475
|
async function handleOnDemandUserUpdate(taskData, config, dependencies) {
|
|
426
|
-
const { cid, username } = taskData;
|
|
427
|
-
|
|
476
|
+
const { cid, username, requestId, source } = taskData;
|
|
477
|
+
|
|
478
|
+
// [FIX] Destructure dependencies first
|
|
479
|
+
const { logger, proxyManager, batchManager, headerManager, db } = dependencies;
|
|
480
|
+
|
|
481
|
+
// Validate and set API URLs with defaults and fallbacks
|
|
482
|
+
const ETORO_API_PORTFOLIO_URL = config.ETORO_API_PORTFOLIO_URL || process.env.ETORO_API_PORTFOLIO_URL || 'https://www.etoro.com/sapi/portfolios/portfolio';
|
|
483
|
+
const ETORO_API_HISTORY_URL = config.ETORO_API_HISTORY_URL || process.env.ETORO_API_HISTORY_URL || 'https://www.etoro.com/sapi/trade-data-real/history/public/credit/flat';
|
|
428
484
|
|
|
429
|
-
|
|
430
|
-
|
|
485
|
+
if (!ETORO_API_PORTFOLIO_URL || ETORO_API_PORTFOLIO_URL === 'undefined') {
|
|
486
|
+
const errorMsg = 'ETORO_API_PORTFOLIO_URL is not configured. Check taskEngine config.';
|
|
487
|
+
logger.log('ERROR', `[On-Demand Update] ${errorMsg}`);
|
|
488
|
+
throw new Error(errorMsg);
|
|
489
|
+
}
|
|
431
490
|
|
|
432
|
-
logger.log('INFO', `[On-Demand Update] Fetching Portfolio & History for Signed-In User: ${username} (${cid})`);
|
|
491
|
+
logger.log('INFO', `[On-Demand Update] Fetching Portfolio & History for Signed-In User: ${username} (${cid})${requestId ? ` [Request: ${requestId}]` : ''}`);
|
|
492
|
+
|
|
493
|
+
// Update request status if this is a sync request
|
|
494
|
+
if (requestId && source === 'on_demand_sync' && db) {
|
|
495
|
+
try {
|
|
496
|
+
const requestRef = db.collection('user_sync_requests')
|
|
497
|
+
.doc(String(cid))
|
|
498
|
+
.collection('requests')
|
|
499
|
+
.doc(requestId);
|
|
500
|
+
|
|
501
|
+
await requestRef.update({
|
|
502
|
+
status: 'processing',
|
|
503
|
+
startedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp(),
|
|
504
|
+
updatedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
|
|
505
|
+
});
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.log('WARN', `[On-Demand Update] Failed to update request status for ${requestId}`, err);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
433
510
|
|
|
434
511
|
const today = new Date().toISOString().split('T')[0];
|
|
435
512
|
const blockId = '19M';
|
|
@@ -568,8 +645,49 @@ async function handleOnDemandUserUpdate(taskData, config, dependencies) {
|
|
|
568
645
|
}
|
|
569
646
|
|
|
570
647
|
logger.log('SUCCESS', `[On-Demand Update] Complete for ${username}`);
|
|
648
|
+
|
|
649
|
+
// Update request status to completed if this is a sync request
|
|
650
|
+
if (requestId && source === 'on_demand_sync' && db) {
|
|
651
|
+
try {
|
|
652
|
+
const requestRef = db.collection('user_sync_requests')
|
|
653
|
+
.doc(String(cid))
|
|
654
|
+
.collection('requests')
|
|
655
|
+
.doc(requestId);
|
|
656
|
+
|
|
657
|
+
await requestRef.update({
|
|
658
|
+
status: 'completed',
|
|
659
|
+
completedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp(),
|
|
660
|
+
updatedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
|
|
661
|
+
});
|
|
662
|
+
logger.log('INFO', `[On-Demand Update] Updated sync request ${requestId} to completed`);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
logger.log('WARN', `[On-Demand Update] Failed to update request status to completed for ${requestId}`, err);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
571
667
|
} catch (error) {
|
|
572
668
|
logger.log('ERROR', `[On-Demand Update] Failed for ${username}`, error);
|
|
669
|
+
|
|
670
|
+
// Update request status to failed if this is a sync request
|
|
671
|
+
if (requestId && source === 'on_demand_sync' && db) {
|
|
672
|
+
try {
|
|
673
|
+
const requestRef = db.collection('user_sync_requests')
|
|
674
|
+
.doc(String(cid))
|
|
675
|
+
.collection('requests')
|
|
676
|
+
.doc(requestId);
|
|
677
|
+
|
|
678
|
+
const errorMessage = error.message || 'Unknown error occurred';
|
|
679
|
+
await requestRef.update({
|
|
680
|
+
status: 'failed',
|
|
681
|
+
error: errorMessage,
|
|
682
|
+
failedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp(),
|
|
683
|
+
updatedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
|
|
684
|
+
});
|
|
685
|
+
logger.log('INFO', `[On-Demand Update] Updated sync request ${requestId} to failed: ${errorMessage}`);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
logger.log('WARN', `[On-Demand Update] Failed to update request status to failed for ${requestId}`, err);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
573
691
|
throw error;
|
|
574
692
|
} finally {
|
|
575
693
|
if (headerManager && headerId !== 'fallback') {
|