bulltrackers-module 1.0.523 → 1.0.525

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.
@@ -6,11 +6,12 @@
6
6
  const { FieldValue } = require('@google-cloud/firestore');
7
7
  const zlib = require('zlib');
8
8
  const { getAlertTypeByComputation, generateAlertMessage } = require('./alert_type_registry');
9
+ const { writeWithMigration } = require('../../generic-api/user-api/helpers/core/path_resolution_helpers');
9
10
 
10
11
  /**
11
12
  * Process alerts for a specific PI from computation results
12
13
  */
13
- async function processAlertForPI(db, logger, piCid, alertType, computationMetadata, computationDate) {
14
+ async function processAlertForPI(db, logger, piCid, alertType, computationMetadata, computationDate, dependencies = {}) {
14
15
  try {
15
16
  // 1. Get PI username from rankings or subscriptions
16
17
  const piUsername = await getPIUsername(db, piCid);
@@ -29,52 +30,24 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
29
30
  // 3. Generate alert message
30
31
  const alertMessage = generateAlertMessage(alertType, piUsername, computationMetadata);
31
32
 
32
- // Helper function to get Firebase UID from eToro CID
33
- async function getFirebaseUidFromCid(etoroCid) {
34
- const signedInUsersSnapshot = await db.collection('signedInUsers')
35
- .where('etoroCID', '==', Number(etoroCid))
36
- .limit(1)
37
- .get();
38
-
39
- if (!signedInUsersSnapshot.empty) {
40
- return signedInUsersSnapshot.docs[0].id; // Firebase UID is the document ID
41
- }
42
- return null;
43
- }
44
-
45
- // 4. Create notifications for each subscribed user (using user_notifications collection)
46
- // Convert eToro CIDs to Firebase UIDs first
47
- const batch = db.batch();
48
- const notificationRefs = [];
49
- const counterUpdates = {};
50
- const uidMapping = {}; // Cache for CID -> UID mappings
33
+ // 4. Create notifications for each subscribed user (using CID and collection registry)
34
+ const { collectionRegistry } = dependencies;
35
+ const config = dependencies.config || {};
36
+ const notificationPromises = [];
51
37
 
52
38
  for (const subscription of subscriptions) {
53
- // Get Firebase UID for this user's eToro CID
54
- let firebaseUid = uidMapping[subscription.userCid];
55
- if (!firebaseUid) {
56
- firebaseUid = await getFirebaseUidFromCid(subscription.userCid);
57
- if (!firebaseUid) {
58
- logger.log('WARN', `[processAlertForPI] Could not find Firebase UID for user CID ${subscription.userCid}, skipping notification`);
59
- continue; // Skip this user if we can't find their Firebase UID
60
- }
61
- uidMapping[subscription.userCid] = firebaseUid;
62
- }
63
-
64
- const notificationId = `alert_${Date.now()}_${subscription.userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
65
- const notificationRef = db.collection('user_notifications')
66
- .doc(firebaseUid) // Use Firebase UID, not eToro CID
67
- .collection('notifications')
68
- .doc(notificationId);
39
+ const userCid = subscription.userCid;
40
+ const notificationId = `alert_${Date.now()}_${userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
69
41
 
70
42
  const notificationData = {
71
43
  id: notificationId,
72
- type: 'alert',
44
+ type: 'watchlistAlerts', // Use watchlistAlerts type for watchlist-based alerts
45
+ subType: 'alert',
73
46
  title: alertType.name,
74
47
  message: alertMessage,
75
48
  read: false,
49
+ timestamp: FieldValue.serverTimestamp(),
76
50
  createdAt: FieldValue.serverTimestamp(),
77
- timestamp: FieldValue.serverTimestamp(), // Also include timestamp for ordering
78
51
  metadata: {
79
52
  piCid: Number(piCid),
80
53
  piUsername: piUsername,
@@ -85,54 +58,46 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
85
58
  severity: alertType.severity,
86
59
  watchlistId: subscription.watchlistId,
87
60
  watchlistName: subscription.watchlistName,
61
+ notificationType: 'watchlistAlerts',
62
+ userCid: Number(userCid),
88
63
  ...(computationMetadata || {})
89
64
  }
90
65
  };
91
66
 
92
- batch.set(notificationRef, notificationData);
93
- notificationRefs.push(notificationRef);
94
-
95
- // Track counter updates (using Firebase UID as key)
96
- const dateKey = computationDate || new Date().toISOString().split('T')[0];
97
- if (!counterUpdates[firebaseUid]) {
98
- counterUpdates[firebaseUid] = {
99
- date: dateKey,
100
- unreadCount: 0,
101
- totalCount: 0,
102
- byType: {}
103
- };
104
- }
105
- counterUpdates[firebaseUid].unreadCount += 1;
106
- counterUpdates[firebaseUid].totalCount += 1;
107
- counterUpdates[firebaseUid].byType[alertType.id] =
108
- (counterUpdates[firebaseUid].byType[alertType.id] || 0) + 1;
109
- }
110
-
111
- // 5. Update notification counters (using Firebase UIDs)
112
- for (const [firebaseUid, counter] of Object.entries(counterUpdates)) {
113
- const counterRef = db.collection('user_notifications')
114
- .doc(firebaseUid) // Use Firebase UID, not eToro CID
115
- .collection('counters')
116
- .doc(counter.date);
67
+ // Use writeWithMigration to write to new path (with legacy fallback)
68
+ // notifications is a subcollection, so we need isCollection: true and documentId
69
+ const writePromise = writeWithMigration(
70
+ db,
71
+ 'signedInUsers',
72
+ 'notifications',
73
+ { cid: String(userCid) },
74
+ notificationData,
75
+ {
76
+ isCollection: true,
77
+ merge: false,
78
+ dataType: 'notifications',
79
+ documentId: notificationId,
80
+ dualWrite: false, // Disable dual write - we're fully migrated to new path
81
+ config,
82
+ collectionRegistry
83
+ }
84
+ ).catch(err => {
85
+ logger.log('ERROR', `[processAlertForPI] Failed to write notification for CID ${userCid}: ${err.message}`, err);
86
+ throw err; // Re-throw so we know if writes are failing
87
+ });
117
88
 
118
- batch.set(counterRef, {
119
- date: counter.date,
120
- unreadCount: FieldValue.increment(counter.unreadCount),
121
- totalCount: FieldValue.increment(counter.totalCount),
122
- [`byType.${alertType.id}`]: FieldValue.increment(counter.byType[alertType.id] || 0),
123
- lastUpdated: FieldValue.serverTimestamp()
124
- }, { merge: true });
89
+ notificationPromises.push(writePromise);
125
90
  }
126
91
 
127
- // 6. Commit batch
128
- await batch.commit();
92
+ // Wait for all notifications to be written
93
+ await Promise.all(notificationPromises);
129
94
 
130
95
  // 7. Update global rootdata collection for computation system
131
96
  const { updateAlertHistoryRootData } = require('../../generic-api/user-api/helpers/rootdata/rootdata_aggregation_helpers');
132
97
  const triggeredUserCids = subscriptions.map(s => s.userCid);
133
98
  await updateAlertHistoryRootData(db, logger, piCid, alertType, computationMetadata, computationDate, triggeredUserCids);
134
99
 
135
- logger.log('SUCCESS', `[processAlertForPI] Created ${notificationRefs.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
100
+ logger.log('SUCCESS', `[processAlertForPI] Created ${notificationPromises.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
136
101
 
137
102
  } catch (error) {
138
103
  logger.log('ERROR', `[processAlertForPI] Error processing alert for PI ${piCid}`, error);
@@ -100,7 +100,8 @@ async function handleAlertTrigger(message, context, config, dependencies) {
100
100
  piCid,
101
101
  alertType,
102
102
  piMetadata,
103
- date
103
+ date,
104
+ dependencies
104
105
  );
105
106
  processedCount++;
106
107
  } catch (error) {
@@ -116,7 +117,7 @@ async function handleAlertTrigger(message, context, config, dependencies) {
116
117
  // This runs after all alert computations are processed
117
118
  if (processedCount > 0 || results.cids.length > 0) {
118
119
  // Check if any PIs were processed but didn't trigger alerts
119
- await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
120
+ await checkAndSendAllClearNotifications(db, logger, results.cids, date, config, dependencies);
120
121
  }
121
122
 
122
123
  } catch (error) {
@@ -179,7 +180,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
179
180
  const docData = change.after.data();
180
181
  const results = await readComputationResultsWithShards(db, docData, change.after.ref);
181
182
  if (results.cids && results.cids.length > 0) {
182
- await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
183
+ await checkAndSendAllClearNotifications(db, logger, results.cids, date, config, dependencies);
183
184
  }
184
185
  return; // Don't process as alert computation
185
186
  }
@@ -221,7 +222,8 @@ async function handleComputationResultWrite(change, context, config, dependencie
221
222
  piCid,
222
223
  alertType,
223
224
  piMetadata,
224
- date
225
+ date,
226
+ dependencies
225
227
  );
226
228
  processedCount++;
227
229
  } catch (error) {
@@ -244,7 +246,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
244
246
  * Send "all clear" notifications to users who have these PIs in their static watchlists
245
247
  * This should be called after PopularInvestorProfileMetrics is computed (not just alert computations)
246
248
  */
247
- async function checkAndSendAllClearNotifications(db, logger, processedPICids, computationDate, config) {
249
+ async function checkAndSendAllClearNotifications(db, logger, processedPICids, computationDate, config, dependencies = {}) {
248
250
  try {
249
251
  const today = computationDate || new Date().toISOString().split('T')[0];
250
252
 
@@ -275,7 +277,7 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
275
277
  const piCid = Number(item.cid);
276
278
  if (processedPICids.includes(piCid)) {
277
279
  // Check if this PI triggered any alerts today for this user
278
- const hasAlerts = await checkIfPIHasAlertsToday(db, logger, userCid, piCid, today);
280
+ const hasAlerts = await checkIfPIHasAlertsToday(db, logger, userCid, piCid, today, dependencies);
279
281
 
280
282
  if (!hasAlerts) {
281
283
  // No alerts triggered - send "all clear" notification
@@ -296,7 +298,7 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
296
298
  let totalNotifications = 0;
297
299
  for (const [userCid, pisMap] of usersToNotify.entries()) {
298
300
  for (const [piCid, pi] of pisMap.entries()) {
299
- await sendAllClearNotification(db, logger, userCid, pi.cid, pi.username, today);
301
+ await sendAllClearNotification(db, logger, userCid, pi.cid, pi.username, today, dependencies);
300
302
  totalNotifications++;
301
303
  }
302
304
  }
@@ -313,17 +315,27 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
313
315
 
314
316
  /**
315
317
  * Check if a PI has any alerts for a user today
316
- * Checks user_notifications collection for alert-type notifications
318
+ * Checks SignedInUsers/{cid}/notifications collection for alert-type notifications
317
319
  */
318
- async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date) {
320
+ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, dependencies = {}) {
319
321
  try {
320
- const notificationsRef = db.collection('user_notifications')
321
- .doc(String(userCid))
322
- .collection('notifications');
322
+ const { collectionRegistry } = dependencies;
323
+ const config = dependencies.config || {};
324
+
325
+ // Use collection registry to get notifications path
326
+ let notificationsPath;
327
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
328
+ notificationsPath = collectionRegistry.getCollectionPath('signedInUsers', 'notifications', { cid: String(userCid) });
329
+ } else {
330
+ // Fallback to legacy path
331
+ notificationsPath = `user_notifications/${userCid}/notifications`;
332
+ }
333
+
334
+ const notificationsRef = db.collection(notificationsPath);
323
335
 
324
336
  // Check if there are any alert notifications for this PI today
325
337
  const snapshot = await notificationsRef
326
- .where('type', '==', 'alert')
338
+ .where('type', '==', 'watchlistAlerts')
327
339
  .where('metadata.piCid', '==', Number(piCid))
328
340
  .where('metadata.computationDate', '==', date)
329
341
  .limit(1)
@@ -342,59 +354,76 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date) {
342
354
  /**
343
355
  * Send an "all clear" notification to a user
344
356
  */
345
- async function sendAllClearNotification(db, logger, userCid, piCid, piUsername, date) {
357
+ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername, date, dependencies = {}) {
346
358
  try {
347
359
  const { FieldValue } = require('@google-cloud/firestore');
360
+ const { collectionRegistry } = dependencies;
361
+ const config = dependencies.config || {};
362
+
363
+ // Use collection registry to get notifications path
364
+ let notificationsPath;
365
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
366
+ notificationsPath = collectionRegistry.getCollectionPath('signedInUsers', 'notifications', { cid: String(userCid) });
367
+ } else {
368
+ // Fallback to legacy path
369
+ notificationsPath = `user_notifications/${userCid}/notifications`;
370
+ }
348
371
 
349
372
  // Check if we already sent an all-clear notification for this PI today
350
- const existingRef = db.collection('user_alerts')
351
- .doc(String(userCid))
352
- .collection('alerts')
353
- .where('piCid', '==', Number(piCid))
354
- .where('alertType', '==', 'all_clear')
355
- .where('computationDate', '==', date)
356
- .limit(1);
357
-
358
- const existingSnapshot = await existingRef.get();
373
+ const existingSnapshot = await db.collection(notificationsPath)
374
+ .where('type', '==', 'watchlistAlerts')
375
+ .where('subType', '==', 'all_clear')
376
+ .where('metadata.piCid', '==', Number(piCid))
377
+ .where('metadata.computationDate', '==', date)
378
+ .limit(1)
379
+ .get();
380
+
359
381
  if (!existingSnapshot.empty) {
360
382
  // Already sent notification for this PI today
361
383
  return;
362
384
  }
363
385
 
364
- // Create the notification using the notification system
365
- const notificationRef = db.collection('user_notifications')
366
- .doc(String(userCid))
367
- .collection('notifications')
368
- .doc();
369
-
370
- await notificationRef.set({
371
- id: notificationRef.id,
372
- type: 'alert',
386
+ // Create the notification using writeWithMigration
387
+ const notificationId = `all_clear_${Date.now()}_${userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
388
+ const notificationData = {
389
+ id: notificationId,
390
+ type: 'watchlistAlerts',
391
+ subType: 'all_clear',
373
392
  title: 'All Clear',
374
393
  message: `${piUsername} was processed, all clear today!`,
375
- piCid: Number(piCid),
376
- piUsername: piUsername,
377
- alertType: 'all_clear',
378
- computationDate: date,
379
394
  read: false,
395
+ timestamp: FieldValue.serverTimestamp(),
380
396
  createdAt: FieldValue.serverTimestamp(),
381
397
  metadata: {
398
+ piCid: Number(piCid),
399
+ piUsername: piUsername,
400
+ alertType: 'all_clear',
401
+ computationDate: date,
402
+ notificationType: 'watchlistAlerts',
403
+ userCid: Number(userCid),
382
404
  message: 'User was processed, all clear today!'
383
405
  }
384
- });
385
-
386
- // Update notification counter
387
- const counterRef = db.collection('user_notifications')
388
- .doc(String(userCid))
389
- .collection('counters')
390
- .doc(date);
391
-
392
- await counterRef.set({
393
- date: date,
394
- unreadCount: FieldValue.increment(1),
395
- totalCount: FieldValue.increment(1),
396
- lastUpdated: FieldValue.serverTimestamp()
397
- }, { merge: true });
406
+ };
407
+
408
+ // Use writeWithMigration to write to new path (with legacy fallback)
409
+ // notifications is a subcollection, so we need isCollection: true and documentId
410
+ const { writeWithMigration } = require('../generic-api/user-api/helpers/core/path_resolution_helpers');
411
+ await writeWithMigration(
412
+ db,
413
+ 'signedInUsers',
414
+ 'notifications',
415
+ { cid: String(userCid) },
416
+ notificationData,
417
+ {
418
+ isCollection: true,
419
+ merge: false,
420
+ dataType: 'notifications',
421
+ documentId: notificationId,
422
+ dualWrite: false, // Disable dual write - we're fully migrated to new path
423
+ config,
424
+ collectionRegistry
425
+ }
426
+ );
398
427
 
399
428
  logger.log('INFO', `[sendAllClearNotification] Sent all-clear notification to user ${userCid} for PI ${piCid}`);
400
429
 
@@ -6,6 +6,8 @@
6
6
  const { FieldValue } = require('@google-cloud/firestore');
7
7
  const { getAllAlertTypes, getAlertTypeByComputation } = require('../../../../alert-system/helpers/alert_type_registry');
8
8
  const { isDeveloperAccount, getDevOverride } = require('../dev/dev_helpers');
9
+ const { getCidFromFirebaseUid } = require('../core/path_resolution_helpers');
10
+ const { writeWithMigration } = require('../core/path_resolution_helpers');
9
11
 
10
12
  /**
11
13
  * POST /user/dev/test-alert
@@ -22,7 +24,7 @@ const { isDeveloperAccount, getDevOverride } = require('../dev/dev_helpers');
22
24
  * }
23
25
  */
24
26
  async function sendTestAlert(req, res, dependencies, config) {
25
- const { db, logger } = dependencies;
27
+ const { db, logger, collectionRegistry } = dependencies;
26
28
  const { userCid, alertTypeId, targetUsers = 'dev', piCid = 1, piUsername = 'TestPI', metadata = {} } = req.body;
27
29
 
28
30
  if (!userCid) {
@@ -59,21 +61,8 @@ async function sendTestAlert(req, res, dependencies, config) {
59
61
  logger.log('INFO', `[sendTestAlert] Using default alert type: ${alertType.id}`);
60
62
  }
61
63
 
62
- // Helper function to get Firebase UID from eToro CID
63
- async function getFirebaseUidFromCid(etoroCid) {
64
- const signedInUsersSnapshot = await db.collection('signedInUsers')
65
- .where('etoroCID', '==', Number(etoroCid))
66
- .limit(1)
67
- .get();
68
-
69
- if (!signedInUsersSnapshot.empty) {
70
- return signedInUsersSnapshot.docs[0].id; // Firebase UID is the document ID
71
- }
72
- return null;
73
- }
74
-
75
- // Determine target users (as Firebase UIDs)
76
- let targetFirebaseUids = [];
64
+ // Determine target users (as eToro CIDs)
65
+ let targetCids = [];
77
66
 
78
67
  if (targetUsers === 'all') {
79
68
  // Get all users from signedInUsers collection (who have etoroCID)
@@ -81,8 +70,10 @@ async function sendTestAlert(req, res, dependencies, config) {
81
70
  .where('etoroCID', '!=', null)
82
71
  .get();
83
72
 
84
- targetFirebaseUids = signedInUsersSnapshot.docs.map(doc => doc.id);
85
- logger.log('INFO', `[sendTestAlert] Sending to all ${targetFirebaseUids.length} users`);
73
+ targetCids = signedInUsersSnapshot.docs
74
+ .map(doc => doc.data().etoroCID)
75
+ .filter(cid => cid != null);
76
+ logger.log('INFO', `[sendTestAlert] Sending to all ${targetCids.length} users`);
86
77
  } else if (targetUsers === 'dev') {
87
78
  // Get all developer accounts with dev override enabled
88
79
  const devOverridesCollection = config.devOverridesCollection || 'dev_overrides';
@@ -101,31 +92,12 @@ async function sendTestAlert(req, res, dependencies, config) {
101
92
  devCids.push(Number(userCid));
102
93
  }
103
94
 
104
- // Convert eToro CIDs to Firebase UIDs
105
- for (const cid of devCids) {
106
- const firebaseUid = await getFirebaseUidFromCid(cid);
107
- if (firebaseUid) {
108
- targetFirebaseUids.push(firebaseUid);
109
- } else {
110
- logger.log('WARN', `[sendTestAlert] Could not find Firebase UID for developer CID ${cid}`);
111
- }
112
- }
113
-
114
- logger.log('INFO', `[sendTestAlert] Sending to ${targetFirebaseUids.length} developer accounts`);
95
+ targetCids = devCids;
96
+ logger.log('INFO', `[sendTestAlert] Sending to ${targetCids.length} developer accounts`);
115
97
  } else if (Array.isArray(targetUsers)) {
116
- // Specific user CIDs - convert to Firebase UIDs
117
- const specificCids = targetUsers.map(cid => Number(cid)).filter(cid => !isNaN(cid) && cid > 0);
118
-
119
- for (const cid of specificCids) {
120
- const firebaseUid = await getFirebaseUidFromCid(cid);
121
- if (firebaseUid) {
122
- targetFirebaseUids.push(firebaseUid);
123
- } else {
124
- logger.log('WARN', `[sendTestAlert] Could not find Firebase UID for CID ${cid}`);
125
- }
126
- }
127
-
128
- logger.log('INFO', `[sendTestAlert] Sending to ${targetFirebaseUids.length} specific users (from ${specificCids.length} CIDs)`);
98
+ // Specific user CIDs
99
+ targetCids = targetUsers.map(cid => Number(cid)).filter(cid => !isNaN(cid) && cid > 0);
100
+ logger.log('INFO', `[sendTestAlert] Sending to ${targetCids.length} specific users`);
129
101
  } else {
130
102
  return res.status(400).json({
131
103
  error: "Invalid targetUsers",
@@ -133,7 +105,7 @@ async function sendTestAlert(req, res, dependencies, config) {
133
105
  });
134
106
  }
135
107
 
136
- if (targetFirebaseUids.length === 0) {
108
+ if (targetCids.length === 0) {
137
109
  return res.status(400).json({
138
110
  error: "No target users",
139
111
  message: "No users found matching the target criteria"
@@ -149,27 +121,22 @@ async function sendTestAlert(req, res, dependencies, config) {
149
121
  testSentAt: new Date().toISOString()
150
122
  });
151
123
 
152
- // Create notifications for each target user (using Firebase UIDs)
153
- const batch = db.batch();
154
- const notificationRefs = [];
155
- const counterUpdates = {};
124
+ // Create notifications for each target user (using eToro CIDs and collection registry)
125
+ const notificationPromises = [];
156
126
  const today = new Date().toISOString().split('T')[0];
157
127
 
158
- for (const firebaseUid of targetFirebaseUids) {
159
- const notificationId = `test_alert_${Date.now()}_${firebaseUid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
160
- const notificationRef = db.collection('user_notifications')
161
- .doc(firebaseUid) // Use Firebase UID, not eToro CID
162
- .collection('notifications')
163
- .doc(notificationId);
128
+ for (const targetCid of targetCids) {
129
+ const notificationId = `test_alert_${Date.now()}_${targetCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
164
130
 
165
131
  const notificationData = {
166
132
  id: notificationId,
167
- type: 'alert',
133
+ type: 'testAlerts', // Use testAlerts type for test notifications
134
+ subType: 'alert',
168
135
  title: `[TEST] ${alertType.name}`,
169
136
  message: alertMessage,
170
137
  read: false,
138
+ timestamp: FieldValue.serverTimestamp(),
171
139
  createdAt: FieldValue.serverTimestamp(),
172
- timestamp: FieldValue.serverTimestamp(), // Also include timestamp for ordering
173
140
  metadata: {
174
141
  piCid: Number(piCid),
175
142
  piUsername: piUsername,
@@ -181,64 +148,58 @@ async function sendTestAlert(req, res, dependencies, config) {
181
148
  isTest: true,
182
149
  testSentBy: Number(userCid),
183
150
  testSentAt: new Date().toISOString(),
151
+ notificationType: 'testAlerts',
152
+ userCid: Number(targetCid),
184
153
  ...metadata
185
154
  }
186
155
  };
187
156
 
188
- batch.set(notificationRef, notificationData);
189
- notificationRefs.push(notificationRef);
190
-
191
- // Track counter updates (using Firebase UID as key)
192
- if (!counterUpdates[firebaseUid]) {
193
- counterUpdates[firebaseUid] = {
194
- date: today,
195
- unreadCount: 0,
196
- totalCount: 0,
197
- byType: {}
198
- };
199
- }
200
- counterUpdates[firebaseUid].unreadCount += 1;
201
- counterUpdates[firebaseUid].totalCount += 1;
202
- counterUpdates[firebaseUid].byType[alertType.id] =
203
- (counterUpdates[firebaseUid].byType[alertType.id] || 0) + 1;
204
- }
205
-
206
- // Update notification counters (using Firebase UIDs)
207
- for (const [firebaseUid, counter] of Object.entries(counterUpdates)) {
208
- const counterRef = db.collection('user_notifications')
209
- .doc(firebaseUid) // Use Firebase UID, not eToro CID
210
- .collection('counters')
211
- .doc(counter.date);
157
+ // Use writeWithMigration to write to new path (with legacy fallback)
158
+ // notifications is a subcollection, so we need isCollection: true and documentId
159
+ const writePromise = writeWithMigration(
160
+ db,
161
+ 'signedInUsers',
162
+ 'notifications',
163
+ { cid: String(targetCid) },
164
+ notificationData,
165
+ {
166
+ isCollection: true,
167
+ merge: false,
168
+ dataType: 'notifications',
169
+ documentId: notificationId,
170
+ dualWrite: false, // Disable dual write - we're fully migrated to new path
171
+ config,
172
+ collectionRegistry
173
+ }
174
+ ).catch(err => {
175
+ logger.log('ERROR', `[sendTestAlert] Failed to write notification for CID ${targetCid}: ${err.message}`, err);
176
+ throw err; // Re-throw so we know if writes are failing
177
+ });
212
178
 
213
- batch.set(counterRef, {
214
- date: counter.date,
215
- unreadCount: FieldValue.increment(counter.unreadCount),
216
- totalCount: FieldValue.increment(counter.totalCount),
217
- [`byType.${alertType.id}`]: FieldValue.increment(counter.byType[alertType.id] || 0),
218
- lastUpdated: FieldValue.serverTimestamp()
219
- }, { merge: true });
179
+ notificationPromises.push(writePromise);
220
180
  }
221
181
 
222
- // Commit batch
223
- await batch.commit();
182
+ // Wait for all notifications to be written
183
+ await Promise.all(notificationPromises);
224
184
 
225
- logger.log('SUCCESS', `[sendTestAlert] Created ${notificationRefs.length} test notifications for alert type ${alertType.id}`);
185
+ const successCount = notificationPromises.length;
186
+ logger.log('SUCCESS', `[sendTestAlert] Created ${successCount} test notifications for alert type ${alertType.id}`);
226
187
 
227
188
  return res.status(200).json({
228
189
  success: true,
229
- message: `Test alert sent to ${targetFirebaseUids.length} users`,
190
+ message: `Test alert sent to ${targetCids.length} users`,
230
191
  alertType: {
231
192
  id: alertType.id,
232
193
  name: alertType.name,
233
194
  computationName: alertType.computationName
234
195
  },
235
196
  targetUsers: {
236
- count: targetFirebaseUids.length,
237
- firebaseUids: targetFirebaseUids
197
+ count: targetCids.length,
198
+ cids: targetCids
238
199
  },
239
200
  piCid: Number(piCid),
240
201
  piUsername: piUsername,
241
- notificationsCreated: notificationRefs.length
202
+ notificationsCreated: successCount
242
203
  });
243
204
 
244
205
  } catch (error) {
@@ -469,10 +469,21 @@ async function writeWithMigration(db, category, subcategory, params, data, optio
469
469
  // Get legacy path if dual write is enabled
470
470
  let legacyPath = null;
471
471
  if (dualWrite && dataType && userCid) {
472
- const { resolvePath: resolve } = getRegistryFunctions(registryOptions);
473
- legacyPath = getLegacyPath(dataType, userCid, config, params, null, null, registryOptions);
474
- if (legacyPath) {
475
- legacyPath = resolve(legacyPath, params);
472
+ try {
473
+ const registryFuncs = getRegistryFunctions(registryOptions);
474
+ const resolve = registryFuncs?.resolvePath;
475
+ legacyPath = getLegacyPath(dataType, userCid, config, params, null, null, registryOptions);
476
+ if (legacyPath && resolve && typeof resolve === 'function') {
477
+ legacyPath = resolve(legacyPath, params);
478
+ } else if (legacyPath) {
479
+ // If resolve is not available, manually replace placeholders
480
+ for (const [key, value] of Object.entries(params)) {
481
+ legacyPath = legacyPath.replace(`{${key}}`, String(value));
482
+ }
483
+ }
484
+ } catch (error) {
485
+ console.warn('[writeWithMigration] Could not resolve legacy path, skipping dual write:', error.message);
486
+ legacyPath = null; // Skip dual write if we can't resolve legacy path
476
487
  }
477
488
  }
478
489
 
@@ -127,17 +127,20 @@ async function sendOnDemandNotification(db, logger, userCid, type, title, messag
127
127
  };
128
128
 
129
129
  // Write using collection registry
130
+ // notifications is a subcollection, so we need isCollection: true and documentId
130
131
  if (collectionRegistry) {
131
132
  await writeWithMigration(
132
133
  db,
133
134
  'signedInUsers',
134
135
  'notifications',
135
- { cid: String(userCid), notificationId },
136
+ { cid: String(userCid) },
136
137
  notificationData,
137
138
  {
138
- isCollection: false,
139
+ isCollection: true,
139
140
  merge: false,
140
141
  dataType: 'notifications',
142
+ documentId: notificationId,
143
+ dualWrite: false, // Disable dual write - we're fully migrated to new path
141
144
  config,
142
145
  collectionRegistry
143
146
  }
@@ -406,6 +409,7 @@ async function getNotificationHistory(req, res, dependencies, config) {
406
409
  notificationsPath = `user_notifications/${userCid}/notifications`;
407
410
  }
408
411
 
412
+ // Try to query from new path first
409
413
  let query = db.collection(notificationsPath)
410
414
  .orderBy('timestamp', 'desc')
411
415
  .limit(parseInt(limit));
@@ -418,29 +422,57 @@ async function getNotificationHistory(req, res, dependencies, config) {
418
422
  query = query.where('read', '==', read === 'true');
419
423
  }
420
424
 
421
- const snapshot = await query.get();
425
+ let snapshot;
426
+ try {
427
+ snapshot = await query.get();
428
+ } catch (queryError) {
429
+ // If query fails (e.g., missing index), try without filters
430
+ logger.log('WARN', `[getNotificationHistory] Query failed, trying without filters: ${queryError.message}`);
431
+ query = db.collection(notificationsPath)
432
+ .orderBy('timestamp', 'desc')
433
+ .limit(parseInt(limit));
434
+ snapshot = await query.get();
435
+ }
436
+
422
437
  const notifications = [];
423
438
 
424
439
  snapshot.forEach(doc => {
425
440
  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
- });
441
+
442
+ // Apply filters in memory if query filters failed
443
+ let include = true;
444
+ if (type && data.type !== type) {
445
+ include = false;
446
+ }
447
+ if (read !== undefined && data.read !== (read === 'true')) {
448
+ include = false;
449
+ }
450
+
451
+ if (include) {
452
+ notifications.push({
453
+ id: doc.id,
454
+ type: data.type || 'other',
455
+ subType: data.subType,
456
+ title: data.title || '',
457
+ message: data.message || '',
458
+ read: data.read || false,
459
+ timestamp: data.timestamp?.toDate?.()?.toISOString() || data.createdAt?.toDate?.()?.toISOString() || new Date().toISOString(),
460
+ metadata: data.metadata || {}
461
+ });
462
+ }
436
463
  });
437
464
 
465
+ // Apply offset in memory
466
+ const offsetNum = parseInt(offset);
467
+ const paginatedNotifications = notifications.slice(offsetNum, offsetNum + parseInt(limit));
468
+
438
469
  return res.status(200).json({
439
470
  success: true,
440
- notifications,
441
- count: notifications.length,
471
+ notifications: paginatedNotifications,
472
+ count: paginatedNotifications.length,
473
+ total: notifications.length,
442
474
  limit: parseInt(limit),
443
- offset: parseInt(offset)
475
+ offset: offsetNum
444
476
  });
445
477
  } catch (error) {
446
478
  logger.log('ERROR', `[getNotificationHistory] Error fetching notifications for ${userCid}`, error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.523",
3
+ "version": "1.0.525",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [