diff --git a/webapp/src/routes/portal.js b/webapp/src/routes/portal.js new file mode 100644 index 0000000..bbbd3d1 --- /dev/null +++ b/webapp/src/routes/portal.js @@ -0,0 +1,160 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const supabase = require('../services/supabase'); +const { setAuditUser } = require('../services/audit'); +const { asyncHandler } = require('../middleware/security'); + +// ============================================================ +// SHIPPER PORTAL AUTH +// ============================================================ + +// GET /portal/login — shipper login page +router.get('/login', (req, res) => { + if (req.session.portalUser) { + return res.redirect('/portal/dashboard'); + } + res.render('pages/portal/login', { error: null, portal: 'shipper' }); +}); + +// POST /portal/login — shipper authenticate +router.post('/login', asyncHandler(async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.render('pages/portal/login', { error: 'Username and password are required', portal: 'shipper' }); + } + + // Look up shipper by username (phone number or email) + const { data: shipper, error } = await supabase + .from('portal_users') + .select('*') + .eq('username', username) + .eq('role', 'shipper') + .eq('is_active', true) + .single(); + + if (error || !shipper) { + return res.render('pages/portal/login', { error: 'Invalid credentials', portal: 'shipper' }); + } + + const valid = await bcrypt.compare(password, shipper.password_hash); + if (!valid) { + return res.render('pages/portal/login', { error: 'Invalid credentials', portal: 'shipper' }); + } + + // Set session + req.session.portalUser = { + id: shipper.id, + username: shipper.username, + role: 'shipper', + shipper_id: shipper.shipper_id, + }; + + // Set audit context + await setAuditUser(shipper.id); + + res.redirect('/portal/dashboard'); +})); + +// GET /portal/logout +router.get('/logout', (req, res) => { + req.session.portalUser = null; + res.redirect('/portal/login'); +}); + +// ============================================================ +// SHIPPER PORTAL DASHBOARD +// ============================================================ + +router.get('/dashboard', requirePortalAuth, asyncHandler(async (req, res) => { + const shipperId = req.session.portalUser.shipper_id; + + // Get shipper info + const { data: shipper } = await supabase + .from('shippers') + .select('*') + .eq('id', shipperId) + .single(); + + // Get loads for this shipper + const { data: loads } = await supabase + .from('loads') + .select('*, payments(*)') + .eq('shipper_id', shipperId) + .order('created_at', { ascending: false }) + .limit(50); + + // Calculate totals + const totalFreight = loads?.reduce((sum, l) => sum + (l.freight_charged || 0), 0) || 0; + const totalPaid = loads?.reduce((sum, l) => { + return sum + (l.payments?.reduce((p, pay) => p + (pay.amount || 0), 0) || 0); + }, 0) || 0; + const totalPending = totalFreight - totalPaid; + + res.render('pages/portal/shipper-dashboard', { + shipper, + loads: loads || [], + totalFreight, + totalPaid, + totalPending, + totalLoads: loads?.length || 0, + }); +})); + +// GET /portal/loads — all loads for this shipper +router.get('/loads', requirePortalAuth, asyncHandler(async (req, res) => { + const shipperId = req.session.portalUser.shipper_id; + const { status, page = 1 } = req.query; + const limit = 20; + const offset = (page - 1) * limit; + + let query = supabase + .from('loads') + .select('*, payments(*)', { count: 'exact' }) + .eq('shipper_id', shipperId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (status) query = query.eq('status', status); + + const { data: loads, count, error } = await query; + + res.render('pages/portal/shipper-loads', { + loads: loads || [], + page: parseInt(page), + totalPages: Math.ceil((count || 0) / limit), + total: count, + filters: { status }, + }); +})); + +// GET /portal/loads/:id — single load detail +router.get('/loads/:id', requirePortalAuth, asyncHandler(async (req, res) => { + const shipperId = req.session.portalUser.shipper_id; + + const { data: load, error } = await supabase + .from('loads') + .select('*, payments(*)') + .eq('id', req.params.id) + .eq('shipper_id', shipperId) + .single(); + + if (error) { + return res.status(404).render('pages/404'); + } + + res.render('pages/portal/shipper-load-detail', { load }); +})); + +// ============================================================ +// AUTH MIDDLEWARE +// ============================================================ +function requirePortalAuth(req, res, next) { + if (!req.session.portalUser) { + return res.redirect('/portal/login'); + } + next(); +} + +module.exports = router; diff --git a/webapp/src/server.js b/webapp/src/server.js index bc80c9e..31e48ee 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -205,6 +205,7 @@ 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() })); diff --git a/webapp/src/services/audit.js b/webapp/src/services/audit.js new file mode 100644 index 0000000..fc3528d --- /dev/null +++ b/webapp/src/services/audit.js @@ -0,0 +1,12 @@ +const supabase = require('./supabase'); + +async function setAuditUser(userId) { + if (!userId) return; + try { + await supabase.rpc('set_audit_user', { user_id: userId }); + } catch (e) { + // Audit function may not exist yet (migration not run) — silently ignore + } +} + +module.exports = { setAuditUser }; diff --git a/webapp/src/views/pages/portal/login.ejs b/webapp/src/views/pages/portal/login.ejs new file mode 100644 index 0000000..9bf3193 --- /dev/null +++ b/webapp/src/views/pages/portal/login.ejs @@ -0,0 +1,44 @@ + + +
+ + +Track your shipments and payments
+Welcome, <%= shipper.name %>
+No loads found.
+ <% } else { %> +| Date | +Route | +Vehicle | +Freight | +Status | +Paid | +
|---|---|---|---|---|---|
| <%= load.date || '—' %> | +<%= load.from_city || '?' %> → <%= load.to_city || '?' %> | +<%= load.vehicle_number || '—' %> | +<%= formatINR(load.freight_charged) %> | +<%= load.status %> | +<%= formatINR(load.payments?.reduce((s, p) => s + (p.amount || 0), 0)) %> | +
<%= load.id %>
+No payments recorded yet.
+ <% } else { %> +| Date | +Type | +Amount | +Reference | +
|---|---|---|---|
| <%= pay.date || '—' %> | +<%= pay.payment_type %> | +<%= formatINR(pay.amount) %> | +<%= pay.reference || '—' %> | +
All your freight loads
+No loads found.
+ <% } else { %> +| Date | +Route | +Vehicle | +Freight | +Status | +
|---|---|---|---|---|
| <%= load.date || '—' %> | +<%= load.from_city || '?' %> → <%= load.to_city || '?' %> | +<%= load.vehicle_number || '—' %> | +<%= formatINR(load.freight_charged) %> | +<%= load.status %> | +