[OWL] REST API layer + Portal user management
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

REST API (/api):
- Full CRUD for loads, shippers, vehicles, payments
- Dashboard stats endpoint (/api/stats)
- Pagination, filtering, sorting on all list endpoints
- Role-based access control on write operations
- Soft delete support

Portal User Management (/portal-users):
- Admin UI to create shipper/driver portal accounts
- Link portal users to existing shippers/drivers
- Enable/disable accounts
- Reset passwords
- Lists all portal accounts with status

Architecture decision documented: keeping EJS+React CDN widgets as primary
This commit is contained in:
FreightDesk 2026-06-08 00:49:23 +00:00
parent 7cee10cba8
commit 8e67cb98ae
6 changed files with 514 additions and 0 deletions

40
ARCHITECTURE_DECISION.md Normal file
View file

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

215
webapp/src/routes/api.js Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,143 @@
<%- include('../partials/header', { activeMenu: 'portal-users' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128101; Portal Users</h1>
<p class="page-subtitle">Manage shipper and driver portal access</p>
</div>
</div>
<!-- Create New Portal User -->
<% if (typeof availableShippers !== 'undefined') { %>
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Create Portal Account</h3>
</div>
<div class="card-body">
<form method="POST" action="/portal-users" class="row" style="gap:16px;align-items:flex-end;">
<div class="form-group" style="flex:2;">
<label class="form-label">Role</label>
<select name="role" class="form-input" id="roleSelect" onchange="toggleRoleSelects()" required>
<option value="">Select role...</option>
<option value="shipper">Shipper</option>
<option value="driver">Driver</option>
</select>
</div>
<div class="form-group" style="flex:2;">
<label class="form-label">Username / Phone</label>
<input type="text" name="username" class="form-input" required placeholder="Phone number or email">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Password</label>
<input type="text" name="password" class="form-input" required placeholder="Min 6 chars" minlength="6">
</div>
<div class="form-group" style="flex:2;">
<label class="form-label">Shipper</label>
<select name="shipper_id" class="form-input" id="shipperSelect" disabled>
<option value="">Select shipper...</option>
<% for (const s of availableShippers) { %>
<option value="<%= s.id %>"><%= s.name %></option>
<% } %>
</select>
</div>
<div class="form-group" style="flex:2;">
<label class="form-label">Driver / Vehicle</label>
<select name="driver_id" class="form-input" id="driverSelect" disabled>
<option value="">Select driver...</option>
<% for (const d of availableDrivers) { %>
<option value="<%= d.id %>"><%= d.number %> <%= d.driver_name ? '(' + d.driver_name + ')' : '' %></option>
<% } %>
</select>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<% } %>
<!-- Existing Portal Users -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Existing Portal Accounts (<%= users.length %>)</h3>
</div>
<div class="card-body">
<% if (users.length === 0) { %>
<p class="empty-state">No portal accounts created yet.</p>
<% } else { %>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Linked To</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% for (const user of users) { %>
<tr>
<td><strong><%= user.username %></strong></td>
<td><span class="badge badge-<%= user.role === 'shipper' ? 'blue' : 'green' %>"><%= user.role %></span></td>
<td>
<% if (user.role === 'shipper' && user.shipper) { %>
<%= user.shipper.name %>
<% } else if (user.role === 'driver' && user.driver) { %>
<%= user.driver.number %>
<% } else { %>
<span class="text-muted">—</span>
<% } %>
</td>
<td>
<span class="badge badge-<%= user.is_active ? 'success' : 'danger' %>">
<%= user.is_active ? 'Active' : 'Disabled' %>
</span>
</td>
<td><%= user.created_at ? new Date(user.created_at).toLocaleDateString('en-IN') : '—' %></td>
<td>
<button class="btn btn-sm btn-outline" onclick="toggleUser('<%= user.id %>', <%= user.is_active %>)">
<%= user.is_active ? 'Disable' : 'Enable' %>
</button>
<button class="btn btn-sm btn-outline" onclick="resetPassword('<%= user.id %>')">Reset PW</button>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<script>
function toggleRoleSelects() {
const role = document.getElementById('roleSelect').value;
document.getElementById('shipperSelect').disabled = role !== 'shipper';
document.getElementById('driverSelect').disabled = role !== 'driver';
if (role !== 'shipper') document.getElementById('shipperSelect').value = '';
if (role !== 'driver') document.getElementById('driverSelect').value = '';
}
async function toggleUser(id, currentStatus) {
if (!confirm(currentStatus ? 'Disable this portal account?' : 'Enable this portal account?')) return;
const res = await fetch(`/portal-users/${id}/toggle`, { method: 'PUT' });
if (res.ok) location.reload();
}
async function resetPassword(id) {
const pw = prompt('Enter new password (min 6 chars):');
if (!pw || pw.length < 6) return alert('Password must be at least 6 characters');
const res = await fetch(`/portal-users/${id}/reset-password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
if (res.ok) alert('Password reset successfully');
}
</script>
<%- include('../partials/footer') %>

View file

@ -40,6 +40,11 @@
<a href="/shippers" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'shippers' ? 'active' : '' %>">&#127970; Shippers</a>
<a href="/vehicles" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'vehicles' ? 'active' : '' %>">&#128666; Vehicles</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Client Portal</span>
<a href="/portal-users" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'portal-users' ? 'active' : '' %>">&#128101; Portal Users</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Reports</span>
<a href="/reports" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'reports' ? 'active' : '' %>">&#128200; Reports</a>