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.
- package/functions/alert-system/helpers/alert_helpers.js +109 -5
- package/functions/alert-system/helpers/alert_manifest_loader.js +165 -0
- package/functions/alert-system/index.js +66 -44
- package/functions/api-v2/helpers/notification_helpers.js +50 -0
- package/functions/api-v2/routes/alerts.js +40 -1
- package/package.json +1 -1
|
@@ -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 {
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
289
|
-
|
|
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/
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
try {
|
|
151
|
+
// [NEW] Load alert types from manifest (cached)
|
|
152
|
+
if (!cachedAlertTypes) {
|
|
153
|
+
cachedAlertTypes = await loadAlertTypesFromManifest(logger);
|
|
154
|
+
}
|
|
142
155
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|