bulltrackers-module 1.0.678 → 1.0.680

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,8 +6,8 @@
6
6
  const { FieldValue } = require('@google-cloud/firestore');
7
7
  const zlib = require('zlib');
8
8
  const { Storage } = require('@google-cloud/storage');
9
- const { getAlertTypeByComputation, generateAlertMessage } = require('./alert_type_registry');
10
- // Migration helpers removed - write directly to new path
9
+ const { generateAlertMessage } = require('./alert_manifest_loader');
10
+ // [UPDATED] Now uses dynamic manifest loading instead of hardcoded registry
11
11
 
12
12
  const storage = new Storage(); // Singleton GCS Client
13
13
 
@@ -16,12 +16,53 @@ const storage = new Storage(); // Singleton GCS Client
16
16
  */
17
17
  async function processAlertForPI(db, logger, piCid, alertType, computationMetadata, computationDate, dependencies = {}) {
18
18
  try {
19
+ // [FIX] Check if computation date is earlier than today (backfill protection)
20
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
21
+ const isHistoricalData = computationDate < today;
22
+
23
+ // If it's historical data, check if this alert already exists
24
+ if (isHistoricalData) {
25
+ // Check if we've already created alerts for this PI/date/alertType combination
26
+ const existingAlertsSnapshot = await db.collection('SignedInUsers')
27
+ .doc('_metadata') // Use metadata doc to track processed alerts
28
+ .collection('processed_alerts')
29
+ .where('piCid', '==', Number(piCid))
30
+ .where('computationDate', '==', computationDate)
31
+ .where('alertType', '==', alertType.id)
32
+ .limit(1)
33
+ .get();
34
+
35
+ if (!existingAlertsSnapshot.empty) {
36
+ logger.log('INFO', `[processAlertForPI] Skipping duplicate alert for historical data: PI ${piCid}, date ${computationDate}, alert type ${alertType.id}`);
37
+ return;
38
+ }
39
+
40
+ logger.log('WARN', `[processAlertForPI] Processing alert for historical data (backfill): PI ${piCid}, date ${computationDate}, alert type ${alertType.id}. This alert will only be sent to developers.`);
41
+ }
42
+
19
43
  // 1. Get PI username from rankings or subscriptions
20
44
  const piUsername = await getPIUsername(db, piCid);
21
45
 
22
46
  // 2. Find all users subscribed to this PI and alert type
23
47
  // Use computationName (e.g., 'RiskScoreIncrease') to map to alertConfig keys
24
- const subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName, computationDate, dependencies);
48
+ let subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName, computationDate, dependencies);
49
+
50
+ // [FIX] If it's historical data, only send to developer accounts
51
+ if (isHistoricalData) {
52
+ const { isDeveloper } = require('../../api-v2/helpers/data-fetchers/firestore.js');
53
+
54
+ // Filter subscriptions to only include developers
55
+ const devSubscriptions = [];
56
+ for (const subscription of subscriptions) {
57
+ const isDev = await isDeveloper(db, String(subscription.userCid));
58
+ if (isDev) {
59
+ devSubscriptions.push(subscription);
60
+ }
61
+ }
62
+
63
+ subscriptions = devSubscriptions;
64
+ logger.log('INFO', `[processAlertForPI] Historical data: Filtered to ${subscriptions.length} developer subscriptions for PI ${piCid}, alert type ${alertType.id}`);
65
+ }
25
66
 
