bulltrackers-module 1.0.778 → 1.0.779
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 +114 -90
- package/functions/alert-system/helpers/alert_manifest_loader.js +88 -99
- package/functions/alert-system/index.js +81 -138
- package/functions/alert-system/tests/stage1-alert-manifest.test.js +94 -0
- package/functions/alert-system/tests/stage2-alert-metadata.test.js +93 -0
- package/functions/alert-system/tests/stage3-alert-handler.test.js +79 -0
- package/functions/api-v2/helpers/data-fetchers/firestore.js +613 -478
- package/functions/api-v2/routes/popular_investors.js +7 -7
- package/functions/api-v2/routes/profile.js +2 -1
- package/functions/api-v2/tests/stage4-profile-paths.test.js +52 -0
- package/functions/api-v2/tests/stage5-aum-bigquery.test.js +81 -0
- package/functions/api-v2/tests/stage7-pi-page-views.test.js +55 -0
- package/functions/api-v2/tests/stage8-watchlist-membership.test.js +49 -0
- package/functions/api-v2/tests/stage9-user-alert-settings.test.js +81 -0
- package/functions/computation-system-v2/computations/BehavioralAnomaly.js +104 -81
- package/functions/computation-system-v2/computations/NewSectorExposure.js +7 -7
- package/functions/computation-system-v2/computations/NewSocialPost.js +6 -6
- package/functions/computation-system-v2/computations/PositionInvestedIncrease.js +11 -11
- package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +1 -1
- package/functions/computation-system-v2/config/bulltrackers.config.js +8 -0
- package/functions/computation-system-v2/framework/core/Manifest.js +1 -0
- package/functions/core/utils/bigquery_utils.js +32 -0
- package/package.json +1 -1
|
@@ -23,13 +23,13 @@ let cachedAlertTypes = null;
|
|
|
23
23
|
*/
|
|
24
24
|
async function handleAlertTrigger(message, context, config, dependencies) {
|
|
25
25
|
const { db, logger } = dependencies;
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
try {
|
|
28
28
|
// [NEW] Load alert types from manifest (cached)
|
|
29
29
|
if (!cachedAlertTypes) {
|
|
30
30
|
cachedAlertTypes = await loadAlertTypesFromManifest(logger);
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
// Parse Pub/Sub message
|
|
34
34
|
let payload;
|
|
35
35
|
if (message.data) {
|
|
@@ -38,28 +38,28 @@ async function handleAlertTrigger(message, context, config, dependencies) {
|
|
|
38
38
|
} else {
|
|
39
39
|
payload = message;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
const { date, computationName, documentPath } = payload;
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
if (!date || !computationName) {
|
|
45
45
|
logger.log('WARN', '[AlertTrigger] Missing required fields: date or computationName');
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
// 1. Check if this is an alert computation (using dynamic manifest)
|
|
50
50
|
if (!isAlertComputation(cachedAlertTypes, computationName)) {
|
|
51
51
|
logger.log('DEBUG', `[AlertTrigger] Not an alert computation: ${computationName}`);
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
const alertType = getAlertTypeByComputation(cachedAlertTypes, computationName);
|
|
56
56
|
if (!alertType) {
|
|
57
57
|
logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
// 2. Read computation results from Firestore
|
|
64
64
|
let docRef;
|
|
65
65
|
if (documentPath) {
|
|
@@ -75,36 +75,36 @@ async function handleAlertTrigger(message, context, config, dependencies) {
|
|
|
75
75
|
.collection('computations')
|
|
76
76
|
.doc(computationName);
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
const docSnapshot = await docRef.get();
|
|
80
|
-
|
|
80
|
+
|
|
81
81
|
if (!docSnapshot.exists) {
|
|
82
82
|
logger.log('WARN', `[AlertTrigger] Document not found: ${docRef.path}`);
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
|
-
|
|
85
|
+
|
|
86
86
|
// 3. Read and decompress computation results (handling GCS, shards, and compression)
|
|
87
87
|
const docData = docSnapshot.data();
|
|
88
88
|
const results = await readComputationResultsWithShards(db, docData, docRef, logger);
|
|
89
|
-
|
|
89
|
+
|
|
90
90
|
if (!results.cids || results.cids.length === 0) {
|
|
91
91
|
logger.log('INFO', `[AlertTrigger] No PIs found in computation results for ${computationName}`);
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
|
|
95
95
|
logger.log('INFO', `[AlertTrigger] Found ${results.cids.length} PIs in ${computationName} results`);
|
|
96
|
-
|
|
96
|
+
|
|
97
97
|
// 4. Process alerts for each PI
|
|
98
98
|
let processedCount = 0;
|
|
99
99
|
let errorCount = 0;
|
|
100
|
-
|
|
100
|
+
|
|
101
101
|
for (const piCid of results.cids) {
|
|
102
102
|
try {
|
|
103
103
|
// Extract PI-specific metadata if available, otherwise use global metadata
|
|
104
|
-
const piMetadata = results.perUserData && results.perUserData[piCid]
|
|
104
|
+
const piMetadata = results.perUserData && results.perUserData[piCid]
|
|
105
105
|
? results.perUserData[piCid]
|
|
106
106
|
: results.metadata || {};
|
|
107
|
-
|
|
107
|
+
|
|
108
108
|
await processAlertForPI(
|
|
109
109
|
db,
|
|
110
110
|
logger,
|
|
@@ -121,16 +121,16 @@ async function handleAlertTrigger(message, context, config, dependencies) {
|
|
|
121
121
|
// Continue processing other PIs even if one fails
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
|
|
125
125
|
logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
// After processing alerts, check for "all clear" notifications for static watchlists
|
|
128
128
|
// This runs after all alert computations are processed
|
|
129
129
|
if (processedCount > 0 || results.cids.length > 0) {
|
|
130
130
|
// Check if any PIs were processed but didn't trigger alerts
|
|
131
131
|
await checkAndSendAllClearNotifications(db, logger, results.cids, date, config, dependencies);
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
|
|
134
134
|
} catch (error) {
|
|
135
135
|
logger.log('ERROR', '[AlertTrigger] Fatal error processing alert trigger', error);
|
|
136
136
|
throw error; // Re-throw for Pub/Sub retry
|
|
@@ -138,140 +138,83 @@ async function handleAlertTrigger(message, context, config, dependencies) {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
* Firestore trigger function for alert generation
|
|
141
|
+
* Firestore trigger function for alert generation (V2 paths only).
|
|
142
142
|
* Triggered when computation results are written to:
|
|
143
|
-
*
|
|
144
|
-
*
|
|
143
|
+
* - Per-entity: alerts/{date}/{computationName}/{entityId}
|
|
144
|
+
* - Single-doc: alerts/{date}/{computationName} (doc contains cids and per-entity data)
|
|
145
|
+
* context.params must include date, computationName; entityId present only for per-entity path.
|
|
145
146
|
*/
|
|
146
147
|
async function handleComputationResultWrite(change, context, config, dependencies) {
|
|
147
148
|
const { db, logger } = dependencies;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
let date, category, computationName;
|
|
149
|
+
|
|
150
|
+
let date, computationName, entityId;
|
|
151
151
|
|
|
152
152
|
try {
|
|
153
|
-
// [NEW] Load alert types from manifest (cached)
|
|
154
153
|
if (!cachedAlertTypes) {
|
|
155
154
|
cachedAlertTypes = await loadAlertTypesFromManifest(logger);
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
|
|
159
|
-
// 1. Try to use context.params (Preferred for Gen 2 & Wildcards)
|
|
160
|
-
// The 'contextShim' in your root index.js passes 'event.params' into here.
|
|
161
|
-
({ date, category, computationName } = context.params || {});
|
|
157
|
+
({ date, computationName, entityId } = context.params || {});
|
|
162
158
|
|
|
163
|
-
// 2. Fallback: Parse from resource string (Legacy / Backup)
|
|
164
159
|
if (!date || !computationName) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Simple split logic can fail on full resource URIs (e.g. //firestore.googleapis.com/...)
|
|
168
|
-
// so we look for specific keywords to anchor the split.
|
|
169
|
-
const pathParts = resource.split('/');
|
|
170
|
-
|
|
171
|
-
const dateIndex = pathParts.indexOf('unified_insights') + 1;
|
|
172
|
-
const categoryIndex = pathParts.indexOf('results') + 1;
|
|
173
|
-
const computationIndex = pathParts.indexOf('computations') + 1;
|
|
174
|
-
|
|
175
|
-
if (dateIndex > 0 && categoryIndex > 0 && computationIndex > 0) {
|
|
176
|
-
date = pathParts[dateIndex];
|
|
177
|
-
category = pathParts[categoryIndex];
|
|
178
|
-
computationName = pathParts[computationIndex];
|
|
179
|
-
}
|
|
160
|
+
logger.log('WARN', `[AlertTrigger] Missing path params. Params: ${JSON.stringify(context.params)}`);
|
|
161
|
+
return;
|
|
180
162
|
}
|
|
181
163
|
|
|
182
|
-
// 3. Validation
|
|
183
|
-
if (!date || !category || !computationName) {
|
|
184
|
-
logger.log('WARN', `[AlertTrigger] Could not resolve path params. Resource: ${context.resource}`);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Only process if document was created or updated (not deleted)
|
|
189
164
|
if (!change.after.exists) {
|
|
190
165
|
logger.log('INFO', `[AlertTrigger] Document deleted, skipping: ${computationName} for ${date}`);
|
|
191
166
|
return;
|
|
192
167
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (!isAlertComputation(cachedAlertTypes, computationName) && !isProfileMetrics) {
|
|
197
|
-
logger.log('DEBUG', `[AlertTrigger] Not an alert computation or profile metrics: ${computationName}`);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// [FIX] Allow both 'popular-investor' (for profile metrics) AND 'alerts' (for actual alerts)
|
|
202
|
-
if (category !== 'popular-investor' && category !== 'alerts') {
|
|
203
|
-
logger.log('DEBUG', `[AlertTrigger] Skipping irrelevant category: ${category}`);
|
|
168
|
+
|
|
169
|
+
if (!isAlertComputation(cachedAlertTypes, computationName)) {
|
|
170
|
+
logger.log('DEBUG', `[AlertTrigger] Not an alert computation: ${computationName}`);
|
|
204
171
|
return;
|
|
205
172
|
}
|
|
206
|
-
|
|
207
|
-
// If it's PopularInvestorProfileMetrics, check for all-clear notifications only
|
|
208
|
-
if (isProfileMetrics) {
|
|
209
|
-
const docData = change.after.data();
|
|
210
|
-
const results = await readComputationResultsWithShards(db, docData, change.after.ref, logger);
|
|
211
|
-
if (results.cids && results.cids.length > 0) {
|
|
212
|
-
await checkAndSendAllClearNotifications(db, logger, results.cids, date, config, dependencies);
|
|
213
|
-
}
|
|
214
|
-
return; // Don't process as alert computation
|
|
215
|
-
}
|
|
216
|
-
|
|
173
|
+
|
|
217
174
|
const alertType = getAlertTypeByComputation(cachedAlertTypes, computationName);
|
|
218
175
|
if (!alertType) {
|
|
219
176
|
logger.log('WARN', `[AlertTrigger] Alert type not found for computation: ${computationName}`);
|
|
220
177
|
return;
|
|
221
178
|
}
|
|
222
|
-
|
|
223
|
-
// [NEW] Filter test alerts - only send to developers if isTest === true
|
|
179
|
+
|
|
224
180
|
if (alertType.isTest) {
|
|
225
|
-
logger.log('INFO', `[AlertTrigger] Processing TEST alert
|
|
181
|
+
logger.log('INFO', `[AlertTrigger] Processing TEST alert: ${computationName} for date ${date}`);
|
|
226
182
|
} else {
|
|
227
|
-
logger.log('INFO', `[AlertTrigger] Processing alert
|
|
183
|
+
logger.log('INFO', `[AlertTrigger] Processing alert: ${computationName} for date ${date}`);
|
|
228
184
|
}
|
|
229
|
-
|
|
230
|
-
// 2. Read and decompress computation results (handling GCS, shards, and compression)
|
|
185
|
+
|
|
231
186
|
const docData = change.after.data();
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
187
|
+
const deps = { ...dependencies, alertType };
|
|
188
|
+
|
|
189
|
+
if (entityId != null && entityId !== '') {
|
|
190
|
+
// Per-entity path: one doc per PI; doc body is the single PI result
|
|
191
|
+
await processAlertForPI(db, logger, entityId, alertType, docData, date, deps);
|
|
192
|
+
logger.log('SUCCESS', `[AlertTrigger] Completed ${computationName} for PI ${entityId}`);
|
|
236
193
|
return;
|
|
237
194
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
195
|
+
|
|
196
|
+
// Single-doc path: doc contains cids and per-entity keys
|
|
197
|
+
const cids = Array.isArray(docData.cids) ? docData.cids : Object.keys(docData).filter(k => /^\d+$/.test(k)).map(Number);
|
|
198
|
+
if (!cids.length) {
|
|
199
|
+
logger.log('INFO', `[AlertTrigger] No PIs in document for ${computationName}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
243
203
|
let processedCount = 0;
|
|
244
204
|
let errorCount = 0;
|
|
245
|
-
|
|
246
|
-
for (const piCid of results.cids) {
|
|
205
|
+
for (const piCid of cids) {
|
|
247
206
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
? results.perUserData[piCid]
|
|
251
|
-
: results.metadata || results.globalMetadata || {};
|
|
252
|
-
|
|
253
|
-
await processAlertForPI(
|
|
254
|
-
db,
|
|
255
|
-
logger,
|
|
256
|
-
piCid,
|
|
257
|
-
alertType,
|
|
258
|
-
piMetadata,
|
|
259
|
-
date,
|
|
260
|
-
{ ...dependencies, alertType } // Pass alertType in dependencies for isTest check
|
|
261
|
-
);
|
|
207
|
+
const piMetadata = docData[String(piCid)] ?? docData[piCid] ?? {};
|
|
208
|
+
await processAlertForPI(db, logger, piCid, alertType, piMetadata, date, deps);
|
|
262
209
|
processedCount++;
|
|
263
210
|
} catch (error) {
|
|
264
211
|
errorCount++;
|
|
265
212
|
logger.log('ERROR', `[AlertTrigger] Error processing alert for PI ${piCid}`, error);
|
|
266
|
-
// Continue processing other PIs even if one fails
|
|
267
213
|
}
|
|
268
214
|
}
|
|
269
|
-
|
|
270
|
-
logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
|
|
271
|
-
|
|
215
|
+
logger.log('SUCCESS', `[AlertTrigger] Completed ${computationName}: ${processedCount} successful, ${errorCount} errors`);
|
|
272
216
|
} catch (error) {
|
|
273
217
|
logger.log('ERROR', `[AlertTrigger] Fatal error processing ${computationName}`, error);
|
|
274
|
-
// Don't throw - we don't want to retry the entire computation write
|
|
275
218
|
}
|
|
276
219
|
}
|
|
277
220
|
|
|
@@ -283,36 +226,36 @@ async function handleComputationResultWrite(change, context, config, dependencie
|
|
|
283
226
|
async function checkAndSendAllClearNotifications(db, logger, processedPICids, computationDate, config, dependencies = {}) {
|
|
284
227
|
try {
|
|
285
228
|
const today = computationDate || new Date().toISOString().split('T')[0];
|
|
286
|
-
|
|
229
|
+
|
|
287
230
|
if (!processedPICids || processedPICids.length === 0) {
|
|
288
231
|
return; // No PIs to check
|
|
289
232
|
}
|
|
290
|
-
|
|
233
|
+
|
|
291
234
|
// Get all static watchlists that contain any of the processed PIs
|
|
292
235
|
const watchlistsCollection = config.watchlistsCollection || 'watchlists';
|
|
293
236
|
const watchlistsSnapshot = await db.collection(watchlistsCollection).get();
|
|
294
|
-
|
|
237
|
+
|
|
295
238
|
const usersToNotify = new Map(); // userCid -> Map of piCid -> {username, ...}
|
|
296
|
-
|
|
239
|
+
|
|
297
240
|
for (const userDoc of watchlistsSnapshot.docs) {
|
|
298
241
|
const userCid = Number(userDoc.id);
|
|
299
242
|
const userListsSnapshot = await userDoc.ref.collection('lists').get();
|
|
300
|
-
|
|
243
|
+
|
|
301
244
|
for (const listDoc of userListsSnapshot.docs) {
|
|
302
245
|
const listData = listDoc.data();
|
|
303
|
-
|
|
246
|
+
|
|
304
247
|
// Only check static watchlists
|
|
305
248
|
if (listData.type !== 'static' || !listData.items || !Array.isArray(listData.items)) {
|
|
306
249
|
continue;
|
|
307
250
|
}
|
|
308
|
-
|
|
251
|
+
|
|
309
252
|
// Check if any processed PI is in this watchlist
|
|
310
253
|
for (const item of listData.items) {
|
|
311
254
|
const piCid = Number(item.cid);
|
|
312
255
|
if (processedPICids.includes(piCid)) {
|
|
313
256
|
// Check if this PI triggered any alerts today for this user
|
|
314
257
|
const hasAlerts = await checkIfPIHasAlertsToday(db, logger, userCid, piCid, today, dependencies);
|
|
315
|
-
|
|
258
|
+
|
|
316
259
|
if (!hasAlerts) {
|
|
317
260
|
// No alerts triggered - send "all clear" notification
|
|
318
261
|
if (!usersToNotify.has(userCid)) {
|
|
@@ -327,7 +270,7 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
|
|
|
327
270
|
}
|
|
328
271
|
}
|
|
329
272
|
}
|
|
330
|
-
|
|
273
|
+
|
|
331
274
|
// Send notifications to all users
|
|
332
275
|
let totalNotifications = 0;
|
|
333
276
|
for (const [userCid, pisMap] of usersToNotify.entries()) {
|
|
@@ -336,11 +279,11 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
|
|
|
336
279
|
totalNotifications++;
|
|
337
280
|
}
|
|
338
281
|
}
|
|
339
|
-
|
|
282
|
+
|
|
340
283
|
if (totalNotifications > 0) {
|
|
341
284
|
logger.log('INFO', `[checkAndSendAllClearNotifications] Sent ${totalNotifications} all-clear notifications to ${usersToNotify.size} users`);
|
|
342
285
|
}
|
|
343
|
-
|
|
286
|
+
|
|
344
287
|
} catch (error) {
|
|
345
288
|
logger.log('ERROR', `[checkAndSendAllClearNotifications] Error checking all-clear notifications`, error);
|
|
346
289
|
// Don't throw - this is a non-critical feature
|
|
@@ -355,7 +298,7 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
|
|
|
355
298
|
try {
|
|
356
299
|
const { collectionRegistry } = dependencies;
|
|
357
300
|
const config = dependencies.config || {};
|
|
358
|
-
|
|
301
|
+
|
|
359
302
|
// Use collection registry to get notifications path
|
|
360
303
|
let notificationsPath;
|
|
361
304
|
if (collectionRegistry && collectionRegistry.getCollectionPath) {
|
|
@@ -364,9 +307,9 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
|
|
|
364
307
|
// Fallback to legacy path
|
|
365
308
|
notificationsPath = `user_notifications/${userCid}/notifications`;
|
|
366
309
|
}
|
|
367
|
-
|
|
310
|
+
|
|
368
311
|
const notificationsRef = db.collection(notificationsPath);
|
|
369
|
-
|
|
312
|
+
|
|
370
313
|
// Check alerts collection instead of notifications
|
|
371
314
|
// Use collection registry to get alerts path
|
|
372
315
|
let alertsPath;
|
|
@@ -376,16 +319,16 @@ async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date, depende
|
|
|
376
319
|
// Fallback to legacy path
|
|
377
320
|
alertsPath = `user_alerts/${userCid}/alerts`;
|
|
378
321
|
}
|
|
379
|
-
|
|
322
|
+
|
|
380
323
|
const alertsRef = db.collection(alertsPath);
|
|
381
|
-
|
|
324
|
+
|
|
382
325
|
// Check if there are any alerts for this PI today
|
|
383
326
|
const snapshot = await alertsRef
|
|
384
327
|
.where('piCid', '==', Number(piCid))
|
|
385
328
|
.where('computationDate', '==', date)
|
|
386
329
|
.limit(1)
|
|
387
330
|
.get();
|
|
388
|
-
|
|
331
|
+
|
|
389
332
|
return !snapshot.empty;
|
|
390
333
|
} catch (error) {
|
|
391
334
|
if (logger) {
|
|
@@ -404,7 +347,7 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
404
347
|
const { FieldValue } = require('@google-cloud/firestore');
|
|
405
348
|
const { collectionRegistry } = dependencies;
|
|
406
349
|
const config = dependencies.config || {};
|
|
407
|
-
|
|
350
|
+
|
|
408
351
|
// Use collection registry to get notifications path
|
|
409
352
|
let notificationsPath;
|
|
410
353
|
if (collectionRegistry && collectionRegistry.getCollectionPath) {
|
|
@@ -413,7 +356,7 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
413
356
|
// Fallback to legacy path
|
|
414
357
|
notificationsPath = `user_notifications/${userCid}/notifications`;
|
|
415
358
|
}
|
|
416
|
-
|
|
359
|
+
|
|
417
360
|
// Check if we already sent an all-clear notification for this PI today
|
|
418
361
|
const existingSnapshot = await db.collection(notificationsPath)
|
|
419
362
|
.where('type', '==', 'watchlistAlerts')
|
|
@@ -422,12 +365,12 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
422
365
|
.where('metadata.computationDate', '==', date)
|
|
423
366
|
.limit(1)
|
|
424
367
|
.get();
|
|
425
|
-
|
|
368
|
+
|
|
426
369
|
if (!existingSnapshot.empty) {
|
|
427
370
|
// Already sent notification for this PI today
|
|
428
371
|
return;
|
|
429
372
|
}
|
|
430
|
-
|
|
373
|
+
|
|
431
374
|
// Create the notification using writeWithMigration
|
|
432
375
|
const notificationId = `all_clear_${Date.now()}_${userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
|
|
433
376
|
const notificationData = {
|
|
@@ -449,7 +392,7 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
449
392
|
message: 'User was processed, all clear today!'
|
|
450
393
|
}
|
|
451
394
|
};
|
|
452
|
-
|
|
395
|
+
|
|
453
396
|
// Write to alerts collection (not notifications) - alerts are separate from system notifications
|
|
454
397
|
// Write directly to new path: SignedInUsers/{userCid}/alerts/{alertId}
|
|
455
398
|
const alertData = {
|
|
@@ -464,14 +407,14 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
464
407
|
createdAt: FieldValue.serverTimestamp(),
|
|
465
408
|
computationDate: date
|
|
466
409
|
};
|
|
467
|
-
|
|
410
|
+
|
|
468
411
|
// 1. Write to Firestore (source of truth)
|
|
469
412
|
await db.collection('SignedInUsers')
|
|
470
413
|
.doc(String(userCid))
|
|
471
414
|
.collection('alerts')
|
|
472
415
|
.doc(notificationId)
|
|
473
416
|
.set(alertData);
|
|
474
|
-
|
|
417
|
+
|
|
475
418
|
// 2. Send FCM push notification (non-blocking)
|
|
476
419
|
try {
|
|
477
420
|
await sendAlertPushNotification(db, userCid, alertData, logger);
|
|
@@ -479,9 +422,9 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
|
|
|
479
422
|
// Log but don't throw - FCM failure shouldn't fail the alert
|
|
480
423
|
logger.log('WARN', `[sendAllClearNotification] FCM push failed for user ${userCid}: ${fcmError.message}`);
|
|
481
424
|
}
|
|
482
|
-
|
|
425
|
+
|
|
483
426
|
logger.log('INFO', `[sendAllClearNotification] Sent all-clear notification to user ${userCid} for PI ${piCid}`);
|
|
484
|
-
|
|
427
|
+
|
|
485
428
|
} catch (error) {
|
|
486
429
|
logger.log('ERROR', `[sendAllClearNotification] Error sending all-clear notification`, error);
|
|
487
430
|
// Don't throw - this is non-critical
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Stage 1 unit test: Mark alert computations and derive triggers from V2 config.
|
|
3
|
+
* Uses real config/computations (read-only). Retained for inspection and debugging.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
|
|
9
|
+
const EXPECTED_ALERT_COMPUTATIONS = [
|
|
10
|
+
'RiskScoreIncrease',
|
|
11
|
+
'BehavioralAnomaly',
|
|
12
|
+
'NewSectorExposure',
|
|
13
|
+
'NewSocialPost',
|
|
14
|
+
'PositionInvestedIncrease'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
async function runStage1Tests() {
|
|
18
|
+
let passed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
|
|
21
|
+
// (a) Manifest: exactly the five alert computations have alert in manifest
|
|
22
|
+
try {
|
|
23
|
+
const v2ConfigPath = path.join(__dirname, '../../computation-system-v2/config/bulltrackers.config.js');
|
|
24
|
+
const v2Config = require(v2ConfigPath);
|
|
25
|
+
const { ManifestBuilder } = require('../../computation-system-v2/framework/core/Manifest.js');
|
|
26
|
+
const builder = new ManifestBuilder(v2Config, null);
|
|
27
|
+
const manifest = builder.build(v2Config.computations);
|
|
28
|
+
const withAlert = manifest.filter(e => e.alert != null);
|
|
29
|
+
const namesWithAlert = withAlert.map(e => e.originalName).sort();
|
|
30
|
+
const expectedSorted = [...EXPECTED_ALERT_COMPUTATIONS].sort();
|
|
31
|
+
assert.deepStrictEqual(
|
|
32
|
+
namesWithAlert,
|
|
33
|
+
expectedSorted,
|
|
34
|
+
`Manifest alert computations: expected ${expectedSorted.join(', ')}, got ${namesWithAlert.join(', ')}`
|
|
35
|
+
);
|
|
36
|
+
passed++;
|
|
37
|
+
console.log('[Stage1] (a) Manifest: exactly five alert computations have alert');
|
|
38
|
+
} catch (e) {
|
|
39
|
+
failed++;
|
|
40
|
+
console.error('[Stage1] (a) FAIL:', e.message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// (b) loadAlertTypesFromManifest returns five alert types with correct computationName and configKey
|
|
44
|
+
try {
|
|
45
|
+
const { loadAlertTypesFromManifest } = require('../helpers/alert_manifest_loader.js');
|
|
46
|
+
const logger = { log: () => {} };
|
|
47
|
+
const alertTypes = await loadAlertTypesFromManifest(logger);
|
|
48
|
+
assert.strictEqual(alertTypes.length, 5, `Expected 5 alert types, got ${alertTypes.length}`);
|
|
49
|
+
const computationNames = alertTypes.map(t => t.computationName).sort();
|
|
50
|
+
assert.deepStrictEqual(
|
|
51
|
+
computationNames,
|
|
52
|
+
[...EXPECTED_ALERT_COMPUTATIONS].sort(),
|
|
53
|
+
`Alert types computationName: expected ${EXPECTED_ALERT_COMPUTATIONS.join(', ')}, got ${computationNames.join(', ')}`
|
|
54
|
+
);
|
|
55
|
+
for (const t of alertTypes) {
|
|
56
|
+
assert(t.configKey != null && t.configKey !== '', `Alert type ${t.computationName} must have configKey`);
|
|
57
|
+
}
|
|
58
|
+
passed++;
|
|
59
|
+
console.log('[Stage1] (b) loadAlertTypesFromManifest returns five alert types with computationName and configKey');
|
|
60
|
+
} catch (e) {
|
|
61
|
+
failed++;
|
|
62
|
+
console.error('[Stage1] (b) FAIL:', e.message);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// (c) Derived alertComputations list matches entries with storage.firestore.enabled and alert
|
|
66
|
+
try {
|
|
67
|
+
const v2ConfigPath = path.join(__dirname, '../../computation-system-v2/config/bulltrackers.config.js');
|
|
68
|
+
const v2Config = require(v2ConfigPath);
|
|
69
|
+
const { ManifestBuilder } = require('../../computation-system-v2/framework/core/Manifest.js');
|
|
70
|
+
const builder = new ManifestBuilder(v2Config, null);
|
|
71
|
+
const manifest = builder.build(v2Config.computations);
|
|
72
|
+
const alertComputations = manifest.filter(e => e.storage?.firestore?.enabled && e.alert);
|
|
73
|
+
const alertNames = alertComputations.map(e => e.originalName).sort();
|
|
74
|
+
assert.deepStrictEqual(
|
|
75
|
+
alertNames,
|
|
76
|
+
[...EXPECTED_ALERT_COMPUTATIONS].sort(),
|
|
77
|
+
`Derived alertComputations: expected ${EXPECTED_ALERT_COMPUTATIONS.join(', ')}, got ${alertNames.join(', ')}`
|
|
78
|
+
);
|
|
79
|
+
passed++;
|
|
80
|
+
console.log('[Stage1] (c) Derived alertComputations matches storage.firestore.enabled && alert');
|
|
81
|
+
} catch (e) {
|
|
82
|
+
failed++;
|
|
83
|
+
console.error('[Stage1] (c) FAIL:', e.message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`[Stage1] Done: ${passed} passed, ${failed} failed`);
|
|
87
|
+
return failed === 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Allow async test (b)
|
|
91
|
+
(async () => {
|
|
92
|
+
const ok = await runStage1Tests();
|
|
93
|
+
process.exit(ok ? 0 : 1);
|
|
94
|
+
})();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Stage 2 unit test: Complete alert metadata on all five alert computations.
|
|
3
|
+
* Asserts each computation has full alert metadata and generateAlertMessage uses only messageTemplate + metadata.
|
|
4
|
+
* Retained for inspection and debugging.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
|
|
10
|
+
const EXPECTED_ALERT_COMPUTATIONS = [
|
|
11
|
+
'RiskScoreIncrease',
|
|
12
|
+
'BehavioralAnomaly',
|
|
13
|
+
'NewSectorExposure',
|
|
14
|
+
'NewSocialPost',
|
|
15
|
+
'PositionInvestedIncrease'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
async function runStage2Tests() {
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
|
|
22
|
+
const v2ConfigPath = path.join(__dirname, '../../computation-system-v2/config/bulltrackers.config.js');
|
|
23
|
+
const v2Config = require(v2ConfigPath);
|
|
24
|
+
const computations = v2Config.computations || [];
|
|
25
|
+
const { generateAlertMessage, loadAlertTypesFromManifest } = require('../helpers/alert_manifest_loader.js');
|
|
26
|
+
const logger = { log: () => {} };
|
|
27
|
+
const alertTypes = await loadAlertTypesFromManifest(logger);
|
|
28
|
+
|
|
29
|
+
for (const ComputationClass of computations) {
|
|
30
|
+
if (!ComputationClass?.getConfig) continue;
|
|
31
|
+
const config = ComputationClass.getConfig();
|
|
32
|
+
if (!config?.alert) continue;
|
|
33
|
+
|
|
34
|
+
const name = config.name;
|
|
35
|
+
const alert = config.alert;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
assert(alert.id != null && alert.id !== '', `${name}: alert.id required`);
|
|
39
|
+
assert(alert.frontendName != null && alert.frontendName !== '', `${name}: alert.frontendName required`);
|
|
40
|
+
assert(alert.description != null, `${name}: alert.description required`);
|
|
41
|
+
assert(alert.messageTemplate != null && alert.messageTemplate !== '', `${name}: alert.messageTemplate required`);
|
|
42
|
+
assert(alert.configKey != null && alert.configKey !== '', `${name}: alert.configKey required`);
|
|
43
|
+
|
|
44
|
+
if (alert.isDynamic && alert.dynamicConfig) {
|
|
45
|
+
const hasThresholds = Array.isArray(alert.dynamicConfig.thresholds) && alert.dynamicConfig.thresholds.length > 0;
|
|
46
|
+
const hasConditions = Array.isArray(alert.dynamicConfig.conditions) && alert.dynamicConfig.conditions.length > 0;
|
|
47
|
+
assert(hasThresholds || hasConditions, `${name}: dynamic alert should have thresholds or conditions`);
|
|
48
|
+
if (alert.dynamicConfig.resultFields) {
|
|
49
|
+
assert(typeof alert.dynamicConfig.resultFields === 'object', `${name}: resultFields should be object`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const alertType = alertTypes.find(t => t.computationName === name);
|
|
54
|
+
assert(alertType != null, `${name}: should be in loaded alert types`);
|
|
55
|
+
|
|
56
|
+
const sampleResult = name === 'RiskScoreIncrease'
|
|
57
|
+
? { change: 1.5, previousRisk: 3, currentRisk: 4.5 }
|
|
58
|
+
: name === 'BehavioralAnomaly'
|
|
59
|
+
? { driver: 'Sector HHI', score: 4.2, driverValue: 'elevated' }
|
|
60
|
+
: name === 'NewSocialPost'
|
|
61
|
+
? { title: 'Market update' }
|
|
62
|
+
: name === 'NewSectorExposure'
|
|
63
|
+
? { newExposures: ['Technology'] }
|
|
64
|
+
: { moves: [{ symbol: 'AAPL', diff: 5, prev: 10, curr: 15 }] };
|
|
65
|
+
|
|
66
|
+
const msg = generateAlertMessage(alertType, 'TestPI', sampleResult);
|
|
67
|
+
assert(typeof msg === 'string' && msg.length > 0, `${name}: generateAlertMessage must return non-empty string`);
|
|
68
|
+
assert(msg.includes('TestPI') || msg.includes('Unknown'), `${name}: message should include piUsername or fallback`);
|
|
69
|
+
|
|
70
|
+
passed++;
|
|
71
|
+
console.log(`[Stage2] ${name}: alert metadata and generateAlertMessage OK`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
failed++;
|
|
74
|
+
console.error(`[Stage2] ${name} FAIL:`, e.message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assert.strictEqual(
|
|
79
|
+
passed,
|
|
80
|
+
EXPECTED_ALERT_COMPUTATIONS.length,
|
|
81
|
+
`Expected ${EXPECTED_ALERT_COMPUTATIONS.length} alert computations to pass, got ${passed} passed, ${failed} failed`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
console.log(`[Stage2] Done: ${passed} passed, ${failed} failed`);
|
|
85
|
+
return failed === 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
runStage2Tests()
|
|
89
|
+
.then(ok => process.exit(ok ? 0 : 1))
|
|
90
|
+
.catch(err => {
|
|
91
|
+
console.error(err);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|