bulltrackers-module 1.0.471 → 1.0.473

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,7 +16,8 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
16
16
  const piUsername = await getPIUsername(db, piCid);
17
17
 
18
18
  // 2. Find all users subscribed to this PI and alert type
19
- const subscriptions = await findSubscriptionsForPI(db, piCid, alertType.id);
19
+ // Use computationName (e.g., 'RiskScoreIncrease') to map to alertConfig keys
20
+ const subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName);
20
21
 
21
22
  if (subscriptions.length === 0) {
22
23
  logger.log('INFO', `[processAlertForPI] No subscriptions found for PI ${piCid}, alert type ${alertType.id}`);
@@ -28,37 +29,41 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
28
29
  // 3. Generate alert message
29
30
  const alertMessage = generateAlertMessage(alertType, piUsername, computationMetadata);
30
31
 
31
- // 4. Create alerts for each subscribed user
32
+ // 4. Create notifications for each subscribed user (using user_notifications collection)
32
33
  const batch = db.batch();
33
- const alertRefs = [];
34
+ const notificationRefs = [];
34
35
  const counterUpdates = {};
35
36
 
36
37
  for (const subscription of subscriptions) {
37
- const alertId = `alert_${Date.now()}_${subscription.userCid}_${piCid}`;
38
- const alertRef = db.collection('user_alerts')
38
+ const notificationId = `alert_${Date.now()}_${subscription.userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
39
+ const notificationRef = db.collection('user_notifications')
39
40
  .doc(String(subscription.userCid))
40
- .collection('alerts')
41
- .doc(alertId);
41
+ .collection('notifications')
42
+ .doc(notificationId);
42
43
 
43
- const alertData = {
44
- id: alertId,
45
- userCid: subscription.userCid,
46
- piCid: piCid,
47
- piUsername: piUsername,
48
- alertType: alertType.id,
49
- alertTypeName: alertType.name,
50
- computationName: alertType.computationName,
51
- computationDate: computationDate,
44
+ const notificationData = {
45
+ id: notificationId,
46
+ type: 'alert',
47
+ title: alertType.name,
52
48
  message: alertMessage,
53
- severity: alertType.severity,
54
49
  read: false,
55
- readAt: null,
56
50
  createdAt: FieldValue.serverTimestamp(),
57
- metadata: computationMetadata || {}
51
+ metadata: {
52
+ piCid: Number(piCid),
53
+ piUsername: piUsername,
54
+ alertType: alertType.id,
55
+ alertTypeName: alertType.name,
56
+ computationName: alertType.computationName,
57
+ computationDate: computationDate,
58
+ severity: alertType.severity,
59
+ watchlistId: subscription.watchlistId,
60
+ watchlistName: subscription.watchlistName,
61
+ ...(computationMetadata || {})
62
+ }
58
63
  };
59
64
 
60
- batch.set(alertRef, alertData);
61
- alertRefs.push(alertRef);
65
+ batch.set(notificationRef, notificationData);
66
+ notificationRefs.push(notificationRef);
62
67
 
63
68
  // Track counter updates
64
69
  const dateKey = computationDate || new Date().toISOString().split('T')[0];
@@ -76,9 +81,9 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
76
81
  (counterUpdates[subscription.userCid].byType[alertType.id] || 0) + 1;
77
82
  }
78
83
 
79
- // 5. Update alert counters
84
+ // 5. Update notification counters
80
85
  for (const [userCid, counter] of Object.entries(counterUpdates)) {
81
- const counterRef = db.collection('user_alerts')
86
+ const counterRef = db.collection('user_notifications')
82
87
  .doc(String(userCid))
83
88
  .collection('counters')
84
89
  .doc(counter.date);
@@ -92,25 +97,10 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
92
97
  }, { merge: true });
93
98
  }
94
99
 
95
- // 6. Update subscription lastAlertAt
96
- for (const subscription of subscriptions) {
97
- const subscriptionRef = db.collection('watchlist_subscriptions')
98
- .doc(String(subscription.userCid))
99
- .collection('alerts')
100
- .doc(String(piCid));
101
-
102
- batch.update(subscriptionRef, {
103
- lastAlertAt: FieldValue.serverTimestamp()
104
- });
105
- }
106
-
107
- // 7. Commit batch
100
+ // 6. Commit batch
108
101
  await batch.commit();
109
102
 
110
- logger.log('SUCCESS', `[processAlertForPI] Created ${alertRefs.length} alerts for PI ${piCid}, alert type ${alertType.id}`);
111
-
112
- // 8. Optionally notify the PI if they're a signed-in user
113
- await notifyPIIfSignedIn(db, logger, piCid, alertType, subscriptions.length);
103
+ logger.log('SUCCESS', `[processAlertForPI] Created ${notificationRefs.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
114
104
 
115
105
  } catch (error) {
116
106
  logger.log('ERROR', `[processAlertForPI] Error processing alert for PI ${piCid}`, error);
@@ -119,35 +109,67 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
119
109
  }
120
110
 
121
111
  /**
122
- * Find all subscriptions for a PI and alert type
112
+ * Find all users who should receive alerts for a PI and alert type
113
+ * Reads from actual watchlist structure: watchlists/{userCid}/lists/{watchlistId}
123
114
  */
124
- async function findSubscriptionsForPI(db, piCid, alertTypeId) {
115
+ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId) {
125
116
  const subscriptions = [];
126
117
 
127
- // Query all users who have subscriptions
128
- const subscriptionsCollection = db.collection('watchlist_subscriptions');
129
- const snapshot = await subscriptionsCollection
130
- .where('piCid', '==', Number(piCid))
131
- .get();
118
+ // Map computation names to watchlist alertConfig keys
119
+ // The alertTypeId is actually the computation name (e.g., 'RiskScoreIncrease')
120
+ const computationToConfigKey = {
121
+ 'RiskScoreIncrease': 'increasedRisk',
122
+ 'SignificantVolatility': 'volatilityChanges',
123
+ 'NewSectorExposure': 'newSector',
124
+ 'PositionInvestedIncrease': 'increasedPositionSize',
125
+ 'NewSocialPost': 'newSocialPost',
126
+ 'NewPositions': 'newPositions' // If this computation exists
127
+ };
132
128
 
133
- for (const doc of snapshot.docs) {
134
- const data = doc.data();
135
- const userCid = data.userCid;
129
+ const configKey = computationToConfigKey[alertTypeId];
130
+ if (!configKey) {
131
+ logger.log('WARN', `[findSubscriptionsForPI] No mapping found for alert type: ${alertTypeId}`);
132
+ return subscriptions;
133
+ }
134
+
135
+ // Get all watchlists
136
+ const watchlistsCollection = db.collection('watchlists');
137
+ const watchlistsSnapshot = await watchlistsCollection.get();
138
+
139
+ for (const userDoc of watchlistsSnapshot.docs) {
140
+ const userCid = Number(userDoc.id);
141
+ const userListsSnapshot = await userDoc.ref.collection('lists').get();
136
142
 
137
- // Check if this user is subscribed to this alert type
138
- if (data.alertTypes && data.alertTypes[alertTypeId] === true) {
139
- // Check thresholds if applicable
140
- if (shouldTriggerAlert(data, alertTypeId)) {
141
- subscriptions.push({
142
- userCid: userCid,
143
- piCid: piCid,
144
- alertTypes: data.alertTypes,
145
- thresholds: data.thresholds || {}
146
- });
143
+ for (const listDoc of userListsSnapshot.docs) {
144
+ const listData = listDoc.data();
145
+
146
+ // Check static watchlists
147
+ if (listData.type === 'static' && listData.items && Array.isArray(listData.items)) {
148
+ for (const item of listData.items) {
149
+ if (Number(item.cid) === Number(piCid)) {
150
+ // Check if this alert type is enabled for this PI in this watchlist
151
+ if (item.alertConfig && item.alertConfig[configKey] === true) {
152
+ subscriptions.push({
153
+ userCid: userCid,
154
+ piCid: piCid,
155
+ piUsername: item.username || `PI-${piCid}`,
156
+ watchlistId: listDoc.id,
157
+ watchlistName: listData.name || 'Unnamed Watchlist',
158
+ alertConfig: item.alertConfig
159
+ });
160
+ break; // Found in this watchlist, no need to check other items
161
+ }
162
+ }
163
+ }
147
164
  }
165
+
166
+ // Check dynamic watchlists - if the PI matches the computation result, check alertConfig
167
+ // Note: Dynamic watchlists are handled separately as they're based on computation results
168
+ // For now, we only process static watchlists here
148
169
  }
149
170
  }
150
171
 
172
+ logger.log('INFO', `[findSubscriptionsForPI] Found ${subscriptions.length} subscriptions for PI ${piCid}, alert type ${alertTypeId}`);
151
173
  return subscriptions;
152
174
  }
153
175
 
@@ -336,10 +358,41 @@ function readComputationResults(docData) {
336
358
  }
337
359
  }
338
360
 
361
+ /**
362
+ * Read computation results, handling sharded data
363
+ */
364
+ async function readComputationResultsWithShards(db, docData, docRef) {
365
+ try {
366
+ // Check if data is sharded
367
+ if (docData._sharded === true && docData._shardCount) {
368
+ // Data is stored in shards - read all shards and merge
369
+ const shardsCol = docRef.collection('_shards');
370
+ const shardsSnapshot = await shardsCol.get();
371
+
372
+ if (!shardsSnapshot.empty) {
373
+ let mergedData = {};
374
+ for (const shardDoc of shardsSnapshot.docs) {
375
+ const shardData = shardDoc.data();
376
+ const decompressed = tryDecompress(shardData);
377
+ Object.assign(mergedData, decompressed);
378
+ }
379
+ return readComputationResults(mergedData);
380
+ }
381
+ }
382
+
383
+ // Data is in the main document (compressed or not)
384
+ return readComputationResults(docData);
385
+ } catch (error) {
386
+ console.error('[readComputationResultsWithShards] Error reading sharded results', error);
387
+ return { cids: [], metadata: {}, perUserData: {} };
388
+ }
389
+ }
390
+
339
391
  module.exports = {
340
392
  processAlertForPI,
341
393
  findSubscriptionsForPI,
342
394
  getPIUsername,
343
- readComputationResults
395
+ readComputationResults,
396
+ readComputationResultsWithShards
344
397
  };
345
398
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const { getAlertTypeByComputation, isAlertComputation } = require('./helpers/alert_type_registry');
7
- const { processAlertForPI, readComputationResults } = require('./helpers/alert_helpers');
7
+ const { processAlertForPI, readComputationResults, readComputationResultsWithShards } = require('./helpers/alert_helpers');
8
8
 
9
9
  /**
10
10
  * Pub/Sub trigger handler for alert generation
@@ -126,13 +126,33 @@ async function handleAlertTrigger(message, context, config, dependencies) {
126
126
  }
127
127
 
128
128
  /**
129
- * Firestore trigger function for alert generation (if using Firestore triggers)
129
+ * Firestore trigger function for alert generation
130
130
  * Triggered when computation results are written to:
131
- * unified_insights/{date}/results/popular-investor/computations/{computationName}
131
+ * unified_insights/{date}/results/{category}/computations/{computationName}
132
+ *
133
+ * Handles sharded data by checking _shards subcollection if _sharded === true
132
134
  */
133
135
  async function handleComputationResultWrite(change, context, config, dependencies) {
134
136
  const { db, logger } = dependencies;
135
- const { date, computationName } = context.params;
137
+
138
+ // Extract path parameters from the document path
139
+ // Path format: unified_insights/{date}/results/{category}/computations/{computationName}
140
+ const resource = context.resource || change.after.ref.path;
141
+ const pathParts = resource.split('/');
142
+
143
+ // Find indices of key path segments
144
+ const dateIndex = pathParts.indexOf('unified_insights') + 1;
145
+ const categoryIndex = pathParts.indexOf('results') + 1;
146
+ const computationIndex = pathParts.indexOf('computations') + 1;
147
+
148
+ if (dateIndex === 0 || categoryIndex === 0 || computationIndex === 0) {
149
+ logger.log('WARN', `[AlertTrigger] Invalid document path format: ${resource}`);
150
+ return;
151
+ }
152
+
153
+ const date = pathParts[dateIndex];
154
+ const category = pathParts[categoryIndex];
155
+ const computationName = pathParts[computationIndex];
136
156
 
137
157
  // Only process if document was created or updated (not deleted)
138
158
  if (!change.after.exists) {
@@ -148,10 +168,16 @@ async function handleComputationResultWrite(change, context, config, dependencie
148
168
  return;
149
169
  }
150
170
 
171
+ // Only process popular-investor category computations
172
+ if (category !== 'popular-investor') {
173
+ logger.log('DEBUG', `[AlertTrigger] Not popular-investor category: ${category}`);
174
+ return;
175
+ }
176
+
151
177
  // If it's PopularInvestorProfileMetrics, check for all-clear notifications only
152
178
  if (isProfileMetrics) {
153
179
  const docData = change.after.data();
154
- const results = readComputationResults(docData);
180
+ const results = await readComputationResultsWithShards(db, docData, change.after.ref);
155
181
  if (results.cids && results.cids.length > 0) {
156
182
  await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
157
183
  }
@@ -166,9 +192,9 @@ async function handleComputationResultWrite(change, context, config, dependencie
166
192
 
167
193
  logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
168
194
 
169
- // 2. Read and decompress computation results
195
+ // 2. Read and decompress computation results (handling shards)
170
196
  const docData = change.after.data();
171
- const results = readComputationResults(docData);
197
+ const results = await readComputationResultsWithShards(db, docData, change.after.ref);
172
198
 
173
199
  if (!results.cids || results.cids.length === 0) {
174
200
  logger.log('INFO', `[AlertTrigger] No PIs found in computation results for ${computationName}`);
@@ -178,6 +204,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
178
204
  logger.log('INFO', `[AlertTrigger] Found ${results.cids.length} PIs in ${computationName} results`);
179
205
 
180
206
  // 3. Process alerts for each PI
207
+ // Use computationName as the alertTypeId (it maps to alertConfig keys)
181
208
  let processedCount = 0;
182
209
  let errorCount = 0;
183
210
 
@@ -186,7 +213,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
186
213
  // Extract PI-specific metadata if available, otherwise use global metadata
187
214
  const piMetadata = results.perUserData && results.perUserData[piCid]
188
215
  ? results.perUserData[piCid]
189
- : results.metadata || {};
216
+ : results.metadata || results.globalMetadata || {};
190
217
 
191
218
  await processAlertForPI(
192
219
  db,
@@ -206,12 +233,6 @@ async function handleComputationResultWrite(change, context, config, dependencie
206
233
 
207
234
  logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
208
235
 
209
- // After processing alerts, check for "all clear" notifications for static watchlists
210
- if (processedCount > 0 || results.cids.length > 0) {
211
- // Check if any PIs were processed but didn't trigger alerts
212
- await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
213
- }
214
-
215
236
  } catch (error) {
216
237
  logger.log('ERROR', `[AlertTrigger] Fatal error processing ${computationName}`, error);
217
238
  // Don't throw - we don't want to retry the entire computation write
@@ -238,7 +259,7 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
238
259
  const usersToNotify = new Map(); // userCid -> Map of piCid -> {username, ...}
239
260
 
240
261
  for (const userDoc of watchlistsSnapshot.docs) {
241
- const userCid = userDoc.id;
262
+ const userCid = Number(userDoc.id);
242
263
  const userListsSnapshot = await userDoc.ref.collection('lists').get();
243
264
 
244
265
  for (const listDoc of userListsSnapshot.docs) {
@@ -253,8 +274,8 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
253
274
  for (const item of listData.items) {
254
275
  const piCid = Number(item.cid);
255
276
  if (processedPICids.includes(piCid)) {
256
- // Check if this PI triggered any alerts today
257
- const hasAlerts = await checkIfPIHasAlertsToday(db, userCid, piCid, today);
277
+ // Check if this PI triggered any alerts today for this user
278
+ const hasAlerts = await checkIfPIHasAlertsToday(db, logger, userCid, piCid, today);
258
279
 
259
280
  if (!hasAlerts) {
260
281
  // No alerts triggered - send "all clear" notification
@@ -292,28 +313,28 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
292
313
 
293
314
  /**
294
315
  * Check if a PI has any alerts for a user today
316
+ * Checks user_notifications collection for alert-type notifications
295
317
  */
296
- async function checkIfPIHasAlertsToday(db, userCid, piCid, date) {
318
+ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date) {
297
319
  try {
298
- const alertsRef = db.collection('user_alerts')
320
+ const notificationsRef = db.collection('user_notifications')
299
321
  .doc(String(userCid))
300
- .collection('alerts');
301
-
302
- // Check if there are any alerts for this PI today
303
- const todayStart = new Date(date);
304
- todayStart.setHours(0, 0, 0, 0);
305
- const todayEnd = new Date(date);
306
- todayEnd.setHours(23, 59, 59, 999);
322
+ .collection('notifications');
307
323
 
308
- const snapshot = await alertsRef
309
- .where('piCid', '==', Number(piCid))
310
- .where('computationDate', '==', date)
324
+ // Check if there are any alert notifications for this PI today
325
+ const snapshot = await notificationsRef
326
+ .where('type', '==', 'alert')
327
+ .where('metadata.piCid', '==', Number(piCid))
328
+ .where('metadata.computationDate', '==', date)
311
329
  .limit(1)
312
330
  .get();
313
331
 
314
332
  return !snapshot.empty;
315
333
  } catch (error) {
316
- // If we can't check, assume there are alerts (safer)
334
+ if (logger) {
335
+ logger.log('WARN', `[checkIfPIHasAlertsToday] Error checking alerts for user ${userCid}, PI ${piCid}: ${error.message}`);
336
+ }
337
+ // If we can't check, assume there are alerts (safer - won't send false all-clear)
317
338
  return true;
318
339
  }
319
340
  }
@@ -202,15 +202,10 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
202
202
  await updateComputationStatus(dStr, successUpdates, config, deps);
203
203
  }
204
204
 
205
- // Flush Alert Triggers to Pub/Sub
205
+ // Alert triggers are now handled via Firestore triggers
206
+ // No need to publish Pub/Sub messages - the Firestore write itself triggers the alert system
206
207
  if (alertTriggers.length > 0) {
207
- const topicName = config.alertTopicName || 'alert-trigger';
208
- try {
209
- await pubSubUtils.publishMessageBatch(topicName, alertTriggers);
210
- logger.log('INFO', `[Alert System] Triggered ${alertTriggers.length} alerts to ${topicName}`);
211
- } catch (alertErr) {
212
- logger.log('ERROR', `[Alert System] Failed to publish alerts: ${alertErr.message}`);
213
- }
208
+ logger.log('INFO', `[Alert System] ${alertTriggers.length} alert computations written to Firestore - triggers will fire automatically`);
214
209
  }
215
210
 
216
211
  return { successUpdates, failureReport, shardIndexes: nextShardIndexes };
package/index.js CHANGED
@@ -57,7 +57,7 @@ const { runRootDataIndexer } = require('./functions
57
57
  const { runPopularInvestorFetch } = require('./functions/fetch-popular-investors/index');
58
58
 
59
59
  // Alert System
60
- const { handleAlertTrigger, handleComputationResultWrite } = require('./functions/alert-system/index');
60
+ const { handleAlertTrigger, handleComputationResultWrite, checkAndSendAllClearNotifications } = require('./functions/alert-system/index');
61
61
 
62
62
  // Proxy
63
63
  const { handlePost } = require('./functions/appscript-api/index');
@@ -126,8 +126,9 @@ const maintenance = {
126
126
  const proxy = { handlePost };
127
127
 
128
128
  const alertSystem = {
129
- handleAlertTrigger,
130
- handleComputationResultWrite
129
+ handleAlertTrigger, // Pub/Sub handler (kept for backward compatibility, but not used)
130
+ handleComputationResultWrite, // Firestore trigger handler - main entry point
131
+ checkAndSendAllClearNotifications
131
132
  };
132
133
 
133
134
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.471",
3
+ "version": "1.0.473",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [