Vu Minh Chien
Improve file permissions and error handling - use dedicated data directory
a22621a
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;