diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2fb5a29 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,96 @@ +name: FreightDesk CI/CD + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + NODE_VERSION: '20' + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ── Lint & Test ────────────────────────────────────────── + test: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: webapp/package-lock.json + + - name: Install dependencies + working-directory: ./webapp + run: npm ci + + - name: Run linter + working-directory: ./webapp + run: npm run lint --if-present + + - name: Run tests + working-directory: ./webapp + run: npm test --if-present + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} + + - name: Smoke test + working-directory: ./webapp + run: | + timeout 15 npm start & + sleep 5 + curl -sf http://localhost:3000/health || exit 1 + echo "✅ Smoke test passed" + env: + NODE_ENV: test + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} + SESSION_SECRET: test-secret + + # ── Build & Push Docker Image ──────────────────────────── + build: + name: Build Docker Image + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + working-directory: ./webapp + run: | + docker build -t freightdesk:${{ github.sha }} . + echo "✅ Docker image built" + + - name: Tag image + run: | + docker tag freightdesk:${{ github.sha }} freightdesk:latest + echo "✅ Image tagged" + + # ── Deploy to Coolify ──────────────────────────────────── + deploy: + name: Deploy to Coolify + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + steps: + - name: Trigger Coolify deployment + run: | + if [ -n "${{ secrets.COOLIFY_WEBHOOK_URL }}" ]; then + curl -sf -X POST "${{ secrets.COOLIFY_WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -d '{"sha": "${{ github.sha }}", "branch": "master"}' + echo "✅ Coolify deployment triggered" + else + echo "⚠️ COOLIFY_WEBHOOK_URL not set — skipping deployment" + fi diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json new file mode 100644 index 0000000..3fd217b --- /dev/null +++ b/webapp/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "env": { + "node": true, + "es2022": true, + "jest": true + }, + "extends": ["eslint:recommended"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "rules": { + "no-var": "error", + "prefer-const": "warn", + "no-unused-vars": ["warn", { "argsIgnorePattern": "^(next|req|res)$" }], + "semi": ["error", "always"], + "no-console": ["warn", { "allow": ["error"] }], + "eqeqeq": "error", + "curly": "error" + }, + "ignorePatterns": ["public/", "node_modules/", "coverage/"] +} diff --git a/webapp/.prettierrc.json b/webapp/.prettierrc.json new file mode 100644 index 0000000..d1fc62c --- /dev/null +++ b/webapp/.prettierrc.json @@ -0,0 +1,13 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "overrides": [ + { + "files": ["*.ejs"], + "options": { "parser": "html" } + } + ] +} diff --git a/webapp/package.json b/webapp/package.json index 24108ee..744f2fa 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -5,21 +5,44 @@ "main": "src/server.js", "scripts": { "start": "node src/server.js", - "dev": "node --watch src/server.js", - "seed": "node seed.js" + "dev": "nodemon src/server.js", + "test": "jest --forceExit --detectOpenHandles", + "test:unit": "jest tests/unit --forceExit", + "test:integration": "jest tests/integration --forceExit --detectOpenHandles", + "lint": "eslint src/ --ext .js --max-warnings 0", + "format": "prettier --write 'src/**/*.js' 'src/**/*.ejs' 'src/**/*.css'" }, - "keywords": ["freight", "logistics", "commission", "agent", "india"], - "license": "ISC", "dependencies": { - "@supabase/supabase-js": "^2.45.0", + "@supabase/supabase-js": "^2.39.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cookie-parser": "^1.4.6", - "dotenv": "^16.4.5", + "dotenv": "^16.3.1", "ejs": "^3.1.9", "express": "^4.18.2", "express-rate-limit": "^7.1.5", - "express-session": "^1.18.0", - "helmet": "^7.1.0" + "express-session": "^1.17.3", + "helmet": "^7.1.0", + "pino": "^8.17.0", + "pino-http": "^9.0.0", + "prom-client": "^15.1.0" + }, + "devDependencies": { + "eslint": "^8.56.0", + "jest": "^29.7.0", + "nodemon": "^3.0.2", + "prettier": "^3.1.1", + "supertest": "^6.3.3" + }, + "jest": { + "testEnvironment": "node", + "coverageDirectory": "coverage", + "collectCoverageFrom": [ + "src/**/*.js", + "!src/server.js" + ], + "testMatch": [ + "tests/**/*.test.js" + ] } } diff --git a/webapp/src/server.js b/webapp/src/server.js index c7b6f5a..beff82f 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -8,9 +8,12 @@ const session = require('express-session'); const cookieParser = require('cookie-parser'); const rateLimit = require('express-rate-limit'); const bcrypt = require('bcryptjs'); +const pinoHttp = require('pino-http'); const config = require('./config/env'); const supabase = require('./services/supabase'); -const { setupCSRF, validateCSRF, sanitizeBody, requestLogger, asyncHandler } = require('./middleware/security'); +const logger = require('./services/logger'); +const metrics = require('./services/metrics'); +const { setupCSRF, validateCSRF, sanitizeBody, asyncHandler } = require('./middleware/security'); const { requireAuth } = require('./middleware/auth'); const { formatINR, getStatusColor } = require('./lib/india'); @@ -35,7 +38,9 @@ app.use(helmet({ })); app.use(compression()); -app.use(requestLogger); + +// Pino HTTP logger (replaces requestLogger) +app.use(pinoHttp({ logger })); // Rate limiting app.use(rateLimit({ @@ -51,16 +56,20 @@ app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(cookieParser()); -// Static files +// Static files (ETag + 1day cache in production) app.use(express.static(path.join(__dirname, 'public'), { maxAge: config.nodeEnv === 'production' ? '1d' : 0, etag: true, + lastModified: true, })); // View engine app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); +// Cache-busting asset version (changes on restart) +const ASSET_VERSION = Date.now(); + // Session app.use(session({ secret: config.session.secret, @@ -88,6 +97,7 @@ app.use((req, res, next) => { res.locals.getStatusColor = getStatusColor; res.locals.year = new Date().getFullYear(); res.locals._csrf = req.session._csrf; + res.locals.assetVersion = ASSET_VERSION; next(); }); @@ -239,6 +249,17 @@ app.use('/reports', require('./routes/reports')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); +// Prometheus metrics +app.get('/metrics', async (req, res) => { + try { + res.set('Content-Type', metrics.register.contentType); + res.end(await metrics.register.metrics()); + } catch (err) { + logger.error({ err }, 'Failed to collect metrics'); + res.status(500).end('Internal Server Error'); + } +}); + // 404 app.use((req, res) => { res.status(404); @@ -247,15 +268,13 @@ app.use((req, res) => { // Error handler app.use((err, req, res, next) => { - console.error(`[ERROR] ${req.method} ${req.url}:`, err.message); - if (config.nodeEnv === 'development') console.error(err.stack); + req.log.error({ err, url: req.url, method: req.method }, 'Unhandled error'); res.status(err.status || 500); res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null }); }); const server = app.listen(config.port, '::', () => { - console.log(`\n🚛 FreightDesk running at http://localhost:${config.port}`); - console.log(` Environment: ${config.nodeEnv}`); + logger.info({ port: config.port, env: config.nodeEnv }, '🚛 FreightDesk started'); console.log(` Press Ctrl+C to stop\n`); }); diff --git a/webapp/src/services/logger.js b/webapp/src/services/logger.js new file mode 100644 index 0000000..d788c91 --- /dev/null +++ b/webapp/src/services/logger.js @@ -0,0 +1,11 @@ +const pino = require('pino'); + +const logger = pino({ + level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), + transport: process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } } + : undefined, + base: { pid: process.pid, env: process.env.NODE_ENV }, +}); + +module.exports = logger; diff --git a/webapp/src/services/metrics.js b/webapp/src/services/metrics.js new file mode 100644 index 0000000..028a240 --- /dev/null +++ b/webapp/src/services/metrics.js @@ -0,0 +1,37 @@ +const client = require('prom-client'); + +// Create a Registry +const register = new client.Registry(); + +// Add default metrics (CPU, memory, etc.) +client.collectDefaultMetrics({ register }); + +// Custom metrics +const httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], +}); +register.registerMetric(httpRequestDuration); + +const httpRequestTotal = new client.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], +}); +register.registerMetric(httpRequestTotal); + +const activeLoads = new client.Gauge({ + name: 'freightdesk_active_loads', + help: 'Number of active (non-settled) loads', +}); +register.registerMetric(activeLoads); + +const totalCommission = new client.Gauge({ + name: 'freightdesk_total_commission', + help: 'Total commission earned (INR)', +}); +register.registerMetric(totalCommission); + +module.exports = { register, httpRequestDuration, httpRequestTotal, activeLoads, totalCommission }; diff --git a/webapp/src/views/pages/loads/list.ejs b/webapp/src/views/pages/loads/list.ejs index 0e1ca1a..772ab50 100644 --- a/webapp/src/views/pages/loads/list.ejs +++ b/webapp/src/views/pages/loads/list.ejs @@ -13,7 +13,7 @@