v2.0: Major improvements - Security, Code Quality, UI/UX, Features

Security:
- Add CSRF protection on all forms
- Fix session config (resave:false, saveUninitialized:false)
- Secure cookie settings for production
- Input sanitization middleware
- Request logging middleware
- Security headers via Helmet

Code Quality:
- Async error handling on ALL route handlers
- Proper HTTP status codes (400, 401, 403, 404, 409, 500)
- Input validation on all forms (server-side)
- Username validation (3-30 chars, alphanumeric+underscore)
- Password min length increased to 6
- Generic error messages (no info leakage)
- Graceful shutdown on SIGTERM

UI/UX:
- Dark mode toggle with persistence
- Toast notifications for success/error
- Loading states on form submit
- Improved CSS with CSS variables
- Better desktop responsive design
- New 403 Forbidden page
- Pagination controls
- Improved header with desktop nav

Features:
- Pagination on all list pages (loads, trips, users, messages, etc.)
- Admin stats JSON endpoint
- Admin user delete route
- Load cancel route
- Mark invoice as paid route
- Search/filter preserved on loadboard

Database:
- Additional composite indexes for performance
- Updated timestamps trigger on trips
- Improved FULL migration script

DevEx:
- Development seed script (seed.js)
- Improved Dockerfile (non-root, healthcheck)
- Comprehensive .gitignore
- Updated README v2.0
This commit is contained in:
iamcoolvivek007 2026-05-31 18:08:01 +00:00
parent ed320e82c1
commit e9025a71eb
26 changed files with 2201 additions and 459 deletions

View file

@ -19,6 +19,14 @@ npm start # http://localhost:3000
**Default admin:** username=`admin`, password=`admin123` **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) ## Deploy to Production (Coolify + Hostinger VPS)
1. Push code to GitHub/GitLab 1. Push code to GitHub/GitLab
@ -34,22 +42,30 @@ npm start # http://localhost:3000
| Backend | Node.js + Express | | Backend | Node.js + Express |
| Views | EJS (server-rendered) | | Views | EJS (server-rendered) |
| Database | Supabase (PostgreSQL) | | Database | Supabase (PostgreSQL) |
| Auth | Username + Password (bcrypt) | | Auth | Username + Password (bcrypt) + CSRF |
| Styles | Custom CSS (govt-app theme) | | Security | Helmet, Rate Limiting, CSRF, Input Sanitization |
| Styles | Custom CSS v2 (govt-app theme, dark mode) |
| Deployment | Docker + Coolify | | Deployment | Docker + Coolify |
| PWA | Service Worker + Manifest | | PWA | Service Worker + Manifest |
## Features ## 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 - **Bidding** — Drivers bid on loads, shippers accept best bid
- **Trip Tracking** — Status flow: confirmed → picked up → in transit → delivered - **Trip Tracking** — Status flow: confirmed → picked up → in transit → delivered
- **Messaging** — Direct chat between users - **Messaging** — Direct chat between users
- **Dashboards** — Role-specific (driver/shipper/broker) with real stats - **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 - **WhatsApp Share** — Share loads via WhatsApp
- **Mobile-First** — Bottom nav, responsive, PWA installable - **Mobile-First** — Bottom nav, responsive, PWA installable
- **Govt-App Design** — Tricolor, navy theme, Hindi-first, trust signals - **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 ## User Roles
@ -65,24 +81,58 @@ npm start # http://localhost:3000
``` ```
webapp/ webapp/
├── src/ ├── src/
│ ├── server.js # Express app entry │ ├── server.js # Express app entry (security hardened)
│ ├── config/ # env.js, constants.js │ ├── config/ # env.js, constants.js
│ ├── middleware/ # auth.js │ ├── middleware/
│ ├── routes/ # auth, loads, trips, admin, messages │ │ ├── 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 │ ├── services/ # supabase.js
│ ├── views/pages/ # All EJS pages │ ├── views/pages/ # All EJS pages
│ ├── views/partials/ # header, footer, bottom-nav │ ├── 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 │ └── public/ # CSS, JS, manifest, SW
├── Dockerfile ├── seed.js # Development seed data script
├── Dockerfile # Production Docker config (alpine, non-root)
├── package.json ├── package.json
└── supabase-FULL-migration.sql ├── supabase-FULL-migration.sql
└── .env.example
``` ```
## Environment Variables ## Environment Variables
``` ```
SUPABASE_URL=https://your-project.supabase.co NODE_ENV=development
SUPABASE_KEY=your-anon-key
SESSION_SECRET=random-64-char-string
PORT=3000 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

40
webapp/.gitignore vendored
View file

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

View file

@ -1,16 +1,25 @@
FROM node:22-alpine # syntax=docker/dockerfile:1
FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ # Install dependencies first (layer caching)
RUN npm ci --omit=dev COPY package.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy application
COPY src ./src COPY src ./src
# Create non-root user
RUN addgroup -S app && adduser -S app -G app
USER app
# Metadata
ENV NODE_ENV=production ENV NODE_ENV=production
EXPOSE 3000 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 wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"] CMD ["node", "src/server.js"]

87
webapp/seed.js Normal file
View file

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

View file

@ -5,14 +5,28 @@ function requireAuth(req, res, next) {
res.locals.user = req.session.user; res.locals.user = req.session.user;
return next(); return next();
} }
res.redirect('/login'); if (req.accepts('html')) {
res.redirect('/login');
} else {
res.status(401).json({ error: 'Authentication required' });
}
} }
function requireRole(...roles) { function requireRole(...roles) {
return (req, res, next) => { return (req, res, next) => {
if (!req.session || !req.session.user) return res.redirect('/login'); if (!req.session || !req.session.user) {
if (roles.includes(req.session.user.role) || req.session.user.role === ROLES.ADMIN) return next(); if (req.accepts('html')) return res.redirect('/login');
res.redirect('/'); 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' });
}
}; };
} }

View file

@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
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 };

View file

