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

🚫 403

+

Access Forbidden

+

आपके पास इस पेज को देखने की अनुमति नहीं है।

+

You don't have permission to access this page.

+ <% if (typeof requiredRoles !== 'undefined' && requiredRoles) { %> +

+ Required role: <%= requiredRoles.join(' or ') %> +

+ <% } %> +
+ ← Go Back + 🏠 Home +
+
+
+ +<%- include('../partials/footer') %> diff --git a/webapp/src/views/pages/loadboard.ejs b/webapp/src/views/pages/loadboard.ejs index 146b08f..ea60d78 100644 --- a/webapp/src/views/pages/loadboard.ejs +++ b/webapp/src/views/pages/loadboard.ejs @@ -68,6 +68,27 @@ <% }) %> <% } %> + <% if (typeof pagination !== 'undefined' && pagination.totalPages > 1) { %> + + <% } %> diff --git a/webapp/src/views/pages/login.ejs b/webapp/src/views/pages/login.ejs index e3cf02e..c92b21b 100644 --- a/webapp/src/views/pages/login.ejs +++ b/webapp/src/views/pages/login.ejs @@ -13,6 +13,7 @@ <% } %>
+
diff --git a/webapp/src/views/pages/register.ejs b/webapp/src/views/pages/register.ejs index cf85964..69cfdc6 100644 --- a/webapp/src/views/pages/register.ejs +++ b/webapp/src/views/pages/register.ejs @@ -13,6 +13,7 @@ <% } %> +
diff --git a/webapp/src/views/partials/bottom-nav.ejs b/webapp/src/views/partials/bottom-nav.ejs index ff614e7..fe38cd4 100644 --- a/webapp/src/views/partials/bottom-nav.ejs +++ b/webapp/src/views/partials/bottom-nav.ejs @@ -1,31 +1,35 @@ -<% if (user) { %> -