bulltrackers-module 1.0.725 → 1.0.727

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.
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * @fileoverview Alert Generation Helpers
3
3
  * Handles creating alerts from computation results
4
+ *
5
+ * UPDATED: Now sends FCM push notifications in addition to Firestore writes.
6
+ * This enables background notifications even when the user is offline.
4
7
  */
5
8
 
6
9
  const { FieldValue } = require('@google-cloud/firestore');
@@ -8,7 +11,7 @@ const zlib = require('zlib');
8
11
  const { Storage } = require('@google-cloud/storage');
9
12
  const { generateAlertMessage } = require('./alert_manifest_loader');
10
13
  const { evaluateDynamicConditions } = require('./dynamic_evaluator');
11
- // [UPDATED] Now uses dynamic manifest loading and condition evaluation
14
+ const { sendAlertPushNotification } = require('../../core/utils/fcm_utils');
12
15
 
13
16
  const storage = new Storage(); // Singleton GCS Client
14
17
 
@@ -174,20 +177,34 @@ async function processAlertForPI(db, logger, piCid, alertType, computationMetada
174
177
  ...(computationMetadata || {})
175
178
  };
176
179
 
177
- const writePromise = db.collection('SignedInUsers')
178
- .doc(String(userCid))
179
- .collection('alerts')
180
- .doc(notificationId)
181
- .set(alertData)
182
- .catch(err => {
180
+ // Combined promise: write to Firestore AND send FCM push notification
181
+ const writeAndPushPromise = (async () => {
182
+ // 1. Write to Firestore first (this is the source of truth)
183
+ await db.collection('SignedInUsers')
184
+ .doc(String(userCid))
185
+ .collection('alerts')
186
+ .doc(notificationId)
187
+ .set(alertData);
188
+
189
+ // 2. Send FCM push notification (non-blocking, don't fail if push fails)
190
+ // This enables notifications even when user is offline
191
+ try {
192
+ await sendAlertPushNotification(db, userCid, alertData, logger);
193
+ } catch (fcmError) {
194
+ // Log but don't throw - FCM failure shouldn't fail the alert
195
+ logger.log('WARN', `[processAlertForPI] FCM push failed for CID ${userCid}: ${fcmError.message}`);
196
+ }
197
+
198
+ return { userCid, success: true };
199
+ })().catch(err => {
183
200
  logger.log('ERROR', `[processAlertForPI] Failed to write alert for CID ${userCid}: ${err.message}`, err);
184
201
  throw err; // Re-throw so we know if writes are failing
185
202
  });
186
203
 
187
- notificationPromises.push(writePromise);
204
+ notificationPromises.push(writeAndPushPromise);
188
205
  }
189
206
 
190
- // Wait for all notifications to be written
207
+ // Wait for all notifications to be written and push notifications sent
191
208
  await Promise.all(notificationPromises);
192
209
 
193
210
  // 5. Notify the PI themselves if they are a signed-in user (Optional feature)
@@ -2,10 +2,12 @@
2
2
  * @fileoverview Alert System Trigger Handler
3
3
  * Can be triggered via Pub/Sub when computation results are written
4
4
  * [UPDATED] Now uses dynamic manifest loading instead of hardcoded registry
5
+ * [UPDATED] Now sends FCM push notifications for all alert types
5
6
  */
6
7
 
7
8
  const { loadAlertTypesFromManifest, getAlertTypeByComputation, isAlertComputation } = require('./helpers/alert_manifest_loader');
8
9
  const { processAlertForPI, readComputationResults, readComputationResultsWithShards } = require('./helpers/alert_helpers');
10
+ const { sendAlertPushNotification } = require('../core/utils/fcm_utils');
9
11
 
10
12
  // Cache for loaded alert types (loaded once per function instance)
11
13
  let cachedAlertTypes = null;
@@ -463,12 +465,21 @@ async function sendAllClearNotification(db, logger, userCid, piCid, piUsername,
463
465
  computationDate: date
464
466
  };
465
467
 
468
+ // 1. Write to Firestore (source of truth)
466
469
  await db.collection('SignedInUsers')
467
470
  .doc(String(userCid))
468
471
  .collection('alerts')
469
472
  .doc(notificationId)
470
473
  .set(alertData);
471
474
 
475
+ // 2. Send FCM push notification (non-blocking)
476
+ try {
477
+ await sendAlertPushNotification(db, userCid, alertData, logger);
478
+ } catch (fcmError) {
479
+ // Log but don't throw - FCM failure shouldn't fail the alert
480
+ logger.log('WARN', `[sendAllClearNotification] FCM push failed for user ${userCid}: ${fcmError.message}`);
481
+ }
482
+
472
483
  logger.log('INFO', `[sendAllClearNotification] Sent all-clear notification to user ${userCid} for PI ${piCid}`);
473
484
 
474
485
  } catch (error) {
@@ -17,7 +17,8 @@ const errorHandler = require('./middleware/error_handler.js');
17
17
  */
18
18
  const allowedOrigins = [
19
19
  'https://bulltrackers.web.app',
20
- 'http://172.26.96.1:8000'
20
+ 'http://172.26.96.1:8000',
21
+ 'http://127.0.0.1:8000/'
21
22
  ];
22
23
 
23
24
  /**
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @fileoverview FCM (Firebase Cloud Messaging) Token Management Routes
3
+ *
4
+ * Handles registration and unregistration of FCM tokens for push notifications.
5
+ * Tokens are associated with user CIDs and stored in Firestore.
6
+ *
7
+ * Storage: SignedInUsers/{cid}/fcm_tokens/{tokenId}
8
+ */
9
+
10
+ const express = require('express');
11
+ const { z } = require('zod');
12
+ const {
13
+ registerFCMToken,
14
+ unregisterFCMToken,
15
+ getUserFCMTokens,
16
+ touchToken
17
+ } = require('../../core/utils/fcm_utils');
18
+
19
+ const router = express.Router();
20
+
21
+ // Input validation schemas
22
+ const registerTokenSchema = z.object({
23
+ // Security: Prevent massive payloads with .max(4096)
24
+ // Flexibility: Allow any format (no regex or min length > 1)
25
+ token: z.string().min(1).max(4096),
26
+
27
+ platform: z.enum(['web', 'ios', 'android']).optional().default('web'),
28
+ userAgent: z.string().max(500).optional() // Keep this limit too
29
+ });
30
+
31
+ const unregisterTokenSchema = z.object({
32
+ token: z.string().min(100).max(500)
33
+ });
34
+
35
+ /**
36
+ * POST /fcm/register-token
37
+ *
38
+ * Register an FCM token for the authenticated user.
39
+ * Called by the frontend after login when the user grants notification permissions.
40
+ *
41
+ * Body:
42
+ * - token: string (required) - The FCM token from getToken()
43
+ * - platform: string (optional) - 'web', 'ios', or 'android' (default: 'web')
44
+ * - userAgent: string (optional) - Browser/device user agent
45
+ *
46
+ * Returns:
47
+ * - success: boolean
48
+ * - tokenId: string (hashed token ID)
49
+ */
50
+ router.post('/register-token', async (req, res, next) => {
51
+ try {
52
+ // Validate input
53
+ const validated = registerTokenSchema.parse(req.body);
54
+
55
+ // Get user CID from authenticated request
56
+ const userCid = req.targetUserId;
57
+ if (!userCid) {
58
+ return res.status(401).json({
59
+ success: false,
60
+ error: 'Authentication required',
61
+ message: 'You must be logged in to register for push notifications'
62
+ });
63
+ }
64
+
65
+ const { db, logger } = req.dependencies;
66
+
67
+ // Register the token
68
+ const result = await registerFCMToken(
69
+ db,
70
+ userCid,
71
+ validated.token,
72
+ {
73
+ platform: validated.platform,
74
+ userAgent: validated.userAgent || req.headers['user-agent']
75
+ },
76
+ logger
77
+ );
78
+
79
+ logger.log('INFO', `[FCM API] Token registered for user ${userCid}`);
80
+
81
+ res.json({
82
+ success: true,
83
+ tokenId: result.tokenId,
84
+ message: 'Push notifications enabled'
85
+ });
86
+
87
+ } catch (error) {
88
+ if (error instanceof z.ZodError) {
89
+ return res.status(400).json({
90
+ success: false,
91
+ error: 'Invalid input',
92
+ details: error.errors
93
+ });
94
+ }
95
+ next(error);
96
+ }
97
+ });
98
+
99
+ /**
100
+ * POST /fcm/unregister-token
101
+ *
102
+ * Unregister an FCM token (e.g., on logout or when user disables notifications).
103
+ *
104
+ * Body:
105
+ * - token: string (required) - The FCM token to unregister
106
+ *
107
+ * Returns:
108
+ * - success: boolean
109
+ */
110
+ router.post('/unregister-token', async (req, res, next) => {
111
+ try {
112
+ // Validate input
113
+ const validated = unregisterTokenSchema.parse(req.body);
114
+
115
+ // Get user CID from authenticated request
116
+ const userCid = req.targetUserId;
117
+ if (!userCid) {
118
+ return res.status(401).json({
119
+ success: false,
120
+ error: 'Authentication required'
121
+ });
122
+ }
123
+
124
+ const { db, logger } = req.dependencies;
125
+
126
+ // Unregister the token
127
+ const result = await unregisterFCMToken(db, userCid, validated.token, logger);
128
+
129
+ logger.log('INFO', `[FCM API] Token unregistered for user ${userCid}`);
130
+
131
+ res.json({
132
+ success: true,
133
+ message: 'Push notifications disabled for this device'
134
+ });
135
+
136
+ } catch (error) {
137
+ if (error instanceof z.ZodError) {
138
+ return res.status(400).json({
139
+ success: false,
140
+ error: 'Invalid input',
141
+ details: error.errors
142
+ });
143
+ }
144
+ next(error);
145
+ }
146
+ });
147
+
148
+ /**
149
+ * GET /fcm/tokens
150
+ *
151
+ * Get the list of registered FCM tokens for the current user.
152
+ * Useful for debugging and showing which devices have notifications enabled.
153
+ *
154
+ * Returns:
155
+ * - success: boolean
156
+ * - count: number
157
+ * - tokens: Array<{tokenId, platform, createdAt}>
158
+ */
159
+ router.get('/tokens', async (req, res, next) => {
160
+ try {
161
+ // Get user CID from authenticated request
162
+ const userCid = req.targetUserId;
163
+ if (!userCid) {
164
+ return res.status(401).json({
165
+ success: false,
166
+ error: 'Authentication required'
167
+ });
168
+ }
169
+
170
+ const { db, logger } = req.dependencies;
171
+
172
+ // Get tokens (without exposing the actual token values)
173
+ const tokens = await getUserFCMTokens(db, userCid, logger);
174
+
175
+ // Return sanitized token info (don't expose actual tokens)
176
+ const sanitizedTokens = tokens.map(t => ({
177
+ tokenId: t.tokenId,
178
+ platform: t.platform
179
+ }));
180
+
181
+ res.json({
182
+ success: true,
183
+ count: sanitizedTokens.length,
184
+ tokens: sanitizedTokens
185
+ });
186
+
187
+ } catch (error) {
188
+ next(error);
189
+ }
190
+ });
191
+
192
+ /**
193
+ * POST /fcm/refresh-token
194
+ *
195
+ * Called when an FCM token is refreshed by the client.
196
+ * Updates the lastUsedAt timestamp and optionally replaces old token with new one.
197
+ *
198
+ * Body:
199
+ * - oldToken: string (optional) - Previous token to replace
200
+ * - newToken: string (required) - New FCM token
201
+ * - platform: string (optional) - Platform identifier
202
+ *
203
+ * Returns:
204
+ * - success: boolean
205
+ * - tokenId: string
206
+ */
207
+ router.post('/refresh-token', async (req, res, next) => {
208
+ try {
209
+ const refreshSchema = z.object({
210
+ oldToken: z.string().min(100).max(500).optional(),
211
+ newToken: z.string().min(100).max(500),
212
+ platform: z.enum(['web', 'ios', 'android']).optional().default('web')
213
+ });
214
+
215
+ const validated = refreshSchema.parse(req.body);
216
+
217
+ const userCid = req.targetUserId;
218
+ if (!userCid) {
219
+ return res.status(401).json({
220
+ success: false,
221
+ error: 'Authentication required'
222
+ });
223
+ }
224
+
225
+ const { db, logger } = req.dependencies;
226
+
227
+ // If old token provided, unregister it first
228
+ if (validated.oldToken) {
229
+ await unregisterFCMToken(db, userCid, validated.oldToken, logger);
230
+ }
231
+
232
+ // Register the new token
233
+ const result = await registerFCMToken(
234
+ db,
235
+ userCid,
236
+ validated.newToken,
237
+ {
238
+ platform: validated.platform,
239
+ userAgent: req.headers['user-agent']
240
+ },
241
+ logger
242
+ );
243
+
244
+ logger.log('INFO', `[FCM API] Token refreshed for user ${userCid}`);
245
+
246
+ res.json({
247
+ success: true,
248
+ tokenId: result.tokenId,
249
+ message: 'Token refreshed successfully'
250
+ });
251
+
252
+ } catch (error) {
253
+ if (error instanceof z.ZodError) {
254
+ return res.status(400).json({
255
+ success: false,
256
+ error: 'Invalid input',
257
+ details: error.errors
258
+ });
259
+ }
260
+ next(error);
261
+ }
262
+ });
263
+
264
+ module.exports = router;
@@ -4,7 +4,8 @@ const { verifyFirebaseToken } = require('../middleware/firebase_auth_middleware.
4
4
  const { handleSyncRequest } = require('./sync.js');
5
5
 
6
6
  const notificationRoutes = require('./notifications.js');
7
- const alertsRoutes = require('./alerts.js'); // <--- NEW
7
+ const alertsRoutes = require('./alerts.js');
8
+ const fcmRoutes = require('./fcm.js'); // FCM push notification token management
8
9
  const verificationRoutes = require('./verification.js');
9
10
  const profileRoutes = require('./profile.js');
10
11
  const piRoutes = require('./popular_investors.js');
@@ -29,7 +30,8 @@ module.exports = (dependencies) => {
29
30
  router.use(resolveUserIdentity);
30
31
 
31
32
  router.use('/notifications', notificationRoutes);
32
- router.use('/alerts', alertsRoutes); // <--- NEW
33
+ router.use('/alerts', alertsRoutes);
34
+ router.use('/fcm', fcmRoutes); // FCM push notification token management
33
35
  router.use('/verification', verificationRoutes);
34
36
  router.use('/profile', profileRoutes);
35
37
  router.use('/popular-investors', piRoutes);
@@ -0,0 +1,352 @@
1
+ /**
2
+ * @fileoverview Firebase Cloud Messaging (FCM) Utilities
3
+ *
4
+ * Provides push notification functionality via FCM.
5
+ * Works in background even when user is offline.
6
+ * Supports web push notifications and future mobile app integration.
7
+ *
8
+ * Token Storage: SignedInUsers/{cid}/fcm_tokens/{tokenId}
9
+ */
10
+
11
+ const admin = require('firebase-admin');
12
+
13
+ // FCM Token collection path
14
+ const FCM_TOKENS_SUBCOLLECTION = 'fcm_tokens';
15
+
16
+ /**
17
+ * Register an FCM token for a user
18
+ * @param {Firestore} db - Firestore instance
19
+ * @param {string|number} userCid - User CID
20
+ * @param {string} token - FCM token from client
21
+ * @param {object} metadata - Additional metadata (platform, userAgent, etc.)
22
+ * @param {object} logger - Logger instance
23
+ * @returns {Promise<{success: boolean, tokenId: string}>}
24
+ */
25
+ async function registerFCMToken(db, userCid, token, metadata = {}, logger = console) {
26
+ if (!token || typeof token !== 'string') {
27
+ throw new Error('Invalid FCM token');
28
+ }
29
+
30
+ const cid = String(userCid);
31
+
32
+ // Use a hash of the token as the document ID to prevent duplicates
33
+ const tokenId = hashToken(token);
34
+
35
+ const tokenDoc = {
36
+ token: token,
37
+ platform: metadata.platform || 'web',
38
+ userAgent: metadata.userAgent || null,
39
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
40
+ lastUsedAt: admin.firestore.FieldValue.serverTimestamp(),
41
+ active: true
42
+ };
43
+
44
+ try {
45
+ await db.collection('SignedInUsers')
46
+ .doc(cid)
47
+ .collection(FCM_TOKENS_SUBCOLLECTION)
48
+ .doc(tokenId)
49
+ .set(tokenDoc, { merge: true });
50
+
51
+ logger.log('INFO', `[FCM] Registered token for user ${cid} (tokenId: ${tokenId.substring(0, 8)}...)`);
52
+
53
+ return { success: true, tokenId };
54
+ } catch (error) {
55
+ logger.log('ERROR', `[FCM] Failed to register token for user ${cid}: ${error.message}`);
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Unregister an FCM token (e.g., on logout)
62
+ * @param {Firestore} db - Firestore instance
63
+ * @param {string|number} userCid - User CID
64
+ * @param {string} token - FCM token to remove
65
+ * @param {object} logger - Logger instance
66
+ * @returns {Promise<{success: boolean}>}
67
+ */
68
+ async function unregisterFCMToken(db, userCid, token, logger = console) {
69
+ const cid = String(userCid);
70
+ const tokenId = hashToken(token);
71
+
72
+ try {
73
+ await db.collection('SignedInUsers')
74
+ .doc(cid)
75
+ .collection(FCM_TOKENS_SUBCOLLECTION)
76
+ .doc(tokenId)
77
+ .delete();
78
+
79
+ logger.log('INFO', `[FCM] Unregistered token for user ${cid}`);
80
+
81
+ return { success: true };
82
+ } catch (error) {
83
+ logger.log('WARN', `[FCM] Failed to unregister token for user ${cid}: ${error.message}`);
84
+ // Don't throw - token removal failure is non-critical
85
+ return { success: false, error: error.message };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Get all active FCM tokens for a user
91
+ * @param {Firestore} db - Firestore instance
92
+ * @param {string|number} userCid - User CID
93
+ * @param {object} logger - Logger instance
94
+ * @returns {Promise<string[]>} Array of FCM tokens
95
+ */
96
+ async function getUserFCMTokens(db, userCid, logger = console) {
97
+ const cid = String(userCid);
98
+
99
+ try {
100
+ const snapshot = await db.collection('SignedInUsers')
101
+ .doc(cid)
102
+ .collection(FCM_TOKENS_SUBCOLLECTION)
103
+ .where('active', '==', true)
104
+ .get();
105
+
106
+ if (snapshot.empty) {
107
+ return [];
108
+ }
109
+
110
+ return snapshot.docs.map(doc => ({
111
+ tokenId: doc.id,
112
+ token: doc.data().token,
113
+ platform: doc.data().platform
114
+ }));
115
+ } catch (error) {
116
+ logger.log('WARN', `[FCM] Failed to get tokens for user ${cid}: ${error.message}`);
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Send a push notification to a specific user
123
+ * @param {Firestore} db - Firestore instance
124
+ * @param {string|number} userCid - User CID
125
+ * @param {object} notification - Notification payload
126
+ * @param {string} notification.title - Notification title
127
+ * @param {string} notification.body - Notification body
128
+ * @param {string} [notification.icon] - Notification icon URL
129
+ * @param {string} [notification.clickAction] - URL to open on click
130
+ * @param {object} [data] - Additional data payload
131
+ * @param {object} logger - Logger instance
132
+ * @returns {Promise<{success: boolean, sent: number, failed: number}>}
133
+ */
134
+ async function sendPushNotification(db, userCid, notification, data = {}, logger = console) {
135
+ const cid = String(userCid);
136
+
137
+ // Get user's FCM tokens
138
+ const tokenData = await getUserFCMTokens(db, cid, logger);
139
+
140
+ if (tokenData.length === 0) {
141
+ logger.log('DEBUG', `[FCM] No FCM tokens registered for user ${cid}, skipping push`);
142
+ return { success: true, sent: 0, failed: 0, reason: 'no_tokens' };
143
+ }
144
+
145
+ const tokens = tokenData.map(t => t.token);
146
+
147
+ // Build FCM message
148
+ const message = {
149
+ notification: {
150
+ title: notification.title,
151
+ body: notification.body,
152
+ ...(notification.icon && { imageUrl: notification.icon })
153
+ },
154
+ data: {
155
+ ...data,
156
+ clickAction: notification.clickAction || '/',
157
+ timestamp: new Date().toISOString()
158
+ },
159
+ webpush: {
160
+ notification: {
161
+ icon: notification.icon || '/icons/notification-icon.png',
162
+ badge: '/icons/badge-icon.png',
163
+ requireInteraction: notification.requireInteraction || false
164
+ },
165
+ fcmOptions: {
166
+ link: notification.clickAction || '/'
167
+ }
168
+ },
169
+ tokens: tokens
170
+ };
171
+
172
+ try {
173
+ const response = await admin.messaging().sendEachForMulticast(message);
174
+
175
+ let sent = response.successCount;
176
+ let failed = response.failureCount;
177
+
178
+ // Handle stale tokens
179
+ if (response.failureCount > 0) {
180
+ const staleTokens = [];
181
+
182
+ response.responses.forEach((resp, idx) => {
183
+ if (!resp.success) {
184
+ const errorCode = resp.error?.code;
185
+
186
+ // These error codes indicate the token is no longer valid
187
+ if (errorCode === 'messaging/invalid-registration-token' ||
188
+ errorCode === 'messaging/registration-token-not-registered') {
189
+ staleTokens.push(tokenData[idx]);
190
+ } else {
191
+ logger.log('WARN', `[FCM] Send failed for user ${cid}: ${resp.error?.message}`);
192
+ }
193
+ }
194
+ });
195
+
196
+ // Clean up stale tokens (async, don't wait)
197
+ if (staleTokens.length > 0) {
198
+ cleanupStaleTokens(db, cid, staleTokens, logger).catch(() => {});
199
+ }
200
+ }
201
+
202
+ logger.log('INFO', `[FCM] Sent push to user ${cid}: ${sent} sent, ${failed} failed`);
203
+
204
+ return { success: true, sent, failed };
205
+ } catch (error) {
206
+ logger.log('ERROR', `[FCM] Failed to send push to user ${cid}: ${error.message}`);
207
+ return { success: false, sent: 0, failed: tokens.length, error: error.message };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Send a push notification to multiple users
213
+ * @param {Firestore} db - Firestore instance
214
+ * @param {Array<string|number>} userCids - Array of user CIDs
215
+ * @param {object} notification - Notification payload
216
+ * @param {object} [data] - Additional data payload
217
+ * @param {object} logger - Logger instance
218
+ * @returns {Promise<{success: boolean, results: object}>}
219
+ */
220
+ async function sendPushNotificationToMany(db, userCids, notification, data = {}, logger = console) {
221
+ const results = {
222
+ total: userCids.length,
223
+ sent: 0,
224
+ failed: 0,
225
+ noTokens: 0
226
+ };
227
+
228
+ // Process in batches to avoid overwhelming FCM
229
+ const BATCH_SIZE = 50;
230
+
231
+ for (let i = 0; i < userCids.length; i += BATCH_SIZE) {
232
+ const batch = userCids.slice(i, i + BATCH_SIZE);
233
+
234
+ const promises = batch.map(async (cid) => {
235
+ const result = await sendPushNotification(db, cid, notification, data, logger);
236
+ return { cid, ...result };
237
+ });
238
+
239
+ const batchResults = await Promise.all(promises);
240
+
241
+ for (const result of batchResults) {
242
+ if (result.reason === 'no_tokens') {
243
+ results.noTokens++;
244
+ } else {
245
+ results.sent += result.sent;
246
+ results.failed += result.failed;
247
+ }
248
+ }
249
+ }
250
+
251
+ logger.log('INFO', `[FCM] Batch send complete: ${results.sent} sent, ${results.failed} failed, ${results.noTokens} users without tokens`);
252
+
253
+ return { success: true, results };
254
+ }
255
+
256
+ /**
257
+ * Send an alert notification (convenience wrapper for alert system)
258
+ * @param {Firestore} db - Firestore instance
259
+ * @param {string|number} userCid - User CID
260
+ * @param {object} alertData - Alert data from alert system
261
+ * @param {object} logger - Logger instance
262
+ * @returns {Promise<{success: boolean}>}
263
+ */
264
+ async function sendAlertPushNotification(db, userCid, alertData, logger = console) {
265
+ const notification = {
266
+ title: alertData.alertTypeName || 'BullTrackers Alert',
267
+ body: alertData.message || `New alert for ${alertData.piUsername}`,
268
+ icon: '/icons/alert-icon.png',
269
+ clickAction: `/alerts/${alertData.alertId}`,
270
+ requireInteraction: alertData.severity === 'high'
271
+ };
272
+
273
+ const data = {
274
+ type: 'alert',
275
+ alertId: alertData.alertId || '',
276
+ alertType: alertData.alertType || '',
277
+ piCid: String(alertData.piCid || ''),
278
+ piUsername: alertData.piUsername || '',
279
+ severity: alertData.severity || 'medium',
280
+ watchlistId: alertData.watchlistId || ''
281
+ };
282
+
283
+ return sendPushNotification(db, userCid, notification, data, logger);
284
+ }
285
+
286
+ /**
287
+ * Clean up stale/invalid FCM tokens
288
+ * @param {Firestore} db - Firestore instance
289
+ * @param {string} userCid - User CID
290
+ * @param {Array} staleTokens - Array of {tokenId, token} objects
291
+ * @param {object} logger - Logger instance
292
+ */
293
+ async function cleanupStaleTokens(db, userCid, staleTokens, logger = console) {
294
+ const batch = db.batch();
295
+
296
+ for (const tokenData of staleTokens) {
297
+ const tokenRef = db.collection('SignedInUsers')
298
+ .doc(userCid)
299
+ .collection(FCM_TOKENS_SUBCOLLECTION)
300
+ .doc(tokenData.tokenId);
301
+
302
+ batch.delete(tokenRef);
303
+ }
304
+
305
+ try {
306
+ await batch.commit();
307
+ logger.log('INFO', `[FCM] Cleaned up ${staleTokens.length} stale tokens for user ${userCid}`);
308
+ } catch (error) {
309
+ logger.log('WARN', `[FCM] Failed to cleanup stale tokens: ${error.message}`);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Update last used timestamp for a token
315
+ * @param {Firestore} db - Firestore instance
316
+ * @param {string|number} userCid - User CID
317
+ * @param {string} token - FCM token
318
+ */
319
+ async function touchToken(db, userCid, token) {
320
+ const cid = String(userCid);
321
+ const tokenId = hashToken(token);
322
+
323
+ await db.collection('SignedInUsers')
324
+ .doc(cid)
325
+ .collection(FCM_TOKENS_SUBCOLLECTION)
326
+ .doc(tokenId)
327
+ .update({
328
+ lastUsedAt: admin.firestore.FieldValue.serverTimestamp()
329
+ })
330
+ .catch(() => {}); // Ignore errors
331
+ }
332
+
333
+ /**
334
+ * Hash a token to create a stable document ID
335
+ * @param {string} token - FCM token
336
+ * @returns {string} Hashed token ID
337
+ */
338
+ function hashToken(token) {
339
+ const crypto = require('crypto');
340
+ return crypto.createHash('sha256').update(token).digest('hex').substring(0, 32);
341
+ }
342
+
343
+ module.exports = {
344
+ registerFCMToken,
345
+ unregisterFCMToken,
346
+ getUserFCMTokens,
347
+ sendPushNotification,
348
+ sendPushNotificationToMany,
349
+ sendAlertPushNotification,
350
+ touchToken,
351
+ FCM_TOKENS_SUBCOLLECTION
352
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.725",
3
+ "version": "1.0.727",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [