Compare commits

..

2 commits

Author SHA1 Message Date
FreightDesk
071f759b8a [OWL] Wire setup route file, remove inline setup routes from server.js
Some checks are pending
FreightDesk CI/CD / Lint & Test (push) Waiting to run
FreightDesk CI/CD / Build Docker Image (push) Blocked by required conditions
FreightDesk CI/CD / Deploy to Coolify (push) Blocked by required conditions
2026-06-07 19:52:25 +00:00
FreightDesk
0da63ae676 [OWL] Roadmap batch: CI/CD, observability, testing, UX polish 2026-06-07 19:49:46 +00:00
15 changed files with 520 additions and 112 deletions

96
.github/workflows/deploy.yml vendored Normal file
View 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
View 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
View file

@ -0,0 +1,13 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"overrides": [
{
"files": ["*.ejs"],
"options": { "parser": "html" }
}
]
}

View file

@ -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"
]
} }
} }

View file

@ -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 (racecondition 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;

View file

@ -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`);
}); });

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

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

View file

@ -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">&nbsp;</label> <label class="form-label">&nbsp;</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') %>

View file

@ -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">

View file

@ -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">&#127760;</div>
<h1 class="login-title-hi"><%= appNameHi %></h1>
<h2 class="login-title-en"><%= appName %> — Initial Setup</h2>
<p class="login-tagline">Create your admin account to get started</p>
</div>
<% if (typeof error !== 'undefined' && error) { %> <% if (typeof error !== 'undefined' && error) { %>
<div class="alert alert-danger py-2 mb-3" role="alert"> <div class="alert alert-error"><%= error %></div>
<%= error %> <% } %>
</div>
<% } %>
<form action="/setup" method="POST" class="text-start"> <form method="POST" action="/setup" class="login-form">
<div class="mb-3"> <input type="hidden" name="_csrf" value="<%= _csrf %>">
<label class="form-label">Admin Username</label> <div class="form-group">
<input type="text" name="username" class="form-control" placeholder="e.g. admin_dispatcher" 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="mb-3">
<label class="form-label">Admin Password</label>
<input type="password" name="password" class="form-control" placeholder="Enter a strong password" required>
</div>
<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>

View file

@ -7,7 +7,7 @@
<p class="footer-muted">&copy; <%= year %> <%= appName %> (<%= appNameHi %>). All rights reserved.</p> <p class="footer-muted">&copy; <%= 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>

View file

@ -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">

View 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);
});
});

View 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');
});
});