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; |