Compare commits
2 commits
f1c75faba1
...
071f759b8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071f759b8a | ||
|
|
0da63ae676 |
15 changed files with 520 additions and 112 deletions
96
.github/workflows/deploy.yml
vendored
Normal file
96
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
name: FreightDesk CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Lint & Test ──────────────────────────────────────────
|
||||||
|
test:
|
||||||
|
name: Lint & Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: webapp/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: ./webapp
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
working-directory: ./webapp
|
||||||
|
run: npm run lint --if-present
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: ./webapp
|
||||||
|
run: npm test --if-present
|
||||||
|
env:
|
||||||
|
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||||
|
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
working-directory: ./webapp
|
||||||
|
run: |
|
||||||
|
timeout 15 npm start &
|
||||||
|
sleep 5
|
||||||
|
curl -sf http://localhost:3000/health || exit 1
|
||||||
|
echo "✅ Smoke test passed"
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||||
|
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
|
||||||
|
SESSION_SECRET: test-secret
|
||||||
|
|
||||||
|
# ── Build & Push Docker Image ────────────────────────────
|
||||||
|
build:
|
||||||
|
name: Build Docker Image
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
working-directory: ./webapp
|
||||||
|
run: |
|
||||||
|
docker build -t freightdesk:${{ github.sha }} .
|
||||||
|
echo "✅ Docker image built"
|
||||||
|
|
||||||
|
- name: Tag image
|
||||||
|
run: |
|
||||||
|
docker tag freightdesk:${{ github.sha }} freightdesk:latest
|
||||||
|
echo "✅ Image tagged"
|
||||||
|
|
||||||
|
# ── Deploy to Coolify ────────────────────────────────────
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Coolify
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deployment
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.COOLIFY_WEBHOOK_URL }}" ]; then
|
||||||
|
curl -sf -X POST "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"sha": "${{ github.sha }}", "branch": "master"}'
|
||||||
|
echo "✅ Coolify deployment triggered"
|
||||||
|
else
|
||||||
|
echo "⚠️ COOLIFY_WEBHOOK_URL not set — skipping deployment"
|
||||||
|
fi
|
||||||
22
webapp/.eslintrc.json
Normal file
22
webapp/.eslintrc.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es2022": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"extends": ["eslint:recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
"no-unused-vars": ["warn", { "argsIgnorePattern": "^(next|req|res)$" }],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"no-console": ["warn", { "allow": ["error"] }],
|
||||||
|
"eqeqeq": "error",
|
||||||
|
"curly": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["public/", "node_modules/", "coverage/"]
|
||||||
|
}
|
||||||
13
webapp/.prettierrc.json
Normal file
13
webapp/.prettierrc.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ejs"],
|
||||||
|
"options": { "parser": "html" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -5,21 +5,44 @@
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "nodemon src/server.js",
|
||||||
"seed": "node seed.js"
|
"test": "jest --forceExit --detectOpenHandles",
|
||||||
|
"test:unit": "jest tests/unit --forceExit",
|
||||||
|
"test:integration": "jest tests/integration --forceExit --detectOpenHandles",
|
||||||
|
"lint": "eslint src/ --ext .js --max-warnings 0",
|
||||||
|
"format": "prettier --write 'src/**/*.js' 'src/**/*.ejs' 'src/**/*.css'"
|
||||||
},
|
},
|
||||||
"keywords": ["freight", "logistics", "commission", "agent", "india"],
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.45.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.3.1",
|
||||||
"ejs": "^3.1.9",
|
"ejs": "^3.1.9",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"express-session": "^1.18.0",
|
"express-session": "^1.17.3",
|
||||||
"helmet": "^7.1.0"
|
"helmet": "^7.1.0",
|
||||||
|
"pino": "^8.17.0",
|
||||||
|
"pino-http": "^9.0.0",
|
||||||
|
"prom-client": "^15.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"coverageDirectory": "coverage",
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"!src/server.js"
|
||||||
|
],
|
||||||
|
"testMatch": [
|
||||||
|
"tests/**/*.test.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,54 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const supabase = require('../services/supabase');
|
const supabase = require('../services/supabase');
|
||||||
|
const { asyncHandler } = require('../middleware/security');
|
||||||
|
|
||||||
|
// GET /setup — show wizard if no admin exists
|
||||||
|
router.get('/', asyncHandler(async (req, res) => {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('portal_users')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('role', 'admin');
|
||||||
|
|
||||||
|
if (count > 0) return res.redirect('/login');
|
||||||
|
|
||||||
// GET /setup – show wizard if no admin exists
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
const { count } = await supabase.from('portal_users').select('*', { count: 'exact', head: true }).eq('role', 'admin');
|
|
||||||
if (count > 0) return res.redirect('/login'); // admin already exists
|
|
||||||
res.render('pages/setup', { error: null });
|
res.render('pages/setup', { error: null });
|
||||||
});
|
}));
|
||||||
|
|
||||||
// POST /setup – create first admin securely
|
// POST /setup — create first admin securely (race-condition safe)
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', asyncHandler(async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
if (!username || !password) return res.render('pages/setup', { error: 'All fields are required' });
|
if (!username || !password) {
|
||||||
|
return res.render('pages/setup', { error: 'Username and password are required' });
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.render('pages/setup', { error: 'Password must be at least 6 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
// ensure admin does not already exist (race‑condition safety)
|
// Race-condition safety: double-check no admin exists
|
||||||
const { data: existing } = await supabase.from('portal_users').select('id').eq('role', 'admin').single();
|
const { data: existing } = await supabase
|
||||||
if (existing) return res.render('pages/setup', { error: 'Admin already configured' });
|
.from('portal_users')
|
||||||
|
.select('id')
|
||||||
|
.eq('role', 'admin')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.render('pages/setup', { error: 'Admin already configured' });
|
||||||
|
}
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
await supabase.from('portal_users').insert({
|
const { error } = await supabase.from('portal_users').insert({
|
||||||
username,
|
username,
|
||||||
password_hash: hash,
|
password_hash: hash,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
// redirect to login after creation
|
|
||||||
|
if (error) {
|
||||||
|
return res.render('pages/setup', { error: 'Failed to create admin: ' + error.message });
|
||||||
|
}
|
||||||
|
|
||||||
res.redirect('/login');
|
res.redirect('/login');
|
||||||
});
|
}));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ const session = require('express-session');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const pinoHttp = require('pino-http');
|
||||||
const config = require('./config/env');
|
const config = require('./config/env');
|
||||||
const supabase = require('./services/supabase');
|
const supabase = require('./services/supabase');
|
||||||
const { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler } = require('./middleware/security');
|
const logger = require('./services/logger');
|
||||||
|
const metrics = require('./services/metrics');
|
||||||
|
const { setupCSRF, validateCSRF, sanitizeBody, asyncHandler } = require('./middleware/security');
|
||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
const { formatINR, getStatusColor } = require('./lib/india');
|
const { formatINR, getStatusColor } = require('./lib/india');
|
||||||
|
|
||||||
|
|
@ -35,7 +38,9 @@ app.use(helmet({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
app.use(requestLogger);
|
|
||||||
|
// Pino HTTP logger (replaces requestLogger)
|
||||||
|
app.use(pinoHttp({ logger }));
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
app.use(rateLimit({
|
app.use(rateLimit({
|
||||||
|
|
@ -51,16 +56,20 @@ app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Static files
|
// Static files (ETag + 1day cache in production)
|
||||||
app.use(express.static(path.join(__dirname, 'public'), {
|
app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
maxAge: config.nodeEnv === 'production' ? '1d' : 0,
|
maxAge: config.nodeEnv === 'production' ? '1d' : 0,
|
||||||
etag: true,
|
etag: true,
|
||||||
|
lastModified: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// View engine
|
// View engine
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
|
||||||
|
// Cache-busting asset version (changes on restart)
|
||||||
|
const ASSET_VERSION = Date.now();
|
||||||
|
|
||||||
// Session
|
// Session
|
||||||
app.use(session({
|
app.use(session({
|
||||||
secret: config.session.secret,
|
secret: config.session.secret,
|
||||||
|
|
@ -88,6 +97,7 @@ app.use((req, res, next) => {
|
||||||
res.locals.getStatusColor = getStatusColor;
|
res.locals.getStatusColor = getStatusColor;
|
||||||
res.locals.year = new Date().getFullYear();
|
res.locals.year = new Date().getFullYear();
|
||||||
res.locals._csrf = req.session._csrf;
|
res.locals._csrf = req.session._csrf;
|
||||||
|
res.locals.assetVersion = ASSET_VERSION;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -144,48 +154,6 @@ app.get('/logout', (req, res) => {
|
||||||
res.redirect('/login');
|
res.redirect('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/setup', asyncHandler(async (req, res) => {
|
|
||||||
// Check if any user exists
|
|
||||||
const { count } = await supabase
|
|
||||||
.from('portal_users')
|
|
||||||
.select('*', { count: 'exact', head: true });
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
return res.redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.render('pages/setup', { error: null });
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.post('/setup', asyncHandler(async (req, res) => {
|
|
||||||
const { count } = await supabase
|
|
||||||
.from('portal_users')
|
|
||||||
.select('*', { count: 'exact', head: true });
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
return res.redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password } = req.body;
|
|
||||||
if (!username || !password || password.length < 6) {
|
|
||||||
return res.render('pages/setup', { error: 'Username required and password must be at least 6 characters' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 10);
|
|
||||||
const { error } = await supabase.from('portal_users').insert({
|
|
||||||
username,
|
|
||||||
password_hash: hash,
|
|
||||||
role: 'admin',
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return res.render('pages/setup', { error: 'Failed to create admin. ' + error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.redirect('/login');
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// API ROUTES (for React dashboard + WhatsApp parser)
|
// API ROUTES (for React dashboard + WhatsApp parser)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -230,6 +198,7 @@ app.get('/api/stats', requireAuth, asyncHandler(async (req, res) => {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
app.use('/', require('./routes/dashboard'));
|
app.use('/', require('./routes/dashboard'));
|
||||||
|
app.use('/setup', require('./routes/setup'));
|
||||||
app.use('/loads', require('./routes/loads'));
|
app.use('/loads', require('./routes/loads'));
|
||||||
app.use('/shippers', require('./routes/shippers'));
|
app.use('/shippers', require('./routes/shippers'));
|
||||||
app.use('/vehicles', require('./routes/vehicles'));
|
app.use('/vehicles', require('./routes/vehicles'));
|
||||||
|
|
@ -239,6 +208,17 @@ app.use('/reports', require('./routes/reports'));
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
|
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
|
||||||
|
|
||||||
|
// Prometheus metrics
|
||||||
|
app.get('/metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.set('Content-Type', metrics.register.contentType);
|
||||||
|
res.end(await metrics.register.metrics());
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to collect metrics');
|
||||||
|
res.status(500).end('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404);
|
res.status(404);
|
||||||
|
|
@ -247,15 +227,13 @@ app.use((req, res) => {
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(`[ERROR] ${req.method} ${req.url}:`, err.message);
|
req.log.error({ err, url: req.url, method: req.method }, 'Unhandled error');
|
||||||
if (config.nodeEnv === 'development') console.error(err.stack);
|
|
||||||
res.status(err.status || 500);
|
res.status(err.status || 500);
|
||||||
res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null });
|
res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null });
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = app.listen(config.port, '::', () => {
|
const server = app.listen(config.port, '::', () => {
|
||||||
console.log(`\n🚛 FreightDesk running at http://localhost:${config.port}`);
|
logger.info({ port: config.port, env: config.nodeEnv }, '🚛 FreightDesk started');
|
||||||
console.log(` Environment: ${config.nodeEnv}`);
|
|
||||||
console.log(` Press Ctrl+C to stop\n`);
|
console.log(` Press Ctrl+C to stop\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
11
webapp/src/services/logger.js
Normal file
11
webapp/src/services/logger.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
const pino = require('pino');
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
||||||
|
transport: process.env.NODE_ENV !== 'production'
|
||||||
|
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } }
|
||||||
|
: undefined,
|
||||||
|
base: { pid: process.pid, env: process.env.NODE_ENV },
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
37
webapp/src/services/metrics.js
Normal file
37
webapp/src/services/metrics.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
const client = require('prom-client');
|
||||||
|
|
||||||
|
// Create a Registry
|
||||||
|
const register = new client.Registry();
|
||||||
|
|
||||||
|
// Add default metrics (CPU, memory, etc.)
|
||||||
|
client.collectDefaultMetrics({ register });
|
||||||
|
|
||||||
|
// Custom metrics
|
||||||
|
const httpRequestDuration = new client.Histogram({
|
||||||
|
name: 'http_request_duration_seconds',
|
||||||
|
help: 'Duration of HTTP requests in seconds',
|
||||||
|
labelNames: ['method', 'route', 'status_code'],
|
||||||
|
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||||
|
});
|
||||||
|
register.registerMetric(httpRequestDuration);
|
||||||
|
|
||||||
|
const httpRequestTotal = new client.Counter({
|
||||||
|
name: 'http_requests_total',
|
||||||
|
help: 'Total number of HTTP requests',
|
||||||
|
labelNames: ['method', 'route', 'status_code'],
|
||||||
|
});
|
||||||
|
register.registerMetric(httpRequestTotal);
|
||||||
|
|
||||||
|
const activeLoads = new client.Gauge({
|
||||||
|
name: 'freightdesk_active_loads',
|
||||||
|
help: 'Number of active (non-settled) loads',
|
||||||
|
});
|
||||||
|
register.registerMetric(activeLoads);
|
||||||
|
|
||||||
|
const totalCommission = new client.Gauge({
|
||||||
|
name: 'freightdesk_total_commission',
|
||||||
|
help: 'Total commission earned (INR)',
|
||||||
|
});
|
||||||
|
register.registerMetric(totalCommission);
|
||||||
|
|
||||||
|
module.exports = { register, httpRequestDuration, httpRequestTotal, activeLoads, totalCommission };
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="GET" action="/loads" class="filter-bar">
|
<form method="GET" action="/loads" class="filter-bar" id="filterForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Status</label>
|
<label class="form-label">Status</label>
|
||||||
<select name="status" class="form-input" onchange="this.form.submit()">
|
<select name="status" class="form-input" onchange="this.form.submit()">
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Search</label>
|
<label class="form-label">Search</label>
|
||||||
<input type="text" name="search" class="form-input" placeholder="City, notes..." value="<%= filters.search || '' %>">
|
<input type="text" name="search" class="form-input" placeholder="City, notes..." value="<%= filters.search || '' %>" id="searchInput" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label"> </label>
|
<label class="form-label"> </label>
|
||||||
|
|
@ -38,11 +38,14 @@
|
||||||
<!-- Loads Table -->
|
<!-- Loads Table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div id="loadingSpinner" class="empty-state" style="display:none;">
|
||||||
|
<span>Searching...</span>
|
||||||
|
</div>
|
||||||
<% if (loads.length === 0) { %>
|
<% if (loads.length === 0) { %>
|
||||||
<p class="empty-state">No loads found. <a href="/loads/new">Add your first load</a></p>
|
<p class="empty-state">No loads found. <a href="/loads/new">Add your first load</a></p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table">
|
<table class="table" id="loadsTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
|
@ -78,4 +81,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Debounced search — submits form 400ms after user stops typing
|
||||||
|
(function() {
|
||||||
|
var searchInput = document.getElementById('searchInput');
|
||||||
|
var form = document.getElementById('filterForm');
|
||||||
|
var timer;
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(function() {
|
||||||
|
form.submit();
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<%- include('../partials/footer') %>
|
<%- include('../partials/footer') %>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>">
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-page">
|
<body class="auth-page">
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,46 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FreightDesk | Admin Setup</title>
|
<title>Setup — <%= appName %></title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<link rel="stylesheet" href="/css/style.css?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>">
|
||||||
body { background: #f8f9fa; height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.setup-card { width: 100%; max-width: 450px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); border: none; border-radius: 15px; }
|
|
||||||
.btn-primary { background: #0d6efd; border: none; border-radius: 8px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="auth-page">
|
||||||
<div class="setup-card card p-4">
|
<div class="login-page">
|
||||||
<div class="card-body text-center">
|
<div class="login-container">
|
||||||
<h3 class="mb-3">Welcome to FreightDesk</h3>
|
<div class="login-header">
|
||||||
<p class="text-muted mb-4">No administrator account found. Please create your first admin account to get started.</p>
|
<div class="login-emblem">🌐</div>
|
||||||
|
<h1 class="login-title-hi"><%= appNameHi %></h1>
|
||||||
<% if (typeof error !== 'undefined' && error) { %>
|
<h2 class="login-title-en"><%= appName %> — Initial Setup</h2>
|
||||||
<div class="alert alert-danger py-2 mb-3" role="alert">
|
<p class="login-tagline">Create your admin account to get started</p>
|
||||||
<%= error %>
|
</div>
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<form action="/setup" method="POST" class="text-start">
|
<% if (typeof error !== 'undefined' && error) { %>
|
||||||
<div class="mb-3">
|
<div class="alert alert-error"><%= error %></div>
|
||||||
<label class="form-label">Admin Username</label>
|
<% } %>
|
||||||
<input type="text" name="username" class="form-control" placeholder="e.g. admin_dispatcher" required>
|
|
||||||
</div>
|
<form method="POST" action="/setup" class="login-form">
|
||||||
<div class="mb-3">
|
<input type="hidden" name="_csrf" value="<%= _csrf %>">
|
||||||
<label class="form-label">Admin Password</label>
|
<div class="form-group">
|
||||||
<input type="password" name="password" class="form-control" placeholder="Enter a strong password" required>
|
<label class="form-label">Admin Username</label>
|
||||||
</div>
|
<input type="text" name="username" class="form-input" required autofocus placeholder="Choose a username" minlength="3">
|
||||||
<div class="d-grid">
|
|
||||||
<button type="submit" class="btn btn-primary py-2">Create Admin Account</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Admin Password</label>
|
||||||
|
<input type="password" name="password" class="form-input" required placeholder="Choose a strong password" minlength="6">
|
||||||
|
<p class="text-muted" style="font-size:12px;margin-top:4px;">Minimum 6 characters</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Create Admin Account</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<div class="footer-tricolor"><span></span><span></span><span></span></div>
|
||||||
|
<p>Secured by Government of India</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/js/app.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<p class="footer-muted">© <%= year %> <%= appName %> (<%= appNameHi %>). All rights reserved.</p>
|
<p class="footer-muted">© <%= year %> <%= appName %> (<%= appNameHi %>). All rights reserved.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>"></script>
|
||||||
<% if (typeof extraJs !== 'undefined') { %>
|
<% if (typeof extraJs !== 'undefined') { %>
|
||||||
<% for (const js of extraJs) { %>
|
<% for (const js of extraJs) { %>
|
||||||
<script src="<%= js %>"></script>
|
<script src="<%= js %>"></script>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Devanagari:wght@400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : '1' %>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="topbar">
|
<nav class="topbar">
|
||||||
|
|
|
||||||
77
webapp/tests/integration/app.test.js
Normal file
77
webapp/tests/integration/app.test.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../../src/server');
|
||||||
|
|
||||||
|
describe('Health Check', () => {
|
||||||
|
test('GET /health returns 200', async () => {
|
||||||
|
const res = await request(app).get('/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.ts).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Metrics Endpoint', () => {
|
||||||
|
test('GET /metrics returns 200 with prometheus format', async () => {
|
||||||
|
const res = await request(app).get('/metrics');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('http_requests_total');
|
||||||
|
expect(res.text).toContain('process_cpu_user_seconds');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auth Flow', () => {
|
||||||
|
test('GET /login returns 200', async () => {
|
||||||
|
const res = await request(app).get('/login');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('Login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /setup returns 200 when no users exist', async () => {
|
||||||
|
const res = await request(app).get('/setup');
|
||||||
|
// Should either show setup form or redirect to login
|
||||||
|
expect([200, 302]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /login with empty body returns 200 with error', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/login')
|
||||||
|
.send({})
|
||||||
|
.set('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET / redirects to /login when not authenticated', async () => {
|
||||||
|
const res = await request(app).get('/');
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.location).toBe('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Protected Routes', () => {
|
||||||
|
test('GET /loads redirects to login', async () => {
|
||||||
|
const res = await request(app).get('/loads');
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /shippers redirects to login', async () => {
|
||||||
|
const res = await request(app).get('/shippers');
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /payments redirects to login', async () => {
|
||||||
|
const res = await request(app).get('/payments');
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /reports redirects to login', async () => {
|
||||||
|
const res = await request(app).get('/reports');
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('404 Handler', () => {
|
||||||
|
test('GET /nonexistent returns 404', async () => {
|
||||||
|
const res = await request(app).get('/nonexistent-page');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
webapp/tests/unit/utils.test.js
Normal file
105
webapp/tests/unit/utils.test.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
const { formatINR, getStatusColor, calcCommission, calcPendingFromShipper, calcPendingToDriver } = require('../../src/lib/india');
|
||||||
|
const { parseWhatsAppMessage } = require('../../src/services/parser');
|
||||||
|
|
||||||
|
describe('India Utils — formatINR', () => {
|
||||||
|
test('formats whole numbers', () => {
|
||||||
|
expect(formatINR(1000)).toBe('₹1,000');
|
||||||
|
expect(formatINR(100000)).toBe('₹1,00,000');
|
||||||
|
expect(formatINR(10000000)).toBe('₹1,00,00,000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats decimals', () => {
|
||||||
|
expect(formatINR(1999.50)).toBe('₹1,999.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles zero', () => {
|
||||||
|
expect(formatINR(0)).toBe('₹0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null/undefined', () => {
|
||||||
|
expect(formatINR(null)).toBe('—');
|
||||||
|
expect(formatINR(undefined)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('India Utils — getStatusColor', () => {
|
||||||
|
test('returns correct badge colors', () => {
|
||||||
|
expect(getStatusColor('settled')).toBe('green');
|
||||||
|
expect(getStatusColor('completed')).toBe('green');
|
||||||
|
expect(getStatusColor('loaded / in transit')).toBe('blue');
|
||||||
|
expect(getStatusColor('pending collection')).toBe('orange');
|
||||||
|
expect(getStatusColor('cancelled')).toBe('red');
|
||||||
|
expect(getStatusColor('unknown')).toBe('gray');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('India Utils — calcCommission', () => {
|
||||||
|
test('calculates commission correctly', () => {
|
||||||
|
expect(calcCommission(19000, 15900)).toBe(3100);
|
||||||
|
expect(calcCommission(50000, 45000)).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for missing values', () => {
|
||||||
|
expect(calcCommission(null, 100)).toBeNull();
|
||||||
|
expect(calcCommission(100, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('India Utils — calcPendingFromShipper', () => {
|
||||||
|
test('calculates pending correctly', () => {
|
||||||
|
expect(calcPendingFromShipper(19000, 5000)).toBe(14000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for missing values', () => {
|
||||||
|
expect(calcPendingFromShipper(null, 100)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('India Utils — calcPendingToDriver', () => {
|
||||||
|
test('calculates pending correctly', () => {
|
||||||
|
expect(calcPendingToDriver(15900, 10000)).toBe(5900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhatsApp Parser', () => {
|
||||||
|
test('parses a standard message', () => {
|
||||||
|
const msg = 'Agarwal Bangalore TN39DV8142 loaded 19000 freight driver advance 15900';
|
||||||
|
const result = parseWhatsAppMessage(msg);
|
||||||
|
|
||||||
|
expect(result.shipper).toBe('Agarwal Packers and Movers');
|
||||||
|
expect(result.to_city).toBe('Bangalore');
|
||||||
|
expect(result.vehicle).toBe('TN39DV8142');
|
||||||
|
expect(result.freight_charged).toBe(19000);
|
||||||
|
expect(result.paid_to_driver).toBe(15900);
|
||||||
|
expect(result.commission).toBe(3100);
|
||||||
|
expect(result.confidence).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses message with Mumbai route', () => {
|
||||||
|
const msg = 'Kahn Transport Mumbai MH12AB1234 loaded 45000 freight advance 40000';
|
||||||
|
const result = parseWhatsAppMessage(msg);
|
||||||
|
|
||||||
|
expect(result.shipper).toBe('Kahn Transport');
|
||||||
|
expect(result.to_city).toBe('Mumbai');
|
||||||
|
expect(result.freight_charged).toBe(45000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses status keywords', () => {
|
||||||
|
const msg1 = 'Agarwal Chennai TN39DV8142 loaded 20000 freight';
|
||||||
|
expect(parseWhatsAppMessage(msg1).status).toBe('loaded / in transit');
|
||||||
|
|
||||||
|
const msg2 = 'Agarwal Chennai TN39DV8142 delivered 20000 freight';
|
||||||
|
expect(parseWhatsAppMessage(msg2).status).toBe('delivered / pending collection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty message', () => {
|
||||||
|
const result = parseWhatsAppMessage('');
|
||||||
|
expect(result.confidence).toBe('low');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts vehicle number', () => {
|
||||||
|
const msg = 'Some text KL01AB1234 more text';
|
||||||
|
const result = parseWhatsAppMessage(msg);
|
||||||
|
expect(result.vehicle).toBe('KL01AB1234');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue