diff --git a/webapp/src/routes/admin-moderation.js b/webapp/src/routes/admin-moderation.js new file mode 100644 index 0000000..ce8a501 --- /dev/null +++ b/webapp/src/routes/admin-moderation.js @@ -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; diff --git a/webapp/src/server.js b/webapp/src/server.js index 518376f..d9a5808 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -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 diff --git a/webapp/src/views/pages/admin/moderation.ejs b/webapp/src/views/pages/admin/moderation.ejs new file mode 100644 index 0000000..00dc06a --- /dev/null +++ b/webapp/src/views/pages/admin/moderation.ejs @@ -0,0 +1,200 @@ +<%- include('../partials/header', { activeMenu: 'moderation' }) %> + + + + +
+
+
👤
+
<%= stats.totalShippers || 0 %>
+
Shippers
+
+
+
🚚
+
<%= stats.totalDrivers || 0 %>
+
Drivers
+
+
+
📑
+
<%= stats.totalLoads || 0 %>
+
Loads
+
+
+
+
<%= stats.openDisputes || 0 %>
+
Disputes
+
+
+ +
+ +
+
+

🏢 Pending Shipper Verifications (<%= pendingShippers.length %>)

+
+
+ <% if (pendingShippers.length === 0) { %> +

No pending verifications

+ <% } else { %> + + + + <% for (const s of pendingShippers) { %> + + + + + + + <% } %> + +
NamePhoneCity
+ <%= s.name %> + <% if (s.company_name) { %>
<%= s.company_name %><% } %> +
<%= s.phone %><%= s.city || 'N/A' %> + + +
+ <% } %> +
+
+ + +
+
+

🚚 Pending Driver Verifications (<%= pendingDrivers.length %>)

+
+
+ <% if (pendingDrivers.length === 0) { %> +

No pending verifications

+ <% } else { %> + + + + <% for (const d of pendingDrivers) { %> + + + + + + + <% } %> + +
DriverVehicleType
+ <%= d.driver_name || 'N/A' %> +
<%= d.phone || '' %> +
<%= d.number %><%= d.vehicle_type || 'N/A' %> + + +
+ <% } %> +
+
+ + +
+
+

💰 Pending Payouts (<%= pendingPayouts.length %>)

+
+
+ <% if (pendingPayouts.length === 0) { %> +

No pending payouts

+ <% } else { %> + + + + <% for (const p of pendingPayouts) { %> + + + + + + + <% } %> + +
DriverAmountMethod
+ <%= p.vehicles?.driver_name || 'N/A' %> +
<%= p.vehicles?.number || '' %> +
₹ <%= (p.amount / 100).toLocaleString('en-IN') %><%= p.upi_id ? 'UPI' : 'Bank' %> + + +
+ <% } %> +
+
+ + +
+
+

⚠ Open Disputes (<%= openDisputes.length %>)

+
+
+ <% if (openDisputes.length === 0) { %> +

No open disputes

+ <% } else { %> + + + + <% for (const d of openDisputes) { %> + + + + + + + <% } %> + +
LoadReasonAmount
+ <%= d.loads?.from_city || '?' %> → <%= d.loads?.to_city || '?' %> +
<%= new Date(d.created_at).toLocaleDateString('en-IN') %> +
<%= d.reason %>₹ <%= (d.loads?.driver_freight || 0).toLocaleString('en-IN') %> + +
+ <% } %> +
+
+
+ + + +<%- include('../partials/footer') %> diff --git a/webapp/src/views/partials/header.ejs b/webapp/src/views/partials/header.ejs index 8f2b78e..a1ac540 100644 --- a/webapp/src/views/partials/header.ejs +++ b/webapp/src/views/partials/header.ejs @@ -55,6 +55,10 @@ 📄 Invoices 📜 Audit Logs +