freightdesk/webapp/src/server.js
FreightDesk 63ed6c445f
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
[OWL] Client portal: shipper login + dashboard + load views
- Shipper portal auth (login/logout with bcrypt sessions)
- Shipper dashboard (stats: total loads, freight, paid, pending)
- Shipper load list (filterable by status, paginated)
- Shipper load detail (with payment history)
- Audit service helper (setAuditUser for session context)
- Wire /portal route into server.js
2026-06-07 20:05:52 +00:00

250 lines
7.6 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const path = require('path');
const helmet = require('helmet');
const compression = require('compression');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const bcrypt = require('bcryptjs');
const pinoHttp = require('pino-http');
const config = require('./config/env');
const supabase = require('./services/supabase');
const logger = require('./services/logger');
const metrics = require('./services/metrics');
const { setupCSRF, validateCSRF, sanitizeBody, asyncHandler } = require('./middleware/security');
const { requireAuth } = require('./middleware/auth');
const { formatINR, getStatusColor } = require('./lib/india');
const app = express();
// Trust proxy
app.set('trust proxy', 1);
// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://unpkg.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://react.dev"],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
}));
app.use(compression());
// Pino HTTP logger (replaces requestLogger)
app.use(pinoHttp({ logger }));
// Rate limiting
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many requests, please try again later.',
}));
// Body parsing
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(cookieParser());
// Static files (ETag + 1day cache in production)
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: config.nodeEnv === 'production' ? '1d' : 0,
etag: true,
lastModified: true,
}));
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Cache-busting asset version (changes on restart)
const ASSET_VERSION = Date.now();
// Session
app.use(session({
secret: config.session.secret,
resave: false,
saveUninitialized: false,
cookie: {
secure: config.nodeEnv === 'production',
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
name: 'fd.sid',
}));
// CSRF
app.use(setupCSRF);
app.use(sanitizeBody);
// Make helpers available to all views
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
res.locals.appName = 'FreightDesk';
res.locals.appNameHi = 'फ्रेटडेस्क';
res.locals.formatINR = formatINR;
res.locals.getStatusColor = getStatusColor;
res.locals.year = new Date().getFullYear();
res.locals._csrf = req.session._csrf;
res.locals.assetVersion = ASSET_VERSION;
next();
});
// CSRF validation for POST/PUT/DELETE
app.use(validateCSRF);
// ============================================================
// AUTH ROUTES (public)
// ============================================================
app.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('pages/login', { error: null });
});
app.post('/login', asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.render('pages/login', { error: 'Username and password required' });
}
const { data: user } = await supabase
.from('portal_users')
.select('*')
.eq('username', username)
.eq('is_active', true)
.single();
if (!user) {
return res.render('pages/login', { error: 'Invalid username or password' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.render('pages/login', { error: 'Invalid username or password' });
}
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
entity_id: user.entity_id,
};
await supabase.from('portal_users').update({ last_login: new Date().toISOString() }).eq('id', user.id);
// Redirect based on role
if (user.role === 'admin') return res.redirect('/');
return res.redirect('/');
}));
app.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/login');
});
// ============================================================
// API ROUTES (for React dashboard + WhatsApp parser)
// ============================================================
// WhatsApp parser API
app.post('/api/parse-whatsapp', requireAuth, asyncHandler(async (req, res) => {
const { parseWhatsAppMessage } = require('./services/parser');
const { message } = req.body;
if (!message) return res.json({ error: 'No message provided' });
const result = parseWhatsAppMessage(message);
res.json(result);
}));
// Dashboard stats API
app.get('/api/stats', requireAuth, asyncHandler(async (req, res) => {
const { data: loads } = await supabase.from('loads').select('*');
const allLoads = loads || [];
const monthly = {};
for (const l of allLoads) {
if (!l.date) continue;
const d = new Date(l.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!monthly[key]) monthly[key] = { freight: 0, commission: 0, count: 0 };
monthly[key].freight += l.freight_charged || 0;
monthly[key].commission += l.commission || 0;
monthly[key].count++;
}
res.json({
totalFreight: allLoads.reduce((s, l) => s + (l.freight_charged || 0), 0),
totalCommission: allLoads.reduce((s, l) => s + (l.commission || 0), 0),
totalPending: allLoads.reduce((s, l) => s + (l.pending_from_shipper || 0), 0),
settledCount: allLoads.filter(l => ['settled', 'completed', 'commission received', 'reconciled'].includes(l.status)).length,
totalLoads: allLoads.length,
monthly: Object.entries(monthly).sort(([a], [b]) => a.localeCompare(b)),
});
}));
// ============================================================
// PAGE ROUTES (protected)
// ============================================================
app.use('/', require('./routes/dashboard'));
app.use('/setup', require('./routes/setup'));
app.use('/loads', require('./routes/loads'));
app.use('/shippers', require('./routes/shippers'));
app.use('/vehicles', require('./routes/vehicles'));
app.use('/payments', require('./routes/payments'));
app.use('/reports', require('./routes/reports'));
app.use('/audit-logs', require('./routes/audit'));
app.use('/portal', require('./routes/portal'));
// Health check
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
app.use((req, res) => {
res.status(404);
res.render('pages/404');
});
// Error handler
app.use((err, req, res, next) => {
req.log.error({ err, url: req.url, method: req.method }, 'Unhandled error');
res.status(err.status || 500);
res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null });
});
const server = app.listen(config.port, '::', () => {
logger.info({ port: config.port, env: config.nodeEnv }, '🚛 FreightDesk started');
console.log(` Press Ctrl+C to stop\n`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
server.close(() => {
console.log('Server closed.');
process.exit(0);
});
});
module.exports = app;