diff --git a/ARCHITECTURE_DECISION.md b/ARCHITECTURE_DECISION.md new file mode 100644 index 0000000..8a2e7d7 --- /dev/null +++ b/ARCHITECTURE_DECISION.md @@ -0,0 +1,40 @@ +# FreightDesk — Final Architecture Decision + +**Date:** 2026-06-07 +**Status:** Active Discussion +**Participants:** OWL (owl-alpha), Hermes (default) + +## Decision + +**Keep EJS server-rendered + React CDN widgets** as the primary architecture. + +## Rationale + +| Factor | Assessment | +|--------|------------| +| **Project scope** | Single freight agent, simple CRUD, one VPS. Not a multi-tenant SaaS. | +| **Time to complete** | EJS+widgets is 90% done. SPA would take weeks of parallel work. | +| **Deployment simplicity** | One codebase, one build, one Coolify deploy. | +| **Security** | Server-side sessions (HttpOnly cookies) > client-side Supabase keys in browser | +| **Feature completeness** | EJS version has audit, portal, CI/CD, tests, observability. SPA branch deletes these. | +| **Code quality** | EJS code is reviewed and tested. React code has bugs (documented in ARCHITECTURE.md). | +| **Maintenance** | Both agents can work on same EJS views without merge conflicts. | +| **Future migration** | Can migrate to SPA later if needed. EJS views can coexist with React widgets during transition. | + +## What We Keep from Hermes' Suggestions + +1. **Shared service layer** — Good idea. Create `services/` modules that both frontends can consume. +2. **REST API layer** — Build JSON API endpoints alongside EJS views. Makes future SPA migration possible. +3. **Client portal** — Shipper/driver portal done in EJS with server-side auth. No need for React here. +4. **Audit logging** — Cherry-picked from Hermes' branch, already on master (migration 004). + +## What We Build Next + +1. **API layer** — REST endpoints for loads, shippers, vehicles, payments (JSON) +2. **Email notifications** — Load status updates via email +3. **Portal user management** — Admin UI to create shipper/driver portal accounts +4. **Invoice PDF polish** — Better templates, batch invoice generation +5. **Dashboard charts** — Recharts via CDN for visual analytics +6. **WhatsApp parser improvements** — Better regex, support more message formats +7. **Mobile responsiveness** — Ensure all views work well on phone screens +8. **i18n** — Hindi + Malayalam language support diff --git a/webapp/src/routes/api.js b/webapp/src/routes/api.js new file mode 100644 index 0000000..0be9633 --- /dev/null +++ b/webapp/src/routes/api.js @@ -0,0 +1,215 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requireRole } = require('../middleware/auth'); +const supabase = require('../services/supabase'); +const { asyncHandler } = require('../middleware/security'); + +// All API routes require authentication +router.use(requireAuth); + +// ============================================================ +// LOADS API +// ============================================================ + +// GET /api/loads — list loads with filters +router.get('/loads', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => { + const { status, shipper_id, page = 1, limit = 50, sort = 'created_at', order = 'desc' } = req.query; + const offset = (page - 1) * limit; + + let query = supabase + .from('loads') + .select('*, shipper:shippers(name), vehicle:vehicles(number), payments(*)', { count: 'exact' }) + .order(sort, { ascending: order === 'asc' }) + .range(offset, offset + parseInt(limit) - 1); + + if (status) query = query.eq('status', status); + if (shipper_id) query = query.eq('shipper_id', shipper_id); + + const { data, count, error } = await query; + if (error) return res.status(500).json({ error: error.message }); + + res.json({ + data: data || [], + pagination: { page: parseInt(page), limit: parseInt(limit), total: count, pages: Math.ceil((count || 0) / limit) }, + }); +})); + +// GET /api/loads/:id — single load +router.get('/loads/:id', asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('loads') + .select('*, shipper:shippers(*), vehicle:vehicles(*), payments(*)') + .eq('id', req.params.id).single(); + if (error) return res.status(404).json({ error: 'Load not found' }); + res.json(data); +})); + +// POST /api/loads — create load +router.post('/loads', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => { + const load = { ...req.body, created_by: req.session?.userId }; + const { data, error } = await supabase.from('loads').insert(load).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.status(201).json(data); +})); + +// PUT /api/loads/:id — update load +router.put('/loads/:id', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('loads').update(req.body).eq('id', req.params.id).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.json(data); +})); + +// DELETE /api/loads/:id — soft delete +router.delete('/loads/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => { + const { error } = await supabase.from('loads').update({ deleted_at: new Date().toISOString() }).eq('id', req.params.id); + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true }); +})); + +// ============================================================ +// SHIPPERS API +// ============================================================ + +router.get('/shippers', asyncHandler(async (req, res) => { + const { search, page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + let query = supabase.from('shippers').select('*', { count: 'exact' }).order('name').range(offset, offset + parseInt(limit) - 1); + if (search) query = query.or(`name.ilike.%${search}%,phone.ilike.%${search}%,city.ilike.%${search}%`); + + const { data, count, error } = await query; + if (error) return res.status(500).json({ error: error.message }); + res.json({ data: data || [], pagination: { page: parseInt(page), limit: parseInt(limit), total: count } }); +})); + +router.get('/shippers/:id', asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('shippers').select('*, loads(*, payments(*))').eq('id', req.params.id).single(); + if (error) return res.status(404).json({ error: 'Shipper not found' }); + res.json(data); +})); + +router.post('/shippers', requireRole('admin', 'manager'), asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('shippers').insert(req.body).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.status(201).json(data); +})); + +router.put('/shippers/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('shippers').update(req.body).eq('id', req.params.id).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.json(data); +})); + +router.delete('/shippers/:id', requireRole('admin'), asyncHandler(async (req, res) => { + const { error } = await supabase.from('shippers').update({ deleted_at: new Date().toISOString() }).eq('id', req.params.id); + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true }); +})); + +// ============================================================ +// VEHICLES API +// ============================================================ + +router.get('/vehicles', asyncHandler(async (req, res) => { + const { search, page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + let query = supabase.from('vehicles').select('*', { count: 'exact' }).order('number').range(offset, offset + parseInt(limit) - 1); + if (search) query = query.or(`number.ilike.%${search}%,driver_name.ilike.%${search}%`); + + const { data, count, error } = await query; + if (error) return res.status(500).json({ error: error.message }); + res.json({ data: data || [], pagination: { page: parseInt(page), limit: parseInt(limit), total: count } }); +})); + +router.get('/vehicles/:id', asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('vehicles').select('*, loads(*)').eq('id', req.params.id).single(); + if (error) return res.status(404).json({ error: 'Vehicle not found' }); + res.json(data); +})); + +router.post('/vehicles', requireRole('admin', 'manager'), asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('vehicles').insert(req.body).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.status(201).json(data); +})); + +router.put('/vehicles/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('vehicles').update(req.body).eq('id', req.params.id).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.json(data); +})); + +router.delete('/vehicles/:id', requireRole('admin'), asyncHandler(async (req, res) => { + const { error } = await supabase.from('vehicles').update({ deleted_at: new Date().toISOString() }).eq('id', req.params.id); + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true }); +})); + +// ============================================================ +// PAYMENTS API +// ============================================================ + +router.get('/payments', asyncHandler(async (req, res) => { + const { load_id, type, page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + let query = supabase.from('payments').select('*, load:loads(from_city, to_city, shipper:shippers(name))', { count: 'exact' }) + .order('date', { ascending: false }).range(offset, offset + parseInt(limit) - 1); + if (load_id) query = query.eq('load_id', load_id); + if (type) query = query.eq('payment_type', type); + + const { data, count, error } = await query; + if (error) return res.status(500).json({ error: error.message }); + res.json({ data: data || [], pagination: { page: parseInt(page), limit: parseInt(limit), total: count } }); +})); + +router.post('/payments', requireRole('admin', 'manager', 'operator'), asyncHandler(async (req, res) => { + const { data, error } = await supabase.from('payments').insert(req.body).select().single(); + if (error) return res.status(400).json({ error: error.message }); + res.status(201).json(data); +})); + +router.delete('/payments/:id', requireRole('admin', 'manager'), asyncHandler(async (req, res) => { + const { error } = await supabase.from('payments').delete().eq('id', req.params.id); + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true }); +})); + +// ============================================================ +// DASHBOARD STATS API +// ============================================================ + +router.get('/stats', asyncHandler(async (req, res) => { + const [ + { count: totalLoads }, + { data: loadsData }, + { count: totalShippers }, + { count: totalVehicles }, + ] = await Promise.all([ + supabase.from('loads').select('*', { count: 'exact', head: true }), + supabase.from('loads').select('freight_charged, commission, status, payments(amount, payment_type)'), + supabase.from('shippers').select('*', { count: 'exact', head: true }), + supabase.from('vehicles').select('*', { count: 'exact', head: true }), + ]); + + const totalFreight = loadsData?.reduce((s, l) => s + (l.freight_charged || 0), 0) || 0; + const totalCommission = loadsData?.reduce((s, l) => s + (l.commission || 0), 0) || 0; + const shipperPaid = loadsData?.reduce((s, l) => s + (l.payments?.filter(p => p.payment_type === 'credit').reduce((p, pay) => p + (pay.amount || 0), 0)), 0) || 0; + const shipperPending = totalFreight - shipperPaid; + + const statusCounts = {}; + loadsData?.forEach(l => { statusCounts[l.status] = (statusCounts[l.status] || 0) + 1; }); + + res.json({ + totalLoads: totalLoads || 0, + totalShippers: totalShippers || 0, + totalVehicles: totalVehicles || 0, + totalFreight, + totalCommission, + shipperPaid, + shipperPending, + statusCounts, + }); +})); + +module.exports = router; diff --git a/webapp/src/routes/portal-users.js b/webapp/src/routes/portal-users.js new file mode 100644 index 0000000..f40796f --- /dev/null +++ b/webapp/src/routes/portal-users.js @@ -0,0 +1,109 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const supabase = require('../services/supabase'); +const { requireAuth, requireRole } = require('../middleware/auth'); +const { asyncHandler } = require('../middleware/security'); + +router.use(requireAuth); +router.use(requireRole('admin', 'manager')); + +// GET /portal-users — list all portal users +router.get('/', asyncHandler(async (req, res) => { + const { data: users, error } = await supabase + .from('portal_users') + .select('*, shipper:shippers(name), driver:vehicles(number)') + .order('created_at', { ascending: false }); + + if (error) return res.status(500).json({ error: error.message }); + + // Get shippers/drivers without portal accounts for the "create" dropdown + const { data: allShippers } = await supabase.from('shippers').select('id, name').order('name'); + const { data: allVehicles } = await supabase.from('vehicles').select('id, number, driver_name').order('number'); + + // Filter to only those without portal accounts + const existingShipperIds = users?.filter(u => u.role === 'shipper').map(u => u.shipper_id) || []; + const existingDriverIds = users?.filter(u => u.role === 'driver').map(u => u.driver_id) || []; + + res.render('pages/portal-users/list', { + users: users || [], + availableShippers: (allShippers || []).filter(s => !existingShipperIds.includes(s.id)), + availableDrivers: (allVehicles || []).filter(v => !existingDriverIds.includes(v.id)), + }); +})); + +// POST /portal-users — create portal user +router.post('/', asyncHandler(async (req, res) => { + const { username, password, role, shipper_id, driver_id } = req.body; + + if (!username || !password || !role) { + return res.status(400).json({ error: 'Username, password, and role are required' }); + } + if (!['shipper', 'driver'].includes(role)) { + return res.status(400).json({ error: 'Role must be shipper or driver' }); + } + if (role === 'shipper' && !shipper_id) { + return res.status(400).json({ error: 'Shipper must be selected' }); + } + if (role === 'driver' && !driver_id) { + return res.status(400).json({ error: 'Driver/Vehicle must be selected' }); + } + + const password_hash = await bcrypt.hash(password, 12); + + const { data, error } = await supabase.from('portal_users').insert({ + username, + password_hash, + role, + shipper_id: role === 'shipper' ? shipper_id : null, + driver_id: role === 'driver' ? driver_id : null, + is_active: true, + }).select().single(); + + if (error) { + if (error.code === '23505') { + return res.status(400).json({ error: 'Username already exists' }); + } + return res.status(400).json({ error: error.message }); + } + + res.redirect('/portal-users'); +})); + +// PUT /portal-users/:id/toggle — enable/disable portal user +router.put('/:id/toggle', asyncHandler(async (req, res) => { + const { data: user } = await supabase.from('portal_users').select('is_active').eq('id', req.params.id).single(); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const { error } = await supabase.from('portal_users') + .update({ is_active: !user.is_active }) + .eq('id', req.params.id); + + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true, is_active: !user.is_active }); +})); + +// PUT /portal-users/:id/reset-password — reset password +router.put('/:id/reset-password', asyncHandler(async (req, res) => { + const { password } = req.body; + if (!password || password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + const password_hash = await bcrypt.hash(password, 12); + const { error } = await supabase.from('portal_users') + .update({ password_hash }) + .eq('id', req.params.id); + + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true }); +})); + +// DELETE /portal-users/:id — delete portal user +router.delete('/:id', requireRole('admin'), asyncHandler(async (req, res) => { + const { error } = await supabase.from('portal_users').delete().eq('id', req.params.id); + if (error) return res.status(400).json({ error: error.message }); + res.json({ success: true }); +})); + +module.exports = router; diff --git a/webapp/src/server.js b/webapp/src/server.js index 743edbe..fad7b41 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -207,6 +207,8 @@ app.use('/reports', require('./routes/reports')); app.use('/audit-logs', require('./routes/audit')); app.use('/portal', require('./routes/portal')); app.use('/invoices', require('./routes/invoices')); +app.use('/portal-users', require('./routes/portal-users')); +app.use('/api', require('./routes/api')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); diff --git a/webapp/src/views/pages/portal-users/list.ejs b/webapp/src/views/pages/portal-users/list.ejs new file mode 100644 index 0000000..01f8312 --- /dev/null +++ b/webapp/src/views/pages/portal-users/list.ejs @@ -0,0 +1,143 @@ +<%- include('../partials/header', { activeMenu: 'portal-users' }) %> + +
Manage shipper and driver portal access
+No portal accounts created yet.
+ <% } else { %> +| Username | +Role | +Linked To | +Status | +Created | +Actions | +
|---|---|---|---|---|---|
| <%= user.username %> | +<%= user.role %> | ++ <% if (user.role === 'shipper' && user.shipper) { %> + <%= user.shipper.name %> + <% } else if (user.role === 'driver' && user.driver) { %> + <%= user.driver.number %> + <% } else { %> + — + <% } %> + | ++ + <%= user.is_active ? 'Active' : 'Disabled' %> + + | +<%= user.created_at ? new Date(user.created_at).toLocaleDateString('en-IN') : '—' %> | ++ + + | +