bulltrackers-module 1.0.521 → 1.0.522

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.
@@ -1,35 +1,124 @@
1
1
  /**
2
2
  * @fileoverview Notification Helpers for On-Demand Requests
3
3
  * Sends notifications to users when their on-demand sync/computation requests complete
4
+ * UPDATED: Added notification preferences support and task engine notifications
4
5
  */
5
6
 
6
7
  const { FieldValue } = require('@google-cloud/firestore');
8
+ const { readWithMigration, writeWithMigration } = require('../core/path_resolution_helpers');
9
+
10
+ /**
11
+ * Get user notification preferences
12
+ * @param {object} db - Firestore instance
13
+ * @param {object} collectionRegistry - Collection registry
14
+ * @param {number} userCid - User CID
15
+ * @param {object} config - Config object
16
+ * @returns {Promise<object>} Notification preferences object
17
+ */
18
+ async function getUserNotificationPreferences(db, collectionRegistry, userCid, config) {
19
+ try {
20
+ // Use readWithMigration to support legacy paths during migration
21
+ const result = await readWithMigration(
22
+ db,
23
+ 'signedInUsers',
24
+ 'notificationPreferences',
25
+ { cid: String(userCid) },
26
+ {
27
+ isCollection: false,
28
+ dataType: 'notificationPreferences',
29
+ config,
30
+ documentId: 'settings',
31
+ collectionRegistry
32
+ }
33
+ );
34
+
35
+ if (result && result.data) {
36
+ const data = result.data;
37
+ // Support both nested structure (settings: {...}) and flat structure
38
+ const preferences = data.settings || data || {};
39
+
40
+ // Default preferences (all enabled except test alerts)
41
+ return {
42
+ syncProcesses: preferences.syncProcesses !== undefined ? preferences.syncProcesses : true,
43
+ userActionCompletions: preferences.userActionCompletions !== undefined ? preferences.userActionCompletions : true,
44
+ watchlistAlerts: preferences.watchlistAlerts !== undefined ? preferences.watchlistAlerts : true,
45
+ testAlerts: preferences.testAlerts !== undefined ? preferences.testAlerts : false
46
+ };
47
+ }
48
+
49
+ // If document doesn't exist, return defaults
50
+ return {
51
+ syncProcesses: true,
52
+ userActionCompletions: true,
53
+ watchlistAlerts: true,
54
+ testAlerts: false
55
+ };
56
+ } catch (error) {
57
+ // If error, return defaults
58
+ return {
59
+ syncProcesses: true,
60
+ userActionCompletions: true,
61
+ watchlistAlerts: true,
62
+ testAlerts: false
63
+ };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Check if user should receive a notification of a given type
69
+ * @param {object} db - Firestore instance
70
+ * @param {object} collectionRegistry - Collection registry
71
+ * @param {number} userCid - User CID
72
+ * @param {string} notificationType - Notification type ('syncProcesses', 'userActionCompletions', 'watchlistAlerts', 'testAlerts')
73
+ * @param {object} config - Config object
74
+ * @returns {Promise<boolean>} True if user should receive notification
75
+ */
76
+ async function shouldSendNotification(db, collectionRegistry, userCid, notificationType, config) {
77
+ const preferences = await getUserNotificationPreferences(db, collectionRegistry, userCid, config);
78
+ return preferences[notificationType] === true;
79
+ }
7
80
 
8
81
  /**
9
82
  * Send a notification to a user about their on-demand request
10
- * @param {Firestore} db - Firestore instance
11
- * @param {Object} logger - Logger instance
83
+ * @param {object} db - Firestore instance
84
+ * @param {object} logger - Logger instance
12
85
  * @param {number} userCid - User CID to notify
13
86
  * @param {string} type - Notification type ('success', 'error', 'progress')
14
87
  * @param {string} title - Notification title
15
88
  * @param {string} message - Notification message
16
- * @param {Object} metadata - Additional metadata (requestId, computationName, etc.)
89
+ * @param {object} metadata - Additional metadata (requestId, computationName, etc.)
90
+ * @param {object} options - Optional: { collectionRegistry, config, notificationType }
17
91
  */
18
- async function sendOnDemandNotification(db, logger, userCid, type, title, message, metadata = {}) {
92
+ async function sendOnDemandNotification(db, logger, userCid, type, title, message, metadata = {}, options = {}) {
93
+ // Check notification preferences if collectionRegistry is provided
94
+ if (options.collectionRegistry && options.config && options.notificationType) {
95
+ const shouldSend = await shouldSendNotification(
96
+ db,
97
+ options.collectionRegistry,
98
+ userCid,
99
+ options.notificationType,
100
+ options.config
101
+ );
102
+
103
+ if (!shouldSend) {
104
+ logger.log('INFO', `[sendOnDemandNotification] Skipping notification to user ${userCid} (preference disabled for ${options.notificationType})`);
105
+ return;
106
+ }
107
+ }
19
108
  try {
20
- const notificationId = `ondemand_${Date.now()}_${userCid}_${Math.random().toString(36).substring(2, 9)}`;
21
- const notificationRef = db.collection('user_notifications')
22
- .doc(String(userCid))
23
- .collection('notifications')
24
- .doc(notificationId);
109
+ const { collectionRegistry } = options;
110
+ const config = options.config || {};
111
+
112
+ const notificationId = `notif_${Date.now()}_${userCid}_${Math.random().toString(36).substring(2, 9)}`;
25
113
 
26
114
  const notificationData = {
27
115
  id: notificationId,
28
- type: 'on_demand',
29
- subType: type, // 'success', 'error', 'progress'
116
+ type: metadata.notificationType || 'on_demand',
117
+ subType: type, // 'success', 'error', 'progress', 'info'
30
118
  title,
31
119
  message,
32
120
  read: false,
121
+ timestamp: FieldValue.serverTimestamp(),
33
122
  createdAt: FieldValue.serverTimestamp(),
34
123
  metadata: {
35
124
  ...metadata,
@@ -37,21 +126,30 @@ async function sendOnDemandNotification(db, logger, userCid, type, title, messag
37
126
  }
38
127
  };
39
128
 
40
- await notificationRef.set(notificationData);
41
-
42
- // Update notification counter
43
- const today = new Date().toISOString().split('T')[0];
44
- const counterRef = db.collection('user_notifications')
45
- .doc(String(userCid))
46
- .collection('counters')
47
- .doc(today);
48
-
49
- await counterRef.set({
50
- date: today,
51
- unreadCount: FieldValue.increment(1),
52
- totalCount: FieldValue.increment(1),
53
- lastUpdated: FieldValue.serverTimestamp()
54
- }, { merge: true });
129
+ // Write using collection registry
130
+ if (collectionRegistry) {
131
+ await writeWithMigration(
132
+ db,
133
+ 'signedInUsers',
134
+ 'notifications',
135
+ { cid: String(userCid), notificationId },
136
+ notificationData,
137
+ {
138
+ isCollection: false,
139
+ merge: false,
140
+ dataType: 'notifications',
141
+ config,
142
+ collectionRegistry
143
+ }
144
+ );
145
+ } else {
146
+ // Fallback to legacy path
147
+ const notificationRef = db.collection('user_notifications')
148
+ .doc(String(userCid))
149
+ .collection('notifications')
150
+ .doc(notificationId);
151
+ await notificationRef.set(notificationData);
152
+ }
55
153
 
56
154
  logger.log('INFO', `[sendOnDemandNotification] Sent ${type} notification to user ${userCid}: ${title}`);
57
155
 
@@ -64,7 +162,7 @@ async function sendOnDemandNotification(db, logger, userCid, type, title, messag
64
162
  /**
65
163
  * Send notification when task engine completes data fetch
66
164
  */
67
- async function notifyTaskEngineComplete(db, logger, requestingUserCid, requestId, username, success, error = null) {
165
+ async function notifyTaskEngineComplete(db, logger, requestingUserCid, requestId, username, success, error = null, options = {}) {
68
166
  if (!requestingUserCid) return; // No user to notify
69
167
 
70
168
  const type = success ? 'success' : 'error';
@@ -79,14 +177,83 @@ async function notifyTaskEngineComplete(db, logger, requestingUserCid, requestId
79
177
  requestId,
80
178
  username,
81
179
  stage: 'task_engine',
82
- success
180
+ success,
181
+ notificationType: 'syncProcesses'
182
+ }, {
183
+ ...options,
184
+ notificationType: 'syncProcesses'
83
185
  });
84
186
  }
85
187
 
188
+ /**
189
+ * Send notification when PI data is refreshed (for users with PI in watchlist)
190
+ * @param {object} db - Firestore instance
191
+ * @param {object} logger - Logger instance
192
+ * @param {object} collectionRegistry - Collection registry
193
+ * @param {number} piCid - Popular Investor CID
194
+ * @param {string} piUsername - Popular Investor username
195
+ * @param {object} config - Config object
196
+ */
197
+ async function notifyPIDataRefreshed(db, logger, collectionRegistry, piCid, piUsername, config) {
198
+ try {
199
+ // Find all users who have this PI in their watchlist
200
+ // Check both static and dynamic watchlists
201
+ const watchlistMembershipRef = db.collection('WatchlistMembershipData')
202
+ .doc(new Date().toISOString().split('T')[0]);
203
+
204
+ const membershipDoc = await watchlistMembershipRef.get();
205
+ if (!membershipDoc.exists) {
206
+ logger.log('INFO', `[notifyPIDataRefreshed] No watchlist membership data found for today`);
207
+ return;
208
+ }
209
+
210
+ const membershipData = membershipDoc.data();
211
+ const piCidStr = String(piCid);
212
+ const piMembership = membershipData[piCidStr];
213
+
214
+ if (!piMembership || !piMembership.users || !Array.isArray(piMembership.users)) {
215
+ logger.log('INFO', `[notifyPIDataRefreshed] No users have PI ${piCid} in their watchlist`);
216
+ return;
217
+ }
218
+
219
+ const userIds = piMembership.users.map(u => String(u));
220
+ logger.log('INFO', `[notifyPIDataRefreshed] Found ${userIds.length} users with PI ${piCid} in watchlist`);
221
+
222
+ // Send notification to each user
223
+ const notificationPromises = userIds.map(async (userId) => {
224
+ await sendOnDemandNotification(
225
+ db,
226
+ logger,
227
+ Number(userId),
228
+ 'info',
229
+ 'Popular Investor Data Updated',
230
+ `${piUsername} had their data refreshed. Portfolio, trade history, and social data have been updated.`,
231
+ {
232
+ piCid: Number(piCid),
233
+ piUsername: piUsername,
234
+ notificationType: 'watchlistAlerts'
235
+ },
236
+ {
237
+ collectionRegistry,
238
+ config,
239
+ notificationType: 'watchlistAlerts'
240
+ }
241
+ );
242
+ });
243
+
244
+ await Promise.all(notificationPromises);
245
+ logger.log('INFO', `[notifyPIDataRefreshed] Sent notifications to ${userIds.length} users for PI ${piCid}`);
246
+
247
+ } catch (error) {
248
+ logger.log('ERROR', `[notifyPIDataRefreshed] Failed to send notifications for PI ${piCid}`, error);
249
+ // Don't throw - notifications are non-critical
250
+ }
251
+ }
252
+
86
253
  /**
87
254
  * Send notification when computation completes
88
255
  */
89
- async function notifyComputationComplete(db, logger, requestingUserCid, requestId, computationName, displayName, success, error = null) {
256
+ async function notifyComputationComplete(db, logger, requestingUserCid, requestId, computationName, displayName, success, error = null, options = {}) {
90
257
  if (!requestingUserCid) return; // No user to notify
91
258
 
92
259
  const type = success ? 'success' : 'error';
@@ -102,7 +269,11 @@ async function notifyComputationComplete(db, logger, requestingUserCid, requestI
102
269
  computationName,
103
270
  displayName,
104
271
  stage: 'computation',
105
- success
272
+ success,
273
+ notificationType: 'userActionCompletions'
274
+ }, {
275
+ ...options,
276
+ notificationType: 'userActionCompletions'
106
277
  });
107
278
  }
108
279
 
@@ -122,9 +293,170 @@ function getComputationDisplayName(computationName) {
122
293
  return displayNames[computationName] || computationName;
123
294
  }
124
295
 
296
+ /**
297
+ * GET /user/me/notification-preferences
298
+ * Get user notification preferences
299
+ */
300
+ async function getNotificationPreferences(req, res, dependencies, config) {
301
+ const { db, logger, collectionRegistry } = dependencies;
302
+ const { userCid } = req.query;
303
+
304
+ if (!userCid) {
305
+ return res.status(400).json({ error: "Missing userCid" });
306
+ }
307
+
308
+ try {
309
+ const preferences = await getUserNotificationPreferences(db, collectionRegistry, Number(userCid), config);
310
+ return res.status(200).json({
311
+ success: true,
312
+ preferences
313
+ });
314
+ } catch (error) {
315
+ logger.log('ERROR', `[getNotificationPreferences] Error fetching preferences for ${userCid}`, error);
316
+ return res.status(500).json({ error: error.message });
317
+ }
318
+ }
319
+
320
+ /**
321
+ * PUT /user/me/notification-preferences
322
+ * Update user notification preferences
323
+ */
324
+ async function updateNotificationPreferences(req, res, dependencies, config) {
325
+ const { db, logger, collectionRegistry } = dependencies;
326
+ const { userCid } = req.query;
327
+ const { preferences } = req.body;
328
+
329
+ if (!userCid) {
330
+ return res.status(400).json({ error: "Missing userCid" });
331
+ }
332
+
333
+ if (!preferences || typeof preferences !== 'object') {
334
+ return res.status(400).json({ error: "Missing or invalid preferences object" });
335
+ }
336
+
337
+ try {
338
+ // Validate preferences
339
+ const validKeys = ['syncProcesses', 'userActionCompletions', 'watchlistAlerts', 'testAlerts'];
340
+ const settingsData = {};
341
+
342
+ for (const key of validKeys) {
343
+ if (preferences.hasOwnProperty(key)) {
344
+ settingsData[key] = Boolean(preferences[key]);
345
+ }
346
+ }
347
+
348
+ if (Object.keys(settingsData).length === 0) {
349
+ return res.status(400).json({ error: "No valid preferences provided" });
350
+ }
351
+
352
+ // Use writeWithMigration to support legacy paths during migration
353
+ await writeWithMigration(
354
+ db,
355
+ 'signedInUsers',
356
+ 'notificationPreferences',
357
+ { cid: String(userCid) },
358
+ {
359
+ settings: settingsData,
360
+ updatedAt: FieldValue.serverTimestamp()
361
+ },
362
+ {
363
+ isCollection: false,
364
+ merge: true,
365
+ dataType: 'notificationPreferences',
366
+ config,
367
+ documentId: 'settings',
368
+ collectionRegistry
369
+ }
370
+ );
371
+
372
+ logger.log('INFO', `[updateNotificationPreferences] Updated preferences for user ${userCid}`);
373
+
374
+ // Return updated preferences
375
+ const updatedPreferences = await getUserNotificationPreferences(db, collectionRegistry, Number(userCid), config);
376
+
377
+ return res.status(200).json({
378
+ success: true,
379
+ preferences: updatedPreferences
380
+ });
381
+ } catch (error) {
382
+ logger.log('ERROR', `[updateNotificationPreferences] Error updating preferences for ${userCid}`, error);
383
+ return res.status(500).json({ error: error.message });
384
+ }
385
+ }
386
+
387
+ /**
388
+ * GET /user/me/notifications
389
+ * Get user notification history
390
+ */
391
+ async function getNotificationHistory(req, res, dependencies, config) {
392
+ const { db, logger, collectionRegistry } = dependencies;
393
+ const { userCid, limit = 50, offset = 0, type, read } = req.query;
394
+
395
+ if (!userCid) {
396
+ return res.status(400).json({ error: "Missing userCid" });
397
+ }
398
+
399
+ try {
400
+ // Use collection registry to get notifications path
401
+ let notificationsPath;
402
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
403
+ notificationsPath = collectionRegistry.getCollectionPath('signedInUsers', 'notifications', { cid: String(userCid) });
404
+ } else {
405
+ // Fallback to legacy path
406
+ notificationsPath = `user_notifications/${userCid}/notifications`;
407
+ }
408
+
409
+ let query = db.collection(notificationsPath)
410
+ .orderBy('timestamp', 'desc')
411
+ .limit(parseInt(limit));
412
+
413
+ if (type) {
414
+ query = query.where('type', '==', type);
415
+ }
416
+
417
+ if (read !== undefined) {
418
+ query = query.where('read', '==', read === 'true');
419
+ }
420
+
421
+ const snapshot = await query.get();
422
+ const notifications = [];
423
+
424
+ snapshot.forEach(doc => {
425
+ const data = doc.data();
426
+ notifications.push({
427
+ id: doc.id,
428
+ type: data.type || 'other',
429
+ subType: data.subType,
430
+ title: data.title || '',
431
+ message: data.message || '',
432
+ read: data.read || false,
433
+ timestamp: data.timestamp?.toDate?.()?.toISOString() || data.createdAt?.toDate?.()?.toISOString() || new Date().toISOString(),
434
+ metadata: data.metadata || {}
435
+ });
436
+ });
437
+
438
+ return res.status(200).json({
439
+ success: true,
440
+ notifications,
441
+ count: notifications.length,
442
+ limit: parseInt(limit),
443
+ offset: parseInt(offset)
444
+ });
445
+ } catch (error) {
446
+ logger.log('ERROR', `[getNotificationHistory] Error fetching notifications for ${userCid}`, error);
447
+ return res.status(500).json({ error: error.message });
448
+ }
449
+ }
450
+
125
451
  module.exports = {
126
452
  sendOnDemandNotification,
127
453
  notifyTaskEngineComplete,
128
454
  notifyComputationComplete,
129
- getComputationDisplayName
455
+ notifyPIDataRefreshed,
456
+ getComputationDisplayName,
457
+ getUserNotificationPreferences,
458
+ shouldSendNotification,
459
+ getNotificationPreferences,
460
+ updateNotificationPreferences,
461
+ getNotificationHistory
130
462
  };
@@ -13,6 +13,7 @@ const { getAlertTypes, getDynamicWatchlistComputations, getUserAlerts, getAlertC
13
13
  const { requestPiFetch, getPiFetchStatus } = require('./helpers/fetch/on_demand_fetch_helpers');
14
14
  const { requestUserSync, getUserSyncStatus } = require('./helpers/sync/user_sync_helpers');
15
15
  const { sendTestAlert } = require('./helpers/alerts/test_alert_helpers');
16
+ const { getNotificationPreferences, updateNotificationPreferences, getNotificationHistory } = require('./helpers/notifications/notification_helpers');
16
17
 
17
18
  module.exports = (dependencies, config) => {
18
19
  const router = express.Router();
@@ -99,5 +100,10 @@ module.exports = (dependencies, config) => {
99
100
  router.put('/me/alerts/read-all', (req, res) => markAllAlertsRead(req, res, dependencies, config));
100
101
  router.delete('/me/alerts/:alertId', (req, res) => deleteAlert(req, res, dependencies, config));
101
102
 
103
+ // --- Notification Preferences & History ---
104
+ router.get('/me/notification-preferences', (req, res) => getNotificationPreferences(req, res, dependencies, config));
105
+ router.put('/me/notification-preferences', (req, res) => updateNotificationPreferences(req, res, dependencies, config));
106
+ router.get('/me/notifications', (req, res) => getNotificationHistory(req, res, dependencies, config));
107
+
102
108
  return router;
103
109
  };
@@ -515,6 +515,17 @@ async function handlePopularInvestorUpdate(taskData, config, dependencies) {
515
515
 
516
516
  logger.log('SUCCESS', `[PI Update] Completed full update for ${username} (Portfolio: ✓, History: ✓, Social: ${socialFetched ? '✓' : '✗'})`);
517
517
 
518
+ // Send notifications to users who have this PI in their watchlist
519
+ if (cid && username && db && collectionRegistry && config) {
520
+ try {
521
+ const { notifyPIDataRefreshed } = require('../../../generic-api/user-api/helpers/notifications/notification_helpers');
522
+ await notifyPIDataRefreshed(db, logger, collectionRegistry, cid, username, config);
523
+ } catch (notifError) {
524
+ logger.log('WARN', `[PI Update] Failed to send notifications for PI ${cid}: ${notifError.message}`);
525
+ // Non-critical, continue
526
+ }
527
+ }
528
+
518
529
  // Update request status and trigger computation if this is an on-demand request (PI fetch or sync)
519
530
  if (requestId && (source === 'on_demand' || source === 'on_demand_sync') && db) {
520
531
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.521",
3
+ "version": "1.0.522",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [