bulltrackers-module 1.0.677 → 1.0.679
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.
|
@@ -16,12 +16,53 @@ const storage = new Storage(); // Singleton GCS Client
|
|
|
16
16
|
*/
|
|
17
17
|
async function processAlertForPI(db, logger, piCid, alertType, computationMetadata, computationDate, dependencies = {}) {
|
|
18
18
|
try {
|
|
19
|
+
// [FIX] Check if computation date is earlier than today (backfill protection)
|
|
20
|
+
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
21
|
+
const isHistoricalData = computationDate < today;
|
|
22
|
+
|
|
23
|
+
// If it's historical data, check if this alert already exists
|
|
24
|
+
if (isHistoricalData) {
|
|
25
|
+
// Check if we've already created alerts for this PI/date/alertType combination
|
|
26
|
+
const existingAlertsSnapshot = await db.collection('SignedInUsers')
|
|
27
|
+
.doc('_metadata') // Use metadata doc to track processed alerts
|
|
28
|
+
.collection('processed_alerts')
|
|
29
|
+
.where('piCid', '==', Number(piCid))
|
|
30
|
+
.where('computationDate', '==', computationDate)
|
|
31
|
+
.where('alertType', '==', alertType.id)
|
|
32
|
+
.limit(1)
|
|
33
|
+
.get();
|
|
34
|
+
|
|
35
|
+
if (!existingAlertsSnapshot.empty) {
|
|
36
|
+
logger.log('INFO', `[processAlertForPI] Skipping duplicate alert for historical data: PI ${piCid}, date ${computationDate}, alert type ${alertType.id}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
logger.log('WARN', `[processAlertForPI] Processing alert for historical data (backfill): PI ${piCid}, date ${computationDate}, alert type ${alertType.id}. This alert will only be sent to developers.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
// 1. Get PI username from rankings or subscriptions
|
|
20
44
|
const piUsername = await getPIUsername(db, piCid);
|
|
21
45
|
|
|
22
46
|
// 2. Find all users subscribed to this PI and alert type
|
|
23
47
|
// Use computationName (e.g., 'RiskScoreIncrease') to map to alertConfig keys
|
|
24
|
-
|
|
48
|
+
let subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName, computationDate, dependencies);
|
|
49
|
+
|
|
50
|
+
// [FIX] If it's historical data, only send to developer accounts
|
|
51
|
+
if (isHistoricalData) {
|
|
52
|
+
const { isDeveloper } = require('../../api-v2/helpers/data-fetchers/firestore.js');
|
|
53
|
+
|
|
54
|
+
// Filter subscriptions to only include developers
|
|
55
|
+
const devSubscriptions = [];
|
|
56
|
+
for (const subscription of subscriptions) {
|
|
57
|
+
const isDev = await isDeveloper(db, String(subscription.userCid));
|
|
58
|
+
if (isDev) {
|
|
59
|
+
devSubscriptions.push(subscription);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
subscriptions = devSubscriptions;
|
|
64
|
+
logger.log('INFO', `[processAlertForPI] Historical data: Filtered to ${subscriptions.length} developer subscriptions for PI ${piCid}, alert type ${alertType.id}`);
|
|
65
|
+
}
|
|
25
66
|
|
|
26
67
|
if (subscriptions.length === 0) {
|
|
27
68
|
logger.log('INFO', `[processAlertForPI] No subscriptions found for PI ${piCid}, alert type ${alertType.id}`);
|
|
@@ -38,8 +79,24 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
38
79
|
const config = dependencies.config || {};
|
|
39
80
|
const notificationPromises = [];
|
|
40
81
|
|
|
82
|
+
// Check watchlistAlerts preference for each user
|
|
83
|
+
const { manageNotificationPreferences } = require('../../api-v2/helpers/data-fetchers/firestore.js');
|
|
84
|
+
|
|
41
85
|
for (const subscription of subscriptions) {
|
|
42
86
|
const userCid = subscription.userCid;
|
|
87
|
+
|
|
88
|
+
// [FIX] Check user's watchlistAlerts preference before creating alert
|
|
89
|
+
try {
|
|
90
|
+
const prefs = await manageNotificationPreferences(db, userCid, 'get');
|
|
91
|
+
if (!prefs.watchlistAlerts) {
|
|
92
|
+
logger.log('DEBUG', `[processAlertForPI] User ${userCid} has watchlistAlerts disabled, skipping alert`);
|
|
93
|
+
continue; // Skip this user
|
|
94
|
+
}
|
|
95
|
+
} catch (prefError) {
|
|
96
|
+
logger.log('WARN', `[processAlertForPI] Error checking watchlistAlerts preference for user ${userCid}: ${prefError.message}`);
|
|
97
|
+
// Continue anyway - fail open for important alerts
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
const notificationId = `alert_${Date.now()}_${userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
|
|
44
101
|
|
|
45
102
|
const notificationData = {
|
|
@@ -129,6 +186,28 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
129
186
|
|
|
130
187
|
logger.log('SUCCESS', `[processAlertForPI] Created ${notificationPromises.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
|
|
131
188
|
|
|
189
|
+
// [FIX] Mark alert as processed to prevent duplicates on backfill
|
|
190
|
+
if (isHistoricalData) {
|
|
191
|
+
try {
|
|
192
|
+
await db.collection('SignedInUsers')
|
|
193
|
+
.doc('_metadata')
|
|
194
|
+
.collection('processed_alerts')
|
|
195
|
+
.doc(`${piCid}_${computationDate}_${alertType.id}`)
|
|
196
|
+
.set({
|
|
197
|
+
piCid: Number(piCid),
|
|
198
|
+
computationDate: computationDate,
|
|
199
|
+
alertType: alertType.id,
|
|
200
|
+
alertTypeName: alertType.name,
|
|
201
|
+
processedAt: FieldValue.serverTimestamp(),
|
|
202
|
+
notificationsSent: notificationPromises.length
|
|
203
|
+
});
|
|
204
|
+
logger.log('INFO', `[processAlertForPI] Marked historical alert as processed: PI ${piCid}, date ${computationDate}, alert type ${alertType.id}`);
|
|
205
|
+
} catch (markError) {
|
|
206
|
+
logger.log('WARN', `[processAlertForPI] Failed to mark alert as processed: ${markError.message}`);
|
|
207
|
+
// Don't throw - this is non-critical
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
132
211
|
} catch (error) {
|
|
133
212
|
logger.log('ERROR', `[processAlertForPI] Error processing alert for PI ${piCid}`, error);
|
|
134
213
|
throw error;
|
|
@@ -285,8 +364,29 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computatio
|
|
|
285
364
|
const isTestProbe = alertTypeId === 'TestSystemProbe';
|
|
286
365
|
const isEnabled = item.alertConfig && item.alertConfig[configKey] === true;
|
|
287
366
|
|
|
288
|
-
//
|
|
289
|
-
|
|
367
|
+
// [FIX] For TestSystemProbe, check user's testAlerts preference
|
|
368
|
+
let shouldSendAlert = false;
|
|
369
|
+
if (isTestProbe) {
|
|
370
|
+
// Check if user has testAlerts enabled in their notification preferences
|
|
371
|
+
try {
|
|
372
|
+
const { manageNotificationPreferences } = require('../../api-v2/helpers/data-fetchers/firestore.js');
|
|
373
|
+
const prefs = await manageNotificationPreferences(db, userCid, 'get');
|
|
374
|
+
shouldSendAlert = prefs.testAlerts === true;
|
|
375
|
+
|
|
376
|
+
if (!shouldSendAlert) {
|
|
377
|
+
logger.log('DEBUG', `[findSubscriptionsForPI] User ${userCid} has testAlerts disabled, skipping TestSystemProbe alert`);
|
|
378
|
+
}
|
|
379
|
+
} catch (prefError) {
|
|
380
|
+
logger.log('WARN', `[findSubscriptionsForPI] Error checking testAlerts preference for user ${userCid}: ${prefError.message}`);
|
|
381
|
+
// Default to not sending test alerts if we can't check preferences
|
|
382
|
+
shouldSendAlert = false;
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
// For non-test alerts, use normal alert config check
|
|
386
|
+
shouldSendAlert = isEnabled;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (shouldSendAlert) {
|
|
290
390
|
subscriptions.push({
|
|
291
391
|
userCid: userCid,
|
|
292
392
|
piCid: piCid,
|
|
@@ -18,6 +18,15 @@ const { FieldValue } = require('@google-cloud/firestore');
|
|
|
18
18
|
*/
|
|
19
19
|
async function notifyTaskEngineComplete(db, logger, userCid, requestId, username, success, errorMessage, options = {}) {
|
|
20
20
|
try {
|
|
21
|
+
// [FIX] Check user's notification preferences before sending
|
|
22
|
+
const { manageNotificationPreferences } = require('./data-fetchers/firestore.js');
|
|
23
|
+
const prefs = await manageNotificationPreferences(db, userCid, 'get');
|
|
24
|
+
|
|
25
|
+
if (!prefs.syncProcesses) {
|
|
26
|
+
logger?.log('DEBUG', `[notifyTaskEngineComplete] User ${userCid} has syncProcesses disabled, skipping notification`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
const notificationData = {
|
|
22
31
|
type: 'sync',
|
|
23
32
|
subType: 'complete',
|
|
@@ -42,6 +51,29 @@ async function notifyTaskEngineComplete(db, logger, userCid, requestId, username
|
|
|
42
51
|
.doc(requestId)
|
|
43
52
|
.set(notificationData);
|
|
44
53
|
|
|
54
|
+
// [FIX] Clean up progress notifications for this sync to stop showing loading icons
|
|
55
|
+
try {
|
|
56
|
+
const progressNotificationsQuery = await db.collection('SignedInUsers')
|
|
57
|
+
.doc(String(userCid))
|
|
58
|
+
.collection('notifications')
|
|
59
|
+
.where('metadata.requestId', '==', requestId)
|
|
60
|
+
.where('subType', '==', 'progress')
|
|
61
|
+
.get();
|
|
62
|
+
|
|
63
|
+
const batch = db.batch();
|
|
64
|
+
progressNotificationsQuery.docs.forEach(doc => {
|
|
65
|
+
batch.delete(doc.ref);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (progressNotificationsQuery.size > 0) {
|
|
69
|
+
await batch.commit();
|
|
70
|
+
logger?.log('INFO', `[notifyTaskEngineComplete] Cleaned up ${progressNotificationsQuery.size} progress notifications for request ${requestId}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (cleanupError) {
|
|
73
|
+
logger?.log('WARN', `[notifyTaskEngineComplete] Failed to clean up progress notifications: ${cleanupError.message}`);
|
|
74
|
+
// Don't throw - cleanup is non-critical
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
logger?.log('INFO', `[notifyTaskEngineComplete] Notification sent for user ${userCid}, request ${requestId}`);
|
|
46
78
|
} catch (error) {
|
|
47
79
|
logger?.log('WARN', `[notifyTaskEngineComplete] Failed to send notification: ${error.message}`);
|
|
@@ -61,6 +93,15 @@ async function notifyTaskEngineComplete(db, logger, userCid, requestId, username
|
|
|
61
93
|
*/
|
|
62
94
|
async function notifyTaskEngineProgress(db, logger, userCid, requestId, username, stage, dataType, options = {}) {
|
|
63
95
|
try {
|
|
96
|
+
// [FIX] Check user's notification preferences before sending
|
|
97
|
+
const { manageNotificationPreferences } = require('./data-fetchers/firestore.js');
|
|
98
|
+
const prefs = await manageNotificationPreferences(db, userCid, 'get');
|
|
99
|
+
|
|
100
|
+
if (!prefs.syncProcesses) {
|
|
101
|
+
logger?.log('DEBUG', `[notifyTaskEngineProgress] User ${userCid} has syncProcesses disabled, skipping notification`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
const stageMessages = {
|
|
65
106
|
'started': 'Data sync started',
|
|
66
107
|
'portfolio_complete': 'Portfolio data fetched',
|
|
@@ -130,6 +171,15 @@ async function notifyPIDataRefreshed(db, logger, collectionRegistry, piCid, user
|
|
|
130
171
|
*/
|
|
131
172
|
async function notifyComputationComplete(db, logger, userCid, requestId, computation, displayName, success, errorMessage, options = {}) {
|
|
132
173
|
try {
|
|
174
|
+
// [FIX] Check user's notification preferences before sending
|
|
175
|
+
const { manageNotificationPreferences } = require('./data-fetchers/firestore.js');
|
|
176
|
+
const prefs = await manageNotificationPreferences(db, userCid, 'get');
|
|
177
|
+
|
|
178
|
+
if (!prefs.userActionCompletions) {
|
|
179
|
+
logger?.log('DEBUG', `[notifyComputationComplete] User ${userCid} has userActionCompletions disabled, skipping notification`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
133
183
|
const notificationData = {
|
|
134
184
|
type: 'computation',
|
|
135
185
|
subType: 'complete',
|
|
@@ -8,6 +8,64 @@ const { IntelligentProxyManager } = require('../../core/utils/intelligent_proxy_
|
|
|
8
8
|
const { IntelligentHeaderManager } = require('../../core/utils/intelligent_header_manager');
|
|
9
9
|
const zlib = require('zlib');
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Fetches individual user rankings data by CID
|
|
13
|
+
* @param {string} cid - Customer ID
|
|
14
|
+
* @param {object} headers - Request headers to use
|
|
15
|
+
* @param {object} proxyManager - ProxyManager instance
|
|
16
|
+
* @param {object} logger - Logger instance
|
|
17
|
+
* @returns {object|null} - User rankings data or null if failed
|
|
18
|
+
*/
|
|
19
|
+
async function fetchIndividualUserRankings(cid, headers, proxyManager, logger) {
|
|
20
|
+
const individualUrl = `https://www.etoro.com/sapi/rankings/cid/${cid}/rankings/?Period=OneYearAgo`;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
logger.log('INFO', `[PopularInvestorFetch] Fetching individual rankings for CID: ${cid}`);
|
|
24
|
+
|
|
25
|
+
// Try with proxy first
|
|
26
|
+
try {
|
|
27
|
+
const response = await proxyManager.fetch(individualUrl, {
|
|
28
|
+
method: 'GET',
|
|
29
|
+
headers
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (response.ok) {
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
if (data && data.Data) {
|
|
35
|
+
logger.log('SUCCESS', `[PopularInvestorFetch] Successfully fetched individual rankings for CID: ${cid} via proxy`);
|
|
36
|
+
return data.Data; // Return the Data object which matches the Items schema
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (proxyError) {
|
|
40
|
+
logger.log('WARN', `[PopularInvestorFetch] Proxy fetch failed for CID ${cid}: ${proxyError.message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback to direct fetch
|
|
44
|
+
try {
|
|
45
|
+
const directResponse = await fetch(individualUrl, {
|
|
46
|
+
method: 'GET',
|
|
47
|
+
headers
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (directResponse.ok) {
|
|
51
|
+
const data = await directResponse.json();
|
|
52
|
+
if (data && data.Data) {
|
|
53
|
+
logger.log('SUCCESS', `[PopularInvestorFetch] Successfully fetched individual rankings for CID: ${cid} via direct fetch`);
|
|
54
|
+
return data.Data;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (directError) {
|
|
58
|
+
logger.log('WARN', `[PopularInvestorFetch] Direct fetch failed for CID ${cid}: ${directError.message}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.log('ERROR', `[PopularInvestorFetch] Failed to fetch individual rankings for CID: ${cid} from all sources`);
|
|
62
|
+
return null;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logger.log('ERROR', `[PopularInvestorFetch] Error fetching individual rankings for CID ${cid}`, { errorMessage: error.message });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
11
69
|
/**
|
|
12
70
|
* Fetches the top Popular Investors and stores the raw result in Firestore.
|
|
13
71
|
* @param {object} dependencies - Contains db, logger.
|
|
@@ -117,6 +175,90 @@ async function fetchAndStorePopularInvestors(config, dependencies) {
|
|
|
117
175
|
await headerManager.flushPerformanceUpdates();
|
|
118
176
|
}
|
|
119
177
|
|
|
178
|
+
// 5.5. Check for missing users from master list and fetch them individually
|
|
179
|
+
if (data && data.Items && Array.isArray(data.Items)) {
|
|
180
|
+
try {
|
|
181
|
+
logger.log('INFO', '[PopularInvestorFetch] Checking for missing users from master list...');
|
|
182
|
+
|
|
183
|
+
// Get master list path
|
|
184
|
+
let masterListPath = 'system_state/popular_investor_master_list';
|
|
185
|
+
if (collectionRegistry && collectionRegistry.getCollectionPath) {
|
|
186
|
+
try {
|
|
187
|
+
const registryPath = collectionRegistry.getCollectionPath('system', 'popularInvestorMasterList', {});
|
|
188
|
+
masterListPath = registryPath;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
logger.log('WARN', `[PopularInvestorFetch] Failed to get master list path from registry, using default: ${e.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const masterListRef = db.doc(masterListPath);
|
|
195
|
+
const masterListDoc = await masterListRef.get();
|
|
196
|
+
|
|
197
|
+
if (masterListDoc.exists) {
|
|
198
|
+
const masterListData = masterListDoc.data();
|
|
199
|
+
const masterInvestors = masterListData.investors || {};
|
|
200
|
+
|
|
201
|
+
// Build a Set of CIDs from the fetched data for fast lookup
|
|
202
|
+
const fetchedCids = new Set(data.Items.map(item => String(item.CustomerId)));
|
|
203
|
+
|
|
204
|
+
// Identify missing CIDs
|
|
205
|
+
const masterCids = Object.keys(masterInvestors);
|
|
206
|
+
const missingCids = masterCids.filter(cid => !fetchedCids.has(cid));
|
|
207
|
+
|
|
208
|
+
if (missingCids.length > 0) {
|
|
209
|
+
logger.log('INFO', `[PopularInvestorFetch] Found ${missingCids.length} missing users from master list. Fetching individually...`);
|
|
210
|
+
|
|
211
|
+
// Prepare headers for individual fetches
|
|
212
|
+
const requestHeaders = {
|
|
213
|
+
'Accept': 'application/json',
|
|
214
|
+
'Referer': 'https://www.etoro.com/',
|
|
215
|
+
...(await headerManager.selectHeader()).header
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Fetch missing users with rate limiting
|
|
219
|
+
const missingUserData = [];
|
|
220
|
+
let successCount = 0;
|
|
221
|
+
let failureCount = 0;
|
|
222
|
+
|
|
223
|
+
for (const cid of missingCids) {
|
|
224
|
+
const userData = await fetchIndividualUserRankings(cid, requestHeaders, proxyManager, logger);
|
|
225
|
+
|
|
226
|
+
if (userData) {
|
|
227
|
+
missingUserData.push(userData);
|
|
228
|
+
successCount++;
|
|
229
|
+
} else {
|
|
230
|
+
failureCount++;
|
|
231
|
+
logger.log('WARN', `[PopularInvestorFetch] Failed to fetch data for missing user CID: ${cid} (${masterInvestors[cid].username})`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Add small delay between requests to avoid rate limiting
|
|
235
|
+
if (missingCids.length > 10 && missingCids.indexOf(cid) < missingCids.length - 1) {
|
|
236
|
+
await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Append successfully fetched missing users to the main data
|
|
241
|
+
if (missingUserData.length > 0) {
|
|
242
|
+
data.Items.push(...missingUserData);
|
|
243
|
+
data.TotalRows += missingUserData.length;
|
|
244
|
+
logger.log('SUCCESS', `[PopularInvestorFetch] Successfully fetched ${successCount}/${missingCids.length} missing users. Updated Items array from ${data.Items.length - missingUserData.length} to ${data.Items.length} users.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (failureCount > 0) {
|
|
248
|
+
logger.log('WARN', `[PopularInvestorFetch] Failed to fetch ${failureCount}/${missingCids.length} missing users.`);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
logger.log('INFO', '[PopularInvestorFetch] All users from master list are present in the main fetch. No missing users to fetch individually.');
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
logger.log('INFO', '[PopularInvestorFetch] Master list document does not exist yet. Skipping missing user check.');
|
|
255
|
+
}
|
|
256
|
+
} catch (missingUserError) {
|
|
257
|
+
logger.log('WARN', `[PopularInvestorFetch] Error while checking/fetching missing users: ${missingUserError.message}. Continuing with main fetch data.`);
|
|
258
|
+
// Non-critical error, continue with whatever data we have
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
120
262
|
// 6. Final Validation & Storage
|
|
121
263
|
if (data && data.Items && Array.isArray(data.Items)) {
|
|
122
264
|
try {
|