mirror of
http://forgejo-oa09toasww4dgii9cj3gpzda.187.127.164.61.sslip.io/iamcoolvivek007/bharath.git
synced 2026-06-11 00:06:51 +00:00
v2.0: Major improvements - Security, Code Quality, UI/UX, Features
Security: - Add CSRF protection on all forms - Fix session config (resave:false, saveUninitialized:false) - Secure cookie settings for production - Input sanitization middleware - Request logging middleware - Security headers via Helmet Code Quality: - Async error handling on ALL route handlers - Proper HTTP status codes (400, 401, 403, 404, 409, 500) - Input validation on all forms (server-side) - Username validation (3-30 chars, alphanumeric+underscore) - Password min length increased to 6 - Generic error messages (no info leakage) - Graceful shutdown on SIGTERM UI/UX: - Dark mode toggle with persistence - Toast notifications for success/error - Loading states on form submit - Improved CSS with CSS variables - Better desktop responsive design - New 403 Forbidden page - Pagination controls - Improved header with desktop nav Features: - Pagination on all list pages (loads, trips, users, messages, etc.) - Admin stats JSON endpoint - Admin user delete route - Load cancel route - Mark invoice as paid route - Search/filter preserved on loadboard Database: - Additional composite indexes for performance - Updated timestamps trigger on trips - Improved FULL migration script DevEx: - Development seed script (seed.js) - Improved Dockerfile (non-root, healthcheck) - Comprehensive .gitignore - Updated README v2.0
This commit is contained in:
parent
ed320e82c1
commit
e9025a71eb
26 changed files with 2201 additions and 459 deletions
74
README.md
74
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
|
||||
|
|
|
|||
40
webapp/.gitignore
vendored
40
webapp/.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
87
webapp/seed.js
Normal file
87
webapp/seed.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* BharathTrucks — Development Seed Script
|
||||
* Creates sample users and loads for local development
|
||||
* Usage: node seed.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY);
|
||||
|
||||
async function seed() {
|
||||
console.log('🌱 Seeding BharathTrucks database...\n');
|
||||
|
||||
const passwordHash = await bcrypt.hash('password123', 10);
|
||||
|
||||
const users = [
|
||||
{ username: 'admin', name: 'Admin User', password_hash: await bcrypt.hash('admin123', 10), role: 'admin' },
|
||||
{ username: 'driver_raj', name: 'Raj Kumar', password_hash: passwordHash, role: 'driver', phone: '9876543210', city: 'Mumbai', state: 'Maharashtra' },
|
||||
{ username: 'driver_aman', name: 'Aman Singh', password_hash: passwordHash, role: 'driver', phone: '9876543211', city: 'Delhi', state: 'Delhi' },
|
||||
{ username: 'shipper_agarwal', name: 'Agarwal Packers', password_hash: passwordHash, role: 'shipper', phone: '9876543212', city: 'Kolkata', state: 'West Bengal' },
|
||||
{ username: 'broker_kahn', name: 'Kahn Transport', password_hash: passwordHash, role: 'broker', phone: '9876543213', city: 'Chennai', state: 'Tamil Nadu' },
|
||||
];
|
||||
|
||||
// Insert users
|
||||
const userIds = {};
|
||||
for (const u of users) {
|
||||
const { data, error } = await supabase.from('app_users').upsert(u, { onConflict: 'username' }).select().single();
|
||||
if (data) {
|
||||
userIds[u.username] = data.id;
|
||||
console.log(` ✓ User: ${u.username} (${u.role})`);
|
||||
} else if (error) {
|
||||
console.log(` ⚠ ${u.username}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const shipperId = userIds['shipper_agarwal'];
|
||||
const brokerId = userIds['broker_kahn'];
|
||||
const driver1 = userIds['driver_raj'];
|
||||
const driver2 = userIds['driver_aman'];
|
||||
|
||||
if (shipperId) {
|
||||
const loads = [
|
||||
{ posted_by: shipperId, origin_city: 'Mumbai', destination_city: 'Delhi', weight_tons: 12, truck_type: 'container', material_type: 'Electronics', budget: 45000, pickup_date: new Date(Date.now() + 2*86400000).toISOString().split('T')[0], description: 'Electronics shipment, handle with care', is_urgent: true, status: 'open' },
|
||||
{ posted_by: shipperId, origin_city: 'Chennai', destination_city: 'Bangalore', weight_tons: 8, truck_type: 'closed', material_type: 'Textiles', budget: 22000, pickup_date: new Date(Date.now() + 1*86400000).toISOString().split('T')[0], description: 'Textile goods', is_urgent: false, status: 'open' },
|
||||
{ posted_by: shipperId, origin_city: 'Kolkata', destination_city: 'Mumbai', weight_tons: 20, truck_type: 'open', material_type: 'Steel', budget: 65000, pickup_date: new Date(Date.now() + 3*86400000).toISOString().split('T')[0], description: 'Steel coils', is_urgent: false, status: 'open' },
|
||||
];
|
||||
|
||||
for (const l of loads) {
|
||||
const { error } = await supabase.from('loads').insert(l);
|
||||
if (!error) console.log(` ✓ Load: ${l.origin_city} → ${l.destination_city}`);
|
||||
else console.log(` ⚠ Load error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (driver1 && driver2) {
|
||||
// Get a load to bid on
|
||||
const { data: load } = await supabase.from('loads').select('id').eq('status', 'open').limit(1).single();
|
||||
if (load) {
|
||||
await supabase.from('bids').insert([
|
||||
{ load_id: load.id, driver_id: driver1, amount: 42000, note: 'Experienced driver with GPS' },
|
||||
{ load_id: load.id, driver_id: driver2, amount: 41000, note: 'Best rates guaranteed' },
|
||||
]);
|
||||
console.log(' ✓ Sample bids created');
|
||||
}
|
||||
}
|
||||
|
||||
if (shipperId) {
|
||||
await supabase.from('user_gamification').upsert([
|
||||
{ user_id: shipperId, xp: 80, login_streak: 3 },
|
||||
{ user_id: driver1, xp: 120, login_streak: 5 },
|
||||
{ user_id: driver2, xp: 60, login_streak: 2 },
|
||||
], { onConflict: 'user_id' });
|
||||
console.log(' ✓ Gamification data seeded');
|
||||
}
|
||||
|
||||
console.log('\n✅ Seed complete!');
|
||||
console.log('\nDefault passwords:');
|
||||
console.log(' admin → admin123');
|
||||
console.log(' all other users → password123');
|
||||
}
|
||||
|
||||
seed().catch(err => {
|
||||
console.error('Seed failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -5,14 +5,28 @@ function requireAuth(req, res, next) {
|
|||
res.locals.user = req.session.user;
|
||||
return next();
|
||||
}
|
||||
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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
66
webapp/src/middleware/security.js
Normal file
66
webapp/src/middleware/security.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
// CSRF token generation and validation
|
||||
function generateCSRFToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function setupCSRF(req, res, next) {
|
||||
if (!req.session._csrf) {
|
||||
req.session._csrf = generateCSRFToken();
|
||||
}
|
||||
res.locals._csrf = req.session._csrf;
|
||||
next();
|
||||
}
|
||||
|
||||
function validateCSRF(req, res, next) {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||||
const token = req.body?._csrf || req.headers['x-csrf-token'] || req.query._csrf;
|
||||
if (!token || token !== req.session._csrf) {
|
||||
return res.status(403).send('Invalid CSRF token. Please go back and try again.');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Input sanitization
|
||||
function sanitizeInput(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str
|
||||
.replace(/</g, '<')
|
||||
.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 };
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 = '<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' +
|
||||
'<span class="toast-msg">' + msg + '</span>' +
|
||||
'<button class="toast-close" onclick="this.parentElement.remove()">×</button>';
|
||||
container.appendChild(el);
|
||||
setTimeout(function() {
|
||||
el.style.animation = 'toast-out 0.3s ease-in forwards';
|
||||
setTimeout(function() { el.remove(); }, 300);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Auto-show toasts from URL params
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('success')) showToast(decodeURIComponent(params.get('success')), 'success');
|
||||
if (params.get('error')) showToast(decodeURIComponent(params.get('error')), 'error');
|
||||
});
|
||||
})();
|
||||
|
||||
// Form Loading State
|
||||
(function() {
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
form.addEventListener('submit', function() {
|
||||
const btn = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||
if (btn) {
|
||||
btn.classList.add('btn-loading');
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Confirm dialogs
|
||||
(function() {
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(el) {
|
||||
el.addEventListener('click', function(e) {
|
||||
if (!confirm(el.getAttribute('data-confirm'))) e.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Voice Input (Web Speech API)
|
||||
(function() {
|
||||
if (!('webkitSpeechRecognition in window')) return;
|
||||
const recognition = new webkitSpeechRecognition();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
|
||||
window.startVoiceInput = function(inputId) {
|
||||
const btn = document.querySelector('.voice-btn[data-for="' + inputId + '"]');
|
||||
const input = document.getElementById(inputId);
|
||||
if (!btn || !input) return;
|
||||
|
||||
btn.classList.add('voice-active');
|
||||
btn.textContent = '🔴';
|
||||
recognition.lang = document.documentElement.lang === 'hi' ? 'hi-IN' : 'en-US';
|
||||
recognition.onresult = function(e) {
|
||||
input.value = e.results[0][0].transcript;
|
||||
btn.classList.remove('voice-active');
|
||||
btn.textContent = '🎤';
|
||||
};
|
||||
recognition.onerror = function() {
|
||||
btn.classList.remove('voice-active');
|
||||
btn.textContent = '🎤';
|
||||
showToast('Voice input failed. Try again.', 'error');
|
||||
};
|
||||
recognition.onend = function() {
|
||||
btn.classList.remove('voice-active');
|
||||
btn.textContent = '🎤';
|
||||
};
|
||||
recognition.start();
|
||||
};
|
||||
})();
|
||||
|
||||
// Auto-refresh form session to prevent CSRF timeout
|
||||
setInterval(function() {
|
||||
fetch('/health', { method: 'HEAD' }).catch(function() {});
|
||||
}, 600000);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ router.use(requireAdmin);
|
|||
|
||||
// GET /admin — dashboard
|
||||
router.get('/', async (req, res) => {
|
||||
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 });
|
||||
|
|
@ -22,29 +23,237 @@ router.get('/', async (req, res) => {
|
|||
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) => {
|
||||
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;
|
||||
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 });
|
||||
|
||||
// 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);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,14 +107,15 @@ router.post('/register', async (req, res) => {
|
|||
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
|
||||
try {
|
||||
const { data: user, error } = await supabase
|
||||
.from('app_users')
|
||||
.insert([{ username: cleanUsername, name: name.trim(), password_hash, role, phone: phone || null }])
|
||||
.insert([{ username, name, password_hash, role, phone }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.render('pages/register', { error: 'पंजीकरण विफल: ' + error.message, role });
|
||||
return res.render('pages/register', { error: 'पंजीकरण विफल हुआ। कृपया पुनः प्रयास करें।', role });
|
||||
}
|
||||
|
||||
req.session.user = {
|
||||
|
|
@ -96,6 +127,9 @@ router.post('/register', async (req, res) => {
|
|||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /logout
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const { getLevelForXP, ACHIEVEMENTS, XP_REWARDS } = require('../lib/gamification
|
|||
|
||||
// Profile score / gamification dashboard
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
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;
|
||||
|
|
@ -14,27 +15,41 @@ router.get('/', requireAuth, async (req, res) => {
|
|||
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) => {
|
||||
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) => {
|
||||
try {
|
||||
const { action } = req.body;
|
||||
const userId = req.session.user.id;
|
||||
const reward = XP_REWARDS[action] || 0;
|
||||
if (!reward) return res.json({ success: false });
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
try {
|
||||
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 || [] });
|
||||
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) => {
|
||||
try {
|
||||
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 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, 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,
|
||||
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',
|
||||
}]);
|
||||
res.redirect('/invoice');
|
||||
|
||||
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');
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,28 +72,57 @@ 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) => {
|
||||
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({
|
||||
|
|
@ -56,75 +139,248 @@ router.post('/post', requireAuth, requireRole(ROLES.SHIPPER, ROLES.BROKER), asyn
|
|||
});
|
||||
|
||||
if (error) {
|
||||
return res.render('pages/post-load', { error: 'लोड पोस्ट करने में त्रुटि', truckTypes: TRUCK_TYPES });
|
||||
console.error('Load insert error:', error);
|
||||
return res.status(500).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(() => {});
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /loadboard/:id — detail
|
||||
// ── GET /loadboard/:id — detail ────────────────────────────────────────────
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { data: load } = await supabase
|
||||
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
|
||||
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 });
|
||||
|
||||
if (bidsError) throw bidsError;
|
||||
|
||||
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
|
||||
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}`);
|
||||
// ── POST /loadboard/:id/bid — place bid ────────────────────────────────────
|
||||
|
||||
await supabase.from('bids').upsert({
|
||||
router.post('/:id/bid', requireAuth, requireRole(ROLES.DRIVER), async (req, res) => {
|
||||
try {
|
||||
const { amount, note } = req.body;
|
||||
|
||||
// Validate bid amount
|
||||
if (!amount || parseFloat(amount) <= 0) {
|
||||
return res.status(400).redirect(`/loadboard/${req.params.id}`);
|
||||
}
|
||||
|
||||
// Check load exists and is open
|
||||
const { data: load } = await supabase
|
||||
.from('loads')
|
||||
.select('id, 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}`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
}, { onConflict: 'load_id,driver_id' });
|
||||
});
|
||||
|
||||
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();
|
||||
if (gam) await supabase.from('user_gamification').update({ xp: (gam.xp || 0) + 20 }).eq('user_id', req.session.user.id).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) + 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) => {
|
||||
try {
|
||||
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}`);
|
||||
|
||||
const { data: bid, error: bidError } = await supabase
|
||||
.from('bids')
|
||||
.select('*')
|
||||
.eq('id', bid_id)
|
||||
.single();
|
||||
|
||||
if (bidError) throw bidError;
|
||||
if (!bid) return res.status(404).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);
|
||||
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,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -5,15 +5,50 @@ 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) => {
|
||||
try {
|
||||
const userId = req.session.user.id;
|
||||
// Get distinct conversations
|
||||
const { data: msgs } = await supabase.from('messages')
|
||||
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;
|
||||
|
||||
// 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(50);
|
||||
.limit(limit)
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
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 = {};
|
||||
|
|
@ -24,39 +59,188 @@ router.get('/', async (req, res) => {
|
|||
if (m.receiver_id === userId && !m.is_read) convos[otherId].unread++;
|
||||
});
|
||||
|
||||
res.render('pages/messages', { conversations: Object.values(convos) });
|
||||
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) => {
|
||||
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')
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// 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 });
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(limit);
|
||||
|
||||
// Mark as read
|
||||
await supabase.from('messages').update({ is_read: true }).eq('receiver_id', userId).eq('sender_id', otherId);
|
||||
if (msgsError) {
|
||||
console.error('[messages] conversation query error:', msgsError);
|
||||
return res.status(500).render('pages/chat', {
|
||||
otherUser,
|
||||
messages: [],
|
||||
otherId,
|
||||
error: 'Unable to load conversation',
|
||||
});
|
||||
}
|
||||
|
||||
res.render('pages/chat', { otherUser: otherUser || { name: 'User', username: '' }, messages: msgs || [], otherId });
|
||||
// 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) => {
|
||||
try {
|
||||
const { content, load_id } = req.body;
|
||||
if (!content || !content.trim()) return res.redirect(`/messages/${req.params.userId}`);
|
||||
const otherId = req.params.userId;
|
||||
|
||||
await supabase.from('messages').insert({
|
||||
// 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' });
|
||||
}
|
||||
|
||||
// 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: req.params.userId,
|
||||
content: content.trim(),
|
||||
receiver_id: otherId,
|
||||
content: trimmed,
|
||||
load_id: load_id || null,
|
||||
});
|
||||
|
||||
res.redirect(`/messages/${req.params.userId}`);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ const supabase = require('../services/supabase');
|
|||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
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 { 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));
|
||||
|
|
@ -20,14 +23,24 @@ router.get('/', requireAuth, async (req, res) => {
|
|||
};
|
||||
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) => {
|
||||
try {
|
||||
const userId = req.session.user.id;
|
||||
const { data: ledger } = await supabase.from('driver_ledger').select('*').eq('user_id', userId).order('trip_date', { ascending: false });
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
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)');
|
||||
// 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;
|
||||
|
||||
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 } = await query.order('created_at', { ascending: false });
|
||||
res.render('pages/trips', { trips: trips || [] });
|
||||
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) => {
|
||||
try {
|
||||
const { status } = req.body;
|
||||
const tripId = 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(', ') })
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
await supabase.from('trips').update(updates).eq('id', req.params.id);
|
||||
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') {
|
||||
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);
|
||||
await supabase
|
||||
.from('loads')
|
||||
.update({ status })
|
||||
.eq('id', trip.load_id);
|
||||
}
|
||||
|
||||
// Award XP on delivery
|
||||
// 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(() => {});
|
||||
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');
|
||||
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' })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
|
||||
<%- include('../partials/footer') %>
|
||||
|
||||
<!-- Desktop Bottom Nav hidden on mobile -->
|
||||
<%- include('../partials/bottom-nav') %>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
22
webapp/src/views/pages/403.ejs
Normal file
22
webapp/src/views/pages/403.ejs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<%- include('../partials/header') %>
|
||||
<div class="tricolor-strip"><div class="tricolor-saffron"></div><div class="tricolor-white"></div><div class="tricolor-green"></div></div>
|
||||
|
||||
<section class="error-page">
|
||||
<div class="container">
|
||||
<h1 style="color: var(--saffron);">🚫 403</h1>
|
||||
<h2>Access Forbidden</h2>
|
||||
<p>आपके पास इस पेज को देखने की अनुमति नहीं है।</p>
|
||||
<p style="font-size: 0.85rem; color: var(--gray-500);">You don't have permission to access this page.</p>
|
||||
<% if (typeof requiredRoles !== 'undefined' && requiredRoles) { %>
|
||||
<p style="font-size: 0.8rem; color: var(--gray-500); margin-top: var(--space-sm);">
|
||||
Required role: <%= requiredRoles.join(' or ') %>
|
||||
</p>
|
||||
<% } %>
|
||||
<div style="margin-top: var(--space-lg); display: flex; gap: var(--space-sm); justify-content: center;">
|
||||
<a href="javascript:history.back()" class="btn btn-outline btn-sm">← Go Back</a>
|
||||
<a href="/" class="btn btn-primary btn-sm">🏠 Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
|
|
@ -68,6 +68,27 @@
|
|||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (typeof pagination !== 'undefined' && pagination.totalPages > 1) { %>
|
||||
<div class="pagination">
|
||||
<% if (pagination.hasPrev) { %>
|
||||
<a href="?page=<%= pagination.page - 1 %>&limit=<%= pagination.limit %>&origin=<%= encodeURIComponent(filters.origin || '') %>&destination=<%= encodeURIComponent(filters.destination || '') %>&truck_type=<%= filters.truck_type || 'all' %>">←</a>
|
||||
<% } else { %>
|
||||
<span class="disabled">←</span>
|
||||
<% } %>
|
||||
<% for (let p = 1; p <= pagination.totalPages; p++) { %>
|
||||
<% if (p === pagination.page) { %>
|
||||
<span class="active"><%= p %></span>
|
||||
<% } else { %>
|
||||
<a href="?page=<%= p %>&limit=<%= pagination.limit %>&origin=<%= encodeURIComponent(filters.origin || '') %>&destination=<%= encodeURIComponent(filters.destination || '') %>&truck_type=<%= filters.truck_type || 'all' %>"><%= p %></a>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if (pagination.hasNext) { %>
|
||||
<a href="?page=<%= pagination.page + 1 %>&limit=<%= pagination.limit %>&origin=<%= encodeURIComponent(filters.origin || '') %>&destination=<%= encodeURIComponent(filters.destination || '') %>&truck_type=<%= filters.truck_type || 'all' %>">→</a>
|
||||
<% } else { %>
|
||||
<span class="disabled">→</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<% } %>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="_csrf" value="<%= _csrf %>">
|
||||
<div class="form-group">
|
||||
<label class="form-label">👤 <%= t('auth.username') %></label>
|
||||
<input type="text" name="username" class="form-input" placeholder="MH31AB1234" required autofocus>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<% } %>
|
||||
|
||||
<form method="POST" action="/register" id="registerForm">
|
||||
<input type="hidden" name="_csrf" value="<%= _csrf %>">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><%= t('auth.yourRole') %> *</label>
|
||||
<div class="role-select-grid">
|
||||
|
|
|
|||
|
|
@ -1,31 +1,35 @@
|
|||
<% if (user) { %>
|
||||
<nav class="bottom-nav" aria-label="Main navigation">
|
||||
<a href="/<%= user.role %>" class="bnav-item">
|
||||
<span class="bnav-icon-lg">🏠</span>
|
||||
<span class="bnav-label"><%= t('nav.home') %></span>
|
||||
<nav class="bottom-nav">
|
||||
<a href="/" class="bnav-item <%= typeof page !== 'undefined' && page === 'home' ? 'active' : '' %>">
|
||||
<span class="bnav-icon">🏠</span>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="/loadboard" class="bnav-item">
|
||||
<span class="bnav-icon-lg">📋</span>
|
||||
<span class="bnav-label"><%= t('nav.loads') %></span>
|
||||
<a href="/loadboard" class="bnav-item <%= typeof page !== 'undefined' && page === 'loadboard' ? 'active' : '' %>">
|
||||
<span class="bnav-icon">📋</span>
|
||||
<span>Loads</span>
|
||||
</a>
|
||||
<% if (user.role === 'shipper' || user.role === 'broker') { %>
|
||||
<% if (user) { %>
|
||||
<a href="/loadboard/post" class="bnav-item bnav-add">
|
||||
<span class="bnav-icon-lg">➕</span>
|
||||
<span class="bnav-label"><%= t('nav.post') %></span>
|
||||
<span class="bnav-icon">➕</span>
|
||||
</a>
|
||||
<a href="/trips" class="bnav-item <%= typeof page !== 'undefined' && page === 'trips' ? 'active' : '' %>">
|
||||
<span class="bnav-icon">🚛</span>
|
||||
<span>Trips</span>
|
||||
</a>
|
||||
<a href="/more" class="bnav-item <%= typeof page !== 'undefined' && page === 'more' ? 'active' : '' %>">
|
||||
<span class="bnav-icon">☰</span>
|
||||
<span>More</span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<a href="/search" class="bnav-item">
|
||||
<span class="bnav-icon-lg">🔍</span>
|
||||
<span class="bnav-label">Search</span>
|
||||
<a href="/register" class="bnav-item bnav-add">
|
||||
<span class="bnav-icon">➕</span>
|
||||
</a>
|
||||
<% } %>
|
||||
<a href="/notifications" class="bnav-item">
|
||||
<span class="bnav-icon-lg">🔔</span>
|
||||
<span class="bnav-label">Alerts</span>
|
||||
<a href="/login" class="bnav-item">
|
||||
<span class="bnav-icon">🔑</span>
|
||||
<span>Login</span>
|
||||
</a>
|
||||
<a href="/more" class="bnav-item">
|
||||
<span class="bnav-icon-lg">⋯</span>
|
||||
<span class="bnav-label">More</span>
|
||||
<span class="bnav-icon">☰</span>
|
||||
<span>More</span>
|
||||
</a>
|
||||
<% } %>
|
||||
</nav>
|
||||
<% } %>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="hi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#1a237e">
|
||||
<title><%= typeof title !== 'undefined' ? title + ' | ' + t('common.appName') : t('common.appName') %></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;600;700&family=Noto+Sans+Devanagari:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/govt-theme.css">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" href="/images/favicon.svg" type="image/svg+xml">
|
||||
</head>
|
||||
<body>
|
||||
<header class="govt-header">
|
||||
<div class="header-inner">
|
||||
<div class="header-brand">
|
||||
<div class="header-emblem">🏛️</div>
|
||||
<a href="/" class="header-brand">
|
||||
<span class="header-emblem">🚛</span>
|
||||
<div class="header-titles">
|
||||
<h1 class="header-title-hi"><%= t('common.appName') %></h1>
|
||||
<p class="header-subtitle"><%= t('common.subtitle') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<div class="lang-switcher">
|
||||
<a href="/lang/hi" class="lang-btn <%= lang === 'hi' ? 'active' : '' %>" title="हिंदी">हि</a>
|
||||
<a href="/lang/en" class="lang-btn <%= lang === 'en' ? 'active' : '' %>" title="English">EN</a>
|
||||
<a href="/lang/ta" class="lang-btn <%= lang === 'ta' ? 'active' : '' %>" title="தமிழ்">த</a>
|
||||
<a href="/lang/te" class="lang-btn <%= lang === 'te' ? 'active' : '' %>" title="తెలుగు">తె</a>
|
||||
<div class="header-title-hi">भारत ट्रक्स</div>
|
||||
<div class="header-subtitle">भारत's National Freight Marketplace</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<nav class="desktop-nav header-nav">
|
||||
<a href="/loadboard" class="header-link">Load Board</a>
|
||||
<% if (user) { %>
|
||||
<span class="header-user"><%= user.name || user.username %></span>
|
||||
<a href="/logout" class="header-link"><%= t('actions.logout') %></a>
|
||||
<% } else { %>
|
||||
<a href="/login" class="header-link"><%= t('actions.login') %></a>
|
||||
<a href="/register" class="btn-header-cta"><%= t('actions.register') %></a>
|
||||
<% if (user.role === 'driver') { %>
|
||||
<a href="/driver" class="header-link">Dashboard</a>
|
||||
<% } else if (user.role === 'shipper') { %>
|
||||
<a href="/shipper" class="header-link">Dashboard</a>
|
||||
<% } else if (user.role === 'broker') { %>
|
||||
<a href="/broker" class="header-link">Dashboard</a>
|
||||
<% } else if (user.role === 'admin') { %>
|
||||
<a href="/admin" class="header-link">Admin</a>
|
||||
<% } %>
|
||||
<% if (user.role !== 'admin') { %>
|
||||
<a href="/trips" class="header-link">Trips</a>
|
||||
<a href="/messages" class="header-link">Messages</a>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</nav>
|
||||
|
||||
<div class="header-nav" style="gap: var(--space-sm);">
|
||||
<!-- Language Switcher -->
|
||||
<div class="lang-switcher">
|
||||
<a href="/lang/hi" class="lang-btn <%= typeof lang !== 'undefined' && lang === 'hi' ? 'active' : '' %>" title="हिंदी">हि</a>
|
||||
<a href="/lang/en" class="lang-btn <%= typeof lang !== 'undefined' && lang === 'en' ? 'active' : '' %>" title="English">En</a>
|
||||
<a href="/lang/ta" class="lang-btn <%= typeof lang !== 'undefined' && lang === 'ta' ? 'active' : '' %>" title="தமிழ்">த</a>
|
||||
</div>
|
||||
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button class="dark-toggle" id="darkToggle" onclick="toggleDark()" title="Toggle dark mode" aria-label="Toggle dark mode">🌙</button>
|
||||
|
||||
<% if (user) { %>
|
||||
<span class="header-user hidden" style="display:none;">
|
||||
<%= user.name %> (<%= user.role %>)
|
||||
</span>
|
||||
<a href="/profile" class="hidden" style="display:none;" class="header-link">Profile</a>
|
||||
<a href="/logout" class="btn-header-cta">Logout</a>
|
||||
<% } else { %>
|
||||
<a href="/login" class="header-link">Login</a>
|
||||
<a href="/register" class="btn-header-cta" style="color:#fff !important;">Register</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
-- ============================================================
|
||||
-- BharathTrucks — FULL DATABASE SETUP
|
||||
-- BharathTrucks — FULL DATABASE SETUP v2.0
|
||||
-- Run this ONCE in Supabase SQL Editor
|
||||
-- ============================================================
|
||||
|
||||
|
|
@ -20,6 +20,9 @@ CREATE TABLE IF NOT EXISTS app_users (
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_users_role ON app_users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_users_username ON app_users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_users_active ON app_users(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_users_created ON app_users(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_users_phone ON app_users(phone) WHERE phone IS NOT NULL;
|
||||
|
||||
-- 2. LOADS
|
||||
CREATE TABLE IF NOT EXISTS loads (
|
||||
|
|
@ -44,48 +47,29 @@ CREATE INDEX IF NOT EXISTS idx_loads_status ON loads(status);
|
|||
CREATE INDEX IF NOT EXISTS idx_loads_origin ON loads(origin_city);
|
||||
CREATE INDEX IF NOT EXISTS idx_loads_destination ON loads(destination_city);
|
||||
CREATE INDEX IF NOT EXISTS idx_loads_posted_by ON loads(posted_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_loads_status_created ON loads(status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_loads_truck_type ON loads(truck_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_loads_urgent ON loads(is_urgent) WHERE is_urgent = true;
|
||||
|
||||
-- 3. BIDS
|
||||
CREATE TABLE IF NOT EXISTS bids (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
load_id UUID NOT NULL REFERENCES loads(id) ON DELETE CASCADE,
|
||||
driver_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
amount NUMERIC(10,2) NOT NULL,
|
||||
note TEXT,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(load_id, driver_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_load ON bids(load_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_driver ON bids(driver_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_status ON bids(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_load_driver ON bids(load_id, driver_id);
|
||||
|
||||
-- 4. TRIPS
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
load_id UUID NOT NULL REFERENCES loads(id),
|
||||
driver_id UUID NOT NULL REFERENCES app_users(id),
|
||||
shipper_id UUID NOT NULL REFERENCES app_users(id),
|
||||
bid_id UUID NOT NULL REFERENCES bids(id),
|
||||
amount NUMERIC(10,2) NOT NULL,
|
||||
status TEXT DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'picked_up', 'in_transit', 'delivered', 'cancelled')),
|
||||
picked_up_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_driver ON trips(driver_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_shipper ON trips(shipper_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_driver_status ON trips(driver_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_shipper_status ON trips(shipper_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_created ON trips(created_at DESC);
|
||||
|
||||
-- 5. MESSAGES
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
sender_id UUID NOT NULL REFERENCES app_users(id),
|
||||
receiver_id UUID NOT NULL REFERENCES app_users(id),
|
||||
load_id UUID REFERENCES loads(id),
|
||||
content TEXT NOT NULL,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages(receiver_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(sender_id, receiver_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_load ON messages(load_id) WHERE load_id IS NOT NULL;
|
||||
|
||||
-- 6. TRIGGERS
|
||||
CREATE OR REPLACE FUNCTION increment_bid_count()
|
||||
|
|
@ -98,6 +82,14 @@ RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE
|
|||
DROP TRIGGER IF EXISTS loads_updated_at ON loads;
|
||||
CREATE TRIGGER loads_updated_at BEFORE UPDATE ON loads FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Add updated_at to trips if not exists
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'trips' AND column_name = 'updated_at') THEN
|
||||
ALTER TABLE trips ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||
CREATE TRIGGER trips_updated_at BEFORE UPDATE ON trips FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 7. RLS (open for now — tighten later)
|
||||
ALTER TABLE app_users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE loads ENABLE ROW LEVEL SECURITY;
|
||||
|
|
@ -105,6 +97,12 @@ ALTER TABLE bids ENABLE ROW LEVEL SECURITY;
|
|||
ALTER TABLE trips ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "open" ON app_users;
|
||||
DROP POLICY IF EXISTS "open" ON loads;
|
||||
DROP POLICY IF EXISTS "open" ON bids;
|
||||
DROP POLICY IF EXISTS "open" ON trips;
|
||||
DROP POLICY IF EXISTS "open" ON messages;
|
||||
|
||||
CREATE POLICY "open" ON app_users FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "open" ON loads FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "open" ON bids FOR ALL USING (true) WITH CHECK (true);
|
||||
|
|
|
|||
Loading…
Reference in a new issue