bulltrackers-module 1.0.795 → 1.0.797
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-v3/adapters/MembershipRepo.js +27 -0
- package/functions/alert-system-v3/adapters/NotificationBus.js +34 -0
- package/functions/alert-system-v3/adapters/UserConfigRepo.js +104 -0
- package/functions/alert-system-v3/config/defaults.js +14 -0
- package/functions/alert-system-v3/core/AlertContext.js +34 -0
- package/functions/alert-system-v3/core/AlertLogic.js +39 -0
- package/functions/alert-system-v3/core/ComputationResults.js +99 -0
- package/functions/alert-system-v3/handlers/history.js +40 -0
- package/functions/alert-system-v3/handlers/ingest.js +35 -0
- package/functions/alert-system-v3/handlers/notifier.js +53 -0
- package/functions/alert-system-v3/handlers/router.js +118 -0
- package/functions/alert-system-v3/index.js +13 -0
- package/functions/alert-system-v3/tests/e2e/AlertSystemE2E.test.js +190 -0
- package/functions/alert-system-v3/tests/e2e/seed_emulator.js +51 -0
- package/functions/alert-system-v3/tests/unit/AlertLogic.test.js +59 -0
- package/functions/computation-system-v3/computations/index.js +31 -33
- package/functions/core/utils/fcm_utils.js +4 -2
- package/index.js +10 -4
- package/package.json +3 -4
- package/functions/alert-system/helpers/alert_helpers.js +0 -726
- package/functions/alert-system/helpers/alert_manifest_loader.js +0 -157
- package/functions/alert-system/helpers/dynamic_evaluator.js +0 -279
- package/functions/alert-system/index.js +0 -441
- package/functions/alert-system/tests/stage1-alert-manifest.test.js +0 -94
- package/functions/alert-system/tests/stage2-alert-metadata.test.js +0 -93
- package/functions/alert-system/tests/stage3-alert-handler.test.js +0 -79
- package/functions/computation-system-v3/computations/RecipeAudit.report.md +0 -38
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class MembershipRepo {
|
|
4
|
+
constructor(dependencies, config) {
|
|
5
|
+
this.db = dependencies.db;
|
|
6
|
+
this.logger = dependencies.logger;
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async getUserCidsForPi(date, piCid) {
|
|
11
|
+
if (!date || piCid == null) return [];
|
|
12
|
+
const col = this.config.collections.watchlistMembership;
|
|
13
|
+
try {
|
|
14
|
+
const doc = await this.db.collection(col).doc(date).get();
|
|
15
|
+
if (!doc.exists) return [];
|
|
16
|
+
const data = doc.data();
|
|
17
|
+
const entry = data[String(piCid)] || data[Number(piCid)];
|
|
18
|
+
if (!entry || !Array.isArray(entry.users)) return [];
|
|
19
|
+
return entry.users.map(cid => String(cid));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
this.logger?.log('WARN', `[MembershipRepo] Failed to read membership for ${piCid} on ${date}: ${error.message}`);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { MembershipRepo };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PubSubUtils } = require('../../core/utils/pubsub_utils');
|
|
4
|
+
|
|
5
|
+
class NotificationBus {
|
|
6
|
+
constructor(dependencies, config) {
|
|
7
|
+
this.pubsubUtils = dependencies.pubsubUtils || new PubSubUtils(dependencies);
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async publishRawEvent(payload) {
|
|
12
|
+
return this.pubsubUtils.publish(this.config.topics.raw, payload);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async publishNotifications(notifications) {
|
|
16
|
+
return this.pubsubUtils.batchPublishTasks({
|
|
17
|
+
topicName: this.config.topics.notify,
|
|
18
|
+
tasks: notifications,
|
|
19
|
+
taskType: 'alert-notifications',
|
|
20
|
+
maxPubsubBatchSize: 200
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async publishHistoryEvents(events) {
|
|
25
|
+
return this.pubsubUtils.batchPublishTasks({
|
|
26
|
+
topicName: this.config.topics.history,
|
|
27
|
+
tasks: events,
|
|
28
|
+
taskType: 'alert-history',
|
|
29
|
+
maxPubsubBatchSize: 200
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { NotificationBus };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isDeveloper, manageNotificationPreferences, fetchPopularInvestorMasterList } = require('../../api-v2/helpers/data-fetchers/firestore');
|
|
4
|
+
|
|
5
|
+
class UserConfigRepo {
|
|
6
|
+
constructor(dependencies, config) {
|
|
7
|
+
this.db = dependencies.db;
|
|
8
|
+
this.logger = dependencies.logger;
|
|
9
|
+
this.config = config;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async getSubscriptionsForPi({ piCid, userCids, alertType }) {
|
|
13
|
+
const subscriptions = [];
|
|
14
|
+
if (!piCid || !Array.isArray(userCids) || userCids.length === 0) return subscriptions;
|
|
15
|
+
|
|
16
|
+
const piUsername = await this._resolvePiUsername(piCid);
|
|
17
|
+
|
|
18
|
+
for (const userCidStr of userCids) {
|
|
19
|
+
const userCid = Number(userCidStr);
|
|
20
|
+
const watchlistsSnap = await this.db
|
|
21
|
+
.collection(this.config.collections.signedInUsers)
|
|
22
|
+
.doc(String(userCid))
|
|
23
|
+
.collection('watchlists')
|
|
24
|
+
.get();
|
|
25
|
+
|
|
26
|
+
if (watchlistsSnap.empty) continue;
|
|
27
|
+
|
|
28
|
+
for (const doc of watchlistsSnap.docs) {
|
|
29
|
+
const watchlist = { id: doc.id, ...doc.data() };
|
|
30
|
+
if (watchlist.type !== 'static' || !Array.isArray(watchlist.items)) continue;
|
|
31
|
+
|
|
32
|
+
for (const item of watchlist.items) {
|
|
33
|
+
if (Number(item.cid) !== Number(piCid)) continue;
|
|
34
|
+
|
|
35
|
+
if (alertType && alertType.isTest) {
|
|
36
|
+
const prefs = await this._getPrefs(userCid);
|
|
37
|
+
if (!prefs || prefs.testAlerts !== true) continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
subscriptions.push({
|
|
41
|
+
userCid,
|
|
42
|
+
piCid: Number(piCid),
|
|
43
|
+
piUsername: item.username || piUsername,
|
|
44
|
+
watchlistId: watchlist.id,
|
|
45
|
+
watchlistName: watchlist.name || 'Unnamed Watchlist',
|
|
46
|
+
alertConfig: item.alertConfig || {},
|
|
47
|
+
useDynamic: item.useDynamic || {},
|
|
48
|
+
dynamicConfig: item.dynamicConfig || {}
|
|
49
|
+
});
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await this._appendDevOverrides(subscriptions, piCid, piUsername, alertType);
|
|
56
|
+
return subscriptions;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async _getPrefs(userCid) {
|
|
60
|
+
try {
|
|
61
|
+
return await manageNotificationPreferences(this.db, userCid, 'get');
|
|
62
|
+
} catch (e) {
|
|
63
|
+
this.logger?.log('WARN', `[UserConfigRepo] Prefs read failed for ${userCid}: ${e.message}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async _resolvePiUsername(piCid) {
|
|
69
|
+
try {
|
|
70
|
+
const data = await fetchPopularInvestorMasterList(this.db, String(piCid));
|
|
71
|
+
if (data && data.username) return data.username;
|
|
72
|
+
} catch (e) {}
|
|
73
|
+
return `PI-${piCid}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _appendDevOverrides(subscriptions, piCid, piUsername, alertType) {
|
|
77
|
+
try {
|
|
78
|
+
const devOverridesSnap = await this.db.collection(this.config.collections.devOverrides).get();
|
|
79
|
+
for (const doc of devOverridesSnap.docs) {
|
|
80
|
+
const userCid = Number(doc.id);
|
|
81
|
+
const isDev = await isDeveloper(this.db, String(userCid));
|
|
82
|
+
if (!isDev) continue;
|
|
83
|
+
|
|
84
|
+
const data = doc.data();
|
|
85
|
+
if (data.enabled !== true || data.pretendSubscribedToAllAlerts !== true) continue;
|
|
86
|
+
|
|
87
|
+
subscriptions.push({
|
|
88
|
+
userCid,
|
|
89
|
+
piCid: Number(piCid),
|
|
90
|
+
piUsername,
|
|
91
|
+
watchlistId: 'dev-override-all-alerts',
|
|
92
|
+
watchlistName: 'Dev Override - All Alerts',
|
|
93
|
+
alertConfig: { [alertType.configKey]: true },
|
|
94
|
+
useDynamic: {},
|
|
95
|
+
dynamicConfig: {}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
this.logger?.log('WARN', `[UserConfigRepo] Dev override check failed: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { UserConfigRepo };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
topics: {
|
|
5
|
+
raw: process.env.ALERT_RAW_TOPIC || 'alert-raw-topic',
|
|
6
|
+
notify: process.env.ALERT_NOTIFY_TOPIC || 'alert-notify-topic',
|
|
7
|
+
history: process.env.ALERT_HISTORY_TOPIC || 'alert-history-topic'
|
|
8
|
+
},
|
|
9
|
+
collections: {
|
|
10
|
+
watchlistMembership: 'WatchlistMembershipData',
|
|
11
|
+
signedInUsers: 'SignedInUsers',
|
|
12
|
+
devOverrides: 'dev_overrides'
|
|
13
|
+
}
|
|
14
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PubSubUtils } = require('../../core/utils/pubsub_utils');
|
|
4
|
+
const { MembershipRepo } = require('../adapters/MembershipRepo');
|
|
5
|
+
const { UserConfigRepo } = require('../adapters/UserConfigRepo');
|
|
6
|
+
const { NotificationBus } = require('../adapters/NotificationBus');
|
|
7
|
+
const defaults = require('../config/defaults');
|
|
8
|
+
|
|
9
|
+
function createAlertContext(dependencies, overrideConfig = {}) {
|
|
10
|
+
const config = {
|
|
11
|
+
...defaults,
|
|
12
|
+
...overrideConfig,
|
|
13
|
+
topics: { ...defaults.topics, ...(overrideConfig.topics || {}) },
|
|
14
|
+
collections: { ...defaults.collections, ...(overrideConfig.collections || {}) }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const pubsubUtils = new PubSubUtils(dependencies);
|
|
18
|
+
const membershipRepo = new MembershipRepo(dependencies, config);
|
|
19
|
+
const userConfigRepo = new UserConfigRepo(dependencies, config);
|
|
20
|
+
const notificationBus = new NotificationBus({ ...dependencies, pubsubUtils }, config);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...dependencies,
|
|
24
|
+
config,
|
|
25
|
+
pubsubUtils,
|
|
26
|
+
membershipRepo,
|
|
27
|
+
userConfigRepo,
|
|
28
|
+
notificationBus
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
createAlertContext
|
|
34
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { evaluateDynamicConditions } = require('../../alert-system/helpers/dynamic_evaluator');
|
|
4
|
+
|
|
5
|
+
function evaluateAlertForSubscription({ alertType, piResult, subscription, isDeveloper, logger }) {
|
|
6
|
+
if (!alertType || !subscription) {
|
|
7
|
+
return { shouldNotify: false, reason: 'missing-input', mode: 'invalid' };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const configKey = alertType.configKey;
|
|
11
|
+
const isEnabled = !!(subscription.alertConfig && configKey && subscription.alertConfig[configKey] === true);
|
|
12
|
+
if (!isEnabled) {
|
|
13
|
+
return { shouldNotify: false, reason: 'disabled', mode: 'static' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const useDynamic = !!(subscription.useDynamic && configKey && subscription.useDynamic[configKey] === true);
|
|
17
|
+
const dynamicConfig = subscription.dynamicConfig && configKey ? subscription.dynamicConfig[configKey] : null;
|
|
18
|
+
|
|
19
|
+
const evaluation = evaluateDynamicConditions(
|
|
20
|
+
alertType,
|
|
21
|
+
piResult || {},
|
|
22
|
+
dynamicConfig || {},
|
|
23
|
+
useDynamic,
|
|
24
|
+
!!isDeveloper,
|
|
25
|
+
logger
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
shouldNotify: evaluation.passes === true,
|
|
30
|
+
reason: evaluation.reason,
|
|
31
|
+
mode: evaluation.mode || (useDynamic ? 'dynamic' : 'static'),
|
|
32
|
+
effectiveConfig: evaluation.effectiveConfig || null,
|
|
33
|
+
developerBypass: evaluation.developerBypass === true
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
evaluateAlertForSubscription
|
|
39
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const zlib = require('zlib');
|
|
4
|
+
const { Storage } = require('@google-cloud/storage');
|
|
5
|
+
|
|
6
|
+
const storage = new Storage();
|
|
7
|
+
|
|
8
|
+
function tryDecompress(data) {
|
|
9
|
+
if (data && data._compressed === true && data.payload) {
|
|
10
|
+
try {
|
|
11
|
+
let buffer;
|
|
12
|
+
if (Buffer.isBuffer(data.payload)) {
|
|
13
|
+
buffer = data.payload;
|
|
14
|
+
} else if (typeof data.payload === 'string') {
|
|
15
|
+
buffer = Buffer.from(data.payload, 'base64');
|
|
16
|
+
} else {
|
|
17
|
+
buffer = Buffer.from(data.payload);
|
|
18
|
+
}
|
|
19
|
+
const decompressed = zlib.gunzipSync(buffer);
|
|
20
|
+
const jsonString = decompressed.toString('utf8');
|
|
21
|
+
const parsed = JSON.parse(jsonString);
|
|
22
|
+
return typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readComputationResults(docData) {
|
|
31
|
+
const decompressed = tryDecompress(docData);
|
|
32
|
+
if (decompressed && typeof decompressed === 'object') {
|
|
33
|
+
if (Array.isArray(decompressed.cids)) {
|
|
34
|
+
const perUserData = {};
|
|
35
|
+
for (const key of Object.keys(decompressed)) {
|
|
36
|
+
if (key !== 'cids' && key !== 'metadata' && /^\d+$/.test(key)) {
|
|
37
|
+
perUserData[Number(key)] = decompressed[key];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
cids: decompressed.cids,
|
|
42
|
+
perUserData,
|
|
43
|
+
metadata: decompressed.metadata || {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const cids = Object.keys(decompressed)
|
|
47
|
+
.filter(k => /^\d+$/.test(k))
|
|
48
|
+
.map(k => Number(k));
|
|
49
|
+
return {
|
|
50
|
+
cids,
|
|
51
|
+
perUserData: cids.reduce((acc, cid) => {
|
|
52
|
+
const val = decompressed[String(cid)];
|
|
53
|
+
if (val) acc[cid] = val;
|
|
54
|
+
return acc;
|
|
55
|
+
}, {}),
|
|
56
|
+
metadata: decompressed.metadata || {}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { cids: [], perUserData: {}, metadata: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readComputationResultsWithShards(db, docData, docRef, logger) {
|
|
63
|
+
try {
|
|
64
|
+
if (docData.gcsUri || (docData._gcs && docData.gcsBucket && docData.gcsPath)) {
|
|
65
|
+
const bucketName = docData.gcsBucket || docData.gcsUri.split('/')[2];
|
|
66
|
+
const fileName = docData.gcsPath || docData.gcsUri.split('/').slice(3).join('/');
|
|
67
|
+
const [fileContent] = await storage.bucket(bucketName).file(fileName).download();
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(zlib.gunzipSync(fileContent).toString('utf8'));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
parsed = JSON.parse(fileContent.toString('utf8'));
|
|
73
|
+
}
|
|
74
|
+
return readComputationResults(parsed);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (docData._sharded === true && docData._shardCount) {
|
|
78
|
+
const shardsCol = docRef.collection('_shards');
|
|
79
|
+
const shardsSnapshot = await shardsCol.get();
|
|
80
|
+
if (!shardsSnapshot.empty) {
|
|
81
|
+
const merged = {};
|
|
82
|
+
for (const shardDoc of shardsSnapshot.docs) {
|
|
83
|
+
const shardData = tryDecompress(shardDoc.data());
|
|
84
|
+
Object.assign(merged, shardData);
|
|
85
|
+
}
|
|
86
|
+
return readComputationResults(merged);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return readComputationResults(docData);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger?.log('ERROR', `[AlertSystemV3] Failed reading results: ${error.message}`);
|
|
93
|
+
return { cids: [], perUserData: {}, metadata: {} };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
readComputationResultsWithShards
|
|
99
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createAlertContext } = require('../core/AlertContext');
|
|
4
|
+
// CHANGE 1: Import the whole module object
|
|
5
|
+
const bqUtils = require('../../core/utils/bigquery_utils');
|
|
6
|
+
|
|
7
|
+
async function handleAlertHistory(message, context, config, dependencies) {
|
|
8
|
+
const ctx = createAlertContext(dependencies, config);
|
|
9
|
+
const { logger } = ctx;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const payload = JSON.parse(Buffer.from(message.data, 'base64').toString());
|
|
13
|
+
const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
|
|
14
|
+
|
|
15
|
+
// CHANGE 2: Call functions off the object
|
|
16
|
+
await bqUtils.ensurePIAlertHistoryTable(logger);
|
|
17
|
+
|
|
18
|
+
const row = {
|
|
19
|
+
date: payload.date,
|
|
20
|
+
pi_id: Number(payload.piId),
|
|
21
|
+
alert_type: payload.alertType,
|
|
22
|
+
triggered: true,
|
|
23
|
+
trigger_count: payload.triggerCount || 0,
|
|
24
|
+
triggered_for: payload.triggeredFor || [],
|
|
25
|
+
metadata: payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {},
|
|
26
|
+
last_triggered: payload.lastTriggered || new Date().toISOString(),
|
|
27
|
+
last_updated: payload.lastUpdated || new Date().toISOString()
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// CHANGE 3: Call functions off the object
|
|
31
|
+
await bqUtils.insertRowsWithMerge(datasetId, 'pi_alert_history', [row], ['date', 'pi_id', 'alert_type'], logger);
|
|
32
|
+
|
|
33
|
+
logger?.log('INFO', `[AlertV3/History] Synced ${payload.alertType} for PI ${payload.piId}`);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
logger?.log('ERROR', `[AlertV3/History] Failed: ${error.message}`);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { handleAlertHistory };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createAlertContext } = require('../core/AlertContext');
|
|
4
|
+
const { loadAlertTypesFromManifest, isAlertComputation } = require('../../alert-system/helpers/alert_manifest_loader');
|
|
5
|
+
|
|
6
|
+
let cachedAlertTypes = null;
|
|
7
|
+
|
|
8
|
+
async function handleComputationResultWrite(change, context, config, dependencies) {
|
|
9
|
+
const ctx = createAlertContext(dependencies, config);
|
|
10
|
+
const { logger } = ctx;
|
|
11
|
+
const { date, computationName, entityId } = context.params || {};
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (!change.after.exists) return;
|
|
15
|
+
if (!cachedAlertTypes) cachedAlertTypes = await loadAlertTypesFromManifest(logger);
|
|
16
|
+
|
|
17
|
+
if (!isAlertComputation(cachedAlertTypes, computationName)) return;
|
|
18
|
+
|
|
19
|
+
const payload = {
|
|
20
|
+
date,
|
|
21
|
+
computationName,
|
|
22
|
+
entityId: entityId || null,
|
|
23
|
+
documentPath: change.after.ref.path,
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
traceId: context.eventId
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
await ctx.notificationBus.publishRawEvent(payload);
|
|
29
|
+
logger?.log('INFO', `[AlertV3/Ingest] Queued ${computationName} for ${date}`);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
logger?.log('ERROR', `[AlertV3/Ingest] Failed: ${error.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { handleComputationResultWrite };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createAlertContext } = require('../core/AlertContext');
|
|
4
|
+
const { FieldValue } = require('@google-cloud/firestore');
|
|
5
|
+
// CHANGE 1: Import the whole object to allow Stubbing
|
|
6
|
+
const fcmUtils = require('../../core/utils/fcm_utils');
|
|
7
|
+
|
|
8
|
+
async function handleAlertNotification(message, context, config, dependencies) {
|
|
9
|
+
const ctx = createAlertContext(dependencies, config);
|
|
10
|
+
const { db, logger } = ctx;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const payload = JSON.parse(Buffer.from(message.data, 'base64').toString());
|
|
14
|
+
|
|
15
|
+
const alertId = `alert_${payload.computationDate}_${payload.alertTypeId}_${payload.piCid}_${payload.userCid}_${payload.watchlistId}`;
|
|
16
|
+
|
|
17
|
+
const alertData = {
|
|
18
|
+
alertId,
|
|
19
|
+
piCid: Number(payload.piCid),
|
|
20
|
+
piUsername: payload.piUsername || `PI-${payload.piCid}`,
|
|
21
|
+
alertType: payload.alertTypeId,
|
|
22
|
+
alertTypeName: payload.alertTypeName,
|
|
23
|
+
message: payload.message,
|
|
24
|
+
severity: payload.severity || 'medium',
|
|
25
|
+
watchlistId: payload.watchlistId,
|
|
26
|
+
watchlistName: payload.watchlistName,
|
|
27
|
+
read: false,
|
|
28
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
29
|
+
computationDate: payload.computationDate,
|
|
30
|
+
computationName: payload.computationName,
|
|
31
|
+
...(payload.metadata || {})
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 1. Write to Inbox (Firestore)
|
|
35
|
+
await db.collection(ctx.config.collections.signedInUsers)
|
|
36
|
+
.doc(String(payload.userCid))
|
|
37
|
+
.collection('alerts')
|
|
38
|
+
.doc(alertId)
|
|
39
|
+
.set(alertData);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// CHANGE 2: Call the function off the object
|
|
43
|
+
await fcmUtils.sendAlertPushNotification(db, payload.userCid, alertData, logger);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
logger?.log('WARN', `[AlertV3/Notifier] FCM failed for ${payload.userCid}: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger?.log('ERROR', `[AlertV3/Notifier] Failed: ${error.message}`);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { handleAlertNotification };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createAlertContext } = require('../core/AlertContext');
|
|
4
|
+
// Use object references to allow Sinon stubbing
|
|
5
|
+
const computationResults = require('../core/ComputationResults');
|
|
6
|
+
const alertLogic = require('../core/AlertLogic');
|
|
7
|
+
// Import legacy helpers as objects
|
|
8
|
+
const manifestLoader = require('../../alert-system/helpers/alert_manifest_loader');
|
|
9
|
+
|
|
10
|
+
let cachedAlertTypes = null;
|
|
11
|
+
|
|
12
|
+
async function handleRawAlert(message, context, config, dependencies) {
|
|
13
|
+
const ctx = createAlertContext(dependencies, config);
|
|
14
|
+
const { db, logger } = ctx;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const payload = JSON.parse(Buffer.from(message.data, 'base64').toString());
|
|
18
|
+
const { date, computationName, documentPath } = payload;
|
|
19
|
+
|
|
20
|
+
// Call through the object reference
|
|
21
|
+
if (!cachedAlertTypes) {
|
|
22
|
+
cachedAlertTypes = await manifestLoader.loadAlertTypesFromManifest(logger);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const alertType = manifestLoader.getAlertTypeByComputation(cachedAlertTypes, computationName);
|
|
26
|
+
|
|
27
|
+
if (!alertType) {
|
|
28
|
+
logger?.log('WARN', `[AlertV3/Router] Unknown computation ${computationName}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const docRef = db.doc(documentPath);
|
|
33
|
+
const snapshot = await docRef.get();
|
|
34
|
+
if (!snapshot.exists) return;
|
|
35
|
+
|
|
36
|
+
// Reference the computationResults object
|
|
37
|
+
const results = await computationResults.readComputationResultsWithShards(db, snapshot.data(), docRef, logger);
|
|
38
|
+
const cids = Array.isArray(results.cids) ? results.cids : [];
|
|
39
|
+
|
|
40
|
+
const notifications = [];
|
|
41
|
+
const historyEvents = [];
|
|
42
|
+
|
|
43
|
+
for (const piCid of cids) {
|
|
44
|
+
const piResult = (results.perUserData && results.perUserData[piCid]) || results.metadata || {};
|
|
45
|
+
|
|
46
|
+
const userCids = await ctx.membershipRepo.getUserCidsForPi(date, piCid);
|
|
47
|
+
if (!userCids.length) continue;
|
|
48
|
+
|
|
49
|
+
const subscriptions = await ctx.userConfigRepo.getSubscriptionsForPi({ piCid, userCids, alertType });
|
|
50
|
+
const triggeredFor = [];
|
|
51
|
+
|
|
52
|
+
for (const subscription of subscriptions) {
|
|
53
|
+
const isDeveloper = false;
|
|
54
|
+
// Call through the alertLogic object reference
|
|
55
|
+
const evaluation = alertLogic.evaluateAlertForSubscription({
|
|
56
|
+
alertType,
|
|
57
|
+
piResult,
|
|
58
|
+
subscription,
|
|
59
|
+
isDeveloper,
|
|
60
|
+
logger
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!evaluation.shouldNotify) continue;
|
|
64
|
+
|
|
65
|
+
const piUsername = subscription.piUsername || piResult.username || `PI-${piCid}`;
|
|
66
|
+
const messageText = manifestLoader.generateAlertMessage(alertType, piUsername, piResult);
|
|
67
|
+
|
|
68
|
+
notifications.push({
|
|
69
|
+
userCid: subscription.userCid,
|
|
70
|
+
piCid: Number(piCid),
|
|
71
|
+
piUsername,
|
|
72
|
+
alertTypeId: alertType.id,
|
|
73
|
+
alertTypeName: alertType.name,
|
|
74
|
+
computationName: alertType.computationName,
|
|
75
|
+
computationDate: date,
|
|
76
|
+
severity: alertType.severity || 'medium',
|
|
77
|
+
watchlistId: subscription.watchlistId,
|
|
78
|
+
watchlistName: subscription.watchlistName,
|
|
79
|
+
message: messageText,
|
|
80
|
+
metadata: piResult,
|
|
81
|
+
evaluation
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
triggeredFor.push(String(subscription.userCid));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (triggeredFor.length) {
|
|
88
|
+
historyEvents.push({
|
|
89
|
+
date,
|
|
90
|
+
piId: Number(piCid),
|
|
91
|
+
alertType: alertType.computationName || alertType.id,
|
|
92
|
+
computationName: alertType.computationName,
|
|
93
|
+
triggered: true,
|
|
94
|
+
triggerCount: triggeredFor.length,
|
|
95
|
+
triggeredFor,
|
|
96
|
+
metadata: piResult,
|
|
97
|
+
lastTriggered: new Date().toISOString(),
|
|
98
|
+
lastUpdated: new Date().toISOString()
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (notifications.length) {
|
|
104
|
+
await ctx.notificationBus.publishNotifications(notifications);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (historyEvents.length) {
|
|
108
|
+
await ctx.notificationBus.publishHistoryEvents(historyEvents);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
logger?.log('INFO', `[AlertV3/Router] Notifications: ${notifications.length}, History: ${historyEvents.length}`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger?.log('ERROR', `[AlertV3/Router] Failed: ${error.message}`);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { handleRawAlert };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { handleComputationResultWrite } = require('./handlers/ingest');
|
|
4
|
+
const { handleRawAlert } = require('./handlers/router');
|
|
5
|
+
const { handleAlertNotification } = require('./handlers/notifier');
|
|
6
|
+
const { handleAlertHistory } = require('./handlers/history');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
handleComputationResultWrite,
|
|
10
|
+
handleRawAlert,
|
|
11
|
+
handleAlertNotification,
|
|
12
|
+
handleAlertHistory
|
|
13
|
+
};
|