bulltrackers-module 1.0.913 → 1.0.915

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.
@@ -39,6 +39,7 @@ function createIdentityMiddleware() {
39
39
  if (/^\/alerts\b/.test(p)) return true;
40
40
  // User settings/profile
41
41
  if (/^\/profile\b/.test(p)) return true;
42
+ if (/^\/dashboard\b/.test(p)) return true;
42
43
  if (/^\/settings\b/.test(p)) return true;
43
44
  return false;
44
45
  };
@@ -0,0 +1,276 @@
1
+ /**
2
+ * @fileoverview Dashboard Routes
3
+ * Serves dashboard page data: global computation result (DashboardPage) plus
4
+ * signed-in user's alerts from Firestore. Frontend uses toggle for "my alerts" vs "all" (recentAlertEvents).
5
+ * Also handles on-demand sync to re-run stale computations (> 2 days old).
6
+ */
7
+
8
+ const { createRoute, createRouter, z } = require('../core/route-factory');
9
+ const { requireTargetUser } = require('../middleware/identity');
10
+
11
+ const today = () => new Date().toISOString().split('T')[0];
12
+ const toDayString = (d) => d.toISOString().split('T')[0];
13
+ const DASHBOARD_COMPUTATION_NAME = 'dashboardpage';
14
+ const DEFAULT_LOOKBACK_DAYS = 7;
15
+ const USER_ALERTS_LIMIT = 100;
16
+ const STALE_DAYS_THRESHOLD = 2;
17
+ const SYNC_JOB_TIMEOUT_MINS = 30;
18
+
19
+ const calculateDaysOld = (computationDate) => {
20
+ if (!computationDate) return null;
21
+ const compDate = new Date(`${computationDate}T00:00:00Z`);
22
+ const todayDate = new Date();
23
+ // Normalize today to UTC midnight
24
+ const todayUTC = new Date(Date.UTC(todayDate.getUTCFullYear(), todayDate.getUTCMonth(), todayDate.getUTCDate()));
25
+ const diffMs = todayUTC.getTime() - compDate.getTime();
26
+ const daysOld = Math.floor(diffMs / (24 * 60 * 60 * 1000));
27
+ return Math.max(0, daysOld);
28
+ };
29
+
30
+ const getSyncJobPath = (userId, jobId) => {
31
+ return `SyncJobs/${userId}/dashboardpage/${jobId}`;
32
+ };
33
+
34
+
35
+ const emptyDashboardData = () => ({
36
+ topPIRiskChange7d: [],
37
+ mostPurchasedAssets: [],
38
+ mostSoldAssets: [],
39
+ mostOwnedAssetsWithAum: [],
40
+ totalAum: 0,
41
+ recentAlertEvents: []
42
+ });
43
+
44
+ const routes = [
45
+ createRoute({
46
+ method: 'GET',
47
+ path: '/',
48
+ summary: 'Get dashboard data for the signed-in user',
49
+ tags: ['Dashboard'],
50
+ middleware: [requireTargetUser],
51
+ validation: {
52
+ query: z.object({
53
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().default(today),
54
+ lookback: z.union([z.string().regex(/^\d+$/).transform(Number), z.number()])
55
+ .pipe(z.number().min(1).max(30)).optional().default(DEFAULT_LOOKBACK_DAYS)
56
+ }),
57
+ context: z.object({
58
+ targetUserId: z.string().describe('The signed-in user ID')
59
+ })
60
+ },
61
+ handler: async (req, res, next) => {
62
+ try {
63
+ const { computationReader, userDataService } = req.services;
64
+ const { date, lookback } = req.validated.query;
65
+ const { targetUserId } = req.validated.context;
66
+
67
+ const [lookbackResult, userAlerts] = await Promise.all([
68
+ computationReader.getResultWithLookback(DASHBOARD_COMPUTATION_NAME, date, null, lookback),
69
+ userDataService.getUserAlerts(targetUserId, { limit: USER_ALERTS_LIMIT })
70
+ ]);
71
+
72
+ const computationDate = lookbackResult?.date || null;
73
+ const daysOld = calculateDaysOld(computationDate);
74
+ const canSync = daysOld !== null && daysOld >= STALE_DAYS_THRESHOLD;
75
+
76
+ const resultPayload = lookbackResult?.data?.result ?? lookbackResult?.data ?? null;
77
+ const data = resultPayload && typeof resultPayload === 'object'
78
+ ? {
79
+ topPIRiskChange7d: resultPayload.topPIRiskChange7d ?? [],
80
+ mostPurchasedAssets: resultPayload.mostPurchasedAssets ?? [],
81
+ mostSoldAssets: resultPayload.mostSoldAssets ?? [],
82
+ mostOwnedAssetsWithAum: resultPayload.mostOwnedAssetsWithAum ?? [],
83
+ totalAum: resultPayload.totalAum ?? 0,
84
+ recentAlertEvents: resultPayload.recentAlertEvents ?? []
85
+ }
86
+ : emptyDashboardData();
87
+
88
+ const userAlertCount = userAlerts.filter(a => a.read !== true).length;
89
+
90
+ res.json({
91
+ success: true,
92
+ computationDate,
93
+ daysOld,
94
+ canSync,
95
+ data,
96
+ userAlerts,
97
+ userAlertCount
98
+ });
99
+ } catch (error) {
100
+ next(error);
101
+ }
102
+ }
103
+ }),
104
+ // POST /sync - Request on-demand sync for stale computation
105
+ createRoute({
106
+ method: 'POST',
107
+ path: '/sync',
108
+ summary: 'Request on-demand sync for dashboard computation if data is stale (> 2 days)',
109
+ tags: ['Dashboard'],
110
+ middleware: [requireTargetUser],
111
+ validation: {
112
+ context: z.object({
113
+ targetUserId: z.string().describe('The signed-in user ID')
114
+ })
115
+ },
116
+ handler: async (req, res, next) => {
117
+ try {
118
+ const { computationReader, computationSystemClient, db } = req.services;
119
+ const { targetUserId } = req.validated.context;
120
+
121
+ // Check current computation date
122
+ const lookbackResult = await computationReader.getResultWithLookback(
123
+ DASHBOARD_COMPUTATION_NAME,
124
+ today(),
125
+ null,
126
+ DEFAULT_LOOKBACK_DAYS
127
+ );
128
+
129
+ const computationDate = lookbackResult?.date || null;
130
+ const daysOld = calculateDaysOld(computationDate);
131
+
132
+ // Check if stale
133
+ if (daysOld === null || daysOld < STALE_DAYS_THRESHOLD) {
134
+ return res.status(400).json({
135
+ success: false,
136
+ error: 'Data is not stale enough for sync',
137
+ daysOld,
138
+ staleDaysRequired: STALE_DAYS_THRESHOLD,
139
+ computationDate
140
+ });
141
+ }
142
+
143
+ // Enqueue on-demand computation request
144
+ const targetDate = today();
145
+ const jobId = `sync-dashboardpage-${targetUserId}-${Date.now()}`;
146
+
147
+ const triggerResult = await computationSystemClient.triggerOnDemandRequest({
148
+ computation: DASHBOARD_COMPUTATION_NAME,
149
+ targetDate,
150
+ entityIds: ['_global'],
151
+ allowToday: true,
152
+ requestKind: 'user_sync_request',
153
+ metadata: {
154
+ userId: targetUserId,
155
+ syncJobId: jobId,
156
+ userType: 'SIGNED_IN_USER'
157
+ }
158
+ });
159
+
160
+ if (!triggerResult.success) {
161
+ return res.status(500).json({
162
+ success: false,
163
+ error: 'Failed to enqueue computation task',
164
+ detail: triggerResult.error
165
+ });
166
+ }
167
+
168
+ // Write job status to Firestore
169
+ const jobRef = db.doc(getSyncJobPath(targetUserId, jobId));
170
+ await jobRef.set({
171
+ status: 'queued',
172
+ createdAt: new Date(),
173
+ computationName: DASHBOARD_COMPUTATION_NAME,
174
+ targetDate,
175
+ daysOld,
176
+ userId: targetUserId
177
+ });
178
+
179
+ res.json({
180
+ success: true,
181
+ jobId,
182
+ status: 'queued',
183
+ eta: '5-10 minutes',
184
+ computationDate
185
+ });
186
+ } catch (error) {
187
+ next(error);
188
+ }
189
+ }
190
+ }),
191
+ // GET /sync/status/:jobId - Poll sync job status
192
+ createRoute({
193
+ method: 'GET',
194
+ path: '/sync/status/:jobId',
195
+ summary: 'Check status of a dashboard sync job',
196
+ tags: ['Dashboard'],
197
+ middleware: [requireTargetUser],
198
+ validation: {
199
+ params: z.object({
200
+ jobId: z.string().describe('The sync job ID')
201
+ }),
202
+ context: z.object({
203
+ targetUserId: z.string().describe('The signed-in user ID')
204
+ })
205
+ },
206
+ handler: async (req, res, next) => {
207
+ try {
208
+ const { computationReader, db } = req.services;
209
+ const { jobId } = req.validated.params;
210
+ const { targetUserId } = req.validated.context;
211
+
212
+ // Read job status from Firestore
213
+ const jobRef = db.doc(getSyncJobPath(targetUserId, jobId));
214
+ const jobDoc = await jobRef.get();
215
+
216
+ if (!jobDoc.exists) {
217
+ return res.status(404).json({
218
+ success: false,
219
+ error: 'Sync job not found'
220
+ });
221
+ }
222
+
223
+ const jobData = jobDoc.data();
224
+ const createdAt = jobData.createdAt?.toDate ? jobData.createdAt.toDate() : new Date(jobData.createdAt);
225
+ const elapsedMs = Date.now() - createdAt.getTime();
226
+ const timedOut = elapsedMs > SYNC_JOB_TIMEOUT_MINS * 60 * 1000;
227
+
228
+ if (timedOut && (jobData.status === 'queued' || jobData.status === 'running')) {
229
+ // Mark as failed due to timeout
230
+ await jobRef.update({ status: 'failed', error: 'Computation timeout' });
231
+ return res.json({
232
+ success: true,
233
+ jobId,
234
+ status: 'failed',
235
+ error: 'Computation timeout (exceeded 30 minutes)'
236
+ });
237
+ }
238
+
239
+ // Check if newer result exists in BQ
240
+ if (jobData.status !== 'completed') {
241
+ const latestResult = await computationReader.getResultWithLookback(
242
+ DASHBOARD_COMPUTATION_NAME,
243
+ today(),
244
+ null,
245
+ 1 // Just check today's result
246
+ );
247
+
248
+ // If result date is newer than job creation, computation succeeded
249
+ if (latestResult?.date && new Date(`${latestResult.date}T00:00:00Z`).getTime() >= createdAt.getTime()) {
250
+ // Mark as completed
251
+ await jobRef.update({ status: 'completed', resultDate: latestResult.date });
252
+ return res.json({
253
+ success: true,
254
+ jobId,
255
+ status: 'completed',
256
+ resultDate: latestResult.date
257
+ });
258
+ }
259
+ }
260
+
261
+ // Still running or already completed
262
+ res.json({
263
+ success: true,
264
+ jobId,
265
+ status: jobData.status || 'running',
266
+ eta: jobData.status === 'completed' ? null : `${Math.ceil((30 * 60 * 1000 - elapsedMs) / 60000)} minutes remaining`,
267
+ resultDate: jobData.resultDate || null
268
+ });
269
+ } catch (error) {
270
+ next(error);
271
+ }
272
+ }
273
+ })
274
+ ];
275
+
276
+ module.exports = createRouter(routes);
@@ -21,6 +21,7 @@ const createComputationsRouter = require('./computations');
21
21
  const createWorkspaceRouter = require('./workspace');