26
67
  if (subscriptions.length === 0) {
27
68
  logger.log('INFO', `[processAlertForPI] No subscriptions found for PI ${piCid}, alert type ${alertType.id}`);
@@ -38,8 +79,24 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
38
79
  const config = dependencies.config || {};
39
80
  const notificationPromises = [];
40
81
 
82
+ // Check watchlistAlerts preference for each user
83
+ const { manageNotificationPreferences } = require('../../api-v2/helpers/data-fetchers/firestore.js');
84
+
41
85
  for (const subscription of subscriptions) {
42
86
  const userCid = subscription.userCid;
87
+
88
+ // [FIX] Check user's watchlistAlerts preference before creating alert
89
+ try {
90
+ const prefs = await manageNotificationPreferences(db, userCid, 'get');
91
+ if (!prefs.watchlistAlerts) {
92
+ logger.log('DEBUG', `[processAlertForPI] User ${userCid} has watchlistAlerts disabled, skipping alert`);
93
+ continue; // Skip this user
94
+ }
95
+ } catch (prefError) {
96
+ logger.log('WARN', `[processAlertForPI] Error checking watchlistAlerts preference for user ${userCid}: ${prefError.message}`);
97
+ // Continue anyway - fail open for important alerts
98
+ }
99
+
43
100
  const notificationId = `alert_${Date.now()}_${userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
44
101
 
45
102
  const notificationData = {
@@ -129,6 +186,28 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
129
186
 
130
187
  logger.log('SUCCESS', `[processAlertForPI] Created ${notificationPromises.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
131
188
 
189
+ // [FIX] Mark alert as processed to prevent duplicates on backfill
190
+ if (isHistoricalData) {
191
+ try {
192
+ await db.collection('SignedInUsers')
193
+ .doc('_metadata')
194
+ .collection('processed_alerts')
195
+ .doc(`${piCid}_${computationDate}_${alertType.id}`)
196
+ .set({
197
+ piCid: Number(piCid),
198
+ computationDate: computationDate,
199
+ alertType: alertType.id,
200
+ alertTypeName: alertType.name,
201
+ processedAt: FieldValue.serverTimestamp(),
202
+ notificationsSent: notificationPromises.length
203
+ });
204
+ logger.log('INFO', `[processAlertForPI] Marked historical alert as processed: PI ${piCid}, date ${computationDate}, alert type ${alertType.id}`);
205
+ } catch (markError) {
206
+ logger.log('WARN', `[processAlertForPI] Failed to mark alert as processed: ${markError.message}`);
207
+ // Don't throw - this is non-critical
208
+ }
209
+ }
210
+
132
211
  } catch (error) {
133
212
  logger.log('ERROR', `[processAlertForPI] Error processing alert for PI ${piCid}`, error);
134
213
  throw error;
@@ -285,8 +364,33 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computatio
285
364
  const isTestProbe = alertTypeId === 'TestSystemProbe';
286
365
  const isEnabled = item.alertConfig && item.alertConfig[configKey] === true;
287
366
 
288
- // If it's the probe OR the user explicitly enabled it
289
- if (isTestProbe || isEnabled) {
367
+ // [FIX] Check if this is a test alert (respect testAlerts preference)
368
+ // Load alert type from dependencies to check isTest flag
369
+ const alertType = dependencies.alertType;
370
+ const isTestAlert = alertType && alertType.isTest === true;
371
+
372
+ let shouldSendAlert = false;
373
+ if (isTestAlert) {
374
+ // Check if user has testAlerts enabled in their notification preferences
375
+ try {
376
+ const { manageNotificationPreferences } = require('../../api-v2/helpers/data-fetchers/firestore.js');
377
+ const prefs = await manageNotificationPreferences(db, userCid, 'get');
378
+ shouldSendAlert = prefs.testAlerts === true;
379
+
380
+ if (!shouldSendAlert) {
381
+ logger.log('DEBUG', `[findSubscriptionsForPI] User ${userCid} has testAlerts disabled, skipping test alert ${alertTypeId}`);
382
+ }
383
+ } catch (prefError) {
384
+ logger.log('WARN', `[findSubscriptionsForPI] Error checking testAlerts preference for user ${userCid}: ${prefError.message}`);
385
+ // Default to not sending test alerts if we can't check preferences
386
+ shouldSendAlert = false;
387
+ }
388
+ } else {
389
+ // For non-test alerts, use normal alert config check
390
+ shouldSendAlert = isEnabled;
391
+ }
392
+
393
+ if (shouldSendAlert) {
290
394
  subscriptions.push({
291
395
  userCid: userCid,
292
396
  piCid: piCid,
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @fileoverview Dynamic Alert Manifest Loader
3
+ * Loads alert types from computation classes instead of hardcoded registry
4
+ */
5
+
6
+ /**
7
+ * Flatten nested calculations object (same logic as index.js)
8
+ * @param {Object} obj - Nested calculations object
9
+ * @returns {Array<Function>} Flat array of computation classes
10
+ */
11
+ const flattenCalculations = (obj) => {
12
+ let result = [];
13
+ for (const key in obj) {
14
+ if (typeof obj[key] === 'function') {
15
+ result.push(obj[key]);
16
+ } else if (typeof obj[key] === 'object' && obj[key] !== null) {
17
+ result = result.concat(flattenCalculations(obj[key]));
18
+ }
19
+ }
20
+ return result;
21
+ };
22
+
23
+ /**
24
+ * Load all alert computations from calculations package
25
+ * @param {Object} logger - Logger instance
26
+ * @returns {Promise<Array>} Array of alert type objects with metadata
27
+ */
28
+ async function loadAlertTypesFromManifest(logger) {
29
+ try {
30
+ // Import calculations the same way index.js does
31
+ const { calculations } = require('aiden-shared-calculations-unified');
32
+ const calculationsArray = flattenCalculations(calculations);
33
+
34
+ const alertTypes = [];
35
+
36
+ // Iterate through all computation classes (same logic as index.js alert trigger registration)
37
+ for (const CalcClass of calculationsArray) {
38
+ if (!CalcClass || typeof CalcClass.getMetadata !== 'function') {
39
+ continue;
40
+ }
41
+
42
+ const metadata = CalcClass.getMetadata();
43
+
44
+ // Check if this is an alert computation
45
+ if (metadata.isAlertComputation !== true) {
46
+ continue;
47
+ }
48
+
49
+ // Check if alert metadata exists
50
+ if (!metadata.alert) {
51
+ logger?.log('WARN', `[AlertManifestLoader] Computation ${metadata.name} has isAlertComputation=true but no alert metadata`);
52
+ continue;
53
+ }
54
+
55
+ // Build alert type object
56
+ const alertType = {
57
+ id: metadata.alert.id,
58
+ name: metadata.alert.frontendName,
59
+ description: metadata.alert.description,
60
+ computationName: metadata.name,
61
+ category: metadata.category || 'alerts',
62
+ messageTemplate: metadata.alert.messageTemplate,
63
+ severity: metadata.alert.severity || 'medium',
64
+ configKey: metadata.alert.configKey,
65
+ isTest: metadata.isTest === true,
66
+ enabled: true
67
+ };
68
+
69
+ alertTypes.push(alertType);
70
+
71
+ logger?.log('DEBUG', `[AlertManifestLoader] Loaded alert type: ${alertType.id} from ${metadata.name}`);
72
+ }
73
+
74
+ logger?.log('INFO', `[AlertManifestLoader] Successfully loaded ${alertTypes.length} alert types from calculations`);
75
+
76
+ return alertTypes;
77
+ } catch (error) {
78
+ logger?.log('ERROR', `[AlertManifestLoader] Failed to load alert types: ${error.message}`);
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get alert type by computation name
85
+ * @param {Array} alertTypes - Array of alert types from loadAlertTypesFromManifest
86
+ * @param {string} computationName - Name of computation
87
+ * @returns {Object|null} Alert type object or null
88
+ */
89
+ function getAlertTypeByComputation(alertTypes, computationName) {
90
+ return alertTypes.find(type => type.computationName === computationName) || null;
91
+ }
92
+
93
+ /**
94
+ * Check if a computation is an alert computation
95
+ * @param {Array} alertTypes - Array of alert types from loadAlertTypesFromManifest
96
+ * @param {string} computationName - Name of computation
97
+ * @returns {boolean} True if computation is an alert
98
+ */
99
+ function isAlertComputation(alertTypes, computationName) {
100
+ return getAlertTypeByComputation(alertTypes, computationName) !== null;
101
+ }
102
+
103
+ /**
104
+ * Generate alert message from template and metadata
105
+ * @param {Object} alertType - Alert type object
106
+ * @param {string} piUsername - PI username
107
+ * @param {Object} metadata - Metadata from computation results
108
+ * @returns {string} Generated alert message
109
+ */
110
+ function generateAlertMessage(alertType, piUsername, metadata = {}) {
111
+ let message = alertType.messageTemplate;
112
+
113
+ // Replace placeholders
114
+ message = message.replace(/{piUsername}/g, piUsername || 'Unknown');
115
+ message = message.replace(/{count}/g, metadata.count || metadata.positions?.length || metadata.moveCount || 0);
116
+ message = message.replace(/{change}/g, metadata.change || metadata.changePercent || metadata.diff || 'N/A');
117
+ message = message.replace(/{previous}/g, metadata.previous || metadata.previousValue || metadata.prev || metadata.previousRisk || 'N/A');
118
+ message = message.replace(/{current}/g, metadata.current || metadata.currentValue || metadata.curr || metadata.currentRisk || 'N/A');
119
+ message = message.replace(/{sectorName}/g, metadata.sectorName || (metadata.newExposures && metadata.newExposures.length > 0 ? metadata.newExposures.join(', ') : 'Unknown'));
120
+ message = message.replace(/{ticker}/g, metadata.ticker || metadata.symbol || 'Unknown');
121
+
122
+ // Format numeric values
123
+ message = message.replace(/{volatility}/g, metadata.volatility ? `${(metadata.volatility * 100).toFixed(1)}` : 'N/A');
124
+ message = message.replace(/{threshold}/g, metadata.threshold ? `${(metadata.threshold * 100).toFixed(0)}` : 'N/A');
125
+ message = message.replace(/{diff}/g, metadata.diff ? `${metadata.diff.toFixed(1)}` : 'N/A');
126
+ message = message.replace(/{prev}/g, metadata.prev ? `${metadata.prev.toFixed(1)}` : 'N/A');
127
+ message = message.replace(/{curr}/g, metadata.curr ? `${metadata.curr.toFixed(1)}` : 'N/A');
128
+ message = message.replace(/{title}/g, metadata.title || 'New Update');
129
+
130
+ // Probe placeholders
131
+ message = message.replace(/{status}/g, metadata.status || 'Unknown Status');
132
+ message = message.replace(/{timestamp}/g, metadata.timestamp || new Date().toISOString());
133
+
134
+ // Behavioral Anomaly placeholders
135
+ message = message.replace(/{primaryDriver}/g, metadata.primaryDriver || 'Unknown Factor');
136
+ message = message.replace(/{driverSignificance}/g, metadata.driverSignificance || 'N/A');
137
+ message = message.replace(/{anomalyScore}/g, metadata.anomalyScore || 'N/A');
138
+
139
+ // Handle positions list if available
140
+ if (metadata.positions && Array.isArray(metadata.positions) && metadata.positions.length > 0) {
141
+ const positionsList = metadata.positions
142
+ .slice(0, 3)
143
+ .map(p => p.ticker || p.instrumentId)
144
+ .join(', ');
145
+ message = message.replace(/{positions}/g, positionsList);
146
+ }
147
+
148
+ // Handle moves array for PositionInvestedIncrease
149
+ if (metadata.moves && Array.isArray(metadata.moves) && metadata.moves.length > 0) {
150
+ const firstMove = metadata.moves[0];
151
+ message = message.replace(/{symbol}/g, firstMove.symbol || 'Unknown');
152
+ message = message.replace(/{diff}/g, firstMove.diff ? `${firstMove.diff.toFixed(1)}` : 'N/A');
153
+ message = message.replace(/{prev}/g, firstMove.prev ? `${firstMove.prev.toFixed(1)}` : 'N/A');
154
+ message = message.replace(/{curr}/g, firstMove.curr ? `${firstMove.curr.toFixed(1)}` : 'N/A');
155
+ }
156
+
157
+ return message;
158
+ }
159
+
160
+ module.exports = {
161
+ loadAlertTypesFromManifest,
162
+ getAlertTypeByComputation,
163
+ isAlertComputation,
164
+ generateAlertMessage
165
+ };
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * @fileoverview Alert System Trigger Handler
3
3
  * Can be triggered via Pub/Sub when computation results are written
4
+ * [UPDATED] Now uses dynamic manifest loading instead of hardcoded registry
4
5
  */
5
6
 
6
- const { getAlertTypeByComputation, isAlertComputation } = require('./helpers/alert_type_registry');
7
+ const { loadAlertTypesFromManifest, getAlertTypeByComputation, isAlertComputation } = require('./helpers/alert_manifest_loader');
7
8
  const { processAlertForPI, readComputationResults, readComputationResultsWithShards } = require('./helpers/alert_helpers');
8
9
 
10
+ // Cache for loaded alert types (loaded once per function instance)
11
+ let cachedAlertTypes = null;
12
+
9
13
  /**
10
14
  * Pub/Sub trigger handler for alert generation
11
15
  * Expected message format:
@@ -19,6 +23,11 @@ async function handleAlertTrigger(message, context, config, dependencies) {
19
23
  const { db, logger } = dependencies;
20
24
 
21
25
  try {
26
+ // [NEW] Load alert types from manifest (cached)
27
+ if (!cachedAlertTypes) {
28
+ cachedAlertTypes = await loadAlertTypesFromManifest(logger);
29
+ }
30
+
22
31
  // Parse Pub/Sub message
23
32
  let payload;
24
33
  if (message.data) {
@@ -35,13 +44,13 @@ async function handleAlertTrigger(message, context, config, dependencies) {
35
44
  return;
36
45
  }
37
46
 
38
- // 1. Check if this is an alert computation
39
- if (!isAlertComputation(computationName)) {
47
+ // 1. Check if this is an alert computation (using dynamic manifest)
48
+ if (!isAlertComputation(cachedAlertTypes, computationName)) {
40
49
  logger.log('DEBUG', `[AlertTrigger] Not an alert computation: ${computationName}`);
41
50
  return;
42
51
  }
43
52
 
44
- const alertType = getAlertTypeByComputation(computationName);
53
+ const alertType = getAlertTypeByComputation(cachedAlertTypes, computationName);
45
54
  if (!alertType) {
46
55
  logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
47
56
  return;
@@ -101,7 +110,7 @@ async function handleAlertTrigger(message, context, config, dependencies) {
101
110
  alertType,
102
111
  piMetadata,
103
112
  date,
104
- dependencies
113
+ { ...dependencies, alertType } // Pass alertType in dependencies for isTest check
105
114
  );
106
115
  processedCount++;
107
116
  } catch (error) {
@@ -134,47 +143,55 @@ async function handleAlertTrigger(message, context, config, dependencies) {
134
143
  */
135
144
  async function handleComputationResultWrite(change, context, config, dependencies) {
136
145
  const { db, logger } = dependencies;
146
+
147
+ // Declare variables outside try block for error logging
148
+ let date, category, computationName;
137
149
 
138
- // --- PATH RESOLUTION LOGIC ---
139
- // 1. Try to use context.params (Preferred for Gen 2 & Wildcards)
140
- // The 'contextShim' in your root index.js passes 'event.params' into here.
141
- let { date, category, computationName } = context.params || {};
150
+ try {
151
+ // [NEW] Load alert types from manifest (cached)
152
+ if (!cachedAlertTypes) {
153
+ cachedAlertTypes = await loadAlertTypesFromManifest(logger);
154
+ }
142
155
 
143
- // 2. Fallback: Parse from resource string (Legacy / Backup)
144
- if (!date || !computationName) {
145
- const resource = context.resource || (change.after && change.after.ref.path) || '';
146
-
147
- // Simple split logic can fail on full resource URIs (e.g. //firestore.googleapis.com/...)
148
- // so we look for specific keywords to anchor the split.
149
- const pathParts = resource.split('/');
150
-
151
- const dateIndex = pathParts.indexOf('unified_insights') + 1;
152
- const categoryIndex = pathParts.indexOf('results') + 1;
153
- const computationIndex = pathParts.indexOf('computations') + 1;
154
-
155
- if (dateIndex > 0 && categoryIndex > 0 && computationIndex > 0) {
156
- date = pathParts[dateIndex];
157
- category = pathParts[categoryIndex];
158
- computationName = pathParts[computationIndex];
159
- }
160
- }
156
+ // --- PATH RESOLUTION LOGIC ---
157
+ // 1. Try to use context.params (Preferred for Gen 2 & Wildcards)
158
+ // The 'contextShim' in your root index.js passes 'event.params' into here.
159
+ ({ date, category, computationName } = context.params || {});
160
+
161
+ // 2. Fallback: Parse from resource string (Legacy / Backup)
162
+ if (!date || !computationName) {
163
+ const resource = context.resource || (change.after && change.after.ref.path) || '';
164
+
165
+ // Simple split logic can fail on full resource URIs (e.g. //firestore.googleapis.com/...)
166
+ // so we look for specific keywords to anchor the split.
167
+ const pathParts = resource.split('/');
168
+
169
+ const dateIndex = pathParts.indexOf('unified_insights') + 1;
170
+ const categoryIndex = pathParts.indexOf('results') + 1;
171
+ const computationIndex = pathParts.indexOf('computations') + 1;
172
+
173
+ if (dateIndex > 0 && categoryIndex > 0 && computationIndex > 0) {
174
+ date = pathParts[dateIndex];
175
+ category = pathParts[categoryIndex];
176
+ computationName = pathParts[computationIndex];
177
+ }
178
+ }
161
179
 
162
- // 3. Validation
163
- if (!date || !category || !computationName) {
164
- logger.log('WARN', `[AlertTrigger] Could not resolve path params. Resource: ${context.resource}`);
180
+ // 3. Validation
181
+ if (!date || !category || !computationName) {
182
+ logger.log('WARN', `[AlertTrigger] Could not resolve path params. Resource: ${context.resource}`);
183
+ return;
184
+ }
185
+
186
+ // Only process if document was created or updated (not deleted)
187
+ if (!change.after.exists) {
188
+ logger.log('INFO', `[AlertTrigger] Document deleted, skipping: ${computationName} for ${date}`);
165
189
  return;
166
- }
167
-
168
- // Only process if document was created or updated (not deleted)
169
- if (!change.after.exists) {
170
- logger.log('INFO', `[AlertTrigger] Document deleted, skipping: ${computationName} for ${date}`);
171
- return;
172
- }
173
-
174
- try {
175
- // 1. Check if this is an alert computation OR PopularInvestorProfileMetrics
190
+ }
191
+
192
+ // 1. Check if this is an alert computation OR PopularInvestorProfileMetrics (using dynamic manifest)
176
193
  const isProfileMetrics = computationName === 'PopularInvestorProfileMetrics';
177
- if (!isAlertComputation(computationName) && !isProfileMetrics) {
194
+ if (!isAlertComputation(cachedAlertTypes, computationName) && !isProfileMetrics) {
178
195
  logger.log('DEBUG', `[AlertTrigger] Not an alert computation or profile metrics: ${computationName}`);
179
196
  return;
180
197
  }
@@ -195,13 +212,18 @@ async function handleComputationResultWrite(change, context, config, dependencie
195
212
  return; // Don't process as alert computation
196
213
  }
197
214
 
198
- const alertType = getAlertTypeByComputation(computationName);
215
+ const alertType = getAlertTypeByComputation(cachedAlertTypes, computationName);
199
216
  if (!alertType) {
200
217
  logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
201
218
  return;
202
219
  }
203
220
 
204
- logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
221
+ // [NEW] Filter test alerts - only send to developers if isTest === true
222
+ if (alertType.isTest) {
223
+ logger.log('INFO', `[AlertTrigger] Processing TEST alert computation: ${computationName} for date ${date} (will only send to developers)`);
224
+ } else {
225
+ logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
226
+ }
205
227
 
206
228
  // 2. Read and decompress computation results (handling GCS, shards, and compression)
207
229
  const docData = change.after.data();
@@ -233,7 +255,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
233
255
  alertType,
234
256
  piMetadata,
235
257
  date,
236
- dependencies
258
+ { ...dependencies, alertType } // Pass alertType in dependencies for isTest check
237
259
  );
238
260
  processedCount++;
239
261
  } catch (error) {
@@ -18,6 +18,15 @@ const { FieldValue } = require('@google-cloud/firestore');
18
18
  */
19
19
  async function notifyTaskEngineComplete(db, logger, userCid, requestId, username, success, errorMessage, options = {}) {
20
20
  try {
21
+ // [FIX] Check user's notification preferences before sending
22
+ const { manageNotificationPreferences } = require('./data-fetchers/firestore.js');
23
+ const prefs = await manageNotificationPreferences(db, userCid, 'get');
24
+
25
+ if (!prefs.syncProcesses) {
26
+ logger?.log('DEBUG', `[notifyTaskEngineComplete] User ${userCid} has syncProcesses disabled, skipping notification`);
27
+ return;
28
+ }
29
+
21
30
  const notificationData = {
22
31
  type: 'sync',
23
32
  subType: 'complete',
@@ -42,6 +51,29 @@ async function notifyTaskEngineComplete(db, logger, userCid, requestId, username
42
51
  .doc(requestId)
43
52
  .set(notificationData);
44
53
 
54
+ // [FIX] Clean up progress notifications for this sync to stop showing loading icons
55
+ try {
56
+ const progressNotificationsQuery = await db.collection('SignedInUsers')
57
+ .doc(String(userCid))
58
+ .collection('notifications')
59
+ .where('metadata.requestId', '==', requestId)
60
+ .where('subType', '==', 'progress')
61
+ .get();
62
+
63
+ const batch = db.batch();
64
+ progressNotificationsQuery.docs.forEach(doc => {
65
+ batch.delete(doc.ref);
66
+ });
67
+
68
+ if (progressNotificationsQuery.size > 0) {
69
+ await batch.commit();
70
+ logger?.log('INFO', `[notifyTaskEngineComplete] Cleaned up ${progressNotificationsQuery.size} progress notifications for request ${requestId}`);
71
+ }
72
+ } catch (cleanupError) {
73
+ logger?.log('WARN', `[notifyTaskEngineComplete] Failed to clean up progress notifications: ${cleanupError.message}`);
74
+ // Don't throw - cleanup is non-critical
75
+ }
76
+
45
77
  logger?.log('INFO', `[notifyTaskEngineComplete] Notification sent for user ${userCid}, request ${requestId}`);
46
78
  } catch (error) {
47
79
  logger?.log('WARN', `[notifyTaskEngineComplete] Failed to send notification: ${error.message}`);
@@ -61,6 +93,15 @@ async function notifyTaskEngineComplete(db, logger, userCid, requestId, username
61
93
  */
62
94
  async function notifyTaskEngineProgress(db, logger, userCid, requestId, username, stage, dataType, options = {}) {
63
95
  try {
96
+ // [FIX] Check user's notification preferences before sending
97
+ const { manageNotificationPreferences } = require('./data-fetchers/firestore.js');
98
+ const prefs = await manageNotificationPreferences(db, userCid, 'get');
99
+
100
+ if (!prefs.syncProcesses) {
101
+ logger?.log('DEBUG', `[notifyTaskEngineProgress] User ${userCid} has syncProcesses disabled, skipping notification`);
102
+ return;
103
+ }
104
+
64
105
  const stageMessages = {
65
106
  'started': 'Data sync started',
66
107
  'portfolio_complete': 'Portfolio data fetched',
@@ -130,6 +171,15 @@ async function notifyPIDataRefreshed(db, logger, collectionRegistry, piCid, user
130
171
  */
131
172
  async function notifyComputationComplete(db, logger, userCid, requestId, computation, displayName, success, errorMessage, options = {}) {
132
173
  try {
174
+ // [FIX] Check user's notification preferences before sending
175
+ const { manageNotificationPreferences } = require('./data-fetchers/firestore.js');
176
+ const prefs = await manageNotificationPreferences(db, userCid, 'get');
177
+
178
+ if (!prefs.userActionCompletions) {
179
+ logger?.log('DEBUG', `[notifyComputationComplete] User ${userCid} has userActionCompletions disabled, skipping notification`);
180
+ return;
181
+ }
182
+
133
183
  const notificationData = {
134
184
  type: 'computation',
135
185
  subType: 'complete',
@@ -6,9 +6,11 @@ const {
6
6
  fetchAlertTypes,
7
7
  getUserSubscriptions,
8
8
  updateSubscription,
9
- unsubscribeFromAlerts
9
+ unsubscribeFromAlerts,
10
+ isDeveloper
10
11
  } = require('../helpers/data-fetchers/firestore.js');
11
12
  const { sanitizeCid, sanitizeDocId, validateBatchSize } = require('../helpers/security_utils.js');
13
+ const { loadAlertTypesFromManifest } = require('../../alert-system/helpers/alert_manifest_loader');
12
14
 
13
15
  const router = express.Router();
14
16
 
@@ -34,6 +36,43 @@ const updateSubscriptionSchema = z.object({
34
36
  thresholds: z.record(z.any()).optional()
35
37
  });
36
38
 
39
+ // [NEW] GET /alerts/types - Dynamically load available alert types from manifest
40
+ router.get('/types', async (req, res, next) => {
41
+ try {
42
+ const { db, logger } = req.dependencies;
43
+
44
+ // Load alert types from manifest
45
+ const alertTypes = await loadAlertTypesFromManifest(logger);
46
+
47
+ // Check if user is a developer
48
+ const isDev = await isDeveloper(db, String(req.targetUserId));
49
+
50
+ // Filter out test alerts for non-developers
51
+ const filteredAlertTypes = isDev
52
+ ? alertTypes
53
+ : alertTypes.filter(type => !type.isTest);
54
+
55
+ // Format response for frontend
56
+ const formattedTypes = filteredAlertTypes.map(type => ({
57
+ id: type.id,
58
+ name: type.name,
59
+ description: type.description,
60
+ severity: type.severity,
61
+ configKey: type.configKey,
62
+ isTest: type.isTest || false
63
+ }));
64
+
65
+ res.json({
66
+ success: true,
67
+ alertTypes: formattedTypes,
68
+ count: formattedTypes.length
69
+ });
70
+ } catch (error) {
71
+ logger?.log('ERROR', `[GET /alerts/types] Error loading alert types: ${error.message}`);
72
+ next(error);
73
+ }
74
+ });
75
+
37
76
  // GET /alerts/history
38
77
  router.get('/history', async (req, res, next) => {
39
78
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.678",
3
+ "version": "1.0.680",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [