- 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
250 lines
7.6 KiB
JavaScript
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;
|