bulltrackers-module 1.0.466 → 1.0.468

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.
@@ -112,6 +112,13 @@ async function handleAlertTrigger(message, context, config, dependencies) {
112
112
 
113
113
  logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
114
114
 
115
+ // After processing alerts, check for "all clear" notifications for static watchlists
116
+ // This runs after all alert computations are processed
117
+ if (processedCount > 0 || results.cids.length > 0) {
118
+ // Check if any PIs were processed but didn't trigger alerts
119
+ await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
120
+ }
121
+
115
122
  } catch (error) {
116
123
  logger.log('ERROR', '[AlertTrigger] Fatal error processing alert trigger', error);
117
124
  throw error; // Re-throw for Pub/Sub retry
@@ -134,12 +141,23 @@ async function handleComputationResultWrite(change, context, config, dependencie
134
141
  }
135
142
 
136
143
  try {
137
- // 1. Check if this is an alert computation
138
- if (!isAlertComputation(computationName)) {
139
- logger.log('DEBUG', `[AlertTrigger] Not an alert computation: ${computationName}`);
144
+ // 1. Check if this is an alert computation OR PopularInvestorProfileMetrics
145
+ const isProfileMetrics = computationName === 'PopularInvestorProfileMetrics';
146
+ if (!isAlertComputation(computationName) && !isProfileMetrics) {
147
+ logger.log('DEBUG', `[AlertTrigger] Not an alert computation or profile metrics: ${computationName}`);
140
148
  return;
141
149
  }
142
150
 
151
+ // If it's PopularInvestorProfileMetrics, check for all-clear notifications only
152
+ if (isProfileMetrics) {
153
+ const docData = change.after.data();
154
+ const results = readComputationResults(docData);
155
+ if (results.cids && results.cids.length > 0) {
156
+ await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
157
+ }
158
+ return; // Don't process as alert computation
159
+ }
160
+
143
161
  const alertType = getAlertTypeByComputation(computationName);
144
162
  if (!alertType) {
145
163
  logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
@@ -188,17 +206,189 @@ async function handleComputationResultWrite(change, context, config, dependencie
188
206
 
189
207
  logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
190
208
 
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
+
191
215
  } catch (error) {
192
216
  logger.log('ERROR', `[AlertTrigger] Fatal error processing ${computationName}`, error);
193
217
  // Don't throw - we don't want to retry the entire computation write
194
218
  }
195
219
  }
196
220
 
221
+ /**
222
+ * Check for PIs in static watchlists that were processed but didn't trigger alerts
223
+ * Send "all clear" notifications to users who have these PIs in their static watchlists
224
+ * This should be called after PopularInvestorProfileMetrics is computed (not just alert computations)
225
+ */
226
+ async function checkAndSendAllClearNotifications(db, logger, processedPICids, computationDate, config) {
227
+ try {
228
+ const today = computationDate || new Date().toISOString().split('T')[0];
229
+
230
+ if (!processedPICids || processedPICids.length === 0) {
231
+ return; // No PIs to check
232
+ }
233
+
234
+ // Get all static watchlists that contain any of the processed PIs
235
+ const watchlistsCollection = config.watchlistsCollection || 'watchlists';
236
+ const watchlistsSnapshot = await db.collection(watchlistsCollection).get();
237
+
238
+ const usersToNotify = new Map(); // userCid -> Map of piCid -> {username, ...}
239
+
240
+ for (const userDoc of watchlistsSnapshot.docs) {
241
+ const userCid = userDoc.id;
242
+ const userListsSnapshot = await userDoc.ref.collection('lists').get();
243
+
244
+ for (const listDoc of userListsSnapshot.docs) {
245
+ const listData = listDoc.data();
246
+
247
+ // Only check static watchlists
248
+ if (listData.type !== 'static' || !listData.items || !Array.isArray(listData.items)) {
249
+ continue;
250
+ }
251
+
252
+ // Check if any processed PI is in this watchlist
253
+ for (const item of listData.items) {
254
+ const piCid = Number(item.cid);
255
+ if (processedPICids.includes(piCid)) {
256
+ // Check if this PI triggered any alerts today
257
+ const hasAlerts = await checkIfPIHasAlertsToday(db, userCid, piCid, today);
258
+
259
+ if (!hasAlerts) {
260
+ // No alerts triggered - send "all clear" notification
261
+ if (!usersToNotify.has(userCid)) {
262
+ usersToNotify.set(userCid, new Map());
263
+ }
264
+ usersToNotify.get(userCid).set(piCid, {
265
+ cid: piCid,
266
+ username: item.username || `PI-${piCid}`
267
+ });
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ // Send notifications to all users
275
+ let totalNotifications = 0;
276
+ for (const [userCid, pisMap] of usersToNotify.entries()) {
277
+ for (const [piCid, pi] of pisMap.entries()) {
278
+ await sendAllClearNotification(db, logger, userCid, pi.cid, pi.username, today);
279
+ totalNotifications++;
280
+ }
281
+ }
282
+
283
+ if (totalNotifications > 0) {
284
+ logger.log('INFO', `[checkAndSendAllClearNotifications] Sent ${totalNotifications} all-clear notifications to ${usersToNotify.size} users`);
285
+ }
286
+
287
+ } catch (error) {
288
+ logger.log('ERROR', `[checkAndSendAllClearNotifications] Error checking all-clear notifications`, error);
289
+ // Don't throw - this is a non-critical feature
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Check if a PI has any alerts for a user today
295
+ */
296
+ async function checkIfPIHasAlertsToday(db, userCid, piCid, date) {
297
+ try {
298
+ const alertsRef = db.collection('user_alerts')
299
+ .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);
307
+
308
+ const snapshot = await alertsRef
309
+ .where('piCid', '==', Number(piCid))
310
+ .where('computationDate', '==', date)
311
+ .limit(1)
312
+ .get();
313
+
314
+ return !snapshot.empty;
315
+ } catch (error) {
316
+ // If we can't check, assume there are alerts (safer)
317
+ return true;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Send an "all clear" notification to a user
323
+ */
324
+ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername, date) {
325
+ try {
326
+ const { FieldValue } = require('@google-cloud/firestore');
327
+
328
+ // Check if we already sent an all-clear notification for this PI today
329
+ const existingRef = db.collection('user_alerts')
330
+ .doc(String(userCid))
331
+ .collection('alerts')
332
+ .where('piCid', '==', Number(piCid))
333
+ .where('alertType', '==', 'all_clear')
334
+ .where('computationDate', '==', date)
335
+ .limit(1);
336
+
337
+ const existingSnapshot = await existingRef.get();
338
+ if (!existingSnapshot.empty) {
339
+ // Already sent notification for this PI today
340
+ return;
341
+ }
342
+
343
+ // Create the notification using the notification system
344
+ const notificationRef = db.collection('user_notifications')
345
+ .doc(String(userCid))
346
+ .collection('notifications')
347
+ .doc();
348
+
349
+ await notificationRef.set({
350
+ id: notificationRef.id,
351
+ type: 'alert',
352
+ title: 'All Clear',
353
+ message: `${piUsername} was processed, all clear today!`,
354
+ piCid: Number(piCid),
355
+ piUsername: piUsername,
356
+ alertType: 'all_clear',
357
+ computationDate: date,
358
+ read: false,
359
+ createdAt: FieldValue.serverTimestamp(),
360
+ metadata: {
361
+ message: 'User was processed, all clear today!'
362
+ }
363
+ });
364
+
365
+ // Update notification counter
366
+ const counterRef = db.collection('user_notifications')
367
+ .doc(String(userCid))
368
+ .collection('counters')
369
+ .doc(date);
370
+
371
+ await counterRef.set({
372
+ date: date,
373
+ unreadCount: FieldValue.increment(1),
374
+ totalCount: FieldValue.increment(1),
375
+ lastUpdated: FieldValue.serverTimestamp()
376
+ }, { merge: true });
377
+
378
+ logger.log('INFO', `[sendAllClearNotification] Sent all-clear notification to user ${userCid} for PI ${piCid}`);
379
+
380
+ } catch (error) {
381
+ logger.log('ERROR', `[sendAllClearNotification] Error sending all-clear notification`, error);
382
+ // Don't throw - this is non-critical
383
+ }
384
+ }
385
+
197
386
  /**
198
387
  * Export the trigger handlers
199
388
  */
200
389
  module.exports = {
201
390
  handleAlertTrigger,
202
- handleComputationResultWrite
391
+ handleComputationResultWrite,
392
+ checkAndSendAllClearNotifications
203
393
  };
204
394
 
@@ -592,15 +592,22 @@ async function publishWatchlistVersion(req, res, dependencies, config) {
592
592
  name: watchlistData.name,
593
593
  type: watchlistData.type,
594
594
  description: watchlistData.dynamicConfig?.description || '',
595
- // Snapshot the current state
596
- items: watchlistData.items ? JSON.parse(JSON.stringify(watchlistData.items)) : undefined,
597
- dynamicConfig: watchlistData.dynamicConfig ? JSON.parse(JSON.stringify(watchlistData.dynamicConfig)) : undefined,
598
595
  snapshotAt: FieldValue.serverTimestamp(),
599
596
  copyCount: 0,
600
597
  createdAt: watchlistData.createdAt,
601
598
  isImmutable: true // Public versions are immutable
602
599
  };
603
600
 
601
+ // Only include items if it's a static watchlist
602
+ if (watchlistData.type === 'static' && watchlistData.items) {
603
+ versionData.items = JSON.parse(JSON.stringify(watchlistData.items));
604
+ }
605
+
606
+ // Only include dynamicConfig if it's a dynamic watchlist
607
+ if (watchlistData.type === 'dynamic' && watchlistData.dynamicConfig) {
608
+ versionData.dynamicConfig = JSON.parse(JSON.stringify(watchlistData.dynamicConfig));
609
+ }
610
+
604
611
  // Store version in versions subcollection
605
612
  const versionRef = db.collection('public_watchlists')
606
613
  .doc(id)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.466",
3
+ "version": "1.0.468",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [