[OWL] Admin moderation panel + deployment docs
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

Admin Moderation (/admin/moderation):
- Dashboard: pending shipper/driver verifications, payouts, disputes
- Approve/reject shipper registrations
- Approve/reject driver registrations
- Process payouts (approve/reject)
- Resolve disputes (refund shipper or release to driver)
- Stats overview (total shippers, drivers, loads, disputes)

Added Moderation link to admin sidebar
This commit is contained in:
FreightDesk 2026-06-08 01:54:35 +00:00
parent 4923357e29
commit 6be15fb059
4 changed files with 454 additions and 0 deletions

View file

@ -0,0 +1,249 @@
const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { asyncHandler } = require('../middleware/security');
// ============================================================
// MIDDLEWARE — Admin only
// ============================================================
function requireAdmin(req, res, next) {
if (!req.session.userId) {
return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl));
}
next();
}
// ============================================================
// MODERATION DASHBOARD
// ============================================================
router.get('/', requireAdmin, asyncHandler(async (req, res) => {
// Pending verifications
const { data: pendingShippers } = await supabase
.from('shippers')
.select('*')
.eq('is_verified', false)
.order('created_at', { ascending: false })
.limit(20);
const { data: pendingDrivers } = await supabase
.from('vehicles')
.select('*')
.eq('is_verified', false)
.order('created_at', { ascending: false })
.limit(20);
// Pending payouts
const { data: pendingPayouts } = await supabase
.from('payout_requests')
.select('*, vehicles(number, driver_name)')
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(20);
// Open disputes
const { data: openDisputes } = await supabase
.from('disputes')
.select('*, loads(from_city, to_city, driver_freight)')
.eq('status', 'open')
.order('created_at', { ascending: false })
.limit(20);
// Stats
const { count: totalShippers } = await supabase.from('shippers').select('*', { count: 'exact', head: true });
const { count: totalDrivers } = await supabase.from('vehicles').select('*', { count: 'exact', head: true });
const { count: totalLoads } = await supabase.from('loads').select('*', { count: 'exact', head: true });
const { count: openDisputesCount } = await supabase.from('disputes').select('*', { count: 'exact', head: true }).eq('status', 'open');
res.render('pages/admin/moderation', {
pendingShippers: pendingShippers || [],
pendingDrivers: pendingDrivers || [],
pendingPayouts: pendingPayouts || [],
openDisputes: openDisputes || [],
stats: { totalShippers, totalDrivers, totalLoads, openDisputes: openDisputesCount },
});
}));
// ============================================================
// APPROVE/REJECT SHIPPER
// ============================================================
router.post('/shippers/:id/approve', requireAdmin, asyncHandler(async (req, res) => {
await supabase.from('shippers').update({ is_verified: true }).eq('id', req.params.id);
res.json({ success: true });
}));
router.post('/shippers/:id/reject', requireAdmin, asyncHandler(async (req, res) => {
const { reason } = req.body;
await supabase.from('shippers').update({ is_verified: false }).eq('id', req.params.id);
// TODO: notify shipper
res.json({ success: true });
}));
// ============================================================
// APPROVE/REJECT DRIVER
// ============================================================
router.post('/drivers/:id/approve', requireAdmin, asyncHandler(async (req, res) => {
await supabase.from('vehicles').update({ is_verified: true }).eq('id', req.params.id);
res.json({ success: true });
}));
router.post('/drivers/:id/reject', requireAdmin, asyncHandler(async (req, res) => {
await supabase.from('vehicles').update({ is_verified: false }).eq('id', req.params.id);
res.json({ success: true });
}));
// ============================================================
// RESOLVE DISPUTE
// ============================================================
router.post('/disputes/:id/resolve', requireAdmin, asyncHandler(async (req, res) => {
const { resolution, action } = req.body; // action: 'refund_shipper' or 'release_driver'
const { data: dispute } = await supabase
.from('disputes')
.select('*, loads(*)')
.eq('id', req.params.id)
.single();
if (!dispute) return res.status(404).json({ error: 'Dispute not found' });
if (action === 'refund_shipper') {
// Refund to shipper
const shipperAccount = await supabase
.from('escrow_accounts')
.select('*')
.eq('user_id', dispute.loads.shipper_id)
.eq('role', 'shipper')
.single();
if (shipperAccount.data) {
await supabase.from('escrow_accounts').update({
balance: shipperAccount.data.balance + dispute.loads.escrow_amount,
held_balance: Math.max(0, shipperAccount.data.held_balance - dispute.loads.escrow_amount),
}).eq('id', shipperAccount.data.id);
await supabase.from('escrow_transactions').insert({
escrow_account_id: shipperAccount.data.id,
load_id: dispute.load_id,
type: 'refund',
amount: dispute.loads.escrow_amount,
status: 'completed',
completed_at: new Date().toISOString(),
});
}
await supabase.from('loads').update({ payment_status: 'refunded' }).eq('id', dispute.load_id);
} else if (action === 'release_driver') {
// Release to driver
const driverAccount = await supabase
.from('escrow_accounts')
.select('*')
.eq('user_id', dispute.raised_against)
.eq('role', 'driver')
.single();
if (driverAccount.data) {
await supabase.from('escrow_accounts').update({
balance: driverAccount.data.balance + dispute.loads.escrow_amount,
held_balance: Math.max(0, driverAccount.data.held_balance - dispute.loads.escrow_amount),
}).eq('id', driverAccount.data.id);
await supabase.from('escrow_transactions').insert({
escrow_account_id: driverAccount.data.id,
load_id: dispute.load_id,
type: 'release',
amount: dispute.loads.escrow_amount,
status: 'completed',
completed_at: new Date().toISOString(),
});
}
await supabase.from('loads').update({ payment_status: 'released', settled_at: new Date().toISOString() }).eq('id', dispute.load_id);
}
// Close dispute
await supabase.from('disputes').update({
status: 'resolved',
resolution,
resolved_by: req.session.userId,
resolved_at: new Date().toISOString(),
}).eq('id', req.params.id);
res.json({ success: true });
}));
// ============================================================
// PROCESS PAYOUT
// ============================================================
router.post('/payouts/:id/process', requireAdmin, asyncHandler(async (req, res) => {
const { action } = req.body; // 'approve' or 'reject'
const { data: payout } = await supabase
.from('payout_requests')
.select('*')
.eq('id', req.params.id)
.single();
if (!payout) return res.status(404).json({ error: 'Payout not found' });
if (action === 'approve') {
await supabase.from('payout_requests').update({
status: 'processed',
processed_by: req.session.userId,
processed_at: new Date().toISOString(),
}).eq('id', req.params.id);
// Deduct from held balance
const { data: account } = await supabase
.from('escrow_accounts')
.select('*')
.eq('user_id', payout.user_id)
.eq('role', 'driver')
.single();
if (account) {
await supabase.from('escrow_accounts').update({
held_balance: Math.max(0, account.held_balance - payout.amount),
total_withdrawn: account.total_withdrawn + payout.amount,
}).eq('id', account.id);
await supabase.from('escrow_transactions').insert({
escrow_account_id: account.id,
type: 'payout',
amount: payout.amount,
status: 'completed',
reference_id: 'PAYOUT-' + payout.id,
completed_at: new Date().toISOString(),
});
}
} else {
// Reject — return funds to available balance
const { data: account } = await supabase
.from('escrow_accounts')
.select('*')
.eq('user_id', payout.user_id)
.eq('role', 'driver')
.single();
if (account) {
await supabase.from('escrow_accounts').update({
balance: account.balance + payout.amount,
held_balance: Math.max(0, account.held_balance - payout.amount),
}).eq('id', account.id);
}
await supabase.from('payout_requests').update({
status: 'rejected',
processed_by: req.session.userId,
processed_at: new Date().toISOString(),
}).eq('id', req.params.id);
}
res.json({ success: true });
}));
module.exports = router;

View file

@ -212,6 +212,7 @@ app.use('/portal-users', require('./routes/portal-users'));
app.use('/api', require('./routes/api'));
app.use('/marketplace', require('./routes/marketplace'));
app.use('/escrow', require('./routes/payments'));
app.use('/admin/moderation', require('./routes/admin-moderation'));
app.use('/', require('./routes/public'));
// Health check

View file

@ -0,0 +1,200 @@
<%- include('../partials/header', { activeMenu: 'moderation' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128274; Admin Moderation</h1>
<p class="page-subtitle">Verify users, process payouts, resolve disputes</p>
</div>
</div>
<!-- Stats -->
<div class="stats-grid mb-4">
<div class="stat-card">
<div class="stat-icon">&#128100;</div>
<div class="stat-value"><%= stats.totalShippers || 0 %></div>
<div class="stat-label">Shippers</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#128666;</div>
<div class="stat-value"><%= stats.totalDrivers || 0 %></div>
<div class="stat-label">Drivers</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#128209;</div>
<div class="stat-value"><%= stats.totalLoads || 0 %></div>
<div class="stat-label">Loads</div>
</div>
<div class="stat-card">
<div class="stat-icon">&#9888;</div>
<div class="stat-value" style="color:#dc3545;"><%= stats.openDisputes || 0 %></div>
<div class="stat-label">Disputes</div>
</div>
</div>
<div class="grid-2">
<!-- Pending Shipper Verifications -->
<div class="card">
<div class="card-header">
<h3 class="card-title">&#127970; Pending Shipper Verifications (<%= pendingShippers.length %>)</h3>
</div>
<div class="card-body" style="padding:0;">
<% if (pendingShippers.length === 0) { %>
<div class="empty-state" style="padding:24px;"><p>No pending verifications</p></div>
<% } else { %>
<table class="table">
<thead><tr><th>Name</th><th>Phone</th><th>City</th><th></th></tr></thead>
<tbody>
<% for (const s of pendingShippers) { %>
<tr>
<td>
<strong><%= s.name %></strong>
<% if (s.company_name) { %><br><small style="color:#666;"><%= s.company_name %></small><% } %>
</td>
<td><%= s.phone %></td>
<td><%= s.city || 'N/A' %></td>
<td>
<button class="btn btn-sm btn-success" onclick="approveShipper('<%= s.id %>')">&#10004;</button>
<button class="btn btn-sm btn-danger" onclick="rejectShipper('<%= s.id %>')">&#10006;</button>
</td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
</div>
<!-- Pending Driver Verifications -->
<div class="card">
<div class="card-header">
<h3 class="card-title">&#128666; Pending Driver Verifications (<%= pendingDrivers.length %>)</h3>
</div>
<div class="card-body" style="padding:0;">
<% if (pendingDrivers.length === 0) { %>
<div class="empty-state" style="padding:24px;"><p>No pending verifications</p></div>
<% } else { %>
<table class="table">
<thead><tr><th>Driver</th><th>Vehicle</th><th>Type</th><th></th></tr></thead>
<tbody>
<% for (const d of pendingDrivers) { %>
<tr>
<td>
<strong><%= d.driver_name || 'N/A' %></strong>
<br><small style="color:#666;"><%= d.phone || '' %></small>
</td>
<td><%= d.number %></td>
<td><span class="badge badge-gray"><%= d.vehicle_type || 'N/A' %></span></td>
<td>
<button class="btn btn-sm btn-success" onclick="approveDriver('<%= d.id %>')">&#10004;</button>
<button class="btn btn-sm btn-danger" onclick="rejectDriver('<%= d.id %>')">&#10006;</button>
</td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
</div>
<!-- Pending Payouts -->
<div class="card">
<div class="card-header">
<h3 class="card-title">&#128176; Pending Payouts (<%= pendingPayouts.length %>)</h3>
</div>
<div class="card-body" style="padding:0;">
<% if (pendingPayouts.length === 0) { %>
<div class="empty-state" style="padding:24px;"><p>No pending payouts</p></div>
<% } else { %>
<table class="table">
<thead><tr><th>Driver</th><th>Amount</th><th>Method</th><th></th></tr></thead>
<tbody>
<% for (const p of pendingPayouts) { %>
<tr>
<td>
<strong><%= p.vehicles?.driver_name || 'N/A' %></strong>
<br><small style="color:#666;"><%= p.vehicles?.number || '' %></small>
</td>
<td style="font-weight:700;">&#8377; <%= (p.amount / 100).toLocaleString('en-IN') %></td>
<td><%= p.upi_id ? 'UPI' : 'Bank' %></td>
<td>
<button class="btn btn-sm btn-success" onclick="processPayout('<%= p.id %>', 'approve')">&#10004; Process</button>
<button class="btn btn-sm btn-danger" onclick="processPayout('<%= p.id %>', 'reject')">&#10006;</button>
</td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
</div>
<!-- Open Disputes -->
<div class="card">
<div class="card-header">
<h3 class="card-title">&#9888; Open Disputes (<%= openDisputes.length %>)</h3>
</div>
<div class="card-body" style="padding:0;">
<% if (openDisputes.length === 0) { %>
<div class="empty-state" style="padding:24px;"><p>No open disputes</p></div>
<% } else { %>
<table class="table">
<thead><tr><th>Load</th><th>Reason</th><th>Amount</th><th></th></tr></thead>
<tbody>
<% for (const d of openDisputes) { %>
<tr>
<td>
<%= d.loads?.from_city || '?' %> &rarr; <%= d.loads?.to_city || '?' %>
<br><small style="color:#666;"><%= new Date(d.created_at).toLocaleDateString('en-IN') %></small>
</td>
<td style="max-width:200px;font-size:13px;"><%= d.reason %></td>
<td style="font-weight:700;">&#8377; <%= (d.loads?.driver_freight || 0).toLocaleString('en-IN') %></td>
<td>
<button class="btn btn-sm btn-primary" onclick="resolveDispute('<%= d.id %>')">Resolve</button>
</td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
</div>
</div>
<script>
async function approveShipper(id) {
await fetch('/admin/moderation/shippers/' + id + '/approve', { method: 'POST' });
location.reload();
}
async function rejectShipper(id) {
if (!confirm('Reject this shipper?')) return;
await fetch('/admin/moderation/shippers/' + id + '/reject', { method: 'POST', body: JSON.stringify({ reason: 'Rejected' }), headers: { 'Content-Type': 'application/json' } });
location.reload();
}
async function approveDriver(id) {
await fetch('/admin/moderation/drivers/' + id + '/approve', { method: 'POST' });
location.reload();
}
async function rejectDriver(id) {
if (!confirm('Reject this driver?')) return;
await fetch('/admin/moderation/drivers/' + id + '/reject', { method: 'POST', body: JSON.stringify({ reason: 'Rejected' }), headers: { 'Content-Type': 'application/json' } });
location.reload();
}
async function processPayout(id, action) {
if (!confirm(action === 'approve' ? 'Approve and process payout?' : 'Reject payout request?')) return;
await fetch('/admin/moderation/payouts/' + id + '/process', { method: 'POST', body: JSON.stringify({ action }), headers: { 'Content-Type': 'application/json' } });
location.reload();
}
async function resolveDispute(id) {
const action = confirm('Click OK to release funds to DRIVER, Cancel to REFUND SHIPPER.');
const resolution = prompt('Resolution notes:');
if (!resolution) return;
await fetch('/admin/moderation/disputes/' + id + '/resolve', {
method: 'POST',
body: JSON.stringify({ resolution, action: action ? 'release_driver' : 'refund_shipper' }),
headers: { 'Content-Type': 'application/json' }
});
location.reload();
}
</script>
<%- include('../partials/footer') %>

View file

@ -55,6 +55,10 @@
<a href="/invoices" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'invoices' ? 'active' : '' %>">&#128196; Invoices</a>
<a href="/audit-logs" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'audit' ? 'active' : '' %>">&#128220; Audit Logs</a>
</div>
<div class="sidebar-section">
<span class="sidebar-title">Moderation</span>
<a href="/admin/moderation" class="sidebar-link <%= typeof activeMenu !== 'undefined' && activeMenu === 'moderation' ? 'active' : '' %>">&#128274; Moderation</a>
</div>
</aside>
<main class="content">