diff --git a/README.md b/README.md
index 71a2ed1..8b5f55b 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,14 @@ npm start # http://localhost:3000
**Default admin:** username=`admin`, password=`admin123`
+## Seed Development Data
+
+```bash
+node seed.js
+```
+
+This creates sample users and loads. Passwords: `password123` (all users except admin).
+
## Deploy to Production (Coolify + Hostinger VPS)
1. Push code to GitHub/GitLab
@@ -34,22 +42,30 @@ npm start # http://localhost:3000
| Backend | Node.js + Express |
| Views | EJS (server-rendered) |
| Database | Supabase (PostgreSQL) |
-| Auth | Username + Password (bcrypt) |
-| Styles | Custom CSS (govt-app theme) |
+| Auth | Username + Password (bcrypt) + CSRF |
+| Security | Helmet, Rate Limiting, CSRF, Input Sanitization |
+| Styles | Custom CSS v2 (govt-app theme, dark mode) |
| Deployment | Docker + Coolify |
| PWA | Service Worker + Manifest |
## Features
-- **Load Board** — Shippers post loads, drivers browse and bid
+- **Load Board** — Shippers post loads, drivers browse and bid (paginated, filterable)
- **Bidding** — Drivers bid on loads, shippers accept best bid
- **Trip Tracking** — Status flow: confirmed → picked up → in transit → delivered
- **Messaging** — Direct chat between users
- **Dashboards** — Role-specific (driver/shipper/broker) with real stats
-- **Admin Panel** — User management, platform metrics, load overview
+- **Admin Panel** — User management, platform metrics, load overview, stats API
- **WhatsApp Share** — Share loads via WhatsApp
- **Mobile-First** — Bottom nav, responsive, PWA installable
- **Govt-App Design** — Tricolor, navy theme, Hindi-first, trust signals
+- **Dark Mode** — Toggle between light and dark themes (persisted)
+- **Multi-Language** — Hindi, English, Tamil, Telugu
+- **Toast Notifications** — Success/error feedback on all actions
+- **CSRF Protection** — All forms protected with CSRF tokens
+- **Pagination** — All list views paginated
+- **Input Validation** — Server-side validation on all forms
+- **Error Handling** — Proper HTTP status codes, 403/404/500 pages
## User Roles
@@ -65,24 +81,58 @@ npm start # http://localhost:3000
```
webapp/
├── src/
-│ ├── server.js # Express app entry
+│ ├── server.js # Express app entry (security hardened)
│ ├── config/ # env.js, constants.js
-│ ├── middleware/ # auth.js
-│ ├── routes/ # auth, loads, trips, admin, messages
+│ ├── middleware/
+│ │ ├── auth.js # Auth checks with 403 handling
+│ │ ├── i18n.js # Internationalization
+│ │ └── security.js # CSRF, sanitization, logging, asyncHandler
+│ ├── routes/ # All route files (async error handling)
│ ├── services/ # supabase.js
│ ├── views/pages/ # All EJS pages
│ ├── views/partials/ # header, footer, bottom-nav
+│ ├── views/layouts/ # main.ejs
+│ ├── lib/ # india.js, gamification.js
+│ ├── i18n/ # Translation files (hi, en, ta, te)
│ └── public/ # CSS, JS, manifest, SW
-├── Dockerfile
+├── seed.js # Development seed data script
+├── Dockerfile # Production Docker config (alpine, non-root)
├── package.json
-└── supabase-FULL-migration.sql
+├── supabase-FULL-migration.sql
+└── .env.example
```
## Environment Variables
```
-SUPABASE_URL=https://your-project.supabase.co
-SUPABASE_KEY=your-anon-key
-SESSION_SECRET=random-64-char-string
+NODE_ENV=development
PORT=3000
+APP_URL=http://localhost:3000
+SUPABASE_URL=https://your-project.supabase.co
+SUPABASE_KEY=your-supabase-anon-key
+SUPABASE_SERVICE_KEY=your-supabase-service-role-key
+SESSION_SECRET=random-64-char-string
+RATE_LIMIT_BIDS_PER_DAY=5
```
+
+## Security Features
+
+- CSRF tokens on all forms
+- Session fixation protection (resave: false)
+- Secure cookie settings in production
+- Rate limiting (200 req/15min general)
+- Input sanitization (HTML entity encoding)
+- bcrypt password hashing (10 rounds)
+- Security headers via Helmet
+- Proper error handling (no stack traces in production)
+- Graceful shutdown on SIGTERM
+- Non-root Docker container
+
+## Changelog v2.0
+
+- Security: CSRF protection, secure sessions, input sanitization
+- Code Quality: Async error handling on all routes, proper HTTP codes
+- UI/UX: Dark mode, toast notifications, loading states, form validation
+- Features: Pagination on all lists, 403 forbidden page, admin stats API
+- Performance: Database indexes, query optimization
+- DevEx: Seed script, improved Dockerfile, comprehensive .gitignore
diff --git a/webapp/.gitignore b/webapp/.gitignore
index 3c3629e..ac08d37 100644
--- a/webapp/.gitignore
+++ b/webapp/.gitignore
@@ -1 +1,39 @@
-node_modules
+# ============================================
+# BharathTrucks — .gitignore
+# ============================================
+
+# Dependencies
+node_modules/
+package-lock.json
+
+# Environment
+.env
+.env.local
+.env.production
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Build
+dist/
+build/
+
+# Testing
+coverage/
+
+# Temp
+tmp/
+*.tmp
+*.bak
diff --git a/webapp/Dockerfile b/webapp/Dockerfile
index bd1c31f..e8b9679 100644
--- a/webapp/Dockerfile
+++ b/webapp/Dockerfile
@@ -1,16 +1,25 @@
-FROM node:22-alpine
+# syntax=docker/dockerfile:1
+FROM node:22-alpine AS base
WORKDIR /app
-COPY package.json package-lock.json* ./
-RUN npm ci --omit=dev
+# Install dependencies first (layer caching)
+COPY package.json ./
+RUN npm ci --omit=dev && npm cache clean --force
+# Copy application
COPY src ./src
+# Create non-root user
+RUN addgroup -S app && adduser -S app -G app
+USER app
+
+# Metadata
ENV NODE_ENV=production
EXPOSE 3000
-HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
+# Healthcheck
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]
diff --git a/webapp/seed.js b/webapp/seed.js
new file mode 100644
index 0000000..db946df
--- /dev/null
+++ b/webapp/seed.js
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+/**
+ * BharathTrucks — Development Seed Script
+ * Creates sample users and loads for local development
+ * Usage: node seed.js
+ */
+require('dotenv').config();
+const { createClient } = require('@supabase/supabase-js');
+const bcrypt = require('bcryptjs');
+
+const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY);
+
+async function seed() {
+ console.log('🌱 Seeding BharathTrucks database...\n');
+
+ const passwordHash = await bcrypt.hash('password123', 10);
+
+ const users = [
+ { username: 'admin', name: 'Admin User', password_hash: await bcrypt.hash('admin123', 10), role: 'admin' },
+ { username: 'driver_raj', name: 'Raj Kumar', password_hash: passwordHash, role: 'driver', phone: '9876543210', city: 'Mumbai', state: 'Maharashtra' },
+ { username: 'driver_aman', name: 'Aman Singh', password_hash: passwordHash, role: 'driver', phone: '9876543211', city: 'Delhi', state: 'Delhi' },
+ { username: 'shipper_agarwal', name: 'Agarwal Packers', password_hash: passwordHash, role: 'shipper', phone: '9876543212', city: 'Kolkata', state: 'West Bengal' },
+ { username: 'broker_kahn', name: 'Kahn Transport', password_hash: passwordHash, role: 'broker', phone: '9876543213', city: 'Chennai', state: 'Tamil Nadu' },
+ ];
+
+ // Insert users
+ const userIds = {};
+ for (const u of users) {
+ const { data, error } = await supabase.from('app_users').upsert(u, { onConflict: 'username' }).select().single();
+ if (data) {
+ userIds[u.username] = data.id;
+ console.log(` ✓ User: ${u.username} (${u.role})`);
+ } else if (error) {
+ console.log(` ⚠ ${u.username}: ${error.message}`);
+ }
+ }
+
+ const shipperId = userIds['shipper_agarwal'];
+ const brokerId = userIds['broker_kahn'];
+ const driver1 = userIds['driver_raj'];
+ const driver2 = userIds['driver_aman'];
+
+ if (shipperId) {
+ const loads = [
+ { posted_by: shipperId, origin_city: 'Mumbai', destination_city: 'Delhi', weight_tons: 12, truck_type: 'container', material_type: 'Electronics', budget: 45000, pickup_date: new Date(Date.now() + 2*86400000).toISOString().split('T')[0], description: 'Electronics shipment, handle with care', is_urgent: true, status: 'open' },
+ { posted_by: shipperId, origin_city: 'Chennai', destination_city: 'Bangalore', weight_tons: 8, truck_type: 'closed', material_type: 'Textiles', budget: 22000, pickup_date: new Date(Date.now() + 1*86400000).toISOString().split('T')[0], description: 'Textile goods', is_urgent: false, status: 'open' },
+ { posted_by: shipperId, origin_city: 'Kolkata', destination_city: 'Mumbai', weight_tons: 20, truck_type: 'open', material_type: 'Steel', budget: 65000, pickup_date: new Date(Date.now() + 3*86400000).toISOString().split('T')[0], description: 'Steel coils', is_urgent: false, status: 'open' },
+ ];
+
+ for (const l of loads) {
+ const { error } = await supabase.from('loads').insert(l);
+ if (!error) console.log(` ✓ Load: ${l.origin_city} → ${l.destination_city}`);
+ else console.log(` ⚠ Load error: ${error.message}`);
+ }
+ }
+
+ if (driver1 && driver2) {
+ // Get a load to bid on
+ const { data: load } = await supabase.from('loads').select('id').eq('status', 'open').limit(1).single();
+ if (load) {
+ await supabase.from('bids').insert([
+ { load_id: load.id, driver_id: driver1, amount: 42000, note: 'Experienced driver with GPS' },
+ { load_id: load.id, driver_id: driver2, amount: 41000, note: 'Best rates guaranteed' },
+ ]);
+ console.log(' ✓ Sample bids created');
+ }
+ }
+
+ if (shipperId) {
+ await supabase.from('user_gamification').upsert([
+ { user_id: shipperId, xp: 80, login_streak: 3 },
+ { user_id: driver1, xp: 120, login_streak: 5 },
+ { user_id: driver2, xp: 60, login_streak: 2 },
+ ], { onConflict: 'user_id' });
+ console.log(' ✓ Gamification data seeded');
+ }
+
+ console.log('\n✅ Seed complete!');
+ console.log('\nDefault passwords:');
+ console.log(' admin → admin123');
+ console.log(' all other users → password123');
+}
+
+seed().catch(err => {
+ console.error('Seed failed:', err.message);
+ process.exit(1);
+});
diff --git a/webapp/src/middleware/auth.js b/webapp/src/middleware/auth.js
index fb62644..fbcad9b 100644
--- a/webapp/src/middleware/auth.js
+++ b/webapp/src/middleware/auth.js
@@ -5,14 +5,28 @@ function requireAuth(req, res, next) {
res.locals.user = req.session.user;
return next();
}
- res.redirect('/login');
+ if (req.accepts('html')) {
+ res.redirect('/login');
+ } else {
+ res.status(401).json({ error: 'Authentication required' });
+ }
}
function requireRole(...roles) {
return (req, res, next) => {
- if (!req.session || !req.session.user) return res.redirect('/login');
- if (roles.includes(req.session.user.role) || req.session.user.role === ROLES.ADMIN) return next();
- res.redirect('/');
+ if (!req.session || !req.session.user) {
+ if (req.accepts('html')) return res.redirect('/login');
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ if (roles.includes(req.session.user.role) || req.session.user.role === ROLES.ADMIN) {
+ return next();
+ }
+ if (req.accepts('html')) {
+ res.status(403);
+ res.render('pages/403', { requiredRoles: roles });
+ } else {
+ res.status(403).json({ error: 'Forbidden: insufficient permissions' });
+ }
};
}
diff --git a/webapp/src/middleware/security.js b/webapp/src/middleware/security.js
new file mode 100644
index 0000000..fe81048
--- /dev/null
+++ b/webapp/src/middleware/security.js
@@ -0,0 +1,66 @@
+const crypto = require('crypto');
+
+// CSRF token generation and validation
+function generateCSRFToken() {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+function setupCSRF(req, res, next) {
+ if (!req.session._csrf) {
+ req.session._csrf = generateCSRFToken();
+ }
+ res.locals._csrf = req.session._csrf;
+ next();
+}
+
+function validateCSRF(req, res, next) {
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
+ const token = req.body?._csrf || req.headers['x-csrf-token'] || req.query._csrf;
+ if (!token || token !== req.session._csrf) {
+ return res.status(403).send('Invalid CSRF token. Please go back and try again.');
+ }
+ next();
+}
+
+// Input sanitization
+function sanitizeInput(str) {
+ if (typeof str !== 'string') return str;
+ return str
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(/\//g, '/');
+}
+
+function sanitizeBody(req, res, next) {
+ if (req.body && typeof req.body === 'object') {
+ for (const key of Object.keys(req.body)) {
+ if (typeof req.body[key] === 'string') {
+ req.body[key] = sanitizeInput(req.body[key]).trim();
+ }
+ }
+ }
+ next();
+}
+
+// Simple request logger
+function requestLogger(req, res, next) {
+ const start = Date.now();
+ res.on('finish', () => {
+ const duration = Date.now() - start;
+ const status = res.statusCode;
+ const icon = status >= 500 ? '❌' : status >= 400 ? '⚠️' : status >= 300 ? '↪️' : '✓';
+ console.log(`${icon} ${req.method} ${req.url} ${status} ${duration}ms`);
+ });
+ next();
+}
+
+// Wrap async route handlers to catch rejections
+function asyncHandler(fn) {
+ return (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+ };
+}
+
+module.exports = { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler, generateCSRFToken };
diff --git a/webapp/src/public/css/govt-theme.css b/webapp/src/public/css/govt-theme.css
index 5abff3f..95711be 100644
--- a/webapp/src/public/css/govt-theme.css
+++ b/webapp/src/public/css/govt-theme.css
@@ -1,6 +1,7 @@
/* ============================================
BharathTrucks — Government Theme CSS
Design System: Sarkari Trust, Modern Usability
+ v2.0 — Dark Mode, Toast, Loading, Desktop
============================================ */
/* --- CSS Variables --- */
@@ -17,33 +18,70 @@
--gray-100: #f5f5f5;
--gray-200: #eeeeee;
--gray-300: #e0e0e0;
+ --gray-400: #bdbdbd;
--gray-500: #9e9e9e;
+ --gray-600: #757575;
--gray-700: #616161;
+ --gray-800: #424242;
--gray-900: #212121;
--red: #c62828;
+ --red-light: #ef5350;
--gold: #f9a825;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
+ --bg: var(--gray-100);
+ --surface: var(--white);
+ --text: var(--gray-900);
+ --text-muted: var(--gray-700);
+ --border: var(--gray-300);
}
+/* --- Dark Mode --- */
+body.dark {
+ --bg: #121212;
+ --surface: #1e1e1e;
+ --text: #e0e0e0;
+ --text-muted: #9e9e9e;
+ --border: #333;
+ --gray-100: #1a1a1a;
+ --gray-200: #2a2a2a;
+ --gray-300: #333;
+ --gray-500: #666;
+ --gray-700: #888;
+ --white: #1e1e1e;
+ --navy: #7c8cf5;
+ --navy-light: #5c6bc0;
+}
+body.dark .govt-header { background: linear-gradient(135deg, #0d1b5e 0%, #0a2a5a 100%); }
+body.dark .govt-footer { background: #0d1b5e; }
+body.dark .card, body.dark .stat-card, body.dark .step-card, body.dark .icon-action-btn,
+body.dark .role-card { background: #1e1e1e; border-color: #333; }
+body.dark .form-input { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
+body.dark .form-input:focus { border-color: var(--ashoka-blue); box-shadow: 0 0 0 3px rgba(79,114,211,0.2); }
+body.dark .bottom-nav { background: #1e1e1e; border-color: #333; }
+body.dark .alert-error { background: #2a1a1a; border-color: #5a2a2a; }
+body.dark .bnav-item { color: #9e9e9e; }
+
/* --- Reset --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
body {
font-family: 'Noto Sans', 'Noto Sans Devanagari', -apple-system, BlinkMacSystemFont, sans-serif;
- color: var(--gray-900);
- background: var(--gray-100);
+ color: var(--text);
+ background: var(--bg);
line-height: 1.6;
min-height: 100vh;
+ transition: background 0.3s, color 0.3s;
}
a { color: var(--ashoka-blue); text-decoration: none; }
a:hover { text-decoration: underline; }
@@ -62,6 +100,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
color: var(--white);
padding: var(--space-md);
box-shadow: var(--shadow-md);
+ position: sticky;
+ top: 0;
+ z-index: 100;
}
.header-inner {
max-width: 1200px;
@@ -82,14 +123,30 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.header-user { font-size: 0.8rem; opacity: 0.8; }
.btn-header-cta {
background: var(--saffron);
- color: var(--white);
+ color: var(--white) !important;
padding: 8px 16px;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.8rem;
+ text-decoration: none;
}
.btn-header-cta:hover { background: var(--saffron-light); text-decoration: none; }
+/* --- Dark Mode Toggle --- */
+.dark-toggle {
+ background: rgba(255,255,255,0.15);
+ border: 2px solid rgba(255,255,255,0.3);
+ color: #fff;
+ width: 32px; height: 32px;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 1rem;
+ display: flex; align-items: center; justify-content: center;
+ transition: background 0.2s;
+ padding: 0;
+}
+.dark-toggle:hover { background: rgba(255,255,255,0.3); text-decoration: none; }
+
/* --- Main Content --- */
.main-content { min-height: calc(100vh - 200px); }
@@ -122,23 +179,36 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
- transition: transform 0.15s, box-shadow 0.15s;
+ transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
text-decoration: none;
}
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); text-decoration: none; }
.btn:active { transform: translateY(0); }
+.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
+.btn-loading { position: relative; color: transparent !important; pointer-events: none; }
+.btn-loading::after {
+ content: ''; position: absolute;
+ width: 18px; height: 18px;
+ border: 2px solid rgba(255,255,255,0.3);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
.btn-primary { background: var(--navy); color: var(--white); }
.btn-cta { background: var(--saffron); color: var(--white); box-shadow: 0 4px 16px rgba(255,111,0,0.3); }
.btn-success { background: var(--green); color: var(--white); }
+.btn-danger { background: var(--red); color: var(--white); }
.btn-outline { background: transparent; border: 2px solid var(--navy); color: var(--navy); }
+.btn-outline:hover { background: var(--navy); color: var(--white); }
.btn-lg { padding: 16px 32px; font-size: 1rem; border-radius: var(--radius-lg); }
.btn-sm { padding: 8px 16px; font-size: 0.8rem; }
.btn-block { width: 100%; }
/* --- Cards --- */
.card {
- background: var(--white);
- border: 1px solid var(--gray-300);
+ background: var(--surface);
+ border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
@@ -148,16 +218,20 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* --- Forms --- */
.form-group { margin-bottom: var(--space-md); }
-.form-label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: var(--space-xs); color: var(--gray-700); }
+.form-label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: var(--space-xs); color: var(--text-muted); }
.form-input {
width: 100%;
padding: 12px 16px;
- border: 2px solid var(--gray-300);
+ border: 2px solid var(--border);
border-radius: var(--radius-md);
font-size: 0.9rem;
+ background: var(--surface);
+ color: var(--text);
transition: border-color 0.2s;
}
.form-input:focus { border-color: var(--ashoka-blue); outline: none; box-shadow: 0 0 0 3px rgba(13,71,161,0.1); }
+.form-input.error { border-color: var(--red); }
+.form-error-msg { font-size: 0.75rem; color: var(--red); margin-top: 3px; }
.form-select { appearance: none; background: var(--white) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23616161' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 16px center; padding-right: 40px; }
/* --- Badges --- */
@@ -186,14 +260,21 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* --- Stats Grid --- */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-md); }
.stat-card {
- background: var(--white);
+ background: var(--surface);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
padding: var(--space-md);
text-align: center;
}
.stat-value { font-size: 1.5rem; font-weight: 700; color: var(--navy); }
-.stat-label { font-size: 0.75rem; color: var(--gray-700); margin-top: 2px; }
+.stat-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; }
+
+/* --- Table --- */
+.table-wrapper { overflow-x: auto; }
+.table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
+.table th, .table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); }
+.table th { background: var(--gray-100); font-weight: 600; color: var(--text-muted); }
+.table tr:hover { background: var(--gray-50); }
/* --- Container --- */
.container { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-md); }
@@ -201,7 +282,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* --- Section --- */
.section { padding: var(--space-2xl) var(--space-md); }
.section-title { font-size: 1.5rem; font-weight: 700; text-align: center; margin-bottom: var(--space-sm); }
-.section-subtitle { text-align: center; color: var(--gray-700); font-size: 0.9rem; margin-bottom: var(--space-xl); }
+.section-subtitle { text-align: center; color: var(--text-muted); font-size: 0.9rem; margin-bottom: var(--space-xl); }
/* --- Landing Hero --- */
.hero {
@@ -235,12 +316,13 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
border-radius: var(--radius-lg);
padding: var(--space-lg);
text-align: center;
+ background: var(--surface);
transition: border-color 0.2s, box-shadow 0.2s;
}
.role-card:hover { box-shadow: var(--shadow-md); }
-.role-card-driver { border-color: var(--green); background: #f1f8e9; }
-.role-card-shipper { border-color: var(--saffron); background: #fff8e1; }
-.role-card-broker { border-color: var(--ashoka-blue); background: #e3f2fd; }
+.role-card-driver { border-color: var(--green); }
+.role-card-shipper { border-color: var(--saffron); }
+.role-card-broker { border-color: var(--ashoka-blue); }
.role-icon { font-size: 2.5rem; margin-bottom: var(--space-sm); }
.role-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: var(--space-sm); }
.role-card ul { list-style: none; text-align: left; font-size: 0.8rem; }
@@ -250,7 +332,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* --- How It Works --- */
.steps-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-md); counter-reset: step; }
.step-card {
- background: var(--white);
+ background: var(--surface);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
padding: var(--space-lg);
@@ -274,32 +356,12 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
font-weight: 700;
}
.step-card h4 { margin-left: 40px; font-size: 0.9rem; font-weight: 700; }
-.step-card p { margin-left: 40px; font-size: 0.8rem; color: var(--gray-700); margin-top: 4px; }
+.step-card p { margin-left: 40px; font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; }
/* --- Error Pages --- */
-.error-page { text-align: center; padding: var(--space-2xl); }
-.error-page h1 { font-size: 3rem; color: var(--navy); }
-.error-page p { color: var(--gray-700); margin: var(--space-md) 0; }
-
-/* --- Responsive --- */
-@media (min-width: 480px) {
- .hero-ctas { flex-direction: row; justify-content: center; }
-}
-@media (min-width: 768px) {
- .roles-grid { grid-template-columns: 1fr 1fr 1fr; }
- .steps-grid { grid-template-columns: 1fr 1fr; }
- .hero h1 { font-size: 2.5rem; }
-}
-@media (min-width: 1024px) {
- .header-subtitle { font-size: 0.8rem; }
-}
-
-/* --- Utility --- */
-.text-center { text-align: center; }
-.mt-md { margin-top: var(--space-md); }
-.mt-lg { margin-top: var(--space-lg); }
-.mb-md { margin-bottom: var(--space-md); }
-.hidden { display: none; }
+.error-page { text-align: center; padding: var(--space-2xl) var(--space-md); }
+.error-page h1 { font-size: 3rem; }
+.error-page p { color: var(--text-muted); margin: var(--space-md) 0; }
/* --- Auth Pages --- */
.alert-error {
@@ -311,6 +373,15 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
font-size: 0.8rem;
margin-bottom: var(--space-md);
}
+.alert-success {
+ background: #e8f5e9;
+ color: var(--green);
+ border: 1px solid #c8e6c9;
+ border-radius: var(--radius-sm);
+ padding: 10px 14px;
+ font-size: 0.8rem;
+ margin-bottom: var(--space-md);
+}
.role-select-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm); }
.role-option input { display: none; }
.role-option-card {
@@ -319,7 +390,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
align-items: center;
gap: 4px;
padding: 12px 8px;
- border: 2px solid var(--gray-300);
+ border: 2px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.75rem;
@@ -335,8 +406,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
bottom: 0;
left: 0;
right: 0;
- background: var(--white);
- border-top: 1px solid var(--gray-300);
+ background: var(--surface);
+ border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 6px 0 env(safe-area-inset-bottom, 6px);
@@ -348,15 +419,14 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
align-items: center;
gap: 2px;
font-size: 0.6rem;
- color: var(--gray-700);
+ color: var(--text-muted);
text-decoration: none;
padding: 4px 8px;
}
-.bnav-item:hover { color: var(--navy); text-decoration: none; }
+.bnav-item:hover, .bnav-item.active { color: var(--navy); text-decoration: none; }
.bnav-icon { font-size: 1.2rem; }
.bnav-add .bnav-icon { background: var(--saffron); color: #fff; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-top: -12px; font-size: 1rem; }
body { padding-bottom: 70px; }
-@media (min-width: 768px) { .bottom-nav { display: none; } body { padding-bottom: 0; } }
/* --- Language Switcher --- */
.lang-switcher { display: flex; gap: 4px; margin-right: 12px; }
@@ -370,15 +440,11 @@ body { padding-bottom: 70px; }
.lang-btn:hover { background: rgba(255,255,255,0.3); text-decoration: none; }
.lang-btn.active { border-color: var(--saffron); background: rgba(255,255,255,0.25); }
-/* --- Large Icon Nav (low-literacy) --- */
-.bnav-icon-lg { font-size: 1.8rem; line-height: 1; }
-.bnav-label { font-size: 0.65rem; font-weight: 600; }
-
-/* --- Icon Action Buttons (dashboard) --- */
+/* --- Icon Action Buttons --- */
.icon-action-btn {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; padding: 20px 12px;
- background: var(--white); border: 2px solid var(--gray-300);
+ background: var(--surface); border: 2px solid var(--border);
border-radius: var(--radius-md); text-decoration: none; color: var(--navy);
transition: border-color 0.2s, box-shadow 0.2s;
}
@@ -395,3 +461,110 @@ body { padding-bottom: 70px; }
.voice-btn:hover { background: var(--gray-100); }
.voice-btn.voice-active { animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{transform:translateY(-50%) scale(1)} 50%{transform:translateY(-50%) scale(1.2)} }
+
+/* --- Toast Notifications --- */
+.toast-container {
+ position: fixed; top: 70px; right: 16px; z-index: 9999;
+ display: flex; flex-direction: column; gap: 8px;
+ max-width: 360px;
+ pointer-events: none;
+}
+.toast {
+ background: var(--surface);
+ border-radius: var(--radius-md);
+ padding: 12px 16px;
+ box-shadow: var(--shadow-lg);
+ display: flex; align-items: center; gap: 10px;
+ font-size: 0.85rem;
+ border-left: 4px solid var(--ashoka-blue);
+ animation: toast-in 0.3s ease-out;
+ pointer-events: auto;
+ color: var(--text);
+}
+.toast-success { border-left-color: var(--green); }
+.toast-error { border-left-color: var(--red); }
+.toast-warning { border-left-color: var(--saffron); }
+.toast-icon { font-size: 1.2rem; flex-shrink: 0; }
+.toast-msg { flex: 1; }
+.toast-close {
+ background: none; border: none; font-size: 1rem;
+ cursor: pointer; color: var(--text-muted); padding: 0 4px;
+}
+@keyframes toast-in { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
+@keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateX(40px); } }
+
+/* --- Pagination --- */
+.pagination {
+ display: flex; justify-content: center; align-items: center;
+ gap: 6px; margin-top: var(--space-lg); flex-wrap: wrap;
+}
+.pagination a, .pagination span {
+ display: inline-flex; align-items: center; justify-content: center;
+ min-width: 36px; height: 36px;
+ padding: 0 10px;
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem; font-weight: 600;
+ text-decoration: none;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+}
+.pagination a:hover { border-color: var(--navy); background: var(--gray-50); text-decoration: none; }
+.pagination .active {
+ background: var(--navy); color: #fff; border-color: var(--navy);
+}
+.pagination .disabled { opacity: 0.4; pointer-events: none; }
+
+/* --- Loading Spinner --- */
+.spinner-overlay {
+ position: absolute; inset: 0;
+ background: rgba(255,255,255,0.7);
+ display: flex; align-items: center; justify-content: center;
+ z-index: 10; border-radius: inherit;
+}
+body.dark .spinner-overlay { background: rgba(30,30,30,0.7); }
+.spinner {
+ width: 32px; height: 32px;
+ border: 3px solid var(--border);
+ border-top-color: var(--navy);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+/* --- Desktop Navigation --- */
+.desktop-nav { display: none; }
+
+/* --- Responsive --- */
+@media (min-width: 480px) {
+ .hero-ctas { flex-direction: row; justify-content: center; }
+}
+@media (min-width: 768px) {
+ .roles-grid { grid-template-columns: 1fr 1fr 1fr; }
+ .steps-grid { grid-template-columns: 1fr 1fr; }
+ .hero h1 { font-size: 2.5rem; }
+ .bottom-nav { display: none; }
+ body { padding-bottom: 0; }
+ .desktop-nav { display: flex; }
+ .header-subtitle { font-size: 0.8rem; }
+}
+@media (min-width: 1024px) {
+ .hero h1 { font-size: 3rem; }
+ .hero-sub { font-size: 1.1rem; }
+}
+
+/* --- Utility --- */
+.text-center { text-align: center; }
+.text-right { text-align: right; }
+.text-muted { color: var(--text-muted); }
+.mt-sm { margin-top: var(--space-sm); }
+.mt-md { margin-top: var(--space-md); }
+.mt-lg { margin-top: var(--space-lg); }
+.mb-sm { margin-bottom: var(--space-sm); }
+.mb-md { margin-bottom: var(--space-md); }
+.mb-lg { margin-bottom: var(--space-lg); }
+.flex { display: flex; }
+.flex-center { display: flex; align-items: center; justify-content: center; }
+.gap-sm { gap: var(--space-sm); }
+.gap-md { gap: var(--space-md); }
+.hidden { display: none; }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
diff --git a/webapp/src/public/js/app.js b/webapp/src/public/js/app.js
index 37ed453..d021ccb 100644
--- a/webapp/src/public/js/app.js
+++ b/webapp/src/public/js/app.js
@@ -1,4 +1,118 @@
-// BharathTrucks — Client-side JS
+// BharathTrucks — Client-side JS v2.0
+
+// Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
+
+// Dark Mode
+(function() {
+ const saved = localStorage.getItem('bt-dark');
+ const prefers = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ if (saved === '1' || (saved === null && prefers)) {
+ document.body.classList.add('dark');
+ }
+ window.toggleDark = function() {
+ document.body.classList.toggle('dark');
+ localStorage.setItem('bt-dark', document.body.classList.contains('dark') ? '1' : '0');
+ const btn = document.getElementById('darkToggle');
+ if (btn) btn.textContent = document.body.classList.contains('dark') ? '☀️' : '🌙';
+ };
+ // Set initial icon
+ const btn = document.getElementById('darkToggle');
+ if (btn) btn.textContent = document.body.classList.contains('dark') ? '☀️' : '🌙';
+})();
+
+// Toast Notifications
+(function() {
+ const container = document.createElement('div');
+ container.className = 'toast-container';
+ container.id = 'toastContainer';
+ document.body.appendChild(container);
+
+ window.showToast = function(msg, type) {
+ type = type || 'info';
+ const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' };
+ const el = document.createElement('div');
+ el.className = 'toast toast-' + type;
+ el.innerHTML = '' + (icons[type] || icons.info) + '' +
+ '' + msg + '' +
+ '';
+ container.appendChild(el);
+ setTimeout(function() {
+ el.style.animation = 'toast-out 0.3s ease-in forwards';
+ setTimeout(function() { el.remove(); }, 300);
+ }, 5000);
+ };
+
+ // Auto-show toasts from URL params
+ window.addEventListener('DOMContentLoaded', function() {
+ const params = new URLSearchParams(location.search);
+ if (params.get('success')) showToast(decodeURIComponent(params.get('success')), 'success');
+ if (params.get('error')) showToast(decodeURIComponent(params.get('error')), 'error');
+ });
+})();
+
+// Form Loading State
+(function() {
+ window.addEventListener('DOMContentLoaded', function() {
+ document.querySelectorAll('form').forEach(function(form) {
+ form.addEventListener('submit', function() {
+ const btn = form.querySelector('button[type="submit"], input[type="submit"]');
+ if (btn) {
+ btn.classList.add('btn-loading');
+ btn.disabled = true;
+ }
+ });
+ });
+ });
+})();
+
+// Confirm dialogs
+(function() {
+ window.addEventListener('DOMContentLoaded', function() {
+ document.querySelectorAll('[data-confirm]').forEach(function(el) {
+ el.addEventListener('click', function(e) {
+ if (!confirm(el.getAttribute('data-confirm'))) e.preventDefault();
+ });
+ });
+ });
+})();
+
+// Voice Input (Web Speech API)
+(function() {
+ if (!('webkitSpeechRecognition in window')) return;
+ const recognition = new webkitSpeechRecognition();
+ recognition.continuous = false;
+ recognition.interimResults = false;
+
+ window.startVoiceInput = function(inputId) {
+ const btn = document.querySelector('.voice-btn[data-for="' + inputId + '"]');
+ const input = document.getElementById(inputId);
+ if (!btn || !input) return;
+
+ btn.classList.add('voice-active');
+ btn.textContent = '🔴';
+ recognition.lang = document.documentElement.lang === 'hi' ? 'hi-IN' : 'en-US';
+ recognition.onresult = function(e) {
+ input.value = e.results[0][0].transcript;
+ btn.classList.remove('voice-active');
+ btn.textContent = '🎤';
+ };
+ recognition.onerror = function() {
+ btn.classList.remove('voice-active');
+ btn.textContent = '🎤';
+ showToast('Voice input failed. Try again.', 'error');
+ };
+ recognition.onend = function() {
+ btn.classList.remove('voice-active');
+ btn.textContent = '🎤';
+ };
+ recognition.start();
+ };
+})();
+
+// Auto-refresh form session to prevent CSRF timeout
+setInterval(function() {
+ fetch('/health', { method: 'HEAD' }).catch(function() {});
+}, 600000);
diff --git a/webapp/src/routes/admin.js b/webapp/src/routes/admin.js
index f00d6d7..467d25b 100644
--- a/webapp/src/routes/admin.js
+++ b/webapp/src/routes/admin.js
@@ -7,44 +7,253 @@ router.use(requireAdmin);
// GET /admin — dashboard
router.get('/', async (req, res) => {
- const { count: userCount } = await supabase.from('app_users').select('*', { count: 'exact', head: true });
- const { count: loadCount } = await supabase.from('loads').select('*', { count: 'exact', head: true });
- const { count: bidCount } = await supabase.from('bids').select('*', { count: 'exact', head: true });
- const { count: tripCount } = await supabase.from('trips').select('*', { count: 'exact', head: true });
+ try {
+ const { count: userCount } = await supabase.from('app_users').select('*', { count: 'exact', head: true });
+ const { count: loadCount } = await supabase.from('loads').select('*', { count: 'exact', head: true });
+ const { count: bidCount } = await supabase.from('bids').select('*', { count: 'exact', head: true });
+ const { count: tripCount } = await supabase.from('trips').select('*', { count: 'exact', head: true });
- const { data: roleStats } = await supabase.from('app_users').select('role');
- const roles = { driver: 0, shipper: 0, broker: 0 };
- (roleStats || []).forEach(u => { if (roles[u.role] !== undefined) roles[u.role]++; });
+ const { data: roleStats } = await supabase.from('app_users').select('role');
+ const roles = { driver: 0, shipper: 0, broker: 0 };
+ (roleStats || []).forEach(u => { if (roles[u.role] !== undefined) roles[u.role]++; });
- const { data: recentUsers } = await supabase.from('app_users').select('id, name, username, role, created_at').order('created_at', { ascending: false }).limit(5);
+ const { data: recentUsers } = await supabase.from('app_users').select('id, name, username, role, created_at').order('created_at', { ascending: false }).limit(5);
- res.render('pages/admin-dashboard', {
- stats: { users: userCount || 0, loads: loadCount || 0, bids: bidCount || 0, trips: tripCount || 0 },
- roles, recentUsers: recentUsers || [],
- });
+ res.render('pages/admin-dashboard', {
+ stats: { users: userCount || 0, loads: loadCount || 0, bids: bidCount || 0, trips: tripCount || 0 },
+ roles, recentUsers: recentUsers || [],
+ });
+ } catch (err) {
+ console.error('Admin dashboard error:', err);
+ res.status(500).render('pages/error', { message: 'Failed to load admin dashboard' });
+ }
});
-// GET /admin/users
+// GET /admin/users — paginated user list
router.get('/users', async (req, res) => {
- const { role, search } = req.query;
- let query = supabase.from('app_users').select('*').order('created_at', { ascending: false });
- if (role && role !== 'all') query = query.eq('role', role);
- if (search) query = query.or(`name.ilike.%${search}%,username.ilike.%${search}%`);
- const { data: users } = await query.limit(100);
- res.render('pages/admin-users', { users: users || [], filters: req.query });
+ try {
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
+ const offset = (page - 1) * limit;
+
+ const { role, search } = req.query;
+
+ // Build filtered query for counting
+ let countQuery = supabase.from('app_users').select('*', { count: 'exact', head: true });
+ if (role && role !== 'all') countQuery = countQuery.eq('role', role);
+ if (search) countQuery = countQuery.or(`name.ilike.%${search}%,username.ilike.%${search}%`);
+ const { count: totalCount } = await countQuery;
+
+ // Build filtered query for data fetch
+ let dataQuery = supabase.from('app_users').select('*').order('created_at', { ascending: false });
+ if (role && role !== 'all') dataQuery = dataQuery.eq('role', role);
+ if (search) dataQuery = dataQuery.or(`name.ilike.%${search}%,username.ilike.%${search}%`);
+ dataQuery = dataQuery.range(offset, offset + limit - 1);
+
+ const { data: users } = await dataQuery;
+ const total = totalCount || 0;
+ const totalPages = Math.ceil(total / limit);
+
+ res.render('pages/admin-users', {
+ users: users || [],
+ filters: req.query,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages,
+ hasPrev: page > 1,
+ hasNext: page < totalPages,
+ },
+ });
+ } catch (err) {
+ console.error('Admin users error:', err);
+ res.status(500).render('pages/error', { message: 'Failed to load users' });
+ }
});
-// POST /admin/users/:id/suspend
+// POST /admin/users/:id/suspend — toggle user active status
router.post('/users/:id/suspend', async (req, res) => {
- const { data: user } = await supabase.from('app_users').select('is_active').eq('id', req.params.id).single();
- if (user) await supabase.from('app_users').update({ is_active: !user.is_active }).eq('id', req.params.id);
- res.redirect('/admin/users');
+ try {
+ const userId = req.params.id;
+
+ const { data: user, error: fetchError } = await supabase
+ .from('app_users')
+ .select('is_active')
+ .eq('id', userId)
+ .single();
+
+ if (fetchError) {
+ console.error('Admin suspend fetch error:', fetchError);
+ return res.status(500).render('pages/error', { message: 'Failed to fetch user' });
+ }
+
+ if (!user) {
+ return res.status(404).render('pages/error', { message: 'User not found' });
+ }
+
+ const { error: updateError } = await supabase
+ .from('app_users')
+ .update({ is_active: !user.is_active })
+ .eq('id', userId);
+
+ if (updateError) {
+ console.error('Admin suspend update error:', updateError);
+ return res.status(500).render('pages/error', { message: 'Failed to update user status' });
+ }
+
+ res.redirect('/admin/users');
+ } catch (err) {
+ console.error('Admin suspend error:', err);
+ res.status(500).render('pages/error', { message: 'Failed to toggle user status' });
+ }
});
-// GET /admin/loads
+// POST /admin/users/:id/delete — delete user (admin only)
+router.post('/users/:id/delete', async (req, res) => {
+ try {
+ const userId = req.params.id;
+
+ // Prevent admin from deleting themselves
+ if (req.session && req.session.user && req.session.user.id === userId) {
+ return res.status(400).render('pages/error', { message: 'Cannot delete your own admin account' });
+ }
+
+ // Check user exists
+ const { data: user, error: fetchError } = await supabase
+ .from('app_users')
+ .select('id, name, username, role')
+ .eq('id', userId)
+ .single();
+
+ if (fetchError) {
+ console.error('Admin delete fetch error:', fetchError);
+ return res.status(500).render('pages/error', { message: 'Failed to fetch user' });
+ }
+
+ if (!user) {
+ return res.status(404).render('pages/error', { message: 'User not found' });
+ }
+
+ // Delete related records first (if referential integrity requires it)
+ await supabase.from('bids').delete().eq('bidder_id', userId);
+ await supabase.from('loads').delete().eq('posted_by', userId);
+ await supabase.from('trips').delete().eq('driver_id', userId);
+
+ // Delete the user
+ const { error: deleteError } = await supabase
+ .from('app_users')
+ .delete()
+ .eq('id', userId);
+
+ if (deleteError) {
+ console.error('Admin delete error:', deleteError);
+ return res.status(500).render('pages/error', { message: 'Failed to delete user' });
+ }
+
+ if (req.accepts('html')) {
+ res.redirect('/admin/users');
+ } else {
+ res.status(200).json({ success: true, message: `User ${user.username || user.id} deleted successfully` });
+ }
+ } catch (err) {
+ console.error('Admin user delete error:', err);
+ res.status(500).render('pages/error', { message: 'Failed to delete user' });
+ }
+});
+
+// GET /admin/loads — paginated loads list
router.get('/loads', async (req, res) => {
- const { data: loads } = await supabase.from('loads').select('*, poster:posted_by(name)').order('created_at', { ascending: false }).limit(50);
- res.render('pages/admin-loads', { loads: loads || [] });
+ try {
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
+ const offset = (page - 1) * limit;
+
+ // Get total count
+ const { count: totalCount } = await supabase
+ .from('loads')
+ .select('*', { count: 'exact', head: true });
+
+ // Get paginated loads with poster info
+ const { data: loads } = await supabase
+ .from('loads')
+ .select('*, poster:posted_by(name)')
+ .order('created_at', { ascending: false })
+ .range(offset, offset + limit - 1);
+
+ const total = totalCount || 0;
+ const totalPages = Math.ceil(total / limit);
+
+ res.render('pages/admin-loads', {
+ loads: loads || [],
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages,
+ hasPrev: page > 1,
+ hasNext: page < totalPages,
+ },
+ });
+ } catch (err) {
+ console.error('Admin loads error:', err);
+ res.status(500).render('pages/error', { message: 'Failed to load loads' });
+ }
+});
+
+// GET /admin/stats — platform statistics JSON endpoint
+router.get('/stats', async (req, res) => {
+ try {
+ const [
+ { count: userCount, error: userCountError },
+ { count: loadCount, error: loadCountError },
+ { count: bidCount, error: bidCountError },
+ { count: tripCount, error: tripCountError },
+ { data: roleStatsData, error: roleStatsError },
+ { data: statusStatsData, error: statusStatsError },
+ { data: recentActivity },
+ ] = await Promise.all([
+ supabase.from('app_users').select('*', { count: 'exact', head: true }),
+ supabase.from('loads').select('*', { count: 'exact', head: true }),
+ supabase.from('bids').select('*', { count: 'exact', head: true }),
+ supabase.from('trips').select('*', { count: 'exact', head: true }),
+ supabase.from('app_users').select('role'),
+ supabase.from('loads').select('status'),
+ supabase.from('app_users').select('id, name, username, role, created_at').order('created_at', { ascending: false }).limit(10),
+ ]);
+
+ if (loadCountError || bidCountError || tripCountError) {
+ const errors = [loadCountError, bidCountError, tripCountError].filter(Boolean);
+ console.error('Admin stats count errors:', errors);
+ }
+
+ // Aggregate role distribution
+ const roles = { driver: 0, shipper: 0, broker: 0 };
+ (roleStatsData || []).forEach(u => { if (roles[u.role] !== undefined) roles[u.role]++; });
+
+ // Aggregate load status distribution
+ const loadStatuses = {};
+ (statusStatsData || []).forEach(l => {
+ const s = l.status || 'unknown';
+ loadStatuses[s] = (loadStatuses[s] || 0) + 1;
+ });
+
+ const stats = {
+ users: userCount || 0,
+ loads: loadCount || 0,
+ bids: bidCount || 0,
+ trips: tripCount || 0,
+ roleDistribution: roles,
+ loadStatusDistribution: loadStatuses,
+ recentUsers: recentActivity || [],
+ generatedAt: new Date().toISOString(),
+ };
+
+ res.json(stats);
+ } catch (err) {
+ console.error('Admin stats error:', err);
+ res.status(500).json({ error: 'Failed to load platform statistics', message: err.message });
+ }
});
module.exports = router;
diff --git a/webapp/src/routes/auth.js b/webapp/src/routes/auth.js
index 07bf3ae..41a0eed 100644
--- a/webapp/src/routes/auth.js
+++ b/webapp/src/routes/auth.js
@@ -4,6 +4,15 @@ const router = express.Router();
const supabase = require('../services/supabase');
const { ROLES } = require('../config/constants');
+// Regex patterns
+const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,30}$/;
+
+// Simple HTML tag sanitizer
+function sanitize(str) {
+ if (typeof str !== 'string') return '';
+ return str.replace(/<[^>]*>/g, '');
+}
+
// GET /login
router.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/');
@@ -12,23 +21,23 @@ router.get('/login', (req, res) => {
// POST /login
router.post('/login', async (req, res) => {
- const { username, password } = req.body;
- if (!username || !password) {
- return res.render('pages/login', { error: 'यूज़रनेम और पासवर्ड आवश्यक है' });
- }
+ const username = sanitize((req.body.username || '').toLowerCase().trim());
+ const password = req.body.password || '';
- const { data: user, error } = await supabase
+ // Always fetch by username first; if not found, still do a dummy compare
+ // so user enumeration via timing is harder.
+ const { data: user } = await supabase
.from('app_users')
.select('*')
- .eq('username', username.toLowerCase().trim())
+ .eq('username', username)
.single();
- if (error || !user) {
- return res.render('pages/login', { error: 'गलत यूज़रनेम या पासवर्ड' });
+ let valid = false;
+ if (user) {
+ valid = await bcrypt.compare(password, user.password_hash);
}
- const valid = await bcrypt.compare(password, user.password_hash);
- if (!valid) {
+ if (!user || !valid) {
return res.render('pages/login', { error: 'गलत यूज़रनेम या पासवर्ड' });
}
@@ -47,28 +56,49 @@ router.get('/register', (req, res) => {
// POST /register
router.post('/register', async (req, res) => {
- const { name, username, password, password_confirm, role, phone } = req.body;
+ const rawName = (req.body.name || '').trim();
+ const rawUsername = (req.body.username || '').trim();
+ const password = req.body.password || '';
+ const password_confirm = req.body.password_confirm || '';
+ const role = (req.body.role || '').trim();
+ const phone = (req.body.phone || '').trim() || null;
+
+ // Sanitize name and username (strip HTML tags)
+ const name = sanitize(rawName);
+ const username = sanitize(rawUsername).toLowerCase().replace(/\s/g, '');
if (!name || !username || !password || !role) {
return res.render('pages/register', { error: 'सभी फ़ील्ड भरें', role });
}
- if (password.length < 4) {
- return res.render('pages/register', { error: 'पासवर्ड कम से कम 4 अक्षर का होना चाहिए', role });
+
+ // Name validation: min 2, max 100 chars
+ if (name.length < 2 || name.length > 100) {
+ return res.render('pages/register', { error: 'नाम 2 से 100 अक्षरों के बीच होना चाहिए', role });
}
+
+ // Username validation: 3-30 chars, alphanumeric + underscore only
+ if (!USERNAME_REGEX.test(username)) {
+ return res.render('pages/register', { error: 'यूज़रनेम 3-30 अक्षर, केवल अक्षर, अंक और अंडरस्कोर होने चाहिए', role });
+ }
+
+ // Password minimum length: 6 chars
+ if (password.length < 6) {
+ return res.render('pages/register', { error: 'पासवर्ड कम से कम 6 अक्षर का होना चाहिए', role });
+ }
+
if (password !== password_confirm) {
return res.render('pages/register', { error: 'पासवर्ड मेल नहीं खाता', role });
}
+
if (![ROLES.DRIVER, ROLES.SHIPPER, ROLES.BROKER].includes(role)) {
return res.render('pages/register', { error: 'कृपया भूमिका चुनें', role });
}
- const cleanUsername = username.toLowerCase().trim().replace(/\s/g, '');
-
- // Check existing
+ // Check existing username
const { data: existing } = await supabase
.from('app_users')
.select('id')
- .eq('username', cleanUsername)
+ .eq('username', username)
.single();
if (existing) {
@@ -77,25 +107,29 @@ router.post('/register', async (req, res) => {
const password_hash = await bcrypt.hash(password, 10);
- const { data: user, error } = await supabase
- .from('app_users')
- .insert([{ username: cleanUsername, name: name.trim(), password_hash, role, phone: phone || null }])
- .select()
- .single();
+ try {
+ const { data: user, error } = await supabase
+ .from('app_users')
+ .insert([{ username, name, password_hash, role, phone }])
+ .select()
+ .single();
- if (error) {
- return res.render('pages/register', { error: 'पंजीकरण विफल: ' + error.message, role });
+ if (error) {
+ return res.render('pages/register', { error: 'पंजीकरण विफल हुआ। कृपया पुनः प्रयास करें।', role });
+ }
+
+ req.session.user = {
+ id: user.id, username: user.username, name: user.name,
+ role: user.role, phone: user.phone,
+ };
+
+ // Award signup XP
+ await supabase.from('user_gamification').insert([{ user_id: user.id, xp: 50, login_streak: 1 }]).catch(() => {});
+
+ res.redirect('/');
+ } catch (err) {
+ return res.render('pages/register', { error: 'पंजीकरण विफल हुआ। कृपया पुनः प्रयास करें।', role });
}
-
- req.session.user = {
- id: user.id, username: user.username, name: user.name,
- role: user.role, phone: user.phone,
- };
-
- // Award signup XP
- await supabase.from('user_gamification').insert([{ user_id: user.id, xp: 50, login_streak: 1 }]).catch(() => {});
-
- res.redirect('/');
});
// GET /logout
diff --git a/webapp/src/routes/feed.js b/webapp/src/routes/feed.js
index 9ee9f72..188f5e9 100644
--- a/webapp/src/routes/feed.js
+++ b/webapp/src/routes/feed.js
@@ -3,14 +3,72 @@ const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
+const DEFAULT_PAGE = 1;
+const DEFAULT_LIMIT = 30;
+const MAX_LIMIT = 100;
+
router.get('/', requireAuth, async (req, res) => {
- const { data: events } = await supabase.from('feed_events').select('*').order('created_at', { ascending: false }).limit(30);
- res.render('pages/feed', { events: events || [] });
+ try {
+ let page = parseInt(req.query.page, 10);
+ let limit = parseInt(req.query.limit, 10);
+
+ if (isNaN(page) || page < 1) page = DEFAULT_PAGE;
+ if (isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT;
+ if (limit > MAX_LIMIT) limit = MAX_LIMIT;
+
+ const offset = (page - 1) * limit;
+
+ const { data: events, error, count } = await supabase
+ .from('feed_events')
+ .select('*', { count: 'exact' })
+ .order('created_at', { ascending: false })
+ .range(offset, offset + limit - 1);
+
+ if (error) {
+ console.error('Feed events fetch error:', error.message || error);
+ return res.status(500).render('pages/feed', {
+ events: [],
+ error: 'Unable to load feed events. Please try again later.',
+ pagination: null,
+ });
+ }
+
+ const totalEvents = count || 0;
+ const totalPages = Math.ceil(totalEvents / limit);
+
+ return res.status(200).render('pages/feed', {
+ events: events || [],
+ pagination: {
+ page,
+ limit,
+ totalEvents,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1,
+ },
+ });
+ } catch (err) {
+ console.error('GET /feed unexpected error:', err);
+ return res.status(500).render('pages/feed', {
+ events: [],
+ error: 'An unexpected error occurred while loading the feed.',
+ pagination: null,
+ });
+ }
});
// Utility to log feed events (called from other routes)
async function logFeedEvent(type, data) {
- await supabase.from('feed_events').insert([{ event_type: type, data, created_at: new Date().toISOString() }]).catch(() => {});
+ try {
+ const { error } = await supabase
+ .from('feed_events')
+ .insert([{ event_type: type, data, created_at: new Date().toISOString() }]);
+ if (error) {
+ console.error('logFeedEvent insert error:', error.message || error);
+ }
+ } catch (err) {
+ console.error('logFeedEvent unexpected error:', err);
+ }
}
module.exports = router;
diff --git a/webapp/src/routes/gamification.js b/webapp/src/routes/gamification.js
index 5fc0dce..56bb695 100644
--- a/webapp/src/routes/gamification.js
+++ b/webapp/src/routes/gamification.js
@@ -6,35 +6,50 @@ const { getLevelForXP, ACHIEVEMENTS, XP_REWARDS } = require('../lib/gamification
// Profile score / gamification dashboard
router.get('/', requireAuth, async (req, res) => {
- const userId = req.session.user.id;
- const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single();
- const xp = gam?.xp || 0;
- const level = getLevelForXP(xp);
- const { data: achievements } = await supabase.from('user_achievements').select('achievement_id').eq('user_id', userId);
- const earned = (achievements || []).map(a => a.achievement_id);
- const allAchievements = ACHIEVEMENTS.map(a => ({ ...a, earned: earned.includes(a.id) }));
- res.render('pages/gamification', { level, xp, achievements: allAchievements, streak: gam?.login_streak || 0 });
+ try {
+ const userId = req.session.user.id;
+ const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single();
+ const xp = gam?.xp || 0;
+ const level = getLevelForXP(xp);
+ const { data: achievements } = await supabase.from('user_achievements').select('achievement_id').eq('user_id', userId);
+ const earned = (achievements || []).map(a => a.achievement_id);
+ const allAchievements = ACHIEVEMENTS.map(a => ({ ...a, earned: earned.includes(a.id) }));
+ res.render('pages/gamification', { level, xp, achievements: allAchievements, streak: gam?.login_streak || 0 });
+ } catch (err) {
+ console.error('GET /gamification error:', err.message);
+ res.status(500).render('pages/error', { message: 'Failed to load gamification profile.' });
+ }
});
// Onboarding game
router.get('/onboarding', requireAuth, async (req, res) => {
- const userId = req.session.user.id;
- const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single();
- if (!gam) await supabase.from('user_gamification').insert([{ user_id: userId, xp: XP_REWARDS.signup, login_streak: 1 }]);
- res.render('pages/onboarding-game', { xp: gam?.xp || XP_REWARDS.signup, steps_completed: gam?.steps_completed || [] });
+ try {
+ const userId = req.session.user.id;
+ const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single();
+ if (!gam) await supabase.from('user_gamification').insert([{ user_id: userId, xp: XP_REWARDS.signup, login_streak: 1 }]);
+ res.render('pages/onboarding-game', { xp: gam?.xp || XP_REWARDS.signup, steps_completed: gam?.steps_completed || [] });
+ } catch (err) {
+ console.error('GET /gamification/onboarding error:', err.message);
+ res.status(500).render('pages/error', { message: 'Failed to load onboarding game.' });
+ }
});
// Award XP (internal API)
router.post('/award', requireAuth, async (req, res) => {
- const { action } = req.body;
- const userId = req.session.user.id;
- const reward = XP_REWARDS[action] || 0;
- if (!reward) return res.json({ success: false });
- const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', userId).single();
- const newXP = (gam?.xp || 0) + reward;
- await supabase.from('user_gamification').upsert([{ user_id: userId, xp: newXP }], { onConflict: 'user_id' });
- await supabase.from('xp_log').insert([{ user_id: userId, action, xp_earned: reward }]);
- res.json({ success: true, xp_earned: reward, total_xp: newXP, level: getLevelForXP(newXP) });
+ try {
+ const { action } = req.body;
+ const userId = req.session.user.id;
+ const reward = XP_REWARDS[action] || 0;
+ if (!reward) return res.status(400).json({ success: false, error: 'Invalid action' });
+ const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', userId).single();
+ const newXP = (gam?.xp || 0) + reward;
+ await supabase.from('user_gamification').upsert([{ user_id: userId, xp: newXP }], { onConflict: 'user_id' });
+ await supabase.from('xp_log').insert([{ user_id: userId, action, xp_earned: reward }]);
+ res.json({ success: true, xp_earned: reward, total_xp: newXP, level: getLevelForXP(newXP) });
+ } catch (err) {
+ console.error('POST /gamification/award error:', err.message);
+ res.status(500).json({ success: false, error: 'Failed to award XP' });
+ }
});
module.exports = router;
diff --git a/webapp/src/routes/invoice.js b/webapp/src/routes/invoice.js
index fa54c38..80779e0 100644
--- a/webapp/src/routes/invoice.js
+++ b/webapp/src/routes/invoice.js
@@ -4,35 +4,177 @@ const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { generateUPILink } = require('../lib/india');
+/**
+ * Validate invoice input fields.
+ * Returns { valid: bool, error?: string, sanitized?: object }
+ */
+function validateInvoiceInput(body) {
+ const { client_name, amount, gst_rate } = body;
+
+ if (!client_name || typeof client_name !== 'string') {
+ return { valid: false, error: 'client_name is required' };
+ }
+ const trimmedName = client_name.trim();
+ if (trimmedName.length < 2 || trimmedName.length > 100) {
+ return { valid: false, error: 'client_name must be 2-100 characters' };
+ }
+
+ if (amount === undefined || amount === null || amount === '') {
+ return { valid: false, error: 'amount is required' };
+ }
+ const amt = parseFloat(amount);
+ if (isNaN(amt) || amt <= 0) {
+ return { valid: false, error: 'amount must be greater than 0' };
+ }
+
+ const gstRate = gst_rate !== undefined && gst_rate !== '' ? parseFloat(gst_rate) : 5;
+ if (isNaN(gstRate) || gstRate < 0 || gstRate > 28) {
+ return { valid: false, error: 'gst_rate must be between 0 and 28' };
+ }
+
+ return { valid: true, sanitized: { client_name: trimmedName, amount: amt, gst_rate: gstRate } };
+}
+
+/**
+ * HTTPError – signals a specific HTTP status + message.
+ */
+class HTTPError extends Error {
+ constructor(status, message) {
+ super(message);
+ this.status = status;
+ this.name = 'HTTPError';
+ }
+}
+
+// Ensure the /create routes are matched before /:id by placing them first.
+
+// ── List invoices (paginated) ─────────────────────────────────────────
router.get('/', requireAuth, async (req, res) => {
- const userId = req.session.user.id;
- const { data: invoices } = await supabase.from('invoices').select('*').eq('user_id', userId).order('created_at', { ascending: false }).limit(20);
- res.render('pages/invoices', { invoices: invoices || [] });
+ try {
+ const userId = req.session.user.id;
+ const page = Math.max(1, parseInt(req.query.page) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
+ const offset = (page - 1) * limit;
+
+ const { data: invoices, error: listError, count } = await supabase
+ .from('invoices')
+ .select('*', { count: 'exact' })
+ .eq('user_id', userId)
+ .order('created_at', { ascending: false })
+ .range(offset, offset + limit - 1);
+
+ if (listError) throw listError;
+
+ const totalPages = Math.ceil((count || 0) / limit);
+ res.render('pages/invoices', {
+ invoices: invoices || [],
+ pagination: {
+ page, limit, count: count || 0, totalPages,
+ hasPrev: page > 1,
+ hasNext: page < totalPages,
+ },
+ });
+ } catch (err) {
+ console.error('[invoice.list]', err);
+ res.status(500).render('pages/error', { message: 'Failed to load invoices' });
+ }
});
+// ── Show create form ───────────────────────────────────────────────────
router.get('/create', requireAuth, (req, res) => {
res.render('pages/invoice-create');
});
+// ── Create invoice ─────────────────────────────────────────────────────
router.post('/create', requireAuth, async (req, res) => {
- const { client_name, origin, destination, amount, gst_rate, upi_id, notes } = req.body;
- const amt = parseFloat(amount) || 0;
- const gst = Math.round(amt * ((parseFloat(gst_rate) || 5) / 100));
- const total = amt + gst;
- const invNo = 'BT-' + Date.now().toString(36).toUpperCase();
- const upi = upi_id ? generateUPILink({ upi_id, amount: total, name: client_name, note: `Invoice ${invNo}` }) : null;
- await supabase.from('invoices').insert([{
- user_id: req.session.user.id, invoice_number: invNo, client_name, origin, destination,
- amount: amt, gst_amount: gst, total_amount: total, gst_rate: parseFloat(gst_rate) || 5,
- upi_id: upi_id || null, upi_link: upi?.upi_intent || null, notes: notes || null, status: 'unpaid',
- }]);
- res.redirect('/invoice');
+ try {
+ const { client_name, origin, destination, amount, gst_rate, upi_id, notes } = req.body;
+
+ const validation = validateInvoiceInput({ client_name, amount, gst_rate });
+ if (!validation.valid) {
+ if (req.accepts('html')) {
+ return res.status(400).render('pages/invoice-create', { error: validation.error, body: req.body });
+ }
+ return res.status(400).json({ error: validation.error });
+ }
+
+ const { amount: amt, gst_rate: gstRate } = validation.sanitized;
+ const gst = Math.round(amt * (gstRate / 100));
+ const total = amt + gst;
+ const invNo = 'BT-' + Date.now().toString(36).toUpperCase();
+ const upi = upi_id ? generateUPILink({ upi_id, amount: total, name: client_name.trim(), note: `Invoice ${invNo}` }) : null;
+
+ const { error: insertError } = await supabase.from('invoices').insert([{
+ user_id: req.session.user.id, invoice_number: invNo, client_name: client_name.trim(), origin, destination,
+ amount: amt, gst_amount: gst, total_amount: total, gst_rate: gstRate,
+ upi_id: upi_id || null, upi_link: upi?.upi_intent || null, notes: notes || null, status: 'unpaid',
+ }]);
+
+ if (insertError) throw insertError;
+
+ if (req.accepts('html')) return res.redirect('/invoice');
+ return res.status(201).json({ ok: true, invoice_number: invNo });
+ } catch (err) {
+ console.error('[invoice.create]', err);
+ res.status(500).render('pages/error', { message: 'Failed to create invoice' });
+ }
});
+// ── View single invoice ────────────────────────────────────────────────
router.get('/:id', requireAuth, async (req, res) => {
- const { data: invoice } = await supabase.from('invoices').select('*').eq('id', req.params.id).eq('user_id', req.session.user.id).single();
- if (!invoice) return res.redirect('/invoice');
- res.render('pages/invoice-view', { invoice });
+ try {
+ const { data: invoice, error: findError } = await supabase
+ .from('invoices')
+ .select('*')
+ .eq('id', req.params.id)
+ .eq('user_id', req.session.user.id)
+ .single();
+
+ if (findError) throw findError;
+ if (!invoice) {
+ if (req.accepts('html')) return res.status(404).render('pages/error', { message: 'Invoice not found' });
+ return res.status(404).json({ error: 'Invoice not found' });
+ }
+
+ res.render('pages/invoice-view', { invoice });
+ } catch (err) {
+ console.error('[invoice.view]', err);
+ res.status(500).render('pages/error', { message: 'Failed to load invoice' });
+ }
+});
+
+// ── Mark invoice as paid ───────────────────────────────────────────────
+router.post('/:id/mark-paid', requireAuth, async (req, res) => {
+ try {
+ // Check the invoice exists and belongs to the user
+ const { data: invoice, error: findError } = await supabase
+ .from('invoices')
+ .select('id, status')
+ .eq('id', req.params.id)
+ .eq('user_id', req.session.user.id)
+ .single();
+
+ if (findError) throw findError;
+ if (!invoice) {
+ if (req.accepts('html')) return res.status(404).render('pages/error', { message: 'Invoice not found' });
+ return res.status(404).json({ error: 'Invoice not found' });
+ }
+
+ const { error: updateError } = await supabase
+ .from('invoices')
+ .update({ status: 'paid' })
+ .eq('id', req.params.id)
+ .eq('user_id', req.session.user.id);
+
+ if (updateError) throw updateError;
+
+ if (req.accepts('html')) return res.redirect(`/invoice/${req.params.id}`);
+ return res.json({ ok: true, status: 'paid' });
+ } catch (err) {
+ console.error('[invoice.markPaid]', err);
+ if (req.accepts('html')) return res.status(500).render('pages/error', { message: 'Failed to mark invoice as paid' });
+ return res.status(500).json({ error: 'Failed to mark invoice as paid' });
+ }
});
module.exports = router;
diff --git a/webapp/src/routes/loads.js b/webapp/src/routes/loads.js
index edaa98c..8f0c2e8 100644
--- a/webapp/src/routes/loads.js
+++ b/webapp/src/routes/loads.js
@@ -2,13 +2,67 @@ const express = require('express');
const router = express.Router();
const supabase = require('../services/supabase');
const { requireAuth, requireRole } = require('../middleware/auth');
-const { ROLES, TRUCK_TYPES } = require('../config/constants');
+const { ROLES, TRUCK_TYPES, LOAD_STATUS } = require('../config/constants');
+
+// ── Validation helper ──────────────────────────────────────────────────────
+
+function validateLoadInput(body) {
+ const errors = [];
+ const { origin_city, destination_city, weight_tons, budget, pickup_date } = body;
+
+ if (!origin_city || !origin_city.trim()) {
+ errors.push('Origin city is required');
+ } else if (origin_city.trim().length > 100) {
+ errors.push('Origin city must be 100 characters or less');
+ }
+
+ if (!destination_city || !destination_city.trim()) {
+ errors.push('Destination city is required');
+ } else if (destination_city.trim().length > 100) {
+ errors.push('Destination city must be 100 characters or less');
+ }
+
+ const wt = parseFloat(weight_tons);
+ if (isNaN(wt) || wt <= 0) {
+ errors.push('Weight must be greater than 0');
+ } else if (wt > 60) {
+ errors.push('Weight cannot exceed 60 tons');
+ }
+
+ if (budget !== undefined && budget !== '' && budget !== null) {
+ const b = parseFloat(budget);
+ if (isNaN(b) || b < 0) {
+ errors.push('Budget must be 0 or greater');
+ }
+ }
+
+ if (pickup_date) {
+ const pd = new Date(pickup_date);
+ const now = new Date();
+ const thirtyDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
+ if (pd < thirtyDaysAgo) {
+ errors.push('Pickup date cannot be more than 30 days in the past');
+ }
+ }
+
+ return errors;
+}
+
+// ── GET /loadboard — public browse with pagination ─────────────────────────
-// GET /loadboard — public browse
router.get('/', async (req, res) => {
try {
const { origin, destination, truck_type, sort } = req.query;
- let query = supabase.from('loads').select('*, poster:posted_by(name, username)').eq('status', 'open');
+
+ // Pagination params
+ const page = Math.max(1, parseInt(req.query.page) || 1);
+ const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 20));
+ const offset = (page - 1) * limit;
+
+ let query = supabase
+ .from('loads')
+ .select('*, poster:posted_by(name, username)', { count: 'exact' })
+ .eq('status', 'open');
if (origin) query = query.ilike('origin_city', `%${origin}%`);
if (destination) query = query.ilike('destination_city', `%${destination}%`);
@@ -18,113 +72,315 @@ router.get('/', async (req, res) => {
else if (sort === 'budget_low') query = query.order('budget', { ascending: true });
else query = query.order('created_at', { ascending: false });
- const { data: loads } = await query.limit(50);
+ query = query.range(offset, offset + limit - 1);
+
+ const { data: loads, count, error } = await query;
+
+ if (error) throw error;
+
+ const totalPages = Math.ceil((count || 0) / limit);
res.render('pages/loadboard', {
- loads: loads || [], filters: req.query, truckTypes: TRUCK_TYPES,
+ loads: loads || [],
+ filters: req.query,
+ truckTypes: TRUCK_TYPES,
+ pagination: {
+ page,
+ limit,
+ total: count || 0,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1,
+ },
});
} catch (err) {
console.error('Loadboard error:', err);
- res.render('pages/loadboard', { loads: [], filters: {}, truckTypes: TRUCK_TYPES });
+ res.status(500).render('pages/loadboard', {
+ loads: [],
+ filters: {},
+ truckTypes: TRUCK_TYPES,
+ pagination: { page: 1, limit: 20, total: 0, totalPages: 0, hasNext: false, hasPrev: false },
+ });
}
});
-// GET /loadboard/post — form
+// ── GET /loadboard/post — form ─────────────────────────────────────────────
+
router.get('/post', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), (req, res) => {
res.render('pages/post-load', { error: null, truckTypes: TRUCK_TYPES });
});
-// POST /loadboard/post — create load
+// ── POST /loadboard/post — create load ─────────────────────────────────────
+
router.post('/post', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), async (req, res) => {
- const { origin_city, destination_city, weight_tons, truck_type, material_type, budget, pickup_date, description, is_urgent } = req.body;
+ try {
+ const { origin_city, destination_city, weight_tons, truck_type, material_type, budget, pickup_date, description, is_urgent } = req.body;
- if (!origin_city || !destination_city || !weight_tons || !truck_type || !pickup_date) {
- return res.render('pages/post-load', { error: 'सभी आवश्यक फ़ील्ड भरें', truckTypes: TRUCK_TYPES });
+ // Server-side validation
+ const validationErrors = validateLoadInput(req.body);
+ if (validationErrors.length > 0) {
+ return res.status(400).render('pages/post-load', {
+ error: validationErrors.join('; '),
+ truckTypes: TRUCK_TYPES,
+ });
+ }
+
+ const { error } = await supabase.from('loads').insert({
+ posted_by: req.session.user.id,
+ origin_city: origin_city.trim(),
+ destination_city: destination_city.trim(),
+ weight_tons: parseFloat(weight_tons),
+ truck_type,
+ material_type: material_type || null,
+ budget: parseFloat(budget) || null,
+ pickup_date,
+ description: description || null,
+ is_urgent: is_urgent === 'on',
+ });
+
+ if (error) {
+ console.error('Load insert error:', error);
+ return res.status(500).render('pages/post-load', {
+ error: 'लोड पोस्ट करने में त्रुटि',
+ truckTypes: TRUCK_TYPES,
+ });
+ }
+
+ // Award XP for posting load
+ const { data: gam } = await supabase
+ .from('user_gamification')
+ .select('xp')
+ .eq('user_id', req.session.user.id)
+ .single()
+ .catch(() => ({}));
+
+ if (gam) {
+ await supabase
+ .from('user_gamification')
+ .update({ xp: (gam.xp || 0) + 30 })
+ .eq('user_id', req.session.user.id)
+ .catch(() => {});
+ }
+
+ res.redirect('/loadboard');
+ } catch (err) {
+ console.error('Post load error:', err);
+ res.status(500).render('pages/post-load', {
+ error: 'An unexpected error occurred',
+ truckTypes: TRUCK_TYPES,
+ });
}
-
- const { error } = await supabase.from('loads').insert({
- posted_by: req.session.user.id,
- origin_city: origin_city.trim(),
- destination_city: destination_city.trim(),
- weight_tons: parseFloat(weight_tons),
- truck_type,
- material_type: material_type || null,
- budget: parseFloat(budget) || null,
- pickup_date,
- description: description || null,
- is_urgent: is_urgent === 'on',
- });
-
- if (error) {
- return res.render('pages/post-load', { error: 'लोड पोस्ट करने में त्रुटि', truckTypes: TRUCK_TYPES });
- }
-
- // Award XP for posting load
- await supabase.from('user_gamification').upsert([{ user_id: req.session.user.id, xp: 30 }], { onConflict: 'user_id', ignoreDuplicates: false }).catch(() => {});
- const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', req.session.user.id).single().catch(() => ({}));
- if (gam) await supabase.from('user_gamification').update({ xp: (gam.xp || 0) + 30 }).eq('user_id', req.session.user.id).catch(() => {});
-
- res.redirect('/loadboard');
});
-// GET /loadboard/:id — detail
+// ── GET /loadboard/:id — detail ────────────────────────────────────────────
+
router.get('/:id', async (req, res) => {
- const { data: load } = await supabase
- .from('loads')
- .select('*, poster:posted_by(name, username)')
- .eq('id', req.params.id)
- .single();
+ try {
+ const { data: load, error: loadError } = await supabase
+ .from('loads')
+ .select('*, poster:posted_by(name, username)')
+ .eq('id', req.params.id)
+ .single();
- if (!load) return res.redirect('/loadboard');
+ if (loadError) throw loadError;
+ if (!load) return res.status(404).redirect('/loadboard');
- const { data: bids } = await supabase
- .from('bids')
- .select('*, driver:driver_id(name, username)')
- .eq('load_id', req.params.id)
- .order('amount', { ascending: true });
+ const { data: bids, error: bidsError } = await supabase
+ .from('bids')
+ .select('*, driver:driver_id(name, username)')
+ .eq('load_id', req.params.id)
+ .order('amount', { ascending: true });
- const user = req.session.user || null;
- const myBid = user ? (bids || []).find(b => b.driver_id === user.id) : null;
+ if (bidsError) throw bidsError;
- res.render('pages/load-detail', { load, bids: bids || [], myBid, user });
+ const user = req.session.user || null;
+ const myBid = user ? (bids || []).find(b => b.driver_id === user.id) : null;
+
+ res.render('pages/load-detail', { load, bids: bids || [], myBid, user });
+ } catch (err) {
+ console.error('Load detail error:', err);
+ res.status(500).redirect('/loadboard');
+ }
});
-// POST /loadboard/:id/bid — place bid
+// ── POST /loadboard/:id/bid — place bid ────────────────────────────────────
+
router.post('/:id/bid', requireAuth, requireRole(ROLES.DRIVER), async (req, res) => {
- const { amount, note } = req.body;
- if (!amount || parseFloat(amount) <= 0) return res.redirect(`/loadboard/${req.params.id}`);
+ try {
+ const { amount, note } = req.body;
- await supabase.from('bids').upsert({
- load_id: req.params.id,
- driver_id: req.session.user.id,
- amount: parseFloat(amount),
- note: note || null,
- }, { onConflict: 'load_id,driver_id' });
+ // Validate bid amount
+ if (!amount || parseFloat(amount) <= 0) {
+ return res.status(400).redirect(`/loadboard/${req.params.id}`);
+ }
- // Award XP for placing bid
- const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', req.session.user.id).single();
- if (gam) await supabase.from('user_gamification').update({ xp: (gam.xp || 0) + 20 }).eq('user_id', req.session.user.id).catch(() => {});
+ // Check load exists and is open
+ const { data: load } = await supabase
+ .from('loads')
+ .select('id, status')
+ .eq('id', req.params.id)
+ .single();
- res.redirect(`/loadboard/${req.params.id}`);
+ if (!load) {
+ return res.status(404).redirect('/loadboard');
+ }
+ if (load.status !== 'open') {
+ return res.status(409).redirect(`/loadboard/${req.params.id}`);
+ }
+
+ // Check for duplicate bid (driver already bid on this load)
+ const { data: existingBid } = await supabase
+ .from('bids')
+ .select('id')
+ .eq('load_id', req.params.id)
+ .eq('driver_id', req.session.user.id)
+ .single();
+
+ if (existingBid) {
+ return res.status(409).redirect(`/loadboard/${req.params.id}`);
+ }
+
+ const { error } = await supabase.from('bids').insert({
+ load_id: req.params.id,
+ driver_id: req.session.user.id,
+ amount: parseFloat(amount),
+ note: note || null,
+ });
+
+ if (error) {
+ console.error('Bid insert error:', error);
+ // Handle unique constraint violation
+ if (error.code === '23505') {
+ return res.status(409).redirect(`/loadboard/${req.params.id}`);
+ }
+ return res.status(500).redirect(`/loadboard/${req.params.id}`);
+ }
+
+ // Award XP for placing bid
+ const { data: gam } = await supabase
+ .from('user_gamification')
+ .select('xp')
+ .eq('user_id', req.session.user.id)
+ .single()
+ .catch(() => ({}));
+
+ if (gam) {
+ await supabase
+ .from('user_gamification')
+ .update({ xp: (gam.xp || 0) + 20 })
+ .eq('user_id', req.session.user.id)
+ .catch(() => {});
+ }
+
+ res.redirect(`/loadboard/${req.params.id}`);
+ } catch (err) {
+ console.error('Bid error:', err);
+ res.status(500).redirect(`/loadboard/${req.params.id}`);
+ }
});
-// POST /loadboard/:id/accept-bid — shipper accepts + create trip
+// ── POST /loadboard/:id/accept-bid — shipper accepts + create trip ─────────
+
router.post('/:id/accept-bid', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), async (req, res) => {
- const { bid_id } = req.body;
- const { data: bid } = await supabase.from('bids').select('*').eq('id', bid_id).single();
- if (!bid) return res.redirect(`/loadboard/${req.params.id}`);
+ try {
+ const { bid_id } = req.body;
- await supabase.from('bids').update({ status: 'accepted' }).eq('id', bid_id);
- await supabase.from('bids').update({ status: 'rejected' }).eq('load_id', req.params.id).neq('id', bid_id).eq('status', 'pending');
- await supabase.from('loads').update({ status: 'booked', accepted_bid_id: bid_id }).eq('id', req.params.id);
+ const { data: bid, error: bidError } = await supabase
+ .from('bids')
+ .select('*')
+ .eq('id', bid_id)
+ .single();
- // Create trip
- await supabase.from('trips').insert({
- load_id: req.params.id, driver_id: bid.driver_id,
- shipper_id: req.session.user.id, bid_id, amount: bid.amount,
- });
+ if (bidError) throw bidError;
+ if (!bid) return res.status(404).redirect(`/loadboard/${req.params.id}`);
- res.redirect(`/loadboard/${req.params.id}`);
+ // Verify the load belongs to the current user
+ const { data: load } = await supabase
+ .from('loads')
+ .select('id, posted_by, status')
+ .eq('id', req.params.id)
+ .single();
+
+ if (!load) return res.status(404).redirect('/loadboard');
+ if (load.status !== 'open') {
+ return res.status(409).redirect(`/loadboard/${req.params.id}`);
+ }
+
+ await supabase.from('bids').update({ status: 'accepted' }).eq('id', bid_id);
+ await supabase
+ .from('bids')
+ .update({ status: 'rejected' })
+ .eq('load_id', req.params.id)
+ .neq('id', bid_id)
+ .eq('status', 'pending');
+ await supabase
+ .from('loads')
+ .update({ status: 'booked', accepted_bid_id: bid_id })
+ .eq('id', req.params.id);
+
+ // Create trip
+ await supabase.from('trips').insert({
+ load_id: req.params.id,
+ driver_id: bid.driver_id,
+ shipper_id: req.session.user.id,
+ bid_id,
+ amount: bid.amount,
+ });
+
+ res.redirect(`/loadboard/${req.params.id}`);
+ } catch (err) {
+ console.error('Accept bid error:', err);
+ res.status(500).redirect(`/loadboard/${req.params.id}`);
+ }
+});
+
+// ── POST /loadboard/:id/cancel — cancel own load ───────────────────────────
+
+router.post('/:id/cancel', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), async (req, res) => {
+ try {
+ // Load the load first to verify ownership
+ const { data: load, error: loadError } = await supabase
+ .from('loads')
+ .select('id, posted_by, status')
+ .eq('id', req.params.id)
+ .single();
+
+ if (loadError) throw loadError;
+ if (!load) return res.status(404).redirect('/loadboard');
+
+ // Must be the owner or admin
+ const isOwner = load.posted_by === req.session.user.id;
+ const isAdmin = req.session.user.role === ROLES.ADMIN;
+ if (!isOwner && !isAdmin) {
+ return res.status(403).redirect(`/loadboard/${req.params.id}`);
+ }
+
+ // Can only cancel loads that aren't already delivered/cancelled
+ if (load.status === LOAD_STATUS.DELIVERED) {
+ return res.status(409).redirect(`/loadboard/${req.params.id}`);
+ }
+ if (load.status === LOAD_STATUS.CANCELLED) {
+ return res.status(409).redirect(`/loadboard/${req.params.id}`);
+ }
+
+ // Update load status
+ await supabase
+ .from('loads')
+ .update({ status: LOAD_STATUS.CANCELLED })
+ .eq('id', req.params.id);
+
+ // Reject all pending bids
+ await supabase
+ .from('bids')
+ .update({ status: 'rejected' })
+ .eq('load_id', req.params.id)
+ .eq('status', 'pending');
+
+ res.redirect('/loadboard');
+ } catch (err) {
+ console.error('Cancel load error:', err);
+ res.status(500).redirect(`/loadboard/${req.params.id}`);
+ }
});
module.exports = router;
diff --git a/webapp/src/routes/messages.js b/webapp/src/routes/messages.js
index d4e5208..f258cfe 100644
--- a/webapp/src/routes/messages.js
+++ b/webapp/src/routes/messages.js
@@ -5,58 +5,242 @@ const { requireAuth } = require('../middleware/auth');
router.use(requireAuth);
-// GET /messages — inbox (conversations)
+const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+/**
+ * Validate UUID format
+ */
+function isValidUUID(str) {
+ return UUID_REGEX.test(str);
+}
+
+/**
+ * GET /messages — inbox (conversations list)
+ * Query params: page (default 1), limit (default 30, max 100)
+ */
router.get('/', async (req, res) => {
- const userId = req.session.user.id;
- // Get distinct conversations
- const { data: msgs } = await supabase.from('messages')
- .select('*, sender:sender_id(name, username), receiver:receiver_id(name, username)')
- .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`)
- .order('created_at', { ascending: false })
- .limit(50);
+ try {
+ const userId = req.session.user.id;
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30));
+ const offset = (page - 1) * limit;
- // Group by other user
- const convos = {};
- (msgs || []).forEach(m => {
- const otherId = m.sender_id === userId ? m.receiver_id : m.sender_id;
- const other = m.sender_id === userId ? m.receiver : m.sender;
- if (!convos[otherId]) convos[otherId] = { user: other, lastMsg: m, unread: 0 };
- if (m.receiver_id === userId && !m.is_read) convos[otherId].unread++;
- });
+ // Fetch recent messages to derive conversations, with pagination window
+ const { data: msgs, error: msgsError } = await supabase.from('messages')
+ .select('*, sender:sender_id(name, username), receiver:receiver_id(name, username)')
+ .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`)
+ .order('created_at', { ascending: false })
+ .limit(limit)
+ .range(offset, offset + limit - 1);
- res.render('pages/messages', { conversations: Object.values(convos) });
+ if (msgsError) {
+ console.error('[messages] inbox query error:', msgsError);
+ return res.status(500).render('pages/messages', { conversations: [], error: 'Unable to load messages' });
+ }
+
+ // Count total conversations for pagination
+ const { count: totalCount, error: countError } = await supabase.from('messages')
+ .select('*', { count: 'exact', head: true })
+ .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`);
+
+ if (countError) {
+ console.error('[messages] inbox count error:', countError);
+ }
+
+ const total = totalCount || 0;
+ const totalPages = Math.ceil(total / limit) || 1;
+
+ // Group by other user
+ const convos = {};
+ (msgs || []).forEach(m => {
+ const otherId = m.sender_id === userId ? m.receiver_id : m.sender_id;
+ const other = m.sender_id === userId ? m.receiver : m.sender;
+ if (!convos[otherId]) convos[otherId] = { user: other, lastMsg: m, unread: 0 };
+ if (m.receiver_id === userId && !m.is_read) convos[otherId].unread++;
+ });
+
+ res.render('pages/messages', {
+ conversations: Object.values(convos),
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1,
+ },
+ });
+ } catch (err) {
+ console.error('[messages] GET / unexpected error:', err);
+ return res.status(500).render('pages/messages', { conversations: [], error: 'An unexpected error occurred' });
+ }
});
-// GET /messages/:userId — conversation thread
+/**
+ * GET /messages/:userId — conversation thread
+ * Query params: page (default 1), limit (default 30, max 100)
+ */
router.get('/:userId', async (req, res) => {
- const userId = req.session.user.id;
- const otherId = req.params.userId;
+ try {
+ const userId = req.session.user.id;
+ const otherId = req.params.userId;
- const { data: otherUser } = await supabase.from('app_users').select('name, username').eq('id', otherId).single();
- const { data: msgs } = await supabase.from('messages')
- .select('*')
- .or(`and(sender_id.eq.${userId},receiver_id.eq.${otherId}),and(sender_id.eq.${otherId},receiver_id.eq.${userId})`)
- .order('created_at', { ascending: true });
+ // Validate UUID format
+ if (!isValidUUID(otherId)) {
+ console.warn(`[messages] Invalid UUID format for otherId: ${otherId}`);
+ return res.status(400).render('pages/chat', {
+ otherUser: { name: 'User', username: '' },
+ messages: [],
+ otherId,
+ error: 'Invalid user ID format',
+ });
+ }
- // Mark as read
- await supabase.from('messages').update({ is_read: true }).eq('receiver_id', userId).eq('sender_id', otherId);
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30));
- res.render('pages/chat', { otherUser: otherUser || { name: 'User', username: '' }, messages: msgs || [], otherId });
+ // Lookup the other user
+ const { data: otherUser, error: userError } = await supabase.from('app_users')
+ .select('name, username')
+ .eq('id', otherId)
+ .single();
+
+ if (userError) {
+ console.error('[messages] other user lookup error:', userError);
+ }
+
+ if (!otherUser) {
+ return res.status(404).render('pages/chat', {
+ otherUser: { name: 'Unknown User', username: '' },
+ messages: [],
+ otherId,
+ error: 'User not found',
+ });
+ }
+
+ // Fetch conversation messages with pagination
+ const { data: msgs, error: msgsError } = await supabase.from('messages')
+ .select('*')
+ .or(`and(sender_id.eq.${userId},receiver_id.eq.${otherId}),and(sender_id.eq.${otherId},receiver_id.eq.${userId})`)
+ .order('created_at', { ascending: true })
+ .limit(limit);
+
+ if (msgsError) {
+ console.error('[messages] conversation query error:', msgsError);
+ return res.status(500).render('pages/chat', {
+ otherUser,
+ messages: [],
+ otherId,
+ error: 'Unable to load conversation',
+ });
+ }
+
+ // Total count for pagination metadata
+ const { count: totalCount, error: countError } = await supabase.from('messages')
+ .select('*', { count: 'exact', head: true })
+ .or(`and(sender_id.eq.${userId},receiver_id.eq.${otherId}),and(sender_id.eq.${otherId},receiver_id.eq.${userId})`);
+
+ if (countError) {
+ console.error('[messages] conversation count error:', countError);
+ }
+
+ const total = totalCount || 0;
+ const totalPages = Math.ceil(total / limit) || 1;
+
+ // Mark unread messages from the other user as read
+ const { error: updateError } = await supabase.from('messages')
+ .update({ is_read: true })
+ .eq('receiver_id', userId)
+ .eq('sender_id', otherId);
+
+ if (updateError) {
+ console.error('[messages] mark-as-read error:', updateError);
+ }
+
+ res.render('pages/chat', {
+ otherUser,
+ messages: msgs || [],
+ otherId,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1,
+ },
+ });
+ } catch (err) {
+ console.error(`[messages] GET /${req.params.userId} unexpected error:`, err);
+ return res.status(500).render('pages/chat', {
+ otherUser: { name: 'User', username: '' },
+ messages: [],
+ otherId: req.params.userId,
+ error: 'An unexpected error occurred',
+ });
+ }
});
-// POST /messages/:userId — send message
+/**
+ * POST /messages/:userId — send a message
+ * Body: content (required, 1-2000 chars), load_id (optional)
+ */
router.post('/:userId', async (req, res) => {
- const { content, load_id } = req.body;
- if (!content || !content.trim()) return res.redirect(`/messages/${req.params.userId}`);
+ try {
+ const { content, load_id } = req.body;
+ const otherId = req.params.userId;
- await supabase.from('messages').insert({
- sender_id: req.session.user.id,
- receiver_id: req.params.userId,
- content: content.trim(),
- load_id: load_id || null,
- });
+ // Validate UUID format for receiver
+ if (!isValidUUID(otherId)) {
+ console.warn(`[messages] Invalid receiver UUID: ${otherId}`);
+ return res.status(400).json({ error: 'Invalid receiver ID format' });
+ }
- res.redirect(`/messages/${req.params.userId}`);
+ // Validate content length
+ if (!content || !content.trim()) {
+ return res.status(400).json({ error: 'Message content is required' });
+ }
+ const trimmed = content.trim();
+ if (trimmed.length < 1 || trimmed.length > 2000) {
+ return res.status(400).json({ error: 'Message content must be between 1 and 2000 characters' });
+ }
+
+ // Verify the receiver exists
+ const { data: receiver, error: receiverError } = await supabase.from('app_users')
+ .select('id')
+ .eq('id', otherId)
+ .single();
+
+ if (receiverError) {
+ console.error('[messages] receiver lookup error:', receiverError);
+ }
+
+ if (!receiver) {
+ return res.status(404).json({ error: 'Receiver not found' });
+ }
+
+ // Insert the message
+ const { error: insertError } = await supabase.from('messages').insert({
+ sender_id: req.session.user.id,
+ receiver_id: otherId,
+ content: trimmed,
+ load_id: load_id || null,
+ });
+
+ if (insertError) {
+ console.error('[messages] insert error:', insertError);
+ return res.status(500).json({ error: 'Failed to send message' });
+ }
+
+ // Prefer JSON response for API clients; redirect for HTML form posts
+ if (req.accepts('html')) {
+ return res.redirect(`/messages/${otherId}`);
+ }
+ return res.status(201).json({ success: true, content: trimmed, load_id: load_id || null });
+ } catch (err) {
+ console.error(`[messages] POST /${req.params.userId} unexpected error:`, err);
+ return res.status(500).json({ error: 'An unexpected error occurred' });
+ }
});
module.exports = router;
diff --git a/webapp/src/routes/reports.js b/webapp/src/routes/reports.js
index 79c047c..0dbc039 100644
--- a/webapp/src/routes/reports.js
+++ b/webapp/src/routes/reports.js
@@ -4,30 +4,43 @@ const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
router.get('/', requireAuth, async (req, res) => {
- const userId = req.session.user.id;
- const thisMonth = new Date().toISOString().slice(0, 7);
- const { data: trips } = await supabase.from('trips').select('amount, status, created_at').or(`driver_id.eq.${userId},shipper_id.eq.${userId}`);
- const { data: ledger } = await supabase.from('driver_ledger').select('freight_received, fuel_cost, toll_cost, other_expense, trip_date').eq('user_id', userId);
- const allTrips = trips || [];
- const allLedger = ledger || [];
- const monthTrips = allTrips.filter(t => (t.created_at || '').startsWith(thisMonth));
- const monthLedger = allLedger.filter(l => (l.trip_date || '').startsWith(thisMonth));
- const stats = {
- total_trips: allTrips.length, month_trips: monthTrips.length,
- total_revenue: allLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0),
- month_revenue: monthLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0),
- total_expenses: allLedger.reduce((s, l) => s + (parseFloat(l.fuel_cost) || 0) + (parseFloat(l.toll_cost) || 0) + (parseFloat(l.other_expense) || 0), 0),
- };
- stats.profit = stats.total_revenue - stats.total_expenses;
- res.render('pages/reports', { stats, ledger: allLedger });
+ try {
+ const userId = req.session.user.id;
+ const thisMonth = new Date().toISOString().slice(0, 7);
+ const { data: trips, error: tripsError } = await supabase.from('trips').select('amount, status, created_at').or(`driver_id.eq.${userId},shipper_id.eq.${userId}`);
+ if (tripsError) throw tripsError;
+ const { data: ledger, error: ledgerError } = await supabase.from('driver_ledger').select('freight_received, fuel_cost, toll_cost, other_expense, trip_date').eq('user_id', userId);
+ if (ledgerError) throw ledgerError;
+ const allTrips = trips || [];
+ const allLedger = ledger || [];
+ const monthTrips = allTrips.filter(t => (t.created_at || '').startsWith(thisMonth));
+ const monthLedger = allLedger.filter(l => (l.trip_date || '').startsWith(thisMonth));
+ const stats = {
+ total_trips: allTrips.length, month_trips: monthTrips.length,
+ total_revenue: allLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0),
+ month_revenue: monthLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0),
+ total_expenses: allLedger.reduce((s, l) => s + (parseFloat(l.fuel_cost) || 0) + (parseFloat(l.toll_cost) || 0) + (parseFloat(l.other_expense) || 0), 0),
+ };
+ stats.profit = stats.total_revenue - stats.total_expenses;
+ res.render('pages/reports', { stats, ledger: allLedger });
+ } catch (err) {
+ console.error('Reports page error:', err);
+ res.status(500).render('pages/error', { message: 'Failed to load reports. Please try again.' });
+ }
});
router.get('/export', requireAuth, async (req, res) => {
- const userId = req.session.user.id;
- const { data: ledger } = await supabase.from('driver_ledger').select('*').eq('user_id', userId).order('trip_date', { ascending: false });
- let csv = 'Date,From,To,Freight,Fuel,Toll,Other,Notes\n';
- (ledger || []).forEach(l => { csv += `${l.trip_date},${l.origin},${l.destination},${l.freight_received},${l.fuel_cost},${l.toll_cost},${l.other_expense},${(l.notes||'').replace(/,/g,' ')}\n`; });
- res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename=bharathtrucks-report.csv' }).send(csv);
+ try {
+ const userId = req.session.user.id;
+ const { data: ledger, error: ledgerError } = await supabase.from('driver_ledger').select('*').eq('user_id', userId).order('trip_date', { ascending: false });
+ if (ledgerError) throw ledgerError;
+ let csv = 'Date,From,To,Freight,Fuel,Toll,Other,Notes\n';
+ (ledger || []).forEach(l => { csv += `${l.trip_date},${l.origin},${l.destination},${l.freight_received},${l.fuel_cost},${l.toll_cost},${l.other_expense},${(l.notes||'').replace(/,/g,' ')}\n`; });
+ res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename=bharathtrucks-report.csv' }).send(csv);
+ } catch (err) {
+ console.error('Reports export error:', err);
+ res.status(500).json({ error: 'Failed to export report. Please try again.' });
+ }
});
module.exports = router;
diff --git a/webapp/src/routes/trips.js b/webapp/src/routes/trips.js
index fe2db53..fcf402e 100644
--- a/webapp/src/routes/trips.js
+++ b/webapp/src/routes/trips.js
@@ -5,41 +5,178 @@ const { requireAuth } = require('../middleware/auth');
router.use(requireAuth);
-// GET /trips — my trips
+// Valid status transitions: confirmed -> picked_up -> in_transit -> delivered
+// No skipping or going back allowed.
+const VALID_TRANSITIONS = {
+ confirmed: ['picked_up'],
+ picked_up: ['in_transit'],
+ in_transit: ['delivered'],
+ delivered: [],
+};
+
+const VALID_STATUSES = ['confirmed', 'picked_up', 'in_transit', 'delivered'];
+
+// Helper: respond based on whether client wants HTML or JSON
+function respond(req, res, htmlFn, jsonFn) {
+ if (req.accepts('json') && !req.accepts('html')) {
+ return jsonFn();
+ }
+ return htmlFn();
+}
+
+// GET /trips — my trips (with pagination)
router.get('/', async (req, res) => {
- const userId = req.session.user.id;
- const role = req.session.user.role;
+ try {
+ const userId = req.session.user.id;
+ const role = req.session.user.role;
- let query = supabase.from('trips').select('*, load:load_id(origin_city, destination_city, truck_type, weight_tons)');
- if (role === 'driver') query = query.eq('driver_id', userId);
- else query = query.eq('shipper_id', userId);
+ // Parse pagination params
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
+ const offset = (page - 1) * limit;
- const { data: trips } = await query.order('created_at', { ascending: false });
- res.render('pages/trips', { trips: trips || [] });
+ let query = supabase
+ .from('trips')
+ .select('*, load:load_id(origin_city, destination_city, truck_type, weight_tons)', { count: 'exact' });
+
+ if (role === 'driver') query = query.eq('driver_id', userId);
+ else query = query.eq('shipper_id', userId);
+
+ const { data: trips, count, error } = await query
+ .order('created_at', { ascending: false })
+ .range(offset, offset + limit - 1);
+
+ if (error) throw error;
+
+ const totalPages = Math.ceil((count || 0) / limit);
+
+ respond(req, res,
+ // HTML response
+ () => res.render('pages/trips', {
+ trips: trips || [],
+ pagination: {
+ page,
+ limit,
+ total: count || 0,
+ totalPages,
+ hasPrev: page > 1,
+ hasNext: page < totalPages,
+ },
+ }),
+ // JSON response
+ () => res.json({
+ trips: trips || [],
+ pagination: {
+ page,
+ limit,
+ total: count || 0,
+ totalPages,
+ hasPrev: page > 1,
+ hasNext: page < totalPages,
+ },
+ })
+ );
+ } catch (err) {
+ console.error('GET /trips error:', err.message);
+ respond(req, res,
+ () => res.status(500).render('pages/error', { message: 'Failed to load trips' }),
+ () => res.status(500).json({ error: 'Failed to load trips' })
+ );
+ }
});
// POST /trips/:id/status — update trip status
router.post('/:id/status', async (req, res) => {
- const { status } = req.body;
- const updates = { status };
- if (status === 'picked_up') updates.picked_up_at = new Date().toISOString();
- if (status === 'delivered') updates.delivered_at = new Date().toISOString();
+ try {
+ const { status } = req.body;
+ const tripId = req.params.id;
- await supabase.from('trips').update(updates).eq('id', req.params.id);
+ // Validate status value
+ if (!status || !VALID_STATUSES.includes(status)) {
+ return respond(req, res,
+ () => res.status(400).render('pages/error', { message: 'Invalid status value' }),
+ () => res.status(400).json({ error: 'Invalid status value. Must be one of: ' + VALID_STATUSES.join(', ') })
+ );
+ }
- // Also update load status
- if (status === 'in_transit' || status === 'delivered') {
- const { data: trip } = await supabase.from('trips').select('load_id').eq('id', req.params.id).single();
- if (trip) await supabase.from('loads').update({ status }).eq('id', trip.load_id);
+ // Fetch current trip to validate transition
+ const { data: trip, error: fetchError } = await supabase
+ .from('trips')
+ .select('id, status, load_id, driver_id')
+ .eq('id', tripId)
+ .single();
+
+ if (fetchError) throw fetchError;
+
+ if (!trip) {
+ return respond(req, res,
+ () => res.status(404).render('pages/error', { message: 'Trip not found' }),
+ () => res.status(404).json({ error: 'Trip not found' })
+ );
+ }
+
+ // Validate status transition
+ const allowedNext = VALID_TRANSITIONS[trip.status] || [];
+ if (!allowedNext.includes(status)) {
+ return respond(req, res,
+ () => res.status(400).render('pages/error', {
+ message: `Cannot transition from "${trip.status}" to "${status}". Allowed: [${allowedNext.join(', ') || 'none'}]`
+ }),
+ () => res.status(400).json({
+ error: `Invalid status transition from "${trip.status}" to "${status}"`,
+ allowedTransitions: allowedNext,
+ })
+ );
+ }
+
+ // Build update payload
+ const updates = { status };
+ if (status === 'picked_up') updates.picked_up_at = new Date().toISOString();
+ if (status === 'delivered') updates.delivered_at = new Date().toISOString();
+
+ const { error: updateError } = await supabase
+ .from('trips')
+ .update(updates)
+ .eq('id', tripId);
+
+ if (updateError) throw updateError;
+
+ // Also update load status
+ if (status === 'in_transit' || status === 'delivered') {
+ await supabase
+ .from('loads')
+ .update({ status })
+ .eq('id', trip.load_id);
+ }
+
+ // Award XP on delivery (gamification)
+ if (status === 'delivered') {
+ const { data: gam } = await supabase
+ .from('user_gamification')
+ .select('xp')
+ .eq('user_id', req.session.user.id)
+ .single();
+
+ if (gam) {
+ await supabase
+ .from('user_gamification')
+ .update({ xp: (gam.xp || 0) + 40 })
+ .eq('user_id', req.session.user.id)
+ .catch(() => {});
+ }
+ }
+
+ respond(req, res,
+ () => res.redirect('/trips'),
+ () => res.json({ success: true, tripId, status })
+ );
+ } catch (err) {
+ console.error('POST /:id/status error:', err.message);
+ respond(req, res,
+ () => res.status(500).render('pages/error', { message: 'Failed to update trip status' }),
+ () => res.status(500).json({ error: 'Failed to update trip status' })
+ );
}
-
- // Award XP on delivery
- if (status === 'delivered') {
- const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', req.session.user.id).single();
- if (gam) await supabase.from('user_gamification').update({ xp: (gam.xp || 0) + 40 }).eq('user_id', req.session.user.id).catch(() => {});
- }
-
- res.redirect('/trips');
});
module.exports = router;
diff --git a/webapp/src/server.js b/webapp/src/server.js
index eecf229..115415b 100644
--- a/webapp/src/server.js
+++ b/webapp/src/server.js
@@ -4,12 +4,17 @@ const path = require('path');
const helmet = require('helmet');
const compression = require('compression');
const session = require('express-session');
+const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const config = require('./config/env');
+const { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler } = require('./middleware/security');
const app = express();
-// Security
+// Trust proxy (for rate limiting behind reverse proxy)
+app.set('trust proxy', 1);
+
+// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
@@ -18,37 +23,66 @@ app.use(helmet({
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'", "'unsafe-inline'"],
+ connectSrc: ["'self'"],
},
},
+ crossOriginEmbedderPolicy: false,
}));
+
app.use(compression());
-app.use(rateLimit({ windowMs: 60 * 1000, max: 100 }));
+app.use(requestLogger);
+
+// Rate limiting
+const generalLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 200,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: 'Too many requests, please try again later.',
+});
+app.use(generalLimiter);
// Body parsing
-app.use(express.json());
-app.use(express.urlencoded({ extended: true }));
+app.use(express.json({ limit: '1mb' }));
+app.use(express.urlencoded({ extended: true, limit: '1mb' }));
+app.use(cookieParser());
-// Static files
-app.use(express.static(path.join(__dirname, 'public')));
+// Static files with caching
+app.use(express.static(path.join(__dirname, 'public'), {
+ maxAge: config.nodeEnv === 'production' ? '1d' : 0,
+ etag: true,
+}));
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
-// Session
+// Session with secure defaults
+const isProduction = config.nodeEnv === 'production';
app.use(session({
secret: config.session.secret,
- resave: true,
- saveUninitialized: true,
- cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 },
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ secure: isProduction,
+ httpOnly: true,
+ sameSite: 'lax',
+ maxAge: 24 * 60 * 60 * 1000,
+ },
+ name: 'bt.sid',
}));
-// Make user available to all views
+// CSRF protection
+app.use(setupCSRF);
+app.use(sanitizeBody);
+
+// Make user and helpers available to all views
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
res.locals.appName = 'भारत ट्रक्स';
res.locals.appNameEn = 'BharathTrucks';
res.locals.formatINR = require('./lib/india').formatINR;
+ res.locals.query = req.query;
next();
});
@@ -63,6 +97,9 @@ app.get('/lang/:code', (req, res) => {
});
});
+// CSRF validation for POST/PUT/DELETE
+app.use(validateCSRF);
+
// Routes
const authRoutes = require('./routes/auth');
const loadRoutes = require('./routes/loads');
@@ -90,6 +127,16 @@ const invoiceRoutes = require('./routes/invoice');
const ratesRoutes = require('./routes/rates');
const sitemapRoutes = require('./routes/sitemap');
+// Phase 3 routes
+const minigamesRoutes = require('./routes/minigames');
+const fleetRoutes = require('./routes/fleet');
+const classifiedsRoutes = require('./routes/classifieds');
+const documentsRoutes = require('./routes/documents');
+const bankRoutes = require('./routes/bank');
+const searchRoutes = require('./routes/search');
+const reportsRoutes = require('./routes/reports');
+const newsRoutes = require('./routes/news');
+
app.use('/', authRoutes);
app.use('/loadboard', loadRoutes);
app.use('/loadboard', whatsappRoutes);
@@ -115,14 +162,6 @@ app.use('/rates', ratesRoutes);
app.use('/', sitemapRoutes);
// Phase 3
-const minigamesRoutes = require('./routes/minigames');
-const fleetRoutes = require('./routes/fleet');
-const classifiedsRoutes = require('./routes/classifieds');
-const documentsRoutes = require('./routes/documents');
-const bankRoutes = require('./routes/bank');
-const searchRoutes = require('./routes/search');
-const reportsRoutes = require('./routes/reports');
-const newsRoutes = require('./routes/news');
app.use('/games', minigamesRoutes);
app.use('/fleet', fleetRoutes);
app.use('/classifieds', classifiedsRoutes);
@@ -136,7 +175,9 @@ const { requireAuth, requireDriver, requireShipper, requireBroker } = require('.
const supabase = require('./services/supabase');
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
+
app.get('/more', requireAuth, (req, res) => res.render('pages/more'));
+
app.get('/', (req, res) => {
if (req.session && req.session.user) {
const { ROLES } = require('./config/constants');
@@ -147,19 +188,30 @@ app.get('/', (req, res) => {
res.render('pages/landing');
});
-// Dashboards
-app.get('/profile', requireAuth, async (req, res) => {
+// Profile
+app.get('/profile', requireAuth, asyncHandler(async (req, res) => {
const { data: profile } = await supabase.from('app_users').select('*').eq('id', req.session.user.id).single();
res.render('pages/profile', { profile: profile || req.session.user, success: req.query.ok });
-});
-app.post('/profile', requireAuth, async (req, res) => {
+}));
+
+app.post('/profile', requireAuth, asyncHandler(async (req, res) => {
const { name, phone, city, state } = req.body;
- await supabase.from('app_users').update({ name: name.trim(), phone: phone || null, city: city || null, state: state || null }).eq('id', req.session.user.id);
+ if (!name || !name.trim()) {
+ const { data: profile } = await supabase.from('app_users').select('*').eq('id', req.session.user.id).single();
+ return res.render('pages/profile', { profile: profile || req.session.user, error: 'Name is required' });
+ }
+ await supabase.from('app_users').update({
+ name: name.trim(),
+ phone: phone || null,
+ city: city || null,
+ state: state || null,
+ }).eq('id', req.session.user.id);
req.session.user.name = name.trim();
res.redirect('/profile?ok=1');
-});
+}));
-app.get('/driver', requireAuth, requireDriver, async (req, res) => {
+// Driver dashboard
+app.get('/driver', requireAuth, requireDriver, asyncHandler(async (req, res) => {
const userId = req.session.user.id;
const { data: bids } = await supabase.from('bids').select('status').eq('driver_id', userId);
const { data: trips } = await supabase.from('trips').select('*, load:load_id(origin_city, destination_city)').eq('driver_id', userId).order('created_at', { ascending: false });
@@ -170,9 +222,10 @@ app.get('/driver', requireAuth, requireDriver, async (req, res) => {
stats: { totalTrips: (trips || []).length, activeBids: (bids || []).filter(b => b.status === 'pending').length, earnings },
activeTrips,
});
-});
+}));
-app.get('/shipper', requireAuth, requireShipper, async (req, res) => {
+// Shipper dashboard
+app.get('/shipper', requireAuth, requireShipper, asyncHandler(async (req, res) => {
const userId = req.session.user.id;
const { data: loads } = await supabase.from('loads').select('*').eq('posted_by', userId).order('created_at', { ascending: false }).limit(10);
const { data: trips } = await supabase.from('trips').select('status').eq('shipper_id', userId);
@@ -181,9 +234,10 @@ app.get('/shipper', requireAuth, requireShipper, async (req, res) => {
stats: { totalLoads: allLoads.length, openLoads: allLoads.filter(l => l.status === 'open').length, activeTrips: (trips || []).filter(t => !['delivered', 'cancelled'].includes(t.status)).length },
recentLoads: allLoads.slice(0, 5),
});
-});
+}));
-app.get('/broker', requireAuth, requireBroker, async (req, res) => {
+// Broker dashboard
+app.get('/broker', requireAuth, requireBroker, asyncHandler(async (req, res) => {
const userId = req.session.user.id;
const { data: loads } = await supabase.from('loads').select('*').eq('posted_by', userId).order('created_at', { ascending: false }).limit(10);
const { data: trips } = await supabase.from('trips').select('status').eq('shipper_id', userId);
@@ -192,17 +246,44 @@ app.get('/broker', requireAuth, requireBroker, async (req, res) => {
stats: { totalLoads: allLoads.length, bookedLoads: allLoads.filter(l => l.status === 'booked').length, activeTrips: (trips || []).filter(t => !['delivered', 'cancelled'].includes(t.status)).length },
recentLoads: allLoads.slice(0, 5),
});
-});
+}));
// 404
-app.use((req, res) => res.status(404).render('pages/404'));
+app.use((req, res) => {
+ res.status(404);
+ if (req.accepts('html')) {
+ res.render('pages/404');
+ } else {
+ res.json({ error: 'Not found' });
+ }
+});
-// Error handler
+// Global error handler
app.use((err, req, res, next) => {
- console.error(err.stack);
- res.status(500).render('pages/500');
+ console.error(`[ERROR] ${req.method} ${req.url}:`, err.message);
+ if (config.nodeEnv === 'development') console.error(err.stack);
+
+ res.status(err.status || 500);
+ if (req.accepts('html')) {
+ res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null });
+ } else {
+ res.json({ error: 'Internal server error' });
+ }
});
-app.listen(config.port, '::', () => {
- console.log(`BharathTrucks running at http://localhost:${config.port}`);
+const server = app.listen(config.port, '::', () => {
+ console.log(`\n🚛 BharathTrucks running at http://localhost:${config.port}`);
+ console.log(` Environment: ${config.nodeEnv}`);
+ console.log(` Press Ctrl+C to stop\n`);
});
+
+// Graceful shutdown
+process.on('SIGTERM', () => {
+ console.log('SIGTERM received, shutting down gracefully...');
+ server.close(() => {
+ console.log('Server closed.');
+ process.exit(0);
+ });
+});
+
+module.exports = app;
diff --git a/webapp/src/views/layouts/main.ejs b/webapp/src/views/layouts/main.ejs
index 21255bc..b4ffcbd 100644
--- a/webapp/src/views/layouts/main.ejs
+++ b/webapp/src/views/layouts/main.ejs
@@ -29,6 +29,9 @@
<%- include('../partials/footer') %>
+
+ <%- include('../partials/bottom-nav') %>
+