bulltrackers-module 1.0.725 → 1.0.726
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
.
|
|
180
|
-
.
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
|
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) {
|
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
token: z.string().min(100).max(500), // FCM tokens are typically 150-180 chars
|
|
24
|
+
platform: z.enum(['web', 'ios', 'android']).optional().default('web'),
|
|
25
|
+
userAgent: z.string().max(500).optional()
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const unregisterTokenSchema = z.object({
|
|
29
|
+
token: z.string().min(100).max(500)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* POST /fcm/register-token
|
|
34
|
+
*
|
|
35
|
+
* Register an FCM token for the authenticated user.
|
|
36
|
+
* Called by the frontend after login when the user grants notification permissions.
|
|
37
|
+
*
|
|
38
|
+
* Body:
|
|
39
|
+
* - token: string (required) - The FCM token from getToken()
|
|
40
|
+
* - platform: string (optional) - 'web', 'ios', or 'android' (default: 'web')
|
|
41
|
+
* - userAgent: string (optional) - Browser/device user agent
|
|
42
|
+
*
|
|
43
|
+
* Returns:
|
|
44
|
+
* - success: boolean
|
|
45
|
+
* - tokenId: string (hashed token ID)
|
|
46
|
+
*/
|
|
47
|
+
router.post('/register-token', async (req, res, next) => {
|
|
48
|
+
try {
|
|
49
|
+
// Validate input
|
|
50
|
+
const validated = registerTokenSchema.parse(req.body);
|
|
51
|
+
|
|
52
|
+
// Get user CID from authenticated request
|
|
53
|
+
const userCid = req.targetUserId;
|
|
54
|
+
if (!userCid) {
|
|
55
|
+
return res.status(401).json({
|
|
56
|
+
success: false,
|
|
57
|
+
error: 'Authentication required',
|
|
58
|
+
message: 'You must be logged in to register for push notifications'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { db, logger } = req.dependencies;
|
|
63
|
+
|
|
64
|
+
// Register the token
|
|
65
|
+
const result = await registerFCMToken(
|
|
66
|
+
db,
|
|
67
|
+
userCid,
|
|
68
|
+
validated.token,
|
|
69
|
+
{
|
|
70
|
+
platform: validated.platform,
|
|
71
|
+
userAgent: validated.userAgent || req.headers['user-agent']
|
|
72
|
+
},
|
|
73
|
+
logger
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
logger.log('INFO', `[FCM API] Token registered for user ${userCid}`);
|
|
77
|
+
|
|
78
|
+
res.json({
|
|
79
|
+
success: true,
|
|
80
|
+
tokenId: result.tokenId,
|
|
81
|
+
message: 'Push notifications enabled'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof z.ZodError) {
|
|
86
|
+
return res.status(400).json({
|
|
87
|
+
success: false,
|
|
88
|
+
error: 'Invalid input',
|
|
89
|
+
details: error.errors
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
next(error);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* POST /fcm/unregister-token
|
|
98
|
+
*
|
|
99
|
+
* Unregister an FCM token (e.g., on logout or when user disables notifications).
|
|
100
|
+
*
|
|
101
|
+
* Body:
|
|
102
|
+
* - token: string (required) - The FCM token to unregister
|
|
103
|
+
*
|
|
104
|
+
* Returns:
|
|
105
|
+
* - success: boolean
|
|
106
|
+
*/
|
|
107
|
+
router.post('/unregister-token', async (req, res, next) => {
|
|
108
|
+
try {
|
|
109
|
+
// Validate input
|
|
110
|
+
const validated = unregisterTokenSchema.parse(req.body);
|
|
111
|
+
|
|
112
|
+
// Get user CID from authenticated request
|
|
113
|
+
const userCid = req.targetUserId;
|
|
114
|
+
if (!userCid) {
|
|
115
|
+
return res.status(401).json({
|
|
116
|
+
success: false,
|
|
117
|
+
error: 'Authentication required'
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { db, logger } = req.dependencies;
|
|
122
|
+
|
|
123
|
+
// Unregister the token
|
|
124
|
+
const result = await unregisterFCMToken(db, userCid, validated.token, logger);
|
|
125
|
+
|
|
126
|
+
logger.log('INFO', `[FCM API] Token unregistered for user ${userCid}`);
|
|
127
|
+
|
|
128
|
+
res.json({
|
|
129
|
+
success: true,
|
|
130
|
+
message: 'Push notifications disabled for this device'
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof z.ZodError) {
|
|
135
|
+
return res.status(400).json({
|
|
136
|
+
success: false,
|
|
137
|
+
error: 'Invalid input',
|
|
138
|
+
details: error.errors
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
next(error);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* GET /fcm/tokens
|
|
147
|
+
*
|
|
148
|
+
* Get the list of registered FCM tokens for the current user.
|
|
149
|
+
* Useful for debugging and showing which devices have notifications enabled.
|
|
150
|
+
*
|
|
151
|
+
* Returns:
|
|
152
|
+
* - success: boolean
|
|
153
|
+
* - count: number
|
|
154
|
+
* - tokens: Array<{tokenId, platform, createdAt}>
|
|
155
|
+
*/
|
|
156
|
+
router.get('/tokens', async (req, res, next) => {
|
|
157
|
+
try {
|
|
158
|
+
// Get user CID from authenticated request
|
|
159
|
+
const userCid = req.targetUserId;
|
|
160
|
+
if (!userCid) {
|
|
161
|
+
return res.status(401).json({
|
|
162
|
+
success: false,
|
|
163
|
+
error: 'Authentication required'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { db, logger } = req.dependencies;
|
|
168
|
+
|
|
169
|
+
// Get tokens (without exposing the actual token values)
|
|
170
|
+
const tokens = await getUserFCMTokens(db, userCid, logger);
|
|
171
|
+
|
|
172
|
+
// Return sanitized token info (don't expose actual tokens)
|
|
173
|
+
const sanitizedTokens = tokens.map(t => ({
|
|
174
|
+
tokenId: t.tokenId,
|
|
175
|
+
platform: t.platform
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
res.json({
|
|
179
|
+
success: true,
|
|
180
|
+
count: sanitizedTokens.length,
|
|
181
|
+
tokens: sanitizedTokens
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
} catch (error) {
|
|
185
|
+
next(error);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* POST /fcm/refresh-token
|
|
191
|
+
*
|
|
192
|
+
* Called when an FCM token is refreshed by the client.
|
|
193
|
+
* Updates the lastUsedAt timestamp and optionally replaces old token with new one.
|
|
194
|
+
*
|
|
195
|
+
* Body:
|
|
196
|
+
* - oldToken: string (optional) - Previous token to replace
|
|
197
|
+
* - newToken: string (required) - New FCM token
|
|
198
|
+
* - platform: string (optional) - Platform identifier
|
|
199
|
+
*
|
|
200
|
+
* Returns:
|
|
201
|
+
* - success: boolean
|
|
202
|
+
* - tokenId: string
|
|
203
|
+
*/
|
|
204
|
+
router.post('/refresh-token', async (req, res, next) => {
|
|
205
|
+
try {
|
|
206
|
+
const refreshSchema = z.object({
|
|
207
|
+
oldToken: z.string().min(100).max(500).optional(),
|
|
208
|
+
newToken: z.string().min(100).max(500),
|
|
209
|
+
platform: z.enum(['web', 'ios', 'android']).optional().default('web')
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const validated = refreshSchema.parse(req.body);
|
|
213
|
+
|
|
214
|
+
const userCid = req.targetUserId;
|
|
215
|
+
if (!userCid) {
|
|
216
|
+
return res.status(401).json({
|
|
217
|
+
success: false,
|
|
218
|
+
error: 'Authentication required'
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { db, logger } = req.dependencies;
|
|
223
|
+
|
|
224
|
+
// If old token provided, unregister it first
|
|
225
|
+
if (validated.oldToken) {
|
|
226
|
+
await unregisterFCMToken(db, userCid, validated.oldToken, logger);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Register the new token
|
|
230
|
+
const result = await registerFCMToken(
|
|
231
|
+
db,
|
|
232
|
+
userCid,
|
|
233
|
+
validated.newToken,
|
|
234
|
+
{
|
|
235
|
+
platform: validated.platform,
|
|
236
|
+
userAgent: req.headers['user-agent']
|
|
237
|
+
},
|
|
238
|
+
logger
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
logger.log('INFO', `[FCM API] Token refreshed for user ${userCid}`);
|
|
242
|
+
|
|
243
|
+
res.json({
|
|
244
|
+
success: true,
|
|
245
|
+
tokenId: result.tokenId,
|
|
246
|
+
message: 'Token refreshed successfully'
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof z.ZodError) {
|
|
251
|
+
return res.status(400).json({
|
|
252
|
+
success: false,
|
|
253
|
+
error: 'Invalid input',
|
|
254
|
+
details: error.errors
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
next(error);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
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');
|
|
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);
|
|
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
|
+
};
|