22
22
  const runsRoutes = require('./runs');
23
23
  const logsRoutes = require('./logs');
24
+ const dashboardRoutes = require('./dashboard');
24
25
 
25
26
  /**
26
27
  * Create and configure all API routes.
@@ -43,6 +44,7 @@ function createRoutes() {
43
44
  router.use('/billing', billingRoutes);
44
45
  router.use('/computations', createComputationsRouter());
45
46
  router.use('/workspace', createWorkspaceRouter());
47
+ router.use('/dashboard', dashboardRoutes);
46
48
  router.use('/runs', runsRoutes);
47
49
  router.use('/logs', logsRoutes);
48
50
 
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @fileoverview Dashboard Page Computation (V3)
3
+ * Global aggregation: top PI risk change (7d), most purchased/sold assets,
4
+ * most commonly owned assets with AUM, total AUM, and recent PI alert events.
5
+ */
6
+
7
+ /** @typedef {import('../framework/core/ContextBuilder').ComputationContext} ComputationContext */
8
+
9
+ const TOP_RISK_CHANGE_N = 20;
10
+ const TOP_ASSETS_N = 30;
11
+ const RECENT_ALERTS_LIMIT = 50;
12
+
13
+ exports.config = {
14
+ name: 'DashboardPage',
15
+ type: 'global',
16
+ category: 'market_insights',
17
+ skills: ['lib', 'bigquery'],
18
+
19
+ requires: {
20
+ 'pi_rankings': {
21
+ lookback: 7,
22
+ mandatory: true,
23
+ fields: ['pi_id', 'username', 'rankings_data', 'date']
24
+ },
25
+ 'trade_history_snapshots': {
26
+ lookback: 7,
27
+ mandatory: true,
28
+ fields: ['user_id', 'history_data', 'date'],
29
+ filter: { user_type: 'POPULAR_INVESTOR' }
30
+ },
31
+ 'portfolio_snapshots': {
32
+ lookback: 0,
33
+ mandatory: false,
34
+ fields: ['user_id', 'portfolio_data', 'date'],
35
+ filter: { user_type: 'POPULAR_INVESTOR' }
36
+ },
37
+ 'pi_alert_history': {
38
+ lookback: 7,
39
+ mandatory: false,
40
+ fields: ['date', 'pi_id', 'alert_type', 'triggered', 'trigger_count', 'last_triggered', 'last_updated']
41
+ },
42
+ 'ticker_mappings': {
43
+ lookback: 0,
44
+ mandatory: false,
45
+ fields: ['instrument_id', 'ticker']
46
+ },
47
+ 'results': {
48
+ lookback: 0,
49
+ mandatory: false,
50
+ fields: ['result_data', 'computation_name', 'date', 'entity_id'],
51
+ filter: { computation_name: 'globalaumperasset30d' }
52
+ }
53
+ },
54
+
55
+ dependencies: ['globalaumperasset30d'],
56
+
57
+ storage: {
58
+ bigquery: true
59
+ }
60
+ };
61
+
62
+ /**
63
+ * @param {ComputationContext} ctx
64
+ */
65
+ exports.process = (ctx) => {
66
+ const { data, date, lib } = ctx;
67
+
68
+ const toArray = (input) => {
69
+ if (!input) return [];
70
+ if (Array.isArray(input)) return input;
71
+ return Object.values(input).flat();
72
+ };
73
+
74
+ const rankings = toArray(data['pi_rankings'] || []);
75
+ const tradeHistory = toArray(data['trade_history_snapshots'] || []);
76
+ const alertHistory = toArray(data['pi_alert_history'] || []);
77
+ const tickerRows = toArray(data['ticker_mappings'] || []);
78
+
79
+ const tickerMap = new Map();
80
+ tickerRows.forEach(row => {
81
+ if (row.instrument_id != null && row.ticker) {
82
+ tickerMap.set(Number(row.instrument_id), String(row.ticker).toUpperCase());
83
+ }
84
+ });
85
+ const resolveTicker = (instrumentId) => tickerMap.get(Number(instrumentId)) || `ID:${instrumentId}`;
86
+
87
+ const usernameByPiId = new Map();
88
+ rankings.forEach(r => {
89
+ const id = r.pi_id != null ? String(r.pi_id) : null;
90
+ if (id) usernameByPiId.set(id, r.username || r.UserName || null);
91
+ });
92
+
93
+ // -------------------------------------------------------------------------
94
+ // 1. Top PI risk change (7d)
95
+ // -------------------------------------------------------------------------
96
+ const riskByPi = new Map();
97
+ rankings.forEach(row => {
98
+ const piId = row.pi_id != null ? String(row.pi_id) : null;
99
+ if (!piId) return;
100
+ const rData = lib.rankings.extractRankingsData(row);
101
+ const risk = rData != null ? lib.rankings.getRiskScore(rData) : 0;
102
+ const dateStr = row.date && (typeof row.date.value === 'string' ? row.date.value : String(row.date).slice(0, 10));
103
+ if (!dateStr) return;
104
+ if (!riskByPi.has(piId)) riskByPi.set(piId, []);
105
+ riskByPi.get(piId).push({ date: dateStr, risk });
106
+ });
107
+
108
+ const topPIRiskChange7d = [];
109
+ riskByPi.forEach((points, piId) => {
110
+ if (points.length < 2) return;
111
+ points.sort((a, b) => a.date.localeCompare(b.date));
112
+ const first = points[0].risk;
113
+ const last = points[points.length - 1].risk;
114
+ const change = last - first;
115
+ topPIRiskChange7d.push({
116
+ piId,
117
+ username: usernameByPiId.get(piId) || 'Unknown',
118
+ change: Number(change.toFixed(2))
119
+ });
120
+ });
121
+ topPIRiskChange7d.sort((a, b) => Math.abs(b.change) - Math.abs(a.change));
122
+ const topPIRiskChange7dSlice = topPIRiskChange7d.slice(0, TOP_RISK_CHANGE_N);
123
+
124
+ // -------------------------------------------------------------------------
125
+ // 2. Most purchased / most sold assets (from trade_history_snapshots)
126
+ // -------------------------------------------------------------------------
127
+ const purchaseCount = new Map();
128
+ const saleCount = new Map();
129
+
130
+ tradeHistory.forEach(row => {
131
+ const trades = lib.trades.extractTrades(row);
132
+ trades.forEach(trade => {
133
+ const instrumentId = lib.trades.getInstrumentId(trade);
134
+ if (instrumentId == null) return;
135
+ const ticker = resolveTicker(instrumentId);
136
+ if (lib.trades.isBuy(trade)) {
137
+ purchaseCount.set(ticker, (purchaseCount.get(ticker) || 0) + 1);
138
+ } else {
139
+ saleCount.set(ticker, (saleCount.get(ticker) || 0) + 1);
140
+ }
141
+ });
142
+ });
143
+
144
+ const mostPurchasedAssets = Array.from(purchaseCount.entries())
145
+ .map(([ticker, count]) => ({ ticker, count }))
146
+ .sort((a, b) => b.count - a.count)
147
+ .slice(0, TOP_ASSETS_N);
148
+
149
+ const mostSoldAssets = Array.from(saleCount.entries())
150
+ .map(([ticker, count]) => ({ ticker, count }))
151
+ .sort((a, b) => b.count - a.count)
152
+ .slice(0, TOP_ASSETS_N);
153
+
154
+ // -------------------------------------------------------------------------
155
+ // 3. Most commonly owned assets + AUM (from GlobalAumPerAsset30D result)
156
+ // -------------------------------------------------------------------------
157
+ let mostOwnedAssetsWithAum = [];
158
+ const resultsRows = toArray(data['results'] || []);
159
+ for (const row of resultsRows) {
160
+ if (row.entity_id !== '_global') continue;
161
+ let resultData = row.result_data;
162
+ if (typeof resultData === 'string') {
163
+ try { resultData = JSON.parse(resultData); } catch (_) { continue; }
164
+ }
165
+ const arr = Array.isArray(resultData) ? resultData : (resultData && resultData.result);
166
+ if (Array.isArray(arr)) {
167
+ mostOwnedAssetsWithAum = arr
168
+ .filter(x => x && (x.symbol || x.ticker) && (x.amount != null))
169
+ .map(x => ({ symbol: x.symbol || x.ticker, amount: Number(x.amount) }));
170
+ break;
171
+ }
172
+ }
173
+
174
+ // -------------------------------------------------------------------------
175
+ // 4. Total AUM (latest date in rankings window)
176
+ // -------------------------------------------------------------------------
177
+ const dates = [...new Set(rankings.map(r => {
178
+ const d = r.date;
179
+ return d && (typeof d.value === 'string' ? d.value : String(d).slice(0, 10));
180
+ }).filter(Boolean))].sort();
181
+ const latestDate = dates.length ? dates[dates.length - 1] : date;
182
+
183
+ let totalAum = 0;
184
+ rankings.forEach(row => {
185
+ const d = row.date;
186
+ const dateStr = d && (typeof d.value === 'string' ? d.value : String(d).slice(0, 10));
187
+ if (dateStr !== latestDate) return;
188
+ const rData = lib.rankings.extractRankingsData(row);
189
+ if (rData) totalAum += lib.rankings.getAUM(rData) || 0;
190
+ });
191
+ totalAum = Number(totalAum.toFixed(2));
192
+
193
+ // -------------------------------------------------------------------------
194
+ // 5. Recent alert events (all) — normalized for API
195
+ // -------------------------------------------------------------------------
196
+ const recentAlertEvents = lib.dashboardParsing
197
+ ? lib.dashboardParsing.normalizeRecentAlertEvents(alertHistory, RECENT_ALERTS_LIMIT)
198
+ : alertHistory.slice(0, RECENT_ALERTS_LIMIT).map(row => ({
199
+ date: row.date && (typeof row.date.value === 'string' ? row.date.value : String(row.date).slice(0, 10)),
200
+ pi_id: row.pi_id != null ? Number(row.pi_id) : null,
201
+ alert_type: row.alert_type || null,
202
+ trigger_count: row.trigger_count != null ? Number(row.trigger_count) : null,
203
+ last_triggered: row.last_triggered
204
+ }));
205
+
206
+ // Attach username to each alert event when available
207
+ recentAlertEvents.forEach(ev => {
208
+ if (ev.pi_id != null && usernameByPiId.has(String(ev.pi_id))) {
209
+ ev.username = usernameByPiId.get(String(ev.pi_id));
210
+ }
211
+ });
212
+
213
+ return {
214
+ outcome: 'completed',
215
+ result: {
216
+ topPIRiskChange7d: topPIRiskChange7dSlice,
217
+ mostPurchasedAssets,
218
+ mostSoldAssets,
219
+ mostOwnedAssetsWithAum,
220
+ totalAum,
221
+ recentAlertEvents
222
+ }
223
+ };
224
+ };
@@ -329,7 +329,8 @@ module.exports = {
329
329
  'trades',
330
330
  'social',
331
331
  'instruments',
332
- 'metrics'
332
+ 'metrics',
333
+ 'dashboardParsing'
333
334
  ]
334
335
  }
335
336
  },
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @fileoverview Dashboard parsing and normalization helpers.
3
+ * Normalizes pi_alert_history rows and optional trade/portfolio aggregates
4
+ * for the DashboardPage computation and consistent API payload shape.
5
+ * Factory pattern: module.exports = () => ({ ... }) for ctx.lib.dashboardParsing.
6
+ */
7
+
8
+ module.exports = () => {
9
+ /**
10
+ * Normalize a single pi_alert_history row for dashboard display.
11
+ * @param {Object} row - Raw row from BigQuery (date, pi_id, alert_type, trigger_count, last_triggered, etc.)
12
+ * @returns {Object} Normalized shape: { date, pi_id, alert_type, trigger_count, last_triggered }
13
+ */
14
+ function normalizeAlertEvent(row) {
15
+ if (!row) return null;
16
+ const toDateStr = (v) => {
17
+ if (!v) return null;
18
+ if (typeof v === 'string') return v.slice(0, 10);
19
+ if (v.value) return String(v.value).slice(0, 10);
20
+ if (v instanceof Date) return v.toISOString().slice(0, 10);
21
+ if (typeof v.toDate === 'function') return v.toDate().toISOString().slice(0, 10);
22
+ return null;
23
+ };
24
+ const toIso = (v) => {
25
+ if (!v) return null;
26
+ if (typeof v === 'string') return v;
27
+ if (v instanceof Date) return v.toISOString();
28
+ if (typeof v.toDate === 'function') return v.toDate().toISOString();
29
+ if (typeof v === 'object' && typeof v._seconds === 'number') {
30
+ const ms = v._seconds * 1000 + Math.floor((v._nanoseconds || 0) / 1e6);
31
+ return new Date(ms).toISOString();
32
+ }
33
+ return null;
34
+ };
35
+ return {
36
+ date: toDateStr(row.date),
37
+ pi_id: row.pi_id != null ? Number(row.pi_id) : null,
38
+ alert_type: row.alert_type || null,
39
+ triggered: row.triggered,
40
+ trigger_count: row.trigger_count != null ? Number(row.trigger_count) : null,
41
+ last_triggered: toIso(row.last_triggered),
42
+ last_updated: toIso(row.last_updated)
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Normalize an array of pi_alert_history rows and sort by last_triggered desc.
48
+ * @param {Array|Object} rows - Rows from framework data (array or entity-keyed object)
49
+ * @param {number} limit - Max items to return
50
+ * @returns {Array<Object>} Normalized array, most recent first
51
+ */
52
+ function normalizeRecentAlertEvents(rows, limit = 50) {
53
+ const toArray = (input) => {
54
+ if (!input) return [];
55
+ if (Array.isArray(input)) return input;
56
+ return Object.values(input).flat();
57
+ };
58
+ const arr = toArray(rows);
59
+ const normalized = arr.map(normalizeAlertEvent).filter(Boolean);
60
+ const withTimestamp = normalized.map((n) => ({
61
+ ...n,
62
+ _sort: n.last_triggered ? new Date(n.last_triggered).getTime() : 0
63
+ }));
64
+ withTimestamp.sort((a, b) => b._sort - a._sort);
65
+ return withTimestamp.slice(0, limit).map(({ _sort, ...rest }) => rest);
66
+ }
67
+
68
+ return {
69
+ normalizeAlertEvent,
70
+ normalizeRecentAlertEvents
71
+ };
72
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.913",
3
+ "version": "1.0.915",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [