bulltrackers-module 1.0.472 → 1.0.474
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 -61
- package/functions/alert-system/index.js +51 -30
- package/functions/computation-system/persistence/ResultCommitter.js +3 -8
- package/functions/computation-system/workflows/data_feeder_pipeline.yaml +147 -114
- package/index.js +4 -3
- package/package.json +1 -1
|
@@ -16,7 +16,8 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
16
16
|
const piUsername = await getPIUsername(db, piCid);
|
|
17
17
|
|
|
18
18
|
// 2. Find all users subscribed to this PI and alert type
|
|
19
|
-
|
|
19
|
+
// Use computationName (e.g., 'RiskScoreIncrease') to map to alertConfig keys
|
|
20
|
+
const subscriptions = await findSubscriptionsForPI(db, logger, piCid, alertType.computationName);
|
|
20
21
|
|
|
21
22
|
if (subscriptions.length === 0) {
|
|
22
23
|
logger.log('INFO', `[processAlertForPI] No subscriptions found for PI ${piCid}, alert type ${alertType.id}`);
|
|
@@ -28,37 +29,41 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
28
29
|
// 3. Generate alert message
|
|
29
30
|
const alertMessage = generateAlertMessage(alertType, piUsername, computationMetadata);
|
|
30
31
|
|
|
31
|
-
// 4. Create
|
|
32
|
+
// 4. Create notifications for each subscribed user (using user_notifications collection)
|
|
32
33
|
const batch = db.batch();
|
|
33
|
-
const
|
|
34
|
+
const notificationRefs = [];
|
|
34
35
|
const counterUpdates = {};
|
|
35
36
|
|
|
36
37
|
for (const subscription of subscriptions) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
38
|
+
const notificationId = `alert_${Date.now()}_${subscription.userCid}_${piCid}_${Math.random().toString(36).substring(2, 9)}`;
|
|
39
|
+
const notificationRef = db.collection('user_notifications')
|
|
39
40
|
.doc(String(subscription.userCid))
|
|
40
|
-
.collection('
|
|
41
|
-
.doc(
|
|
41
|
+
.collection('notifications')
|
|
42
|
+
.doc(notificationId);
|
|
42
43
|
|
|
43
|
-
const
|
|
44
|
-
id:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
piUsername: piUsername,
|
|
48
|
-
alertType: alertType.id,
|
|
49
|
-
alertTypeName: alertType.name,
|
|
50
|
-
computationName: alertType.computationName,
|
|
51
|
-
computationDate: computationDate,
|
|
44
|
+
const notificationData = {
|
|
45
|
+
id: notificationId,
|
|
46
|
+
type: 'alert',
|
|
47
|
+
title: alertType.name,
|
|
52
48
|
message: alertMessage,
|
|
53
|
-
severity: alertType.severity,
|
|
54
49
|
read: false,
|
|
55
|
-
readAt: null,
|
|
56
50
|
createdAt: FieldValue.serverTimestamp(),
|
|
57
|
-
metadata:
|
|
51
|
+
metadata: {
|
|
52
|
+
piCid: Number(piCid),
|
|
53
|
+
piUsername: piUsername,
|
|
54
|
+
alertType: alertType.id,
|
|
55
|
+
alertTypeName: alertType.name,
|
|
56
|
+
computationName: alertType.computationName,
|
|
57
|
+
computationDate: computationDate,
|
|
58
|
+
severity: alertType.severity,
|
|
59
|
+
watchlistId: subscription.watchlistId,
|
|
60
|
+
watchlistName: subscription.watchlistName,
|
|
61
|
+
...(computationMetadata || {})
|
|
62
|
+
}
|
|
58
63
|
};
|
|
59
64
|
|
|
60
|
-
batch.set(
|
|
61
|
-
|
|
65
|
+
batch.set(notificationRef, notificationData);
|
|
66
|
+
notificationRefs.push(notificationRef);
|
|
62
67
|
|
|
63
68
|
// Track counter updates
|
|
64
69
|
const dateKey = computationDate || new Date().toISOString().split('T')[0];
|
|
@@ -76,9 +81,9 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
76
81
|
(counterUpdates[subscription.userCid].byType[alertType.id] || 0) + 1;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
// 5. Update
|
|
84
|
+
// 5. Update notification counters
|
|
80
85
|
for (const [userCid, counter] of Object.entries(counterUpdates)) {
|
|
81
|
-
const counterRef = db.collection('
|
|
86
|
+
const counterRef = db.collection('user_notifications')
|
|
82
87
|
.doc(String(userCid))
|
|
83
88
|
.collection('counters')
|
|
84
89
|
.doc(counter.date);
|
|
@@ -92,25 +97,10 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
92
97
|
}, { merge: true });
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
// 6.
|
|
96
|
-
for (const subscription of subscriptions) {
|
|
97
|
-
const subscriptionRef = db.collection('watchlist_subscriptions')
|
|
98
|
-
.doc(String(subscription.userCid))
|
|
99
|
-
.collection('alerts')
|
|
100
|
-
.doc(String(piCid));
|
|
101
|
-
|
|
102
|
-
batch.update(subscriptionRef, {
|
|
103
|
-
lastAlertAt: FieldValue.serverTimestamp()
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// 7. Commit batch
|
|
100
|
+
// 6. Commit batch
|
|
108
101
|
await batch.commit();
|
|
109
102
|
|
|
110
|
-
logger.log('SUCCESS', `[processAlertForPI] Created ${
|
|
111
|
-
|
|
112
|
-
// 8. Optionally notify the PI if they're a signed-in user
|
|
113
|
-
await notifyPIIfSignedIn(db, logger, piCid, alertType, subscriptions.length);
|
|
103
|
+
logger.log('SUCCESS', `[processAlertForPI] Created ${notificationRefs.length} notifications for PI ${piCid}, alert type ${alertType.id}`);
|
|
114
104
|
|
|
115
105
|
} catch (error) {
|
|
116
106
|
logger.log('ERROR', `[processAlertForPI] Error processing alert for PI ${piCid}`, error);
|
|
@@ -119,35 +109,67 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
|
|
|
119
109
|
}
|
|
120
110
|
|
|
121
111
|
/**
|
|
122
|
-
* Find all
|
|
112
|
+
* Find all users who should receive alerts for a PI and alert type
|
|
113
|
+
* Reads from actual watchlist structure: watchlists/{userCid}/lists/{watchlistId}
|
|
123
114
|
*/
|
|
124
|
-
async function findSubscriptionsForPI(db, piCid, alertTypeId) {
|
|
115
|
+
async function findSubscriptionsForPI(db, logger, piCid, alertTypeId) {
|
|
125
116
|
const subscriptions = [];
|
|
126
117
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
// Map computation names to watchlist alertConfig keys
|
|
119
|
+
// The alertTypeId is actually the computation name (e.g., 'RiskScoreIncrease')
|
|
120
|
+
const computationToConfigKey = {
|
|
121
|
+
'RiskScoreIncrease': 'increasedRisk',
|
|
122
|
+
'SignificantVolatility': 'volatilityChanges',
|
|
123
|
+
'NewSectorExposure': 'newSector',
|
|
124
|
+
'PositionInvestedIncrease': 'increasedPositionSize',
|
|
125
|
+
'NewSocialPost': 'newSocialPost',
|
|
126
|
+
'NewPositions': 'newPositions' // If this computation exists
|
|
127
|
+
};
|
|
132
128
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
const configKey = computationToConfigKey[alertTypeId];
|
|
130
|
+
if (!configKey) {
|
|
131
|
+
logger.log('WARN', `[findSubscriptionsForPI] No mapping found for alert type: ${alertTypeId}`);
|
|
132
|
+
return subscriptions;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Get all watchlists
|
|
136
|
+
const watchlistsCollection = db.collection('watchlists');
|
|
137
|
+
const watchlistsSnapshot = await watchlistsCollection.get();
|
|
138
|
+
|
|
139
|
+
for (const userDoc of watchlistsSnapshot.docs) {
|
|
140
|
+
const userCid = Number(userDoc.id);
|
|
141
|
+
const userListsSnapshot = await userDoc.ref.collection('lists').get();
|
|
136
142
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
piCid
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
for (const listDoc of userListsSnapshot.docs) {
|
|
144
|
+
const listData = listDoc.data();
|
|
145
|
+
|
|
146
|
+
// Check static watchlists
|
|
147
|
+
if (listData.type === 'static' && listData.items && Array.isArray(listData.items)) {
|
|
148
|
+
for (const item of listData.items) {
|
|
149
|
+
if (Number(item.cid) === Number(piCid)) {
|
|
150
|
+
// Check if this alert type is enabled for this PI in this watchlist
|
|
151
|
+
if (item.alertConfig && item.alertConfig[configKey] === true) {
|
|
152
|
+
subscriptions.push({
|
|
153
|
+
userCid: userCid,
|
|
154
|
+
piCid: piCid,
|
|
155
|
+
piUsername: item.username || `PI-${piCid}`,
|
|
156
|
+
watchlistId: listDoc.id,
|
|
157
|
+
watchlistName: listData.name || 'Unnamed Watchlist',
|
|
158
|
+
alertConfig: item.alertConfig
|
|
159
|
+
});
|
|
160
|
+
break; // Found in this watchlist, no need to check other items
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
147
164
|
}
|
|
165
|
+
|
|
166
|
+
// Check dynamic watchlists - if the PI matches the computation result, check alertConfig
|
|
167
|
+
// Note: Dynamic watchlists are handled separately as they're based on computation results
|
|
168
|
+
// For now, we only process static watchlists here
|
|
148
169
|
}
|
|
149
170
|
}
|
|
150
171
|
|
|
172
|
+
logger.log('INFO', `[findSubscriptionsForPI] Found ${subscriptions.length} subscriptions for PI ${piCid}, alert type ${alertTypeId}`);
|
|
151
173
|
return subscriptions;
|
|
152
174
|
}
|
|
153
175
|
|
|
@@ -336,10 +358,41 @@ function readComputationResults(docData) {
|
|
|
336
358
|
}
|
|
337
359
|
}
|
|
338
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Read computation results, handling sharded data
|
|
363
|
+
*/
|
|
364
|
+
async function readComputationResultsWithShards(db, docData, docRef) {
|
|
365
|
+
try {
|
|
366
|
+
// Check if data is sharded
|
|
367
|
+
if (docData._sharded === true && docData._shardCount) {
|
|
368
|
+
// Data is stored in shards - read all shards and merge
|
|
369
|
+
const shardsCol = docRef.collection('_shards');
|
|
370
|
+
const shardsSnapshot = await shardsCol.get();
|
|
371
|
+
|
|
372
|
+
if (!shardsSnapshot.empty) {
|
|
373
|
+
let mergedData = {};
|
|
374
|
+
for (const shardDoc of shardsSnapshot.docs) {
|
|
375
|
+
const shardData = shardDoc.data();
|
|
376
|
+
const decompressed = tryDecompress(shardData);
|
|
377
|
+
Object.assign(mergedData, decompressed);
|
|
378
|
+
}
|
|
379
|
+
return readComputationResults(mergedData);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Data is in the main document (compressed or not)
|
|
384
|
+
return readComputationResults(docData);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
console.error('[readComputationResultsWithShards] Error reading sharded results', error);
|
|
387
|
+
return { cids: [], metadata: {}, perUserData: {} };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
339
391
|
module.exports = {
|
|
340
392
|
processAlertForPI,
|
|
341
393
|
findSubscriptionsForPI,
|
|
342
394
|
getPIUsername,
|
|
343
|
-
readComputationResults
|
|
395
|
+
readComputationResults,
|
|
396
|
+
readComputationResultsWithShards
|
|
344
397
|
};
|
|
345
398
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { getAlertTypeByComputation, isAlertComputation } = require('./helpers/alert_type_registry');
|
|
7
|
-
const { processAlertForPI, readComputationResults } = require('./helpers/alert_helpers');
|
|
7
|
+
const { processAlertForPI, readComputationResults, readComputationResultsWithShards } = require('./helpers/alert_helpers');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Pub/Sub trigger handler for alert generation
|
|
@@ -126,13 +126,33 @@ async function handleAlertTrigger(message, context, config, dependencies) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Firestore trigger function for alert generation
|
|
129
|
+
* Firestore trigger function for alert generation
|
|
130
130
|
* Triggered when computation results are written to:
|
|
131
|
-
* unified_insights/{date}/results/
|
|
131
|
+
* unified_insights/{date}/results/{category}/computations/{computationName}
|
|
132
|
+
*
|
|
133
|
+
* Handles sharded data by checking _shards subcollection if _sharded === true
|
|
132
134
|
*/
|
|
133
135
|
async function handleComputationResultWrite(change, context, config, dependencies) {
|
|
134
136
|
const { db, logger } = dependencies;
|
|
135
|
-
|
|
137
|
+
|
|
138
|
+
// Extract path parameters from the document path
|
|
139
|
+
// Path format: unified_insights/{date}/results/{category}/computations/{computationName}
|
|
140
|
+
const resource = context.resource || change.after.ref.path;
|
|
141
|
+
const pathParts = resource.split('/');
|
|
142
|
+
|
|
143
|
+
// Find indices of key path segments
|
|
144
|
+
const dateIndex = pathParts.indexOf('unified_insights') + 1;
|
|
145
|
+
const categoryIndex = pathParts.indexOf('results') + 1;
|
|
146
|
+
const computationIndex = pathParts.indexOf('computations') + 1;
|
|
147
|
+
|
|
148
|
+
if (dateIndex === 0 || categoryIndex === 0 || computationIndex === 0) {
|
|
149
|
+
logger.log('WARN', `[AlertTrigger] Invalid document path format: ${resource}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const date = pathParts[dateIndex];
|
|
154
|
+
const category = pathParts[categoryIndex];
|
|
155
|
+
const computationName = pathParts[computationIndex];
|
|
136
156
|
|
|
137
157
|
// Only process if document was created or updated (not deleted)
|
|
138
158
|
if (!change.after.exists) {
|
|
@@ -148,10 +168,16 @@ async function handleComputationResultWrite(change, context, config, dependencie
|
|
|
148
168
|
return;
|
|
149
169
|
}
|
|
150
170
|
|
|
171
|
+
// Only process popular-investor category computations
|
|
172
|
+
if (category !== 'popular-investor') {
|
|
173
|
+
logger.log('DEBUG', `[AlertTrigger] Not popular-investor category: ${category}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
151
177
|
// If it's PopularInvestorProfileMetrics, check for all-clear notifications only
|
|
152
178
|
if (isProfileMetrics) {
|
|
153
179
|
const docData = change.after.data();
|
|
154
|
-
const results =
|
|
180
|
+
const results = await readComputationResultsWithShards(db, docData, change.after.ref);
|
|
155
181
|
if (results.cids && results.cids.length > 0) {
|
|
156
182
|
await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
|
|
157
183
|
}
|
|
@@ -166,9 +192,9 @@ async function handleComputationResultWrite(change, context, config, dependencie
|
|
|
166
192
|
|
|
167
193
|
logger.log('INFO', `[AlertTrigger] Processing alert computation: ${computationName} for date ${date}`);
|
|
168
194
|
|
|
169
|
-
// 2. Read and decompress computation results
|
|
195
|
+
// 2. Read and decompress computation results (handling shards)
|
|
170
196
|
const docData = change.after.data();
|
|
171
|
-
const results =
|
|
197
|
+
const results = await readComputationResultsWithShards(db, docData, change.after.ref);
|
|
172
198
|
|
|
173
199
|
if (!results.cids || results.cids.length === 0) {
|
|
174
200
|
logger.log('INFO', `[AlertTrigger] No PIs found in computation results for ${computationName}`);
|
|
@@ -178,6 +204,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
|
|
|
178
204
|
logger.log('INFO', `[AlertTrigger] Found ${results.cids.length} PIs in ${computationName} results`);
|
|
179
205
|
|
|
180
206
|
// 3. Process alerts for each PI
|
|
207
|
+
// Use computationName as the alertTypeId (it maps to alertConfig keys)
|
|
181
208
|
let processedCount = 0;
|
|
182
209
|
let errorCount = 0;
|
|
183
210
|
|
|
@@ -186,7 +213,7 @@ async function handleComputationResultWrite(change, context, config, dependencie
|
|
|
186
213
|
// Extract PI-specific metadata if available, otherwise use global metadata
|
|
187
214
|
const piMetadata = results.perUserData && results.perUserData[piCid]
|
|
188
215
|
? results.perUserData[piCid]
|
|
189
|
-
: results.metadata || {};
|
|
216
|
+
: results.metadata || results.globalMetadata || {};
|
|
190
217
|
|
|
191
218
|
await processAlertForPI(
|
|
192
219
|
db,
|
|
@@ -206,12 +233,6 @@ async function handleComputationResultWrite(change, context, config, dependencie
|
|
|
206
233
|
|
|
207
234
|
logger.log('SUCCESS', `[AlertTrigger] Completed processing ${computationName}: ${processedCount} successful, ${errorCount} errors`);
|
|
208
235
|
|
|
209
|
-
// After processing alerts, check for "all clear" notifications for static watchlists
|
|
210
|
-
if (processedCount > 0 || results.cids.length > 0) {
|
|
211
|
-
// Check if any PIs were processed but didn't trigger alerts
|
|
212
|
-
await checkAndSendAllClearNotifications(db, logger, results.cids, date, config);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
236
|
} catch (error) {
|
|
216
237
|
logger.log('ERROR', `[AlertTrigger] Fatal error processing ${computationName}`, error);
|
|
217
238
|
// Don't throw - we don't want to retry the entire computation write
|
|
@@ -238,7 +259,7 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
|
|
|
238
259
|
const usersToNotify = new Map(); // userCid -> Map of piCid -> {username, ...}
|
|
239
260
|
|
|
240
261
|
for (const userDoc of watchlistsSnapshot.docs) {
|
|
241
|
-
const userCid = userDoc.id;
|
|
262
|
+
const userCid = Number(userDoc.id);
|
|
242
263
|
const userListsSnapshot = await userDoc.ref.collection('lists').get();
|
|
243
264
|
|
|
244
265
|
for (const listDoc of userListsSnapshot.docs) {
|
|
@@ -253,8 +274,8 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
|
|
|
253
274
|
for (const item of listData.items) {
|
|
254
275
|
const piCid = Number(item.cid);
|
|
255
276
|
if (processedPICids.includes(piCid)) {
|
|
256
|
-
// Check if this PI triggered any alerts today
|
|
257
|
-
const hasAlerts = await checkIfPIHasAlertsToday(db, userCid, piCid, today);
|
|
277
|
+
// Check if this PI triggered any alerts today for this user
|
|
278
|
+
const hasAlerts = await checkIfPIHasAlertsToday(db, logger, userCid, piCid, today);
|
|
258
279
|
|
|
259
280
|
if (!hasAlerts) {
|
|
260
281
|
// No alerts triggered - send "all clear" notification
|
|
@@ -292,28 +313,28 @@ async function checkAndSendAllClearNotifications(db, logger, processedPICids, co
|
|
|
292
313
|
|
|
293
314
|
/**
|
|
294
315
|
* Check if a PI has any alerts for a user today
|
|
316
|
+
* Checks user_notifications collection for alert-type notifications
|
|
295
317
|
*/
|
|
296
|
-
async function checkIfPIHasAlertsToday(db, userCid, piCid, date) {
|
|
318
|
+
async function checkIfPIHasAlertsToday(db, logger, userCid, piCid, date) {
|
|
297
319
|
try {
|
|
298
|
-
const
|
|
320
|
+
const notificationsRef = db.collection('user_notifications')
|
|
299
321
|
.doc(String(userCid))
|
|
300
|
-
.collection('
|
|
301
|
-
|
|
302
|
-
// Check if there are any alerts for this PI today
|
|
303
|
-
const todayStart = new Date(date);
|
|
304
|
-
todayStart.setHours(0, 0, 0, 0);
|
|
305
|
-
const todayEnd = new Date(date);
|
|
306
|
-
todayEnd.setHours(23, 59, 59, 999);
|
|
322
|
+
.collection('notifications');
|
|
307
323
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
.where('
|
|
324
|
+
// Check if there are any alert notifications for this PI today
|
|
325
|
+
const snapshot = await notificationsRef
|
|
326
|
+
.where('type', '==', 'alert')
|
|
327
|
+
.where('metadata.piCid', '==', Number(piCid))
|
|
328
|
+
.where('metadata.computationDate', '==', date)
|
|
311
329
|
.limit(1)
|
|
312
330
|
.get();
|
|
313
331
|
|
|
314
332
|
return !snapshot.empty;
|
|
315
333
|
} catch (error) {
|
|
316
|
-
|
|
334
|
+
if (logger) {
|
|
335
|
+
logger.log('WARN', `[checkIfPIHasAlertsToday] Error checking alerts for user ${userCid}, PI ${piCid}: ${error.message}`);
|
|
336
|
+
}
|
|
337
|
+
// If we can't check, assume there are alerts (safer - won't send false all-clear)
|
|
317
338
|
return true;
|
|
318
339
|
}
|
|
319
340
|
}
|
|
@@ -202,15 +202,10 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
|
|
|
202
202
|
await updateComputationStatus(dStr, successUpdates, config, deps);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
//
|
|
205
|
+
// Alert triggers are now handled via Firestore triggers
|
|
206
|
+
// No need to publish Pub/Sub messages - the Firestore write itself triggers the alert system
|
|
206
207
|
if (alertTriggers.length > 0) {
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
await pubSubUtils.publishMessageBatch(topicName, alertTriggers);
|
|
210
|
-
logger.log('INFO', `[Alert System] Triggered ${alertTriggers.length} alerts to ${topicName}`);
|
|
211
|
-
} catch (alertErr) {
|
|
212
|
-
logger.log('ERROR', `[Alert System] Failed to publish alerts: ${alertErr.message}`);
|
|
213
|
-
}
|
|
208
|
+
logger.log('INFO', `[Alert System] ${alertTriggers.length} alert computations written to Firestore - triggers will fire automatically`);
|
|
214
209
|
}
|
|
215
210
|
|
|
216
211
|
return { successUpdates, failureReport, shardIndexes: nextShardIndexes };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
# Data Feeder Pipeline (
|
|
2
|
-
#
|
|
1
|
+
# Data Feeder Pipeline (V3.1 - Syntax Fixed)
|
|
2
|
+
# Starts at 22:00 UTC via Cloud Scheduler.
|
|
3
|
+
# Fixes: Split assign/call steps and corrected assign syntax.
|
|
3
4
|
|
|
4
5
|
main:
|
|
5
6
|
params: [input]
|
|
@@ -8,17 +9,8 @@ main:
|
|
|
8
9
|
assign:
|
|
9
10
|
- project: '${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}'
|
|
10
11
|
- location: "europe-west1"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- default_retry:
|
|
14
|
-
predicate: ${http.default_retry_predicate}
|
|
15
|
-
max_retries: 5
|
|
16
|
-
backoff:
|
|
17
|
-
initial_delay: 2
|
|
18
|
-
max_delay: 60
|
|
19
|
-
multiplier: 2
|
|
20
|
-
|
|
21
|
-
# --- TEST MODE / SELECTIVE EXECUTION ---
|
|
12
|
+
|
|
13
|
+
# --- TEST MODE ---
|
|
22
14
|
- check_test_mode:
|
|
23
15
|
switch:
|
|
24
16
|
- condition: '${input != null and "target_step" in input}'
|
|
@@ -26,130 +18,164 @@ main:
|
|
|
26
18
|
- route_test:
|
|
27
19
|
switch:
|
|
28
20
|
- condition: '${input.target_step == "market"}'
|
|
29
|
-
next:
|
|
30
|
-
- condition: '${input.target_step == "
|
|
31
|
-
next:
|
|
21
|
+
next: phase_2200_price
|
|
22
|
+
- condition: '${input.target_step == "midnight"}'
|
|
23
|
+
next: phase_0000_rankings
|
|
32
24
|
- condition: '${input.target_step == "social"}'
|
|
33
|
-
next:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
branches:
|
|
41
|
-
- price_fetch:
|
|
42
|
-
steps:
|
|
43
|
-
- call_price_fetcher:
|
|
44
|
-
try:
|
|
45
|
-
call: http.post
|
|
46
|
-
args:
|
|
47
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/price-fetcher"}'
|
|
48
|
-
auth: { type: OIDC }
|
|
49
|
-
timeout: 300
|
|
50
|
-
retry: ${default_retry} # Fixed: Moved retry to a Try Step
|
|
51
|
-
- insights_fetch:
|
|
52
|
-
steps:
|
|
53
|
-
- call_insights_fetcher:
|
|
54
|
-
try:
|
|
55
|
-
call: http.post
|
|
56
|
-
args:
|
|
57
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/insights-fetcher"}'
|
|
58
|
-
auth: { type: OIDC }
|
|
59
|
-
timeout: 300
|
|
60
|
-
retry: ${default_retry} # Fixed: Moved retry to a Try Step
|
|
61
|
-
|
|
62
|
-
- index_market_data:
|
|
25
|
+
next: social_loop_start
|
|
26
|
+
|
|
27
|
+
# ==========================================
|
|
28
|
+
# PHASE 1: MARKET CLOSE (Starts 22:00 UTC)
|
|
29
|
+
# ==========================================
|
|
30
|
+
|
|
31
|
+
- phase_2200_price:
|
|
63
32
|
try:
|
|
64
33
|
call: http.post
|
|
65
34
|
args:
|
|
66
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/
|
|
67
|
-
body:
|
|
68
|
-
targetDate: '${market_date}'
|
|
35
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/price-fetcher"}'
|
|
69
36
|
auth: { type: OIDC }
|
|
70
37
|
timeout: 300
|
|
71
|
-
|
|
38
|
+
except:
|
|
39
|
+
as: e
|
|
40
|
+
steps:
|
|
41
|
+
- log_price_error:
|
|
42
|
+
call: sys.log
|
|
43
|
+
args: { severity: "WARNING", text: "Price fetch timed out/failed. Proceeding." }
|
|
44
|
+
|
|
45
|
+
- wait_10_after_price:
|
|
46
|
+
call: sys.sleep
|
|
47
|
+
args: { seconds: 600 } # 10 Minutes
|
|
72
48
|
|
|
73
|
-
#
|
|
74
|
-
-
|
|
49
|
+
# FIX 1: Split assign and call
|
|
50
|
+
- prepare_index_price:
|
|
75
51
|
assign:
|
|
76
|
-
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- do_midnight_sleep:
|
|
80
|
-
call: sys.sleep
|
|
52
|
+
- today: '${text.split(time.format(sys.now()), "T")[0]}'
|
|
53
|
+
- index_today_after_price:
|
|
54
|
+
call: http.post
|
|
81
55
|
args:
|
|
82
|
-
|
|
56
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
|
|
57
|
+
body: { targetDate: '${today}' }
|
|
58
|
+
auth: { type: OIDC }
|
|
83
59
|
|
|
84
|
-
|
|
85
|
-
|
|
60
|
+
- wait_10_before_insights:
|
|
61
|
+
call: sys.sleep
|
|
62
|
+
args: { seconds: 600 }
|
|
63
|
+
|
|
64
|
+
- phase_2200_insights:
|
|
86
65
|
try:
|
|
87
66
|
call: http.post
|
|
88
67
|
args:
|
|
89
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/
|
|
68
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/insights-fetcher"}'
|
|
90
69
|
auth: { type: OIDC }
|
|
91
70
|
timeout: 300
|
|
92
|
-
retry: ${default_retry}
|
|
93
71
|
except:
|
|
94
72
|
as: e
|
|
95
73
|
steps:
|
|
96
|
-
-
|
|
74
|
+
- log_insights_error:
|
|
97
75
|
call: sys.log
|
|
98
|
-
args:
|
|
99
|
-
severity: "ERROR"
|
|
100
|
-
text: '${"Rankings Fetch Failed: " + json.encode(e)}'
|
|
76
|
+
args: { severity: "WARNING", text: "Insights fetch timed out/failed. Proceeding." }
|
|
101
77
|
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
args:
|
|
106
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/social-orchestrator"}'
|
|
107
|
-
auth: { type: OIDC }
|
|
108
|
-
timeout: 300
|
|
109
|
-
retry: ${default_retry}
|
|
78
|
+
- wait_10_after_insights:
|
|
79
|
+
call: sys.sleep
|
|
80
|
+
args: { seconds: 600 }
|
|
110
81
|
|
|
111
|
-
|
|
82
|
+
# FIX 2: Split assign and call
|
|
83
|
+
- prepare_index_insights:
|
|
112
84
|
assign:
|
|
113
|
-
|
|
114
|
-
-
|
|
85
|
+
- today: '${text.split(time.format(sys.now()), "T")[0]}'
|
|
86
|
+
- index_today_after_insights:
|
|
87
|
+
call: http.post
|
|
88
|
+
args:
|
|
89
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
|
|
90
|
+
body: { targetDate: '${today}' }
|
|
91
|
+
auth: { type: OIDC }
|
|
92
|
+
|
|
93
|
+
# ==========================================
|
|
94
|
+
# PHASE 2: WAIT FOR MIDNIGHT
|
|
95
|
+
# ==========================================
|
|
96
|
+
|
|
97
|
+
- align_to_midnight:
|
|
98
|
+
assign:
|
|
99
|
+
- now_sec: '${int(sys.now())}'
|
|
100
|
+
- day_sec: 86400
|
|
101
|
+
- sleep_midnight: '${day_sec - (now_sec % day_sec)}'
|
|
102
|
+
- wait_for_midnight:
|
|
103
|
+
call: sys.sleep
|
|
104
|
+
args: { seconds: '${sleep_midnight}' }
|
|
105
|
+
|
|
106
|
+
# ==========================================
|
|
107
|
+
# PHASE 3: MIDNIGHT TASKS (00:00 UTC)
|
|
108
|
+
# ==========================================
|
|
109
|
+
|
|
110
|
+
- phase_0000_rankings:
|
|
115
111
|
try:
|
|
116
112
|
call: http.post
|
|
117
113
|
args:
|
|
118
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/
|
|
119
|
-
body:
|
|
120
|
-
targetDate: '${current_date}'
|
|
114
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/fetch-popular-investors"}'
|
|
121
115
|
auth: { type: OIDC }
|
|
122
116
|
timeout: 300
|
|
123
|
-
|
|
117
|
+
except:
|
|
118
|
+
as: e
|
|
119
|
+
steps:
|
|
120
|
+
- log_ranking_error:
|
|
121
|
+
call: sys.log
|
|
122
|
+
args: { severity: "WARNING", text: "Rankings failed. Proceeding to Social (risky)." }
|
|
124
123
|
|
|
125
|
-
-
|
|
124
|
+
- wait_10_after_rankings:
|
|
125
|
+
call: sys.sleep
|
|
126
|
+
args: { seconds: 600 }
|
|
127
|
+
|
|
128
|
+
# FIX 3: Split assign and call
|
|
129
|
+
- prepare_index_rankings:
|
|
130
|
+
assign:
|
|
131
|
+
- today: '${text.split(time.format(sys.now()), "T")[0]}'
|
|
132
|
+
- index_today_after_rankings:
|
|
133
|
+
call: http.post
|
|
134
|
+
args:
|
|
135
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
|
|
136
|
+
body: { targetDate: '${today}' }
|
|
137
|
+
auth: { type: OIDC }
|
|
138
|
+
|
|
139
|
+
- phase_0000_social:
|
|
126
140
|
try:
|
|
127
141
|
call: http.post
|
|
128
142
|
args:
|
|
129
|
-
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/
|
|
143
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/social-orchestrator"}'
|
|
130
144
|
auth: { type: OIDC }
|
|
131
145
|
timeout: 300
|
|
132
|
-
|
|
146
|
+
except:
|
|
147
|
+
as: e
|
|
148
|
+
steps:
|
|
149
|
+
- log_social_error:
|
|
150
|
+
call: sys.log
|
|
151
|
+
args: { severity: "WARNING", text: "Social failed. Proceeding." }
|
|
152
|
+
|
|
153
|
+
- wait_10_after_social:
|
|
154
|
+
call: sys.sleep
|
|
155
|
+
args: { seconds: 600 }
|
|
156
|
+
|
|
157
|
+
- global_index_midnight:
|
|
158
|
+
call: http.post
|
|
159
|
+
args:
|
|
160
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
|
|
161
|
+
# No targetDate = Global Run
|
|
162
|
+
auth: { type: OIDC }
|
|
163
|
+
|
|
164
|
+
# ==========================================
|
|
165
|
+
# PHASE 4: SOCIAL LOOP (Every 3 Hours)
|
|
166
|
+
# ==========================================
|
|
133
167
|
|
|
134
|
-
# --- PHASE 4: RECURRING SOCIAL FETCH (UTC Aligned 3hr) ---
|
|
135
168
|
- init_social_loop:
|
|
136
169
|
assign:
|
|
137
170
|
- i: 0
|
|
138
171
|
|
|
139
|
-
-
|
|
172
|
+
- social_loop_start:
|
|
140
173
|
switch:
|
|
141
|
-
- condition: ${i < 7}
|
|
174
|
+
- condition: ${i < 7} # Covers the remainder of the 24h cycle
|
|
142
175
|
steps:
|
|
143
|
-
-
|
|
144
|
-
assign:
|
|
145
|
-
- now_sec_loop: '${int(sys.now())}'
|
|
146
|
-
- window_size: 10800
|
|
147
|
-
- sleep_loop: '${window_size - (now_sec_loop % window_size)}'
|
|
148
|
-
|
|
149
|
-
- wait_for_3hr_window:
|
|
176
|
+
- wait_3_hours:
|
|
150
177
|
call: sys.sleep
|
|
151
|
-
args:
|
|
152
|
-
seconds: '${sleep_loop}'
|
|
178
|
+
args: { seconds: 10800 }
|
|
153
179
|
|
|
154
180
|
- run_social_recurring:
|
|
155
181
|
try:
|
|
@@ -158,27 +184,34 @@ main:
|
|
|
158
184
|
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/social-orchestrator"}'
|
|
159
185
|
auth: { type: OIDC }
|
|
160
186
|
timeout: 300
|
|
161
|
-
|
|
187
|
+
except:
|
|
188
|
+
as: e
|
|
189
|
+
steps:
|
|
190
|
+
- log_loop_social_warn:
|
|
191
|
+
call: sys.log
|
|
192
|
+
args: { severity: "WARNING", text: "Loop Social timed out. Proceeding." }
|
|
162
193
|
|
|
163
|
-
-
|
|
194
|
+
- wait_10_in_loop:
|
|
195
|
+
call: sys.sleep
|
|
196
|
+
args: { seconds: 600 }
|
|
197
|
+
|
|
198
|
+
# FIX 4: Split assign and call
|
|
199
|
+
- prepare_index_loop:
|
|
164
200
|
assign:
|
|
165
|
-
-
|
|
166
|
-
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
targetDate: '${cur_date_rec}'
|
|
173
|
-
auth: { type: OIDC }
|
|
174
|
-
timeout: 300
|
|
175
|
-
retry: ${default_retry}
|
|
201
|
+
- today: '${text.split(time.format(sys.now()), "T")[0]}'
|
|
202
|
+
- index_today_in_loop:
|
|
203
|
+
call: http.post
|
|
204
|
+
args:
|
|
205
|
+
url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
|
|
206
|
+
body: { targetDate: '${today}' }
|
|
207
|
+
auth: { type: OIDC }
|
|
176
208
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
209
|
+
# FIX 5: Correct assign syntax (must be a list)
|
|
210
|
+
- increment_loop:
|
|
211
|
+
assign:
|
|
212
|
+
- i: '${i + 1}'
|
|
180
213
|
- next_iteration:
|
|
181
|
-
next:
|
|
214
|
+
next: social_loop_start
|
|
182
215
|
|
|
183
216
|
- finish:
|
|
184
|
-
return: "
|
|
217
|
+
return: "Complete 24h Cycle Finished"
|
package/index.js
CHANGED
|
@@ -57,7 +57,7 @@ const { runRootDataIndexer } = require('./functions
|
|
|
57
57
|
const { runPopularInvestorFetch } = require('./functions/fetch-popular-investors/index');
|
|
58
58
|
|
|
59
59
|
// Alert System
|
|
60
|
-
const { handleAlertTrigger, handleComputationResultWrite } = require('./functions/alert-system/index');
|
|
60
|
+
const { handleAlertTrigger, handleComputationResultWrite, checkAndSendAllClearNotifications } = require('./functions/alert-system/index');
|
|
61
61
|
|
|
62
62
|
// Proxy
|
|
63
63
|
const { handlePost } = require('./functions/appscript-api/index');
|
|
@@ -126,8 +126,9 @@ const maintenance = {
|
|
|
126
126
|
const proxy = { handlePost };
|
|
127
127
|
|
|
128
128
|
const alertSystem = {
|
|
129
|
-
handleAlertTrigger,
|
|
130
|
-
handleComputationResultWrite
|
|
129
|
+
handleAlertTrigger, // Pub/Sub handler (kept for backward compatibility, but not used)
|
|
130
|
+
handleComputationResultWrite, // Firestore trigger handler - main entry point
|
|
131
|
+
checkAndSendAllClearNotifications
|
|
131
132
|
};
|
|
132
133
|
|
|
133
134
|
module.exports = {
|