Spaces:
Sleeping
Sleeping
| const express = require('express'); | |
| const admin = require('firebase-admin'); | |
| const cors = require('cors'); | |
| const bodyParser = require('body-parser'); | |
| const os = require('os'); | |
| require('dotenv').config(); | |
| const app = express(); | |
| const PORT = process.env.PORT || 3000; | |
| // Middleware | |
| app.use(cors()); | |
| app.use(bodyParser.json()); | |
| app.use(bodyParser.urlencoded({ extended: true })); | |
| // Initialize Firebase Admin SDK | |
| // Supports both environment variable (production) and file (development) | |
| try { | |
| let serviceAccount; | |
| if (process.env.FIREBASE_SERVICE_ACCOUNT) { | |
| // Production: Read from environment variable (Hugging Face Spaces secret) | |
| console.log('π₯ Loading Firebase service account from environment variable'); | |
| serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT); | |
| } else { | |
| // Development: Read from file | |
| console.log('π₯ Loading Firebase service account from file'); | |
| serviceAccount = require('./firebase-service-account.json'); | |
| } | |
| console.log(`π₯ Firebase service account loaded for project: ${serviceAccount.project_id}`); | |
| admin.initializeApp({ | |
| credential: admin.credential.cert(serviceAccount), | |
| }); | |
| console.log('β Firebase Admin SDK initialized successfully'); | |
| } catch (error) { | |
| console.error('β Firebase Admin SDK initialization failed:', error); | |
| if (!process.env.FIREBASE_SERVICE_ACCOUNT) { | |
| console.error('π‘ Please check that firebase-service-account.json exists and has correct permissions'); | |
| console.error('π‘ Or set FIREBASE_SERVICE_ACCOUNT environment variable for production'); | |
| } else { | |
| console.error('π‘ Please check that FIREBASE_SERVICE_ACCOUNT environment variable contains valid JSON'); | |
| } | |
| process.exit(1); | |
| } | |
| // Sample products data (matching your Flutter app) | |
| const sampleProducts = [ | |
| { id: 1, title: "NMN 10000mg Ultra", price: "Β₯8,800", category: "Anti-Aging" }, | |
| { id: 2, title: "Arginine & Citrulline", price: "Β₯5,200", category: "Sports Nutrition" }, | |
| { id: 3, title: "Broccoli Sprout Extract", price: "Β₯3,600", category: "Detox & Cleanse" }, | |
| { id: 4, title: "Sun Protection Plus", price: "Β₯4,200", category: "Skin Health" }, | |
| { id: 5, title: "Alpha-GPC Cognitive", price: "Β₯6,500", category: "Brain Health" }, | |
| { id: 6, title: "Multivitamin Complete", price: "Β₯2,800", category: "General Health" }, | |
| ]; | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // File storage for FCM tokens | |
| const DEVICES_FILE = path.join(__dirname, 'data', 'devices.json'); | |
| // Fallback to in-memory storage if file operations fail | |
| let fileOperationsEnabled = true; | |
| // Load devices from file or create empty storage | |
| let deviceTokens = new Map(); | |
| function loadDevicesFromFile() { | |
| try { | |
| // Ensure data directory exists | |
| const dataDir = path.dirname(DEVICES_FILE); | |
| if (!fs.existsSync(dataDir)) { | |
| console.log('π Creating data directory'); | |
| fs.mkdirSync(dataDir, { recursive: true }); | |
| } | |
| if (fs.existsSync(DEVICES_FILE)) { | |
| const data = fs.readFileSync(DEVICES_FILE, 'utf8'); | |
| const devicesArray = JSON.parse(data); | |
| deviceTokens = new Map(devicesArray); | |
| console.log(`π Loaded ${deviceTokens.size} devices from file`); | |
| } else { | |
| console.log('π No devices file found, starting fresh'); | |
| // Try to create an empty file to test write permissions | |
| try { | |
| fs.writeFileSync(DEVICES_FILE, '[]'); | |
| console.log('π Created empty devices file'); | |
| } catch (writeError) { | |
| console.warn('β οΈ Cannot create devices file, will use in-memory storage only'); | |
| console.warn('β οΈ File write error:', writeError.message); | |
| fileOperationsEnabled = false; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('β Error loading devices file:', error.message); | |
| console.log('β οΈ Using in-memory storage only'); | |
| fileOperationsEnabled = false; | |
| deviceTokens = new Map(); | |
| } | |
| } | |
| function saveDevicesToFile() { | |
| if (!fileOperationsEnabled) { | |
| // Only log this occasionally to avoid spam | |
| if (Math.random() < 0.1) { | |
| console.log('πΎ File operations disabled, keeping devices in memory only'); | |
| } | |
| return; | |
| } | |
| try { | |
| const devicesArray = Array.from(deviceTokens.entries()); | |
| fs.writeFileSync(DEVICES_FILE, JSON.stringify(devicesArray, null, 2)); | |
| console.log(`πΎ Saved ${deviceTokens.size} devices to file`); | |
| } catch (error) { | |
| console.error('β Error saving devices file:', error.message); | |
| console.log('β οΈ Disabling file operations, using in-memory storage only'); | |
| fileOperationsEnabled = false; | |
| } | |
| } | |
| // Load devices on startup | |
| loadDevicesFromFile(); | |
| // Routes | |
| // Health check | |
| app.get('/', (req, res) => { | |
| res.json({ | |
| message: 'Houzou Medical Notification Server', | |
| status: 'running', | |
| timestamp: new Date().toISOString(), | |
| fileOperationsEnabled: fileOperationsEnabled, | |
| deviceCount: deviceTokens.size | |
| }); | |
| }); | |
| // Test Firebase connectivity | |
| app.get('/test-firebase', async (req, res) => { | |
| try { | |
| console.log('π₯ Testing Firebase connectivity...'); | |
| // Try to get Firebase project info | |
| const projectId = admin.app().options.projectId; | |
| console.log(`π± Project ID: ${projectId}`); | |
| // Try a simple Firebase operation | |
| const testMessage = { | |
| notification: { | |
| title: 'Test', | |
| body: 'Firebase connectivity test' | |
| }, | |
| token: 'test-token-that-will-fail' // This will fail but test auth | |
| }; | |
| try { | |
| await admin.messaging().send(testMessage); | |
| } catch (testError) { | |
| console.log(`π Test message error (expected):`, testError.code); | |
| if (testError.code === 'messaging/invalid-registration-token') { | |
| // This is expected - it means Firebase auth is working | |
| res.json({ | |
| success: true, | |
| message: 'Firebase connectivity OK', | |
| projectId: projectId, | |
| note: 'Authentication working (test token failed as expected)' | |
| }); | |
| return; | |
| } else if (testError.code && testError.code.includes('auth')) { | |
| // Auth error - this is the real problem | |
| throw testError; | |
| } else { | |
| // Other error but auth seems OK | |
| res.json({ | |
| success: true, | |
| message: 'Firebase connectivity OK', | |
| projectId: projectId, | |
| note: `Test completed with expected error: ${testError.code}` | |
| }); | |
| return; | |
| } | |
| } | |
| res.json({ | |
| success: true, | |
| message: 'Firebase connectivity OK', | |
| projectId: projectId | |
| }); | |
| } catch (error) { | |
| console.error('β Firebase test failed:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message, | |
| code: error.code | |
| }); | |
| } | |
| }); | |
| // Send notification to navigate to Home | |
| app.post('/send-home-notification', async (req, res) => { | |
| try { | |
| const { token, title, body } = req.body; | |
| if (!token) { | |
| return res.status(400).json({ error: 'FCM token is required' }); | |
| } | |
| const message = { | |
| notification: { | |
| title: title || 'Welcome Back!', | |
| body: body || 'Check out our latest health supplements', | |
| }, | |
| data: { | |
| type: 'home', | |
| click_action: 'FLUTTER_NOTIFICATION_CLICK', | |
| }, | |
| token: token, | |
| }; | |
| const response = await admin.messaging().send(message); | |
| res.json({ | |
| success: true, | |
| messageId: response, | |
| message: 'Home notification sent successfully' | |
| }); | |
| } catch (error) { | |
| console.error('Error sending home notification:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Send notification to navigate to Product Detail | |
| app.post('/send-product-notification', async (req, res) => { | |
| try { | |
| const { token, productId, title, body } = req.body; | |
| if (!token) { | |
| return res.status(400).json({ error: 'FCM token is required' }); | |
| } | |
| if (!productId) { | |
| return res.status(400).json({ error: 'Product ID is required' }); | |
| } | |
| // Find product by ID | |
| const product = sampleProducts.find(p => p.id.toString() === productId.toString()); | |
| if (!product) { | |
| return res.status(404).json({ error: 'Product not found' }); | |
| } | |
| const message = { | |
| notification: { | |
| title: title || `New Deal: ${product.title}`, | |
| body: body || `Special offer on ${product.title} - ${product.price}. Tap to view details!`, | |
| }, | |
| data: { | |
| type: 'product_detail', | |
| product_id: productId.toString(), | |
| product_title: product.title, | |
| product_price: product.price, | |
| click_action: 'FLUTTER_NOTIFICATION_CLICK', | |
| }, | |
| token: token, | |
| }; | |
| const response = await admin.messaging().send(message); | |
| res.json({ | |
| success: true, | |
| messageId: response, | |
| message: 'Product notification sent successfully', | |
| product: product | |
| }); | |
| } catch (error) { | |
| console.error('Error sending product notification:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Send notification to multiple devices (topic) | |
| app.post('/send-topic-notification', async (req, res) => { | |
| try { | |
| const { topic, title, body, type, productId } = req.body; | |
| if (!topic) { | |
| return res.status(400).json({ error: 'Topic is required' }); | |
| } | |
| let data = { | |
| type: type || 'home', | |
| click_action: 'FLUTTER_NOTIFICATION_CLICK', | |
| }; | |
| // Add product data if it's a product notification | |
| if (type === 'product_detail' && productId) { | |
| const product = sampleProducts.find(p => p.id.toString() === productId.toString()); | |
| if (product) { | |
| data.product_id = productId.toString(); | |
| data.product_title = product.title; | |
| data.product_price = product.price; | |
| } | |
| } | |
| const message = { | |
| notification: { | |
| title: title || 'Houzou Medical', | |
| body: body || 'New update available!', | |
| }, | |
| data: data, | |
| topic: topic, | |
| }; | |
| const response = await admin.messaging().send(message); | |
| res.json({ | |
| success: true, | |
| messageId: response, | |
| message: `Topic notification sent successfully to ${topic}` | |
| }); | |
| } catch (error) { | |
| console.error('Error sending topic notification:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Get sample products | |
| app.get('/products', (req, res) => { | |
| res.json({ | |
| success: true, | |
| products: sampleProducts | |
| }); | |
| }); | |
| // Test notification endpoint | |
| app.post('/test-notification', async (req, res) => { | |
| try { | |
| const { token } = req.body; | |
| if (!token) { | |
| return res.status(400).json({ error: 'FCM token is required for testing' }); | |
| } | |
| // Send a test notification to home | |
| const homeMessage = { | |
| notification: { | |
| title: 'π Test Home Notification', | |
| body: 'This will take you to the home screen!', | |
| }, | |
| data: { | |
| type: 'home', | |
| click_action: 'FLUTTER_NOTIFICATION_CLICK', | |
| }, | |
| token: token, | |
| }; | |
| const homeResponse = await admin.messaging().send(homeMessage); | |
| // Send a test notification for product detail (random product) | |
| const randomProduct = sampleProducts[Math.floor(Math.random() * sampleProducts.length)]; | |
| const productMessage = { | |
| notification: { | |
| title: 'ποΈ Test Product Notification', | |
| body: `Check out ${randomProduct.title} - ${randomProduct.price}`, | |
| }, | |
| data: { | |
| type: 'product_detail', | |
| product_id: randomProduct.id.toString(), | |
| product_title: randomProduct.title, | |
| product_price: randomProduct.price, | |
| click_action: 'FLUTTER_NOTIFICATION_CLICK', | |
| }, | |
| token: token, | |
| }; | |
| const productResponse = await admin.messaging().send(productMessage); | |
| res.json({ | |
| success: true, | |
| message: 'Test notifications sent successfully', | |
| results: { | |
| home: homeResponse, | |
| product: productResponse, | |
| testedProduct: randomProduct | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error sending test notifications:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Register/Update FCM Token | |
| app.post('/register-token', async (req, res) => { | |
| try { | |
| const { token, deviceId, platform, appVersion } = req.body; | |
| if (!token || !deviceId) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'FCM token and device ID are required' | |
| }); | |
| } | |
| // Store token with metadata | |
| deviceTokens.set(deviceId, { | |
| token: token, | |
| lastUpdated: new Date().toISOString(), | |
| platform: platform || 'unknown', | |
| appVersion: appVersion || '1.0.0', | |
| registered: true | |
| }); | |
| console.log(`π± Token registered for device ${deviceId} (${platform})`); | |
| // Auto-subscribe to 'all_users' topic for broadcast notifications | |
| try { | |
| await admin.messaging().subscribeToTopic([token], 'all_users'); | |
| console.log(`β Device ${deviceId} subscribed to 'all_users' topic`); | |
| } catch (topicError) { | |
| console.warn(`β οΈ Failed to subscribe device to topic: ${topicError.message}`); | |
| } | |
| // Save to file | |
| saveDevicesToFile(); | |
| res.json({ | |
| success: true, | |
| message: 'FCM token registered successfully', | |
| deviceCount: deviceTokens.size | |
| }); | |
| } catch (error) { | |
| console.error('Error registering token:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Unregister FCM Token | |
| app.post('/unregister-token', async (req, res) => { | |
| try { | |
| const { deviceId } = req.body; | |
| if (!deviceId) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'Device ID is required' | |
| }); | |
| } | |
| const deviceInfo = deviceTokens.get(deviceId); | |
| if (deviceInfo) { | |
| // Unsubscribe from topics | |
| try { | |
| await admin.messaging().unsubscribeFromTopic([deviceInfo.token], 'all_users'); | |
| } catch (topicError) { | |
| console.warn(`β οΈ Failed to unsubscribe device from topic: ${topicError.message}`); | |
| } | |
| deviceTokens.delete(deviceId); | |
| console.log(`π± Token unregistered for device ${deviceId}`); | |
| } | |
| // Save to file | |
| saveDevicesToFile(); | |
| res.json({ | |
| success: true, | |
| message: 'FCM token unregistered successfully', | |
| deviceCount: deviceTokens.size | |
| }); | |
| } catch (error) { | |
| console.error('Error unregistering token:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Get registered devices info | |
| app.get('/devices', (req, res) => { | |
| const devices = Array.from(deviceTokens.entries()).map(([deviceId, info]) => ({ | |
| deviceId, | |
| platform: info.platform, | |
| appVersion: info.appVersion, | |
| lastUpdated: info.lastUpdated, | |
| registered: info.registered | |
| })); | |
| res.json({ | |
| success: true, | |
| deviceCount: devices.length, | |
| devices: devices | |
| }); | |
| }); | |
| // Debug endpoint to manually add test device | |
| app.post('/debug-add-device', (req, res) => { | |
| try { | |
| const { deviceId, token, platform } = req.body; | |
| if (!deviceId || !token) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'deviceId and token are required' | |
| }); | |
| } | |
| deviceTokens.set(deviceId, { | |
| token: token, | |
| lastUpdated: new Date().toISOString(), | |
| platform: platform || 'debug', | |
| appVersion: '1.0.0', | |
| registered: true | |
| }); | |
| saveDevicesToFile(); | |
| console.log(`π§ Debug: Added device ${deviceId} (${platform})`); | |
| res.json({ | |
| success: true, | |
| message: 'Device added for testing', | |
| deviceCount: deviceTokens.size | |
| }); | |
| } catch (error) { | |
| console.error('Error adding debug device:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Send notification to ALL registered devices | |
| app.post('/send-broadcast-notification', async (req, res) => { | |
| try { | |
| const { title, body, type, productId } = req.body; | |
| if (deviceTokens.size === 0) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'No devices registered' | |
| }); | |
| } | |
| // Prepare message data | |
| let data = { | |
| type: type || 'home', | |
| click_action: 'FLUTTER_NOTIFICATION_CLICK', | |
| }; | |
| // Add product data if it's a product notification | |
| if (type === 'product_detail' && productId) { | |
| const product = sampleProducts.find(p => p.id.toString() === productId.toString()); | |
| if (product) { | |
| data.product_id = productId.toString(); | |
| data.product_title = product.title; | |
| data.product_price = product.price; | |
| } | |
| } | |
| // Get all active tokens | |
| const tokens = Array.from(deviceTokens.values()).map(device => device.token); | |
| // Send to all devices using multicast | |
| const message = { | |
| notification: { | |
| title: title || 'Houzou Medical', | |
| body: body || 'New update available!', | |
| }, | |
| data: data, | |
| tokens: tokens, | |
| }; | |
| console.log(`π€ Attempting to send notification to ${tokens.length} devices`); | |
| console.log(`π Message data:`, JSON.stringify(message, null, 2)); | |
| const response = await admin.messaging().sendEachForMulticast(message); | |
| console.log(`π Send results: Success=${response.successCount}, Failed=${response.failureCount}`); | |
| // Handle failed tokens with detailed logging | |
| if (response.failureCount > 0) { | |
| console.log(`β Detailed failure analysis:`); | |
| const failedTokens = []; | |
| response.responses.forEach((resp, idx) => { | |
| const token = tokens[idx]; | |
| const deviceId = Array.from(deviceTokens.entries()).find(([id, info]) => info.token === token)?.[0]; | |
| if (!resp.success) { | |
| console.log(`β Device ${deviceId} failed:`, resp.error); | |
| console.log(` Error code: ${resp.error?.code}`); | |
| console.log(` Error message: ${resp.error?.message}`); | |
| // Only remove tokens for specific errors (not auth errors) | |
| const errorCode = resp.error?.code; | |
| const shouldRemoveToken = [ | |
| 'messaging/invalid-registration-token', | |
| 'messaging/registration-token-not-registered' | |
| ].includes(errorCode); | |
| if (shouldRemoveToken) { | |
| failedTokens.push(token); | |
| console.log(`ποΈ Marking token for removal: ${deviceId} (${errorCode})`); | |
| } else { | |
| console.log(`β οΈ Keeping token for ${deviceId} - temporary error: ${errorCode}`); | |
| } | |
| } else { | |
| console.log(`β Device ${deviceId} notification sent successfully`); | |
| } | |
| }); | |
| // Remove only truly invalid tokens | |
| if (failedTokens.length > 0) { | |
| console.log(`ποΈ Removing ${failedTokens.length} invalid tokens`); | |
| failedTokens.forEach(failedToken => { | |
| for (const [deviceId, info] of deviceTokens.entries()) { | |
| if (info.token === failedToken) { | |
| console.log(`ποΈ Removing invalid token for device ${deviceId}`); | |
| deviceTokens.delete(deviceId); | |
| break; | |
| } | |
| } | |
| }); | |
| saveDevicesToFile(); | |
| } else { | |
| console.log(`β οΈ No tokens removed - all failures appear to be temporary`); | |
| } | |
| } | |
| res.json({ | |
| success: true, | |
| message: `Broadcast notification sent to ${response.successCount} devices`, | |
| results: { | |
| successCount: response.successCount, | |
| failureCount: response.failureCount, | |
| totalDevices: deviceTokens.size | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error sending broadcast notification:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Error handling middleware | |
| app.use((error, req, res, next) => { | |
| console.error('Server error:', error); | |
| res.status(500).json({ | |
| success: false, | |
| error: 'Internal server error' | |
| }); | |
| }); | |
| // Get local IP address | |
| function getLocalIPAddress() { | |
| const interfaces = os.networkInterfaces(); | |
| for (const devName in interfaces) { | |
| const iface = interfaces[devName]; | |
| for (let i = 0; i < iface.length; i++) { | |
| const alias = iface[i]; | |
| if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) { | |
| return alias.address; | |
| } | |
| } | |
| } | |
| return 'localhost'; | |
| } | |
| // Start server | |
| app.listen(PORT, () => { | |
| const localIP = getLocalIPAddress(); | |
| console.log(`π Houzou Medical Notification Server running on port ${PORT}`); | |
| console.log(`π± Ready to send notifications to your Flutter app!`); | |
| console.log(`\nπ Server URLs:`); | |
| console.log(` Local: http://localhost:${PORT}`); | |
| console.log(` Network: http://${localIP}:${PORT}`); | |
| console.log(`\nπ§ For iPhone app, use: http://${localIP}:${PORT}`); | |
| console.log(`π Devices storage: ${fileOperationsEnabled ? DEVICES_FILE : 'In-memory only'}`); | |
| console.log(`πΎ File operations: ${fileOperationsEnabled ? 'Enabled' : 'Disabled'}`); | |
| }); | |
| module.exports = app; |