bulltrackers-module 1.0.679 → 1.0.681

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
 
@@ -222,21 +222,13 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
222
222
  async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computationDate, dependencies = {}) {
223
223
  const subscriptions = [];
224
224
 
225
- // Map computation names to watchlist alertConfig keys
226
- const computationToConfigKey = {
227
- 'RiskScoreIncrease': 'increasedRisk',
228
- 'SignificantVolatility': 'volatilityChanges',
229
- 'NewSectorExposure': 'newSector',
230
- 'PositionInvestedIncrease': 'increasedPositionSize',
231
- 'NewSocialPost': 'newSocialPost',
232
- // [NEW] Mapping for BehavioralAnomaly
233
- 'BehavioralAnomaly': 'behavioralAnomaly',
234
- 'TestSystemProbe': 'increasedRisk' // Hack: Map to 'increasedRisk' key
235
- };
225
+ // [DYNAMIC] Get configKey from alertType metadata instead of hardcoded mapping
226
+ // The alertType is passed in dependencies and contains the configKey from computation metadata
227
+ const alertType = dependencies.alertType;
228
+ const configKey = alertType?.configKey;
236
229
 
237
- const configKey = computationToConfigKey[alertTypeId];
238
230
  if (!configKey) {
239
- logger.log('WARN', `[findSubscriptionsForPI] No mapping found for alert type: ${alertTypeId}`);
231
+ logger.log('WARN', `[findSubscriptionsForPI] No configKey found for alert type: ${alertTypeId}`);
240
232
  return subscriptions;
241
233
  }
242
234
 
@@ -264,8 +256,8 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computatio
264
256
  increasedPositionSize: true,
265
257
  newSocialPost: true,
266
258
  newPositions: true,
267
- // [NEW] Enable for Dev Override
268
- behavioralAnomaly: true
259
+ behavioralAnomaly: true,
260
+ testSystemProbe: true // Test alerts for developers
269
261
  };
270
262
 
271
263
  // Check all developer accounts
@@ -364,9 +356,13 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computatio
364
356
  const isTestProbe = alertTypeId === 'TestSystemProbe';
365
357
  const isEnabled = item.alertConfig && item.alertConfig[configKey] === true;
366
358
 
367
- // [FIX] For TestSystemProbe, check user's testAlerts preference
359
+ // [FIX] Check if this is a test alert (respect testAlerts preference)
360
+ // Load alert type from dependencies to check isTest flag
361
+ const alertType = dependencies.alertType;
362
+ const isTestAlert = alertType && alertType.isTest === true;
363
+
368
364
  let shouldSendAlert = false;
369
- if (isTestProbe) {
365
+ if (isTestAlert) {
370
366
  // Check if user has testAlerts enabled in their notification preferences
371
367
  try {
372
368
  const { manageNotificationPreferences } = require('../../api-v2/helpers/data-fetchers/firestore.js');
@@ -374,7 +370,7 @@ async function findSubscriptionsForPI(db, logger, piCid, alertTypeId, computatio
374
370
  shouldSendAlert = prefs.testAlerts === true;
375
371
 
376
372
  if (!shouldSendAlert) {
377
- logger.log('DEBUG', `[findSubscriptionsForPI] User ${userCid} has testAlerts disabled, skipping TestSystemProbe alert`);
373
+ logger.log('DEBUG', `[findSubscriptionsForPI] User ${userCid} has testAlerts disabled, skipping test alert ${alertTypeId}`);
378
374
  }
379
375
  } catch (prefError) {
380
376
  logger.log('WARN', `[findSubscriptionsForPI] Error checking testAlerts preference for user ${userCid}: ${prefError.message}`);
@@ -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) {
@@ -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.679",
3
+ "version": "1.0.681",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [