Spaces:
Sleeping
Sleeping
Vu Minh Chien
commited on
Commit
Β·
24e8af1
1
Parent(s):
f6902b3
init
Browse files- .dockerignore +81 -0
- Dockerfile +39 -0
- devices.json +22 -0
- package-lock.json +0 -0
- package.json +23 -0
- 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;
|