@ -1,6 +1,7 @@
/* ============================================ /* ============================================
BharathTrucks Government Theme CSS BharathTrucks Government Theme CSS
Design System: Sarkari Trust, Modern Usability Design System: Sarkari Trust, Modern Usability
v2.0 Dark Mode, Toast, Loading, Desktop
============================================ */ ============================================ */
/* --- CSS Variables --- */ /* --- CSS Variables --- */
@ -17,33 +18,70 @@
--gray-100: #f5f5f5; --gray-100: #f5f5f5;
--gray-200: #eeeeee; --gray-200: #eeeeee;
--gray-300: #e0e0e0; --gray-300: #e0e0e0;
--gray-400: #bdbdbd;
--gray-500: #9e9e9e; --gray-500: #9e9e9e;
--gray-600: #757575;
--gray-700: #616161; --gray-700: #616161;
--gray-800: #424242;
--gray-900: #212121; --gray-900: #212121;
--red: #c62828; --red: #c62828;
--red-light: #ef5350;
--gold: #f9a825; --gold: #f9a825;
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 10px; --radius-md: 10px;
--radius-lg: 14px; --radius-lg: 14px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08); --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1); --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-xs: 4px;
--space-sm: 8px; --space-sm: 8px;
--space-md: 16px; --space-md: 16px;
--space-lg: 24px; --space-lg: 24px;
--space-xl: 32px; --space-xl: 32px;
--space-2xl: 48px; --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 --- */ /* --- Reset --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-text-size-adjust: 100%; } html { font-size: 16px; -webkit-text-size-adjust: 100%; }
body { body {
font-family: 'Noto Sans', 'Noto Sans Devanagari', -apple-system, BlinkMacSystemFont, sans-serif; font-family: 'Noto Sans', 'Noto Sans Devanagari', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--gray-900); color: var(--text);
background: var(--gray-100); background: var(--bg);
line-height: 1.6; line-height: 1.6;
min-height: 100vh; min-height: 100vh;
transition: background 0.3s, color 0.3s;
} }
a { color: var(--ashoka-blue); text-decoration: none; } a { color: var(--ashoka-blue); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
@ -62,6 +100,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
color: var(--white); color: var(--white);
padding: var(--space-md); padding: var(--space-md);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 100;
} }
.header-inner { .header-inner {
max-width: 1200px; 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; } .header-user { font-size: 0.8rem; opacity: 0.8; }
.btn-header-cta { .btn-header-cta {
background: var(--saffron); background: var(--saffron);
color: var(--white); color: var(--white) !important;
padding: 8px 16px; padding: 8px 16px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-weight: 600;
font-size: 0.8rem; font-size: 0.8rem;
text-decoration: none;
} }
.btn-header-cta:hover { background: var(--saffron-light); 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 --- */
.main-content { min-height: calc(100vh - 200px); } .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-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s; transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
text-decoration: none; text-decoration: none;
} }
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); text-decoration: none; } .btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); text-decoration: none; }
.btn:active { transform: translateY(0); } .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-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-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-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 { 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-lg { padding: 16px 32px; font-size: 1rem; border-radius: var(--radius-lg); }
.btn-sm { padding: 8px 16px; font-size: 0.8rem; } .btn-sm { padding: 8px 16px; font-size: 0.8rem; }
.btn-block { width: 100%; } .btn-block { width: 100%; }
/* --- Cards --- */ /* --- Cards --- */
.card { .card {
background: var(--white); background: var(--surface);
border: 1px solid var(--gray-300); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--space-lg); padding: var(--space-lg);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@ -148,16 +218,20 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* --- Forms --- */ /* --- Forms --- */
.form-group { margin-bottom: var(--space-md); } .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 { .form-input {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
border: 2px solid var(--gray-300); border: 2px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--surface);
color: var(--text);
transition: border-color 0.2s; 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: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; } .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 --- */ /* --- Badges --- */
@ -186,14 +260,21 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* --- Stats Grid --- */ /* --- Stats Grid --- */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-md); } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-md); }
.stat-card { .stat-card {
background: var(--white); background: var(--surface);
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-md); padding: var(--space-md);
text-align: center; text-align: center;
} }
.stat-value { font-size: 1.5rem; font-weight: 700; color: var(--navy); } .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 --- */
.container { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-md); } .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 --- */
.section { padding: var(--space-2xl) var(--space-md); } .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-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 --- */ /* --- Landing Hero --- */
.hero { .hero {
@ -235,12 +316,13 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--space-lg); padding: var(--space-lg);
text-align: center; text-align: center;
background: var(--surface);
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.role-card:hover { box-shadow: var(--shadow-md); } .role-card:hover { box-shadow: var(--shadow-md); }
.role-card-driver { border-color: var(--green); background: #f1f8e9; } .role-card-driver { border-color: var(--green); }
.role-card-shipper { border-color: var(--saffron); background: #fff8e1; } .role-card-shipper { border-color: var(--saffron); }
.role-card-broker { border-color: var(--ashoka-blue); background: #e3f2fd; } .role-card-broker { border-color: var(--ashoka-blue); }
.role-icon { font-size: 2.5rem; margin-bottom: var(--space-sm); } .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 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; } .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 --- */ /* --- How It Works --- */
.steps-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-md); counter-reset: step; } .steps-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-md); counter-reset: step; }
.step-card { .step-card {
background: var(--white); background: var(--surface);
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-lg); padding: var(--space-lg);
@ -274,32 +356,12 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
font-weight: 700; font-weight: 700;
} }
.step-card h4 { margin-left: 40px; font-size: 0.9rem; 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 Pages --- */
.error-page { text-align: center; padding: var(--space-2xl); } .error-page { text-align: center; padding: var(--space-2xl) var(--space-md); }
.error-page h1 { font-size: 3rem; color: var(--navy); } .error-page h1 { font-size: 3rem; }
.error-page p { color: var(--gray-700); margin: var(--space-md) 0; } .error-page p { color: var(--text-muted); 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; }
/* --- Auth Pages --- */ /* --- Auth Pages --- */
.alert-error { .alert-error {
@ -311,6 +373,15 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
font-size: 0.8rem; font-size: 0.8rem;
margin-bottom: var(--space-md); 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-select-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm); }
.role-option input { display: none; } .role-option input { display: none; }
.role-option-card { .role-option-card {
@ -319,7 +390,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 12px 8px; padding: 12px 8px;
border: 2px solid var(--gray-300); border: 2px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
font-size: 0.75rem; font-size: 0.75rem;
@ -335,8 +406,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background: var(--white); background: var(--surface);
border-top: 1px solid var(--gray-300); border-top: 1px solid var(--border);
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
padding: 6px 0 env(safe-area-inset-bottom, 6px); 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; align-items: center;
gap: 2px; gap: 2px;
font-size: 0.6rem; font-size: 0.6rem;
color: var(--gray-700); color: var(--text-muted);
text-decoration: none; text-decoration: none;
padding: 4px 8px; 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-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; } .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; } body { padding-bottom: 70px; }
@media (min-width: 768px) { .bottom-nav { display: none; } body { padding-bottom: 0; } }
/* --- Language Switcher --- */ /* --- Language Switcher --- */
.lang-switcher { display: flex; gap: 4px; margin-right: 12px; } .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: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); } .lang-btn.active { border-color: var(--saffron); background: rgba(255,255,255,0.25); }
/* --- Large Icon Nav (low-literacy) --- */ /* --- Icon Action Buttons --- */
.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-btn { .icon-action-btn {
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; padding: 20px 12px; 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); border-radius: var(--radius-md); text-decoration: none; color: var(--navy);
transition: border-color 0.2s, box-shadow 0.2s; 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:hover { background: var(--gray-100); }
.voice-btn.voice-active { animation: pulse 1s infinite; } .voice-btn.voice-active { animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{transform:translateY(-50%) scale(1)} 50%{transform:translateY(-50%) scale(1.2)} } @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); }

View file

@ -1,4 +1,118 @@
// BharathTrucks — Client-side JS // BharathTrucks — Client-side JS v2.0
// Service Worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {}); 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 = '<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' +
'<span class="toast-msg">' + msg + '</span>' +
'<button class="toast-close" onclick="this.parentElement.remove()">×</button>';
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);

View file

