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.
@@ -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
- 'notifications',
72
+ 'alerts', // Write to alerts collection, not notifications
72
73
  { cid: String(userCid) },
73
- notificationData,
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: 'notifications',
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 notification for CID ${userCid}: ${err.message}`, err);
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 if there are any alert notifications for this PI today
347
- const snapshot = await notificationsRef
348
- .where('type', '==', 'watchlistAlerts')
349
- .where('metadata.piCid', '==', Number(piCid))
350
- .where('metadata.computationDate', '==', date)
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
- // Use writeWithMigration to write to new path (with legacy fallback)
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
- 'notifications',
434
+ 'alerts', // Write to alerts collection, not notifications
425
435
  { cid: String(userCid) },
426
- notificationData,
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: 'notifications',
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
- return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
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
- let query = db.collection('user_alerts').doc(userId).collection('alerts').orderBy('createdAt', 'desc');
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
- return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
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('user_alerts').doc(req.targetUserId).collection('alerts').doc(req.params.id)
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('user_alerts').doc(req.targetUserId).collection('alerts').where('read', '==', false).get();
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('user_alerts').doc(req.targetUserId).collection('alerts').doc(req.params.id).delete();
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
- // 3. Get username
76
+ // 4. Get username
39
77
  const username = await getUserUsername(db, targetId) || String(targetId);
40
78
 
41
- // 4. Create request IDs and dispatch tasks
79
+ // 5. Create request IDs and dispatch tasks
42
80
  const requestIds = [];
43
81
  const tasks = [];
44
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.609",
3
+ "version": "1.0.611",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [