bulltrackers-module 1.0.609 → 1.0.611
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/functions/alert-system/helpers/alert_helpers.js +21 -4
- package/functions/alert-system/index.js +32 -10
- package/functions/api-v2/helpers/data-fetchers/firestore.js +79 -6
- package/functions/api-v2/routes/alerts.js +6 -5
- package/functions/api-v2/routes/popular_investors.js +5 -0
- package/functions/api-v2/routes/sync.js +42 -4
- package/package.json +1 -1
|
@@ -64,24 +64,41 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
64
64
|
}
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
// Write to alerts collection (not notifications) - alerts are separate from system notifications
|
|
67
68
|
// Use writeWithMigration to write to new path (with legacy fallback)
|
|
68
69
|
const writePromise = writeWithMigration(
|
|
69
70
|
db,
|
|
70
71
|
'signedInUsers',
|
|
71
|
-
'
|
|
72
|
+
'alerts', // Write to alerts collection, not notifications
|
|
72
73
|
{ cid: String(userCid) },
|
|
73
|
-
|
|
74
|
+
{
|
|
75
|
+
// Alert-specific format (simplified from notification format)
|
|
76
|
+
alertId: notificationId,
|
|
77
|
+
piCid: Number(piCid),
|
|
78
|
+
piUsername: piUsername,
|
|
79
|
+
alertType: alertType.id,
|
|
80
|
+
alertTypeName: alertType.name,
|
|
81
|
+
message: alertMessage,
|
|
82
|
+
severity: alertType.severity,
|
|
83
|
+
watchlistId: subscription.watchlistId,
|
|
84
|
+
watchlistName: subscription.watchlistName,
|
|
85
|
+
read: false,
|
|
86
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
87
|
+
computationDate: computationDate,
|
|
88
|
+
computationName: alertType.computationName,
|
|
89
|
+
...(computationMetadata || {})
|
|
90
|
+
},
|
|
74
91
|
{
|
|
75
92
|
isCollection: true,
|
|
76
93
|
merge: false,
|
|
77
|
-
dataType: '
|
|
94
|
+
dataType: 'alerts',
|
|
78
95
|
documentId: notificationId,
|
|
79
96
|
dualWrite: false, // Disable dual write - we're fully migrated to new path
|
|
80
97
|
config,
|
|
81
98
|
collectionRegistry
|
|
82
99
|
}
|
|
83
100
|
).catch(err => {
|
|
84
|
-
logger.log('ERROR', `[processAlertForPI] Failed to write
|
|
101
|
+
logger.log('ERROR', `[processAlertForPI] Failed to write alert for CID ${userCid}: ${err.message}`, err);
|
|
85
102
|
throw err; // Re-throw so we know if writes are failing
|
|
86
103
|
});
|
|
87
104
|
|
|
@@ -343,11 +343,22 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
|
|
|
343
343
|
|
|
344
344
|
const notificationsRef = db.collection(notificationsPath);
|
|
345
345
|
|
|
346
|
-
// Check
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.
|
|
346
|
+
// Check alerts collection instead of notifications
|
|
347
|
+
// Use collection registry to get alerts path
|
|
348
|
+
let alertsPath;
|
|
349
|
+
if (collectionRegistry && collectionRegistry.getCollectionPath) {
|
|
350
|
+
alertsPath = collectionRegistry.getCollectionPath('signedInUsers', 'alerts', { cid: String(userCid) });
|
|
351
|
+
} else {
|
|
352
|
+
// Fallback to legacy path
|
|
353
|
+
alertsPath = `user_alerts/${userCid}/alerts`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const alertsRef = db.collection(alertsPath);
|
|
357
|
+
|
|
358
|
+
// Check if there are any alerts for this PI today
|
|
359
|
+
const snapshot = await alertsRef
|
|
360
|
+
.where('piCid', '==', Number(piCid))
|
|
361
|
+
.where('computationDate', '==', date)
|
|
351
362
|
.limit(1)
|
|
352
363
|
.get();
|
|
353
364
|
|
|
@@ -415,19 +426,30 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
415
426
|
}
|
|
416
427
|
};
|
|
417
428
|
|
|
418
|
-
//
|
|
419
|
-
// notifications is a subcollection, so we need isCollection: true and documentId
|
|
429
|
+
// Write to alerts collection (not notifications) - alerts are separate from system notifications
|
|
420
430
|
const { writeWithMigration } = require('../old-generic-api/user-api/helpers/core/path_resolution_helpers');
|
|
421
431
|
await writeWithMigration(
|
|
422
432
|
db,
|
|
423
433
|
'signedInUsers',
|
|
424
|
-
'
|
|
434
|
+
'alerts', // Write to alerts collection, not notifications
|
|
425
435
|
{ cid: String(userCid) },
|
|
426
|
-
|
|
436
|
+
{
|
|
437
|
+
// Alert-specific format
|
|
438
|
+
alertId: notificationId,
|
|
439
|
+
piCid: Number(piCid),
|
|
440
|
+
piUsername: piUsername,
|
|
441
|
+
alertType: 'all_clear',
|
|
442
|
+
alertTypeName: 'All Clear',
|
|
443
|
+
message: `${piUsername} was processed, all clear today!`,
|
|
444
|
+
severity: 'info',
|
|
445
|
+
read: false,
|
|
446
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
447
|
+
computationDate: date
|
|
448
|
+
},
|
|
427
449
|
{
|
|
428
450
|
isCollection: true,
|
|
429
451
|
merge: false,
|
|
430
|
-
dataType: '
|
|
452
|
+
dataType: 'alerts',
|
|
431
453
|
documentId: notificationId,
|
|
432
454
|
dualWrite: false, // Disable dual write - we're fully migrated to new path
|
|
433
455
|
config,
|
|
@@ -1112,13 +1112,26 @@ const getComputationResults = async (db, computationName, dateStr, userId = null
|
|
|
1112
1112
|
|
|
1113
1113
|
// 11. Fetch User Notifications
|
|
1114
1114
|
const fetchNotifications = async (firestore, userId, options = {}) => {
|
|
1115
|
-
const { limit = 20, unreadOnly = false } = options;
|
|
1115
|
+
const { limit = 20, unreadOnly = false, excludeTypes = ['watchlistAlerts'] } = options;
|
|
1116
1116
|
try {
|
|
1117
1117
|
let query = firestore.collection('SignedInUsers').doc(userId).collection('notifications');
|
|
1118
1118
|
if (unreadOnly) query = query.where('read', '==', false);
|
|
1119
1119
|
|
|
1120
|
-
const snapshot = await query.orderBy('createdAt', 'desc').limit(limit).get();
|
|
1121
|
-
|
|
1120
|
+
const snapshot = await query.orderBy('createdAt', 'desc').limit(limit * 2).get(); // Fetch more to filter
|
|
1121
|
+
|
|
1122
|
+
// Filter out excluded types (like watchlistAlerts which go to alerts bell)
|
|
1123
|
+
let notifications = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
|
1124
|
+
|
|
1125
|
+
if (excludeTypes && excludeTypes.length > 0) {
|
|
1126
|
+
notifications = notifications.filter(notif => {
|
|
1127
|
+
// Exclude by type field or metadata.notificationType
|
|
1128
|
+
const notifType = notif.type || notif.metadata?.notificationType;
|
|
1129
|
+
return !excludeTypes.includes(notifType);
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Limit after filtering
|
|
1134
|
+
return notifications.slice(0, limit);
|
|
1122
1135
|
} catch (error) {
|
|
1123
1136
|
console.error(`Error fetching notifications: ${error}`);
|
|
1124
1137
|
throw error;
|
|
@@ -1225,14 +1238,29 @@ const trackPopularInvestorView = async (db, piId, viewerId = null, viewerType =
|
|
|
1225
1238
|
// ==========================================
|
|
1226
1239
|
|
|
1227
1240
|
const fetchUserAlerts = async (db, userId, options = {}) => {
|
|
1228
|
-
const { limit = 50, unreadOnly = false, type } = options;
|
|
1229
|
-
|
|
1241
|
+
const { limit = 50, unreadOnly = false, type, piCid } = options;
|
|
1242
|
+
// Use new path: SignedInUsers/{cid}/alerts
|
|
1243
|
+
let query = db.collection('SignedInUsers').doc(String(userId)).collection('alerts').orderBy('createdAt', 'desc');
|
|
1230
1244
|
|
|
1231
1245
|
if (unreadOnly) query = query.where('read', '==', false);
|
|
1232
1246
|
if (type) query = query.where('alertType', '==', type);
|
|
1247
|
+
if (piCid) query = query.where('piCid', '==', Number(piCid));
|
|
1233
1248
|
|
|
1234
1249
|
const snapshot = await query.limit(limit).get();
|
|
1235
|
-
|
|
1250
|
+
// Convert Firestore timestamps to ISO strings for frontend
|
|
1251
|
+
return snapshot.docs.map(doc => {
|
|
1252
|
+
const data = doc.data();
|
|
1253
|
+
// Convert Firestore timestamps to ISO strings
|
|
1254
|
+
if (data.createdAt) {
|
|
1255
|
+
data.createdAt = data.createdAt.toDate ? data.createdAt.toDate().toISOString() :
|
|
1256
|
+
(data.createdAt.toISOString ? data.createdAt.toISOString() : data.createdAt);
|
|
1257
|
+
}
|
|
1258
|
+
if (data.readAt) {
|
|
1259
|
+
data.readAt = data.readAt.toDate ? data.readAt.toDate().toISOString() :
|
|
1260
|
+
(data.readAt.toISOString ? data.readAt.toISOString() : data.readAt);
|
|
1261
|
+
}
|
|
1262
|
+
return { id: doc.id, ...data };
|
|
1263
|
+
});
|
|
1236
1264
|
};
|
|
1237
1265
|
|
|
1238
1266
|
const subscribeToWatchlistAlerts = async (db, userId, watchlistId, piId, alertConfig) => {
|
|
@@ -2184,6 +2212,50 @@ const checkDataStatus = async (db, userId) => {
|
|
|
2184
2212
|
};
|
|
2185
2213
|
};
|
|
2186
2214
|
|
|
2215
|
+
/**
|
|
2216
|
+
* Check Popular Investor data status - finds the latest computation date for a PI
|
|
2217
|
+
* Similar to checkDataStatus but specifically for PopularInvestorProfileMetrics
|
|
2218
|
+
*/
|
|
2219
|
+
const checkPopularInvestorDataStatus = async (db, piId) => {
|
|
2220
|
+
const lookbackDays = 7;
|
|
2221
|
+
const today = new Date();
|
|
2222
|
+
let computationDate = null;
|
|
2223
|
+
|
|
2224
|
+
const computationName = 'PopularInvestorProfileMetrics';
|
|
2225
|
+
|
|
2226
|
+
// Check for computation results in the last 7 days
|
|
2227
|
+
for (let i = 0; i < lookbackDays; i++) {
|
|
2228
|
+
const checkDate = new Date(today);
|
|
2229
|
+
checkDate.setDate(checkDate.getDate() - i);
|
|
2230
|
+
const dateStr = checkDate.toISOString().split('T')[0];
|
|
2231
|
+
|
|
2232
|
+
try {
|
|
2233
|
+
const pageRef = db.collection('unified_insights')
|
|
2234
|
+
.doc(dateStr)
|
|
2235
|
+
.collection('results')
|
|
2236
|
+
.doc('popular-investor')
|
|
2237
|
+
.collection('computations')
|
|
2238
|
+
.doc(computationName)
|
|
2239
|
+
.collection('pages')
|
|
2240
|
+
.doc(String(piId));
|
|
2241
|
+
|
|
2242
|
+
const pageSnap = await pageRef.get();
|
|
2243
|
+
if (pageSnap.exists) {
|
|
2244
|
+
computationDate = dateStr;
|
|
2245
|
+
break;
|
|
2246
|
+
}
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
// Continue checking other dates
|
|
2249
|
+
console.error(`Error checking computation ${computationName} for ${dateStr}:`, error);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
return {
|
|
2254
|
+
computationDate: computationDate,
|
|
2255
|
+
available: computationDate !== null
|
|
2256
|
+
};
|
|
2257
|
+
};
|
|
2258
|
+
|
|
2187
2259
|
const sendTestAlert = async (db, userId, payload) => {
|
|
2188
2260
|
// Create a fake notification
|
|
2189
2261
|
const id = `test_${Date.now()}`;
|
|
@@ -2511,6 +2583,7 @@ module.exports = {
|
|
|
2511
2583
|
getSyncStatus,
|
|
2512
2584
|
autoGenerateWatchlist,
|
|
2513
2585
|
checkDataStatus,
|
|
2586
|
+
checkPopularInvestorDataStatus,
|
|
2514
2587
|
sendTestAlert,
|
|
2515
2588
|
isSignedInUser,
|
|
2516
2589
|
getUserUsername,
|
|
@@ -14,11 +14,12 @@ const router = express.Router();
|
|
|
14
14
|
router.get('/history', async (req, res) => {
|
|
15
15
|
try {
|
|
16
16
|
const { db } = req.dependencies;
|
|
17
|
-
const { limit, unreadOnly, type } = req.query;
|
|
17
|
+
const { limit, unreadOnly, type, piCid } = req.query;
|
|
18
18
|
const alerts = await fetchUserAlerts(db, req.targetUserId, {
|
|
19
19
|
limit: parseInt(limit || 50),
|
|
20
20
|
unreadOnly: unreadOnly === 'true',
|
|
21
|
-
type
|
|
21
|
+
type: type || undefined,
|
|
22
|
+
piCid: piCid ? Number(piCid) : undefined
|
|
22
23
|
});
|
|
23
24
|
res.json({ success: true, count: alerts.length, data: alerts });
|
|
24
25
|
} catch (error) {
|
|
@@ -41,7 +42,7 @@ router.get('/count', async (req, res) => {
|
|
|
41
42
|
router.put('/:id/read', async (req, res) => {
|
|
42
43
|
try {
|
|
43
44
|
const { db } = req.dependencies;
|
|
44
|
-
await db.collection('
|
|
45
|
+
await db.collection('SignedInUsers').doc(String(req.targetUserId)).collection('alerts').doc(req.params.id)
|
|
45
46
|
.update({ read: true, readAt: new Date() });
|
|
46
47
|
res.json({ success: true });
|
|
47
48
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
@@ -52,7 +53,7 @@ router.put('/read-all', async (req, res) => {
|
|
|
52
53
|
try {
|
|
53
54
|
const { db } = req.dependencies;
|
|
54
55
|
const batch = db.batch();
|
|
55
|
-
const snaps = await db.collection('
|
|
56
|
+
const snaps = await db.collection('SignedInUsers').doc(String(req.targetUserId)).collection('alerts').where('read', '==', false).get();
|
|
56
57
|
snaps.docs.forEach(doc => batch.update(doc.ref, { read: true, readAt: new Date() }));
|
|
57
58
|
await batch.commit();
|
|
58
59
|
res.json({ success: true, updated: snaps.size });
|
|
@@ -63,7 +64,7 @@ router.put('/read-all', async (req, res) => {
|
|
|
63
64
|
router.delete('/:id', async (req, res) => {
|
|
64
65
|
try {
|
|
65
66
|
const { db } = req.dependencies;
|
|
66
|
-
await db.collection('
|
|
67
|
+
await db.collection('SignedInUsers').doc(String(req.targetUserId)).collection('alerts').doc(req.params.id).delete();
|
|
67
68
|
res.json({ success: true });
|
|
68
69
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
69
70
|
});
|
|
@@ -147,11 +147,16 @@ router.get('/:piId/profile', async (req, res) => {
|
|
|
147
147
|
const computationName = 'PopularInvestorProfileMetrics';
|
|
148
148
|
const profileData = await pageCollection(db, targetDate, computationName, piId, parseInt(lookback));
|
|
149
149
|
|
|
150
|
+
// Extract computationDate from the latest data entry (first item in array, sorted by date descending)
|
|
151
|
+
// The profileData array contains { date, data } objects, sorted with latest first
|
|
152
|
+
const computationDate = profileData && profileData.length > 0 ? profileData[0].date : null;
|
|
153
|
+
|
|
150
154
|
res.json({
|
|
151
155
|
success: true,
|
|
152
156
|
computation: computationName,
|
|
153
157
|
piId: piId,
|
|
154
158
|
data: profileData,
|
|
159
|
+
computationDate: computationDate, // Latest computation date for up-to-date validation
|
|
155
160
|
profileType: 'public' // Indicates this is a public PI profile
|
|
156
161
|
});
|
|
157
162
|
} catch (error) {
|
|
@@ -6,7 +6,9 @@ const {
|
|
|
6
6
|
isDeveloper,
|
|
7
7
|
isSignedInUser,
|
|
8
8
|
fetchPopularInvestorMasterList,
|
|
9
|
-
getUserUsername
|
|
9
|
+
getUserUsername,
|
|
10
|
+
checkDataStatus,
|
|
11
|
+
checkPopularInvestorDataStatus
|
|
10
12
|
} = require('../helpers/data-fetchers/firestore.js');
|
|
11
13
|
|
|
12
14
|
const router = express.Router();
|
|
@@ -25,20 +27,56 @@ const handleSyncRequest = async (req, res) => {
|
|
|
25
27
|
const limit = await checkSyncRateLimits(db, targetId, req.targetUserId, isDev);
|
|
26
28
|
if (!limit.allowed) return res.status(429).json({ error: limit.message });
|
|
27
29
|
|
|
28
|
-
// 2. Detect User Types
|
|
30
|
+
// 2. Detect User Types (needed for validation)
|
|
29
31
|
const [isSignedIn, isPI] = await Promise.all([
|
|
30
32
|
isSignedInUser(db, targetId),
|
|
31
33
|
fetchPopularInvestorMasterList(db, String(targetId)).then(() => true).catch(() => false)
|
|
32
34
|
]);
|
|
33
35
|
|
|
36
|
+
// 3. Check if data is already up-to-date (prevent unnecessary syncs)
|
|
37
|
+
// Developers can bypass this check
|
|
38
|
+
if (!isDev) {
|
|
39
|
+
try {
|
|
40
|
+
let computationDate = null;
|
|
41
|
+
|
|
42
|
+
// Check Popular Investor data status if target is a PI (anyone can sync a PI, so always check)
|
|
43
|
+
if (isPI) {
|
|
44
|
+
const piDataStatus = await checkPopularInvestorDataStatus(db, String(targetId));
|
|
45
|
+
computationDate = piDataStatus.computationDate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check signed-in user data status if target is a signed-in user syncing themselves
|
|
49
|
+
// (Only check if not already found from PI check, and only if syncing themselves)
|
|
50
|
+
if (isSignedIn && targetId === req.targetUserId && !computationDate) {
|
|
51
|
+
const userDataStatus = await checkDataStatus(db, String(targetId));
|
|
52
|
+
computationDate = userDataStatus.computationDate;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// If we found a computation date and it's today, block the sync
|
|
56
|
+
if (computationDate) {
|
|
57
|
+
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
58
|
+
if (computationDate === today) {
|
|
59
|
+
return res.status(400).json({
|
|
60
|
+
error: "Data is already up-to-date for today. Sync is not needed.",
|
|
61
|
+
code: "DATA_UP_TO_DATE",
|
|
62
|
+
computationDate: computationDate
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// If data status check fails, log but don't block sync (fail open for reliability)
|
|
68
|
+
console.warn(`[handleSyncRequest] Failed to check data status for ${targetId}:`, error.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
if (!isSignedIn && !isPI) {
|
|
35
73
|
return res.status(404).json({ error: "User not found in SignedInUsers or Popular Investor master list" });
|
|
36
74
|
}
|
|
37
75
|
|
|
38
|
-
//
|
|
76
|
+
// 4. Get username
|
|
39
77
|
const username = await getUserUsername(db, targetId) || String(targetId);
|
|
40
78
|
|
|
41
|
-
//
|
|
79
|
+
// 5. Create request IDs and dispatch tasks
|
|
42
80
|
const requestIds = [];
|
|
43
81
|
const tasks = [];
|
|
44
82
|
|