@ -7,44 +7,253 @@ router.use(requireAdmin);
// GET /admin — dashboard // GET /admin — dashboard
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { count: userCount } = await supabase.from('app_users').select('*', { count: 'exact', head: true }); try {
const { count: loadCount } = await supabase.from('loads').select('*', { count: 'exact', head: true }); const { count: userCount } = await supabase.from('app_users').select('*', { count: 'exact', head: true });
const { count: bidCount } = await supabase.from('bids').select('*', { count: 'exact', head: true }); const { count: loadCount } = await supabase.from('loads').select('*', { count: 'exact', head: true });
const { count: tripCount } = await supabase.from('trips').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 { data: roleStats } = await supabase.from('app_users').select('role');
const roles = { driver: 0, shipper: 0, broker: 0 }; const roles = { driver: 0, shipper: 0, broker: 0 };
(roleStats || []).forEach(u => { if (roles[u.role] !== undefined) roles[u.role]++; }); (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', { res.render('pages/admin-dashboard', {
stats: { users: userCount || 0, loads: loadCount || 0, bids: bidCount || 0, trips: tripCount || 0 }, stats: { users: userCount || 0, loads: loadCount || 0, bids: bidCount || 0, trips: tripCount || 0 },
roles, recentUsers: recentUsers || [], 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) => { router.get('/users', async (req, res) => {
const { role, search } = req.query; try {
let query = supabase.from('app_users').select('*').order('created_at', { ascending: false }); const page = Math.max(1, parseInt(req.query.page, 10) || 1);
if (role && role !== 'all') query = query.eq('role', role); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
if (search) query = query.or(`name.ilike.%${search}%,username.ilike.%${search}%`); const offset = (page - 1) * limit;
const { data: users } = await query.limit(100);
res.render('pages/admin-users', { users: users || [], filters: req.query }); 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) => { 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(); try {
if (user) await supabase.from('app_users').update({ is_active: !user.is_active }).eq('id', req.params.id); const userId = req.params.id;
res.redirect('/admin/users');
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) => { 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); try {
res.render('pages/admin-loads', { loads: loads || [] }); 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; module.exports = router;

View file

@ -4,6 +4,15 @@ const router = express.Router();
const supabase = require('../services/supabase'); const supabase = require('../services/supabase');
const { ROLES } = require('../config/constants'); 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 // GET /login
router.get('/login', (req, res) => { router.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/'); if (req.session.user) return res.redirect('/');
@ -12,23 +21,23 @@ router.get('/login', (req, res) => {
// POST /login // POST /login
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
const { username, password } = req.body; const username = sanitize((req.body.username || '').toLowerCase().trim());
if (!username || !password) { const password = req.body.password || '';
return res.render('pages/login', { error: 'यूज़रनेम और पासवर्ड आवश्यक है' });
}
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') .from('app_users')
.select('*') .select('*')
.eq('username', username.toLowerCase().trim()) .eq('username', username)
.single(); .single();
if (error || !user) { let valid = false;
return res.render('pages/login', { error: 'गलत यूज़रनेम या पासवर्ड' }); if (user) {
valid = await bcrypt.compare(password, user.password_hash);
} }
const valid = await bcrypt.compare(password, user.password_hash); if (!user || !valid) {
if (!valid) {
return res.render('pages/login', { error: 'गलत यूज़रनेम या पासवर्ड' }); return res.render('pages/login', { error: 'गलत यूज़रनेम या पासवर्ड' });
} }
@ -47,28 +56,49 @@ router.get('/register', (req, res) => {
// POST /register // POST /register
router.post('/register', async (req, res) => { 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) { if (!name || !username || !password || !role) {
return res.render('pages/register', { error: 'सभी फ़ील्ड भरें', 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) { if (password !== password_confirm) {
return res.render('pages/register', { error: 'पासवर्ड मेल नहीं खाता', role }); return res.render('pages/register', { error: 'पासवर्ड मेल नहीं खाता', role });
} }
if (![ROLES.DRIVER, ROLES.SHIPPER, ROLES.BROKER].includes(role)) { if (![ROLES.DRIVER, ROLES.SHIPPER, ROLES.BROKER].includes(role)) {
return res.render('pages/register', { error: 'कृपया भूमिका चुनें', role }); return res.render('pages/register', { error: 'कृपया भूमिका चुनें', role });
} }
const cleanUsername = username.toLowerCase().trim().replace(/\s/g, ''); // Check existing username
// Check existing
const { data: existing } = await supabase const { data: existing } = await supabase
.from('app_users') .from('app_users')
.select('id') .select('id')
.eq('username', cleanUsername) .eq('username', username)
.single(); .single();
if (existing) { if (existing) {
@ -77,25 +107,29 @@ router.post('/register', async (req, res) => {
const password_hash = await bcrypt.hash(password, 10); const password_hash = await bcrypt.hash(password, 10);
const { data: user, error } = await supabase try {
.from('app_users') const { data: user, error } = await supabase
.insert([{ username: cleanUsername, name: name.trim(), password_hash, role, phone: phone || null }]) .from('app_users')
.select() .insert([{ username, name, password_hash, role, phone }])
.single(); .select()
.single();
if (error) { if (error) {
return res.render('pages/register', { error: 'पंजीकरण विफल: ' + error.message, role }); 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 // GET /logout

View file

@ -3,14 +3,72 @@ const router = express.Router();
const supabase = require('../services/supabase'); const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
router.get('/', requireAuth, async (req, res) => { router.get('/', requireAuth, async (req, res) => {
const { data: events } = await supabase.from('feed_events').select('*').order('created_at', { ascending: false }).limit(30); try {
res.render('pages/feed', { events: events || [] }); 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) // Utility to log feed events (called from other routes)
async function logFeedEvent(type, data) { 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; module.exports = router;

View file

@ -6,35 +6,50 @@ const { getLevelForXP, ACHIEVEMENTS, XP_REWARDS } = require('../lib/gamification
// Profile score / gamification dashboard // Profile score / gamification dashboard
router.get('/', requireAuth, async (req, res) => { router.get('/', requireAuth, async (req, res) => {
const userId = req.session.user.id; try {
const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single(); const userId = req.session.user.id;
const xp = gam?.xp || 0; const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single();
const level = getLevelForXP(xp); const xp = gam?.xp || 0;
const { data: achievements } = await supabase.from('user_achievements').select('achievement_id').eq('user_id', userId); const level = getLevelForXP(xp);
const earned = (achievements || []).map(a => a.achievement_id); const { data: achievements } = await supabase.from('user_achievements').select('achievement_id').eq('user_id', userId);
const allAchievements = ACHIEVEMENTS.map(a => ({ ...a, earned: earned.includes(a.id) })); const earned = (achievements || []).map(a => a.achievement_id);
res.render('pages/gamification', { level, xp, achievements: allAchievements, streak: gam?.login_streak || 0 }); 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 // Onboarding game
router.get('/onboarding', requireAuth, async (req, res) => { router.get('/onboarding', requireAuth, async (req, res) => {
const userId = req.session.user.id; try {
const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single(); const userId = req.session.user.id;
if (!gam) await supabase.from('user_gamification').insert([{ user_id: userId, xp: XP_REWARDS.signup, login_streak: 1 }]); const { data: gam } = await supabase.from('user_gamification').select('*').eq('user_id', userId).single();
res.render('pages/onboarding-game', { xp: gam?.xp || XP_REWARDS.signup, steps_completed: gam?.steps_completed || [] }); 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) // Award XP (internal API)
router.post('/award', requireAuth, async (req, res) => { router.post('/award', requireAuth, async (req, res) => {
const { action } = req.body; try {
const userId = req.session.user.id; const { action } = req.body;
const reward = XP_REWARDS[action] || 0; const userId = req.session.user.id;
if (!reward) return res.json({ success: false }); const reward = XP_REWARDS[action] || 0;
const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', userId).single(); if (!reward) return res.status(400).json({ success: false, error: 'Invalid action' });
const newXP = (gam?.xp || 0) + reward; const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', userId).single();
await supabase.from('user_gamification').upsert([{ user_id: userId, xp: newXP }], { onConflict: 'user_id' }); const newXP = (gam?.xp || 0) + reward;
await supabase.from('xp_log').insert([{ user_id: userId, action, xp_earned: reward }]); await supabase.from('user_gamification').upsert([{ user_id: userId, xp: newXP }], { onConflict: 'user_id' });
res.json({ success: true, xp_earned: reward, total_xp: newXP, level: getLevelForXP(newXP) }); 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; module.exports = router;

View file

@ -4,35 +4,177 @@ const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { generateUPILink } = require('../lib/india'); 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) => { router.get('/', requireAuth, async (req, res) => {
const userId = req.session.user.id; try {
const { data: invoices } = await supabase.from('invoices').select('*').eq('user_id', userId).order('created_at', { ascending: false }).limit(20); const userId = req.session.user.id;
res.render('pages/invoices', { invoices: invoices || [] }); 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) => { router.get('/create', requireAuth, (req, res) => {
res.render('pages/invoice-create'); res.render('pages/invoice-create');
}); });
// ── Create invoice ─────────────────────────────────────────────────────
router.post('/create', requireAuth, async (req, res) => { router.post('/create', requireAuth, async (req, res) => {
const { client_name, origin, destination, amount, gst_rate, upi_id, notes } = req.body; try {
const amt = parseFloat(amount) || 0; const { client_name, origin, destination, amount, gst_rate, upi_id, notes } = req.body;
const gst = Math.round(amt * ((parseFloat(gst_rate) || 5) / 100));
const total = amt + gst; const validation = validateInvoiceInput({ client_name, amount, gst_rate });
const invNo = 'BT-' + Date.now().toString(36).toUpperCase(); if (!validation.valid) {
const upi = upi_id ? generateUPILink({ upi_id, amount: total, name: client_name, note: `Invoice ${invNo}` }) : null; if (req.accepts('html')) {
await supabase.from('invoices').insert([{ return res.status(400).render('pages/invoice-create', { error: validation.error, body: req.body });
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, return res.status(400).json({ error: validation.error });
upi_id: upi_id || null, upi_link: upi?.upi_intent || null, notes: notes || null, status: 'unpaid', }
}]);
res.redirect('/invoice'); 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) => { 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(); try {
if (!invoice) return res.redirect('/invoice'); const { data: invoice, error: findError } = await supabase
res.render('pages/invoice-view', { invoice }); .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; module.exports = router;

View file

@ -2,13 +2,67 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const supabase = require('../services/supabase'); const supabase = require('../services/supabase');
const { requireAuth, requireRole } = require('../middleware/auth'); 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) => { router.get('/', async (req, res) => {
try { try {
const { origin, destination, truck_type, sort } = req.query; 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 (origin) query = query.ilike('origin_city', `%${origin}%`);
if (destination) query = query.ilike('destination_city', `%${destination}%`); 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 if (sort === 'budget_low') query = query.order('budget', { ascending: true });
else query = query.order('created_at', { ascending: false }); 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', { 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) { } catch (err) {
console.error('Loadboard error:', 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) => { router.get('/post', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), (req, res) => {
res.render('pages/post-load', { error: null, truckTypes: TRUCK_TYPES }); 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) => { 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) { // Server-side validation
return res.render('pages/post-load', { error: 'सभी आवश्यक फ़ील्ड भरें', truckTypes: TRUCK_TYPES }); 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) => { router.get('/:id', async (req, res) => {
const { data: load } = await supabase try {
.from('loads') const { data: load, error: loadError } = await supabase
.select('*, poster:posted_by(name, username)') .from('loads')
.eq('id', req.params.id) .select('*, poster:posted_by(name, username)')
.single(); .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 const { data: bids, error: bidsError } = await supabase
.from('bids') .from('bids')
.select('*, driver:driver_id(name, username)') .select('*, driver:driver_id(name, username)')
.eq('load_id', req.params.id) .eq('load_id', req.params.id)
.order('amount', { ascending: true }); .order('amount', { ascending: true });
const user = req.session.user || null; if (bidsError) throw bidsError;
const myBid = user ? (bids || []).find(b => b.driver_id === user.id) : null;
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) => { router.post('/:id/bid', requireAuth, requireRole(ROLES.DRIVER), async (req, res) => {
const { amount, note } = req.body; try {
if (!amount || parseFloat(amount) <= 0) return res.redirect(`/loadboard/${req.params.id}`); const { amount, note } = req.body;
await supabase.from('bids').upsert({ // Validate bid amount
load_id: req.params.id, if (!amount || parseFloat(amount) <= 0) {
driver_id: req.session.user.id, return res.status(400).redirect(`/loadboard/${req.params.id}`);
amount: parseFloat(amount), }
note: note || null,
}, { onConflict: 'load_id,driver_id' });
// Award XP for placing bid // Check load exists and is open
const { data: gam } = await supabase.from('user_gamification').select('xp').eq('user_id', req.session.user.id).single(); const { data: load } = await supabase
if (gam) await supabase.from('user_gamification').update({ xp: (gam.xp || 0) + 20 }).eq('user_id', req.session.user.id).catch(() => {}); .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) => { router.post('/:id/accept-bid', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), async (req, res) => {
const { bid_id } = req.body; try {
const { data: bid } = await supabase.from('bids').select('*').eq('id', bid_id).single(); const { bid_id } = req.body;
if (!bid) return res.redirect(`/loadboard/${req.params.id}`);
await supabase.from('bids').update({ status: 'accepted' }).eq('id', bid_id); const { data: bid, error: bidError } = await supabase
await supabase.from('bids').update({ status: 'rejected' }).eq('load_id', req.params.id).neq('id', bid_id).eq('status', 'pending'); .from('bids')
await supabase.from('loads').update({ status: 'booked', accepted_bid_id: bid_id }).eq('id', req.params.id); .select('*')
.eq('id', bid_id)
.single();
// Create trip if (bidError) throw bidError;
await supabase.from('trips').insert({ if (!bid) return res.status(404).redirect(`/loadboard/${req.params.id}`);
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}`); // 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; module.exports = router;

View file

@ -5,58 +5,242 @@ const { requireAuth } = require('../middleware/auth');
router.use(requireAuth); 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) => { router.get('/', async (req, res) => {
const userId = req.session.user.id; try {
// Get distinct conversations const userId = req.session.user.id;
const { data: msgs } = await supabase.from('messages') const page = Math.max(1, parseInt(req.query.page, 10) || 1);
.select('*, sender:sender_id(name, username), receiver:receiver_id(name, username)') const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30));
.or(`sender_id.eq.${userId},receiver_id.eq.${userId}`) const offset = (page - 1) * limit;
.order('created_at', { ascending: false })
.limit(50);
// Group by other user // Fetch recent messages to derive conversations, with pagination window
const convos = {}; const { data: msgs, error: msgsError } = await supabase.from('messages')
(msgs || []).forEach(m => { .select('*, sender:sender_id(name, username), receiver:receiver_id(name, username)')
const otherId = m.sender_id === userId ? m.receiver_id : m.sender_id; .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`)
const other = m.sender_id === userId ? m.receiver : m.sender; .order('created_at', { ascending: false })
if (!convos[otherId]) convos[otherId] = { user: other, lastMsg: m, unread: 0 }; .limit(limit)
if (m.receiver_id === userId && !m.is_read) convos[otherId].unread++; .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) => { router.get('/:userId', async (req, res) => {
const userId = req.session.user.id; try {
const otherId = req.params.userId; 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(); // Validate UUID format
const { data: msgs } = await supabase.from('messages') if (!isValidUUID(otherId)) {
.select('*') console.warn(`[messages] Invalid UUID format for otherId: ${otherId}`);
.or(`and(sender_id.eq.${userId},receiver_id.eq.${otherId}),and(sender_id.eq.${otherId},receiver_id.eq.${userId})`) return res.status(400).render('pages/chat', {
.order('created_at', { ascending: true }); otherUser: { name: 'User', username: '' },
messages: [],
otherId,
error: 'Invalid user ID format',
});
}
// Mark as read const page = Math.max(1, parseInt(req.query.page, 10) || 1);
await supabase.from('messages').update({ is_read: true }).eq('receiver_id', userId).eq('sender_id', otherId); 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) => { router.post('/:userId', async (req, res) => {
const { content, load_id } = req.body; try {
if (!content || !content.trim()) return res.redirect(`/messages/${req.params.userId}`); const { content, load_id } = req.body;
const otherId = req.params.userId;
await supabase.from('messages').insert({ // Validate UUID format for receiver
sender_id: req.session.user.id, if (!isValidUUID(otherId)) {
receiver_id: req.params.userId, console.warn(`[messages] Invalid receiver UUID: ${otherId}`);
content: content.trim(), return res.status(400).json({ error: 'Invalid receiver ID format' });
load_id: load_id || null, }
});
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; module.exports = router;

View file

@ -4,30 +4,43 @@ const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
router.get('/', requireAuth, async (req, res) => { router.get('/', requireAuth, async (req, res) => {
const userId = req.session.user.id; try {
const thisMonth = new Date().toISOString().slice(0, 7); const userId = req.session.user.id;
const { data: trips } = await supabase.from('trips').select('amount, status, created_at').or(`driver_id.eq.${userId},shipper_id.eq.${userId}`); const thisMonth = new Date().toISOString().slice(0, 7);
const { data: ledger } = await supabase.from('driver_ledger').select('freight_received, fuel_cost, toll_cost, other_expense, trip_date').eq('user_id', userId); const { data: trips, error: tripsError } = await supabase.from('trips').select('amount, status, created_at').or(`driver_id.eq.${userId},shipper_id.eq.${userId}`);
const allTrips = trips || []; if (tripsError) throw tripsError;
const allLedger = ledger || []; 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);
const monthTrips = allTrips.filter(t => (t.created_at || '').startsWith(thisMonth)); if (ledgerError) throw ledgerError;
const monthLedger = allLedger.filter(l => (l.trip_date || '').startsWith(thisMonth)); const allTrips = trips || [];
const stats = { const allLedger = ledger || [];
total_trips: allTrips.length, month_trips: monthTrips.length, const monthTrips = allTrips.filter(t => (t.created_at || '').startsWith(thisMonth));
total_revenue: allLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0), const monthLedger = allLedger.filter(l => (l.trip_date || '').startsWith(thisMonth));
month_revenue: monthLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0), const stats = {
total_expenses: allLedger.reduce((s, l) => s + (parseFloat(l.fuel_cost) || 0) + (parseFloat(l.toll_cost) || 0) + (parseFloat(l.other_expense) || 0), 0), total_trips: allTrips.length, month_trips: monthTrips.length,
}; total_revenue: allLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0),
stats.profit = stats.total_revenue - stats.total_expenses; month_revenue: monthLedger.reduce((s, l) => s + (parseFloat(l.freight_received) || 0), 0),
res.render('pages/reports', { stats, ledger: allLedger }); 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) => { router.get('/export', requireAuth, async (req, res) => {
const userId = req.session.user.id; try {
const { data: ledger } = await supabase.from('driver_ledger').select('*').eq('user_id', userId).order('trip_date', { ascending: false }); const userId = req.session.user.id;
let csv = 'Date,From,To,Freight,Fuel,Toll,Other,Notes\n'; const { data: ledger, error: ledgerError } = await supabase.from('driver_ledger').select('*').eq('user_id', userId).order('trip_date', { ascending: false });
(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`; }); if (ledgerError) throw ledgerError;
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename=bharathtrucks-report.csv' }).send(csv); 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; module.exports = router;

View file

@ -5,41 +5,178 @@ const { requireAuth } = require('../middleware/auth');
router.use(requireAuth); 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) => { router.get('/', async (req, res) => {
const userId = req.session.user.id; try {
const role = req.session.user.role; 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)'); // Parse pagination params
if (role === 'driver') query = query.eq('driver_id', userId); const page = Math.max(1, parseInt(req.query.page, 10) || 1);
else query = query.eq('shipper_id', userId); 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 }); let query = supabase
res.render('pages/trips', { trips: trips || [] }); .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 // POST /trips/:id/status — update trip status
router.post('/:id/status', async (req, res) => { router.post('/:id/status', async (req, res) => {
const { status } = req.body; try {
const updates = { status }; const { status } = req.body;
if (status === 'picked_up') updates.picked_up_at = new Date().toISOString(); const tripId = req.params.id;
if (status === 'delivered') updates.delivered_at = new Date().toISOString();
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 // Fetch current trip to validate transition
if (status === 'in_transit' || status === 'delivered') { const { data: trip, error: fetchError } = await supabase
const { data: trip } = await supabase.from('trips').select('load_id').eq('id', req.params.id).single(); .from('trips')
if (trip) await supabase.from('loads').update({ status }).eq('id', trip.load_id); .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; module.exports = router;

View file

@ -4,12 +4,17 @@ const path = require('path');
const helmet = require('helmet'); const helmet = require('helmet');
const compression = require('compression'); const compression = require('compression');
const session = require('express-session'); const session = require('express-session');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const config = require('./config/env'); const config = require('./config/env');
const { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler } = require('./middleware/security');
const app = express(); const app = express();
// Security // Trust proxy (for rate limiting behind reverse proxy)
app.set('trust proxy', 1);
// Security headers
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
@ -18,37 +23,66 @@ app.use(helmet({
fontSrc: ["'self'", "https://fonts.gstatic.com"], fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"], imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "'unsafe-inline'"],
connectSrc: ["'self'"],
}, },
}, },
crossOriginEmbedderPolicy: false,
})); }));
app.use(compression()); 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 // Body parsing
app.use(express.json()); app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(cookieParser());
// Static files // Static files with caching
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public'), {
maxAge: config.nodeEnv === 'production' ? '1d' : 0,
etag: true,
}));
// View engine // View engine
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
// Session // Session with secure defaults
const isProduction = config.nodeEnv === 'production';
app.use(session({ app.use(session({
secret: config.session.secret, secret: config.session.secret,
resave: true, resave: false,
saveUninitialized: true, saveUninitialized: false,
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }, 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) => { app.use((req, res, next) => {
res.locals.user = req.session.user || null; res.locals.user = req.session.user || null;
res.locals.appName = 'भारत ट्रक्स'; res.locals.appName = 'भारत ट्रक्स';
res.locals.appNameEn = 'BharathTrucks'; res.locals.appNameEn = 'BharathTrucks';
res.locals.formatINR = require('./lib/india').formatINR; res.locals.formatINR = require('./lib/india').formatINR;
res.locals.query = req.query;
next(); next();
}); });
@ -63,6 +97,9 @@ app.get('/lang/:code', (req, res) => {
}); });
}); });
// CSRF validation for POST/PUT/DELETE
app.use(validateCSRF);
// Routes // Routes
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const loadRoutes = require('./routes/loads'); const loadRoutes = require('./routes/loads');
@ -90,6 +127,16 @@ const invoiceRoutes = require('./routes/invoice');
const ratesRoutes = require('./routes/rates'); const ratesRoutes = require('./routes/rates');
const sitemapRoutes = require('./routes/sitemap'); 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('/', authRoutes);
app.use('/loadboard', loadRoutes); app.use('/loadboard', loadRoutes);
app.use('/loadboard', whatsappRoutes); app.use('/loadboard', whatsappRoutes);
@ -115,14 +162,6 @@ app.use('/rates', ratesRoutes);
app.use('/', sitemapRoutes); app.use('/', sitemapRoutes);
// Phase 3 // 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('/games', minigamesRoutes);
app.use('/fleet', fleetRoutes); app.use('/fleet', fleetRoutes);
app.use('/classifieds', classifiedsRoutes); app.use('/classifieds', classifiedsRoutes);
@ -136,7 +175,9 @@ const { requireAuth, requireDriver, requireShipper, requireBroker } = require('.
const supabase = require('./services/supabase'); const supabase = require('./services/supabase');
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
app.get('/more', requireAuth, (req, res) => res.render('pages/more')); app.get('/more', requireAuth, (req, res) => res.render('pages/more'));
app.get('/', (req, res) => { app.get('/', (req, res) => {
if (req.session && req.session.user) { if (req.session && req.session.user) {
const { ROLES } = require('./config/constants'); const { ROLES } = require('./config/constants');
@ -147,19 +188,30 @@ app.get('/', (req, res) => {
res.render('pages/landing'); res.render('pages/landing');
}); });
// Dashboards // Profile
app.get('/profile', requireAuth, async (req, res) => { app.get('/profile', requireAuth, asyncHandler(async (req, res) => {
const { data: profile } = await supabase.from('app_users').select('*').eq('id', req.session.user.id).single(); 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 }); 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; 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(); req.session.user.name = name.trim();
res.redirect('/profile?ok=1'); 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 userId = req.session.user.id;
const { data: bids } = await supabase.from('bids').select('status').eq('driver_id', userId); 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 }); 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 }, stats: { totalTrips: (trips || []).length, activeBids: (bids || []).filter(b => b.status === 'pending').length, earnings },
activeTrips, 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 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: 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); 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 }, 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), 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 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: 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); 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 }, 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), recentLoads: allLoads.slice(0, 5),
}); });
}); }));
// 404 // 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) => { app.use((err, req, res, next) => {
console.error(err.stack); console.error(`[ERROR] ${req.method} ${req.url}:`, err.message);
res.status(500).render('pages/500'); 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, '::', () => { const server = app.listen(config.port, '::', () => {
console.log(`BharathTrucks running at http://localhost:${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;

View file

@ -29,6 +29,9 @@
<%- include('../partials/footer') %> <%- include('../partials/footer') %>
<!-- Desktop Bottom Nav hidden on mobile -->
<%- include('../partials/bottom-nav') %>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,22 @@
<%- include('../partials/header') %>
<div class="tricolor-strip"><div class="tricolor-saffron"></div><div class="tricolor-white"></div><div class="tricolor-green"></div></div>
<section class="error-page">
<div class="container">
<h1 style="color: var(--saffron);">🚫 403</h1>
<h2>Access Forbidden</h2>
<p>आपके पास इस पेज को देखने की अनुमति नहीं है।</p>
<p style="font-size: 0.85rem; color: var(--gray-500);">You don't have permission to access this page.</p>
<% if (typeof requiredRoles !== 'undefined' && requiredRoles) { %>
<p style="font-size: 0.8rem; color: var(--gray-500); margin-top: var(--space-sm);">
Required role: <%= requiredRoles.join(' or ') %>
</p>
<% } %>
<div style="margin-top: var(--space-lg); display: flex; gap: var(--space-sm); justify-content: center;">
<a href="javascript:history.back()" class="btn btn-outline btn-sm">← Go Back</a>
<a href="/" class="btn btn-primary btn-sm">🏠 Home</a>
</div>
</div>
</section>
<%- include('../partials/footer') %>

View file

@ -68,6 +68,27 @@
<% }) %> <% }) %>
</div> </div>
<% } %> <% } %>
<% if (typeof pagination !== 'undefined' && pagination.totalPages > 1) { %>
<div class="pagination">
<% if (pagination.hasPrev) { %>
<a href="?page=<%= pagination.page - 1 %>&limit=<%= pagination.limit %>&origin=<%= encodeURIComponent(filters.origin || '') %>&destination=<%= encodeURIComponent(filters.destination || '') %>&truck_type=<%= filters.truck_type || 'all' %>">←</a>
<% } else { %>
<span class="disabled">←</span>
<% } %>
<% for (let p = 1; p <= pagination.totalPages; p++) { %>
<% if (p === pagination.page) { %>
<span class="active"><%= p %></span>
<% } else { %>
<a href="?page=<%= p %>&limit=<%= pagination.limit %>&origin=<%= encodeURIComponent(filters.origin || '') %>&destination=<%= encodeURIComponent(filters.destination || '') %>&truck_type=<%= filters.truck_type || 'all' %>"><%= p %></a>
<% } %>
<% } %>
<% if (pagination.hasNext) { %>
<a href="?page=<%= pagination.page + 1 %>&limit=<%= pagination.limit %>&origin=<%= encodeURIComponent(filters.origin || '') %>&destination=<%= encodeURIComponent(filters.destination || '') %>&truck_type=<%= filters.truck_type || 'all' %>">→</a>
<% } else { %>
<span class="disabled">→</span>
<% } %>
</div>
<% } %>
</div> </div>
</section> </section>

View file

@ -13,6 +13,7 @@
<% } %> <% } %>
<form method="POST" action="/login"> <form method="POST" action="/login">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<div class="form-group"> <div class="form-group">
<label class="form-label">👤 <%= t('auth.username') %></label> <label class="form-label">👤 <%= t('auth.username') %></label>
<input type="text" name="username" class="form-input" placeholder="MH31AB1234" required autofocus> <input type="text" name="username" class="form-input" placeholder="MH31AB1234" required autofocus>

View file

@ -13,6 +13,7 @@
<% } %> <% } %>
<form method="POST" action="/register" id="registerForm"> <form method="POST" action="/register" id="registerForm">
<input type="hidden" name="_csrf" value="<%= _csrf %>">
<div class="form-group"> <div class="form-group">
<label class="form-label"><%= t('auth.yourRole') %> *</label> <label class="form-label"><%= t('auth.yourRole') %> *</label>
<div class="role-select-grid"> <div class="role-select-grid">

View file

@ -1,31 +1,35 @@
<% if (user) { %> <nav class="bottom-nav">
<nav class="bottom-nav" aria-label="Main navigation"> <a href="/" class="bnav-item <%= typeof page !== 'undefined' && page === 'home' ? 'active' : '' %>">
<a href="/<%= user.role %>" class="bnav-item"> <span class="bnav-icon">🏠</span>
<span class="bnav-icon-lg">🏠</span> <span>Home</span>
<span class="bnav-label"><%= t('nav.home') %></span>
</a> </a>
<a href="/loadboard" class="bnav-item"> <a href="/loadboard" class="bnav-item <%= typeof page !== 'undefined' && page === 'loadboard' ? 'active' : '' %>">
<span class="bnav-icon-lg">📋</span> <span class="bnav-icon">📋</span>
<span class="bnav-label"><%= t('nav.loads') %></span> <span>Loads</span>
</a> </a>
<% if (user.role === 'shipper' || user.role === 'broker') { %> <% if (user) { %>
<a href="/loadboard/post" class="bnav-item bnav-add"> <a href="/loadboard/post" class="bnav-item bnav-add">
<span class="bnav-icon-lg"></span> <span class="bnav-icon"></span>
<span class="bnav-label"><%= t('nav.post') %></span> </a>
<a href="/trips" class="bnav-item <%= typeof page !== 'undefined' && page === 'trips' ? 'active' : '' %>">
<span class="bnav-icon">🚛</span>
<span>Trips</span>
</a>
<a href="/more" class="bnav-item <%= typeof page !== 'undefined' && page === 'more' ? 'active' : '' %>">
<span class="bnav-icon">☰</span>
<span>More</span>
</a> </a>
<% } else { %> <% } else { %>
<a href="/search" class="bnav-item"> <a href="/register" class="bnav-item bnav-add">
<span class="bnav-icon-lg">🔍</span> <span class="bnav-icon"></span>
<span class="bnav-label">Search</span> </a>
<a href="/login" class="bnav-item">
<span class="bnav-icon">🔑</span>
<span>Login</span>
</a>
<a href="/more" class="bnav-item">
<span class="bnav-icon">☰</span>
<span>More</span>
</a> </a>
<% } %> <% } %>
<a href="/notifications" class="bnav-item">
<span class="bnav-icon-lg">🔔</span>
<span class="bnav-label">Alerts</span>
</a>
<a href="/more" class="bnav-item">
<span class="bnav-icon-lg">⋯</span>
<span class="bnav-label">More</span>
</a>
</nav> </nav>
<% } %>

View file

@ -1,41 +1,53 @@
<!DOCTYPE html>
<html lang="hi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#1a237e">
<title><%= typeof title !== 'undefined' ? title + ' | ' + t('common.appName') : t('common.appName') %></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;600;700&family=Noto+Sans+Devanagari:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/govt-theme.css">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/images/favicon.svg" type="image/svg+xml">
</head>
<body>
<header class="govt-header"> <header class="govt-header">
<div class="header-inner"> <div class="header-inner">
<div class="header-brand"> <a href="/" class="header-brand">
<div class="header-emblem">🏛️</div> <span class="header-emblem">🚛</span>
<div class="header-titles"> <div class="header-titles">
<h1 class="header-title-hi"><%= t('common.appName') %></h1> <div class="header-title-hi">भारत ट्रक्स</div>
<p class="header-subtitle"><%= t('common.subtitle') %></p> <div class="header-subtitle">भारत's National Freight Marketplace</div>
</div>
</div>
<nav class="header-nav">
<div class="lang-switcher">
<a href="/lang/hi" class="lang-btn <%= lang === 'hi' ? 'active' : '' %>" title="हिंदी">हि</a>
<a href="/lang/en" class="lang-btn <%= lang === 'en' ? 'active' : '' %>" title="English">EN</a>
<a href="/lang/ta" class="lang-btn <%= lang === 'ta' ? 'active' : '' %>" title="தமிழ்">த</a>
<a href="/lang/te" class="lang-btn <%= lang === 'te' ? 'active' : '' %>" title="తెలుగు">తె</a>
</div> </div>
</a>
<nav class="desktop-nav header-nav">
<a href="/loadboard" class="header-link">Load Board</a>
<% if (user) { %> <% if (user) { %>
<span class="header-user"><%= user.name || user.username %></span> <% if (user.role === 'driver') { %>
<a href="/logout" class="header-link"><%= t('actions.logout') %></a> <a href="/driver" class="header-link">Dashboard</a>
<% } else { %> <% } else if (user.role === 'shipper') { %>
<a href="/login" class="header-link"><%= t('actions.login') %></a> <a href="/shipper" class="header-link">Dashboard</a>
<a href="/register" class="btn-header-cta"><%= t('actions.register') %></a> <% } else if (user.role === 'broker') { %>
<a href="/broker" class="header-link">Dashboard</a>
<% } else if (user.role === 'admin') { %>
<a href="/admin" class="header-link">Admin</a>
<% } %>
<% if (user.role !== 'admin') { %>
<a href="/trips" class="header-link">Trips</a>
<a href="/messages" class="header-link">Messages</a>
<% } %>
<% } %> <% } %>
</nav> </nav>
<div class="header-nav" style="gap: var(--space-sm);">
<!-- Language Switcher -->
<div class="lang-switcher">
<a href="/lang/hi" class="lang-btn <%= typeof lang !== 'undefined' && lang === 'hi' ? 'active' : '' %>" title="हिंदी">हि</a>
<a href="/lang/en" class="lang-btn <%= typeof lang !== 'undefined' && lang === 'en' ? 'active' : '' %>" title="English">En</a>
<a href="/lang/ta" class="lang-btn <%= typeof lang !== 'undefined' && lang === 'ta' ? 'active' : '' %>" title="தமிழ்">த</a>
</div>
<!-- Dark Mode Toggle -->
<button class="dark-toggle" id="darkToggle" onclick="toggleDark()" title="Toggle dark mode" aria-label="Toggle dark mode">🌙</button>
<% if (user) { %>
<span class="header-user hidden" style="display:none;">
<%= user.name %> (<%= user.role %>)
</span>
<a href="/profile" class="hidden" style="display:none;" class="header-link">Profile</a>
<a href="/logout" class="btn-header-cta">Logout</a>
<% } else { %>
<a href="/login" class="header-link">Login</a>
<a href="/register" class="btn-header-cta" style="color:#fff !important;">Register</a>
<% } %>
</div>
</div> </div>
</header> </header>

View file

@ -1,5 +1,5 @@
-- ============================================================ -- ============================================================
-- BharathTrucks — FULL DATABASE SETUP -- BharathTrucks — FULL DATABASE SETUP v2.0
-- Run this ONCE in Supabase SQL Editor -- Run this ONCE in Supabase SQL Editor
-- ============================================================ -- ============================================================
@ -20,6 +20,9 @@ CREATE TABLE IF NOT EXISTS app_users (
); );
CREATE INDEX IF NOT EXISTS idx_app_users_role ON app_users(role); CREATE INDEX IF NOT EXISTS idx_app_users_role ON app_users(role);
CREATE INDEX IF NOT EXISTS idx_app_users_username ON app_users(username); CREATE INDEX IF NOT EXISTS idx_app_users_username ON app_users(username);
CREATE INDEX IF NOT EXISTS idx_app_users_active ON app_users(is_active);
CREATE INDEX IF NOT EXISTS idx_app_users_created ON app_users(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_users_phone ON app_users(phone) WHERE phone IS NOT NULL;
-- 2. LOADS -- 2. LOADS
CREATE TABLE IF NOT EXISTS loads ( CREATE TABLE IF NOT EXISTS loads (
@ -44,48 +47,29 @@ CREATE INDEX IF NOT EXISTS idx_loads_status ON loads(status);
CREATE INDEX IF NOT EXISTS idx_loads_origin ON loads(origin_city); CREATE INDEX IF NOT EXISTS idx_loads_origin ON loads(origin_city);
CREATE INDEX IF NOT EXISTS idx_loads_destination ON loads(destination_city); CREATE INDEX IF NOT EXISTS idx_loads_destination ON loads(destination_city);
CREATE INDEX IF NOT EXISTS idx_loads_posted_by ON loads(posted_by); CREATE INDEX IF NOT EXISTS idx_loads_posted_by ON loads(posted_by);
CREATE INDEX IF NOT EXISTS idx_loads_status_created ON loads(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_loads_truck_type ON loads(truck_type);
CREATE INDEX IF NOT EXISTS idx_loads_urgent ON loads(is_urgent) WHERE is_urgent = true;
-- 3. BIDS -- 3. BIDS
CREATE TABLE IF NOT EXISTS bids (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
load_id UUID NOT NULL REFERENCES loads(id) ON DELETE CASCADE,
driver_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
amount NUMERIC(10,2) NOT NULL,
note TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(load_id, driver_id)
);
CREATE INDEX IF NOT EXISTS idx_bids_load ON bids(load_id); CREATE INDEX IF NOT EXISTS idx_bids_load ON bids(load_id);
CREATE INDEX IF NOT EXISTS idx_bids_driver ON bids(driver_id); CREATE INDEX IF NOT EXISTS idx_bids_driver ON bids(driver_id);
CREATE INDEX IF NOT EXISTS idx_bids_status ON bids(status);
CREATE INDEX IF NOT EXISTS idx_bids_load_driver ON bids(load_id, driver_id);
-- 4. TRIPS -- 4. TRIPS
CREATE TABLE IF NOT EXISTS trips (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
load_id UUID NOT NULL REFERENCES loads(id),
driver_id UUID NOT NULL REFERENCES app_users(id),
shipper_id UUID NOT NULL REFERENCES app_users(id),
bid_id UUID NOT NULL REFERENCES bids(id),
amount NUMERIC(10,2) NOT NULL,
status TEXT DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'picked_up', 'in_transit', 'delivered', 'cancelled')),
picked_up_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trips_driver ON trips(driver_id); CREATE INDEX IF NOT EXISTS idx_trips_driver ON trips(driver_id);
CREATE INDEX IF NOT EXISTS idx_trips_shipper ON trips(shipper_id); CREATE INDEX IF NOT EXISTS idx_trips_shipper ON trips(shipper_id);
CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status);
CREATE INDEX IF NOT EXISTS idx_trips_driver_status ON trips(driver_id, status);
CREATE INDEX IF NOT EXISTS idx_trips_shipper_status ON trips(shipper_id, status);
CREATE INDEX IF NOT EXISTS idx_trips_created ON trips(created_at DESC);
-- 5. MESSAGES -- 5. MESSAGES
CREATE TABLE IF NOT EXISTS messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
sender_id UUID NOT NULL REFERENCES app_users(id),
receiver_id UUID NOT NULL REFERENCES app_users(id),
load_id UUID REFERENCES loads(id),
content TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages(receiver_id, is_read); CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages(receiver_id, is_read);
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(sender_id, receiver_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_load ON messages(load_id) WHERE load_id IS NOT NULL;
-- 6. TRIGGERS -- 6. TRIGGERS
CREATE OR REPLACE FUNCTION increment_bid_count() CREATE OR REPLACE FUNCTION increment_bid_count()
@ -98,6 +82,14 @@ RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE
DROP TRIGGER IF EXISTS loads_updated_at ON loads; DROP TRIGGER IF EXISTS loads_updated_at ON loads;
CREATE TRIGGER loads_updated_at BEFORE UPDATE ON loads FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TRIGGER loads_updated_at BEFORE UPDATE ON loads FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Add updated_at to trips if not exists
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'trips' AND column_name = 'updated_at') THEN
ALTER TABLE trips ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
CREATE TRIGGER trips_updated_at BEFORE UPDATE ON trips FOR EACH ROW EXECUTE FUNCTION update_updated_at();
END IF;
END $$;
-- 7. RLS (open for now — tighten later) -- 7. RLS (open for now — tighten later)
ALTER TABLE app_users ENABLE ROW LEVEL SECURITY; ALTER TABLE app_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE loads ENABLE ROW LEVEL SECURITY; ALTER TABLE loads ENABLE ROW LEVEL SECURITY;
@ -105,6 +97,12 @@ ALTER TABLE bids ENABLE ROW LEVEL SECURITY;
ALTER TABLE trips ENABLE ROW LEVEL SECURITY; ALTER TABLE trips ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY; ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "open" ON app_users;
DROP POLICY IF EXISTS "open" ON loads;
DROP POLICY IF EXISTS "open" ON bids;
DROP POLICY IF EXISTS "open" ON trips;
DROP POLICY IF EXISTS "open" ON messages;
CREATE POLICY "open" ON app_users FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "open" ON app_users FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "open" ON loads FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "open" ON loads FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "open" ON bids FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "open" ON bids FOR ALL USING (true) WITH CHECK (true);