Vu Minh Chien commited on
Commit
24e8af1
Β·
1 Parent(s): f6902b3
Files changed (6) hide show
  1. .dockerignore +81 -0
  2. Dockerfile +39 -0
  3. devices.json +22 -0
  4. package-lock.json +0 -0
  5. package.json +23 -0
  6. server.js +685 -0
.dockerignore ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node.js dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+
7
+ # Runtime data
8
+ pids
9
+ *.pid
10
+ *.seed
11
+ *.pid.lock
12
+
13
+ # Coverage directory used by tools like istanbul
14
+ coverage/
15
+
16
+ # Logs
17
+ logs
18
+ *.log
19
+
20
+ # Dependency directories
21
+ .npm
22
+ .eslintcache
23
+
24
+ # Optional npm cache directory
25
+ .npm
26
+
27
+ # Optional eslint cache
28
+ .eslintcache
29
+
30
+ # Microbundle cache
31
+ .rpt2_cache/
32
+ .rts2_cache_cjs/
33
+ .rts2_cache_es/
34
+ .rts2_cache_umd/
35
+
36
+ # Optional REPL history
37
+ .node_repl_history
38
+
39
+ # Output of 'npm pack'
40
+ *.tgz
41
+
42
+ # Yarn Integrity file
43
+ .yarn-integrity
44
+
45
+ # dotenv environment variables file
46
+ .env
47
+ .env.local
48
+ .env.development.local
49
+ .env.test.local
50
+ .env.production.local
51
+
52
+ # parcel-bundler cache (https://parceljs.org/)
53
+ .cache
54
+ .parcel-cache
55
+
56
+ # OS generated files
57
+ .DS_Store
58
+ .DS_Store?
59
+ ._*
60
+ .Spotlight-V100
61
+ .Trashes
62
+ ehthumbs.db
63
+ Thumbs.db
64
+
65
+ # Git
66
+ .git
67
+ .gitignore
68
+
69
+ # Development files
70
+ README.md
71
+ *.md
72
+ test/
73
+ tests/
74
+ docs/
75
+
76
+ # Firebase service account (should be mounted as secret)
77
+ firebase-service-account.json
78
+
79
+ # Development scripts
80
+ test-*.sh
81
+ test-*.js
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Node.js runtime as base image
2
+ FROM node:18-alpine
3
+
4
+ # Set the working directory
5
+ WORKDIR /app
6
+
7
+ # Copy package.json and package-lock.json
8
+ COPY package*.json ./
9
+
10
+ # Install dependencies
11
+ RUN npm ci --only=production
12
+
13
+ # Copy the rest of the application code
14
+ COPY . .
15
+
16
+ # Create a non-root user
17
+ RUN addgroup -g 1001 -S nodejs
18
+ RUN adduser -S nodejs -u 1001
19
+
20
+ # Change ownership of the app directory to the nodejs user
21
+ RUN chown -R nodejs:nodejs /app
22
+
23
+ # Switch to the non-root user
24
+ USER nodejs
25
+
26
+ # Expose the port that the app runs on
27
+ # Hugging Face Spaces expects the app to run on port 7860
28
+ EXPOSE 7860
29
+
30
+ # Set environment variables
31
+ ENV NODE_ENV=production
32
+ ENV PORT=7860
33
+
34
+ # Health check
35
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
36
+ CMD node -e "require('http').get('http://localhost:7860', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
37
+
38
+ # Start the application
39
+ CMD ["node", "server.js"]
devices.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ [
3
+ "4903319C-6B6F-45A5-B3E1-1B2B1A0DE705",
4
+ {
5
+ "token": "f4jB7HvitkbLitRnP8PezM:APA91bFpr0zdompJnWqBZwjG_1--WdCnXbUAleDpnpbs_bhdBWGZuJBircYGyiW13JrPJ1OuI5Q3ZzLjZflsWIvvx_XCrnpa5JP5lJOrCcHZCJUdqBXuSzM",
6
+ "lastUpdated": "2025-07-04T01:57:11.482Z",
7
+ "platform": "iOS",
8
+ "appVersion": "1.0.0",
9
+ "registered": true
10
+ }
11
+ ],
12
+ [
13
+ "326514BA-AC8D-4F58-AFD8-AEE5708B9AE4",
14
+ {
15
+ "token": "dJz9z8vH0UMVhyRr0-M5Ul:APA91bHE1qqammp3lpv9KCN13yiRUk4EiQqZu0LvWqGtc__r7axaWgBjiTS5oIElfsZpyM6p3vYPv2he8XfbWRQDl1fITivKwvrjjjyeHdgzAcwZtCI55R4",
16
+ "lastUpdated": "2025-07-04T06:40:28.014Z",
17
+ "platform": "iOS",
18
+ "appVersion": "1.0.0",
19
+ "registered": true
20
+ }
21
+ ]
22
+ ]
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "houzou-notification-server",
3
+ "version": "1.0.0",
4
+ "description": "Notification server for Houzou Medical App",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js",
8
+ "dev": "nodemon server.js"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.18.2",
12
+ "firebase-admin": "^12.0.0",
13
+ "cors": "^2.8.5",
14
+ "body-parser": "^1.20.2",
15
+ "dotenv": "^16.3.1"
16
+ },
17
+ "devDependencies": {
18
+ "nodemon": "^3.0.2"
19
+ },
20
+ "keywords": ["firebase", "fcm", "notification", "flutter"],
21
+ "author": "Houzou Medical",
22
+ "license": "MIT"
23
+ }
server.js ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const admin = require('firebase-admin');
3
+ const cors = require('cors');
4
+ const bodyParser = require('body-parser');
5
+ const os = require('os');
6
+ require('dotenv').config();
7
+
8
+ const app = express();
9
+ const PORT = process.env.PORT || 3000;
10
+
11
+ // Middleware
12
+ app.use(cors());
13
+ app.use(bodyParser.json());
14
+ app.use(bodyParser.urlencoded({ extended: true }));
15
+
16
+ // Initialize Firebase Admin SDK
17
+ // Supports both environment variable (production) and file (development)
18
+ try {
19
+ let serviceAccount;
20
+
21
+ if (process.env.FIREBASE_SERVICE_ACCOUNT) {
22
+ // Production: Read from environment variable (Hugging Face Spaces secret)
23
+ console.log('πŸ”₯ Loading Firebase service account from environment variable');
24
+ serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT);
25
+ } else {
26
+ // Development: Read from file
27
+ console.log('πŸ”₯ Loading Firebase service account from file');
28
+ serviceAccount = require('./firebase-service-account.json');
29
+ }
30
+
31
+ console.log(`πŸ”₯ Firebase service account loaded for project: ${serviceAccount.project_id}`);
32
+
33
+ admin.initializeApp({
34
+ credential: admin.credential.cert(serviceAccount),
35
+ });
36
+
37
+ console.log('βœ… Firebase Admin SDK initialized successfully');
38
+ } catch (error) {
39
+ console.error('❌ Firebase Admin SDK initialization failed:', error);
40
+ if (!process.env.FIREBASE_SERVICE_ACCOUNT) {
41
+ console.error('πŸ’‘ Please check that firebase-service-account.json exists and has correct permissions');
42
+ console.error('πŸ’‘ Or set FIREBASE_SERVICE_ACCOUNT environment variable for production');
43
+ } else {
44
+ console.error('πŸ’‘ Please check that FIREBASE_SERVICE_ACCOUNT environment variable contains valid JSON');
45
+ }
46
+ process.exit(1);
47
+ }
48
+
49
+ // Sample products data (matching your Flutter app)
50
+ const sampleProducts = [
51
+ { id: 1, title: "NMN 10000mg Ultra", price: "Β₯8,800", category: "Anti-Aging" },
52
+ { id: 2, title: "Arginine & Citrulline", price: "Β₯5,200", category: "Sports Nutrition" },
53
+ { id: 3, title: "Broccoli Sprout Extract", price: "Β₯3,600", category: "Detox & Cleanse" },
54
+ { id: 4, title: "Sun Protection Plus", price: "Β₯4,200", category: "Skin Health" },
55
+ { id: 5, title: "Alpha-GPC Cognitive", price: "Β₯6,500", category: "Brain Health" },
56
+ { id: 6, title: "Multivitamin Complete", price: "Β₯2,800", category: "General Health" },
57
+ ];
58
+
59
+ const fs = require('fs');
60
+ const path = require('path');
61
+
62
+ // File storage for FCM tokens
63
+ const DEVICES_FILE = path.join(__dirname, 'devices.json');
64
+
65
+ // Load devices from file or create empty storage
66
+ let deviceTokens = new Map();
67
+
68
+ function loadDevicesFromFile() {
69
+ try {
70
+ if (fs.existsSync(DEVICES_FILE)) {
71
+ const data = fs.readFileSync(DEVICES_FILE, 'utf8');
72
+ const devicesArray = JSON.parse(data);
73
+ deviceTokens = new Map(devicesArray);
74
+ console.log(`πŸ“ Loaded ${deviceTokens.size} devices from file`);
75
+ } else {
76
+ console.log('πŸ“ No devices file found, starting fresh');
77
+ }
78
+ } catch (error) {
79
+ console.error('❌ Error loading devices file:', error);
80
+ deviceTokens = new Map();
81
+ }
82
+ }
83
+
84
+ function saveDevicesToFile() {
85
+ try {
86
+ const devicesArray = Array.from(deviceTokens.entries());
87
+ fs.writeFileSync(DEVICES_FILE, JSON.stringify(devicesArray, null, 2));
88
+ console.log(`πŸ’Ύ Saved ${deviceTokens.size} devices to file`);
89
+ } catch (error) {
90
+ console.error('❌ Error saving devices file:', error);
91
+ }
92
+ }
93
+
94
+ // Load devices on startup
95
+ loadDevicesFromFile();
96
+
97
+ // Routes
98
+
99
+ // Health check
100
+ app.get('/', (req, res) => {
101
+ res.json({
102
+ message: 'Houzou Medical Notification Server',
103
+ status: 'running',
104
+ timestamp: new Date().toISOString()
105
+ });
106
+ });
107
+
108
+ // Test Firebase connectivity
109
+ app.get('/test-firebase', async (req, res) => {
110
+ try {
111
+ console.log('πŸ”₯ Testing Firebase connectivity...');
112
+
113
+ // Try to get Firebase project info
114
+ const projectId = admin.app().options.projectId;
115
+ console.log(`πŸ“± Project ID: ${projectId}`);
116
+
117
+ // Try a simple Firebase operation
118
+ const testMessage = {
119
+ notification: {
120
+ title: 'Test',
121
+ body: 'Firebase connectivity test'
122
+ },
123
+ token: 'test-token-that-will-fail' // This will fail but test auth
124
+ };
125
+
126
+ try {
127
+ await admin.messaging().send(testMessage);
128
+ } catch (testError) {
129
+ console.log(`πŸ” Test message error (expected):`, testError.code);
130
+
131
+ if (testError.code === 'messaging/invalid-registration-token') {
132
+ // This is expected - it means Firebase auth is working
133
+ res.json({
134
+ success: true,
135
+ message: 'Firebase connectivity OK',
136
+ projectId: projectId,
137
+ note: 'Authentication working (test token failed as expected)'
138
+ });
139
+ return;
140
+ } else if (testError.code && testError.code.includes('auth')) {
141
+ // Auth error - this is the real problem
142
+ throw testError;
143
+ } else {
144
+ // Other error but auth seems OK
145
+ res.json({
146
+ success: true,
147
+ message: 'Firebase connectivity OK',
148
+ projectId: projectId,
149
+ note: `Test completed with expected error: ${testError.code}`
150
+ });
151
+ return;
152
+ }
153
+ }
154
+
155
+ res.json({
156
+ success: true,
157
+ message: 'Firebase connectivity OK',
158
+ projectId: projectId
159
+ });
160
+
161
+ } catch (error) {
162
+ console.error('❌ Firebase test failed:', error);
163
+ res.status(500).json({
164
+ success: false,
165
+ error: error.message,
166
+ code: error.code
167
+ });
168
+ }
169
+ });
170
+
171
+ // Send notification to navigate to Home
172
+ app.post('/send-home-notification', async (req, res) => {
173
+ try {
174
+ const { token, title, body } = req.body;
175
+
176
+ if (!token) {
177
+ return res.status(400).json({ error: 'FCM token is required' });
178
+ }
179
+
180
+ const message = {
181
+ notification: {
182
+ title: title || 'Welcome Back!',
183
+ body: body || 'Check out our latest health supplements',
184
+ },
185
+ data: {
186
+ type: 'home',
187
+ click_action: 'FLUTTER_NOTIFICATION_CLICK',
188
+ },
189
+ token: token,
190
+ };
191
+
192
+ const response = await admin.messaging().send(message);
193
+
194
+ res.json({
195
+ success: true,
196
+ messageId: response,
197
+ message: 'Home notification sent successfully'
198
+ });
199
+
200
+ } catch (error) {
201
+ console.error('Error sending home notification:', error);
202
+ res.status(500).json({
203
+ success: false,
204
+ error: error.message
205
+ });
206
+ }
207
+ });
208
+
209
+ // Send notification to navigate to Product Detail
210
+ app.post('/send-product-notification', async (req, res) => {
211
+ try {
212
+ const { token, productId, title, body } = req.body;
213
+
214
+ if (!token) {
215
+ return res.status(400).json({ error: 'FCM token is required' });
216
+ }
217
+
218
+ if (!productId) {
219
+ return res.status(400).json({ error: 'Product ID is required' });
220
+ }
221
+
222
+ // Find product by ID
223
+ const product = sampleProducts.find(p => p.id.toString() === productId.toString());
224
+
225
+ if (!product) {
226
+ return res.status(404).json({ error: 'Product not found' });
227
+ }
228
+
229
+ const message = {
230
+ notification: {
231
+ title: title || `New Deal: ${product.title}`,
232
+ body: body || `Special offer on ${product.title} - ${product.price}. Tap to view details!`,
233
+ },
234
+ data: {
235
+ type: 'product_detail',
236
+ product_id: productId.toString(),
237
+ product_title: product.title,
238
+ product_price: product.price,
239
+ click_action: 'FLUTTER_NOTIFICATION_CLICK',
240
+ },
241
+ token: token,
242
+ };
243
+
244
+ const response = await admin.messaging().send(message);
245
+
246
+ res.json({
247
+ success: true,
248
+ messageId: response,
249
+ message: 'Product notification sent successfully',
250
+ product: product
251
+ });
252
+
253
+ } catch (error) {
254
+ console.error('Error sending product notification:', error);
255
+ res.status(500).json({
256
+ success: false,
257
+ error: error.message
258
+ });
259
+ }
260
+ });
261
+
262
+ // Send notification to multiple devices (topic)
263
+ app.post('/send-topic-notification', async (req, res) => {
264
+ try {
265
+ const { topic, title, body, type, productId } = req.body;
266
+
267
+ if (!topic) {
268
+ return res.status(400).json({ error: 'Topic is required' });
269
+ }
270
+
271
+ let data = {
272
+ type: type || 'home',
273
+ click_action: 'FLUTTER_NOTIFICATION_CLICK',
274
+ };
275
+
276
+ // Add product data if it's a product notification
277
+ if (type === 'product_detail' && productId) {
278
+ const product = sampleProducts.find(p => p.id.toString() === productId.toString());
279
+ if (product) {
280
+ data.product_id = productId.toString();
281
+ data.product_title = product.title;
282
+ data.product_price = product.price;
283
+ }
284
+ }
285
+
286
+ const message = {
287
+ notification: {
288
+ title: title || 'Houzou Medical',
289
+ body: body || 'New update available!',
290
+ },
291
+ data: data,
292
+ topic: topic,
293
+ };
294
+
295
+ const response = await admin.messaging().send(message);
296
+
297
+ res.json({
298
+ success: true,
299
+ messageId: response,
300
+ message: `Topic notification sent successfully to ${topic}`
301
+ });
302
+
303
+ } catch (error) {
304
+ console.error('Error sending topic notification:', error);
305
+ res.status(500).json({
306
+ success: false,
307
+ error: error.message
308
+ });
309
+ }
310
+ });
311
+
312
+ // Get sample products
313
+ app.get('/products', (req, res) => {
314
+ res.json({
315
+ success: true,
316
+ products: sampleProducts
317
+ });
318
+ });
319
+
320
+ // Test notification endpoint
321
+ app.post('/test-notification', async (req, res) => {
322
+ try {
323
+ const { token } = req.body;
324
+
325
+ if (!token) {
326
+ return res.status(400).json({ error: 'FCM token is required for testing' });
327
+ }
328
+
329
+ // Send a test notification to home
330
+ const homeMessage = {
331
+ notification: {
332
+ title: '🏠 Test Home Notification',
333
+ body: 'This will take you to the home screen!',
334
+ },
335
+ data: {
336
+ type: 'home',
337
+ click_action: 'FLUTTER_NOTIFICATION_CLICK',
338
+ },
339
+ token: token,
340
+ };
341
+
342
+ const homeResponse = await admin.messaging().send(homeMessage);
343
+
344
+ // Send a test notification for product detail (random product)
345
+ const randomProduct = sampleProducts[Math.floor(Math.random() * sampleProducts.length)];
346
+
347
+ const productMessage = {
348
+ notification: {
349
+ title: 'πŸ›οΈ Test Product Notification',
350
+ body: `Check out ${randomProduct.title} - ${randomProduct.price}`,
351
+ },
352
+ data: {
353
+ type: 'product_detail',
354
+ product_id: randomProduct.id.toString(),
355
+ product_title: randomProduct.title,
356
+ product_price: randomProduct.price,
357
+ click_action: 'FLUTTER_NOTIFICATION_CLICK',
358
+ },
359
+ token: token,
360
+ };
361
+
362
+ const productResponse = await admin.messaging().send(productMessage);
363
+
364
+ res.json({
365
+ success: true,
366
+ message: 'Test notifications sent successfully',
367
+ results: {
368
+ home: homeResponse,
369
+ product: productResponse,
370
+ testedProduct: randomProduct
371
+ }
372
+ });
373
+
374
+ } catch (error) {
375
+ console.error('Error sending test notifications:', error);
376
+ res.status(500).json({
377
+ success: false,
378
+ error: error.message
379
+ });
380
+ }
381
+ });
382
+
383
+ // Register/Update FCM Token
384
+ app.post('/register-token', async (req, res) => {
385
+ try {
386
+ const { token, deviceId, platform, appVersion } = req.body;
387
+
388
+ if (!token || !deviceId) {
389
+ return res.status(400).json({
390
+ success: false,
391
+ error: 'FCM token and device ID are required'
392
+ });
393
+ }
394
+
395
+ // Store token with metadata
396
+ deviceTokens.set(deviceId, {
397
+ token: token,
398
+ lastUpdated: new Date().toISOString(),
399
+ platform: platform || 'unknown',
400
+ appVersion: appVersion || '1.0.0',
401
+ registered: true
402
+ });
403
+
404
+ console.log(`πŸ“± Token registered for device ${deviceId} (${platform})`);
405
+
406
+ // Auto-subscribe to 'all_users' topic for broadcast notifications
407
+ try {
408
+ await admin.messaging().subscribeToTopic([token], 'all_users');
409
+ console.log(`βœ… Device ${deviceId} subscribed to 'all_users' topic`);
410
+ } catch (topicError) {
411
+ console.warn(`⚠️ Failed to subscribe device to topic: ${topicError.message}`);
412
+ }
413
+
414
+ // Save to file
415
+ saveDevicesToFile();
416
+
417
+ res.json({
418
+ success: true,
419
+ message: 'FCM token registered successfully',
420
+ deviceCount: deviceTokens.size
421
+ });
422
+
423
+ } catch (error) {
424
+ console.error('Error registering token:', error);
425
+ res.status(500).json({
426
+ success: false,
427
+ error: error.message
428
+ });
429
+ }
430
+ });
431
+
432
+ // Unregister FCM Token
433
+ app.post('/unregister-token', async (req, res) => {
434
+ try {
435
+ const { deviceId } = req.body;
436
+
437
+ if (!deviceId) {
438
+ return res.status(400).json({
439
+ success: false,
440
+ error: 'Device ID is required'
441
+ });
442
+ }
443
+
444
+ const deviceInfo = deviceTokens.get(deviceId);
445
+ if (deviceInfo) {
446
+ // Unsubscribe from topics
447
+ try {
448
+ await admin.messaging().unsubscribeFromTopic([deviceInfo.token], 'all_users');
449
+ } catch (topicError) {
450
+ console.warn(`⚠️ Failed to unsubscribe device from topic: ${topicError.message}`);
451
+ }
452
+
453
+ deviceTokens.delete(deviceId);
454
+ console.log(`πŸ“± Token unregistered for device ${deviceId}`);
455
+ }
456
+
457
+ // Save to file
458
+ saveDevicesToFile();
459
+
460
+ res.json({
461
+ success: true,
462
+ message: 'FCM token unregistered successfully',
463
+ deviceCount: deviceTokens.size
464
+ });
465
+
466
+ } catch (error) {
467
+ console.error('Error unregistering token:', error);
468
+ res.status(500).json({
469
+ success: false,
470
+ error: error.message
471
+ });
472
+ }
473
+ });
474
+
475
+ // Get registered devices info
476
+ app.get('/devices', (req, res) => {
477
+ const devices = Array.from(deviceTokens.entries()).map(([deviceId, info]) => ({
478
+ deviceId,
479
+ platform: info.platform,
480
+ appVersion: info.appVersion,
481
+ lastUpdated: info.lastUpdated,
482
+ registered: info.registered
483
+ }));
484
+
485
+ res.json({
486
+ success: true,
487
+ deviceCount: devices.length,
488
+ devices: devices
489
+ });
490
+ });
491
+
492
+ // Debug endpoint to manually add test device
493
+ app.post('/debug-add-device', (req, res) => {
494
+ try {
495
+ const { deviceId, token, platform } = req.body;
496
+
497
+ if (!deviceId || !token) {
498
+ return res.status(400).json({
499
+ success: false,
500
+ error: 'deviceId and token are required'
501
+ });
502
+ }
503
+
504
+ deviceTokens.set(deviceId, {
505
+ token: token,
506
+ lastUpdated: new Date().toISOString(),
507
+ platform: platform || 'debug',
508
+ appVersion: '1.0.0',
509
+ registered: true
510
+ });
511
+
512
+ saveDevicesToFile();
513
+
514
+ console.log(`πŸ”§ Debug: Added device ${deviceId} (${platform})`);
515
+
516
+ res.json({
517
+ success: true,
518
+ message: 'Device added for testing',
519
+ deviceCount: deviceTokens.size
520
+ });
521
+
522
+ } catch (error) {
523
+ console.error('Error adding debug device:', error);
524
+ res.status(500).json({
525
+ success: false,
526
+ error: error.message
527
+ });
528
+ }
529
+ });
530
+
531
+ // Send notification to ALL registered devices
532
+ app.post('/send-broadcast-notification', async (req, res) => {
533
+ try {
534
+ const { title, body, type, productId } = req.body;
535
+
536
+ if (deviceTokens.size === 0) {
537
+ return res.status(400).json({
538
+ success: false,
539
+ error: 'No devices registered'
540
+ });
541
+ }
542
+
543
+ // Prepare message data
544
+ let data = {
545
+ type: type || 'home',
546
+ click_action: 'FLUTTER_NOTIFICATION_CLICK',
547
+ };
548
+
549
+ // Add product data if it's a product notification
550
+ if (type === 'product_detail' && productId) {
551
+ const product = sampleProducts.find(p => p.id.toString() === productId.toString());
552
+ if (product) {
553
+ data.product_id = productId.toString();
554
+ data.product_title = product.title;
555
+ data.product_price = product.price;
556
+ }
557
+ }
558
+
559
+ // Get all active tokens
560
+ const tokens = Array.from(deviceTokens.values()).map(device => device.token);
561
+
562
+ // Send to all devices using multicast
563
+ const message = {
564
+ notification: {
565
+ title: title || 'Houzou Medical',
566
+ body: body || 'New update available!',
567
+ },
568
+ data: data,
569
+ tokens: tokens,
570
+ };
571
+
572
+ console.log(`πŸ“€ Attempting to send notification to ${tokens.length} devices`);
573
+ console.log(`πŸ“‹ Message data:`, JSON.stringify(message, null, 2));
574
+
575
+ const response = await admin.messaging().sendEachForMulticast(message);
576
+
577
+ console.log(`πŸ“Š Send results: Success=${response.successCount}, Failed=${response.failureCount}`);
578
+
579
+ // Handle failed tokens with detailed logging
580
+ if (response.failureCount > 0) {
581
+ console.log(`❌ Detailed failure analysis:`);
582
+ const failedTokens = [];
583
+
584
+ response.responses.forEach((resp, idx) => {
585
+ const token = tokens[idx];
586
+ const deviceId = Array.from(deviceTokens.entries()).find(([id, info]) => info.token === token)?.[0];
587
+
588
+ if (!resp.success) {
589
+ console.log(`❌ Device ${deviceId} failed:`, resp.error);
590
+ console.log(` Error code: ${resp.error?.code}`);
591
+ console.log(` Error message: ${resp.error?.message}`);
592
+
593
+ // Only remove tokens for specific errors (not auth errors)
594
+ const errorCode = resp.error?.code;
595
+ const shouldRemoveToken = [
596
+ 'messaging/invalid-registration-token',
597
+ 'messaging/registration-token-not-registered'
598
+ ].includes(errorCode);
599
+
600
+ if (shouldRemoveToken) {
601
+ failedTokens.push(token);
602
+ console.log(`πŸ—‘οΈ Marking token for removal: ${deviceId} (${errorCode})`);
603
+ } else {
604
+ console.log(`⚠️ Keeping token for ${deviceId} - temporary error: ${errorCode}`);
605
+ }
606
+ } else {
607
+ console.log(`βœ… Device ${deviceId} notification sent successfully`);
608
+ }
609
+ });
610
+
611
+ // Remove only truly invalid tokens
612
+ if (failedTokens.length > 0) {
613
+ console.log(`πŸ—‘οΈ Removing ${failedTokens.length} invalid tokens`);
614
+ failedTokens.forEach(failedToken => {
615
+ for (const [deviceId, info] of deviceTokens.entries()) {
616
+ if (info.token === failedToken) {
617
+ console.log(`πŸ—‘οΈ Removing invalid token for device ${deviceId}`);
618
+ deviceTokens.delete(deviceId);
619
+ break;
620
+ }
621
+ }
622
+ });
623
+ saveDevicesToFile();
624
+ } else {
625
+ console.log(`⚠️ No tokens removed - all failures appear to be temporary`);
626
+ }
627
+ }
628
+
629
+ res.json({
630
+ success: true,
631
+ message: `Broadcast notification sent to ${response.successCount} devices`,
632
+ results: {
633
+ successCount: response.successCount,
634
+ failureCount: response.failureCount,
635
+ totalDevices: deviceTokens.size
636
+ }
637
+ });
638
+
639
+ } catch (error) {
640
+ console.error('Error sending broadcast notification:', error);
641
+ res.status(500).json({
642
+ success: false,
643
+ error: error.message
644
+ });
645
+ }
646
+ });
647
+
648
+ // Error handling middleware
649
+ app.use((error, req, res, next) => {
650
+ console.error('Server error:', error);
651
+ res.status(500).json({
652
+ success: false,
653
+ error: 'Internal server error'
654
+ });
655
+ });
656
+
657
+ // Get local IP address
658
+ function getLocalIPAddress() {
659
+ const interfaces = os.networkInterfaces();
660
+ for (const devName in interfaces) {
661
+ const iface = interfaces[devName];
662
+ for (let i = 0; i < iface.length; i++) {
663
+ const alias = iface[i];
664
+ if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
665
+ return alias.address;
666
+ }
667
+ }
668
+ }
669
+ return 'localhost';
670
+ }
671
+
672
+ // Start server
673
+ app.listen(PORT, () => {
674
+ const localIP = getLocalIPAddress();
675
+
676
+ console.log(`πŸš€ Houzou Medical Notification Server running on port ${PORT}`);
677
+ console.log(`πŸ“± Ready to send notifications to your Flutter app!`);
678
+ console.log(`\nπŸ“ Server URLs:`);
679
+ console.log(` Local: http://localhost:${PORT}`);
680
+ console.log(` Network: http://${localIP}:${PORT}`);
681
+ console.log(`\nπŸ”§ For iPhone app, use: http://${localIP}:${PORT}`);
682
+ console.log(`πŸ“ Devices saved to: ${DEVICES_FILE}`);
683
+ });
684
+
685
+ module.exports = app;