bulltrackers-module 1.0.428 → 1.0.429

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.
@@ -2,12 +2,14 @@
2
2
  * @fileoverview Handles saving computation results with observability and Smart Cleanup.
3
3
  * UPDATED: Tracks specific Firestore Ops (Writes/Deletes) for cost analysis.
4
4
  * UPDATED: Added Dynamic Circuit Breaker Bypass for Price-Only calculations.
5
+ * UPDATED: Integrated Alert System Pub/Sub triggers for marked computations.
5
6
  */
6
7
  const { commitBatchInChunks, generateDataHash } = require('../utils/utils');
7
8
  const { updateComputationStatus } = require('./StatusRepository');
8
9
  const { batchStoreSchemas } = require('../utils/schema_capture');
9
10
  const { generateProcessId, PROCESS_TYPES } = require('../logger/logger');
10
11
  const { HeuristicValidator } = require('./ResultsValidator');
12
+ const { PubSubUtils } = require('../../core/utils/pubsub_utils');
11
13
  const ContractValidator = require('./ContractValidator');
12
14
  const validationOverrides = require('../config/validation_overrides');
13
15
  const pLimit = require('p-limit');
@@ -22,6 +24,7 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
22
24
  const failureReport = [];
23
25
  const schemas = [];
24
26
  const cleanupTasks = [];
27
+ const alertTriggers = []; // [NEW] Collect alert triggers
25
28
  const { logger, db } = deps;
26
29
  const pid = generateProcessId(PROCESS_TYPES.STORAGE, passName, dStr);
27
30
 
@@ -30,6 +33,7 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
30
33
  const shardIndexes = options.shardIndexes || {};
31
34
  const nextShardIndexes = {};
32
35
  const fanOutLimit = pLimit(10);
36
+ const pubSubUtils = new PubSubUtils(deps);
33
37
 
34
38
  // 1. [BATCH OPTIMIZATION] Fetch all SimHashes and Contracts upfront
35
39
  const calcNames = Object.keys(stateObj);
@@ -61,14 +65,11 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
61
65
  const isPriceOnly = (dataDeps.length === 1 && dataDeps[0] === 'price');
62
66
  let effectiveOverrides = { ...configOverrides };
63
67
 
64
- // If a calculation relies solely on price, we strictly bypass the circuit breaker.
65
- // This handles Holidays (Weekday 0s), Weekends (Scheduler Gaps), and Crypto/Stock mix.
66
68
  if (isPriceOnly) {
67
69
  effectiveOverrides.maxZeroPct = 100;
68
70
  effectiveOverrides.maxFlatlinePct = 100;
69
71
  effectiveOverrides.maxNullPct = 100;
70
72
  effectiveOverrides.maxNanPct = 100;
71
- // Remove specific weekend config to ensure these permissive global limits apply 7 days/week
72
73
  delete effectiveOverrides.weekend;
73
74
  }
74
75
  // --- [DYNAMIC OVERRIDE LOGIC END] ---
@@ -99,7 +100,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
99
100
  const isEmpty = !result || (typeof result === 'object' && Object.keys(result).length === 0);
100
101
  const resultHash = isEmpty ? 'empty' : generateDataHash(result);
101
102
 
102
- // [NEW] Use pre-fetched SimHash
103
103
  const simHash = (flushMode !== 'INTERMEDIATE') ? (simHashMap[calc.manifest.hash] || null) : null;
104
104
 
105
105
  if (isEmpty) {
@@ -119,6 +119,9 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
119
119
  const resultKeys = Object.keys(result || {});
120
120
  const isMultiDate = resultKeys.length > 0 && resultKeys.every(k => /^\d{4}-\d{2}-\d{2}$/.test(k));
121
121
 
122
+ // [NEW] Check metadata for alert flag (defaults to false)
123
+ const isAlertComputation = calc.manifest.isAlertComputation === true;
124
+
122
125
  if (isMultiDate) {
123
126
  const datePromises = resultKeys.map((historicalDate) => fanOutLimit(async () => {
124
127
  const dailyData = result[historicalDate];
@@ -127,6 +130,15 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
127
130
  const stats = await writeSingleResult(dailyData, historicalDocRef, name, historicalDate, logger, config, deps, 0, 'STANDARD', false);
128
131
  runMetrics.io.writes += stats.opCounts.writes;
129
132
  runMetrics.io.deletes += stats.opCounts.deletes;
133
+
134
+ // [NEW] Queue alert for historical date if applicable
135
+ if (isAlertComputation && flushMode !== 'INTERMEDIATE') {
136
+ alertTriggers.push({
137
+ date: historicalDate,
138
+ computationName: name,
139
+ documentPath: historicalDocRef.path
140
+ });
141
+ }
130
142
  }));
131
143
  await Promise.all(datePromises);
132
144
 
@@ -143,11 +155,19 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
143
155
 
144
156
  nextShardIndexes[name] = writeStats.nextShardIndex;
145
157
  if (calc.manifest.hash) { successUpdates[name] = { hash: calc.manifest.hash, simHash, resultHash, dependencyResultHashes: calc.manifest.dependencyResultHashes || {}, category: calc.manifest.category, composition: calc.manifest.composition, metrics: runMetrics }; }
158
+
159
+ // [NEW] Queue alert for standard write
160
+ if (isAlertComputation && flushMode !== 'INTERMEDIATE') {
161
+ alertTriggers.push({
162
+ date: dStr,
163
+ computationName: name,
164
+ documentPath: mainDocRef.path
165
+ });
166
+ }
146
167
  }
147
168
 
148
169
  if (calc.manifest.class.getSchema && flushMode !== 'INTERMEDIATE') {
149
170
  const { class: _cls, ...safeMetadata } = calc.manifest;
150
- // Remove undefined values to prevent Firestore errors
151
171
  const cleanedMetadata = {};
152
172
  for (const [key, value] of Object.entries(safeMetadata)) {
153
173
  if (value !== undefined) {
@@ -172,6 +192,18 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
172
192
  if (!skipStatusWrite && Object.keys(successUpdates).length > 0 && flushMode !== 'INTERMEDIATE') {
173
193
  await updateComputationStatus(dStr, successUpdates, config, deps);
174
194
  }
195
+
196
+ // [NEW] Flush Alert Triggers to Pub/Sub
197
+ if (alertTriggers.length > 0) {
198
+ const topicName = config.alertTopicName || 'alert-trigger';
199
+ try {
200
+ await pubSubUtils.publishMessageBatch(topicName, alertTriggers);
201
+ logger.log('INFO', `[Alert System] Triggered ${alertTriggers.length} alerts to ${topicName}`);
202
+ } catch (alertErr) {
203
+ logger.log('ERROR', `[Alert System] Failed to publish alerts: ${alertErr.message}`);
204
+ // Non-blocking: We don't fail the computation if alerts fail to publish, but we log strictly.
205
+ }
206
+ }
175
207
 
176
208
  return { successUpdates, failureReport, shardIndexes: nextShardIndexes };
177
209
  }
@@ -207,8 +239,6 @@ async function writeSingleResult(result, docRef, name, dateContext, logger, conf
207
239
  if (isInitialWrite) {
208
240
  try {
209
241
  const currentSnap = await docRef.get();
210
- // Note: Reads tracked implicitly by calling code or approximated here if needed.
211
- // We focus on writes/deletes here.
212
242
  if (currentSnap.exists) {
213
243
  const d = currentSnap.data();
214
244
  wasSharded = (d._sharded === true);
@@ -0,0 +1,326 @@
1
+ /**
2
+ * @fileoverview Alert Management API Helpers
3
+ * Handles fetching and managing user alerts
4
+ */
5
+
6
+ const { FieldValue } = require('@google-cloud/firestore');
7
+ const { getAllAlertTypes, getAlertType } = require('../../../alert-system/helpers/alert_type_registry');
8
+
9
+ /**
10
+ * GET /user/me/alert-types
11
+ * Get all available alert types
12
+ */
13
+ async function getAlertTypes(req, res, dependencies, config) {
14
+ try {
15
+ const alertTypes = getAllAlertTypes();
16
+
17
+ return res.status(200).json({
18
+ success: true,
19
+ alertTypes: alertTypes.map(type => ({
20
+ id: type.id,
21
+ name: type.name,
22
+ description: type.description,
23
+ severity: type.severity
24
+ }))
25
+ });
26
+ } catch (error) {
27
+ const { logger } = dependencies;
28
+ logger.log('ERROR', '[getAlertTypes] Error fetching alert types', error);
29
+ return res.status(500).json({ error: error.message });
30
+ }
31
+ }
32
+
33
+ /**
34
+ * GET /user/me/alerts
35
+ * Get user's alerts (paginated)
36
+ */
37
+ async function getUserAlerts(req, res, dependencies, config) {
38
+ const { db, logger } = dependencies;
39
+ const { userCid } = req.query;
40
+ const { unreadOnly, limit = 50, offset = 0, alertType } = req.query;
41
+
42
+ if (!userCid) {
43
+ return res.status(400).json({ error: "Missing userCid" });
44
+ }
45
+
46
+ try {
47
+ const alertsRef = db.collection('user_alerts')
48
+ .doc(String(userCid))
49
+ .collection('alerts');
50
+
51
+ let query = alertsRef.orderBy('createdAt', 'desc');
52
+
53
+ // Filter by read status
54
+ if (unreadOnly === 'true') {
55
+ query = query.where('read', '==', false);
56
+ }
57
+
58
+ // Filter by alert type
59
+ if (alertType) {
60
+ query = query.where('alertType', '==', alertType);
61
+ }
62
+
63
+ // Apply pagination
64
+ query = query.limit(Number(limit)).offset(Number(offset));
65
+
66
+ const snapshot = await query.get();
67
+ const alerts = [];
68
+
69
+ snapshot.forEach(doc => {
70
+ const data = doc.data();
71
+ alerts.push({
72
+ id: doc.id,
73
+ ...data,
74
+ createdAt: data.createdAt?.toDate?.() || data.createdAt,
75
+ readAt: data.readAt?.toDate?.() || data.readAt
76
+ });
77
+ });
78
+
79
+ return res.status(200).json({
80
+ success: true,
81
+ alerts: alerts,
82
+ count: alerts.length,
83
+ limit: Number(limit),
84
+ offset: Number(offset)
85
+ });
86
+ } catch (error) {
87
+ logger.log('ERROR', `[getUserAlerts] Error fetching alerts for user ${userCid}`, error);
88
+ return res.status(500).json({ error: error.message });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * GET /user/me/alerts/count
94
+ * Get unread alert count
95
+ */
96
+ async function getAlertCount(req, res, dependencies, config) {
97
+ const { db, logger } = dependencies;
98
+ const { userCid } = req.query;
99
+
100
+ if (!userCid) {
101
+ return res.status(400).json({ error: "Missing userCid" });
102
+ }
103
+
104
+ try {
105
+ // Get today's counter
106
+ const today = new Date().toISOString().split('T')[0];
107
+ const counterRef = db.collection('user_alerts')
108
+ .doc(String(userCid))
109
+ .collection('counters')
110
+ .doc(today);
111
+
112
+ const counterDoc = await counterRef.get();
113
+
114
+ if (counterDoc.exists) {
115
+ const data = counterDoc.data();
116
+ return res.status(200).json({
117
+ success: true,
118
+ unreadCount: data.unreadCount || 0,
119
+ totalCount: data.totalCount || 0,
120
+ byType: data.byType || {}
121
+ });
122
+ }
123
+
124
+ // If no counter exists, count unread alerts directly
125
+ const unreadSnapshot = await db.collection('user_alerts')
126
+ .doc(String(userCid))
127
+ .collection('alerts')
128
+ .where('read', '==', false)
129
+ .get();
130
+
131
+ return res.status(200).json({
132
+ success: true,
133
+ unreadCount: unreadSnapshot.size,
134
+ totalCount: unreadSnapshot.size,
135
+ byType: {}
136
+ });
137
+ } catch (error) {
138
+ logger.log('ERROR', `[getAlertCount] Error fetching alert count for user ${userCid}`, error);
139
+ return res.status(500).json({ error: error.message });
140
+ }
141
+ }
142
+
143
+ /**
144
+ * PUT /user/me/alerts/:alertId/read
145
+ * Mark alert as read
146
+ */
147
+ async function markAlertRead(req, res, dependencies, config) {
148
+ const { db, logger } = dependencies;
149
+ const { userCid } = req.query;
150
+ const { alertId } = req.params;
151
+
152
+ if (!userCid || !alertId) {
153
+ return res.status(400).json({ error: "Missing userCid or alertId" });
154
+ }
155
+
156
+ try {
157
+ const alertRef = db.collection('user_alerts')
158
+ .doc(String(userCid))
159
+ .collection('alerts')
160
+ .doc(alertId);
161
+
162
+ const alertDoc = await alertRef.get();
163
+
164
+ if (!alertDoc.exists) {
165
+ return res.status(404).json({ error: "Alert not found" });
166
+ }
167
+
168
+ const alertData = alertDoc.data();
169
+
170
+ // Only update if not already read
171
+ if (!alertData.read) {
172
+ await alertRef.update({
173
+ read: true,
174
+ readAt: FieldValue.serverTimestamp()
175
+ });
176
+
177
+ // Update counter
178
+ const today = new Date().toISOString().split('T')[0];
179
+ const counterRef = db.collection('user_alerts')
180
+ .doc(String(userCid))
181
+ .collection('counters')
182
+ .doc(today);
183
+
184
+ await counterRef.set({
185
+ unreadCount: FieldValue.increment(-1),
186
+ lastUpdated: FieldValue.serverTimestamp()
187
+ }, { merge: true });
188
+ }
189
+
190
+ return res.status(200).json({
191
+ success: true,
192
+ message: "Alert marked as read"
193
+ });
194
+ } catch (error) {
195
+ logger.log('ERROR', `[markAlertRead] Error marking alert ${alertId} as read`, error);
196
+ return res.status(500).json({ error: error.message });
197
+ }
198
+ }
199
+
200
+ /**
201
+ * PUT /user/me/alerts/read-all
202
+ * Mark all alerts as read
203
+ */
204
+ async function markAllAlertsRead(req, res, dependencies, config) {
205
+ const { db, logger } = dependencies;
206
+ const { userCid } = req.query;
207
+
208
+ if (!userCid) {
209
+ return res.status(400).json({ error: "Missing userCid" });
210
+ }
211
+
212
+ try {
213
+ const alertsRef = db.collection('user_alerts')
214
+ .doc(String(userCid))
215
+ .collection('alerts');
216
+
217
+ const unreadSnapshot = await alertsRef
218
+ .where('read', '==', false)
219
+ .get();
220
+
221
+ if (unreadSnapshot.empty) {
222
+ return res.status(200).json({
223
+ success: true,
224
+ message: "No unread alerts",
225
+ updated: 0
226
+ });
227
+ }
228
+
229
+ const batch = db.batch();
230
+ let count = 0;
231
+
232
+ unreadSnapshot.forEach(doc => {
233
+ batch.update(doc.ref, {
234
+ read: true,
235
+ readAt: FieldValue.serverTimestamp()
236
+ });
237
+ count++;
238
+ });
239
+
240
+ await batch.commit();
241
+
242
+ // Reset counter
243
+ const today = new Date().toISOString().split('T')[0];
244
+ const counterRef = db.collection('user_alerts')
245
+ .doc(String(userCid))
246
+ .collection('counters')
247
+ .doc(today);
248
+
249
+ await counterRef.set({
250
+ unreadCount: 0,
251
+ lastUpdated: FieldValue.serverTimestamp()
252
+ }, { merge: true });
253
+
254
+ return res.status(200).json({
255
+ success: true,
256
+ message: "All alerts marked as read",
257
+ updated: count
258
+ });
259
+ } catch (error) {
260
+ logger.log('ERROR', `[markAllAlertsRead] Error marking all alerts as read for user ${userCid}`, error);
261
+ return res.status(500).json({ error: error.message });
262
+ }
263
+ }
264
+
265
+ /**
266
+ * DELETE /user/me/alerts/:alertId
267
+ * Delete specific alert
268
+ */
269
+ async function deleteAlert(req, res, dependencies, config) {
270
+ const { db, logger } = dependencies;
271
+ const { userCid } = req.query;
272
+ const { alertId } = req.params;
273
+
274
+ if (!userCid || !alertId) {
275
+ return res.status(400).json({ error: "Missing userCid or alertId" });
276
+ }
277
+
278
+ try {
279
+ const alertRef = db.collection('user_alerts')
280
+ .doc(String(userCid))
281
+ .collection('alerts')
282
+ .doc(alertId);
283
+
284
+ const alertDoc = await alertRef.get();
285
+
286
+ if (!alertDoc.exists) {
287
+ return res.status(404).json({ error: "Alert not found" });
288
+ }
289
+
290
+ await alertRef.delete();
291
+
292
+ // Update counter if alert was unread
293
+ const alertData = alertDoc.data();
294
+ if (!alertData.read) {
295
+ const today = new Date().toISOString().split('T')[0];
296
+ const counterRef = db.collection('user_alerts')
297
+ .doc(String(userCid))
298
+ .collection('counters')
299
+ .doc(today);
300
+
301
+ await counterRef.set({
302
+ unreadCount: FieldValue.increment(-1),
303
+ totalCount: FieldValue.increment(-1),
304
+ lastUpdated: FieldValue.serverTimestamp()
305
+ }, { merge: true });
306
+ }
307
+
308
+ return res.status(200).json({
309
+ success: true,
310
+ message: "Alert deleted"
311
+ });
312
+ } catch (error) {
313
+ logger.log('ERROR', `[deleteAlert] Error deleting alert ${alertId}`, error);
314
+ return res.status(500).json({ error: error.message });
315
+ }
316
+ }
317
+
318
+ module.exports = {
319
+ getAlertTypes,
320
+ getUserAlerts,
321
+ getAlertCount,
322
+ markAlertRead,
323
+ markAllAlertsRead,
324
+ deleteAlert
325
+ };
326
+
@@ -9,6 +9,7 @@ const { initiateVerification, finalizeVerification } = require('./helpers/verifi
9
9
  const { getUserWatchlists, getWatchlist: getWatchlistById, createWatchlist, updateWatchlist: updateWatchlistById, deleteWatchlist, copyWatchlist, getPublicWatchlists } = require('./helpers/watchlist_helpers');
10
10
  const { subscribeToAlerts, updateSubscription, unsubscribeFromAlerts, getUserSubscriptions, subscribeToWatchlist } = require('./helpers/subscription_helpers');
11
11
  const { setDevOverride, getDevOverrideStatus } = require('./helpers/dev_helpers');
12
+ const { getAlertTypes, getUserAlerts, getAlertCount, markAlertRead, markAllAlertsRead, deleteAlert } = require('./helpers/alert_helpers');
12
13
 
13
14
  module.exports = (dependencies, config) => {
14
15
  const router = express.Router();
@@ -75,5 +76,13 @@ module.exports = (dependencies, config) => {
75
76
  router.post('/dev/override', (req, res) => setDevOverride(req, res, dependencies, config));
76
77
  router.get('/dev/override', (req, res) => getDevOverrideStatus(req, res, dependencies, config));
77
78
 
79
+ // --- Alert Management ---
80
+ router.get('/me/alert-types', (req, res) => getAlertTypes(req, res, dependencies, config));
81
+ router.get('/me/alerts', (req, res) => getUserAlerts(req, res, dependencies, config));
82
+ router.get('/me/alerts/count', (req, res) => getAlertCount(req, res, dependencies, config));
83
+ router.put('/me/alerts/:alertId/read', (req, res) => markAlertRead(req, res, dependencies, config));
84
+ router.put('/me/alerts/read-all', (req, res) => markAllAlertsRead(req, res, dependencies, config));
85
+ router.delete('/me/alerts/:alertId', (req, res) => deleteAlert(req, res, dependencies, config));
86
+
78
87
  return router;
79
88
  };
package/index.js CHANGED
@@ -56,6 +56,9 @@ const { runRootDataIndexer } = require('./functions
56
56
  // [NEW] Popular Investor Fetcher
57
57
  const { runPopularInvestorFetch } = require('./functions/fetch-popular-investors/index');
58
58
 
59
+ // Alert System
60
+ const { handleAlertTrigger, handleComputationResultWrite } = require('./functions/alert-system/index');
61
+
59
62
  // Proxy
60
63
  const { handlePost } = require('./functions/appscript-api/index');
61
64
 
@@ -122,6 +125,11 @@ const maintenance = {
122
125
 
123
126
  const proxy = { handlePost };
124
127
 
128
+ const alertSystem = {
129
+ handleAlertTrigger,
130
+ handleComputationResultWrite
131
+ };
132
+
125
133
  module.exports = {
126
- pipe: { core, orchestrator, dispatcher, taskEngine, computationSystem, api, maintenance, proxy },
134
+ pipe: { core, orchestrator, dispatcher, taskEngine, computationSystem, api, maintenance, proxy, alertSystem },
127
135
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.428",
3
+ "version": "1.0.429",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [