Commit ·
c33bf01
0
Parent(s):
Initial commit — multi-tenant WiFi hotspot monetization platform
Browse filesNode.js/Express backend with Omada SDN integration, M-Pesa payments via
Snippe, SMS notifications, captive portal, and tenant dashboard API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .env.example +41 -0
- .gitignore +28 -0
- API_DOCUMENTATION.md +1018 -0
- Dockerfile +7 -0
- docker-compose.yml +44 -0
- package.json +26 -0
- public/engine.js +252 -0
- schema.sql +510 -0
- src/app.js +53 -0
- src/config/db.js +25 -0
- src/jobs/adoptDevices.job.js +70 -0
- src/jobs/billingReminders.job.js +33 -0
- src/jobs/expireTokens.job.js +68 -0
- src/jobs/index.js +34 -0
- src/jobs/pollAlerts.job.js +60 -0
- src/jobs/pollDevices.job.js +93 -0
- src/jobs/pollSessions.job.js +106 -0
- src/jobs/suspendDevices.job.js +62 -0
- src/middleware/auth.js +49 -0
- src/middleware/rateLimiter.js +49 -0
- src/routes/admin.routes.js +130 -0
- src/routes/alerts.routes.js +39 -0
- src/routes/auth.routes.js +161 -0
- src/routes/dashboard.routes.js +77 -0
- src/routes/devices.routes.js +326 -0
- src/routes/payouts.routes.js +114 -0
- src/routes/plans.routes.js +122 -0
- src/routes/portal.routes.js +326 -0
- src/routes/sessions.routes.js +62 -0
- src/routes/webhooks.routes.js +215 -0
- src/server.js +31 -0
- src/services/omada.js +240 -0
- src/services/sms.js +50 -0
- src/services/snippe.js +111 -0
- src/utils/adminAlert.js +24 -0
- src/utils/generateCode.js +10 -0
- src/utils/messages.js +49 -0
- src/views/portal.ejs +245 -0
- src/views/screens/portal.ejs +267 -0
- src/views/screens/reconnected.ejs +82 -0
- src/views/screens/suspended.ejs +54 -0
- src/views/suspended.ejs +51 -0
.env.example
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Server
|
| 2 |
+
PORT=3000
|
| 3 |
+
NODE_ENV=development
|
| 4 |
+
|
| 5 |
+
# Portal — host:port as Omada sees it (no scheme)
|
| 6 |
+
PORTAL_HOST=your-domain.com:3000
|
| 7 |
+
PORTAL_SCHEME=http
|
| 8 |
+
|
| 9 |
+
# MySQL
|
| 10 |
+
DB_HOST=localhost
|
| 11 |
+
DB_PORT=3306
|
| 12 |
+
DB_USER=wifiuser
|
| 13 |
+
DB_PASS=wifipass
|
| 14 |
+
DB_NAME=wifiplatform
|
| 15 |
+
|
| 16 |
+
# JWT
|
| 17 |
+
JWT_SECRET=change_me_to_a_long_random_string_min_32_chars
|
| 18 |
+
JWT_ADMIN_SECRET=change_me_admin_secret_min_32_chars
|
| 19 |
+
|
| 20 |
+
# Omada Controller
|
| 21 |
+
OMADA_URL=https://13.63.50.201:8043
|
| 22 |
+
OMADA_USER=Mbonea
|
| 23 |
+
OMADA_PASS=yourpassword
|
| 24 |
+
|
| 25 |
+
# Snippe Payments
|
| 26 |
+
SNIPPE_API_KEY=snp_live_...
|
| 27 |
+
SNIPPE_WEBHOOK_SECRET=whsec_...
|
| 28 |
+
|
| 29 |
+
# SMS Gateway (Android SMS Gateway)
|
| 30 |
+
SMS_GATEWAY_URL=https://your-sms-gateway.hf.space/api/3rdparty/v1/messages
|
| 31 |
+
SMS_GATEWAY_USER=your_user
|
| 32 |
+
SMS_GATEWAY_PASS=your_pass
|
| 33 |
+
|
| 34 |
+
# Admin access token (simple bearer token for admin endpoints)
|
| 35 |
+
ADMIN_TOKEN=change_me_strong_admin_token
|
| 36 |
+
|
| 37 |
+
# Platform config
|
| 38 |
+
PLATFORM_COMMISSION_RATE=0.05
|
| 39 |
+
SNIPPE_FEE_RATE=0.005
|
| 40 |
+
DEVICE_MONTHLY_FEE=20000
|
| 41 |
+
DEVICE_TRIAL_DAYS=14
|
.gitignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Environment variables
|
| 5 |
+
.env
|
| 6 |
+
.env.local
|
| 7 |
+
.env.*.local
|
| 8 |
+
|
| 9 |
+
# Logs
|
| 10 |
+
logs/
|
| 11 |
+
*.log
|
| 12 |
+
npm-debug.log*
|
| 13 |
+
|
| 14 |
+
# Runtime
|
| 15 |
+
pids/
|
| 16 |
+
*.pid
|
| 17 |
+
|
| 18 |
+
# OS
|
| 19 |
+
.DS_Store
|
| 20 |
+
Thumbs.db
|
| 21 |
+
|
| 22 |
+
# Editor
|
| 23 |
+
.vscode/
|
| 24 |
+
.idea/
|
| 25 |
+
|
| 26 |
+
# Build / uploads
|
| 27 |
+
dist/
|
| 28 |
+
portals/uploads/
|
API_DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WiFi Platform — Backend API Documentation
|
| 2 |
+
|
| 3 |
+
> For frontend engineers building the Tenant Dashboard and Super Admin Panel.
|
| 4 |
+
> Base URL: `http://{SERVER_HOST}:3000`
|
| 5 |
+
> All API responses are JSON. All request bodies must be `Content-Type: application/json`.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Table of Contents
|
| 10 |
+
|
| 11 |
+
1. [Authentication](#1-authentication)
|
| 12 |
+
2. [Tenant — Devices](#2-tenant--devices)
|
| 13 |
+
3. [Tenant — WiFi Plans](#3-tenant--wifi-plans)
|
| 14 |
+
4. [Tenant — Dashboard](#4-tenant--dashboard)
|
| 15 |
+
5. [Tenant — Sessions](#5-tenant--sessions)
|
| 16 |
+
6. [Tenant — Payouts](#6-tenant--payouts)
|
| 17 |
+
7. [Tenant — Alerts](#7-tenant--alerts)
|
| 18 |
+
8. [Super Admin](#8-super-admin)
|
| 19 |
+
9. [Captive Portal (Public)](#9-captive-portal-public)
|
| 20 |
+
10. [Error Format](#10-error-format)
|
| 21 |
+
11. [Enums & Constants](#11-enums--constants)
|
| 22 |
+
12. [UI Building Guide](#12-ui-building-guide)
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 1. Authentication
|
| 27 |
+
|
| 28 |
+
All tenant endpoints require a `Bearer` token in the `Authorization` header:
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
Authorization: Bearer <jwt_token>
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
The token is returned on login and registration. It expires after **30 days**.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
### POST `/api/auth/register`
|
| 39 |
+
Register a new tenant account.
|
| 40 |
+
|
| 41 |
+
**Body:**
|
| 42 |
+
```json
|
| 43 |
+
{
|
| 44 |
+
"business_name": "Sunset Hostel",
|
| 45 |
+
"contact_name": "John Doe",
|
| 46 |
+
"email": "john@sunset.co.tz",
|
| 47 |
+
"phone": "0712345678",
|
| 48 |
+
"password": "mypassword"
|
| 49 |
+
}
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
**Validation:**
|
| 53 |
+
- All fields required
|
| 54 |
+
- `password` minimum 8 characters
|
| 55 |
+
- `email` and `phone` must be unique
|
| 56 |
+
|
| 57 |
+
**Response `201`:**
|
| 58 |
+
```json
|
| 59 |
+
{
|
| 60 |
+
"token": "eyJhbGci...",
|
| 61 |
+
"client": {
|
| 62 |
+
"id": 1,
|
| 63 |
+
"business_name": "Sunset Hostel",
|
| 64 |
+
"contact_name": "John Doe",
|
| 65 |
+
"email": "john@sunset.co.tz",
|
| 66 |
+
"phone": "0712345678",
|
| 67 |
+
"balance": 0,
|
| 68 |
+
"total_earned": 0,
|
| 69 |
+
"total_withdrawn": 0,
|
| 70 |
+
"is_active": 1,
|
| 71 |
+
"created_at": "2026-04-15T10:00:00.000Z"
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
> A welcome SMS is sent to `phone` on success.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
### POST `/api/auth/login`
|
| 81 |
+
Login with email and password.
|
| 82 |
+
|
| 83 |
+
**Body:**
|
| 84 |
+
```json
|
| 85 |
+
{
|
| 86 |
+
"email": "john@sunset.co.tz",
|
| 87 |
+
"password": "mypassword"
|
| 88 |
+
}
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
**Response `200`:**
|
| 92 |
+
```json
|
| 93 |
+
{
|
| 94 |
+
"token": "eyJhbGci...",
|
| 95 |
+
"client": { ...same as register... }
|
| 96 |
+
}
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
### POST `/api/auth/forgot-password`
|
| 102 |
+
Request a password reset OTP via SMS.
|
| 103 |
+
|
| 104 |
+
**Body:**
|
| 105 |
+
```json
|
| 106 |
+
{ "phone": "0712345678" }
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
**Response `200`:**
|
| 110 |
+
```json
|
| 111 |
+
{ "message": "If that number is registered, an OTP has been sent." }
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
> Always returns 200 regardless of whether the phone exists — prevents enumeration.
|
| 115 |
+
> OTP is a 6-digit code, valid for **10 minutes**, single-use.
|
| 116 |
+
> Rate limited to 3 requests per 15 minutes per IP.
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
### POST `/api/auth/reset-password`
|
| 121 |
+
Set a new password using the OTP received via SMS.
|
| 122 |
+
|
| 123 |
+
**Body:**
|
| 124 |
+
```json
|
| 125 |
+
{
|
| 126 |
+
"phone": "0712345678",
|
| 127 |
+
"otp": "482910",
|
| 128 |
+
"new_password": "newsecurepassword"
|
| 129 |
+
}
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**Validation:**
|
| 133 |
+
- `new_password` minimum 8 characters
|
| 134 |
+
- OTP must be unused and not expired
|
| 135 |
+
|
| 136 |
+
**Response `200`:**
|
| 137 |
+
```json
|
| 138 |
+
{ "message": "Password reset successfully. Please log in." }
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
**Error `400`:** `"Invalid or expired OTP"`
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### GET `/api/auth/me`
|
| 146 |
+
Get the authenticated tenant's profile.
|
| 147 |
+
|
| 148 |
+
**Auth:** Required
|
| 149 |
+
|
| 150 |
+
**Response `200`:**
|
| 151 |
+
```json
|
| 152 |
+
{
|
| 153 |
+
"id": 1,
|
| 154 |
+
"business_name": "Sunset Hostel",
|
| 155 |
+
"contact_name": "John Doe",
|
| 156 |
+
"email": "john@sunset.co.tz",
|
| 157 |
+
"phone": "0712345678",
|
| 158 |
+
"balance": 45000.00,
|
| 159 |
+
"total_earned": 320000.00,
|
| 160 |
+
"total_withdrawn": 275000.00,
|
| 161 |
+
"created_at": "2026-04-15T10:00:00.000Z"
|
| 162 |
+
}
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## 2. Tenant — Devices
|
| 168 |
+
|
| 169 |
+
A **device** represents one WiFi access point (AP) registered to a tenant. Each device maps to one Omada site and has its own captive portal, WiFi plans, billing, and guest sessions.
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
### GET `/api/devices`
|
| 174 |
+
List all devices for the authenticated tenant.
|
| 175 |
+
|
| 176 |
+
**Auth:** Required
|
| 177 |
+
|
| 178 |
+
**Response `200`:**
|
| 179 |
+
```json
|
| 180 |
+
[
|
| 181 |
+
{
|
| 182 |
+
"id": 3,
|
| 183 |
+
"name": "Main Building",
|
| 184 |
+
"location": "Floor 1",
|
| 185 |
+
"mac": "24-2F-D0-DF-19-9C",
|
| 186 |
+
"model": "EAP110-Outdoor",
|
| 187 |
+
"ip": "192.168.1.137",
|
| 188 |
+
"status": "online",
|
| 189 |
+
"billing_status": "active",
|
| 190 |
+
"billing_expires_at": "2026-05-15T00:00:00.000Z",
|
| 191 |
+
"monthly_fee": 20000.00,
|
| 192 |
+
"cpu_util": 12,
|
| 193 |
+
"mem_util": 63,
|
| 194 |
+
"guest_count": 4,
|
| 195 |
+
"client_count": 5,
|
| 196 |
+
"last_seen_at": "2026-04-15T11:30:00.000Z",
|
| 197 |
+
"registered_at": "2026-03-01T08:00:00.000Z",
|
| 198 |
+
"omada_site_id": "69dcc0a73044367bfbc43692"
|
| 199 |
+
}
|
| 200 |
+
]
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
**`status` values:** `pending` · `adopting` · `online` · `offline` · `removed`
|
| 204 |
+
**`billing_status` values:** `trial` · `active` · `suspended` · `cancelled`
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
### POST `/api/devices`
|
| 209 |
+
Register a new WiFi access point.
|
| 210 |
+
|
| 211 |
+
**Auth:** Required
|
| 212 |
+
|
| 213 |
+
**Body:**
|
| 214 |
+
```json
|
| 215 |
+
{
|
| 216 |
+
"name": "Main Building",
|
| 217 |
+
"mac": "24:2F:D0:DF:19:9C",
|
| 218 |
+
"location": "Floor 1",
|
| 219 |
+
"device_username": "admin",
|
| 220 |
+
"device_password": "MyCustomPass@123"
|
| 221 |
+
}
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
**Notes:**
|
| 225 |
+
- `name` and `mac` are required. All other fields are optional.
|
| 226 |
+
- MAC is auto-normalized to `AA-BB-CC-DD-EE-FF` format.
|
| 227 |
+
- `device_username` / `device_password` are the credentials the AP was configured with. If not supplied, Omada's auto-generated site credentials are used. Supply these if the AP already has custom credentials set before registration.
|
| 228 |
+
- An Omada site and captive portal are created automatically.
|
| 229 |
+
- Device starts in `trial` billing status (default 14-day trial).
|
| 230 |
+
|
| 231 |
+
**Response `201`:**
|
| 232 |
+
```json
|
| 233 |
+
{
|
| 234 |
+
"device": { ...full device object... },
|
| 235 |
+
"message": "Device registered. Plug in your AP and wait for it to come online."
|
| 236 |
+
}
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
### GET `/api/devices/:id`
|
| 242 |
+
Get a single device with today's stats and active guest sessions.
|
| 243 |
+
|
| 244 |
+
**Auth:** Required
|
| 245 |
+
|
| 246 |
+
**Response `200`:**
|
| 247 |
+
```json
|
| 248 |
+
{
|
| 249 |
+
"id": 3,
|
| 250 |
+
"name": "Main Building",
|
| 251 |
+
"status": "online",
|
| 252 |
+
"billing_status": "active",
|
| 253 |
+
"sales_today": 12,
|
| 254 |
+
"earned_today": 11400.00,
|
| 255 |
+
"active_sessions": [
|
| 256 |
+
{
|
| 257 |
+
"client_mac": "2A-61-D9-9B-90-7C",
|
| 258 |
+
"started_at": "2026-04-15T09:00:00.000Z",
|
| 259 |
+
"bytes_down": 4321769,
|
| 260 |
+
"bytes_up": 6990620,
|
| 261 |
+
"expires_at": "2026-04-15T11:00:00.000Z",
|
| 262 |
+
"code": "ABCD-1234",
|
| 263 |
+
"plan_name": "2 Hour Plan"
|
| 264 |
+
}
|
| 265 |
+
]
|
| 266 |
+
}
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
### DELETE `/api/devices/:id`
|
| 272 |
+
Pause a device (suspends billing, kicks all connected guests, revokes active tokens).
|
| 273 |
+
|
| 274 |
+
**Auth:** Required
|
| 275 |
+
|
| 276 |
+
> This is reversible — use `POST /api/devices/:id/renew` to reactivate.
|
| 277 |
+
> The captive portal will show a "Renew Subscription" page to guests.
|
| 278 |
+
> Tenant receives an SMS with the renewal amount.
|
| 279 |
+
|
| 280 |
+
**Response `200`:**
|
| 281 |
+
```json
|
| 282 |
+
{
|
| 283 |
+
"message": "Device paused. Renew your subscription to reactivate."
|
| 284 |
+
}
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
---
|
| 288 |
+
|
| 289 |
+
### POST `/api/devices/:id/renew`
|
| 290 |
+
Initiate a monthly subscription payment for a device via M-Pesa.
|
| 291 |
+
|
| 292 |
+
**Auth:** Required
|
| 293 |
+
|
| 294 |
+
**Body:** *(empty)*
|
| 295 |
+
|
| 296 |
+
**Response `200`:**
|
| 297 |
+
```json
|
| 298 |
+
{
|
| 299 |
+
"reference": "abc123def456ghi789",
|
| 300 |
+
"amount": 20000,
|
| 301 |
+
"message": "Check your phone for M-Pesa prompt"
|
| 302 |
+
}
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
> Payment is processed via M-Pesa. On success the webhook automatically extends `billing_expires_at` by 30 days and sets `billing_status = 'active'`.
|
| 306 |
+
|
| 307 |
+
---
|
| 308 |
+
|
| 309 |
+
## 3. Tenant — WiFi Plans
|
| 310 |
+
|
| 311 |
+
Plans define what guests can purchase on the captive portal. Each plan belongs to a specific device.
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
### GET `/api/plans?deviceId=:id`
|
| 316 |
+
List all plans (active and inactive) for a device.
|
| 317 |
+
|
| 318 |
+
**Auth:** Required
|
| 319 |
+
**Query:** `deviceId` (required)
|
| 320 |
+
|
| 321 |
+
**Response `200`:**
|
| 322 |
+
```json
|
| 323 |
+
[
|
| 324 |
+
{
|
| 325 |
+
"id": 7,
|
| 326 |
+
"client_id": 1,
|
| 327 |
+
"device_id": 3,
|
| 328 |
+
"name": "1 Hour Browsing",
|
| 329 |
+
"duration_seconds": 3600,
|
| 330 |
+
"price": 500.00,
|
| 331 |
+
"down_limit": 5,
|
| 332 |
+
"down_unit": 2,
|
| 333 |
+
"up_limit": 2,
|
| 334 |
+
"up_unit": 2,
|
| 335 |
+
"display_order": 0,
|
| 336 |
+
"is_active": 1,
|
| 337 |
+
"created_at": "2026-03-01T08:00:00.000Z"
|
| 338 |
+
}
|
| 339 |
+
]
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
**Speed units:** `1` = Kbps · `2` = Mbps
|
| 343 |
+
|
| 344 |
+
---
|
| 345 |
+
|
| 346 |
+
### POST `/api/plans`
|
| 347 |
+
Create a new WiFi plan.
|
| 348 |
+
|
| 349 |
+
**Auth:** Required
|
| 350 |
+
|
| 351 |
+
**Body:**
|
| 352 |
+
```json
|
| 353 |
+
{
|
| 354 |
+
"device_id": 3,
|
| 355 |
+
"name": "1 Hour Browsing",
|
| 356 |
+
"duration_seconds": 3600,
|
| 357 |
+
"price": 500,
|
| 358 |
+
"down_limit": 5,
|
| 359 |
+
"down_unit": 2,
|
| 360 |
+
"up_limit": 2,
|
| 361 |
+
"up_unit": 2,
|
| 362 |
+
"display_order": 0
|
| 363 |
+
}
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
**Required:** `device_id`, `name`, `duration_seconds`, `price`
|
| 367 |
+
**Defaults:** `down_limit: 2`, `down_unit: 2`, `up_limit: 2`, `up_unit: 2`, `display_order: 0`
|
| 368 |
+
|
| 369 |
+
**Response `201`:** Full plan object.
|
| 370 |
+
|
| 371 |
+
---
|
| 372 |
+
|
| 373 |
+
### PUT `/api/plans/:id`
|
| 374 |
+
Update a plan. All fields are optional (partial update supported).
|
| 375 |
+
|
| 376 |
+
**Auth:** Required
|
| 377 |
+
|
| 378 |
+
**Body (all optional):**
|
| 379 |
+
```json
|
| 380 |
+
{
|
| 381 |
+
"name": "1 Hour Plan",
|
| 382 |
+
"duration_seconds": 3600,
|
| 383 |
+
"price": 600,
|
| 384 |
+
"down_limit": 10,
|
| 385 |
+
"down_unit": 2,
|
| 386 |
+
"up_limit": 5,
|
| 387 |
+
"up_unit": 2,
|
| 388 |
+
"display_order": 1,
|
| 389 |
+
"is_active": 1
|
| 390 |
+
}
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
**Response `200`:** Updated plan object.
|
| 394 |
+
|
| 395 |
+
> Set `is_active: 0` to hide a plan from the portal without deleting it.
|
| 396 |
+
|
| 397 |
+
---
|
| 398 |
+
|
| 399 |
+
### DELETE `/api/plans/:id`
|
| 400 |
+
Deactivate a plan (soft delete — it stops appearing on the portal).
|
| 401 |
+
|
| 402 |
+
**Auth:** Required
|
| 403 |
+
|
| 404 |
+
**Response `200`:**
|
| 405 |
+
```json
|
| 406 |
+
{ "message": "Plan deactivated" }
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
---
|
| 410 |
+
|
| 411 |
+
## 4. Tenant — Dashboard
|
| 412 |
+
|
| 413 |
+
---
|
| 414 |
+
|
| 415 |
+
### GET `/api/dashboard/summary`
|
| 416 |
+
Main dashboard overview for the authenticated tenant.
|
| 417 |
+
|
| 418 |
+
**Auth:** Required
|
| 419 |
+
|
| 420 |
+
**Response `200`:**
|
| 421 |
+
```json
|
| 422 |
+
{
|
| 423 |
+
"balance": 45000.00,
|
| 424 |
+
"total_earned": 320000.00,
|
| 425 |
+
"total_withdrawn": 275000.00,
|
| 426 |
+
"alert_count": 2,
|
| 427 |
+
"devices": [
|
| 428 |
+
{
|
| 429 |
+
"id": 3,
|
| 430 |
+
"name": "Main Building",
|
| 431 |
+
"location": "Floor 1",
|
| 432 |
+
"status": "online",
|
| 433 |
+
"billing_status": "active",
|
| 434 |
+
"billing_expires_at": "2026-05-15T00:00:00.000Z",
|
| 435 |
+
"model": "EAP110-Outdoor",
|
| 436 |
+
"cpu_util": 12,
|
| 437 |
+
"mem_util": 63,
|
| 438 |
+
"guest_count": 4,
|
| 439 |
+
"last_seen_at": "2026-04-15T11:30:00.000Z",
|
| 440 |
+
"sales_today": 12,
|
| 441 |
+
"earned_today": 11400.00,
|
| 442 |
+
"guests_online": 4
|
| 443 |
+
}
|
| 444 |
+
]
|
| 445 |
+
}
|
| 446 |
+
```
|
| 447 |
+
|
| 448 |
+
---
|
| 449 |
+
|
| 450 |
+
### GET `/api/dashboard/revenue/weekly`
|
| 451 |
+
Last 7 days of revenue broken down by day.
|
| 452 |
+
|
| 453 |
+
**Auth:** Required
|
| 454 |
+
|
| 455 |
+
**Response `200`:**
|
| 456 |
+
```json
|
| 457 |
+
[
|
| 458 |
+
{
|
| 459 |
+
"day": "2026-04-15",
|
| 460 |
+
"sales": 12,
|
| 461 |
+
"gross": 12000.00,
|
| 462 |
+
"net": 11340.00
|
| 463 |
+
}
|
| 464 |
+
]
|
| 465 |
+
```
|
| 466 |
+
|
| 467 |
+
> `net` = `gross` minus Snippe fee (0.5%) minus platform commission (varies by tenant, default 5%).
|
| 468 |
+
> Days with no sales are omitted — fill gaps in the frontend.
|
| 469 |
+
|
| 470 |
+
---
|
| 471 |
+
|
| 472 |
+
## 5. Tenant — Sessions
|
| 473 |
+
|
| 474 |
+
---
|
| 475 |
+
|
| 476 |
+
### GET `/api/sessions/active`
|
| 477 |
+
All currently active guest sessions across all devices.
|
| 478 |
+
|
| 479 |
+
**Auth:** Required
|
| 480 |
+
|
| 481 |
+
**Response `200`:**
|
| 482 |
+
```json
|
| 483 |
+
[
|
| 484 |
+
{
|
| 485 |
+
"id": 201,
|
| 486 |
+
"client_mac": "2A-61-D9-9B-90-7C",
|
| 487 |
+
"client_ip": "192.168.1.156",
|
| 488 |
+
"ssid": "Sunset Hostel WiFi",
|
| 489 |
+
"started_at": "2026-04-15T09:00:00.000Z",
|
| 490 |
+
"last_seen_at": "2026-04-15T11:25:00.000Z",
|
| 491 |
+
"bytes_down": 4321769,
|
| 492 |
+
"bytes_up": 6990620,
|
| 493 |
+
"duration_seconds": 8700,
|
| 494 |
+
"rssi": -42,
|
| 495 |
+
"signal_level": 80,
|
| 496 |
+
"device_name": "Main Building",
|
| 497 |
+
"token_code": "ABCD-1234",
|
| 498 |
+
"expires_at": "2026-04-15T11:00:00.000Z",
|
| 499 |
+
"plan_name": "2 Hour Plan"
|
| 500 |
+
}
|
| 501 |
+
]
|
| 502 |
+
```
|
| 503 |
+
|
| 504 |
+
---
|
| 505 |
+
|
| 506 |
+
### GET `/api/sessions`
|
| 507 |
+
Session history (active and ended).
|
| 508 |
+
|
| 509 |
+
**Auth:** Required
|
| 510 |
+
**Query params:**
|
| 511 |
+
- `deviceId` *(optional)* — filter by device
|
| 512 |
+
- `limit` *(optional, default 50)* — max records to return
|
| 513 |
+
|
| 514 |
+
**Response `200`:** Array of session objects including `ended_at` and `is_active`.
|
| 515 |
+
|
| 516 |
+
---
|
| 517 |
+
|
| 518 |
+
## 6. Tenant — Payouts
|
| 519 |
+
|
| 520 |
+
Tenants withdraw their earned balance via M-Pesa.
|
| 521 |
+
|
| 522 |
+
---
|
| 523 |
+
|
| 524 |
+
### GET `/api/payouts`
|
| 525 |
+
Payout history for the authenticated tenant (last 50).
|
| 526 |
+
|
| 527 |
+
**Auth:** Required
|
| 528 |
+
|
| 529 |
+
**Response `200`:**
|
| 530 |
+
```json
|
| 531 |
+
[
|
| 532 |
+
{
|
| 533 |
+
"id": 5,
|
| 534 |
+
"amount": 50000.00,
|
| 535 |
+
"payout_fee": 1500.00,
|
| 536 |
+
"net_amount": 48500.00,
|
| 537 |
+
"destination_phone": "0712345678",
|
| 538 |
+
"destination_provider": "mpesa",
|
| 539 |
+
"status": "completed",
|
| 540 |
+
"failure_reason": null,
|
| 541 |
+
"created_at": "2026-04-10T14:00:00.000Z",
|
| 542 |
+
"completed_at": "2026-04-10T14:02:00.000Z"
|
| 543 |
+
}
|
| 544 |
+
]
|
| 545 |
+
```
|
| 546 |
+
|
| 547 |
+
**`status` values:** `pending` · `processing` · `completed` · `failed`
|
| 548 |
+
|
| 549 |
+
---
|
| 550 |
+
|
| 551 |
+
### POST `/api/payouts`
|
| 552 |
+
Request a withdrawal to M-Pesa.
|
| 553 |
+
|
| 554 |
+
**Auth:** Required
|
| 555 |
+
|
| 556 |
+
**Body:**
|
| 557 |
+
```json
|
| 558 |
+
{
|
| 559 |
+
"amount": 50000,
|
| 560 |
+
"phone": "0712345678",
|
| 561 |
+
"provider": "mpesa"
|
| 562 |
+
}
|
| 563 |
+
```
|
| 564 |
+
|
| 565 |
+
**Validation:**
|
| 566 |
+
- `amount` minimum: 5,000 TZS
|
| 567 |
+
- `amount` must not exceed `balance`
|
| 568 |
+
- `provider` options: `mpesa` · `airtel` · `tigo` · `halopesa` (default: `mpesa`)
|
| 569 |
+
|
| 570 |
+
**Response `201`:**
|
| 571 |
+
```json
|
| 572 |
+
{
|
| 573 |
+
"id": 6,
|
| 574 |
+
"amount": 50000,
|
| 575 |
+
"payout_fee": 1500,
|
| 576 |
+
"net_amount": 48500,
|
| 577 |
+
"status": "processing",
|
| 578 |
+
"message": "Payout initiated. Funds will arrive shortly."
|
| 579 |
+
}
|
| 580 |
+
```
|
| 581 |
+
|
| 582 |
+
> Balance is deducted immediately on request. If the payout fails, balance is automatically restored. Tenant receives an SMS on completion or failure.
|
| 583 |
+
|
| 584 |
+
---
|
| 585 |
+
|
| 586 |
+
## 7. Tenant — Alerts
|
| 587 |
+
|
| 588 |
+
Alerts are AP events synced from the Omada controller (device offline, client blocked, etc.).
|
| 589 |
+
|
| 590 |
+
---
|
| 591 |
+
|
| 592 |
+
### GET `/api/alerts/unresolved`
|
| 593 |
+
All unresolved alerts for the tenant (last 50, newest first).
|
| 594 |
+
|
| 595 |
+
**Auth:** Required
|
| 596 |
+
|
| 597 |
+
**Response `200`:**
|
| 598 |
+
```json
|
| 599 |
+
[
|
| 600 |
+
{
|
| 601 |
+
"id": 12,
|
| 602 |
+
"alert_key": "DEV_DISCONN",
|
| 603 |
+
"level": "error",
|
| 604 |
+
"content": "[ap:24-2F-D0-DF-19-9C] was disconnected.",
|
| 605 |
+
"device_mac": "24-2F-D0-DF-19-9C",
|
| 606 |
+
"alert_time": "2026-04-15T08:00:00.000Z",
|
| 607 |
+
"fetched_at": "2026-04-15T08:15:00.000Z",
|
| 608 |
+
"device_name": "Main Building"
|
| 609 |
+
}
|
| 610 |
+
]
|
| 611 |
+
```
|
| 612 |
+
|
| 613 |
+
**`level` values:** `info` · `warning` · `error` · `critical`
|
| 614 |
+
**Common `alert_key` values:** `DEV_DISCONN` · `DEV_CONN` · `CLIENT_BLOCK`
|
| 615 |
+
|
| 616 |
+
---
|
| 617 |
+
|
| 618 |
+
### PATCH `/api/alerts/:id/resolve`
|
| 619 |
+
Mark an alert as resolved.
|
| 620 |
+
|
| 621 |
+
**Auth:** Required
|
| 622 |
+
|
| 623 |
+
**Response `200`:**
|
| 624 |
+
```json
|
| 625 |
+
{ "message": "Alert resolved" }
|
| 626 |
+
```
|
| 627 |
+
|
| 628 |
+
---
|
| 629 |
+
|
| 630 |
+
## 8. Super Admin
|
| 631 |
+
|
| 632 |
+
All admin endpoints require a static admin token passed as a Bearer token.
|
| 633 |
+
This is a **separate secret** from tenant JWTs — set via `ADMIN_TOKEN` environment variable.
|
| 634 |
+
|
| 635 |
+
```
|
| 636 |
+
Authorization: Bearer <ADMIN_TOKEN>
|
| 637 |
+
```
|
| 638 |
+
|
| 639 |
+
---
|
| 640 |
+
|
| 641 |
+
### GET `/api/admin/overview`
|
| 642 |
+
Platform-wide stats for the admin dashboard.
|
| 643 |
+
|
| 644 |
+
**Response `200`:**
|
| 645 |
+
```json
|
| 646 |
+
{
|
| 647 |
+
"tenants": 24,
|
| 648 |
+
"devices_online": 18,
|
| 649 |
+
"devices_offline": 3,
|
| 650 |
+
"devices_suspended": 2,
|
| 651 |
+
"guests_online": 47,
|
| 652 |
+
"sales_today": 312,
|
| 653 |
+
"gross_today": 312000.00,
|
| 654 |
+
"commission_today": 15600.00,
|
| 655 |
+
"snippe_fees_today": 1560.00,
|
| 656 |
+
"pending_payouts": 5,
|
| 657 |
+
"pending_payout_total": 230000.00
|
| 658 |
+
}
|
| 659 |
+
```
|
| 660 |
+
|
| 661 |
+
---
|
| 662 |
+
|
| 663 |
+
### GET `/api/admin/tenants`
|
| 664 |
+
List all tenants with device counts.
|
| 665 |
+
|
| 666 |
+
**Response `200`:**
|
| 667 |
+
```json
|
| 668 |
+
[
|
| 669 |
+
{
|
| 670 |
+
"id": 1,
|
| 671 |
+
"business_name": "Sunset Hostel",
|
| 672 |
+
"contact_name": "John Doe",
|
| 673 |
+
"email": "john@sunset.co.tz",
|
| 674 |
+
"phone": "0712345678",
|
| 675 |
+
"balance": 45000.00,
|
| 676 |
+
"total_earned": 320000.00,
|
| 677 |
+
"total_withdrawn": 275000.00,
|
| 678 |
+
"is_active": 1,
|
| 679 |
+
"created_at": "2026-03-01T08:00:00.000Z",
|
| 680 |
+
"device_count": 2,
|
| 681 |
+
"devices_online": 1
|
| 682 |
+
}
|
| 683 |
+
]
|
| 684 |
+
```
|
| 685 |
+
|
| 686 |
+
---
|
| 687 |
+
|
| 688 |
+
### GET `/api/admin/devices`
|
| 689 |
+
List all devices across all tenants.
|
| 690 |
+
|
| 691 |
+
**Response `200`:**
|
| 692 |
+
```json
|
| 693 |
+
[
|
| 694 |
+
{
|
| 695 |
+
"id": 3,
|
| 696 |
+
"name": "Main Building",
|
| 697 |
+
"location": "Floor 1",
|
| 698 |
+
"mac": "24-2F-D0-DF-19-9C",
|
| 699 |
+
"model": "EAP110-Outdoor",
|
| 700 |
+
"status": "online",
|
| 701 |
+
"billing_status": "active",
|
| 702 |
+
"billing_expires_at": "2026-05-15T00:00:00.000Z",
|
| 703 |
+
"monthly_fee": 20000.00,
|
| 704 |
+
"cpu_util": 12,
|
| 705 |
+
"mem_util": 63,
|
| 706 |
+
"guest_count": 4,
|
| 707 |
+
"last_seen_at": "2026-04-15T11:30:00.000Z",
|
| 708 |
+
"business_name": "Sunset Hostel",
|
| 709 |
+
"client_phone": "0712345678"
|
| 710 |
+
}
|
| 711 |
+
]
|
| 712 |
+
```
|
| 713 |
+
|
| 714 |
+
---
|
| 715 |
+
|
| 716 |
+
### GET `/api/admin/payouts/pending`
|
| 717 |
+
All payouts in `pending` or `processing` state across all tenants.
|
| 718 |
+
|
| 719 |
+
**Response `200`:**
|
| 720 |
+
```json
|
| 721 |
+
[
|
| 722 |
+
{
|
| 723 |
+
"id": 6,
|
| 724 |
+
"amount": 50000.00,
|
| 725 |
+
"payout_fee": 1500.00,
|
| 726 |
+
"net_amount": 48500.00,
|
| 727 |
+
"destination_phone": "0712345678",
|
| 728 |
+
"destination_provider": "mpesa",
|
| 729 |
+
"status": "processing",
|
| 730 |
+
"created_at": "2026-04-15T10:00:00.000Z",
|
| 731 |
+
"business_name": "Sunset Hostel",
|
| 732 |
+
"contact_name": "John Doe"
|
| 733 |
+
}
|
| 734 |
+
]
|
| 735 |
+
```
|
| 736 |
+
|
| 737 |
+
---
|
| 738 |
+
|
| 739 |
+
### GET `/api/admin/revenue/daily?days=30`
|
| 740 |
+
Daily revenue breakdown across the entire platform.
|
| 741 |
+
|
| 742 |
+
**Query:** `days` *(optional, default 30)*
|
| 743 |
+
|
| 744 |
+
**Response `200`:**
|
| 745 |
+
```json
|
| 746 |
+
[
|
| 747 |
+
{
|
| 748 |
+
"day": "2026-04-15",
|
| 749 |
+
"sales": 312,
|
| 750 |
+
"gross": 312000.00,
|
| 751 |
+
"commission": 15600.00,
|
| 752 |
+
"tenant_credit": 294840.00
|
| 753 |
+
}
|
| 754 |
+
]
|
| 755 |
+
```
|
| 756 |
+
|
| 757 |
+
---
|
| 758 |
+
|
| 759 |
+
### POST `/api/admin/devices/:id/reboot`
|
| 760 |
+
Send a reboot command to a specific AP via Omada.
|
| 761 |
+
|
| 762 |
+
**Response `200`:**
|
| 763 |
+
```json
|
| 764 |
+
{ "message": "Reboot command sent" }
|
| 765 |
+
```
|
| 766 |
+
|
| 767 |
+
---
|
| 768 |
+
|
| 769 |
+
## 9. Captive Portal (Public)
|
| 770 |
+
|
| 771 |
+
These endpoints are hit by the AP firmware redirect and the guest's browser. No authentication required.
|
| 772 |
+
|
| 773 |
+
---
|
| 774 |
+
|
| 775 |
+
### GET `/portal/:siteId?clientMac=&apMac=&ssidName=`
|
| 776 |
+
Render the captive portal page for a guest.
|
| 777 |
+
|
| 778 |
+
**Query params (injected by Omada redirect):**
|
| 779 |
+
- `clientMac` — guest's MAC address
|
| 780 |
+
- `apMac` — AP MAC address
|
| 781 |
+
- `ssidName` — SSID name
|
| 782 |
+
|
| 783 |
+
**Behavior:**
|
| 784 |
+
- If device is suspended/cancelled → renders **"Subscription Required"** page
|
| 785 |
+
- If guest MAC has a valid active token → **auto-reconnects** silently and renders **"Welcome Back"** page
|
| 786 |
+
- Otherwise → renders the **portal** with available plans
|
| 787 |
+
|
| 788 |
+
> This is a server-rendered EJS page, not a JSON API.
|
| 789 |
+
|
| 790 |
+
---
|
| 791 |
+
|
| 792 |
+
### POST `/portal/:siteId/purchase`
|
| 793 |
+
Guest initiates a WiFi plan purchase.
|
| 794 |
+
|
| 795 |
+
**Body:**
|
| 796 |
+
```json
|
| 797 |
+
{
|
| 798 |
+
"planId": 7,
|
| 799 |
+
"phone": "0712345678"
|
| 800 |
+
}
|
| 801 |
+
```
|
| 802 |
+
|
| 803 |
+
**Response `200`:**
|
| 804 |
+
```json
|
| 805 |
+
{
|
| 806 |
+
"reference": "abc123def456ghi789jkl",
|
| 807 |
+
"status": "pending"
|
| 808 |
+
}
|
| 809 |
+
```
|
| 810 |
+
|
| 811 |
+
> An M-Pesa payment prompt is sent to `phone`. Poll `/check/:reference` to track completion.
|
| 812 |
+
|
| 813 |
+
---
|
| 814 |
+
|
| 815 |
+
### GET `/portal/:siteId/check/:reference`
|
| 816 |
+
Poll payment status after purchase.
|
| 817 |
+
|
| 818 |
+
**Response — pending:**
|
| 819 |
+
```json
|
| 820 |
+
{ "status": "pending" }
|
| 821 |
+
```
|
| 822 |
+
|
| 823 |
+
**Response — paid:**
|
| 824 |
+
```json
|
| 825 |
+
{
|
| 826 |
+
"status": "paid",
|
| 827 |
+
"code": "ABCD-1234",
|
| 828 |
+
"plan_name": "1 Hour Browsing"
|
| 829 |
+
}
|
| 830 |
+
```
|
| 831 |
+
|
| 832 |
+
**Response — failed:**
|
| 833 |
+
```json
|
| 834 |
+
{ "status": "failed" }
|
| 835 |
+
```
|
| 836 |
+
|
| 837 |
+
---
|
| 838 |
+
|
| 839 |
+
### POST `/portal/:siteId/auth`
|
| 840 |
+
Authorize a guest on the WiFi using their access code.
|
| 841 |
+
|
| 842 |
+
**Body:**
|
| 843 |
+
```json
|
| 844 |
+
{
|
| 845 |
+
"code": "ABCD-1234",
|
| 846 |
+
"clientMac": "2a:61:d9:9b:90:7c"
|
| 847 |
+
}
|
| 848 |
+
```
|
| 849 |
+
|
| 850 |
+
**Response `200`:**
|
| 851 |
+
```json
|
| 852 |
+
{
|
| 853 |
+
"success": true,
|
| 854 |
+
"code": "ABCD-1234",
|
| 855 |
+
"expiresIn": 3600,
|
| 856 |
+
"expiresAt": "2026-04-15T13:00:00.000Z",
|
| 857 |
+
"speedDown": "5Mbps",
|
| 858 |
+
"speedUp": "2Mbps"
|
| 859 |
+
}
|
| 860 |
+
```
|
| 861 |
+
|
| 862 |
+
**Error responses:**
|
| 863 |
+
- `404` — code not found
|
| 864 |
+
- `403` — code locked to a different device MAC
|
| 865 |
+
- `410` — code expired
|
| 866 |
+
- `503` — AP offline, authorization failed
|
| 867 |
+
|
| 868 |
+
> The code is locked to the guest's MAC on first use. The same guest can reconnect later using the same code — they'll be re-authorized automatically without re-entering it.
|
| 869 |
+
|
| 870 |
+
---
|
| 871 |
+
|
| 872 |
+
## 10. Error Format
|
| 873 |
+
|
| 874 |
+
All errors follow this shape:
|
| 875 |
+
|
| 876 |
+
```json
|
| 877 |
+
{ "error": "Human-readable message" }
|
| 878 |
+
```
|
| 879 |
+
|
| 880 |
+
| Status | Meaning |
|
| 881 |
+
|--------|---------|
|
| 882 |
+
| `400` | Bad request — missing or invalid fields |
|
| 883 |
+
| `401` | Not authenticated or invalid token |
|
| 884 |
+
| `403` | Forbidden — resource belongs to another user |
|
| 885 |
+
| `404` | Resource not found |
|
| 886 |
+
| `409` | Conflict — duplicate email, phone, or MAC |
|
| 887 |
+
| `410` | Gone — token expired or revoked |
|
| 888 |
+
| `500` | Server error |
|
| 889 |
+
| `502` | Upstream error (Omada or Snippe unreachable) |
|
| 890 |
+
| `503` | Service unavailable (AP offline) |
|
| 891 |
+
|
| 892 |
+
---
|
| 893 |
+
|
| 894 |
+
## 11. Enums & Constants
|
| 895 |
+
|
| 896 |
+
### Device `status`
|
| 897 |
+
| Value | Meaning |
|
| 898 |
+
|-------|---------|
|
| 899 |
+
| `pending` | Registered, AP not yet visible to Omada |
|
| 900 |
+
| `adopting` | Omada adoption in progress |
|
| 901 |
+
| `online` | AP connected and serving guests |
|
| 902 |
+
| `offline` | AP disconnected |
|
| 903 |
+
| `removed` | Decommissioned |
|
| 904 |
+
|
| 905 |
+
### Device `billing_status`
|
| 906 |
+
| Value | Meaning |
|
| 907 |
+
|-------|---------|
|
| 908 |
+
| `trial` | Free trial period (default 14 days) |
|
| 909 |
+
| `active` | Subscription paid and active |
|
| 910 |
+
| `suspended` | Billing expired or manually paused — portal blocked |
|
| 911 |
+
| `cancelled` | Permanently decommissioned |
|
| 912 |
+
|
| 913 |
+
### Access token `status`
|
| 914 |
+
| Value | Meaning |
|
| 915 |
+
|-------|---------|
|
| 916 |
+
| `unused` | Paid for, not yet activated |
|
| 917 |
+
| `active` | In use — locked to a MAC |
|
| 918 |
+
| `expired` | Time ran out |
|
| 919 |
+
| `revoked` | Cancelled (device suspended/paused) |
|
| 920 |
+
|
| 921 |
+
### Speed `unit`
|
| 922 |
+
| Value | Meaning |
|
| 923 |
+
|-------|---------|
|
| 924 |
+
| `1` | Kbps |
|
| 925 |
+
| `2` | Mbps |
|
| 926 |
+
|
| 927 |
+
### Alert `level`
|
| 928 |
+
| Value | Meaning |
|
| 929 |
+
|-------|---------|
|
| 930 |
+
| `info` | Informational |
|
| 931 |
+
| `warning` | Non-critical issue |
|
| 932 |
+
| `error` | AP disconnected or serious event |
|
| 933 |
+
| `critical` | Requires immediate action |
|
| 934 |
+
|
| 935 |
+
### Payout / Payment `status`
|
| 936 |
+
| Value | Meaning |
|
| 937 |
+
|-------|---------|
|
| 938 |
+
| `pending` | Created, waiting for M-Pesa |
|
| 939 |
+
| `processing` | M-Pesa prompt sent |
|
| 940 |
+
| `completed` | Funds transferred |
|
| 941 |
+
| `failed` | Payment/payout failed |
|
| 942 |
+
|
| 943 |
+
---
|
| 944 |
+
|
| 945 |
+
## 12. UI Building Guide
|
| 946 |
+
|
| 947 |
+
### Tenant Dashboard — Recommended Pages
|
| 948 |
+
|
| 949 |
+
| Page | Key endpoints |
|
| 950 |
+
|------|--------------|
|
| 951 |
+
| Login / Register | `POST /api/auth/login`, `POST /api/auth/register` |
|
| 952 |
+
| Dashboard Home | `GET /api/dashboard/summary`, `GET /api/dashboard/revenue/weekly` |
|
| 953 |
+
| Devices List | `GET /api/devices` |
|
| 954 |
+
| Device Detail | `GET /api/devices/:id`, `GET /api/plans?deviceId=`, `GET /api/sessions/active` |
|
| 955 |
+
| Add Device | `POST /api/devices` |
|
| 956 |
+
| Manage Plans | `POST /api/plans`, `PUT /api/plans/:id`, `DELETE /api/plans/:id` |
|
| 957 |
+
| Session History | `GET /api/sessions` |
|
| 958 |
+
| Payouts | `GET /api/payouts`, `POST /api/payouts` |
|
| 959 |
+
| Alerts | `GET /api/alerts/unresolved`, `PATCH /api/alerts/:id/resolve` |
|
| 960 |
+
| Profile | `GET /api/auth/me` |
|
| 961 |
+
|
| 962 |
+
### Super Admin Panel — Recommended Pages
|
| 963 |
+
|
| 964 |
+
| Page | Key endpoints |
|
| 965 |
+
|------|--------------|
|
| 966 |
+
| Admin Overview | `GET /api/admin/overview` |
|
| 967 |
+
| Tenants List | `GET /api/admin/tenants` |
|
| 968 |
+
| Devices List | `GET /api/admin/devices` |
|
| 969 |
+
| Pending Payouts | `GET /api/admin/payouts/pending` |
|
| 970 |
+
| Revenue Chart | `GET /api/admin/revenue/daily?days=30` |
|
| 971 |
+
| Device Actions | `POST /api/admin/devices/:id/reboot` |
|
| 972 |
+
|
| 973 |
+
### Key UX Notes for Frontend
|
| 974 |
+
|
| 975 |
+
**Device status indicators:**
|
| 976 |
+
- Show `online` as green, `offline` as red, `adopting` as yellow spinner, `pending` as grey
|
| 977 |
+
- `billing_status = 'suspended'` should show a prominent "Renew" CTA regardless of `status`
|
| 978 |
+
- Show `billing_expires_at` with a warning if within 3 days
|
| 979 |
+
|
| 980 |
+
**Balance display:**
|
| 981 |
+
- `balance` = available to withdraw right now
|
| 982 |
+
- `total_earned - total_withdrawn` should equal `balance` (use for sanity check display)
|
| 983 |
+
- Show earnings chart from `revenue/weekly` on dashboard home
|
| 984 |
+
|
| 985 |
+
**Payout flow:**
|
| 986 |
+
1. Show current `balance` from `GET /api/auth/me`
|
| 987 |
+
2. `POST /api/payouts` — deducts balance immediately on submit
|
| 988 |
+
3. Poll or refresh `GET /api/payouts` to show updated status
|
| 989 |
+
4. Show `net_amount` (after fee) prominently, not `amount`
|
| 990 |
+
|
| 991 |
+
**Device renewal flow:**
|
| 992 |
+
1. `POST /api/devices/:id/renew`
|
| 993 |
+
2. Inform user to check their phone for M-Pesa prompt
|
| 994 |
+
3. Poll `GET /api/devices/:id` until `billing_status = 'active'`
|
| 995 |
+
|
| 996 |
+
**Alert badge:**
|
| 997 |
+
- Use `alert_count` from `GET /api/dashboard/summary` for the sidebar badge
|
| 998 |
+
- Refresh count after `PATCH /api/alerts/:id/resolve`
|
| 999 |
+
|
| 1000 |
+
**Data formatting:**
|
| 1001 |
+
- All amounts are TZS (Tanzanian Shilling) — display with `.toLocaleString()`
|
| 1002 |
+
- `bytes_down` / `bytes_up` are raw bytes — convert to MB/GB for display
|
| 1003 |
+
- `duration_seconds` — convert to human readable (e.g. `3600` → `1h`, `86400` → `1 day`)
|
| 1004 |
+
- All timestamps are UTC ISO strings — convert to local time for display
|
| 1005 |
+
|
| 1006 |
+
**Icons and visuals:**
|
| 1007 |
+
- **Do not use emojis** anywhere in the UI. Use SVG icons exclusively (e.g. Lucide, Heroicons, Feather, or inline SVG).
|
| 1008 |
+
- Emojis render inconsistently across operating systems, fonts, and browsers — SVGs are predictable and styleable.
|
| 1009 |
+
- Icon stroke color should use the active theme's primary color or semantic colors (green for success, amber for warning, red for error).
|
| 1010 |
+
- Recommended semantic icon mappings:
|
| 1011 |
+
- WiFi / connected → `wifi` icon, stroke = primary color
|
| 1012 |
+
- Success / authorized → `check-circle` icon, stroke = `#22c55e`
|
| 1013 |
+
- Suspended / locked → `lock` icon, stroke = `#f59e0b`
|
| 1014 |
+
- Error / failed → `x-circle` icon, stroke = `#ef4444`
|
| 1015 |
+
- Offline / disconnected → `wifi-off` icon, stroke = `#9ca3af`
|
| 1016 |
+
- Device / AP → `radio` or `router` icon
|
| 1017 |
+
- Payout → `send` or `credit-card` icon
|
| 1018 |
+
- Alert → `alert-triangle` icon, stroke = `#f59e0b`
|
Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package*.json ./
|
| 4 |
+
RUN npm ci --omit=dev
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 3000
|
| 7 |
+
CMD ["node", "src/server.js"]
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
mysql:
|
| 5 |
+
image: mysql:8.0
|
| 6 |
+
container_name: wifi_mysql
|
| 7 |
+
restart: unless-stopped
|
| 8 |
+
environment:
|
| 9 |
+
MYSQL_ROOT_PASSWORD: rootpass
|
| 10 |
+
MYSQL_DATABASE: wifiplatform
|
| 11 |
+
MYSQL_USER: wifiuser
|
| 12 |
+
MYSQL_PASSWORD: wifipass
|
| 13 |
+
ports:
|
| 14 |
+
- "3306:3306"
|
| 15 |
+
volumes:
|
| 16 |
+
- mysql_data:/var/lib/mysql
|
| 17 |
+
- ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql
|
| 18 |
+
command: >
|
| 19 |
+
--character-set-server=utf8mb4
|
| 20 |
+
--collation-server=utf8mb4_unicode_ci
|
| 21 |
+
--default-authentication-plugin=mysql_native_password
|
| 22 |
+
healthcheck:
|
| 23 |
+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "wifiuser", "-pwifipass"]
|
| 24 |
+
interval: 10s
|
| 25 |
+
timeout: 5s
|
| 26 |
+
retries: 5
|
| 27 |
+
|
| 28 |
+
app:
|
| 29 |
+
build: .
|
| 30 |
+
container_name: wifi_app
|
| 31 |
+
restart: unless-stopped
|
| 32 |
+
depends_on:
|
| 33 |
+
mysql:
|
| 34 |
+
condition: service_healthy
|
| 35 |
+
env_file: .env
|
| 36 |
+
environment:
|
| 37 |
+
DB_HOST: mysql
|
| 38 |
+
ports:
|
| 39 |
+
- "3000:3000"
|
| 40 |
+
volumes:
|
| 41 |
+
- ./public:/app/public
|
| 42 |
+
|
| 43 |
+
volumes:
|
| 44 |
+
mysql_data:
|
package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "wifi-platform",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Multi-tenant WiFi hotspot platform",
|
| 5 |
+
"main": "src/server.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"start": "node src/server.js",
|
| 8 |
+
"dev": "nodemon src/server.js"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"axios": "^1.6.8",
|
| 12 |
+
"bcryptjs": "^2.4.3",
|
| 13 |
+
"cors": "^2.8.5",
|
| 14 |
+
"dotenv": "^16.4.5",
|
| 15 |
+
"ejs": "^3.1.10",
|
| 16 |
+
"express": "^4.18.3",
|
| 17 |
+
"express-rate-limit": "^7.3.1",
|
| 18 |
+
"jsonwebtoken": "^9.0.2",
|
| 19 |
+
"mysql2": "^3.9.2",
|
| 20 |
+
"node-cron": "^3.0.3",
|
| 21 |
+
"uuid": "^9.0.1"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"nodemon": "^3.1.0"
|
| 25 |
+
}
|
| 26 |
+
}
|
public/engine.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WiFi Portal Engine
|
| 3 |
+
* Handles the guest payment & authorization flow.
|
| 4 |
+
* Requires window.PORTAL to be set before this script loads.
|
| 5 |
+
*/
|
| 6 |
+
(function () {
|
| 7 |
+
'use strict';
|
| 8 |
+
|
| 9 |
+
const P = window.PORTAL;
|
| 10 |
+
if (!P) return;
|
| 11 |
+
|
| 12 |
+
// ── DOM refs ──────────────────────────────────────────────────────────────
|
| 13 |
+
const $ = id => document.getElementById(id);
|
| 14 |
+
|
| 15 |
+
const screens = {
|
| 16 |
+
select: $('screen-select'),
|
| 17 |
+
waiting: $('screen-waiting'),
|
| 18 |
+
code: $('screen-code'),
|
| 19 |
+
success: $('screen-success'),
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
function showScreen(name) {
|
| 23 |
+
Object.values(screens).forEach(s => s && s.classList.remove('active'));
|
| 24 |
+
if (screens[name]) screens[name].classList.add('active');
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function showError(elId, msg) {
|
| 28 |
+
const el = $(elId);
|
| 29 |
+
if (!el) return;
|
| 30 |
+
el.textContent = msg;
|
| 31 |
+
el.classList.add('show');
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function clearError(elId) {
|
| 35 |
+
const el = $(elId);
|
| 36 |
+
if (el) { el.textContent = ''; el.classList.remove('show'); }
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// ── State ─────────────────────────────────────────────────────────────────
|
| 40 |
+
let selectedPlanId = null;
|
| 41 |
+
let selectedPlanPrice = null;
|
| 42 |
+
let pollTimer = null;
|
| 43 |
+
let currentReference = null;
|
| 44 |
+
|
| 45 |
+
// ── Plan selection ────────────────────────────────────────────────────────
|
| 46 |
+
const planBtns = document.querySelectorAll('.plan-btn');
|
| 47 |
+
const payBtn = $('pay-btn');
|
| 48 |
+
const phoneInput = $('phone-input');
|
| 49 |
+
|
| 50 |
+
planBtns.forEach(btn => {
|
| 51 |
+
btn.addEventListener('click', () => {
|
| 52 |
+
planBtns.forEach(b => b.classList.remove('selected'));
|
| 53 |
+
btn.classList.add('selected');
|
| 54 |
+
selectedPlanId = btn.dataset.planId;
|
| 55 |
+
selectedPlanPrice = btn.dataset.planPrice;
|
| 56 |
+
if (payBtn) {
|
| 57 |
+
payBtn.textContent = `Pay ${parseInt(selectedPlanPrice).toLocaleString()} TZS & Connect`;
|
| 58 |
+
payBtn.disabled = false;
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
// ── Pay button ────────────────────────────────────────────────────────────
|
| 64 |
+
if (payBtn) {
|
| 65 |
+
payBtn.addEventListener('click', async () => {
|
| 66 |
+
clearError('select-error');
|
| 67 |
+
const phone = phoneInput?.value?.trim();
|
| 68 |
+
if (!selectedPlanId) return showError('select-error', 'Please select a plan.');
|
| 69 |
+
if (!phone || phone.replace(/\D/g, '').length < 9) {
|
| 70 |
+
return showError('select-error', 'Enter a valid phone number.');
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
payBtn.disabled = true;
|
| 74 |
+
showScreen('waiting');
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const res = await fetch(`/portal/${P.siteId}/purchase`, {
|
| 78 |
+
method: 'POST',
|
| 79 |
+
headers: { 'Content-Type': 'application/json' },
|
| 80 |
+
body: JSON.stringify({ planId: selectedPlanId, phone }),
|
| 81 |
+
});
|
| 82 |
+
const data = await res.json();
|
| 83 |
+
|
| 84 |
+
if (!res.ok || !data.reference) {
|
| 85 |
+
showScreen('select');
|
| 86 |
+
payBtn.disabled = false;
|
| 87 |
+
return showError('select-error', data.error || 'Purchase failed. Try again.');
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
currentReference = data.reference;
|
| 91 |
+
startPolling(data.reference);
|
| 92 |
+
} catch (err) {
|
| 93 |
+
showScreen('select');
|
| 94 |
+
payBtn.disabled = false;
|
| 95 |
+
showError('select-error', 'Network error. Please try again.');
|
| 96 |
+
}
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ── Polling ───────────────────────────────────────────────────────────────
|
| 101 |
+
function startPolling(reference) {
|
| 102 |
+
let attempts = 0;
|
| 103 |
+
const MAX = 100; // ~5 min at 3s
|
| 104 |
+
|
| 105 |
+
clearInterval(pollTimer);
|
| 106 |
+
pollTimer = setInterval(async () => {
|
| 107 |
+
attempts++;
|
| 108 |
+
if (attempts > MAX) {
|
| 109 |
+
clearInterval(pollTimer);
|
| 110 |
+
showScreen('select');
|
| 111 |
+
if (payBtn) payBtn.disabled = false;
|
| 112 |
+
showError('select-error', 'Payment timed out. Please try again.');
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
try {
|
| 117 |
+
const res = await fetch(`/portal/${P.siteId}/check/${reference}`);
|
| 118 |
+
const data = await res.json();
|
| 119 |
+
|
| 120 |
+
if (data.status === 'paid' && data.code) {
|
| 121 |
+
clearInterval(pollTimer);
|
| 122 |
+
await authorizeWithCode(data.code);
|
| 123 |
+
} else if (data.status === 'failed') {
|
| 124 |
+
clearInterval(pollTimer);
|
| 125 |
+
showScreen('select');
|
| 126 |
+
if (payBtn) payBtn.disabled = false;
|
| 127 |
+
showError('select-error', 'Payment was declined. Please try again.');
|
| 128 |
+
}
|
| 129 |
+
} catch { /* network blip — keep polling */ }
|
| 130 |
+
}, 3000);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Cancel button
|
| 134 |
+
const cancelBtn = $('cancel-btn');
|
| 135 |
+
if (cancelBtn) {
|
| 136 |
+
cancelBtn.addEventListener('click', () => {
|
| 137 |
+
clearInterval(pollTimer);
|
| 138 |
+
showScreen('select');
|
| 139 |
+
if (payBtn) payBtn.disabled = false;
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// ── Auth with code ────────────────────────────────────────────────────────
|
| 144 |
+
async function authorizeWithCode(code) {
|
| 145 |
+
try {
|
| 146 |
+
const res = await fetch(`/portal/${P.siteId}/auth`, {
|
| 147 |
+
method: 'POST',
|
| 148 |
+
headers: { 'Content-Type': 'application/json' },
|
| 149 |
+
body: JSON.stringify({ code, clientMac: P.clientMac }),
|
| 150 |
+
});
|
| 151 |
+
const data = await res.json();
|
| 152 |
+
|
| 153 |
+
if (!res.ok || !data.success) {
|
| 154 |
+
showScreen('select');
|
| 155 |
+
if (payBtn) payBtn.disabled = false;
|
| 156 |
+
showError('select-error', data.error || 'Authorization failed. Enter your code manually.');
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
showSuccess(data);
|
| 161 |
+
} catch (err) {
|
| 162 |
+
// Show code entry as fallback so guest can manually enter
|
| 163 |
+
showCodeEntry(code);
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function showSuccess(data) {
|
| 168 |
+
const codeEl = $('success-code');
|
| 169 |
+
if (codeEl) codeEl.textContent = data.code || '—';
|
| 170 |
+
|
| 171 |
+
const metaEl = $('success-meta');
|
| 172 |
+
if (metaEl) {
|
| 173 |
+
const hours = data.expiresIn ? Math.ceil(data.expiresIn / 3600) : '?';
|
| 174 |
+
metaEl.innerHTML = `
|
| 175 |
+
<div class="meta-row"><span>Speed</span><strong>${data.speedDown} ↓ / ${data.speedUp} ↑</strong></div>
|
| 176 |
+
<div class="meta-row"><span>Valid for</span><strong>${hours < 24 ? hours + 'h' : Math.ceil(hours/24) + 'd'}</strong></div>
|
| 177 |
+
<div class="meta-row"><span>Reconnect?</span><strong>Enter code above on login page</strong></div>
|
| 178 |
+
`;
|
| 179 |
+
}
|
| 180 |
+
showScreen('success');
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// ── Code entry (reconnect) ────────────────────────────────────────────────
|
| 184 |
+
const showCodeEntryLink = $('show-code-entry');
|
| 185 |
+
const showPlansLink = $('show-plans');
|
| 186 |
+
const codeInput = $('code-input');
|
| 187 |
+
const codeSubmitBtn = $('code-submit-btn');
|
| 188 |
+
|
| 189 |
+
if (showCodeEntryLink) {
|
| 190 |
+
showCodeEntryLink.addEventListener('click', () => {
|
| 191 |
+
clearError('code-error');
|
| 192 |
+
showScreen('code');
|
| 193 |
+
});
|
| 194 |
+
}
|
| 195 |
+
if (showPlansLink) {
|
| 196 |
+
showPlansLink.addEventListener('click', () => showScreen('select'));
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function showCodeEntry(prefill) {
|
| 200 |
+
if (codeInput && prefill) codeInput.value = prefill;
|
| 201 |
+
clearError('code-error');
|
| 202 |
+
showScreen('code');
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
if (codeInput) {
|
| 206 |
+
codeInput.addEventListener('input', () => {
|
| 207 |
+
// Auto-insert dash after 4 chars
|
| 208 |
+
let v = codeInput.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
| 209 |
+
if (v.length > 4) v = v.slice(0, 4) + '-' + v.slice(4, 8);
|
| 210 |
+
codeInput.value = v;
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
if (codeSubmitBtn) {
|
| 215 |
+
codeSubmitBtn.addEventListener('click', async () => {
|
| 216 |
+
clearError('code-error');
|
| 217 |
+
const code = codeInput?.value?.trim();
|
| 218 |
+
if (!code || code.length < 9) {
|
| 219 |
+
return showError('code-error', 'Enter a valid 8-character code (XXXX-XXXX).');
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
codeSubmitBtn.disabled = true;
|
| 223 |
+
codeSubmitBtn.textContent = 'Connecting...';
|
| 224 |
+
|
| 225 |
+
try {
|
| 226 |
+
const res = await fetch(`/portal/${P.siteId}/auth`, {
|
| 227 |
+
method: 'POST',
|
| 228 |
+
headers: { 'Content-Type': 'application/json' },
|
| 229 |
+
body: JSON.stringify({ code, clientMac: P.clientMac }),
|
| 230 |
+
});
|
| 231 |
+
const data = await res.json();
|
| 232 |
+
|
| 233 |
+
if (!res.ok || !data.success) {
|
| 234 |
+
showError('code-error', data.error || 'Code not valid or expired.');
|
| 235 |
+
} else {
|
| 236 |
+
showSuccess(data);
|
| 237 |
+
}
|
| 238 |
+
} catch {
|
| 239 |
+
showError('code-error', 'Network error. Please try again.');
|
| 240 |
+
} finally {
|
| 241 |
+
codeSubmitBtn.disabled = false;
|
| 242 |
+
codeSubmitBtn.textContent = 'Connect';
|
| 243 |
+
}
|
| 244 |
+
});
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// If clientMac is missing (portal opened in browser), warn in console only
|
| 248 |
+
if (!P.clientMac) {
|
| 249 |
+
console.warn('[WiFi Portal] No clientMac — running in preview mode');
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
})();
|
schema.sql
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =============================================
|
| 2 |
+
-- OMADA MULTI-TENANT WIFI PLATFORM
|
| 3 |
+
-- Final Schema
|
| 4 |
+
-- =============================================
|
| 5 |
+
|
| 6 |
+
SET NAMES utf8mb4;
|
| 7 |
+
SET FOREIGN_KEY_CHECKS = 0;
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
-- =============================================
|
| 11 |
+
-- TABLE 1: clients
|
| 12 |
+
-- =============================================
|
| 13 |
+
DROP TABLE IF EXISTS `clients`;
|
| 14 |
+
CREATE TABLE `clients` (
|
| 15 |
+
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 16 |
+
`business_name` VARCHAR(100) NOT NULL,
|
| 17 |
+
`contact_name` VARCHAR(100) NOT NULL,
|
| 18 |
+
`email` VARCHAR(255) NOT NULL,
|
| 19 |
+
`phone` VARCHAR(20) NOT NULL,
|
| 20 |
+
`password_hash` VARCHAR(255) NOT NULL,
|
| 21 |
+
`commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
|
| 22 |
+
`balance` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
| 23 |
+
`total_earned` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
| 24 |
+
`total_withdrawn` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
| 25 |
+
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
| 26 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 27 |
+
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 28 |
+
UNIQUE KEY `uk_email` (`email`),
|
| 29 |
+
UNIQUE KEY `uk_phone` (`phone`)
|
| 30 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
-- =============================================
|
| 34 |
+
-- TABLE 2: client_staff
|
| 35 |
+
-- =============================================
|
| 36 |
+
DROP TABLE IF EXISTS `client_staff`;
|
| 37 |
+
CREATE TABLE `client_staff` (
|
| 38 |
+
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 39 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 40 |
+
`name` VARCHAR(100) NOT NULL,
|
| 41 |
+
`email` VARCHAR(255) NOT NULL,
|
| 42 |
+
`phone` VARCHAR(20) DEFAULT NULL,
|
| 43 |
+
`password_hash` VARCHAR(255) NOT NULL,
|
| 44 |
+
`role` ENUM('manager','viewer') NOT NULL DEFAULT 'viewer',
|
| 45 |
+
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
| 46 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 47 |
+
UNIQUE KEY `uk_staff_email` (`email`),
|
| 48 |
+
KEY `idx_staff_client` (`client_id`),
|
| 49 |
+
CONSTRAINT `fk_staff_client` FOREIGN KEY (`client_id`)
|
| 50 |
+
REFERENCES `clients`(`id`) ON DELETE CASCADE
|
| 51 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
-- =============================================
|
| 55 |
+
-- TABLE 3: devices
|
| 56 |
+
-- =============================================
|
| 57 |
+
DROP TABLE IF EXISTS `devices`;
|
| 58 |
+
CREATE TABLE `devices` (
|
| 59 |
+
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 60 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 61 |
+
`name` VARCHAR(100) NOT NULL,
|
| 62 |
+
`location` VARCHAR(255) DEFAULT NULL,
|
| 63 |
+
`omada_site_id` VARCHAR(64) DEFAULT NULL,
|
| 64 |
+
`omada_portal_id` VARCHAR(64) DEFAULT NULL,
|
| 65 |
+
`device_username` VARCHAR(50) DEFAULT NULL,
|
| 66 |
+
`device_password` VARCHAR(100) DEFAULT NULL,
|
| 67 |
+
`mac` VARCHAR(17) NOT NULL,
|
| 68 |
+
`model` VARCHAR(50) DEFAULT NULL,
|
| 69 |
+
`firmware_version` VARCHAR(100) DEFAULT NULL,
|
| 70 |
+
`ip` VARCHAR(45) DEFAULT NULL,
|
| 71 |
+
`public_ip` VARCHAR(45) DEFAULT NULL,
|
| 72 |
+
`portal_html_path` VARCHAR(255) DEFAULT NULL,
|
| 73 |
+
`status` ENUM('pending','adopting','online','offline','removed') NOT NULL DEFAULT 'pending',
|
| 74 |
+
`registered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 75 |
+
`adopted_at` TIMESTAMP NULL DEFAULT NULL,
|
| 76 |
+
`last_seen_at` TIMESTAMP NULL DEFAULT NULL,
|
| 77 |
+
`billing_status` ENUM('trial','active','suspended','cancelled') NOT NULL DEFAULT 'trial',
|
| 78 |
+
`billing_expires_at` TIMESTAMP NULL DEFAULT NULL,
|
| 79 |
+
`monthly_fee` DECIMAL(10,2) NOT NULL DEFAULT 20000.00,
|
| 80 |
+
`cpu_util` TINYINT UNSIGNED DEFAULT NULL,
|
| 81 |
+
`mem_util` TINYINT UNSIGNED DEFAULT NULL,
|
| 82 |
+
`client_count` SMALLINT UNSIGNED DEFAULT 0,
|
| 83 |
+
`guest_count` SMALLINT UNSIGNED DEFAULT 0,
|
| 84 |
+
`uptime_seconds` INT UNSIGNED DEFAULT 0,
|
| 85 |
+
`download_bytes` BIGINT UNSIGNED DEFAULT 0,
|
| 86 |
+
`upload_bytes` BIGINT UNSIGNED DEFAULT 0,
|
| 87 |
+
`needs_upgrade` TINYINT(1) NOT NULL DEFAULT 0,
|
| 88 |
+
UNIQUE KEY `uk_device_mac` (`mac`),
|
| 89 |
+
UNIQUE KEY `uk_omada_site` (`omada_site_id`),
|
| 90 |
+
KEY `idx_device_client` (`client_id`),
|
| 91 |
+
KEY `idx_device_status` (`status`),
|
| 92 |
+
KEY `idx_device_billing` (`billing_status`, `billing_expires_at`),
|
| 93 |
+
CONSTRAINT `fk_device_client` FOREIGN KEY (`client_id`)
|
| 94 |
+
REFERENCES `clients`(`id`)
|
| 95 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
-- =============================================
|
| 99 |
+
-- TABLE 4: device_payments
|
| 100 |
+
-- =============================================
|
| 101 |
+
DROP TABLE IF EXISTS `device_payments`;
|
| 102 |
+
CREATE TABLE `device_payments` (
|
| 103 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 104 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 105 |
+
`device_id` INT UNSIGNED NOT NULL,
|
| 106 |
+
`amount` DECIMAL(10,2) NOT NULL DEFAULT 20000.00,
|
| 107 |
+
`reference` VARCHAR(64) NOT NULL,
|
| 108 |
+
`snippe_payment_id` VARCHAR(100) DEFAULT NULL,
|
| 109 |
+
`period_start` DATE NOT NULL,
|
| 110 |
+
`period_end` DATE NOT NULL,
|
| 111 |
+
`status` ENUM('pending','completed','failed') NOT NULL DEFAULT 'pending',
|
| 112 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 113 |
+
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
| 114 |
+
UNIQUE KEY `uk_devpay_ref` (`reference`),
|
| 115 |
+
KEY `idx_devpay_client` (`client_id`),
|
| 116 |
+
KEY `idx_devpay_device` (`device_id`),
|
| 117 |
+
KEY `idx_devpay_status` (`status`),
|
| 118 |
+
CONSTRAINT `fk_devpay_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
|
| 119 |
+
CONSTRAINT `fk_devpay_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`)
|
| 120 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
-- =============================================
|
| 124 |
+
-- TABLE 5: device_status_log
|
| 125 |
+
-- =============================================
|
| 126 |
+
DROP TABLE IF EXISTS `device_status_log`;
|
| 127 |
+
CREATE TABLE `device_status_log` (
|
| 128 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 129 |
+
`device_id` INT UNSIGNED NOT NULL,
|
| 130 |
+
`old_status` VARCHAR(20) NOT NULL,
|
| 131 |
+
`new_status` VARCHAR(20) NOT NULL,
|
| 132 |
+
`reason` VARCHAR(255) DEFAULT NULL,
|
| 133 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 134 |
+
KEY `idx_devlog_device` (`device_id`),
|
| 135 |
+
KEY `idx_devlog_time` (`created_at`),
|
| 136 |
+
CONSTRAINT `fk_devlog_device` FOREIGN KEY (`device_id`)
|
| 137 |
+
REFERENCES `devices`(`id`) ON DELETE CASCADE
|
| 138 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
-- =============================================
|
| 142 |
+
-- TABLE 6: wifi_plans
|
| 143 |
+
-- =============================================
|
| 144 |
+
DROP TABLE IF EXISTS `wifi_plans`;
|
| 145 |
+
CREATE TABLE `wifi_plans` (
|
| 146 |
+
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 147 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 148 |
+
`device_id` INT UNSIGNED NOT NULL,
|
| 149 |
+
`name` VARCHAR(50) NOT NULL,
|
| 150 |
+
`duration_seconds` INT UNSIGNED NOT NULL,
|
| 151 |
+
`price` DECIMAL(10,2) NOT NULL,
|
| 152 |
+
`down_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
|
| 153 |
+
`down_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT '1=Kbps 2=Mbps',
|
| 154 |
+
`up_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
|
| 155 |
+
`up_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT '1=Kbps 2=Mbps',
|
| 156 |
+
`display_order` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
| 157 |
+
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
| 158 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 159 |
+
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 160 |
+
KEY `idx_plan_client` (`client_id`),
|
| 161 |
+
KEY `idx_plan_device` (`device_id`),
|
| 162 |
+
KEY `idx_plan_active` (`device_id`, `is_active`, `display_order`),
|
| 163 |
+
CONSTRAINT `fk_plan_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
|
| 164 |
+
CONSTRAINT `fk_plan_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`)
|
| 165 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
-- =============================================
|
| 169 |
+
-- TABLE 7: payments
|
| 170 |
+
-- =============================================
|
| 171 |
+
DROP TABLE IF EXISTS `payments`;
|
| 172 |
+
CREATE TABLE `payments` (
|
| 173 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 174 |
+
`reference` VARCHAR(64) NOT NULL,
|
| 175 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 176 |
+
`device_id` INT UNSIGNED NOT NULL,
|
| 177 |
+
`plan_id` INT UNSIGNED NOT NULL,
|
| 178 |
+
`phone` VARCHAR(20) NOT NULL,
|
| 179 |
+
`phone_provider` ENUM('mpesa','airtel','tigo','halopesa') NOT NULL DEFAULT 'mpesa',
|
| 180 |
+
`gross_amount` DECIMAL(10,2) NOT NULL,
|
| 181 |
+
`snippe_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
| 182 |
+
`commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
|
| 183 |
+
`commission_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
| 184 |
+
`client_credit` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
| 185 |
+
`status` ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending',
|
| 186 |
+
`snippe_payment_id` VARCHAR(100) DEFAULT NULL,
|
| 187 |
+
`access_token_code` VARCHAR(20) DEFAULT NULL,
|
| 188 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 189 |
+
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
| 190 |
+
UNIQUE KEY `uk_reference` (`reference`),
|
| 191 |
+
KEY `idx_pay_client` (`client_id`),
|
| 192 |
+
KEY `idx_pay_device` (`device_id`),
|
| 193 |
+
KEY `idx_pay_status` (`status`),
|
| 194 |
+
KEY `idx_pay_completed` (`completed_at`),
|
| 195 |
+
CONSTRAINT `fk_pay_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
|
| 196 |
+
CONSTRAINT `fk_pay_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`),
|
| 197 |
+
CONSTRAINT `fk_pay_plan` FOREIGN KEY (`plan_id`) REFERENCES `wifi_plans`(`id`)
|
| 198 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
-- =============================================
|
| 202 |
+
-- TABLE 8: access_tokens
|
| 203 |
+
-- =============================================
|
| 204 |
+
DROP TABLE IF EXISTS `access_tokens`;
|
| 205 |
+
CREATE TABLE `access_tokens` (
|
| 206 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 207 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 208 |
+
`device_id` INT UNSIGNED NOT NULL,
|
| 209 |
+
`plan_id` INT UNSIGNED NOT NULL,
|
| 210 |
+
`payment_id` BIGINT UNSIGNED DEFAULT NULL,
|
| 211 |
+
`code` VARCHAR(20) NOT NULL,
|
| 212 |
+
`duration_seconds` INT UNSIGNED NOT NULL,
|
| 213 |
+
`down_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
|
| 214 |
+
`down_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2,
|
| 215 |
+
`up_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
|
| 216 |
+
`up_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2,
|
| 217 |
+
`status` ENUM('unused','active','expired','revoked') NOT NULL DEFAULT 'unused',
|
| 218 |
+
`locked_mac` VARCHAR(17) DEFAULT NULL,
|
| 219 |
+
`activated_at` TIMESTAMP NULL DEFAULT NULL,
|
| 220 |
+
`expires_at` TIMESTAMP NULL DEFAULT NULL,
|
| 221 |
+
`total_seconds_used` INT UNSIGNED NOT NULL DEFAULT 0,
|
| 222 |
+
`total_bytes_down` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
| 223 |
+
`total_bytes_up` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
| 224 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 225 |
+
UNIQUE KEY `uk_token_code` (`code`),
|
| 226 |
+
KEY `idx_token_client` (`client_id`),
|
| 227 |
+
KEY `idx_token_device` (`device_id`),
|
| 228 |
+
KEY `idx_token_status` (`status`),
|
| 229 |
+
KEY `idx_token_expires` (`status`, `expires_at`),
|
| 230 |
+
KEY `idx_token_payment` (`payment_id`),
|
| 231 |
+
CONSTRAINT `fk_token_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
|
| 232 |
+
CONSTRAINT `fk_token_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`),
|
| 233 |
+
CONSTRAINT `fk_token_plan` FOREIGN KEY (`plan_id`) REFERENCES `wifi_plans`(`id`)
|
| 234 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
-- =============================================
|
| 238 |
+
-- TABLE 9: sessions
|
| 239 |
+
-- =============================================
|
| 240 |
+
DROP TABLE IF EXISTS `sessions`;
|
| 241 |
+
CREATE TABLE `sessions` (
|
| 242 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 243 |
+
`access_token_id` BIGINT UNSIGNED DEFAULT NULL,
|
| 244 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 245 |
+
`device_id` INT UNSIGNED DEFAULT NULL,
|
| 246 |
+
`client_mac` VARCHAR(17) NOT NULL,
|
| 247 |
+
`client_ip` VARCHAR(45) DEFAULT NULL,
|
| 248 |
+
`client_name` VARCHAR(100) DEFAULT NULL,
|
| 249 |
+
`ssid` VARCHAR(64) DEFAULT NULL,
|
| 250 |
+
`started_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 251 |
+
`last_seen_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 252 |
+
`ended_at` TIMESTAMP NULL DEFAULT NULL,
|
| 253 |
+
`duration_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
| 254 |
+
`bytes_down` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
| 255 |
+
`bytes_up` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
| 256 |
+
`rssi` SMALLINT DEFAULT NULL,
|
| 257 |
+
`signal_level` TINYINT UNSIGNED DEFAULT NULL,
|
| 258 |
+
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
| 259 |
+
KEY `idx_sess_token` (`access_token_id`),
|
| 260 |
+
KEY `idx_sess_client` (`client_id`),
|
| 261 |
+
KEY `idx_sess_device` (`device_id`),
|
| 262 |
+
KEY `idx_sess_mac` (`client_mac`),
|
| 263 |
+
KEY `idx_sess_active` (`is_active`, `client_id`),
|
| 264 |
+
KEY `idx_sess_time` (`started_at`),
|
| 265 |
+
CONSTRAINT `fk_sess_token` FOREIGN KEY (`access_token_id`) REFERENCES `access_tokens`(`id`),
|
| 266 |
+
CONSTRAINT `fk_sess_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
|
| 267 |
+
CONSTRAINT `fk_sess_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`)
|
| 268 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
-- =============================================
|
| 272 |
+
-- TABLE 10: payouts
|
| 273 |
+
-- =============================================
|
| 274 |
+
DROP TABLE IF EXISTS `payouts`;
|
| 275 |
+
CREATE TABLE `payouts` (
|
| 276 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 277 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 278 |
+
`amount` DECIMAL(10,2) NOT NULL,
|
| 279 |
+
`payout_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
| 280 |
+
`net_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
| 281 |
+
`destination_phone` VARCHAR(20) NOT NULL,
|
| 282 |
+
`destination_provider` ENUM('mpesa','airtel','tigo','halopesa') NOT NULL DEFAULT 'mpesa',
|
| 283 |
+
`status` ENUM('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
|
| 284 |
+
`snippe_disbursement_id` VARCHAR(100) DEFAULT NULL,
|
| 285 |
+
`failure_reason` VARCHAR(255) DEFAULT NULL,
|
| 286 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 287 |
+
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
| 288 |
+
KEY `idx_payout_client` (`client_id`),
|
| 289 |
+
KEY `idx_payout_status` (`status`),
|
| 290 |
+
CONSTRAINT `fk_payout_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`)
|
| 291 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
-- =============================================
|
| 295 |
+
-- TABLE 11: balance_ledger
|
| 296 |
+
-- =============================================
|
| 297 |
+
DROP TABLE IF EXISTS `balance_ledger`;
|
| 298 |
+
CREATE TABLE `balance_ledger` (
|
| 299 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 300 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 301 |
+
`type` ENUM('wifi_sale','payout','payout_reversal','adjustment','refund') NOT NULL,
|
| 302 |
+
`amount` DECIMAL(10,2) NOT NULL,
|
| 303 |
+
`balance_after` DECIMAL(12,2) NOT NULL,
|
| 304 |
+
`reference_type` ENUM('payment','payout','manual') NOT NULL,
|
| 305 |
+
`reference_id` BIGINT UNSIGNED NOT NULL,
|
| 306 |
+
`description` VARCHAR(255) DEFAULT NULL,
|
| 307 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 308 |
+
KEY `idx_ledger_client` (`client_id`),
|
| 309 |
+
KEY `idx_ledger_time` (`created_at`),
|
| 310 |
+
KEY `idx_ledger_ref` (`reference_type`, `reference_id`),
|
| 311 |
+
CONSTRAINT `fk_ledger_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`)
|
| 312 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
-- =============================================
|
| 316 |
+
-- TABLE 12: omada_alerts
|
| 317 |
+
-- =============================================
|
| 318 |
+
DROP TABLE IF EXISTS `omada_alerts`;
|
| 319 |
+
CREATE TABLE `omada_alerts` (
|
| 320 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 321 |
+
`client_id` INT UNSIGNED NOT NULL,
|
| 322 |
+
`device_id` INT UNSIGNED DEFAULT NULL,
|
| 323 |
+
`omada_alert_id` VARCHAR(64) NOT NULL,
|
| 324 |
+
`alert_key` VARCHAR(50) NOT NULL,
|
| 325 |
+
`module` VARCHAR(50) DEFAULT NULL,
|
| 326 |
+
`level` ENUM('info','warning','error','critical') NOT NULL DEFAULT 'info',
|
| 327 |
+
`content` TEXT NOT NULL,
|
| 328 |
+
`device_mac` VARCHAR(17) DEFAULT NULL,
|
| 329 |
+
`resolved` TINYINT(1) NOT NULL DEFAULT 0,
|
| 330 |
+
`alert_time` TIMESTAMP NOT NULL,
|
| 331 |
+
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 332 |
+
UNIQUE KEY `uk_omada_alert` (`omada_alert_id`),
|
| 333 |
+
KEY `idx_alert_client` (`client_id`),
|
| 334 |
+
KEY `idx_alert_device` (`device_id`),
|
| 335 |
+
KEY `idx_alert_unresolved` (`client_id`, `resolved`),
|
| 336 |
+
CONSTRAINT `fk_alert_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
|
| 337 |
+
CONSTRAINT `fk_alert_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON DELETE SET NULL
|
| 338 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
-- =============================================
|
| 342 |
+
-- TABLE 13: sms_log
|
| 343 |
+
-- =============================================
|
| 344 |
+
DROP TABLE IF EXISTS `sms_log`;
|
| 345 |
+
CREATE TABLE `sms_log` (
|
| 346 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 347 |
+
`phone` VARCHAR(20) NOT NULL,
|
| 348 |
+
`message` VARCHAR(320) NOT NULL,
|
| 349 |
+
`status` ENUM('sent','delivered','failed') NOT NULL,
|
| 350 |
+
`gateway_id` VARCHAR(100) DEFAULT NULL,
|
| 351 |
+
`error` VARCHAR(255) DEFAULT NULL,
|
| 352 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 353 |
+
`delivered_at` TIMESTAMP NULL DEFAULT NULL,
|
| 354 |
+
KEY `idx_sms_phone` (`phone`),
|
| 355 |
+
KEY `idx_sms_status` (`status`),
|
| 356 |
+
KEY `idx_sms_time` (`created_at`),
|
| 357 |
+
KEY `idx_sms_gateway` (`gateway_id`)
|
| 358 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
-- =============================================
|
| 362 |
+
-- TRIGGERS
|
| 363 |
+
-- =============================================
|
| 364 |
+
|
| 365 |
+
DROP TRIGGER IF EXISTS `trg_payment_completed`;
|
| 366 |
+
DELIMITER $$
|
| 367 |
+
CREATE TRIGGER `trg_payment_completed`
|
| 368 |
+
BEFORE UPDATE ON `payments`
|
| 369 |
+
FOR EACH ROW
|
| 370 |
+
BEGIN
|
| 371 |
+
DECLARE v_new_balance DECIMAL(12,2);
|
| 372 |
+
|
| 373 |
+
IF OLD.status = 'pending' AND NEW.status = 'completed' THEN
|
| 374 |
+
SET NEW.snippe_fee = ROUND(NEW.gross_amount * 0.005, 2);
|
| 375 |
+
SET NEW.commission_amount = ROUND(NEW.gross_amount * NEW.commission_rate, 2);
|
| 376 |
+
SET NEW.client_credit = NEW.gross_amount - NEW.snippe_fee - NEW.commission_amount;
|
| 377 |
+
SET NEW.completed_at = CURRENT_TIMESTAMP;
|
| 378 |
+
|
| 379 |
+
UPDATE `clients`
|
| 380 |
+
SET `balance` = `balance` + NEW.client_credit,
|
| 381 |
+
`total_earned` = `total_earned` + NEW.client_credit
|
| 382 |
+
WHERE `id` = NEW.client_id;
|
| 383 |
+
|
| 384 |
+
SET v_new_balance = (SELECT `balance` FROM `clients` WHERE `id` = NEW.client_id);
|
| 385 |
+
|
| 386 |
+
INSERT INTO `balance_ledger`
|
| 387 |
+
(`client_id`, `type`, `amount`, `balance_after`, `reference_type`, `reference_id`, `description`)
|
| 388 |
+
VALUES
|
| 389 |
+
(NEW.client_id, 'wifi_sale', NEW.client_credit, v_new_balance, 'payment', NEW.id,
|
| 390 |
+
CONCAT('WiFi sale: ', NEW.gross_amount, ' TZS (fee: ', NEW.snippe_fee, ', comm: ', NEW.commission_amount, ')'));
|
| 391 |
+
END IF;
|
| 392 |
+
END$$
|
| 393 |
+
DELIMITER ;
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
DROP TRIGGER IF EXISTS `trg_payout_requested`;
|
| 397 |
+
DELIMITER $$
|
| 398 |
+
CREATE TRIGGER `trg_payout_requested`
|
| 399 |
+
AFTER INSERT ON `payouts`
|
| 400 |
+
FOR EACH ROW
|
| 401 |
+
BEGIN
|
| 402 |
+
DECLARE v_new_balance DECIMAL(12,2);
|
| 403 |
+
|
| 404 |
+
IF NEW.status = 'pending' THEN
|
| 405 |
+
UPDATE `clients`
|
| 406 |
+
SET `balance` = `balance` - NEW.amount
|
| 407 |
+
WHERE `id` = NEW.client_id;
|
| 408 |
+
|
| 409 |
+
SET v_new_balance = (SELECT `balance` FROM `clients` WHERE `id` = NEW.client_id);
|
| 410 |
+
|
| 411 |
+
INSERT INTO `balance_ledger`
|
| 412 |
+
(`client_id`, `type`, `amount`, `balance_after`, `reference_type`, `reference_id`, `description`)
|
| 413 |
+
VALUES
|
| 414 |
+
(NEW.client_id, 'payout', -NEW.amount, v_new_balance, 'payout', NEW.id,
|
| 415 |
+
CONCAT('Payout request: ', NEW.amount, ' TZS to ', NEW.destination_phone));
|
| 416 |
+
END IF;
|
| 417 |
+
END$$
|
| 418 |
+
DELIMITER ;
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
DROP TRIGGER IF EXISTS `trg_payout_failed`;
|
| 422 |
+
DELIMITER $$
|
| 423 |
+
CREATE TRIGGER `trg_payout_failed`
|
| 424 |
+
BEFORE UPDATE ON `payouts`
|
| 425 |
+
FOR EACH ROW
|
| 426 |
+
BEGIN
|
| 427 |
+
DECLARE v_new_balance DECIMAL(12,2);
|
| 428 |
+
|
| 429 |
+
IF OLD.status = 'pending' AND NEW.status = 'failed' THEN
|
| 430 |
+
UPDATE `clients`
|
| 431 |
+
SET `balance` = `balance` + OLD.amount
|
| 432 |
+
WHERE `id` = OLD.client_id;
|
| 433 |
+
|
| 434 |
+
SET v_new_balance = (SELECT `balance` FROM `clients` WHERE `id` = OLD.client_id);
|
| 435 |
+
|
| 436 |
+
INSERT INTO `balance_ledger`
|
| 437 |
+
(`client_id`, `type`, `amount`, `balance_after`, `reference_type`, `reference_id`, `description`)
|
| 438 |
+
VALUES
|
| 439 |
+
(OLD.client_id, 'payout_reversal', OLD.amount, v_new_balance, 'payout', OLD.id,
|
| 440 |
+
CONCAT('Payout failed: ', IFNULL(NEW.failure_reason, 'unknown')));
|
| 441 |
+
END IF;
|
| 442 |
+
END$$
|
| 443 |
+
DELIMITER ;
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
DROP TRIGGER IF EXISTS `trg_payout_completed`;
|
| 447 |
+
DELIMITER $$
|
| 448 |
+
CREATE TRIGGER `trg_payout_completed`
|
| 449 |
+
BEFORE UPDATE ON `payouts`
|
| 450 |
+
FOR EACH ROW
|
| 451 |
+
BEGIN
|
| 452 |
+
IF OLD.status IN ('pending','processing') AND NEW.status = 'completed' THEN
|
| 453 |
+
SET NEW.completed_at = CURRENT_TIMESTAMP;
|
| 454 |
+
|
| 455 |
+
UPDATE `clients`
|
| 456 |
+
SET `total_withdrawn` = `total_withdrawn` + OLD.amount
|
| 457 |
+
WHERE `id` = OLD.client_id;
|
| 458 |
+
END IF;
|
| 459 |
+
END$$
|
| 460 |
+
DELIMITER ;
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
-- =============================================
|
| 464 |
+
-- TABLE 14: device_portal_settings
|
| 465 |
+
-- =============================================
|
| 466 |
+
DROP TABLE IF EXISTS `device_portal_settings`;
|
| 467 |
+
CREATE TABLE `device_portal_settings` (
|
| 468 |
+
`device_id` INT UNSIGNED NOT NULL PRIMARY KEY,
|
| 469 |
+
`display_name` VARCHAR(100) DEFAULT NULL COMMENT 'Overrides business_name on the portal',
|
| 470 |
+
`welcome_text` VARCHAR(255) DEFAULT NULL COMMENT 'Tagline shown under the name',
|
| 471 |
+
`logo_url` VARCHAR(500) DEFAULT NULL COMMENT 'Full URL to logo image',
|
| 472 |
+
`support_phone` VARCHAR(20) DEFAULT NULL COMMENT 'Support number shown to guests',
|
| 473 |
+
`primary_color` VARCHAR(7) NOT NULL DEFAULT '#4361ee' COMMENT 'Buttons and accents',
|
| 474 |
+
`bg_color` VARCHAR(7) NOT NULL DEFAULT '#1a1a2e' COMMENT 'Page background',
|
| 475 |
+
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 476 |
+
CONSTRAINT `fk_portal_settings_device`
|
| 477 |
+
FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON DELETE CASCADE
|
| 478 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
-- =============================================
|
| 482 |
+
-- TABLE 15: password_resets
|
| 483 |
+
-- =============================================
|
| 484 |
+
DROP TABLE IF EXISTS `password_resets`;
|
| 485 |
+
CREATE TABLE `password_resets` (
|
| 486 |
+
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 487 |
+
`phone` VARCHAR(20) NOT NULL,
|
| 488 |
+
`otp` VARCHAR(6) NOT NULL,
|
| 489 |
+
`expires_at` TIMESTAMP NOT NULL,
|
| 490 |
+
`used` TINYINT(1) NOT NULL DEFAULT 0,
|
| 491 |
+
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 492 |
+
KEY `idx_reset_phone` (`phone`)
|
| 493 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
-- =============================================
|
| 497 |
+
-- TABLE 15: system_config
|
| 498 |
+
-- =============================================
|
| 499 |
+
DROP TABLE IF EXISTS `system_config`;
|
| 500 |
+
CREATE TABLE `system_config` (
|
| 501 |
+
`key` VARCHAR(64) NOT NULL PRIMARY KEY,
|
| 502 |
+
`value` VARCHAR(255) NOT NULL,
|
| 503 |
+
`description` VARCHAR(255) DEFAULT NULL
|
| 504 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 505 |
+
|
| 506 |
+
INSERT INTO `system_config` (`key`, `value`, `description`) VALUES
|
| 507 |
+
('admin_phone', '+255769590766', 'System admin phone — receives critical error alerts');
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
SET FOREIGN_KEY_CHECKS = 1;
|
src/app.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const cors = require('cors');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
const app = express();
|
| 6 |
+
|
| 7 |
+
// ── View engine ───────────────────────────────────────────────────────────────
|
| 8 |
+
app.set('view engine', 'ejs');
|
| 9 |
+
app.set('views', path.join(__dirname, 'views', 'screens'));
|
| 10 |
+
|
| 11 |
+
// ── Static files ──────────────────────────────────────────────────────────────
|
| 12 |
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
| 13 |
+
|
| 14 |
+
// ── Middleware ────────────────────────────────────────────────────────────────
|
| 15 |
+
app.use(cors());
|
| 16 |
+
|
| 17 |
+
// NOTE: /api/webhooks/snippe needs raw body — mount it BEFORE json middleware
|
| 18 |
+
app.use('/api/webhooks', require('./routes/webhooks.routes'));
|
| 19 |
+
|
| 20 |
+
// JSON body parser for everything else
|
| 21 |
+
app.use(express.json());
|
| 22 |
+
app.use(express.urlencoded({ extended: false }));
|
| 23 |
+
|
| 24 |
+
// ── Routes ────────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
// Public captive portal
|
| 27 |
+
app.use('/portal', require('./routes/portal.routes'));
|
| 28 |
+
|
| 29 |
+
// Tenant API
|
| 30 |
+
app.use('/api/auth', require('./routes/auth.routes'));
|
| 31 |
+
app.use('/api/devices', require('./routes/devices.routes'));
|
| 32 |
+
app.use('/api/plans', require('./routes/plans.routes'));
|
| 33 |
+
app.use('/api/dashboard', require('./routes/dashboard.routes'));
|
| 34 |
+
app.use('/api/payouts', require('./routes/payouts.routes'));
|
| 35 |
+
app.use('/api/sessions', require('./routes/sessions.routes'));
|
| 36 |
+
app.use('/api/alerts', require('./routes/alerts.routes'));
|
| 37 |
+
|
| 38 |
+
// Admin API
|
| 39 |
+
app.use('/api/admin', require('./routes/admin.routes'));
|
| 40 |
+
|
| 41 |
+
// ── Health check ──────────────────────────────────────────────────────────────
|
| 42 |
+
app.get('/health', (req, res) => res.json({ status: 'ok', ts: new Date() }));
|
| 43 |
+
|
| 44 |
+
// ── 404 ───────────────────────────────────────────────────────────────────────
|
| 45 |
+
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
|
| 46 |
+
|
| 47 |
+
// ── Error handler ─────────────────────────────────────────────────────────────
|
| 48 |
+
app.use((err, req, res, next) => {
|
| 49 |
+
console.error('[Unhandled]', err.message);
|
| 50 |
+
res.status(500).json({ error: 'Internal server error' });
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
module.exports = app;
|
src/config/db.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mysql = require('mysql2/promise');
|
| 2 |
+
|
| 3 |
+
const pool = mysql.createPool({
|
| 4 |
+
host: process.env.DB_HOST || 'localhost',
|
| 5 |
+
port: parseInt(process.env.DB_PORT) || 3306,
|
| 6 |
+
user: process.env.DB_USER || 'wifiuser',
|
| 7 |
+
password: process.env.DB_PASS || 'wifipass',
|
| 8 |
+
database: process.env.DB_NAME || 'wifiplatform',
|
| 9 |
+
waitForConnections: true,
|
| 10 |
+
connectionLimit: 20,
|
| 11 |
+
charset: 'utf8mb4',
|
| 12 |
+
timezone: '+00:00',
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
async function query(sql, params = []) {
|
| 16 |
+
const [rows] = await pool.execute(sql, params);
|
| 17 |
+
return rows;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
async function queryOne(sql, params = []) {
|
| 21 |
+
const rows = await query(sql, params);
|
| 22 |
+
return rows[0] || null;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
module.exports = { pool, query, queryOne };
|
src/jobs/adoptDevices.job.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const omada = require('../services/omada');
|
| 3 |
+
|
| 4 |
+
async function adoptDevices() {
|
| 5 |
+
try {
|
| 6 |
+
const pending = await db.query(`
|
| 7 |
+
SELECT id, mac, omada_site_id, device_username, device_password
|
| 8 |
+
FROM devices
|
| 9 |
+
WHERE status = 'pending' AND omada_site_id IS NOT NULL
|
| 10 |
+
`);
|
| 11 |
+
|
| 12 |
+
if (!pending.length) return;
|
| 13 |
+
|
| 14 |
+
// Fetch sites once to get deviceAccount credentials
|
| 15 |
+
let siteMap = {};
|
| 16 |
+
try {
|
| 17 |
+
const sites = await omada.getSites();
|
| 18 |
+
siteMap = Object.fromEntries(sites.map(s => [s.id, s]));
|
| 19 |
+
} catch (err) {
|
| 20 |
+
console.error('[adoptDevices] getSites failed:', err.message);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
for (const device of pending) {
|
| 25 |
+
const site = siteMap[device.omada_site_id];
|
| 26 |
+
if (!site) continue;
|
| 27 |
+
|
| 28 |
+
let omadaDevices;
|
| 29 |
+
try {
|
| 30 |
+
omadaDevices = await omada.getSiteDevices(device.omada_site_id);
|
| 31 |
+
} catch { continue; }
|
| 32 |
+
|
| 33 |
+
// Normalize MACs by stripping separators before comparing
|
| 34 |
+
// (DB may store colons/dashes; Omada always returns dashes)
|
| 35 |
+
const stripMac = m => m.toUpperCase().replace(/[:\-]/g, '');
|
| 36 |
+
const normalMac = stripMac(device.mac);
|
| 37 |
+
const found = omadaDevices.find(d => d.mac && stripMac(d.mac) === normalMac);
|
| 38 |
+
|
| 39 |
+
if (!found) continue; // AP not visible yet
|
| 40 |
+
if (found.statusCategory !== 0) {
|
| 41 |
+
// Already adopted or connected — advance status
|
| 42 |
+
if (found.statusCategory === 1) {
|
| 43 |
+
await db.query(
|
| 44 |
+
`UPDATE devices SET status = 'online', adopted_at = NOW() WHERE id = ?`,
|
| 45 |
+
[device.id]
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
continue;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const username = device.device_username || site.deviceAccount?.username || 'admin';
|
| 52 |
+
const password = device.device_password || site.deviceAccount?.password || 'Admin@123';
|
| 53 |
+
|
| 54 |
+
try {
|
| 55 |
+
await omada.adoptDevice(device.omada_site_id, device.mac, username, password);
|
| 56 |
+
await db.query(
|
| 57 |
+
`UPDATE devices SET status = 'adopting', adopted_at = NOW() WHERE id = ?`,
|
| 58 |
+
[device.id]
|
| 59 |
+
);
|
| 60 |
+
console.log(`[adoptDevices] Adopting ${device.mac}`);
|
| 61 |
+
} catch (err) {
|
| 62 |
+
console.error(`[adoptDevices] Adopt ${device.mac} failed:`, err.message);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
} catch (err) {
|
| 66 |
+
console.error('[adoptDevices]', err.message);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
module.exports = adoptDevices;
|
src/jobs/billingReminders.job.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const sms = require('../services/sms');
|
| 3 |
+
const messages = require('../utils/messages');
|
| 4 |
+
|
| 5 |
+
async function billingReminders() {
|
| 6 |
+
try {
|
| 7 |
+
// Devices expiring in the next 3 days that haven't been reminded today
|
| 8 |
+
const expiring = await db.query(`
|
| 9 |
+
SELECT d.id, d.name AS device_name,
|
| 10 |
+
d.billing_expires_at, d.monthly_fee,
|
| 11 |
+
c.phone AS client_phone, c.business_name
|
| 12 |
+
FROM devices d
|
| 13 |
+
JOIN clients c ON c.id = d.client_id
|
| 14 |
+
WHERE d.billing_status IN ('trial', 'active')
|
| 15 |
+
AND d.billing_expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 3 DAY)
|
| 16 |
+
`);
|
| 17 |
+
|
| 18 |
+
for (const device of expiring) {
|
| 19 |
+
const expiresAt = new Date(device.billing_expires_at);
|
| 20 |
+
const daysLeft = Math.ceil((expiresAt - Date.now()) / 86400000);
|
| 21 |
+
|
| 22 |
+
await sms.sendSMS(device.client_phone,
|
| 23 |
+
messages.billingReminder(device.device_name, daysLeft, device.monthly_fee)
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
if (expiring.length) console.log(`[billingReminders] Sent ${expiring.length} reminder(s)`);
|
| 28 |
+
} catch (err) {
|
| 29 |
+
console.error('[billingReminders]', err.message);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
module.exports = billingReminders;
|
src/jobs/expireTokens.job.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const omada = require('../services/omada');
|
| 3 |
+
|
| 4 |
+
async function expireTokens() {
|
| 5 |
+
try {
|
| 6 |
+
// ── 1. Expire active tokens that have passed their expiry time ─────────────
|
| 7 |
+
const expired = await db.query(`
|
| 8 |
+
SELECT t.id, t.code, t.locked_mac, d.omada_site_id
|
| 9 |
+
FROM access_tokens t
|
| 10 |
+
JOIN devices d ON d.id = t.device_id
|
| 11 |
+
WHERE t.status = 'active' AND t.expires_at < NOW()
|
| 12 |
+
`);
|
| 13 |
+
|
| 14 |
+
for (const token of expired) {
|
| 15 |
+
// Mark expired first — prevents reconnect even if deauth fails
|
| 16 |
+
await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
|
| 17 |
+
|
| 18 |
+
// Deauth on Omada; if it fails the session stays is_active=1 as a retry flag
|
| 19 |
+
if (token.locked_mac && token.omada_site_id) {
|
| 20 |
+
try {
|
| 21 |
+
await omada.deauthorizeClient(token.omada_site_id, token.locked_mac);
|
| 22 |
+
await db.query(`
|
| 23 |
+
UPDATE sessions SET is_active = 0, ended_at = NOW()
|
| 24 |
+
WHERE access_token_id = ? AND is_active = 1
|
| 25 |
+
`, [token.id]);
|
| 26 |
+
} catch (err) {
|
| 27 |
+
console.error(`[expireTokens] Deauth ${token.code} failed (will retry):`, err.message);
|
| 28 |
+
// Session left is_active=1 intentionally — picked up by retry pass below
|
| 29 |
+
}
|
| 30 |
+
} else {
|
| 31 |
+
await db.query(`
|
| 32 |
+
UPDATE sessions SET is_active = 0, ended_at = NOW()
|
| 33 |
+
WHERE access_token_id = ? AND is_active = 1
|
| 34 |
+
`, [token.id]);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (expired.length > 0) console.log(`[expireTokens] Expired ${expired.length} token(s)`);
|
| 39 |
+
|
| 40 |
+
// ── 2. Retry deauth for expired/revoked tokens whose sessions are still open ─
|
| 41 |
+
const pendingDeauth = await db.query(`
|
| 42 |
+
SELECT t.id, t.code, t.locked_mac, d.omada_site_id
|
| 43 |
+
FROM access_tokens t
|
| 44 |
+
JOIN sessions s ON s.access_token_id = t.id
|
| 45 |
+
JOIN devices d ON d.id = t.device_id
|
| 46 |
+
WHERE t.status IN ('expired', 'revoked')
|
| 47 |
+
AND s.is_active = 1
|
| 48 |
+
AND t.locked_mac IS NOT NULL
|
| 49 |
+
`);
|
| 50 |
+
|
| 51 |
+
for (const token of pendingDeauth) {
|
| 52 |
+
try {
|
| 53 |
+
await omada.deauthorizeClient(token.omada_site_id, token.locked_mac);
|
| 54 |
+
await db.query(`
|
| 55 |
+
UPDATE sessions SET is_active = 0, ended_at = NOW()
|
| 56 |
+
WHERE access_token_id = ? AND is_active = 1
|
| 57 |
+
`, [token.id]);
|
| 58 |
+
console.log(`[expireTokens] Retry deauth succeeded for ${token.code}`);
|
| 59 |
+
} catch (err) {
|
| 60 |
+
// Still down — will try again next tick
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
} catch (err) {
|
| 64 |
+
console.error('[expireTokens]', err.message);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
module.exports = expireTokens;
|
src/jobs/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const cron = require('node-cron');
|
| 2 |
+
|
| 3 |
+
const expireTokens = require('./expireTokens.job');
|
| 4 |
+
const adoptDevices = require('./adoptDevices.job');
|
| 5 |
+
const pollDevices = require('./pollDevices.job');
|
| 6 |
+
const pollSessions = require('./pollSessions.job');
|
| 7 |
+
const pollAlerts = require('./pollAlerts.job');
|
| 8 |
+
const suspendDevices = require('./suspendDevices.job');
|
| 9 |
+
const billingReminders = require('./billingReminders.job');
|
| 10 |
+
|
| 11 |
+
function initJobs() {
|
| 12 |
+
// Every 30 seconds
|
| 13 |
+
cron.schedule('*/30 * * * * *', expireTokens);
|
| 14 |
+
cron.schedule('*/30 * * * * *', adoptDevices);
|
| 15 |
+
|
| 16 |
+
// Every 60 seconds
|
| 17 |
+
cron.schedule('*/60 * * * * *', pollSessions);
|
| 18 |
+
|
| 19 |
+
// Every 5 minutes
|
| 20 |
+
cron.schedule('*/5 * * * *', pollDevices);
|
| 21 |
+
|
| 22 |
+
// Every 15 minutes
|
| 23 |
+
cron.schedule('*/15 * * * *', pollAlerts);
|
| 24 |
+
|
| 25 |
+
// Daily at midnight
|
| 26 |
+
cron.schedule('0 0 * * *', suspendDevices);
|
| 27 |
+
|
| 28 |
+
// Daily at 9am
|
| 29 |
+
cron.schedule('0 9 * * *', billingReminders);
|
| 30 |
+
|
| 31 |
+
console.log('[Jobs] All cron jobs started');
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
module.exports = { initJobs };
|
src/jobs/pollAlerts.job.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const omada = require('../services/omada');
|
| 3 |
+
|
| 4 |
+
const LEVEL_MAP = {
|
| 5 |
+
Error: 'error',
|
| 6 |
+
Warning: 'warning',
|
| 7 |
+
Information: 'info',
|
| 8 |
+
Critical: 'critical',
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
async function pollAlerts() {
|
| 12 |
+
try {
|
| 13 |
+
const devices = await db.query(`
|
| 14 |
+
SELECT DISTINCT d.client_id, d.omada_site_id, d.id AS device_id, d.mac
|
| 15 |
+
FROM devices d
|
| 16 |
+
WHERE d.status IN ('online','offline')
|
| 17 |
+
AND d.omada_site_id IS NOT NULL
|
| 18 |
+
`);
|
| 19 |
+
|
| 20 |
+
for (const device of devices) {
|
| 21 |
+
try {
|
| 22 |
+
const alerts = await omada.getSiteAlerts(device.omada_site_id);
|
| 23 |
+
|
| 24 |
+
for (const alert of alerts) {
|
| 25 |
+
// Find matching device_id by alert MAC
|
| 26 |
+
let deviceId = device.device_id;
|
| 27 |
+
if (alert.device?.mac && alert.device.mac.toUpperCase() !== device.mac.toUpperCase()) {
|
| 28 |
+
const match = await db.query(
|
| 29 |
+
`SELECT id FROM devices WHERE mac = ? LIMIT 1`,
|
| 30 |
+
[alert.device.mac.toUpperCase()]
|
| 31 |
+
);
|
| 32 |
+
if (match.length) deviceId = match[0].id;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
await db.query(`
|
| 36 |
+
INSERT IGNORE INTO omada_alerts
|
| 37 |
+
(client_id, device_id, omada_alert_id, alert_key, module, level, content, device_mac, alert_time)
|
| 38 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(? / 1000))
|
| 39 |
+
`, [
|
| 40 |
+
device.client_id,
|
| 41 |
+
deviceId,
|
| 42 |
+
alert.id,
|
| 43 |
+
alert.key || '',
|
| 44 |
+
alert.module || null,
|
| 45 |
+
LEVEL_MAP[alert.level] || 'info',
|
| 46 |
+
alert.content || '',
|
| 47 |
+
alert.device?.mac || null,
|
| 48 |
+
alert.time,
|
| 49 |
+
]);
|
| 50 |
+
}
|
| 51 |
+
} catch (err) {
|
| 52 |
+
// ignore per-site errors
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
} catch (err) {
|
| 56 |
+
console.error('[pollAlerts]', err.message);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
module.exports = pollAlerts;
|
src/jobs/pollDevices.job.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const omada = require('../services/omada');
|
| 3 |
+
const sms = require('../services/sms');
|
| 4 |
+
const messages = require('../utils/messages');
|
| 5 |
+
|
| 6 |
+
async function pollDevices() {
|
| 7 |
+
try {
|
| 8 |
+
const devices = await db.query(`
|
| 9 |
+
SELECT d.id, d.omada_site_id, d.status AS old_status,
|
| 10 |
+
d.name AS device_name, c.phone AS client_phone
|
| 11 |
+
FROM devices d
|
| 12 |
+
JOIN clients c ON c.id = d.client_id
|
| 13 |
+
WHERE d.status NOT IN ('pending', 'removed')
|
| 14 |
+
AND d.omada_site_id IS NOT NULL
|
| 15 |
+
AND d.billing_status != 'cancelled'
|
| 16 |
+
`);
|
| 17 |
+
|
| 18 |
+
for (const device of devices) {
|
| 19 |
+
try {
|
| 20 |
+
const omadaDevs = await omada.getSiteDevices(device.omada_site_id);
|
| 21 |
+
if (!omadaDevs.length) continue;
|
| 22 |
+
|
| 23 |
+
const stripMac = m => m.toUpperCase().replace(/[:\-]/g, '');
|
| 24 |
+
const ap = omadaDevs.find(d => d.mac && stripMac(d.mac) === stripMac(device.mac));
|
| 25 |
+
if (!ap) continue;
|
| 26 |
+
|
| 27 |
+
let newStatus;
|
| 28 |
+
if (ap.statusCategory === 1) newStatus = 'online';
|
| 29 |
+
else if (ap.statusCategory === 2) newStatus = 'offline';
|
| 30 |
+
else if (ap.statusCategory === 0) newStatus = 'adopting';
|
| 31 |
+
else continue;
|
| 32 |
+
|
| 33 |
+
await db.query(`
|
| 34 |
+
UPDATE devices SET
|
| 35 |
+
status = ?,
|
| 36 |
+
model = COALESCE(?, model),
|
| 37 |
+
firmware_version = COALESCE(?, firmware_version),
|
| 38 |
+
ip = ?,
|
| 39 |
+
public_ip = ?,
|
| 40 |
+
cpu_util = ?,
|
| 41 |
+
mem_util = ?,
|
| 42 |
+
client_count = ?,
|
| 43 |
+
guest_count = ?,
|
| 44 |
+
uptime_seconds = ?,
|
| 45 |
+
download_bytes = ?,
|
| 46 |
+
upload_bytes = ?,
|
| 47 |
+
needs_upgrade = ?,
|
| 48 |
+
last_seen_at = NOW()
|
| 49 |
+
WHERE id = ?
|
| 50 |
+
`, [
|
| 51 |
+
newStatus,
|
| 52 |
+
ap.model || null,
|
| 53 |
+
ap.firmwareVersion || null,
|
| 54 |
+
ap.ip || null,
|
| 55 |
+
ap.publicIp || null,
|
| 56 |
+
ap.cpuUtil ?? null,
|
| 57 |
+
ap.memUtil ?? null,
|
| 58 |
+
ap.clientNum || 0,
|
| 59 |
+
ap.guestNum || 0,
|
| 60 |
+
ap.uptimeLong || 0,
|
| 61 |
+
ap.download || 0,
|
| 62 |
+
ap.upload || 0,
|
| 63 |
+
ap.needUpgrade ? 1 : 0,
|
| 64 |
+
device.id,
|
| 65 |
+
]);
|
| 66 |
+
|
| 67 |
+
if (newStatus !== device.old_status) {
|
| 68 |
+
await db.query(
|
| 69 |
+
`INSERT INTO device_status_log (device_id, old_status, new_status) VALUES (?, ?, ?)`,
|
| 70 |
+
[device.id, device.old_status, newStatus]
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
if (newStatus === 'online' && device.old_status === 'adopting') {
|
| 74 |
+
sms.sendSMS(device.client_phone, messages.deviceOnline(device.device_name)).catch(() => {});
|
| 75 |
+
} else if (newStatus === 'offline' && device.old_status === 'online') {
|
| 76 |
+
sms.sendSMS(device.client_phone, messages.deviceOffline(device.device_name)).catch(() => {});
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
console.log(`[pollDevices] Device ${device.id}: ${device.old_status} → ${newStatus}`);
|
| 80 |
+
}
|
| 81 |
+
} catch (err) {
|
| 82 |
+
// Don't let one failure stop others
|
| 83 |
+
if (!['timeout', 'ECONNREFUSED', 'ENOTFOUND'].some(s => err.message?.includes(s))) {
|
| 84 |
+
console.error(`[pollDevices] Device ${device.id}:`, err.message);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
} catch (err) {
|
| 89 |
+
console.error('[pollDevices]', err.message);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
module.exports = pollDevices;
|
src/jobs/pollSessions.job.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const omada = require('../services/omada');
|
| 3 |
+
const { alertAdmin } = require('../utils/adminAlert');
|
| 4 |
+
|
| 5 |
+
async function pollSessions() {
|
| 6 |
+
try {
|
| 7 |
+
const devices = await db.query(`
|
| 8 |
+
SELECT id, client_id, omada_site_id
|
| 9 |
+
FROM devices
|
| 10 |
+
WHERE status = 'online' AND omada_site_id IS NOT NULL
|
| 11 |
+
`);
|
| 12 |
+
|
| 13 |
+
for (const device of devices) {
|
| 14 |
+
try {
|
| 15 |
+
const clients = await omada.getSiteClients(device.omada_site_id);
|
| 16 |
+
|
| 17 |
+
const activeMacs = new Set(clients.map(c => c.mac?.toUpperCase()).filter(Boolean));
|
| 18 |
+
|
| 19 |
+
// Mark sessions inactive for clients no longer visible on Omada
|
| 20 |
+
await db.query(`
|
| 21 |
+
UPDATE sessions SET is_active = 0, ended_at = NOW()
|
| 22 |
+
WHERE device_id = ? AND is_active = 1
|
| 23 |
+
AND client_mac NOT IN (${activeMacs.size ? [...activeMacs].map(() => '?').join(',') : "''"})
|
| 24 |
+
`, [device.id, ...activeMacs]);
|
| 25 |
+
|
| 26 |
+
if (!clients.length) continue;
|
| 27 |
+
|
| 28 |
+
const macs = [...activeMacs];
|
| 29 |
+
|
| 30 |
+
// ── Batch fetch tokens and sessions in 2 queries instead of 2×N ────────
|
| 31 |
+
const tokens = await db.query(
|
| 32 |
+
`SELECT id, locked_mac FROM access_tokens
|
| 33 |
+
WHERE device_id = ? AND status = 'active'
|
| 34 |
+
AND locked_mac IN (${macs.map(() => '?').join(',')})`,
|
| 35 |
+
[device.id, ...macs]
|
| 36 |
+
);
|
| 37 |
+
const tokenByMac = Object.fromEntries(tokens.map(t => [t.locked_mac, t]));
|
| 38 |
+
|
| 39 |
+
const sessions = await db.query(
|
| 40 |
+
`SELECT id, client_mac FROM sessions
|
| 41 |
+
WHERE device_id = ? AND is_active = 1
|
| 42 |
+
AND client_mac IN (${macs.map(() => '?').join(',')})`,
|
| 43 |
+
[device.id, ...macs]
|
| 44 |
+
);
|
| 45 |
+
const sessionByMac = Object.fromEntries(sessions.map(s => [s.client_mac, s]));
|
| 46 |
+
|
| 47 |
+
// ── Upsert sessions ───────────────────────────────────────────────────
|
| 48 |
+
for (const c of clients) {
|
| 49 |
+
if (!c.mac) continue;
|
| 50 |
+
const mac = c.mac.toUpperCase();
|
| 51 |
+
const token = tokenByMac[mac] || null;
|
| 52 |
+
const existingSession = sessionByMac[mac] || null;
|
| 53 |
+
|
| 54 |
+
if (existingSession) {
|
| 55 |
+
await db.query(`
|
| 56 |
+
UPDATE sessions SET
|
| 57 |
+
last_seen_at = NOW(),
|
| 58 |
+
duration_seconds = TIMESTAMPDIFF(SECOND, started_at, NOW()),
|
| 59 |
+
bytes_down = ?,
|
| 60 |
+
bytes_up = ?,
|
| 61 |
+
rssi = ?,
|
| 62 |
+
signal_level = ?
|
| 63 |
+
WHERE id = ?
|
| 64 |
+
`, [
|
| 65 |
+
c.trafficDown || 0,
|
| 66 |
+
c.trafficUp || 0,
|
| 67 |
+
c.rssi || null,
|
| 68 |
+
c.signalLevel || null,
|
| 69 |
+
existingSession.id,
|
| 70 |
+
]);
|
| 71 |
+
} else {
|
| 72 |
+
await db.query(`
|
| 73 |
+
INSERT IGNORE INTO sessions
|
| 74 |
+
(access_token_id, client_id, device_id, client_mac, client_ip, client_name, ssid,
|
| 75 |
+
bytes_down, bytes_up, rssi, signal_level)
|
| 76 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 77 |
+
`, [
|
| 78 |
+
token?.id || null,
|
| 79 |
+
device.client_id,
|
| 80 |
+
device.id,
|
| 81 |
+
mac,
|
| 82 |
+
c.ip || null,
|
| 83 |
+
c.name || null,
|
| 84 |
+
c.ssid || null,
|
| 85 |
+
c.trafficDown || 0,
|
| 86 |
+
c.trafficUp || 0,
|
| 87 |
+
c.rssi || null,
|
| 88 |
+
c.signalLevel || null,
|
| 89 |
+
]);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
} catch (err) {
|
| 93 |
+
console.error(`[pollSessions] Device ${device.id} (site ${device.omada_site_id}):`, err.message);
|
| 94 |
+
alertAdmin(
|
| 95 |
+
`pollSessions:${device.id}`,
|
| 96 |
+
`Session sync failed for device ${device.id} (site ${device.omada_site_id}): ${err.message}`
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
} catch (err) {
|
| 101 |
+
console.error('[pollSessions] Fatal:', err.message);
|
| 102 |
+
alertAdmin('pollSessions:fatal', `pollSessions job crashed: ${err.message}`);
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
module.exports = pollSessions;
|
src/jobs/suspendDevices.job.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const sms = require('../services/sms');
|
| 3 |
+
const omada = require('../services/omada');
|
| 4 |
+
const messages = require('../utils/messages');
|
| 5 |
+
|
| 6 |
+
async function suspendDevices() {
|
| 7 |
+
try {
|
| 8 |
+
// Find expired trial/active devices
|
| 9 |
+
const expired = await db.query(`
|
| 10 |
+
SELECT d.id, d.name AS device_name, d.monthly_fee, d.omada_site_id, c.phone AS client_phone
|
| 11 |
+
FROM devices d
|
| 12 |
+
JOIN clients c ON c.id = d.client_id
|
| 13 |
+
WHERE d.billing_status IN ('trial', 'active')
|
| 14 |
+
AND d.billing_expires_at < NOW()
|
| 15 |
+
AND d.billing_status != 'suspended'
|
| 16 |
+
`);
|
| 17 |
+
|
| 18 |
+
for (const device of expired) {
|
| 19 |
+
await db.query(
|
| 20 |
+
`UPDATE devices SET billing_status = 'suspended' WHERE id = ?`,
|
| 21 |
+
[device.id]
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
// Revoke all active tokens and kick connected guests off Omada
|
| 25 |
+
const activeTokens = await db.query(
|
| 26 |
+
`SELECT id, locked_mac FROM access_tokens
|
| 27 |
+
WHERE device_id = ? AND status = 'active'`,
|
| 28 |
+
[device.id]
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
for (const token of activeTokens) {
|
| 32 |
+
if (token.locked_mac && device.omada_site_id) {
|
| 33 |
+
try {
|
| 34 |
+
await omada.deauthorizeClient(device.omada_site_id, token.locked_mac);
|
| 35 |
+
} catch (err) {
|
| 36 |
+
console.error(`[suspendDevices] Deauth ${token.locked_mac} failed:`, err.message);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
await db.query(
|
| 40 |
+
`UPDATE access_tokens SET status = 'revoked' WHERE id = ?`,
|
| 41 |
+
[token.id]
|
| 42 |
+
);
|
| 43 |
+
await db.query(
|
| 44 |
+
`UPDATE sessions SET is_active = 0, ended_at = NOW()
|
| 45 |
+
WHERE access_token_id = ? AND is_active = 1`,
|
| 46 |
+
[token.id]
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const fee = parseInt(device.monthly_fee || 20000).toLocaleString();
|
| 51 |
+
sms.sendSMS(device.client_phone, messages.deviceSuspended(device.device_name, fee)).catch(() => {});
|
| 52 |
+
|
| 53 |
+
console.log(`[suspendDevices] Suspended device ${device.id} (${device.device_name}), revoked ${activeTokens.length} token(s)`);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (expired.length) console.log(`[suspendDevices] Suspended ${expired.length} device(s)`);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.error('[suspendDevices]', err.message);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
module.exports = suspendDevices;
|
src/middleware/auth.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const jwt = require('jsonwebtoken');
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
|
| 4 |
+
async function requireAuth(req, res, next) {
|
| 5 |
+
const header = req.headers.authorization;
|
| 6 |
+
if (!header?.startsWith('Bearer ')) {
|
| 7 |
+
return res.status(401).json({ error: 'Authentication required' });
|
| 8 |
+
}
|
| 9 |
+
try {
|
| 10 |
+
const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
|
| 11 |
+
|
| 12 |
+
// Confirm account is still active — catches deactivated tenants mid-session
|
| 13 |
+
const client = await db.queryOne(
|
| 14 |
+
'SELECT id, is_active FROM clients WHERE id = ? LIMIT 1',
|
| 15 |
+
[payload.id]
|
| 16 |
+
);
|
| 17 |
+
if (!client || !client.is_active) {
|
| 18 |
+
return res.status(401).json({ error: 'Account is inactive' });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
req.client = payload;
|
| 22 |
+
next();
|
| 23 |
+
} catch {
|
| 24 |
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function requireAdmin(req, res, next) {
|
| 29 |
+
const header = req.headers.authorization;
|
| 30 |
+
if (!header?.startsWith('Bearer ')) {
|
| 31 |
+
return res.status(401).json({ error: 'Admin authentication required' });
|
| 32 |
+
}
|
| 33 |
+
const token = header.slice(7);
|
| 34 |
+
if (token === process.env.ADMIN_TOKEN) {
|
| 35 |
+
req.isAdmin = true;
|
| 36 |
+
return next();
|
| 37 |
+
}
|
| 38 |
+
return res.status(401).json({ error: 'Invalid admin token' });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function signToken(client) {
|
| 42 |
+
return jwt.sign(
|
| 43 |
+
{ id: client.id, email: client.email, business_name: client.business_name },
|
| 44 |
+
process.env.JWT_SECRET,
|
| 45 |
+
{ expiresIn: '30d' }
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
module.exports = { requireAuth, requireAdmin, signToken };
|
src/middleware/rateLimiter.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const rateLimit = require('express-rate-limit');
|
| 2 |
+
|
| 3 |
+
// Tenant login — 5 failed attempts per 15 min per IP
|
| 4 |
+
const loginLimiter = rateLimit({
|
| 5 |
+
windowMs: 15 * 60 * 1000,
|
| 6 |
+
max: 5,
|
| 7 |
+
skipSuccessfulRequests: true,
|
| 8 |
+
standardHeaders: true,
|
| 9 |
+
legacyHeaders: false,
|
| 10 |
+
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// Tenant registration — 3 accounts per hour per IP
|
| 14 |
+
const registerLimiter = rateLimit({
|
| 15 |
+
windowMs: 60 * 60 * 1000,
|
| 16 |
+
max: 3,
|
| 17 |
+
standardHeaders: true,
|
| 18 |
+
legacyHeaders: false,
|
| 19 |
+
message: { error: 'Too many registration attempts. Try again in an hour.' },
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// Portal purchase — 5 per 10 min per IP (prevents M-Pesa spam to any phone)
|
| 23 |
+
const purchaseLimiter = rateLimit({
|
| 24 |
+
windowMs: 10 * 60 * 1000,
|
| 25 |
+
max: 5,
|
| 26 |
+
standardHeaders: true,
|
| 27 |
+
legacyHeaders: false,
|
| 28 |
+
message: { error: 'Too many purchase attempts. Please wait before trying again.' },
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
// Forgot password — 3 OTP requests per 15 min per IP (prevents SMS bombing)
|
| 32 |
+
const forgotPasswordLimiter = rateLimit({
|
| 33 |
+
windowMs: 15 * 60 * 1000,
|
| 34 |
+
max: 3,
|
| 35 |
+
standardHeaders: true,
|
| 36 |
+
legacyHeaders: false,
|
| 37 |
+
message: { error: 'Too many reset requests. Try again in 15 minutes.' },
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// Portal auth / code entry — 10 per 15 min per IP (prevents code brute force)
|
| 41 |
+
const portalAuthLimiter = rateLimit({
|
| 42 |
+
windowMs: 15 * 60 * 1000,
|
| 43 |
+
max: 10,
|
| 44 |
+
standardHeaders: true,
|
| 45 |
+
legacyHeaders: false,
|
| 46 |
+
message: { error: 'Too many authorization attempts. Try again in 15 minutes.' },
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
module.exports = { loginLimiter, registerLimiter, forgotPasswordLimiter, purchaseLimiter, portalAuthLimiter };
|
src/routes/admin.routes.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const omada = require('../services/omada');
|
| 4 |
+
const { requireAdmin } = require('../middleware/auth');
|
| 5 |
+
|
| 6 |
+
router.use(requireAdmin);
|
| 7 |
+
|
| 8 |
+
// GET /api/admin/overview
|
| 9 |
+
router.get('/overview', async (req, res) => {
|
| 10 |
+
try {
|
| 11 |
+
const [[stats]] = await Promise.all([
|
| 12 |
+
db.query(`
|
| 13 |
+
SELECT
|
| 14 |
+
(SELECT COUNT(*) FROM clients WHERE is_active = 1) AS tenants,
|
| 15 |
+
(SELECT COUNT(*) FROM devices WHERE status = 'online') AS devices_online,
|
| 16 |
+
(SELECT COUNT(*) FROM devices WHERE status = 'offline') AS devices_offline,
|
| 17 |
+
(SELECT COUNT(*) FROM devices WHERE billing_status = 'suspended') AS devices_suspended,
|
| 18 |
+
(SELECT COUNT(*) FROM sessions WHERE is_active = 1) AS guests_online,
|
| 19 |
+
(SELECT COUNT(*) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS sales_today,
|
| 20 |
+
(SELECT COALESCE(SUM(gross_amount),0) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS gross_today,
|
| 21 |
+
(SELECT COALESCE(SUM(commission_amount),0) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS commission_today,
|
| 22 |
+
(SELECT COALESCE(SUM(snippe_fee),0) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS snippe_fees_today,
|
| 23 |
+
(SELECT COUNT(*) FROM payouts WHERE status = 'pending') AS pending_payouts,
|
| 24 |
+
(SELECT COALESCE(SUM(amount),0) FROM payouts WHERE status = 'pending') AS pending_payout_total
|
| 25 |
+
`)
|
| 26 |
+
]);
|
| 27 |
+
|
| 28 |
+
res.json(stats[0]);
|
| 29 |
+
} catch (err) {
|
| 30 |
+
console.error('[admin/overview]', err.message);
|
| 31 |
+
res.status(500).json({ error: 'Failed to load overview' });
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
// GET /api/admin/tenants
|
| 36 |
+
router.get('/tenants', async (req, res) => {
|
| 37 |
+
try {
|
| 38 |
+
const tenants = await db.query(`
|
| 39 |
+
SELECT c.id, c.business_name, c.contact_name, c.email, c.phone,
|
| 40 |
+
c.balance, c.total_earned, c.total_withdrawn, c.is_active, c.created_at,
|
| 41 |
+
COUNT(d.id) AS device_count,
|
| 42 |
+
SUM(CASE WHEN d.status = 'online' THEN 1 ELSE 0 END) AS devices_online
|
| 43 |
+
FROM clients c
|
| 44 |
+
LEFT JOIN devices d ON d.client_id = c.id AND d.billing_status != 'cancelled'
|
| 45 |
+
GROUP BY c.id
|
| 46 |
+
ORDER BY c.created_at DESC
|
| 47 |
+
`);
|
| 48 |
+
res.json(tenants);
|
| 49 |
+
} catch (err) {
|
| 50 |
+
res.status(500).json({ error: 'Failed to fetch tenants' });
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// GET /api/admin/devices
|
| 55 |
+
router.get('/devices', async (req, res) => {
|
| 56 |
+
try {
|
| 57 |
+
const devices = await db.query(`
|
| 58 |
+
SELECT d.id, d.name, d.location, d.mac, d.model, d.status, d.billing_status,
|
| 59 |
+
d.billing_expires_at, d.monthly_fee, d.cpu_util, d.mem_util,
|
| 60 |
+
d.guest_count, d.last_seen_at,
|
| 61 |
+
c.business_name, c.phone AS client_phone
|
| 62 |
+
FROM devices d
|
| 63 |
+
JOIN clients c ON c.id = d.client_id
|
| 64 |
+
WHERE d.billing_status != 'cancelled'
|
| 65 |
+
ORDER BY d.status ASC, d.registered_at DESC
|
| 66 |
+
`);
|
| 67 |
+
res.json(devices);
|
| 68 |
+
} catch (err) {
|
| 69 |
+
res.status(500).json({ error: 'Failed to fetch devices' });
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// GET /api/admin/payouts/pending
|
| 74 |
+
router.get('/payouts/pending', async (req, res) => {
|
| 75 |
+
try {
|
| 76 |
+
const payouts = await db.query(`
|
| 77 |
+
SELECT po.id, po.amount, po.payout_fee, po.net_amount,
|
| 78 |
+
po.destination_phone, po.destination_provider, po.status, po.created_at,
|
| 79 |
+
c.business_name, c.contact_name
|
| 80 |
+
FROM payouts po
|
| 81 |
+
JOIN clients c ON c.id = po.client_id
|
| 82 |
+
WHERE po.status IN ('pending', 'processing')
|
| 83 |
+
ORDER BY po.created_at ASC
|
| 84 |
+
`);
|
| 85 |
+
res.json(payouts);
|
| 86 |
+
} catch (err) {
|
| 87 |
+
res.status(500).json({ error: 'Failed to fetch pending payouts' });
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
// GET /api/admin/revenue/daily?days=30
|
| 92 |
+
router.get('/revenue/daily', async (req, res) => {
|
| 93 |
+
const days = parseInt(req.query.days) || 30;
|
| 94 |
+
try {
|
| 95 |
+
const rows = await db.query(`
|
| 96 |
+
SELECT DATE(completed_at) AS day,
|
| 97 |
+
COUNT(*) AS sales,
|
| 98 |
+
SUM(gross_amount) AS gross,
|
| 99 |
+
SUM(commission_amount) AS commission,
|
| 100 |
+
SUM(client_credit) AS tenant_credit
|
| 101 |
+
FROM payments
|
| 102 |
+
WHERE status = 'completed'
|
| 103 |
+
AND completed_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
| 104 |
+
GROUP BY DATE(completed_at)
|
| 105 |
+
ORDER BY day DESC
|
| 106 |
+
`, [days]);
|
| 107 |
+
res.json(rows);
|
| 108 |
+
} catch (err) {
|
| 109 |
+
res.status(500).json({ error: 'Failed to fetch revenue' });
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
// POST /api/admin/devices/:id/reboot
|
| 114 |
+
router.post('/devices/:id/reboot', async (req, res) => {
|
| 115 |
+
try {
|
| 116 |
+
const device = await db.query(
|
| 117 |
+
'SELECT omada_site_id, mac FROM devices WHERE id = ? LIMIT 1',
|
| 118 |
+
[req.params.id]
|
| 119 |
+
).then(r => r[0]);
|
| 120 |
+
|
| 121 |
+
if (!device?.omada_site_id) return res.status(404).json({ error: 'Device not found or not adopted' });
|
| 122 |
+
|
| 123 |
+
await omada.rebootDevice(device.omada_site_id, device.mac);
|
| 124 |
+
res.json({ message: 'Reboot command sent' });
|
| 125 |
+
} catch (err) {
|
| 126 |
+
res.status(500).json({ error: 'Reboot failed: ' + err.message });
|
| 127 |
+
}
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
module.exports = router;
|
src/routes/alerts.routes.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const { requireAuth } = require('../middleware/auth');
|
| 4 |
+
|
| 5 |
+
router.use(requireAuth);
|
| 6 |
+
|
| 7 |
+
// GET /api/alerts/unresolved
|
| 8 |
+
router.get('/unresolved', async (req, res) => {
|
| 9 |
+
try {
|
| 10 |
+
const alerts = await db.query(
|
| 11 |
+
`SELECT a.id, a.alert_key, a.level, a.content, a.device_mac,
|
| 12 |
+
a.alert_time, a.fetched_at,
|
| 13 |
+
d.name AS device_name
|
| 14 |
+
FROM omada_alerts a
|
| 15 |
+
LEFT JOIN devices d ON d.id = a.device_id
|
| 16 |
+
WHERE a.client_id = ? AND a.resolved = 0
|
| 17 |
+
ORDER BY a.alert_time DESC LIMIT 50`,
|
| 18 |
+
[req.client.id]
|
| 19 |
+
);
|
| 20 |
+
res.json(alerts);
|
| 21 |
+
} catch (err) {
|
| 22 |
+
res.status(500).json({ error: 'Failed to fetch alerts' });
|
| 23 |
+
}
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
// PATCH /api/alerts/:id/resolve
|
| 27 |
+
router.patch('/:id/resolve', async (req, res) => {
|
| 28 |
+
try {
|
| 29 |
+
await db.query(
|
| 30 |
+
'UPDATE omada_alerts SET resolved = 1 WHERE id = ? AND client_id = ?',
|
| 31 |
+
[req.params.id, req.client.id]
|
| 32 |
+
);
|
| 33 |
+
res.json({ message: 'Alert resolved' });
|
| 34 |
+
} catch (err) {
|
| 35 |
+
res.status(500).json({ error: 'Failed to resolve alert' });
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
module.exports = router;
|
src/routes/auth.routes.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const bcrypt = require('bcryptjs');
|
| 3 |
+
const db = require('../config/db');
|
| 4 |
+
const sms = require('../services/sms');
|
| 5 |
+
const messages = require('../utils/messages');
|
| 6 |
+
const { requireAuth, signToken } = require('../middleware/auth');
|
| 7 |
+
const { loginLimiter, registerLimiter, forgotPasswordLimiter } = require('../middleware/rateLimiter');
|
| 8 |
+
|
| 9 |
+
// POST /api/auth/register
|
| 10 |
+
router.post('/register', registerLimiter, async (req, res) => {
|
| 11 |
+
const { business_name, contact_name, email, phone, password } = req.body;
|
| 12 |
+
|
| 13 |
+
if (!business_name || !contact_name || !email || !phone || !password) {
|
| 14 |
+
return res.status(400).json({ error: 'All fields are required' });
|
| 15 |
+
}
|
| 16 |
+
if (password.length < 8) {
|
| 17 |
+
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const existing = await db.query(
|
| 22 |
+
'SELECT id FROM clients WHERE email = ? OR phone = ? LIMIT 1',
|
| 23 |
+
[email.toLowerCase(), phone]
|
| 24 |
+
);
|
| 25 |
+
if (existing.length) {
|
| 26 |
+
return res.status(409).json({ error: 'Email or phone already registered' });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const hash = await bcrypt.hash(password, 10);
|
| 30 |
+
const result = await db.query(
|
| 31 |
+
`INSERT INTO clients (business_name, contact_name, email, phone, password_hash)
|
| 32 |
+
VALUES (?, ?, ?, ?, ?)`,
|
| 33 |
+
[business_name, contact_name, email.toLowerCase(), phone, hash]
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
const client = await db.query('SELECT * FROM clients WHERE id = ?', [result.insertId])
|
| 37 |
+
.then(r => r[0]);
|
| 38 |
+
|
| 39 |
+
// Welcome SMS (fire-and-forget)
|
| 40 |
+
sms.sendSMS(phone, messages.welcome()).catch(() => {});
|
| 41 |
+
|
| 42 |
+
const token = signToken(client);
|
| 43 |
+
const { password_hash, ...safe } = client;
|
| 44 |
+
res.status(201).json({ token, client: safe });
|
| 45 |
+
} catch (err) {
|
| 46 |
+
console.error('[auth/register]', err.message);
|
| 47 |
+
res.status(500).json({ error: 'Registration failed' });
|
| 48 |
+
}
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
// POST /api/auth/login
|
| 52 |
+
router.post('/login', loginLimiter, async (req, res) => {
|
| 53 |
+
const { email, password } = req.body;
|
| 54 |
+
if (!email || !password) {
|
| 55 |
+
return res.status(400).json({ error: 'Email and password required' });
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
try {
|
| 59 |
+
const client = await db.query(
|
| 60 |
+
'SELECT * FROM clients WHERE email = ? AND is_active = 1 LIMIT 1',
|
| 61 |
+
[email.toLowerCase()]
|
| 62 |
+
).then(r => r[0]);
|
| 63 |
+
|
| 64 |
+
if (!client || !(await bcrypt.compare(password, client.password_hash))) {
|
| 65 |
+
return res.status(401).json({ error: 'Invalid credentials' });
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const token = signToken(client);
|
| 69 |
+
const { password_hash, ...safe } = client;
|
| 70 |
+
res.json({ token, client: safe });
|
| 71 |
+
} catch (err) {
|
| 72 |
+
console.error('[auth/login]', err.message);
|
| 73 |
+
res.status(500).json({ error: 'Login failed' });
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
// POST /api/auth/forgot-password
|
| 78 |
+
router.post('/forgot-password', forgotPasswordLimiter, async (req, res) => {
|
| 79 |
+
const { phone } = req.body;
|
| 80 |
+
if (!phone) return res.status(400).json({ error: 'phone required' });
|
| 81 |
+
|
| 82 |
+
try {
|
| 83 |
+
const client = await db.queryOne(
|
| 84 |
+
'SELECT id FROM clients WHERE phone = ? AND is_active = 1 LIMIT 1',
|
| 85 |
+
[phone]
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
// Always respond 200 — don't reveal whether phone is registered
|
| 89 |
+
if (!client) return res.json({ message: 'If that number is registered, an OTP has been sent.' });
|
| 90 |
+
|
| 91 |
+
const otp = String(Math.floor(100000 + Math.random() * 900000));
|
| 92 |
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
| 93 |
+
|
| 94 |
+
// Invalidate any previous unused OTPs for this phone
|
| 95 |
+
await db.query(`UPDATE password_resets SET used = 1 WHERE phone = ? AND used = 0`, [phone]);
|
| 96 |
+
|
| 97 |
+
await db.query(
|
| 98 |
+
`INSERT INTO password_resets (phone, otp, expires_at) VALUES (?, ?, ?)`,
|
| 99 |
+
[phone, otp, expiresAt]
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
sms.sendSMS(phone, messages.passwordResetOtp(otp)).catch(() => {});
|
| 103 |
+
|
| 104 |
+
res.json({ message: 'If that number is registered, an OTP has been sent.' });
|
| 105 |
+
} catch (err) {
|
| 106 |
+
console.error('[auth/forgot-password]', err.message);
|
| 107 |
+
res.status(500).json({ error: 'Request failed' });
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// POST /api/auth/reset-password
|
| 112 |
+
router.post('/reset-password', async (req, res) => {
|
| 113 |
+
const { phone, otp, new_password } = req.body;
|
| 114 |
+
|
| 115 |
+
if (!phone || !otp || !new_password) {
|
| 116 |
+
return res.status(400).json({ error: 'phone, otp, and new_password required' });
|
| 117 |
+
}
|
| 118 |
+
if (new_password.length < 8) {
|
| 119 |
+
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
try {
|
| 123 |
+
const reset = await db.queryOne(
|
| 124 |
+
`SELECT id FROM password_resets
|
| 125 |
+
WHERE phone = ? AND otp = ? AND used = 0 AND expires_at > NOW()
|
| 126 |
+
ORDER BY created_at DESC LIMIT 1`,
|
| 127 |
+
[phone, otp]
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
if (!reset) {
|
| 131 |
+
return res.status(400).json({ error: 'Invalid or expired OTP' });
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const hash = await bcrypt.hash(new_password, 10);
|
| 135 |
+
|
| 136 |
+
await db.query(`UPDATE clients SET password_hash = ? WHERE phone = ?`, [hash, phone]);
|
| 137 |
+
await db.query(`UPDATE password_resets SET used = 1 WHERE id = ?`, [reset.id]);
|
| 138 |
+
|
| 139 |
+
res.json({ message: 'Password reset successfully. Please log in.' });
|
| 140 |
+
} catch (err) {
|
| 141 |
+
console.error('[auth/reset-password]', err.message);
|
| 142 |
+
res.status(500).json({ error: 'Reset failed' });
|
| 143 |
+
}
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
// GET /api/auth/me
|
| 147 |
+
router.get('/me', requireAuth, async (req, res) => {
|
| 148 |
+
try {
|
| 149 |
+
const client = await db.query(
|
| 150 |
+
'SELECT id, business_name, contact_name, email, phone, balance, total_earned, total_withdrawn, created_at FROM clients WHERE id = ?',
|
| 151 |
+
[req.client.id]
|
| 152 |
+
).then(r => r[0]);
|
| 153 |
+
|
| 154 |
+
if (!client) return res.status(404).json({ error: 'Account not found' });
|
| 155 |
+
res.json(client);
|
| 156 |
+
} catch (err) {
|
| 157 |
+
res.status(500).json({ error: 'Failed to fetch profile' });
|
| 158 |
+
}
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
module.exports = router;
|
src/routes/dashboard.routes.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const { requireAuth } = require('../middleware/auth');
|
| 4 |
+
|
| 5 |
+
router.use(requireAuth);
|
| 6 |
+
|
| 7 |
+
// GET /api/dashboard/summary
|
| 8 |
+
router.get('/summary', async (req, res) => {
|
| 9 |
+
const clientId = req.client.id;
|
| 10 |
+
try {
|
| 11 |
+
// Balance
|
| 12 |
+
const client = await db.query(
|
| 13 |
+
'SELECT balance, total_earned, total_withdrawn FROM clients WHERE id = ?',
|
| 14 |
+
[clientId]
|
| 15 |
+
).then(r => r[0]);
|
| 16 |
+
|
| 17 |
+
// Devices with today's stats
|
| 18 |
+
const devices = await db.query(
|
| 19 |
+
`SELECT d.id, d.name, d.location, d.status, d.billing_status, d.billing_expires_at,
|
| 20 |
+
d.model, d.cpu_util, d.mem_util, d.guest_count, d.last_seen_at,
|
| 21 |
+
(SELECT COUNT(*) FROM payments
|
| 22 |
+
WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()
|
| 23 |
+
) AS sales_today,
|
| 24 |
+
(SELECT COALESCE(SUM(client_credit),0) FROM payments
|
| 25 |
+
WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()
|
| 26 |
+
) AS earned_today,
|
| 27 |
+
(SELECT COUNT(*) FROM sessions
|
| 28 |
+
WHERE device_id = d.id AND is_active = 1
|
| 29 |
+
) AS guests_online
|
| 30 |
+
FROM devices d
|
| 31 |
+
WHERE d.client_id = ? AND d.billing_status != 'cancelled'
|
| 32 |
+
ORDER BY d.registered_at ASC`,
|
| 33 |
+
[clientId]
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
// Unresolved alerts count
|
| 37 |
+
const alertCount = await db.query(
|
| 38 |
+
'SELECT COUNT(*) AS cnt FROM omada_alerts WHERE client_id = ? AND resolved = 0',
|
| 39 |
+
[clientId]
|
| 40 |
+
).then(r => r[0].cnt);
|
| 41 |
+
|
| 42 |
+
res.json({
|
| 43 |
+
balance: parseFloat(client.balance),
|
| 44 |
+
total_earned: parseFloat(client.total_earned),
|
| 45 |
+
total_withdrawn: parseFloat(client.total_withdrawn),
|
| 46 |
+
devices,
|
| 47 |
+
alert_count: alertCount,
|
| 48 |
+
});
|
| 49 |
+
} catch (err) {
|
| 50 |
+
console.error('[dashboard/summary]', err.message);
|
| 51 |
+
res.status(500).json({ error: 'Failed to load dashboard' });
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
// GET /api/dashboard/revenue/weekly
|
| 56 |
+
router.get('/revenue/weekly', async (req, res) => {
|
| 57 |
+
const clientId = req.client.id;
|
| 58 |
+
try {
|
| 59 |
+
const rows = await db.query(
|
| 60 |
+
`SELECT DATE(completed_at) AS day,
|
| 61 |
+
COUNT(*) AS sales,
|
| 62 |
+
SUM(gross_amount) AS gross,
|
| 63 |
+
SUM(client_credit) AS net
|
| 64 |
+
FROM payments
|
| 65 |
+
WHERE client_id = ? AND status = 'completed'
|
| 66 |
+
AND completed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
| 67 |
+
GROUP BY DATE(completed_at)
|
| 68 |
+
ORDER BY day ASC`,
|
| 69 |
+
[clientId]
|
| 70 |
+
);
|
| 71 |
+
res.json(rows);
|
| 72 |
+
} catch (err) {
|
| 73 |
+
res.status(500).json({ error: 'Failed to load revenue data' });
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
module.exports = router;
|
src/routes/devices.routes.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const omada = require('../services/omada');
|
| 4 |
+
const snippe = require('../services/snippe');
|
| 5 |
+
const sms = require('../services/sms');
|
| 6 |
+
const messages = require('../utils/messages');
|
| 7 |
+
const { requireAuth } = require('../middleware/auth');
|
| 8 |
+
const { v4: uuidv4 } = require('uuid');
|
| 9 |
+
|
| 10 |
+
router.use(requireAuth);
|
| 11 |
+
|
| 12 |
+
// GET /api/devices
|
| 13 |
+
router.get('/', async (req, res) => {
|
| 14 |
+
try {
|
| 15 |
+
const devices = await db.query(
|
| 16 |
+
`SELECT id, name, location, mac, model, ip, status, billing_status,
|
| 17 |
+
billing_expires_at, monthly_fee, cpu_util, mem_util, guest_count,
|
| 18 |
+
client_count, last_seen_at, registered_at, omada_site_id
|
| 19 |
+
FROM devices WHERE client_id = ? AND billing_status != 'cancelled'
|
| 20 |
+
ORDER BY registered_at ASC`,
|
| 21 |
+
[req.client.id]
|
| 22 |
+
);
|
| 23 |
+
res.json(devices);
|
| 24 |
+
} catch (err) {
|
| 25 |
+
res.status(500).json({ error: 'Failed to fetch devices' });
|
| 26 |
+
}
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
// POST /api/devices
|
| 30 |
+
router.post('/', async (req, res) => {
|
| 31 |
+
const { name, location, mac, device_username, device_password } = req.body;
|
| 32 |
+
|
| 33 |
+
if (!name || !mac) {
|
| 34 |
+
return res.status(400).json({ error: 'name and mac are required' });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Normalize MAC: uppercase with dashes
|
| 38 |
+
const normalMac = mac.trim().toUpperCase()
|
| 39 |
+
.replace(/[:\-]/g, '-')
|
| 40 |
+
.replace(/(.{2})(?=.)/g, '$1-')
|
| 41 |
+
.slice(0, 17);
|
| 42 |
+
|
| 43 |
+
if (!/^([0-9A-F]{2}-){5}[0-9A-F]{2}$/.test(normalMac)) {
|
| 44 |
+
return res.status(400).json({ error: 'Invalid MAC address format (use AA-BB-CC-DD-EE-FF)' });
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Check MAC not already registered
|
| 48 |
+
const existing = await db.query('SELECT id FROM devices WHERE mac = ? LIMIT 1', [normalMac]);
|
| 49 |
+
if (existing.length) {
|
| 50 |
+
return res.status(409).json({ error: 'This MAC address is already registered' });
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const client = await db.query('SELECT * FROM clients WHERE id = ? LIMIT 1', [req.client.id])
|
| 55 |
+
.then(r => r[0]);
|
| 56 |
+
|
| 57 |
+
// 1. Create Omada site
|
| 58 |
+
const siteName = `${client.business_name} - ${name}`;
|
| 59 |
+
let siteId, portalId, deviceUsername, devicePassword;
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
siteId = await omada.createSite(siteName);
|
| 63 |
+
|
| 64 |
+
// Tenant-supplied credentials take priority; fall back to Omada site defaults
|
| 65 |
+
const siteInfo = await omada.getSiteById(siteId);
|
| 66 |
+
deviceUsername = device_username || siteInfo?.deviceAccount?.username || 'admin';
|
| 67 |
+
devicePassword = device_password || siteInfo?.deviceAccount?.password || 'Admin@123';
|
| 68 |
+
|
| 69 |
+
// 2. Create captive portal on this site
|
| 70 |
+
portalId = await omada.createPortal(siteId, `${name} Portal`, []);
|
| 71 |
+
} catch (omadaErr) {
|
| 72 |
+
console.error('[devices/POST] Omada setup failed:', omadaErr.message);
|
| 73 |
+
// Continue — device can still be registered, Omada setup retried later
|
| 74 |
+
siteId = null;
|
| 75 |
+
portalId = null;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// 3. Calculate trial expiry
|
| 79 |
+
const trialDays = parseInt(process.env.DEVICE_TRIAL_DAYS) || 14;
|
| 80 |
+
const expiresAt = new Date(Date.now() + trialDays * 24 * 60 * 60 * 1000);
|
| 81 |
+
|
| 82 |
+
// 4. Insert device
|
| 83 |
+
const result = await db.query(
|
| 84 |
+
`INSERT INTO devices
|
| 85 |
+
(client_id, name, location, mac, omada_site_id, omada_portal_id,
|
| 86 |
+
device_username, device_password, billing_status, billing_expires_at, monthly_fee)
|
| 87 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'trial', ?, ?)`,
|
| 88 |
+
[
|
| 89 |
+
req.client.id,
|
| 90 |
+
name,
|
| 91 |
+
location || null,
|
| 92 |
+
normalMac,
|
| 93 |
+
siteId,
|
| 94 |
+
portalId,
|
| 95 |
+
deviceUsername || null,
|
| 96 |
+
devicePassword || null,
|
| 97 |
+
expiresAt,
|
| 98 |
+
parseFloat(process.env.DEVICE_MONTHLY_FEE) || 20000,
|
| 99 |
+
]
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
const device = await db.query('SELECT * FROM devices WHERE id = ?', [result.insertId])
|
| 103 |
+
.then(r => r[0]);
|
| 104 |
+
|
| 105 |
+
res.status(201).json({
|
| 106 |
+
device,
|
| 107 |
+
message: siteId
|
| 108 |
+
? 'Device registered. Plug in your AP and wait for it to come online.'
|
| 109 |
+
: 'Device registered (Omada setup pending). Contact support if the AP does not come online.',
|
| 110 |
+
});
|
| 111 |
+
} catch (err) {
|
| 112 |
+
console.error('[devices/POST]', err.message);
|
| 113 |
+
res.status(500).json({ error: 'Failed to register device' });
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// GET /api/devices/:id
|
| 118 |
+
router.get('/:id', async (req, res) => {
|
| 119 |
+
try {
|
| 120 |
+
const device = await db.query(
|
| 121 |
+
`SELECT d.*,
|
| 122 |
+
(SELECT COUNT(*) FROM payments WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()) AS sales_today,
|
| 123 |
+
(SELECT COALESCE(SUM(client_credit),0) FROM payments WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()) AS earned_today
|
| 124 |
+
FROM devices d
|
| 125 |
+
WHERE d.id = ? AND d.client_id = ?`,
|
| 126 |
+
[req.params.id, req.client.id]
|
| 127 |
+
).then(r => r[0]);
|
| 128 |
+
|
| 129 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 130 |
+
|
| 131 |
+
// Active guests from DB
|
| 132 |
+
const activeSessions = await db.query(
|
| 133 |
+
`SELECT s.client_mac, s.started_at, s.bytes_down, s.bytes_up,
|
| 134 |
+
t.expires_at, t.code, wp.name AS plan_name
|
| 135 |
+
FROM sessions s
|
| 136 |
+
LEFT JOIN access_tokens t ON t.id = s.access_token_id
|
| 137 |
+
LEFT JOIN wifi_plans wp ON wp.id = t.plan_id
|
| 138 |
+
WHERE s.device_id = ? AND s.is_active = 1`,
|
| 139 |
+
[device.id]
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
res.json({ ...device, active_sessions: activeSessions });
|
| 143 |
+
} catch (err) {
|
| 144 |
+
res.status(500).json({ error: 'Failed to fetch device' });
|
| 145 |
+
}
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
// GET /api/devices/:id/portal — get portal customization settings
|
| 149 |
+
router.get('/:id/portal', async (req, res) => {
|
| 150 |
+
try {
|
| 151 |
+
const device = await db.queryOne(
|
| 152 |
+
'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
|
| 153 |
+
[req.params.id, req.client.id]
|
| 154 |
+
);
|
| 155 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 156 |
+
|
| 157 |
+
const settings = await db.queryOne(
|
| 158 |
+
'SELECT * FROM device_portal_settings WHERE device_id = ?',
|
| 159 |
+
[req.params.id]
|
| 160 |
+
);
|
| 161 |
+
|
| 162 |
+
// Return settings or defaults if not yet customized
|
| 163 |
+
res.json(settings || {
|
| 164 |
+
device_id: parseInt(req.params.id),
|
| 165 |
+
display_name: null,
|
| 166 |
+
welcome_text: null,
|
| 167 |
+
logo_url: null,
|
| 168 |
+
support_phone: null,
|
| 169 |
+
primary_color: '#4361ee',
|
| 170 |
+
bg_color: '#1a1a2e',
|
| 171 |
+
});
|
| 172 |
+
} catch (err) {
|
| 173 |
+
res.status(500).json({ error: 'Failed to fetch portal settings' });
|
| 174 |
+
}
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
// PATCH /api/devices/:id/portal — update portal customization settings
|
| 178 |
+
router.patch('/:id/portal', async (req, res) => {
|
| 179 |
+
const { display_name, welcome_text, logo_url, support_phone, primary_color, bg_color } = req.body;
|
| 180 |
+
|
| 181 |
+
const hexColor = v => !v || /^#[0-9A-Fa-f]{6}$/.test(v);
|
| 182 |
+
if (!hexColor(primary_color) || !hexColor(bg_color)) {
|
| 183 |
+
return res.status(400).json({ error: 'Colors must be a valid hex value (e.g. #4361ee)' });
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
try {
|
| 187 |
+
const device = await db.queryOne(
|
| 188 |
+
'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
|
| 189 |
+
[req.params.id, req.client.id]
|
| 190 |
+
);
|
| 191 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 192 |
+
|
| 193 |
+
await db.query(
|
| 194 |
+
`INSERT INTO device_portal_settings
|
| 195 |
+
(device_id, display_name, welcome_text, logo_url, support_phone, primary_color, bg_color)
|
| 196 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 197 |
+
ON DUPLICATE KEY UPDATE
|
| 198 |
+
display_name = COALESCE(VALUES(display_name), display_name),
|
| 199 |
+
welcome_text = COALESCE(VALUES(welcome_text), welcome_text),
|
| 200 |
+
logo_url = COALESCE(VALUES(logo_url), logo_url),
|
| 201 |
+
support_phone = COALESCE(VALUES(support_phone), support_phone),
|
| 202 |
+
primary_color = COALESCE(VALUES(primary_color), primary_color),
|
| 203 |
+
bg_color = COALESCE(VALUES(bg_color), bg_color)`,
|
| 204 |
+
[
|
| 205 |
+
req.params.id,
|
| 206 |
+
display_name ?? null,
|
| 207 |
+
welcome_text ?? null,
|
| 208 |
+
logo_url ?? null,
|
| 209 |
+
support_phone ?? null,
|
| 210 |
+
primary_color ?? null,
|
| 211 |
+
bg_color ?? null,
|
| 212 |
+
]
|
| 213 |
+
);
|
| 214 |
+
|
| 215 |
+
const updated = await db.queryOne(
|
| 216 |
+
'SELECT * FROM device_portal_settings WHERE device_id = ?',
|
| 217 |
+
[req.params.id]
|
| 218 |
+
);
|
| 219 |
+
res.json(updated);
|
| 220 |
+
} catch (err) {
|
| 221 |
+
console.error('[devices/portal PATCH]', err.message);
|
| 222 |
+
res.status(500).json({ error: 'Failed to update portal settings' });
|
| 223 |
+
}
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
// DELETE /api/devices/:id (pause device — reversible via renewal)
|
| 227 |
+
router.delete('/:id', async (req, res) => {
|
| 228 |
+
try {
|
| 229 |
+
const device = await db.query(
|
| 230 |
+
`SELECT d.*, c.phone AS client_phone
|
| 231 |
+
FROM devices d JOIN clients c ON c.id = d.client_id
|
| 232 |
+
WHERE d.id = ? AND d.client_id = ? LIMIT 1`,
|
| 233 |
+
[req.params.id, req.client.id]
|
| 234 |
+
).then(r => r[0]);
|
| 235 |
+
|
| 236 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 237 |
+
if (device.billing_status === 'suspended') {
|
| 238 |
+
return res.status(400).json({ error: 'Device is already suspended' });
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Suspend billing
|
| 242 |
+
await db.query(
|
| 243 |
+
`UPDATE devices SET billing_status = 'suspended' WHERE id = ?`,
|
| 244 |
+
[device.id]
|
| 245 |
+
);
|
| 246 |
+
|
| 247 |
+
// Revoke active tokens and kick guests off Omada
|
| 248 |
+
const activeTokens = await db.query(
|
| 249 |
+
`SELECT id, locked_mac FROM access_tokens WHERE device_id = ? AND status = 'active'`,
|
| 250 |
+
[device.id]
|
| 251 |
+
);
|
| 252 |
+
|
| 253 |
+
for (const token of activeTokens) {
|
| 254 |
+
if (token.locked_mac && device.omada_site_id) {
|
| 255 |
+
try {
|
| 256 |
+
await omada.deauthorizeClient(device.omada_site_id, token.locked_mac);
|
| 257 |
+
} catch (err) {
|
| 258 |
+
console.error(`[devices/DELETE] Deauth ${token.locked_mac} failed:`, err.message);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
await db.query(`UPDATE access_tokens SET status = 'revoked' WHERE id = ?`, [token.id]);
|
| 262 |
+
await db.query(
|
| 263 |
+
`UPDATE sessions SET is_active = 0, ended_at = NOW() WHERE access_token_id = ? AND is_active = 1`,
|
| 264 |
+
[token.id]
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Notify tenant to renew
|
| 269 |
+
const fee = parseInt(device.monthly_fee || 20000).toLocaleString();
|
| 270 |
+
sms.sendSMS(device.client_phone, messages.devicePaused(device.name, fee)).catch(() => {});
|
| 271 |
+
|
| 272 |
+
res.json({ message: 'Device paused. Renew your subscription to reactivate.' });
|
| 273 |
+
} catch (err) {
|
| 274 |
+
console.error('[devices/DELETE]', err.message);
|
| 275 |
+
res.status(500).json({ error: 'Failed to pause device' });
|
| 276 |
+
}
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
// POST /api/devices/:id/renew — initiate monthly billing payment
|
| 280 |
+
router.post('/:id/renew', async (req, res) => {
|
| 281 |
+
try {
|
| 282 |
+
const device = await db.query(
|
| 283 |
+
`SELECT d.*, c.phone AS client_phone, c.contact_name
|
| 284 |
+
FROM devices d JOIN clients c ON c.id = d.client_id
|
| 285 |
+
WHERE d.id = ? AND d.client_id = ? LIMIT 1`,
|
| 286 |
+
[req.params.id, req.client.id]
|
| 287 |
+
).then(r => r[0]);
|
| 288 |
+
|
| 289 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 290 |
+
if (device.billing_status === 'cancelled') {
|
| 291 |
+
return res.status(400).json({ error: 'Device is cancelled' });
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const reference = uuidv4().replace(/-/g, '').slice(0, 20);
|
| 295 |
+
const now = new Date();
|
| 296 |
+
const start = device.billing_expires_at ? new Date(device.billing_expires_at) : now;
|
| 297 |
+
const end = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
|
| 298 |
+
|
| 299 |
+
await db.query(
|
| 300 |
+
`INSERT INTO device_payments (client_id, device_id, amount, reference, period_start, period_end, status)
|
| 301 |
+
VALUES (?, ?, ?, ?, ?, ?, 'pending')`,
|
| 302 |
+
[req.client.id, device.id, device.monthly_fee, reference, start, end]
|
| 303 |
+
);
|
| 304 |
+
|
| 305 |
+
// Initiate Snippe payment to tenant's phone
|
| 306 |
+
const webhookBase = `${process.env.PORTAL_SCHEME || 'http'}://${process.env.PORTAL_HOST}`;
|
| 307 |
+
try {
|
| 308 |
+
await snippe.createPayment({
|
| 309 |
+
reference,
|
| 310 |
+
amount: device.monthly_fee,
|
| 311 |
+
phone: device.client_phone,
|
| 312 |
+
webhookUrl: `${webhookBase}/api/webhooks/snippe`,
|
| 313 |
+
metadata: { type: 'device_renewal', device_id: String(device.id) },
|
| 314 |
+
});
|
| 315 |
+
} catch (payErr) {
|
| 316 |
+
console.error('[devices/renew] Snippe error:', payErr.message);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
res.json({ reference, amount: device.monthly_fee, message: 'Check your phone for M-Pesa prompt' });
|
| 320 |
+
} catch (err) {
|
| 321 |
+
console.error('[devices/renew]', err.message);
|
| 322 |
+
res.status(500).json({ error: 'Failed to initiate renewal' });
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
module.exports = router;
|
src/routes/payouts.routes.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const snippe = require('../services/snippe');
|
| 4 |
+
const { requireAuth } = require('../middleware/auth');
|
| 5 |
+
|
| 6 |
+
router.use(requireAuth);
|
| 7 |
+
|
| 8 |
+
// GET /api/payouts
|
| 9 |
+
router.get('/', async (req, res) => {
|
| 10 |
+
try {
|
| 11 |
+
const payouts = await db.query(
|
| 12 |
+
`SELECT id, amount, payout_fee, net_amount, destination_phone,
|
| 13 |
+
destination_provider, status, failure_reason, created_at, completed_at
|
| 14 |
+
FROM payouts WHERE client_id = ?
|
| 15 |
+
ORDER BY created_at DESC LIMIT 50`,
|
| 16 |
+
[req.client.id]
|
| 17 |
+
);
|
| 18 |
+
res.json(payouts);
|
| 19 |
+
} catch (err) {
|
| 20 |
+
res.status(500).json({ error: 'Failed to fetch payouts' });
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// POST /api/payouts
|
| 25 |
+
router.post('/', async (req, res) => {
|
| 26 |
+
const { amount, phone, provider = 'mpesa' } = req.body;
|
| 27 |
+
|
| 28 |
+
if (!amount || !phone) {
|
| 29 |
+
return res.status(400).json({ error: 'amount and phone required' });
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const amountNum = parseFloat(amount);
|
| 33 |
+
if (amountNum < 5000) {
|
| 34 |
+
return res.status(400).json({ error: 'Minimum payout is 5,000 TZS' });
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
// Check balance
|
| 39 |
+
const client = await db.query(
|
| 40 |
+
'SELECT balance, contact_name FROM clients WHERE id = ? FOR UPDATE',
|
| 41 |
+
[req.client.id]
|
| 42 |
+
).then(r => r[0]);
|
| 43 |
+
|
| 44 |
+
if (parseFloat(client.balance) < amountNum) {
|
| 45 |
+
return res.status(400).json({
|
| 46 |
+
error: 'Insufficient balance',
|
| 47 |
+
balance: parseFloat(client.balance),
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Calculate fee
|
| 52 |
+
let feeAmount = 1500;
|
| 53 |
+
try {
|
| 54 |
+
const feeData = await snippe.getPayoutFee(amountNum);
|
| 55 |
+
feeAmount = feeData?.fee_amount || 1500;
|
| 56 |
+
} catch { /* use default */ }
|
| 57 |
+
|
| 58 |
+
const netAmount = amountNum - feeAmount;
|
| 59 |
+
|
| 60 |
+
// Insert payout (trg_payout_requested fires — deducts balance)
|
| 61 |
+
const result = await db.query(
|
| 62 |
+
`INSERT INTO payouts
|
| 63 |
+
(client_id, amount, payout_fee, net_amount, destination_phone, destination_provider, status)
|
| 64 |
+
VALUES (?, ?, ?, ?, ?, ?, 'pending')`,
|
| 65 |
+
[req.client.id, amountNum, feeAmount, netAmount, phone, provider]
|
| 66 |
+
);
|
| 67 |
+
|
| 68 |
+
const payoutId = result.insertId;
|
| 69 |
+
const webhookBase = `${process.env.PORTAL_SCHEME || 'http'}://${process.env.PORTAL_HOST}`;
|
| 70 |
+
|
| 71 |
+
// Call Snippe disbursement
|
| 72 |
+
try {
|
| 73 |
+
const disbResult = await snippe.createPayout({
|
| 74 |
+
payoutId,
|
| 75 |
+
amount: amountNum,
|
| 76 |
+
phone,
|
| 77 |
+
recipientName: client.contact_name,
|
| 78 |
+
narration: 'WiFi platform payout',
|
| 79 |
+
webhookUrl: `${webhookBase}/api/webhooks/snippe`,
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// Save disbursement reference
|
| 83 |
+
const disbRef = disbResult?.data?.reference || disbResult?.reference || null;
|
| 84 |
+
if (disbRef) {
|
| 85 |
+
await db.query(
|
| 86 |
+
`UPDATE payouts SET status = 'processing', snippe_disbursement_id = ? WHERE id = ?`,
|
| 87 |
+
[disbRef, payoutId]
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
} catch (disbErr) {
|
| 91 |
+
console.error('[payouts/POST] Snippe disbursement error:', disbErr.message);
|
| 92 |
+
// Mark failed — trigger restores balance
|
| 93 |
+
await db.query(
|
| 94 |
+
`UPDATE payouts SET status = 'failed', failure_reason = ? WHERE id = ?`,
|
| 95 |
+
[disbErr.message.slice(0, 255), payoutId]
|
| 96 |
+
);
|
| 97 |
+
return res.status(502).json({ error: 'Payout failed. Balance restored. Try again.' });
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
res.status(201).json({
|
| 101 |
+
id: payoutId,
|
| 102 |
+
amount: amountNum,
|
| 103 |
+
payout_fee: feeAmount,
|
| 104 |
+
net_amount: netAmount,
|
| 105 |
+
status: 'processing',
|
| 106 |
+
message: 'Payout initiated. Funds will arrive shortly.',
|
| 107 |
+
});
|
| 108 |
+
} catch (err) {
|
| 109 |
+
console.error('[payouts/POST]', err.message);
|
| 110 |
+
res.status(500).json({ error: 'Payout failed' });
|
| 111 |
+
}
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
module.exports = router;
|
src/routes/plans.routes.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const { requireAuth } = require('../middleware/auth');
|
| 4 |
+
|
| 5 |
+
router.use(requireAuth);
|
| 6 |
+
|
| 7 |
+
// GET /api/plans?deviceId=
|
| 8 |
+
router.get('/', async (req, res) => {
|
| 9 |
+
const { deviceId } = req.query;
|
| 10 |
+
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
// Verify device belongs to client
|
| 14 |
+
const device = await db.query(
|
| 15 |
+
'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
|
| 16 |
+
[deviceId, req.client.id]
|
| 17 |
+
).then(r => r[0]);
|
| 18 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 19 |
+
|
| 20 |
+
const plans = await db.query(
|
| 21 |
+
`SELECT * FROM wifi_plans WHERE device_id = ? ORDER BY display_order ASC`,
|
| 22 |
+
[deviceId]
|
| 23 |
+
);
|
| 24 |
+
res.json(plans);
|
| 25 |
+
} catch (err) {
|
| 26 |
+
res.status(500).json({ error: 'Failed to fetch plans' });
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// POST /api/plans
|
| 31 |
+
router.post('/', async (req, res) => {
|
| 32 |
+
const { device_id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order } = req.body;
|
| 33 |
+
|
| 34 |
+
if (!device_id || !name || !duration_seconds || !price) {
|
| 35 |
+
return res.status(400).json({ error: 'device_id, name, duration_seconds, price required' });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
const device = await db.query(
|
| 40 |
+
'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
|
| 41 |
+
[device_id, req.client.id]
|
| 42 |
+
).then(r => r[0]);
|
| 43 |
+
if (!device) return res.status(404).json({ error: 'Device not found' });
|
| 44 |
+
|
| 45 |
+
const result = await db.query(
|
| 46 |
+
`INSERT INTO wifi_plans
|
| 47 |
+
(client_id, device_id, name, duration_seconds, price,
|
| 48 |
+
down_limit, down_unit, up_limit, up_unit, display_order)
|
| 49 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
| 50 |
+
[
|
| 51 |
+
req.client.id, device_id, name,
|
| 52 |
+
parseInt(duration_seconds), parseFloat(price),
|
| 53 |
+
down_limit || 2, down_unit || 2,
|
| 54 |
+
up_limit || 2, up_unit || 2,
|
| 55 |
+
display_order || 0,
|
| 56 |
+
]
|
| 57 |
+
);
|
| 58 |
+
|
| 59 |
+
const plan = await db.query('SELECT * FROM wifi_plans WHERE id = ?', [result.insertId]).then(r => r[0]);
|
| 60 |
+
res.status(201).json(plan);
|
| 61 |
+
} catch (err) {
|
| 62 |
+
res.status(500).json({ error: 'Failed to create plan' });
|
| 63 |
+
}
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
// PUT /api/plans/:id
|
| 67 |
+
router.put('/:id', async (req, res) => {
|
| 68 |
+
const { name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order, is_active } = req.body;
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
const plan = await db.query(
|
| 72 |
+
'SELECT p.* FROM wifi_plans p JOIN devices d ON d.id = p.device_id WHERE p.id = ? AND d.client_id = ? LIMIT 1',
|
| 73 |
+
[req.params.id, req.client.id]
|
| 74 |
+
).then(r => r[0]);
|
| 75 |
+
if (!plan) return res.status(404).json({ error: 'Plan not found' });
|
| 76 |
+
|
| 77 |
+
await db.query(
|
| 78 |
+
`UPDATE wifi_plans SET
|
| 79 |
+
name = COALESCE(?, name),
|
| 80 |
+
duration_seconds = COALESCE(?, duration_seconds),
|
| 81 |
+
price = COALESCE(?, price),
|
| 82 |
+
down_limit = COALESCE(?, down_limit),
|
| 83 |
+
down_unit = COALESCE(?, down_unit),
|
| 84 |
+
up_limit = COALESCE(?, up_limit),
|
| 85 |
+
up_unit = COALESCE(?, up_unit),
|
| 86 |
+
display_order = COALESCE(?, display_order),
|
| 87 |
+
is_active = COALESCE(?, is_active)
|
| 88 |
+
WHERE id = ?`,
|
| 89 |
+
[
|
| 90 |
+
name || null, duration_seconds || null, price || null,
|
| 91 |
+
down_limit || null, down_unit || null,
|
| 92 |
+
up_limit || null, up_unit || null,
|
| 93 |
+
display_order !== undefined ? display_order : null,
|
| 94 |
+
is_active !== undefined ? is_active : null,
|
| 95 |
+
req.params.id,
|
| 96 |
+
]
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
const updated = await db.query('SELECT * FROM wifi_plans WHERE id = ?', [req.params.id]).then(r => r[0]);
|
| 100 |
+
res.json(updated);
|
| 101 |
+
} catch (err) {
|
| 102 |
+
res.status(500).json({ error: 'Failed to update plan' });
|
| 103 |
+
}
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
// DELETE /api/plans/:id (soft delete — deactivate)
|
| 107 |
+
router.delete('/:id', async (req, res) => {
|
| 108 |
+
try {
|
| 109 |
+
const plan = await db.query(
|
| 110 |
+
'SELECT p.id FROM wifi_plans p JOIN devices d ON d.id = p.device_id WHERE p.id = ? AND d.client_id = ? LIMIT 1',
|
| 111 |
+
[req.params.id, req.client.id]
|
| 112 |
+
).then(r => r[0]);
|
| 113 |
+
if (!plan) return res.status(404).json({ error: 'Plan not found' });
|
| 114 |
+
|
| 115 |
+
await db.query('UPDATE wifi_plans SET is_active = 0 WHERE id = ?', [req.params.id]);
|
| 116 |
+
res.json({ message: 'Plan deactivated' });
|
| 117 |
+
} catch (err) {
|
| 118 |
+
res.status(500).json({ error: 'Failed to delete plan' });
|
| 119 |
+
}
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
module.exports = router;
|
src/routes/portal.routes.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const fs = require('fs');
|
| 4 |
+
const db = require('../config/db');
|
| 5 |
+
const omada = require('../services/omada');
|
| 6 |
+
const snippe = require('../services/snippe');
|
| 7 |
+
const sms = require('../services/sms');
|
| 8 |
+
const { generateCode } = require('../utils/generateCode');
|
| 9 |
+
const { v4: uuidv4 } = require('uuid');
|
| 10 |
+
const { purchaseLimiter, portalAuthLimiter } = require('../middleware/rateLimiter');
|
| 11 |
+
|
| 12 |
+
// ── GET /portal/:siteId — render captive portal page ────────────────────────
|
| 13 |
+
router.get('/:siteId', async (req, res) => {
|
| 14 |
+
const { siteId } = req.params;
|
| 15 |
+
const { clientMac = '', apMac = '', ssidName = '' } = req.query;
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const device = await db.query(
|
| 19 |
+
`SELECT d.*, c.business_name, c.commission_rate
|
| 20 |
+
FROM devices d JOIN clients c ON c.id = d.client_id
|
| 21 |
+
WHERE d.omada_site_id = ? LIMIT 1`,
|
| 22 |
+
[siteId]
|
| 23 |
+
).then(r => r[0]);
|
| 24 |
+
|
| 25 |
+
if (!device) {
|
| 26 |
+
return res.status(404).send('<h2>Portal not found</h2>');
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Suspended device
|
| 30 |
+
if (!['trial', 'active'].includes(device.billing_status)) {
|
| 31 |
+
return res.render('suspended', { businessName: device.business_name });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Auto-reconnect: if this MAC has an active, non-expired token, re-authorize silently
|
| 35 |
+
if (clientMac) {
|
| 36 |
+
const mac = clientMac.toUpperCase().replace(/:/g, '-');
|
| 37 |
+
const activeToken = await db.query(
|
| 38 |
+
`SELECT * FROM access_tokens
|
| 39 |
+
WHERE device_id = ? AND locked_mac = ? AND status = 'active'
|
| 40 |
+
AND expires_at > NOW()
|
| 41 |
+
ORDER BY expires_at DESC LIMIT 1`,
|
| 42 |
+
[device.id, mac]
|
| 43 |
+
).then(r => r[0]);
|
| 44 |
+
|
| 45 |
+
if (activeToken) {
|
| 46 |
+
try {
|
| 47 |
+
await omada.authorizeClient(siteId, mac);
|
| 48 |
+
await omada.applyRateLimit(siteId, mac, {
|
| 49 |
+
downLimit: activeToken.down_limit,
|
| 50 |
+
downUnit: activeToken.down_unit,
|
| 51 |
+
upLimit: activeToken.up_limit,
|
| 52 |
+
upUnit: activeToken.up_unit,
|
| 53 |
+
name: `Guest-${activeToken.code}`,
|
| 54 |
+
});
|
| 55 |
+
} catch (omadaErr) {
|
| 56 |
+
console.error('[portal/GET] Auto-reconnect Omada error:', omadaErr.message);
|
| 57 |
+
// Fall through to normal portal render if Omada fails
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const secsLeft = Math.floor((new Date(activeToken.expires_at) - Date.now()) / 1000);
|
| 61 |
+
const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
|
| 62 |
+
return res.render('reconnected', {
|
| 63 |
+
businessName: device.business_name,
|
| 64 |
+
code: activeToken.code,
|
| 65 |
+
expiresIn: secsLeft,
|
| 66 |
+
speedDown: unitLabel(activeToken.down_limit, activeToken.down_unit),
|
| 67 |
+
speedUp: unitLabel(activeToken.up_limit, activeToken.up_unit),
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Get active plans
|
| 73 |
+
const plans = await db.query(
|
| 74 |
+
`SELECT id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit
|
| 75 |
+
FROM wifi_plans
|
| 76 |
+
WHERE device_id = ? AND is_active = 1
|
| 77 |
+
ORDER BY display_order ASC`,
|
| 78 |
+
[device.id]
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
// Portal customization settings (use defaults if not set)
|
| 82 |
+
const portalSettings = await db.queryOne(
|
| 83 |
+
'SELECT * FROM device_portal_settings WHERE device_id = ?',
|
| 84 |
+
[device.id]
|
| 85 |
+
);
|
| 86 |
+
const customize = {
|
| 87 |
+
displayName: portalSettings?.display_name || device.business_name,
|
| 88 |
+
welcomeText: portalSettings?.welcome_text || 'Get online instantly',
|
| 89 |
+
logoUrl: portalSettings?.logo_url || null,
|
| 90 |
+
supportPhone: portalSettings?.support_phone || null,
|
| 91 |
+
primaryColor: portalSettings?.primary_color || '#4361ee',
|
| 92 |
+
bgColor: portalSettings?.bg_color || '#1a1a2e',
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const portalData = {
|
| 96 |
+
siteId,
|
| 97 |
+
clientMac,
|
| 98 |
+
apMac,
|
| 99 |
+
ssidName,
|
| 100 |
+
plans,
|
| 101 |
+
businessName: device.business_name,
|
| 102 |
+
deviceName: device.name,
|
| 103 |
+
customize,
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// Custom portal HTML?
|
| 107 |
+
if (device.portal_html_path) {
|
| 108 |
+
const htmlPath = path.resolve('./portals', device.portal_html_path);
|
| 109 |
+
if (fs.existsSync(htmlPath)) {
|
| 110 |
+
let html = fs.readFileSync(htmlPath, 'utf8');
|
| 111 |
+
const inject =
|
| 112 |
+
`<script>window.PORTAL=${JSON.stringify(portalData)};</script>` +
|
| 113 |
+
`<script src="/engine.js"></script>`;
|
| 114 |
+
html = html.replace('</body>', inject + '</body>');
|
| 115 |
+
return res.send(html);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Default EJS template
|
| 120 |
+
return res.render('portal', { portal: portalData });
|
| 121 |
+
} catch (err) {
|
| 122 |
+
console.error('[portal/GET]', err.message);
|
| 123 |
+
res.status(500).send('<h2>Service error. Please try again.</h2>');
|
| 124 |
+
}
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
// ── POST /portal/:siteId/purchase — initiate payment ────────────────────────
|
| 128 |
+
router.post('/:siteId/purchase', purchaseLimiter, async (req, res) => {
|
| 129 |
+
const { siteId } = req.params;
|
| 130 |
+
const { planId, phone } = req.body;
|
| 131 |
+
|
| 132 |
+
if (!planId || !phone) {
|
| 133 |
+
return res.status(400).json({ error: 'planId and phone required' });
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
try {
|
| 137 |
+
const device = await db.query(
|
| 138 |
+
`SELECT d.*, c.commission_rate
|
| 139 |
+
FROM devices d JOIN clients c ON c.id = d.client_id
|
| 140 |
+
WHERE d.omada_site_id = ? AND d.status = 'online' LIMIT 1`,
|
| 141 |
+
[siteId]
|
| 142 |
+
).then(r => r[0]);
|
| 143 |
+
|
| 144 |
+
if (!device) return res.status(404).json({ error: 'Device not found or offline' });
|
| 145 |
+
if (!['trial', 'active'].includes(device.billing_status)) {
|
| 146 |
+
return res.status(403).json({ error: 'WiFi service unavailable — billing suspended' });
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const plan = await db.query(
|
| 150 |
+
'SELECT * FROM wifi_plans WHERE id = ? AND device_id = ? AND is_active = 1 LIMIT 1',
|
| 151 |
+
[planId, device.id]
|
| 152 |
+
).then(r => r[0]);
|
| 153 |
+
|
| 154 |
+
if (!plan) return res.status(404).json({ error: 'Plan not found' });
|
| 155 |
+
|
| 156 |
+
const reference = uuidv4().replace(/-/g, '').slice(0, 24);
|
| 157 |
+
|
| 158 |
+
await db.query(
|
| 159 |
+
`INSERT INTO payments
|
| 160 |
+
(reference, client_id, device_id, plan_id, phone, phone_provider,
|
| 161 |
+
gross_amount, commission_rate, status)
|
| 162 |
+
VALUES (?, ?, ?, ?, ?, 'mpesa', ?, ?, 'pending')`,
|
| 163 |
+
[reference, device.client_id, device.id, plan.id, phone, plan.price, device.commission_rate]
|
| 164 |
+
);
|
| 165 |
+
|
| 166 |
+
const webhookBase = `${process.env.PORTAL_SCHEME || 'http'}://${process.env.PORTAL_HOST}`;
|
| 167 |
+
try {
|
| 168 |
+
await snippe.createPayment({
|
| 169 |
+
reference,
|
| 170 |
+
amount: plan.price,
|
| 171 |
+
phone,
|
| 172 |
+
webhookUrl: `${webhookBase}/api/webhooks/snippe`,
|
| 173 |
+
metadata: {
|
| 174 |
+
payment_type: 'wifi_purchase',
|
| 175 |
+
device_id: String(device.id),
|
| 176 |
+
plan_id: String(plan.id),
|
| 177 |
+
},
|
| 178 |
+
});
|
| 179 |
+
} catch (payErr) {
|
| 180 |
+
console.error('[portal/purchase] Snippe error:', payErr.message);
|
| 181 |
+
// Don't fail — webhook will confirm; guest can poll
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
res.json({ reference, status: 'pending' });
|
| 185 |
+
} catch (err) {
|
| 186 |
+
console.error('[portal/purchase]', err.message);
|
| 187 |
+
res.status(500).json({ error: 'Purchase failed. Please try again.' });
|
| 188 |
+
}
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
// ── GET /portal/:siteId/check/:reference — poll payment status ───────────────
|
| 192 |
+
router.get('/:siteId/check/:reference', async (req, res) => {
|
| 193 |
+
try {
|
| 194 |
+
const payment = await db.query(
|
| 195 |
+
`SELECT p.status, p.access_token_code, p.gross_amount,
|
| 196 |
+
wp.name AS plan_name, wp.duration_seconds,
|
| 197 |
+
wp.down_limit, wp.down_unit, wp.up_limit, wp.up_unit
|
| 198 |
+
FROM payments p
|
| 199 |
+
JOIN wifi_plans wp ON wp.id = p.plan_id
|
| 200 |
+
WHERE p.reference = ? LIMIT 1`,
|
| 201 |
+
[req.params.reference]
|
| 202 |
+
).then(r => r[0]);
|
| 203 |
+
|
| 204 |
+
if (!payment) return res.status(404).json({ error: 'Payment not found' });
|
| 205 |
+
|
| 206 |
+
if (payment.status === 'completed' && payment.access_token_code) {
|
| 207 |
+
return res.json({
|
| 208 |
+
status: 'paid',
|
| 209 |
+
code: payment.access_token_code,
|
| 210 |
+
plan_name: payment.plan_name,
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
if (payment.status === 'failed') {
|
| 215 |
+
return res.json({ status: 'failed' });
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
res.json({ status: 'pending' });
|
| 219 |
+
} catch (err) {
|
| 220 |
+
res.status(500).json({ error: 'Check failed' });
|
| 221 |
+
}
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
// ── POST /portal/:siteId/auth — authorize guest on WiFi ──────────────────────
|
| 225 |
+
router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
|
| 226 |
+
const { siteId } = req.params;
|
| 227 |
+
const { code, clientMac } = req.body;
|
| 228 |
+
|
| 229 |
+
if (!code || !clientMac) {
|
| 230 |
+
return res.status(400).json({ error: 'code and clientMac required' });
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const mac = clientMac.toUpperCase().replace(/:/g, '-');
|
| 234 |
+
|
| 235 |
+
try {
|
| 236 |
+
const token = await db.query(
|
| 237 |
+
`SELECT t.*, d.omada_site_id
|
| 238 |
+
FROM access_tokens t
|
| 239 |
+
JOIN devices d ON d.id = t.device_id
|
| 240 |
+
WHERE t.code = ? AND d.omada_site_id = ? LIMIT 1`,
|
| 241 |
+
[code.toUpperCase(), siteId]
|
| 242 |
+
).then(r => r[0]);
|
| 243 |
+
|
| 244 |
+
if (!token) return res.status(404).json({ error: 'Invalid code' });
|
| 245 |
+
|
| 246 |
+
if (token.status === 'expired' || token.status === 'revoked') {
|
| 247 |
+
return res.status(410).json({ error: 'Code has expired' });
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (token.status === 'active') {
|
| 251 |
+
// Reconnect — verify same MAC
|
| 252 |
+
if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
|
| 253 |
+
return res.status(403).json({ error: 'Code is locked to another device' });
|
| 254 |
+
}
|
| 255 |
+
// Check not expired
|
| 256 |
+
if (token.expires_at && new Date(token.expires_at) < new Date()) {
|
| 257 |
+
await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
|
| 258 |
+
return res.status(410).json({ error: 'Code has expired' });
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
if (token.status !== 'unused' && token.status !== 'active') {
|
| 263 |
+
return res.status(400).json({ error: 'Code cannot be used' });
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Authorize on Omada
|
| 267 |
+
try {
|
| 268 |
+
await omada.authorizeClient(siteId, mac);
|
| 269 |
+
await omada.applyRateLimit(siteId, mac, {
|
| 270 |
+
downLimit: token.down_limit,
|
| 271 |
+
downUnit: token.down_unit,
|
| 272 |
+
upLimit: token.up_limit,
|
| 273 |
+
upUnit: token.up_unit,
|
| 274 |
+
name: `Guest-${token.code}`,
|
| 275 |
+
});
|
| 276 |
+
} catch (omadaErr) {
|
| 277 |
+
console.error('[portal/auth] Omada auth failed:', omadaErr.message);
|
| 278 |
+
return res.status(503).json({ error: 'Authorization failed. The AP may be offline.' });
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Activate token
|
| 282 |
+
const now = new Date();
|
| 283 |
+
const expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
|
| 284 |
+
|
| 285 |
+
await db.query(
|
| 286 |
+
`UPDATE access_tokens SET
|
| 287 |
+
status = 'active',
|
| 288 |
+
locked_mac = ?,
|
| 289 |
+
activated_at = ?,
|
| 290 |
+
expires_at = ?
|
| 291 |
+
WHERE id = ?`,
|
| 292 |
+
[mac, now, expiresAt, token.id]
|
| 293 |
+
);
|
| 294 |
+
|
| 295 |
+
// Upsert session
|
| 296 |
+
const existingSession = await db.query(
|
| 297 |
+
'SELECT id FROM sessions WHERE access_token_id = ? AND client_mac = ? LIMIT 1',
|
| 298 |
+
[token.id, mac]
|
| 299 |
+
).then(r => r[0]);
|
| 300 |
+
|
| 301 |
+
if (!existingSession) {
|
| 302 |
+
await db.query(
|
| 303 |
+
`INSERT INTO sessions (access_token_id, client_id, device_id, client_mac)
|
| 304 |
+
VALUES (?, ?, ?, ?)`,
|
| 305 |
+
[token.id, token.client_id, token.device_id, mac]
|
| 306 |
+
);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
|
| 310 |
+
const secsLeft = Math.floor((expiresAt - now) / 1000);
|
| 311 |
+
|
| 312 |
+
res.json({
|
| 313 |
+
success: true,
|
| 314 |
+
code: token.code,
|
| 315 |
+
expiresIn: secsLeft,
|
| 316 |
+
expiresAt: expiresAt.toISOString(),
|
| 317 |
+
speedDown: unitLabel(token.down_limit, token.down_unit),
|
| 318 |
+
speedUp: unitLabel(token.up_limit, token.up_unit),
|
| 319 |
+
});
|
| 320 |
+
} catch (err) {
|
| 321 |
+
console.error('[portal/auth]', err.message);
|
| 322 |
+
res.status(500).json({ error: 'Authorization failed' });
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
module.exports = router;
|
src/routes/sessions.routes.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const { requireAuth } = require('../middleware/auth');
|
| 4 |
+
|
| 5 |
+
router.use(requireAuth);
|
| 6 |
+
|
| 7 |
+
// GET /api/sessions/active
|
| 8 |
+
router.get('/active', async (req, res) => {
|
| 9 |
+
try {
|
| 10 |
+
const sessions = await db.query(
|
| 11 |
+
`SELECT s.id, s.client_mac, s.client_ip, s.ssid, s.started_at,
|
| 12 |
+
s.last_seen_at, s.bytes_down, s.bytes_up, s.duration_seconds,
|
| 13 |
+
s.rssi, s.signal_level,
|
| 14 |
+
d.name AS device_name,
|
| 15 |
+
t.code AS token_code, t.expires_at,
|
| 16 |
+
wp.name AS plan_name
|
| 17 |
+
FROM sessions s
|
| 18 |
+
JOIN devices d ON d.id = s.device_id
|
| 19 |
+
LEFT JOIN access_tokens t ON t.id = s.access_token_id
|
| 20 |
+
LEFT JOIN wifi_plans wp ON wp.id = t.plan_id
|
| 21 |
+
WHERE s.client_id = ? AND s.is_active = 1
|
| 22 |
+
ORDER BY s.started_at DESC`,
|
| 23 |
+
[req.client.id]
|
| 24 |
+
);
|
| 25 |
+
res.json(sessions);
|
| 26 |
+
} catch (err) {
|
| 27 |
+
res.status(500).json({ error: 'Failed to fetch sessions' });
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
// GET /api/sessions?deviceId=&limit=
|
| 32 |
+
router.get('/', async (req, res) => {
|
| 33 |
+
const { deviceId, limit = 50 } = req.query;
|
| 34 |
+
try {
|
| 35 |
+
let sql = `
|
| 36 |
+
SELECT s.id, s.client_mac, s.started_at, s.ended_at,
|
| 37 |
+
s.bytes_down, s.bytes_up, s.duration_seconds, s.is_active,
|
| 38 |
+
d.name AS device_name,
|
| 39 |
+
t.code AS token_code, wp.name AS plan_name, wp.price AS plan_price
|
| 40 |
+
FROM sessions s
|
| 41 |
+
JOIN devices d ON d.id = s.device_id
|
| 42 |
+
LEFT JOIN access_tokens t ON t.id = s.access_token_id
|
| 43 |
+
LEFT JOIN wifi_plans wp ON wp.id = t.plan_id
|
| 44 |
+
WHERE s.client_id = ?`;
|
| 45 |
+
const params = [req.client.id];
|
| 46 |
+
|
| 47 |
+
if (deviceId) {
|
| 48 |
+
sql += ' AND s.device_id = ?';
|
| 49 |
+
params.push(deviceId);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
sql += ` ORDER BY s.started_at DESC LIMIT ?`;
|
| 53 |
+
params.push(parseInt(limit) || 50);
|
| 54 |
+
|
| 55 |
+
const sessions = await db.query(sql, params);
|
| 56 |
+
res.json(sessions);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
res.status(500).json({ error: 'Failed to fetch sessions' });
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
module.exports = router;
|
src/routes/webhooks.routes.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const router = require('express').Router();
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
const snippe = require('../services/snippe');
|
| 4 |
+
const sms = require('../services/sms');
|
| 5 |
+
const messages = require('../utils/messages');
|
| 6 |
+
const { generateCode } = require('../utils/generateCode');
|
| 7 |
+
|
| 8 |
+
// Raw body needed for signature verification
|
| 9 |
+
router.use(require('express').raw({ type: 'application/json' }));
|
| 10 |
+
|
| 11 |
+
// POST /api/webhooks/snippe
|
| 12 |
+
router.post('/snippe', async (req, res) => {
|
| 13 |
+
let event;
|
| 14 |
+
try {
|
| 15 |
+
event = snippe.verifyWebhookSignature(req.body.toString(), req.headers);
|
| 16 |
+
} catch (err) {
|
| 17 |
+
console.error('[webhook/snippe] Signature invalid:', err.message);
|
| 18 |
+
return res.status(400).json({ error: 'Invalid signature' });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Respond immediately
|
| 22 |
+
res.status(200).json({ received: true });
|
| 23 |
+
|
| 24 |
+
// Process async
|
| 25 |
+
processSnippeEvent(event).catch(err =>
|
| 26 |
+
console.error('[webhook/snippe] Processing error:', err.message)
|
| 27 |
+
);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
async function processSnippeEvent(event) {
|
| 31 |
+
const { type, data } = event;
|
| 32 |
+
|
| 33 |
+
switch (type) {
|
| 34 |
+
case 'payment.completed':
|
| 35 |
+
await handlePaymentCompleted(data);
|
| 36 |
+
break;
|
| 37 |
+
case 'payment.failed':
|
| 38 |
+
await handlePaymentFailed(data);
|
| 39 |
+
break;
|
| 40 |
+
case 'payout.completed':
|
| 41 |
+
await handlePayoutCompleted(data);
|
| 42 |
+
break;
|
| 43 |
+
case 'payout.failed':
|
| 44 |
+
await handlePayoutFailed(data);
|
| 45 |
+
break;
|
| 46 |
+
default:
|
| 47 |
+
console.log(`[webhook/snippe] Unhandled event: ${type}`);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function handlePaymentCompleted(data) {
|
| 52 |
+
const reference = data.reference || data.external_reference;
|
| 53 |
+
if (!reference) return;
|
| 54 |
+
|
| 55 |
+
// ── WiFi guest purchase ────────────────────────────────────────────────────
|
| 56 |
+
// Atomically claim the payment — only one concurrent webhook can advance it
|
| 57 |
+
const claim = await db.query(
|
| 58 |
+
`UPDATE payments SET status = 'processing' WHERE reference = ? AND status = 'pending'`,
|
| 59 |
+
[reference]
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
if (claim.affectedRows === 1) {
|
| 63 |
+
const payment = await db.query(
|
| 64 |
+
`SELECT p.*, wp.duration_seconds,
|
| 65 |
+
wp.down_limit, wp.down_unit, wp.up_limit, wp.up_unit, wp.name AS plan_name,
|
| 66 |
+
d.name AS device_name, c.phone AS client_phone
|
| 67 |
+
FROM payments p
|
| 68 |
+
JOIN wifi_plans wp ON wp.id = p.plan_id
|
| 69 |
+
JOIN devices d ON d.id = p.device_id
|
| 70 |
+
JOIN clients c ON c.id = p.client_id
|
| 71 |
+
WHERE p.reference = ? LIMIT 1`,
|
| 72 |
+
[reference]
|
| 73 |
+
).then(r => r[0]);
|
| 74 |
+
|
| 75 |
+
const code = generateCode();
|
| 76 |
+
|
| 77 |
+
// Create access token
|
| 78 |
+
const tokenResult = await db.query(
|
| 79 |
+
`INSERT INTO access_tokens
|
| 80 |
+
(client_id, device_id, plan_id, payment_id, code, duration_seconds,
|
| 81 |
+
down_limit, down_unit, up_limit, up_unit, status)
|
| 82 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
|
| 83 |
+
[
|
| 84 |
+
payment.client_id, payment.device_id, payment.plan_id, payment.id,
|
| 85 |
+
code, payment.duration_seconds,
|
| 86 |
+
payment.down_limit, payment.down_unit, payment.up_limit, payment.up_unit,
|
| 87 |
+
]
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
// Mark payment completed (trigger fires here — credits client balance)
|
| 91 |
+
await db.query(
|
| 92 |
+
`UPDATE payments SET
|
| 93 |
+
status = 'completed',
|
| 94 |
+
access_token_code = ?,
|
| 95 |
+
snippe_payment_id = ?
|
| 96 |
+
WHERE id = ?`,
|
| 97 |
+
[code, data.external_reference || null, payment.id]
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
// SMS to guest with code
|
| 101 |
+
const secs = payment.duration_seconds;
|
| 102 |
+
const duration = secs >= 86400
|
| 103 |
+
? `${Math.round(secs / 86400)}d`
|
| 104 |
+
: secs >= 3600
|
| 105 |
+
? `${Math.round(secs / 3600)}h`
|
| 106 |
+
: `${Math.round(secs / 60)}min`;
|
| 107 |
+
sms.sendSMS(payment.phone, messages.wifiCode(code, payment.plan_name, duration, payment.device_name)).catch(() => {});
|
| 108 |
+
|
| 109 |
+
console.log(`[webhook] WiFi payment completed: ${reference} → code ${code}`);
|
| 110 |
+
return;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// ── Device subscription renewal ───────────────────────────────────────────
|
| 114 |
+
const devPayment = await db.query(
|
| 115 |
+
`SELECT dp.*, d.billing_expires_at, d.name AS device_name, c.phone AS client_phone
|
| 116 |
+
FROM device_payments dp
|
| 117 |
+
JOIN devices d ON d.id = dp.device_id
|
| 118 |
+
JOIN clients c ON c.id = dp.client_id
|
| 119 |
+
WHERE dp.reference = ? AND dp.status = 'pending' LIMIT 1`,
|
| 120 |
+
[reference]
|
| 121 |
+
).then(r => r[0]);
|
| 122 |
+
|
| 123 |
+
if (devPayment) {
|
| 124 |
+
// Extend billing by 30 days from current expiry (or now if suspended)
|
| 125 |
+
const base = devPayment.billing_expires_at
|
| 126 |
+
? new Date(devPayment.billing_expires_at)
|
| 127 |
+
: new Date();
|
| 128 |
+
const newExpiry = new Date(base.getTime() + 30 * 24 * 60 * 60 * 1000);
|
| 129 |
+
|
| 130 |
+
await db.query(
|
| 131 |
+
`UPDATE devices SET billing_status = 'active', billing_expires_at = ? WHERE id = ?`,
|
| 132 |
+
[newExpiry, devPayment.device_id]
|
| 133 |
+
);
|
| 134 |
+
|
| 135 |
+
await db.query(
|
| 136 |
+
`UPDATE device_payments SET status = 'completed', completed_at = NOW(),
|
| 137 |
+
snippe_payment_id = ? WHERE id = ?`,
|
| 138 |
+
[data.external_reference || null, devPayment.id]
|
| 139 |
+
);
|
| 140 |
+
|
| 141 |
+
sms.sendSMS(devPayment.client_phone,
|
| 142 |
+
messages.deviceRenewal(devPayment.device_name, newExpiry.toLocaleDateString('en-TZ'))
|
| 143 |
+
).catch(() => {});
|
| 144 |
+
|
| 145 |
+
console.log(`[webhook] Device renewal completed: ${reference}`);
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function handlePaymentFailed(data) {
|
| 150 |
+
const reference = data.reference;
|
| 151 |
+
if (!reference) return;
|
| 152 |
+
|
| 153 |
+
await db.query(
|
| 154 |
+
`UPDATE payments SET status = 'failed' WHERE reference = ? AND status = 'pending'`,
|
| 155 |
+
[reference]
|
| 156 |
+
);
|
| 157 |
+
await db.query(
|
| 158 |
+
`UPDATE device_payments SET status = 'failed' WHERE reference = ? AND status = 'pending'`,
|
| 159 |
+
[reference]
|
| 160 |
+
);
|
| 161 |
+
|
| 162 |
+
console.log(`[webhook] Payment failed: ${reference}`);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function handlePayoutCompleted(data) {
|
| 166 |
+
const reference = data.reference;
|
| 167 |
+
if (!reference) return;
|
| 168 |
+
|
| 169 |
+
// trg_payout_completed trigger updates total_withdrawn
|
| 170 |
+
await db.query(
|
| 171 |
+
`UPDATE payouts SET status = 'completed', snippe_disbursement_id = ?, completed_at = NOW()
|
| 172 |
+
WHERE snippe_disbursement_id = ? OR id = ?
|
| 173 |
+
AND status IN ('pending','processing')`,
|
| 174 |
+
[reference, reference, 0]
|
| 175 |
+
);
|
| 176 |
+
|
| 177 |
+
// Better: find by disbursement id
|
| 178 |
+
const payout = await db.query(
|
| 179 |
+
`SELECT po.*, c.phone AS client_phone
|
| 180 |
+
FROM payouts po JOIN clients c ON c.id = po.client_id
|
| 181 |
+
WHERE po.snippe_disbursement_id = ? LIMIT 1`,
|
| 182 |
+
[reference]
|
| 183 |
+
).then(r => r[0]);
|
| 184 |
+
|
| 185 |
+
if (payout) {
|
| 186 |
+
await db.query(
|
| 187 |
+
`UPDATE payouts SET status = 'completed', completed_at = NOW() WHERE id = ?`,
|
| 188 |
+
[payout.id]
|
| 189 |
+
);
|
| 190 |
+
sms.sendSMS(payout.client_phone, messages.payoutCompleted(payout.net_amount)).catch(() => {});
|
| 191 |
+
console.log(`[webhook] Payout completed: ${reference}`);
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
async function handlePayoutFailed(data) {
|
| 196 |
+
const reference = data.reference;
|
| 197 |
+
const payout = await db.query(
|
| 198 |
+
`SELECT po.*, c.phone AS client_phone
|
| 199 |
+
FROM payouts po JOIN clients c ON c.id = po.client_id
|
| 200 |
+
WHERE po.snippe_disbursement_id = ? LIMIT 1`,
|
| 201 |
+
[reference]
|
| 202 |
+
).then(r => r[0]);
|
| 203 |
+
|
| 204 |
+
if (payout) {
|
| 205 |
+
// trg_payout_failed trigger restores balance
|
| 206 |
+
await db.query(
|
| 207 |
+
`UPDATE payouts SET status = 'failed', failure_reason = ? WHERE id = ?`,
|
| 208 |
+
[data.failure_reason || 'Provider error', payout.id]
|
| 209 |
+
);
|
| 210 |
+
sms.sendSMS(payout.client_phone, messages.payoutFailed(payout.amount)).catch(() => {});
|
| 211 |
+
console.log(`[webhook] Payout failed: ${reference}`);
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
module.exports = router;
|
src/server.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
require('dotenv').config();
|
| 2 |
+
|
| 3 |
+
const app = require('./app');
|
| 4 |
+
const omada = require('./services/omada');
|
| 5 |
+
const { initJobs } = require('./jobs');
|
| 6 |
+
|
| 7 |
+
const PORT = process.env.PORT || 3000;
|
| 8 |
+
|
| 9 |
+
async function start() {
|
| 10 |
+
// 1. Boot Omada connection
|
| 11 |
+
try {
|
| 12 |
+
await omada.initOmada();
|
| 13 |
+
} catch (err) {
|
| 14 |
+
console.error('[Boot] Omada init failed:', err.message);
|
| 15 |
+
console.warn('[Boot] Continuing without Omada — check OMADA_URL / credentials');
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// 2. Start background jobs
|
| 19 |
+
initJobs();
|
| 20 |
+
|
| 21 |
+
// 3. Listen
|
| 22 |
+
app.listen(PORT, () => {
|
| 23 |
+
console.log(`[Server] Listening on http://0.0.0.0:${PORT}`);
|
| 24 |
+
console.log(`[Server] Portal base: ${process.env.PORTAL_SCHEME}://${process.env.PORTAL_HOST}`);
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
start().catch(err => {
|
| 29 |
+
console.error('[Boot] Fatal error:', err.message);
|
| 30 |
+
process.exit(1);
|
| 31 |
+
});
|
src/services/omada.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const axios = require('axios');
|
| 2 |
+
const https = require('https');
|
| 3 |
+
|
| 4 |
+
// Omada uses self-signed certs on the controller
|
| 5 |
+
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
| 6 |
+
|
| 7 |
+
let controllerId = null;
|
| 8 |
+
let csrfToken = null;
|
| 9 |
+
let sessionCookies = '';
|
| 10 |
+
|
| 11 |
+
const client = axios.create({
|
| 12 |
+
httpsAgent,
|
| 13 |
+
timeout: 20000,
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
// ── Init ──────────────────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
async function initOmada() {
|
| 19 |
+
// 1. Fetch controller ID
|
| 20 |
+
const infoRes = await client.get(`${process.env.OMADA_URL}/api/info`);
|
| 21 |
+
controllerId = infoRes.data.result.omadacId;
|
| 22 |
+
|
| 23 |
+
// 2. Login
|
| 24 |
+
await omadaLogin();
|
| 25 |
+
|
| 26 |
+
// 3. 25-min keepalive
|
| 27 |
+
setInterval(keepAlive, 25 * 60 * 1000);
|
| 28 |
+
|
| 29 |
+
console.log(`[Omada] Ready. controllerId=${controllerId}`);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async function omadaLogin() {
|
| 33 |
+
const res = await client.post(
|
| 34 |
+
`${process.env.OMADA_URL}/${controllerId}/api/v2/login`,
|
| 35 |
+
{ username: process.env.OMADA_USER, password: process.env.OMADA_PASS }
|
| 36 |
+
);
|
| 37 |
+
csrfToken = res.data.result.token;
|
| 38 |
+
const setCookie = res.headers['set-cookie'] || [];
|
| 39 |
+
sessionCookies = setCookie.map(c => c.split(';')[0]).join('; ');
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
async function keepAlive() {
|
| 43 |
+
try {
|
| 44 |
+
await omadaRequest('GET', '/current/users');
|
| 45 |
+
} catch (err) {
|
| 46 |
+
console.error('[Omada] Keepalive failed:', err.message);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// ── Core request helper ───────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
async function omadaRequest(method, path, data = null, retried = false) {
|
| 53 |
+
if (!csrfToken) await omadaLogin();
|
| 54 |
+
|
| 55 |
+
try {
|
| 56 |
+
const res = await client({
|
| 57 |
+
method,
|
| 58 |
+
url: `${process.env.OMADA_URL}/${controllerId}/api/v2${path}`,
|
| 59 |
+
headers: {
|
| 60 |
+
'Csrf-Token': csrfToken,
|
| 61 |
+
Cookie: sessionCookies,
|
| 62 |
+
'Content-Type': 'application/json',
|
| 63 |
+
},
|
| 64 |
+
data: data ?? undefined,
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
// Omada returns errorCode 0 on success; -1000 = session expired
|
| 68 |
+
if (res.data.errorCode === -1000 && !retried) {
|
| 69 |
+
await omadaLogin();
|
| 70 |
+
return omadaRequest(method, path, data, true);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
if (res.data.errorCode !== 0) {
|
| 74 |
+
const err = new Error(res.data.msg || `Omada error ${res.data.errorCode}`);
|
| 75 |
+
err.errorCode = res.data.errorCode;
|
| 76 |
+
err.result = res.data.result;
|
| 77 |
+
throw err;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return res.data;
|
| 81 |
+
} catch (err) {
|
| 82 |
+
if (err.response?.status === 401 && !retried) {
|
| 83 |
+
await omadaLogin();
|
| 84 |
+
return omadaRequest(method, path, data, true);
|
| 85 |
+
}
|
| 86 |
+
throw err;
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// ── Sites ─────────────────────────────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
async function createSite(name, scenario = 'Hotel') {
|
| 93 |
+
const res = await omadaRequest('POST', '/sites', {
|
| 94 |
+
name,
|
| 95 |
+
scenario,
|
| 96 |
+
region: 'Tanzania',
|
| 97 |
+
timeZone: 'Africa/Dar_es_Salaam',
|
| 98 |
+
});
|
| 99 |
+
return res.result; // siteId string
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
async function getSites() {
|
| 103 |
+
const res = await omadaRequest('GET', '/sites?currentPageSize=200¤tPage=1');
|
| 104 |
+
return res.result?.data || [];
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
async function getSiteById(siteId) {
|
| 108 |
+
const sites = await getSites();
|
| 109 |
+
return sites.find(s => s.id === siteId) || null;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// ── Portal ────────────────────────────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
async function createPortal(siteId, portalName, ssidList = []) {
|
| 115 |
+
const serverUrl = `${process.env.PORTAL_HOST}/portal/${siteId}`;
|
| 116 |
+
const res = await omadaRequest('POST', `/sites/${siteId}/setting/portals`, {
|
| 117 |
+
name: portalName,
|
| 118 |
+
enable: true,
|
| 119 |
+
httpsRedirectEnable: false,
|
| 120 |
+
landingPage: 1,
|
| 121 |
+
authType: 4,
|
| 122 |
+
externalPortal: {
|
| 123 |
+
hostType: 2,
|
| 124 |
+
serverUrlScheme: process.env.PORTAL_SCHEME || 'http',
|
| 125 |
+
serverUrl,
|
| 126 |
+
logout: false,
|
| 127 |
+
},
|
| 128 |
+
ssidList,
|
| 129 |
+
networkList: [],
|
| 130 |
+
pageType: 1,
|
| 131 |
+
portalCustomize: {
|
| 132 |
+
defaultLanguage: 1,
|
| 133 |
+
background: 2,
|
| 134 |
+
logoDisplay: true,
|
| 135 |
+
welcomeEnable: false,
|
| 136 |
+
termsOfServiceEnable: false,
|
| 137 |
+
copyrightEnable: false,
|
| 138 |
+
redirectionCountDownEnable: true,
|
| 139 |
+
advertisement: { enable: false, pictureIds: [] },
|
| 140 |
+
buttonText: 'Log In',
|
| 141 |
+
},
|
| 142 |
+
});
|
| 143 |
+
return res.result; // portalId string
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// ── Devices ───────────────────────────────────────────────────────────────────
|
| 147 |
+
|
| 148 |
+
async function getSiteDevices(siteId) {
|
| 149 |
+
const res = await omadaRequest('GET',
|
| 150 |
+
`/sites/${siteId}/grid/devices?currentPage=1¤tPageSize=50`
|
| 151 |
+
);
|
| 152 |
+
return res.result?.data || [];
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
async function getAllAdoptedDevices() {
|
| 156 |
+
const res = await omadaRequest('GET',
|
| 157 |
+
'/grid/devices/adopted?currentPage=1¤tPageSize=200'
|
| 158 |
+
);
|
| 159 |
+
return res.result?.data || [];
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
async function adoptDevice(siteId, mac, username, password) {
|
| 163 |
+
return omadaRequest('POST', `/sites/${siteId}/cmd/devices/adopt`, {
|
| 164 |
+
mac, username, password,
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
async function rebootDevice(siteId, mac) {
|
| 169 |
+
return omadaRequest('POST', `/sites/${siteId}/cmd/devices/${mac}/reboot`, { mac });
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ── Clients (guests) ──────────────────────────────────────────────────────────
|
| 173 |
+
|
| 174 |
+
async function getClient(siteId, mac) {
|
| 175 |
+
const res = await omadaRequest('GET', `/sites/${siteId}/clients/${mac}`);
|
| 176 |
+
return res.result;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
async function getSiteClients(siteId) {
|
| 180 |
+
const res = await omadaRequest('GET',
|
| 181 |
+
`/sites/${siteId}/clients?currentPage=1¤tPageSize=200&filters.active=true`
|
| 182 |
+
);
|
| 183 |
+
return res.result?.data || [];
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
async function authorizeClient(siteId, mac) {
|
| 187 |
+
return omadaRequest('POST', `/sites/${siteId}/cmd/clients/${mac}/auth`, {});
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
async function deauthorizeClient(siteId, mac) {
|
| 191 |
+
return omadaRequest('POST', `/sites/${siteId}/cmd/clients/${mac}/unauthorize`, {});
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
async function applyRateLimit(siteId, mac, { downLimit, downUnit, upLimit, upUnit, name = 'Guest' }) {
|
| 195 |
+
return omadaRequest('PATCH', `/sites/${siteId}/clients/${mac}`, {
|
| 196 |
+
name,
|
| 197 |
+
rateLimit: {
|
| 198 |
+
enable: true,
|
| 199 |
+
downEnable: true,
|
| 200 |
+
downLimit,
|
| 201 |
+
downUnit,
|
| 202 |
+
upEnable: true,
|
| 203 |
+
upLimit,
|
| 204 |
+
upUnit,
|
| 205 |
+
},
|
| 206 |
+
clientLockToApSetting: { enable: false },
|
| 207 |
+
ipSetting: { useFixedAddr: false },
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// ── Alerts ────────────────────────────────────────────────────────────────────
|
| 212 |
+
|
| 213 |
+
async function getSiteAlerts(siteId) {
|
| 214 |
+
const now = Date.now();
|
| 215 |
+
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
| 216 |
+
const res = await omadaRequest('GET',
|
| 217 |
+
`/sites/${siteId}/logs/alerts` +
|
| 218 |
+
`?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` +
|
| 219 |
+
`&filters.resolved=false¤tPage=1¤tPageSize=50`
|
| 220 |
+
);
|
| 221 |
+
return res.result?.data || [];
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
module.exports = {
|
| 225 |
+
initOmada,
|
| 226 |
+
createSite,
|
| 227 |
+
getSites,
|
| 228 |
+
getSiteById,
|
| 229 |
+
createPortal,
|
| 230 |
+
getSiteDevices,
|
| 231 |
+
getAllAdoptedDevices,
|
| 232 |
+
adoptDevice,
|
| 233 |
+
rebootDevice,
|
| 234 |
+
getClient,
|
| 235 |
+
getSiteClients,
|
| 236 |
+
authorizeClient,
|
| 237 |
+
deauthorizeClient,
|
| 238 |
+
applyRateLimit,
|
| 239 |
+
getSiteAlerts,
|
| 240 |
+
};
|
src/services/sms.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const axios = require('axios');
|
| 2 |
+
const db = require('../config/db');
|
| 3 |
+
|
| 4 |
+
function normalizePhone(phone) {
|
| 5 |
+
const d = phone.replace(/\D/g, '');
|
| 6 |
+
if (d.startsWith('255')) return '+' + d;
|
| 7 |
+
if (d.startsWith('0')) return '+255' + d.slice(1);
|
| 8 |
+
return '+255' + d;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
async function sendSMS(phone, message) {
|
| 12 |
+
const to = normalizePhone(phone);
|
| 13 |
+
let status = 'sent';
|
| 14 |
+
let gatewayId = null;
|
| 15 |
+
let error = null;
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const res = await axios.post(
|
| 19 |
+
process.env.SMS_GATEWAY_URL,
|
| 20 |
+
{
|
| 21 |
+
textMessage: { text: message },
|
| 22 |
+
phoneNumbers: [to],
|
| 23 |
+
withDeliveryReport: false,
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
auth: {
|
| 27 |
+
username: process.env.SMS_GATEWAY_USER,
|
| 28 |
+
password: process.env.SMS_GATEWAY_PASS,
|
| 29 |
+
},
|
| 30 |
+
timeout: 10000,
|
| 31 |
+
}
|
| 32 |
+
);
|
| 33 |
+
gatewayId = res.data?.id || null;
|
| 34 |
+
console.log(`[SMS] Sent to ${to}: "${message.slice(0, 40)}..."`);
|
| 35 |
+
} catch (err) {
|
| 36 |
+
status = 'failed';
|
| 37 |
+
error = err.message;
|
| 38 |
+
console.error(`[SMS] Failed to ${to}:`, err.message);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Fire-and-forget log
|
| 42 |
+
db.query(
|
| 43 |
+
'INSERT INTO sms_log (phone, message, status, gateway_id, error) VALUES (?, ?, ?, ?, ?)',
|
| 44 |
+
[to, message, status, gatewayId, error]
|
| 45 |
+
).catch(() => {});
|
| 46 |
+
|
| 47 |
+
return status === 'sent';
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
module.exports = { sendSMS };
|
src/services/snippe.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const crypto = require('crypto');
|
| 2 |
+
const axios = require('axios');
|
| 3 |
+
|
| 4 |
+
const BASE_URL = 'https://api.snippe.sh';
|
| 5 |
+
|
| 6 |
+
function ikey(prefix, id) {
|
| 7 |
+
return `${prefix}-${id}`.slice(0, 30);
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function normalizePhone(phone) {
|
| 11 |
+
const d = phone.replace(/\D/g, '');
|
| 12 |
+
if (d.startsWith('255')) return d;
|
| 13 |
+
if (d.startsWith('0')) return '255' + d.slice(1);
|
| 14 |
+
return '255' + d;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
async function snippeRequest(method, path, body = null, idempotencyKey = null) {
|
| 18 |
+
const headers = {
|
| 19 |
+
Authorization: `Bearer ${process.env.SNIPPE_API_KEY}`,
|
| 20 |
+
'Content-Type': 'application/json',
|
| 21 |
+
};
|
| 22 |
+
if (idempotencyKey) {
|
| 23 |
+
if (idempotencyKey.length > 30) {
|
| 24 |
+
throw new Error(`Idempotency key too long (${idempotencyKey.length} chars, max 30)`);
|
| 25 |
+
}
|
| 26 |
+
headers['Idempotency-Key'] = idempotencyKey;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const res = await axios({
|
| 30 |
+
method,
|
| 31 |
+
url: `${BASE_URL}${path}`,
|
| 32 |
+
headers,
|
| 33 |
+
data: body ?? undefined,
|
| 34 |
+
timeout: 30000,
|
| 35 |
+
});
|
| 36 |
+
return res.data;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// ── Collection ────────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
async function createPayment({ reference, amount, phone, webhookUrl, metadata = {} }) {
|
| 42 |
+
return snippeRequest('POST', '/v1/payments', {
|
| 43 |
+
payment_type: 'mobile',
|
| 44 |
+
details: { amount, currency: 'TZS' },
|
| 45 |
+
phone_number: normalizePhone(phone),
|
| 46 |
+
customer: {
|
| 47 |
+
firstname: metadata.firstname || 'Guest',
|
| 48 |
+
lastname: metadata.lastname || 'User',
|
| 49 |
+
email: metadata.email || 'guest@wifi.local',
|
| 50 |
+
},
|
| 51 |
+
webhook_url: webhookUrl,
|
| 52 |
+
metadata,
|
| 53 |
+
}, ikey('pay', reference));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function getPayment(reference) {
|
| 57 |
+
return snippeRequest('GET', `/v1/payments/${reference}`);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// ── Disbursements ─────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
async function getPayoutFee(amount) {
|
| 63 |
+
const res = await snippeRequest('GET', `/v1/payouts/fee?amount=${amount}`);
|
| 64 |
+
return res.data; // { amount, fee_amount, total_amount, currency }
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
async function createPayout({ payoutId, amount, phone, recipientName, narration, webhookUrl }) {
|
| 68 |
+
const feeData = await getPayoutFee(amount);
|
| 69 |
+
const res = await snippeRequest('POST', '/v1/payouts/send', {
|
| 70 |
+
amount,
|
| 71 |
+
channel: 'mobile',
|
| 72 |
+
recipient_phone: normalizePhone(phone),
|
| 73 |
+
recipient_name: recipientName,
|
| 74 |
+
narration: narration || 'WiFi platform payout',
|
| 75 |
+
webhook_url: webhookUrl,
|
| 76 |
+
metadata: { payout_id: String(payoutId) },
|
| 77 |
+
}, ikey('po', payoutId));
|
| 78 |
+
return { ...res, feeData };
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ── Webhook verification ──────────────────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
function verifyWebhookSignature(rawBody, headers) {
|
| 84 |
+
const timestamp = headers['x-webhook-timestamp'];
|
| 85 |
+
const receivedSig = headers['x-webhook-signature'];
|
| 86 |
+
|
| 87 |
+
if (!timestamp || !receivedSig) throw new Error('Missing webhook signature headers');
|
| 88 |
+
|
| 89 |
+
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
|
| 90 |
+
if (age > 300) throw new Error('Webhook timestamp too old (possible replay attack)');
|
| 91 |
+
|
| 92 |
+
const expected = crypto
|
| 93 |
+
.createHmac('sha256', process.env.SNIPPE_WEBHOOK_SECRET)
|
| 94 |
+
.update(`${timestamp}.${rawBody}`)
|
| 95 |
+
.digest('hex');
|
| 96 |
+
|
| 97 |
+
if (!crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expected))) {
|
| 98 |
+
throw new Error('Invalid webhook signature');
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
return JSON.parse(rawBody);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
module.exports = {
|
| 105 |
+
createPayment,
|
| 106 |
+
getPayment,
|
| 107 |
+
getPayoutFee,
|
| 108 |
+
createPayout,
|
| 109 |
+
verifyWebhookSignature,
|
| 110 |
+
normalizePhone,
|
| 111 |
+
};
|
src/utils/adminAlert.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const sms = require('../services/sms');
|
| 3 |
+
|
| 4 |
+
// In-memory cooldown — don't spam the same alert key more than once per 10 min
|
| 5 |
+
const lastSent = {};
|
| 6 |
+
const COOLDOWN_MS = 10 * 60 * 1000;
|
| 7 |
+
|
| 8 |
+
async function alertAdmin(key, message) {
|
| 9 |
+
const now = Date.now();
|
| 10 |
+
if (lastSent[key] && now - lastSent[key] < COOLDOWN_MS) return;
|
| 11 |
+
lastSent[key] = now;
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
const row = await db.queryOne(
|
| 15 |
+
`SELECT value FROM system_config WHERE \`key\` = 'admin_phone'`
|
| 16 |
+
);
|
| 17 |
+
if (!row) return;
|
| 18 |
+
await sms.sendSMS(row.value, `[SYSTEM ALERT] ${message}`);
|
| 19 |
+
} catch (err) {
|
| 20 |
+
console.error('[adminAlert] Failed to send alert:', err.message);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
module.exports = { alertAdmin };
|
src/utils/generateCode.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Readable code chars — no 0/O, 1/I confusion
|
| 2 |
+
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
| 3 |
+
|
| 4 |
+
function generateCode() {
|
| 5 |
+
const part = (n) =>
|
| 6 |
+
Array.from({ length: n }, () => CHARS[Math.floor(Math.random() * CHARS.length)]).join('');
|
| 7 |
+
return `${part(4)}-${part(4)}`; // e.g. AB3K-9X2M
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
module.exports = { generateCode };
|
src/utils/messages.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SMS message templates.
|
| 3 |
+
* All outbound SMS text lives here — edit copy in one place.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
module.exports = {
|
| 7 |
+
// Guest receives this after paying for WiFi
|
| 8 |
+
wifiCode: (code, planName, duration, deviceName) =>
|
| 9 |
+
`WiFi Code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nEnter this code on the WiFi login page. Valid for ${duration} after connecting.`,
|
| 10 |
+
|
| 11 |
+
// Tenant receives this after their device subscription renews
|
| 12 |
+
deviceRenewal: (deviceName, expiryDate) =>
|
| 13 |
+
`"${deviceName}" renewed until ${expiryDate}. Keep selling!`,
|
| 14 |
+
|
| 15 |
+
// Tenant receives this when their AP comes online for the first time
|
| 16 |
+
deviceOnline: (deviceName) =>
|
| 17 |
+
`Your device "${deviceName}" is now online! Create WiFi plans to start selling.`,
|
| 18 |
+
|
| 19 |
+
// Tenant receives this when their AP goes offline
|
| 20 |
+
deviceOffline: (deviceName) =>
|
| 21 |
+
`Alert: Your device "${deviceName}" went offline. Check the AP.`,
|
| 22 |
+
|
| 23 |
+
// Tenant receives this when billing expires and device is auto-suspended
|
| 24 |
+
deviceSuspended: (deviceName, fee) =>
|
| 25 |
+
`Your device "${deviceName}" has been suspended (billing expired). Pay ${fee} TZS to reactivate.`,
|
| 26 |
+
|
| 27 |
+
// Tenant receives this when they manually pause their device
|
| 28 |
+
devicePaused: (deviceName, fee) =>
|
| 29 |
+
`Your device "${deviceName}" has been paused. Pay ${fee} TZS to reactivate and resume WiFi service.`,
|
| 30 |
+
|
| 31 |
+
// Tenant receives this on successful registration
|
| 32 |
+
welcome: () =>
|
| 33 |
+
`Welcome to WiFi Platform! Add your first device on the dashboard to start selling WiFi.`,
|
| 34 |
+
|
| 35 |
+
// Tenant receives this 1–3 days before billing expires
|
| 36 |
+
billingReminder: (deviceName, daysLeft, fee) =>
|
| 37 |
+
`Reminder: Your WiFi device "${deviceName}" expires in ${daysLeft} day(s). Pay ${parseInt(fee).toLocaleString()} TZS to keep it active.`,
|
| 38 |
+
|
| 39 |
+
// Tenant receives this when requesting a password reset
|
| 40 |
+
passwordResetOtp: (otp) => `Token is: ${otp}\n.`,
|
| 41 |
+
|
| 42 |
+
// Tenant receives this when a payout completes
|
| 43 |
+
payoutCompleted: (amount) =>
|
| 44 |
+
`Payout of ${parseInt(amount).toLocaleString()} TZS sent to your M-Pesa.`,
|
| 45 |
+
|
| 46 |
+
// Tenant receives this when a payout fails
|
| 47 |
+
payoutFailed: (amount) =>
|
| 48 |
+
`Payout of ${parseInt(amount).toLocaleString()} TZS failed. Your balance has been restored.`,
|
| 49 |
+
};
|
src/views/portal.ejs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title><%= portal.businessName %> — WiFi</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 11 |
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
display: flex;
|
| 14 |
+
align-items: center;
|
| 15 |
+
justify-content: center;
|
| 16 |
+
padding: 16px;
|
| 17 |
+
}
|
| 18 |
+
.card {
|
| 19 |
+
background: #fff;
|
| 20 |
+
border-radius: 20px;
|
| 21 |
+
padding: 32px 24px;
|
| 22 |
+
width: 100%;
|
| 23 |
+
max-width: 380px;
|
| 24 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
| 25 |
+
}
|
| 26 |
+
.header {
|
| 27 |
+
text-align: center;
|
| 28 |
+
margin-bottom: 28px;
|
| 29 |
+
}
|
| 30 |
+
.header h1 {
|
| 31 |
+
font-size: 22px;
|
| 32 |
+
font-weight: 700;
|
| 33 |
+
color: #1a1a2e;
|
| 34 |
+
margin-bottom: 4px;
|
| 35 |
+
}
|
| 36 |
+
.header p {
|
| 37 |
+
color: #666;
|
| 38 |
+
font-size: 14px;
|
| 39 |
+
}
|
| 40 |
+
.wifi-icon {
|
| 41 |
+
font-size: 40px;
|
| 42 |
+
margin-bottom: 12px;
|
| 43 |
+
}
|
| 44 |
+
/* Screens */
|
| 45 |
+
.screen { display: none; }
|
| 46 |
+
.screen.active { display: block; }
|
| 47 |
+
/* Plans */
|
| 48 |
+
.plan-btn {
|
| 49 |
+
display: block;
|
| 50 |
+
width: 100%;
|
| 51 |
+
padding: 14px 16px;
|
| 52 |
+
margin-bottom: 10px;
|
| 53 |
+
background: #f8f9ff;
|
| 54 |
+
border: 2px solid #e8ecff;
|
| 55 |
+
border-radius: 12px;
|
| 56 |
+
cursor: pointer;
|
| 57 |
+
text-align: left;
|
| 58 |
+
transition: all 0.2s;
|
| 59 |
+
position: relative;
|
| 60 |
+
}
|
| 61 |
+
.plan-btn:hover, .plan-btn.selected {
|
| 62 |
+
border-color: #4361ee;
|
| 63 |
+
background: #eef0ff;
|
| 64 |
+
}
|
| 65 |
+
.plan-btn .plan-name { font-weight: 600; color: #1a1a2e; font-size: 15px; }
|
| 66 |
+
.plan-btn .plan-price { float: right; font-weight: 700; color: #4361ee; font-size: 16px; }
|
| 67 |
+
.plan-btn .plan-speed { font-size: 12px; color: #888; margin-top: 2px; }
|
| 68 |
+
/* Phone input */
|
| 69 |
+
.input-group { margin-top: 16px; }
|
| 70 |
+
.input-group label { font-size: 13px; color: #555; margin-bottom: 6px; display: block; }
|
| 71 |
+
.input-group input {
|
| 72 |
+
width: 100%;
|
| 73 |
+
padding: 12px 14px;
|
| 74 |
+
border: 2px solid #e0e0e0;
|
| 75 |
+
border-radius: 10px;
|
| 76 |
+
font-size: 16px;
|
| 77 |
+
outline: none;
|
| 78 |
+
transition: border-color 0.2s;
|
| 79 |
+
}
|
| 80 |
+
.input-group input:focus { border-color: #4361ee; }
|
| 81 |
+
/* Primary button */
|
| 82 |
+
.btn-primary {
|
| 83 |
+
display: block;
|
| 84 |
+
width: 100%;
|
| 85 |
+
padding: 15px;
|
| 86 |
+
margin-top: 16px;
|
| 87 |
+
background: #4361ee;
|
| 88 |
+
color: #fff;
|
| 89 |
+
border: none;
|
| 90 |
+
border-radius: 12px;
|
| 91 |
+
font-size: 16px;
|
| 92 |
+
font-weight: 600;
|
| 93 |
+
cursor: pointer;
|
| 94 |
+
transition: background 0.2s;
|
| 95 |
+
}
|
| 96 |
+
.btn-primary:hover { background: #3451d1; }
|
| 97 |
+
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
|
| 98 |
+
/* Secondary link */
|
| 99 |
+
.link-btn {
|
| 100 |
+
display: block;
|
| 101 |
+
text-align: center;
|
| 102 |
+
margin-top: 14px;
|
| 103 |
+
color: #4361ee;
|
| 104 |
+
font-size: 14px;
|
| 105 |
+
cursor: pointer;
|
| 106 |
+
text-decoration: underline;
|
| 107 |
+
}
|
| 108 |
+
/* Spinner */
|
| 109 |
+
.spinner {
|
| 110 |
+
text-align: center;
|
| 111 |
+
padding: 20px 0;
|
| 112 |
+
}
|
| 113 |
+
.spinner-ring {
|
| 114 |
+
width: 48px; height: 48px;
|
| 115 |
+
border: 4px solid #e8ecff;
|
| 116 |
+
border-top-color: #4361ee;
|
| 117 |
+
border-radius: 50%;
|
| 118 |
+
animation: spin 0.8s linear infinite;
|
| 119 |
+
margin: 0 auto 12px;
|
| 120 |
+
}
|
| 121 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 122 |
+
.spinner p { color: #555; font-size: 14px; }
|
| 123 |
+
/* Success */
|
| 124 |
+
.success-icon { font-size: 56px; text-align: center; margin-bottom: 12px; }
|
| 125 |
+
.code-box {
|
| 126 |
+
background: #f8f9ff;
|
| 127 |
+
border: 2px solid #4361ee;
|
| 128 |
+
border-radius: 12px;
|
| 129 |
+
padding: 16px;
|
| 130 |
+
text-align: center;
|
| 131 |
+
margin: 16px 0;
|
| 132 |
+
}
|
| 133 |
+
.code-box .code-label { font-size: 12px; color: #888; margin-bottom: 4px; }
|
| 134 |
+
.code-box .code-value {
|
| 135 |
+
font-size: 28px;
|
| 136 |
+
font-weight: 700;
|
| 137 |
+
color: #1a1a2e;
|
| 138 |
+
letter-spacing: 3px;
|
| 139 |
+
font-family: monospace;
|
| 140 |
+
}
|
| 141 |
+
.meta-row {
|
| 142 |
+
display: flex;
|
| 143 |
+
justify-content: space-between;
|
| 144 |
+
font-size: 13px;
|
| 145 |
+
color: #555;
|
| 146 |
+
padding: 6px 0;
|
| 147 |
+
border-bottom: 1px solid #f0f0f0;
|
| 148 |
+
}
|
| 149 |
+
.meta-row:last-child { border-bottom: none; }
|
| 150 |
+
.meta-row strong { color: #1a1a2e; }
|
| 151 |
+
/* Error */
|
| 152 |
+
.error-msg {
|
| 153 |
+
background: #fff0f0;
|
| 154 |
+
border: 1px solid #ffcccc;
|
| 155 |
+
color: #cc0000;
|
| 156 |
+
border-radius: 8px;
|
| 157 |
+
padding: 10px 14px;
|
| 158 |
+
font-size: 13px;
|
| 159 |
+
margin-top: 12px;
|
| 160 |
+
display: none;
|
| 161 |
+
}
|
| 162 |
+
.error-msg.show { display: block; }
|
| 163 |
+
.divider { text-align: center; color: #bbb; font-size: 13px; margin: 16px 0; }
|
| 164 |
+
</style>
|
| 165 |
+
</head>
|
| 166 |
+
<body>
|
| 167 |
+
<div class="card">
|
| 168 |
+
<div class="header">
|
| 169 |
+
<div class="wifi-icon">📶</div>
|
| 170 |
+
<h1><%= portal.businessName %></h1>
|
| 171 |
+
<p>Get online instantly</p>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<!-- SCREEN 1: Plan selection & phone -->
|
| 175 |
+
<div id="screen-select" class="screen active">
|
| 176 |
+
<% if (portal.plans.length === 0) { %>
|
| 177 |
+
<p style="text-align:center;color:#888;padding:20px 0;">No WiFi plans available yet.<br>Check back soon.</p>
|
| 178 |
+
<% } else { %>
|
| 179 |
+
<% portal.plans.forEach(function(plan) {
|
| 180 |
+
const unit = plan.down_unit === 2 ? 'Mbps' : 'Kbps';
|
| 181 |
+
const speed = plan.down_limit + unit + ' ↓ / ' + plan.up_limit + unit + ' ↑';
|
| 182 |
+
const hours = Math.floor(plan.duration_seconds / 3600);
|
| 183 |
+
const days = Math.floor(plan.duration_seconds / 86400);
|
| 184 |
+
const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hour' : ' Hours');
|
| 185 |
+
%>
|
| 186 |
+
<button class="plan-btn" data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>">
|
| 187 |
+
<span class="plan-name"><%= plan.name %></span>
|
| 188 |
+
<span class="plan-price"><%= parseInt(plan.price).toLocaleString() %> TZS</span>
|
| 189 |
+
<div class="plan-speed"><%= dur %> • <%= speed %></div>
|
| 190 |
+
</button>
|
| 191 |
+
<% }); %>
|
| 192 |
+
|
| 193 |
+
<div class="input-group">
|
| 194 |
+
<label for="phone-input">Your M-Pesa / Mobile Money Phone Number</label>
|
| 195 |
+
<input type="tel" id="phone-input" placeholder="0712 345 678" inputmode="numeric">
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div class="error-msg" id="select-error"></div>
|
| 199 |
+
<button class="btn-primary" id="pay-btn" disabled>Select a plan to continue</button>
|
| 200 |
+
|
| 201 |
+
<div class="divider">or</div>
|
| 202 |
+
<span class="link-btn" id="show-code-entry">Have a code? Enter it here</span>
|
| 203 |
+
<% } %>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<!-- SCREEN 2: Waiting for payment -->
|
| 207 |
+
<div id="screen-waiting" class="screen">
|
| 208 |
+
<div class="spinner">
|
| 209 |
+
<div class="spinner-ring"></div>
|
| 210 |
+
<p>Waiting for M-Pesa payment...</p>
|
| 211 |
+
<p style="font-size:12px;color:#aaa;margin-top:8px;">Check your phone and enter your PIN</p>
|
| 212 |
+
</div>
|
| 213 |
+
<div class="error-msg" id="payment-error"></div>
|
| 214 |
+
<button class="btn-primary" id="cancel-btn" style="background:#eee;color:#555;">Cancel</button>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
<!-- SCREEN 3: Code entry (reconnect) -->
|
| 218 |
+
<div id="screen-code" class="screen">
|
| 219 |
+
<div class="input-group">
|
| 220 |
+
<label for="code-input">Enter your WiFi code</label>
|
| 221 |
+
<input type="text" id="code-input" placeholder="XXXX-XXXX" maxlength="9" style="text-transform:uppercase;letter-spacing:2px;">
|
| 222 |
+
</div>
|
| 223 |
+
<div class="error-msg" id="code-error"></div>
|
| 224 |
+
<button class="btn-primary" id="code-submit-btn">Connect</button>
|
| 225 |
+
<span class="link-btn" id="show-plans">Buy a new plan instead</span>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<!-- SCREEN 4: Connected! -->
|
| 229 |
+
<div id="screen-success" class="screen">
|
| 230 |
+
<div class="success-icon">✅</div>
|
| 231 |
+
<h2 style="text-align:center;color:#1a1a2e;margin-bottom:8px;">Connected!</h2>
|
| 232 |
+
<div class="code-box">
|
| 233 |
+
<div class="code-label">Your WiFi Code (save this to reconnect)</div>
|
| 234 |
+
<div class="code-value" id="success-code">—</div>
|
| 235 |
+
</div>
|
| 236 |
+
<div id="success-meta"></div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<script>
|
| 241 |
+
window.PORTAL = <%- JSON.stringify(portal).replace(/<\/script>/gi, '<\\/script>') %>;
|
| 242 |
+
</script>
|
| 243 |
+
<script src="/engine.js"></script>
|
| 244 |
+
</body>
|
| 245 |
+
</html>
|
src/views/screens/portal.ejs
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title><%= portal.customize.displayName %> — WiFi</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--primary: <%= portal.customize.primaryColor %>;
|
| 10 |
+
--primary-dark: <%= portal.customize.primaryColor %>cc;
|
| 11 |
+
--bg: <%= portal.customize.bgColor %>;
|
| 12 |
+
}
|
| 13 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 14 |
+
body {
|
| 15 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 16 |
+
background: var(--bg);
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
display: flex;
|
| 19 |
+
align-items: center;
|
| 20 |
+
justify-content: center;
|
| 21 |
+
padding: 16px;
|
| 22 |
+
}
|
| 23 |
+
.card {
|
| 24 |
+
background: #fff;
|
| 25 |
+
border-radius: 20px;
|
| 26 |
+
padding: 32px 24px;
|
| 27 |
+
width: 100%;
|
| 28 |
+
max-width: 380px;
|
| 29 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
| 30 |
+
}
|
| 31 |
+
.header {
|
| 32 |
+
text-align: center;
|
| 33 |
+
margin-bottom: 28px;
|
| 34 |
+
}
|
| 35 |
+
.header h1 {
|
| 36 |
+
font-size: 22px;
|
| 37 |
+
font-weight: 700;
|
| 38 |
+
color: #1a1a2e;
|
| 39 |
+
margin-bottom: 4px;
|
| 40 |
+
}
|
| 41 |
+
.header p {
|
| 42 |
+
color: #666;
|
| 43 |
+
font-size: 14px;
|
| 44 |
+
}
|
| 45 |
+
.wifi-icon {
|
| 46 |
+
font-size: 40px;
|
| 47 |
+
margin-bottom: 12px;
|
| 48 |
+
}
|
| 49 |
+
/* Screens */
|
| 50 |
+
.screen { display: none; }
|
| 51 |
+
.screen.active { display: block; }
|
| 52 |
+
/* Plans */
|
| 53 |
+
.plan-btn {
|
| 54 |
+
display: block;
|
| 55 |
+
width: 100%;
|
| 56 |
+
padding: 14px 16px;
|
| 57 |
+
margin-bottom: 10px;
|
| 58 |
+
background: #f8f9ff;
|
| 59 |
+
border: 2px solid #e8ecff;
|
| 60 |
+
border-radius: 12px;
|
| 61 |
+
cursor: pointer;
|
| 62 |
+
text-align: left;
|
| 63 |
+
transition: all 0.2s;
|
| 64 |
+
position: relative;
|
| 65 |
+
}
|
| 66 |
+
.plan-btn:hover, .plan-btn.selected {
|
| 67 |
+
border-color: var(--primary);
|
| 68 |
+
background: #eef0ff;
|
| 69 |
+
}
|
| 70 |
+
.plan-btn .plan-name { font-weight: 600; color: #1a1a2e; font-size: 15px; }
|
| 71 |
+
.plan-btn .plan-price { float: right; font-weight: 700; color: var(--primary); font-size: 16px; }
|
| 72 |
+
.plan-btn .plan-speed { font-size: 12px; color: #888; margin-top: 2px; }
|
| 73 |
+
/* Phone input */
|
| 74 |
+
.input-group { margin-top: 16px; }
|
| 75 |
+
.input-group label { font-size: 13px; color: #555; margin-bottom: 6px; display: block; }
|
| 76 |
+
.input-group input {
|
| 77 |
+
width: 100%;
|
| 78 |
+
padding: 12px 14px;
|
| 79 |
+
border: 2px solid #e0e0e0;
|
| 80 |
+
border-radius: 10px;
|
| 81 |
+
font-size: 16px;
|
| 82 |
+
outline: none;
|
| 83 |
+
transition: border-color 0.2s;
|
| 84 |
+
}
|
| 85 |
+
.input-group input:focus { border-color: var(--primary); }
|
| 86 |
+
/* Primary button */
|
| 87 |
+
.btn-primary {
|
| 88 |
+
display: block;
|
| 89 |
+
width: 100%;
|
| 90 |
+
padding: 15px;
|
| 91 |
+
margin-top: 16px;
|
| 92 |
+
background: var(--primary);
|
| 93 |
+
color: #fff;
|
| 94 |
+
border: none;
|
| 95 |
+
border-radius: 12px;
|
| 96 |
+
font-size: 16px;
|
| 97 |
+
font-weight: 600;
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
transition: filter 0.2s;
|
| 100 |
+
}
|
| 101 |
+
.btn-primary:hover { filter: brightness(0.88); }
|
| 102 |
+
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
|
| 103 |
+
/* Secondary link */
|
| 104 |
+
.link-btn {
|
| 105 |
+
display: block;
|
| 106 |
+
text-align: center;
|
| 107 |
+
margin-top: 14px;
|
| 108 |
+
color: var(--primary);
|
| 109 |
+
font-size: 14px;
|
| 110 |
+
cursor: pointer;
|
| 111 |
+
text-decoration: underline;
|
| 112 |
+
}
|
| 113 |
+
/* Spinner */
|
| 114 |
+
.spinner {
|
| 115 |
+
text-align: center;
|
| 116 |
+
padding: 20px 0;
|
| 117 |
+
}
|
| 118 |
+
.spinner-ring {
|
| 119 |
+
width: 48px; height: 48px;
|
| 120 |
+
border: 4px solid #e8ecff;
|
| 121 |
+
border-top-color: var(--primary);
|
| 122 |
+
border-radius: 50%;
|
| 123 |
+
animation: spin 0.8s linear infinite;
|
| 124 |
+
margin: 0 auto 12px;
|
| 125 |
+
}
|
| 126 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 127 |
+
.spinner p { color: #555; font-size: 14px; }
|
| 128 |
+
/* Success */
|
| 129 |
+
.success-icon { font-size: 56px; text-align: center; margin-bottom: 12px; }
|
| 130 |
+
.code-box {
|
| 131 |
+
background: #f8f9ff;
|
| 132 |
+
border: 2px solid var(--primary);
|
| 133 |
+
border-radius: 12px;
|
| 134 |
+
padding: 16px;
|
| 135 |
+
text-align: center;
|
| 136 |
+
margin: 16px 0;
|
| 137 |
+
}
|
| 138 |
+
.code-box .code-label { font-size: 12px; color: #888; margin-bottom: 4px; }
|
| 139 |
+
.code-box .code-value {
|
| 140 |
+
font-size: 28px;
|
| 141 |
+
font-weight: 700;
|
| 142 |
+
color: #1a1a2e;
|
| 143 |
+
letter-spacing: 3px;
|
| 144 |
+
font-family: monospace;
|
| 145 |
+
}
|
| 146 |
+
.meta-row {
|
| 147 |
+
display: flex;
|
| 148 |
+
justify-content: space-between;
|
| 149 |
+
font-size: 13px;
|
| 150 |
+
color: #555;
|
| 151 |
+
padding: 6px 0;
|
| 152 |
+
border-bottom: 1px solid #f0f0f0;
|
| 153 |
+
}
|
| 154 |
+
.meta-row:last-child { border-bottom: none; }
|
| 155 |
+
.meta-row strong { color: #1a1a2e; }
|
| 156 |
+
/* Error */
|
| 157 |
+
.error-msg {
|
| 158 |
+
background: #fff0f0;
|
| 159 |
+
border: 1px solid #ffcccc;
|
| 160 |
+
color: #cc0000;
|
| 161 |
+
border-radius: 8px;
|
| 162 |
+
padding: 10px 14px;
|
| 163 |
+
font-size: 13px;
|
| 164 |
+
margin-top: 12px;
|
| 165 |
+
display: none;
|
| 166 |
+
}
|
| 167 |
+
.error-msg.show { display: block; }
|
| 168 |
+
.divider { text-align: center; color: #bbb; font-size: 13px; margin: 16px 0; }
|
| 169 |
+
</style>
|
| 170 |
+
</head>
|
| 171 |
+
<body>
|
| 172 |
+
<div class="card">
|
| 173 |
+
<div class="header">
|
| 174 |
+
<% if (portal.customize.logoUrl) { %>
|
| 175 |
+
<img src="<%= portal.customize.logoUrl %>" alt="<%= portal.customize.displayName %>" style="max-height:64px;max-width:180px;object-fit:contain;margin-bottom:10px;">
|
| 176 |
+
<% } else { %>
|
| 177 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:12px">
|
| 178 |
+
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
|
| 179 |
+
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
| 180 |
+
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
| 181 |
+
<circle cx="12" cy="20" r="1" fill="var(--primary)" stroke="none"/>
|
| 182 |
+
</svg>
|
| 183 |
+
<% } %>
|
| 184 |
+
<h1><%= portal.customize.displayName %></h1>
|
| 185 |
+
<p><%= portal.customize.welcomeText %></p>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<!-- SCREEN 1: Plan selection & phone -->
|
| 189 |
+
<div id="screen-select" class="screen active">
|
| 190 |
+
<% if (portal.plans.length === 0) { %>
|
| 191 |
+
<p style="text-align:center;color:#888;padding:20px 0;">No WiFi plans available yet.<br>Check back soon.</p>
|
| 192 |
+
<% } else { %>
|
| 193 |
+
<% portal.plans.forEach(function(plan) {
|
| 194 |
+
const unit = plan.down_unit === 2 ? 'Mbps' : 'Kbps';
|
| 195 |
+
const speed = plan.down_limit + unit + ' ↓ / ' + plan.up_limit + unit + ' ↑';
|
| 196 |
+
const hours = Math.floor(plan.duration_seconds / 3600);
|
| 197 |
+
const days = Math.floor(plan.duration_seconds / 86400);
|
| 198 |
+
const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hour' : ' Hours');
|
| 199 |
+
%>
|
| 200 |
+
<button class="plan-btn" data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>">
|
| 201 |
+
<span class="plan-name"><%= plan.name %></span>
|
| 202 |
+
<span class="plan-price"><%= parseInt(plan.price).toLocaleString() %> TZS</span>
|
| 203 |
+
<div class="plan-speed"><%= dur %> • <%= speed %></div>
|
| 204 |
+
</button>
|
| 205 |
+
<% }); %>
|
| 206 |
+
|
| 207 |
+
<div class="input-group">
|
| 208 |
+
<label for="phone-input">Your M-Pesa / Mobile Money Phone Number</label>
|
| 209 |
+
<input type="tel" id="phone-input" placeholder="0712 345 678" inputmode="numeric">
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<div class="error-msg" id="select-error"></div>
|
| 213 |
+
<button class="btn-primary" id="pay-btn" disabled>Select a plan to continue</button>
|
| 214 |
+
|
| 215 |
+
<div class="divider">or</div>
|
| 216 |
+
<span class="link-btn" id="show-code-entry">Have a code? Enter it here</span>
|
| 217 |
+
<% } %>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<!-- SCREEN 2: Waiting for payment -->
|
| 221 |
+
<div id="screen-waiting" class="screen">
|
| 222 |
+
<div class="spinner">
|
| 223 |
+
<div class="spinner-ring"></div>
|
| 224 |
+
<p>Waiting for M-Pesa payment...</p>
|
| 225 |
+
<p style="font-size:12px;color:#aaa;margin-top:8px;">Check your phone and enter your PIN</p>
|
| 226 |
+
</div>
|
| 227 |
+
<div class="error-msg" id="payment-error"></div>
|
| 228 |
+
<button class="btn-primary" id="cancel-btn" style="background:#eee;color:#555;">Cancel</button>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<!-- SCREEN 3: Code entry (reconnect) -->
|
| 232 |
+
<div id="screen-code" class="screen">
|
| 233 |
+
<div class="input-group">
|
| 234 |
+
<label for="code-input">Enter your WiFi code</label>
|
| 235 |
+
<input type="text" id="code-input" placeholder="XXXX-XXXX" maxlength="9" style="text-transform:uppercase;letter-spacing:2px;">
|
| 236 |
+
</div>
|
| 237 |
+
<div class="error-msg" id="code-error"></div>
|
| 238 |
+
<button class="btn-primary" id="code-submit-btn">Connect</button>
|
| 239 |
+
<span class="link-btn" id="show-plans">Buy a new plan instead</span>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<!-- SCREEN 4: Connected! -->
|
| 243 |
+
<div id="screen-success" class="screen">
|
| 244 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 12px">
|
| 245 |
+
<circle cx="12" cy="12" r="10"/>
|
| 246 |
+
<polyline points="9 12 11 14 15 10"/>
|
| 247 |
+
</svg>
|
| 248 |
+
<h2 style="text-align:center;color:#1a1a2e;margin-bottom:8px;">Connected!</h2>
|
| 249 |
+
<div class="code-box">
|
| 250 |
+
<div class="code-label">Your WiFi Code (save this to reconnect)</div>
|
| 251 |
+
<div class="code-value" id="success-code">—</div>
|
| 252 |
+
</div>
|
| 253 |
+
<div id="success-meta"></div>
|
| 254 |
+
</div>
|
| 255 |
+
<% if (portal.customize.supportPhone) { %>
|
| 256 |
+
<p style="text-align:center;font-size:12px;color:#aaa;margin-top:20px;">
|
| 257 |
+
Need help? Call <a href="tel:<%= portal.customize.supportPhone %>" style="color:var(--primary);text-decoration:none;"><%= portal.customize.supportPhone %></a>
|
| 258 |
+
</p>
|
| 259 |
+
<% } %>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<script>
|
| 263 |
+
window.PORTAL = <%- JSON.stringify(portal).replace(/<\/script>/gi, '<\\/script>') %>;
|
| 264 |
+
</script>
|
| 265 |
+
<script src="/engine.js"></script>
|
| 266 |
+
</body>
|
| 267 |
+
</html>
|
src/views/screens/reconnected.ejs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title><%= businessName %> — Reconnected</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 11 |
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
display: flex; align-items: center; justify-content: center;
|
| 14 |
+
padding: 16px;
|
| 15 |
+
}
|
| 16 |
+
.card {
|
| 17 |
+
background: #fff;
|
| 18 |
+
border-radius: 20px;
|
| 19 |
+
padding: 40px 24px;
|
| 20 |
+
width: 100%; max-width: 380px;
|
| 21 |
+
text-align: center;
|
| 22 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
| 23 |
+
}
|
| 24 |
+
.icon { font-size: 56px; margin-bottom: 12px; }
|
| 25 |
+
h1 { font-size: 22px; font-weight: 700; color: #1a1a2e; margin-bottom: 6px; }
|
| 26 |
+
.subtitle { color: #666; font-size: 14px; margin-bottom: 24px; }
|
| 27 |
+
.code-box {
|
| 28 |
+
background: #f8f9ff;
|
| 29 |
+
border: 2px solid #4361ee;
|
| 30 |
+
border-radius: 12px;
|
| 31 |
+
padding: 16px;
|
| 32 |
+
margin-bottom: 20px;
|
| 33 |
+
}
|
| 34 |
+
.code-label { font-size: 12px; color: #888; margin-bottom: 4px; }
|
| 35 |
+
.code-value {
|
| 36 |
+
font-size: 28px;
|
| 37 |
+
font-weight: 700;
|
| 38 |
+
color: #1a1a2e;
|
| 39 |
+
letter-spacing: 3px;
|
| 40 |
+
font-family: monospace;
|
| 41 |
+
}
|
| 42 |
+
.meta-row {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
font-size: 13px;
|
| 46 |
+
color: #555;
|
| 47 |
+
padding: 7px 0;
|
| 48 |
+
border-bottom: 1px solid #f0f0f0;
|
| 49 |
+
}
|
| 50 |
+
.meta-row:last-child { border-bottom: none; }
|
| 51 |
+
.meta-row strong { color: #1a1a2e; }
|
| 52 |
+
</style>
|
| 53 |
+
</head>
|
| 54 |
+
<body>
|
| 55 |
+
<div class="card">
|
| 56 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 12px">
|
| 57 |
+
<circle cx="12" cy="12" r="10"/>
|
| 58 |
+
<polyline points="9 12 11 14 15 10"/>
|
| 59 |
+
</svg>
|
| 60 |
+
<h1>Welcome back!</h1>
|
| 61 |
+
<p class="subtitle"><%= businessName %> — You're connected</p>
|
| 62 |
+
|
| 63 |
+
<div class="code-box">
|
| 64 |
+
<div class="code-label">Your WiFi Code</div>
|
| 65 |
+
<div class="code-value"><%= code %></div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div class="meta-row">
|
| 69 |
+
<span>Speed</span>
|
| 70 |
+
<strong><%= speedDown %> ↓ / <%= speedUp %> ↑</strong>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="meta-row">
|
| 73 |
+
<span>Time remaining</span>
|
| 74 |
+
<strong><%= expiresIn >= 3600 ? Math.ceil(expiresIn / 3600) + 'h' : Math.ceil(expiresIn / 60) + 'min' %></strong>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="meta-row">
|
| 77 |
+
<span>Reconnect?</span>
|
| 78 |
+
<strong>Enter code above on login page</strong>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</body>
|
| 82 |
+
</html>
|
src/views/screens/suspended.ejs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Subscription Required</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 11 |
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
display: flex; align-items: center; justify-content: center;
|
| 14 |
+
padding: 16px;
|
| 15 |
+
}
|
| 16 |
+
.card {
|
| 17 |
+
background: #fff;
|
| 18 |
+
border-radius: 20px;
|
| 19 |
+
padding: 40px 24px;
|
| 20 |
+
width: 100%; max-width: 360px;
|
| 21 |
+
text-align: center;
|
| 22 |
+
}
|
| 23 |
+
.icon { font-size: 56px; margin-bottom: 16px; }
|
| 24 |
+
h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 10px; }
|
| 25 |
+
.business { font-size: 15px; font-weight: 600; color: #0492eb; margin-bottom: 16px; }
|
| 26 |
+
p { color: #666; font-size: 14px; line-height: 1.6; }
|
| 27 |
+
.divider { border: none; border-top: 1px solid #eee; margin: 20px 0; }
|
| 28 |
+
.notice {
|
| 29 |
+
background: #fff8e1;
|
| 30 |
+
border: 1px solid #ffe082;
|
| 31 |
+
border-radius: 10px;
|
| 32 |
+
padding: 12px 16px;
|
| 33 |
+
font-size: 13px;
|
| 34 |
+
color: #7a6000;
|
| 35 |
+
line-height: 1.5;
|
| 36 |
+
}
|
| 37 |
+
</style>
|
| 38 |
+
</head>
|
| 39 |
+
<body>
|
| 40 |
+
<div class="card">
|
| 41 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 16px">
|
| 42 |
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
| 43 |
+
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
| 44 |
+
</svg>
|
| 45 |
+
<div class="business"><%= businessName %></div>
|
| 46 |
+
<h1>Subscription Required</h1>
|
| 47 |
+
<p>WiFi service at this location is temporarily unavailable.</p>
|
| 48 |
+
<hr class="divider">
|
| 49 |
+
<div class="notice">
|
| 50 |
+
If you are the owner of this hotspot, please renew your subscription to restore WiFi service for your customers.
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</body>
|
| 54 |
+
</html>
|
src/views/suspended.ejs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Subscription Required</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 11 |
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
display: flex; align-items: center; justify-content: center;
|
| 14 |
+
padding: 16px;
|
| 15 |
+
}
|
| 16 |
+
.card {
|
| 17 |
+
background: #fff;
|
| 18 |
+
border-radius: 20px;
|
| 19 |
+
padding: 40px 24px;
|
| 20 |
+
width: 100%; max-width: 360px;
|
| 21 |
+
text-align: center;
|
| 22 |
+
}
|
| 23 |
+
.icon { font-size: 56px; margin-bottom: 16px; }
|
| 24 |
+
h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 10px; }
|
| 25 |
+
.business { font-size: 15px; font-weight: 600; color: #0492eb; margin-bottom: 16px; }
|
| 26 |
+
p { color: #666; font-size: 14px; line-height: 1.6; }
|
| 27 |
+
.divider { border: none; border-top: 1px solid #eee; margin: 20px 0; }
|
| 28 |
+
.notice {
|
| 29 |
+
background: #fff8e1;
|
| 30 |
+
border: 1px solid #ffe082;
|
| 31 |
+
border-radius: 10px;
|
| 32 |
+
padding: 12px 16px;
|
| 33 |
+
font-size: 13px;
|
| 34 |
+
color: #7a6000;
|
| 35 |
+
line-height: 1.5;
|
| 36 |
+
}
|
| 37 |
+
</style>
|
| 38 |
+
</head>
|
| 39 |
+
<body>
|
| 40 |
+
<div class="card">
|
| 41 |
+
<div class="icon">🔒</div>
|
| 42 |
+
<div class="business"><%= businessName %></div>
|
| 43 |
+
<h1>Subscription Required</h1>
|
| 44 |
+
<p>WiFi service at this location is temporarily unavailable.</p>
|
| 45 |
+
<hr class="divider">
|
| 46 |
+
<div class="notice">
|
| 47 |
+
If you are the owner of this hotspot, please renew your subscription to restore WiFi service for your customers.
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</body>
|
| 51 |
+
</html>
|