diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdf3dfe --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ========================================== +# payNEXT Environment Variables Template +# ========================================== +# COPY THIS FILE AND RENAME IT TO .env +# Command: cp .env.example .env + +# Remote Shared Database +DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/paynext_db?sslmode=require + +# Optional split variables if your backend uses them +DB_HOST=your-db-host.supabase.com +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=change_me +DB_NAME=postgres +DB_SSL=true + +# API Secrets +JWT_SECRET=choose_your_own_local_jwt_secret \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcd8294..5c3ce37 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,14 +2,9 @@ name: payNEXT CI/CD on: push: - branches: - - devs - - release - - main + branches: [devs, release, main] pull_request: - branches: - - release - - main + branches: [release, main] workflow_dispatch: concurrency: @@ -20,59 +15,41 @@ jobs: validate: name: Validate Project runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Check required files run: | - echo "Checking required payNEXT files..." test -f docker-compose.yml - test -f client/Dockerfile - test -f client/nginx.conf - test -f client/public/index.html - echo "All required files exist." + test -f web/Dockerfile + test -f web/nginx.conf + test -f web/public/index.html + test -f db/init.sql - name: Create dummy .env file run: echo "JWT_SECRET=super_secret_test_key" > .env - name: Validate Docker Compose file - run: | - docker compose -f docker-compose.yml config > /dev/null - echo "Docker Compose file is valid." - - - name: Boot up API for Testing - run: docker compose up -d --build - - - name: Wait for API to be healthy - run: | - echo "Waiting for API and Database to start..." - sleep 15 - docker ps -a - docker logs paynext-api + run: docker compose -f docker-compose.yml config > /dev/null - - name: Run Automated API Tests - run: node test_api.js deploy-production: name: Deploy to Production runs-on: ubuntu-latest - needs: validate - if: github.ref == 'refs/heads/main' - environment: name: production - - env: + env: SERVER_IP: ${{ secrets.SERVER_IP }} SSH_USER: ${{ secrets.SSH_USER }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - + DB_HOST: ${{ secrets.DB_HOST }} + DB_USER: ${{ secrets.DB_USER }} + DB_NAME: ${{ secrets.DB_NAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup SSH run: | @@ -83,37 +60,36 @@ jobs: ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts - name: Create deployment folder on server - run: | - ssh "$SSH_USER@$SERVER_IP" "mkdir -p /opt/paynext" + run: ssh "$SSH_USER@$SERVER_IP" "mkdir -p /opt/paynext" - - name: Package application files + - name: Build server .env on the runner run: | - tar --exclude='./.git' --exclude='./.github' -czf paynext.tgz docker-compose.yml client server db - - - name: Copy package to server + { + echo "DB_HOST=$DB_HOST" + echo "DB_PORT=5432" + echo "DB_USER=$DB_USER" + echo "DB_PASSWORD=$DB_PASSWORD" + echo "DB_NAME=$DB_NAME" + echo "DB_SSL=true" + echo "JWT_SECRET=$JWT_SECRET" + } > server.env + + - name: Copy app and .env to server run: | + tar --exclude='./.git' --exclude='./.github' -czf paynext.tgz docker-compose.yml web db scp paynext.tgz "$SSH_USER@$SERVER_IP":/tmp/paynext.tgz + scp server.env "$SSH_USER@$SERVER_IP":/opt/paynext/.env + rm -f server.env paynext.tgz - name: Deploy using Docker run: | ssh "$SSH_USER@$SERVER_IP" << 'EOF' set -e - cd /opt/paynext - - echo "Generating production .env file..." - echo "JWT_SECRET=super_secret_production_key" > .env - echo "NODE_ENV=production" >> .env - docker compose down --remove-orphans || true - - rm -rf web docker-compose.yml - + rm -rf web db docker-compose.yml tar -xzf /tmp/paynext.tgz - - rm /tmp/paynext.tgz - + rm -f /tmp/paynext.tgz docker compose up -d --build - docker image prune -f EOF diff --git a/.gitignore b/.gitignore index f7227db..edc5428 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,10 @@ node_modules/ *.log .DS_Store dist/ -build/ \ No newline at end of file +build/get-docker.sh + +# Block all .env files everywhere +**/.env +.env +.agents +skills-lock.json \ No newline at end of file diff --git a/Caddyfile b/Caddyfile index 9e3cd05..abb95c7 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,13 +1,13 @@ -paynextt.me, www.paynextt.me, localhost { +paynextt.me, www.paynextt.me { encode zstd gzip handle /api/* { - reverse_proxy api:3000 + reverse_proxy api:3000 } handle { - reverse_proxy web:80 + reverse_proxy web:80 } } diff --git a/README.md b/README.md index 8e40a5f..fccc98b 100644 --- a/README.md +++ b/README.md @@ -111,40 +111,4 @@ docker compose down -v Use the `-v` option only when you want to remove the stored local database data. -## 🔐 Authentication & Authorization (Lab 05) - -### Registration (`POST /api/v1/auth/register`) -Request: -```json -{ "email": "user@test.com", "password": "password123", "fullName": "Test User" } -``` -Response (`201 Created`): -```json -{ "success": true, "message": "Registered successfully", "data": { "token": "jwt_string..." } } -``` - -### Login (`POST /api/v1/auth/login`) -Request: -```json -{ "email": "user@test.com", "password": "password123" } -``` -Response (`200 OK`): -```json -{ "success": true, "message": "Login successful", "data": { "token": "jwt_string..." } } -``` - -### Admin Restricted Endpoint (`GET /api/v1/auth/users`) -Requires: `Authorization: Bearer ` -Response (`200 OK`): -```json -{ - "success": true, - "data": [ - { "id": 1, "email": "admin@test.com", "role": "admin" }, - { "id": 2, "email": "user@test.com", "role": "user" } - ] -} -``` -If accessed with a standard user token, returns `403 Forbidden`. - payNEXT - Powering Your Next Move. Next Generation Digital Wallet. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dbfc6eb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# 🔐 Security Measures in payNEXT + +This document outlines the security implementations in the payNEXT architecture. + +## 1. Transport Layer Security + +- **HTTPS Enforcement:** Caddy automatically provisions Let's Encrypt SSL certificates and redirects all HTTP traffic to HTTPS. +- **Security Headers:** `helmet.js` is configured globally to set secure HTTP headers (HSTS, X-Frame-Options, etc.). + +## 2. Access Control & Rate Limiting + +- **Rate Limiting:** Authentication endpoints (`/api/v1/auth/login`, `/register`) are restricted to 5 requests per 15 minutes per IP to prevent brute-force attacks. +- **CORS:** Strict origin whitelisting is enforced. Only `https://paynextt.me` and `http://localhost:8080` are permitted. + +## 3. Input Validation & Injection Prevention + +- **SQL Injection:** All PostgreSQL queries use parameterized inputs via the `pg` library. +- **XSS & Data Sanitization:** `express-validator` is used to trim, escape, and validate all incoming user inputs before processing. + +## 4. Infrastructure & Load Balancing + +- **Reverse Proxy:** Caddy acts as the single entry point, routing `/api/*` to the backend and `/` to the Nginx frontend. +- **Load Balancing:** The API service can be scaled horizontally (`docker-compose up --scale api=2`). Caddy distributes traffic across instances using a `round_robin` policy. + +## 5. How to Test + +See the repository wiki or run the provided `curl` commands in `TESTING.md` to verify rate limiting, CORS, and header configurations. diff --git a/assets/payNEXT_logo.png b/assets/payNEXT_logo.png new file mode 100644 index 0000000..e713d5c Binary files /dev/null and b/assets/payNEXT_logo.png differ diff --git a/db/db_schema.svg b/db/db_schema.svg new file mode 100644 index 0000000..3eee0cd --- /dev/null +++ b/db/db_schema.svg @@ -0,0 +1,20 @@ +1*1*1*1*1*1*1*usersiduuidemailvarchar(255)password_hashvarchar(255)full_namevarchar(100)phonevarchar(20)statususer_statuscreated_attimestampupdated_attimestampwalletsiduuiduser_iduuidwallet_numbervarchar(50)currencyvarchar(3)balancenumeric(15,2)statuswallet_statuscreated_attimestampupdated_attimestamptransactionsiduuidwallet_iduuidtypetransaction_typeamountnumeric(15,2)balance_afternumeric(15,2)reference_iduuiddescriptiontextstatustransaction_statuscreated_attimestamptransfersiduuidfrom_wallet_iduuidto_wallet_iduuidamountnumeric(15,2)currencyvarchar(3)statustransaction_statusnotetextcreated_attimestamppayment_requestsiduuidrequester_wallet_iduuidpayer_wallet_iduuidpayer_emailvarchar(255)amountnumeric(15,2)currencyvarchar(3)statusrequest_statusnotetextcreated_attimestampupdated_attimestampgateway_transactionsiduuidwallet_iduuidprovidervarchar(50)provider_transaction_idvarchar(255)typevarchar(20)amountnumeric(15,2)currencyvarchar(3)statustransaction_statusmetadatajsonbcreated_attimestampupdated_attimestamp \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cce27f2..aa6bbdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,10 @@ -version: '3.8' +version: "3.8" services: api: build: ./server container_name: paynext-api restart: unless-stopped - ports: - - "3000:3000" env_file: - .env environment: @@ -22,8 +20,6 @@ services: image: postgres:16-alpine container_name: paynext-db restart: unless-stopped - ports: - - "5432:5432" environment: POSTGRES_USER: ${DB_USER:-postgres} POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} @@ -32,7 +28,11 @@ services: - postgres_data:/var/lib/postgresql/data - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-paynext_db}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-paynext_db}", + ] interval: 5s timeout: 5s retries: 20 diff --git a/server/package-lock.json b/server/package-lock.json index da6094b..7b331ec 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,8 +9,12 @@ "version": "1.0.0", "dependencies": { "bcryptjs": "^2.4.3", + "cors": "^2.8.6", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.7.0", + "express-validator": "^7.3.2", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.2", "pg": "^8.12.0" } @@ -144,6 +148,23 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -313,6 +334,61 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -431,6 +507,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -469,6 +557,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -527,6 +624,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -653,6 +756,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1092,6 +1204,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/server/package.json b/server/package.json index 41c82e1..ecc7a66 100644 --- a/server/package.json +++ b/server/package.json @@ -8,8 +8,12 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "cors": "^2.8.6", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.7.0", + "express-validator": "^7.3.2", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.2", "pg": "^8.12.0" } diff --git a/server/src/app.js b/server/src/app.js index daa1419..5c877fd 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -1,13 +1,60 @@ const express = require("express"); +const helmet = require("helmet"); +const cors = require("cors"); const routes = require("./routes"); const errorHandler = require("./middleware/errorHandler"); const app = express(); + +// 1. TRUST PROXY (CRITICAL) +// Tells Express to trust the first proxy (Caddy/Cloudflare). +// This ensures rate limiting and logging use the real client IP, not the Docker/Cloudflare IP. +app.set("trust proxy", 1); + +// 2. SECURITY HEADERS +// Must be near the top to protect all subsequent routes +app.use(helmet()); + +// 3. CORS CONFIGURATION +// Restrict access to only your known frontend domains +const allowedOrigins = [ + "https://paynextt.me", + "http://localhost:80", // Adjust if your local frontend runs on a different port + "http://localhost:3000", +]; + +app.use( + cors({ + origin: function (origin, callback) { + // Allow requests with no origin (e.g., mobile apps, curl, Postman, or server-to-server) + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error("Not allowed by CORS")); + } + }, + credentials: true, // Allow cookies or Authorization headers to be sent + }), +); + +// 4. BODY PARSERS app.use(express.json()); +app.use(express.urlencoded({ extended: true })); // Helpful if you ever accept form submissions + +// 5. ROUTES +// All /api/v1/* requests go here (where your rate-limited auth routes live) app.use("/api/v1", routes); + +// 6. 404 NOT FOUND HANDLER +// Must be placed AFTER all valid routes. +// If a request reaches here, it means no route matched. app.use((req, res) => res.status(404).json({ success: false, error: "Route not found" }), ); + +// 7. GLOBAL ERROR HANDLER +// Must ALWAYS be the very last middleware. +// It catches errors passed via next(err) from routes or controllers. app.use(errorHandler); module.exports = app; diff --git a/server/src/config/db.js b/server/src/config/db.js index f7131cb..c9171df 100644 --- a/server/src/config/db.js +++ b/server/src/config/db.js @@ -1,12 +1,18 @@ require("dotenv").config(); const { Pool } = require("pg"); +const isNeon = (process.env.DB_HOST || "").endsWith(".neon.tech"); + const pool = new Pool({ host: process.env.DB_HOST || "localhost", port: Number(process.env.DB_PORT || 5432), user: process.env.DB_USER || "paynext", password: process.env.DB_PASSWORD, database: process.env.DB_NAME || "paynext_db", + ssl: + process.env.DB_SSL === "true" || isNeon + ? { rejectUnauthorized: false } + : false, }); module.exports = pool; diff --git a/server/src/controllers/authController.js b/server/src/controllers/authController.js index 575d2d9..ebeef33 100644 --- a/server/src/controllers/authController.js +++ b/server/src/controllers/authController.js @@ -7,13 +7,21 @@ const register = async (req, res, next) => { try { const { email, password, fullName, phone } = req.body; if (!email || !password || !fullName) - return res.status(400).json({ success: false, error: "email, password and fullName are required" }); + return res.status(400).json({ + success: false, + error: "email, password and fullName are required", + }); if (password.length < 6) - return res.status(400).json({ success: false, error: "Password must be at least 6 characters" }); + return res.status(400).json({ + success: false, + error: "Password must be at least 6 characters", + }); const existing = await userModel.findByEmail(email.toLowerCase()); if (existing) - return res.status(409).json({ success: false, error: "Email already registered" }); + return res + .status(409) + .json({ success: false, error: "Email already registered" }); const passwordHash = await bcrypt.hash(password, 10); const user = await userModel.create({ @@ -41,38 +49,46 @@ const login = async (req, res, next) => { const { email, password } = req.body; const user = await userModel.findByEmail((email || "").toLowerCase()); if (!user) - return res.status(401).json({ success: false, error: "Invalid credentials" }); + return res + .status(401) + .json({ success: false, error: "Invalid credentials" }); const ok = await bcrypt.compare(password || "", user.password_hash); if (!ok) - return res.status(401).json({ success: false, error: "Invalid credentials" }); + return res + .status(401) + .json({ success: false, error: "Invalid credentials" }); const token = signToken(user); const { password_hash, ...safeUser } = user; - res.json({ success: true, message: "Login successful", data: { user: safeUser, token } }); + res.json({ + success: true, + message: "Login successful", + data: { user: safeUser, token }, + }); } catch (e) { next(e); } }; -const me = async (req, res, next) => { - try { - const user = await userModel.findById(req.user.sub); - if (!user) - return res.status(404).json({ success: false, error: "User not found" }); - res.json({ success: true, data: user }); - } catch (e) { - next(e); - } -}; -const getAllUsers = async (req, res, next) => { +const me = async (req, res) => { try { - const users = await userModel.findAll(); - res.json({ success: true, data: users }); - } catch (e) { - next(e); + const user = req.user; + + if (!user) { + return res.status(401).json({ success: false, error: "Unauthorized" }); + } + + const { password_hash, ...userData } = user; + + res.status(200).json({ + success: true, + data: userData, + }); + } catch (error) { + res.status(500).json({ success: false, error: "Server error" }); } }; -module.exports = { register, login, me, getAllUsers }; +module.exports = { register, login, me }; diff --git a/server/src/controllers/merchantController.js b/server/src/controllers/merchantController.js new file mode 100644 index 0000000..f43df2b --- /dev/null +++ b/server/src/controllers/merchantController.js @@ -0,0 +1,107 @@ +const merchantModel = require("../models/merchantModel"); +const walletModel = require("../models/walletModel"); +const { ApiError, withTransaction, lockWallet, setBalance, logTx } = require("../services/moneyService"); + +const createMerchant = async (req, res, next) => { + try { + if (!req.body.name) throw new ApiError("Merchant name is required", 400); + const merchant = await merchantModel.create({ name: req.body.name }); + res.status(201).json({ success: true, message: "Merchant created", data: merchant }); + } catch (e) { + next(e); + } +}; + +const getAllMerchants = async (req, res, next) => { + try { + const merchants = await merchantModel.findAll(); + res.json({ success: true, count: merchants.length, data: merchants }); + } catch (e) { + next(e); + } +}; + +const getMerchantById = async (req, res, next) => { + try { + const merchant = await merchantModel.findById(req.params.id); + if (!merchant) throw new ApiError("Merchant not found", 404); + res.json({ success: true, data: merchant }); + } catch (e) { + next(e); + } +}; + +const updateMerchant = async (req, res, next) => { + try { + const { name, status } = req.body; + if (!name || !status) throw new ApiError("Name and status are required", 400); + const merchant = await merchantModel.update(req.params.id, name, status); + if (!merchant) throw new ApiError("Merchant not found", 404); + res.json({ success: true, message: "Merchant updated", data: merchant }); + } catch (e) { + next(e); + } +}; + +const deleteMerchant = async (req, res, next) => { + try { + const merchant = await merchantModel.remove(req.params.id); + if (!merchant) throw new ApiError("Merchant not found", 404); + res.json({ success: true, message: "Merchant deleted", data: merchant }); + } catch (e) { + next(e); + } +}; + +const payMerchant = async (req, res, next) => { + try { + const { merchantId, amount } = req.body; + if (!merchantId || !amount || amount <= 0) { + throw new ApiError("Valid merchantId and amount > 0 are required", 400); + } + + const data = await withTransaction(async (client) => { + // 1. Verify merchant exists + const merchant = await merchantModel.findById(merchantId); + if (!merchant) throw new ApiError("Merchant not found", 404); + if (merchant.status !== 'active') throw new ApiError("Merchant is not active", 400); + + // 2. Lock the user's wallet + const wallets = await walletModel.listByUser(req.user.sub); + if (!wallets || wallets.length === 0) throw new ApiError("No wallet found", 404); + + const userWallet = await lockWallet(client, wallets[0].id); + const paymentAmount = Number(amount); + + if (Number(userWallet.balance) < paymentAmount) { + throw new ApiError("Insufficient balance to pay merchant", 400); + } + + // 3. Deduct balance + const newBalance = Number(userWallet.balance) - paymentAmount; + await setBalance(client, userWallet, newBalance); + + // 4. Log in master ledger + await logTx(client, { + walletId: userWallet.id, + type: 'transfer_out', + amount: paymentAmount, + balanceAfter: newBalance, + description: `Payment to merchant: ${merchant.name}` + }); + + // 5. Log in merchant payments table + const paymentLog = await merchantModel.logPayment( + client, merchant.id, userWallet.id, paymentAmount, userWallet.currency + ); + + return { paymentLog, newBalance }; + }); + + res.json({ success: true, message: "Payment successful", data }); + } catch (e) { + next(e); + } +}; + +module.exports = { createMerchant, getAllMerchants, getMerchantById, updateMerchant, deleteMerchant, payMerchant }; diff --git a/server/src/controllers/requestController.js b/server/src/controllers/requestController.js new file mode 100644 index 0000000..65ca93a --- /dev/null +++ b/server/src/controllers/requestController.js @@ -0,0 +1,169 @@ +const walletModel = require("../models/walletModel"); +const paymentRequestModel = require("../models/paymentRequestModel"); +const { + ApiError, + withTransaction, + lockWallet, + setBalance, + logTx, +} = require("../services/moneyService"); + +const createRequest = async (req, res, next) => { + try { + const amount = Number(req.body.amount); + const { requesterWalletId, payerEmail, note } = req.body; + if (!amount || amount <= 0) + throw new ApiError("amount must be greater than 0", 400); + if (!requesterWalletId || !payerEmail) + throw new ApiError("requesterWalletId and payerEmail are required", 400); + + const wallet = await walletModel.findById(requesterWalletId); + if (!wallet) throw new ApiError("Wallet not found", 404); + if (wallet.user_id !== req.user.sub) + throw new ApiError("Not your wallet", 403); + + const request = await paymentRequestModel.create({ + requesterWalletId: wallet.id, + payerEmail: payerEmail.toLowerCase(), + amount, + currency: wallet.currency, + note: note || null, + }); + + res + .status(201) + .json({ success: true, message: "Payment request sent", data: request }); + } catch (e) { + next(e); + } +}; + +const myRequests = async (req, res, next) => { + try { + const rows = await paymentRequestModel.listForUser( + req.user.sub, + req.user.email, + ); + res.json({ success: true, count: rows.length, data: rows }); + } catch (e) { + next(e); + } +}; + +const approveRequest = async (req, res, next) => { + try { + const { fromWalletId } = req.body; + if (!fromWalletId) throw new ApiError("fromWalletId is required", 400); + + const data = await withTransaction(async (client) => { + const request = await paymentRequestModel.findByIdForUpdate( + client, + req.params.id, + ); + if (!request) throw new ApiError("Payment request not found", 404); + if (request.status !== "pending") + throw new ApiError("Request is not pending", 400); + if (request.payer_email !== req.user.email) + throw new ApiError("Only the payer can approve this request", 403); + + const payerWallet = await lockWallet(client, fromWalletId); + if (!payerWallet) throw new ApiError("Payer wallet not found", 404); + if (payerWallet.user_id !== req.user.sub) + throw new ApiError("Not your wallet", 403); + + const amount = Number(request.amount); + if (Number(payerWallet.balance) < amount) + throw new ApiError("Insufficient balance", 400); + + const requesterWallet = await lockWallet( + client, + request.requester_wallet_id, + ); + + const payerBalance = Number(payerWallet.balance) - amount; + const requesterBalance = Number(requesterWallet.balance) + amount; + await setBalance(client, payerWallet, payerBalance); + await setBalance(client, requesterWallet, requesterBalance); + + await logTx(client, { + walletId: payerWallet.id, + type: "request_out", + amount, + balanceAfter: payerBalance, + referenceId: request.id, + description: request.note || "Paid request", + }); + await logTx(client, { + walletId: requesterWallet.id, + type: "request_in", + amount, + balanceAfter: requesterBalance, + referenceId: request.id, + description: request.note || "Request paid", + }); + + const paid = await paymentRequestModel.markPaid( + client, + request.id, + payerWallet.id, + ); + return { paymentRequest: paid }; + }); + + res.json({ success: true, message: "Payment request paid", data }); + } catch (e) { + next(e); + } +}; + +const declineRequest = async (req, res, next) => { + try { + const declined = await paymentRequestModel.decline( + req.params.id, + req.user.email, + ); + if (!declined) + return res + .status(404) + .json({ success: false, error: "Pending request not found for you" }); + res.json({ success: true, message: "Request declined", data: declined }); + } catch (e) { + next(e); + } +}; +const getRequestById = async (req, res, next) => { + try { + const request = await paymentRequestModel.findById(req.params.id); + if (!request) throw new ApiError("Payment request not found", 404); + res.json({ success: true, data: request }); + } catch (e) { + next(e); + } +}; + +const updateRequest = async (req, res, next) => { + try { + const { amount, note } = req.body; + if (!amount || amount <= 0) throw new ApiError("Amount must be > 0", 400); + + const updated = await paymentRequestModel.update(req.params.id, amount, note, req.user.email); + if (!updated) throw new ApiError("Pending request not found for you", 404); + + res.json({ success: true, message: "Payment request updated", data: updated }); + } catch (e) { + next(e); + } +}; + +const deleteRequest = async (req, res, next) => { + try { + const deleted = await paymentRequestModel.remove(req.params.id, req.user.email); + if (!deleted) throw new ApiError("Pending request not found for you", 404); + + res.json({ success: true, message: "Payment request deleted", data: deleted }); + } catch (e) { + next(e); + } +}; + +module.exports = { createRequest, myRequests, approveRequest, declineRequest, getRequestById, updateRequest, deleteRequest }; diff --git a/server/src/controllers/transactionController.js b/server/src/controllers/transactionController.js new file mode 100644 index 0000000..7689419 --- /dev/null +++ b/server/src/controllers/transactionController.js @@ -0,0 +1,23 @@ +const transactionModel = require("../models/transactionModel"); +const walletModel = require("../models/walletModel"); + +const getHistory = async (req, res, next) => { + try { + const { limit = 10, offset = 0 } = req.query; + + // Find the user's wallet to get transactions + const wallets = await walletModel.listByUser(req.user.sub); + if (!wallets || wallets.length === 0) { + return res.status(404).json({ success: false, error: "Wallet not found for this user" }); + } + const wallet = wallets[0]; // Get the primary wallet + + const transactions = await transactionModel.listByWallet(wallet.id, limit, offset); + + res.json({ success: true, count: transactions.length, data: transactions }); + } catch (e) { + next(e); + } +}; + +module.exports = { getHistory }; diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index ad61235..080e1a9 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -1,7 +1,7 @@ const jwt = require("jsonwebtoken"); function signToken(user) { - return jwt.sign({ sub: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { + return jwt.sign({ sub: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: "7d", }); } @@ -21,13 +21,4 @@ function requireAuth(req, res, next) { } } -function requireRole(role) { - return (req, res, next) => { - if (!req.user || req.user.role !== role) { - return res.status(403).json({ success: false, error: "Forbidden: insufficient permissions" }); - } - next(); - }; -} - -module.exports = { signToken, requireAuth, requireRole }; +module.exports = { signToken, requireAuth }; diff --git a/server/src/middleware/errorHandler.js b/server/src/middleware/errorHandler.js index ca362f1..f560675 100644 --- a/server/src/middleware/errorHandler.js +++ b/server/src/middleware/errorHandler.js @@ -1,9 +1,8 @@ module.exports = (err, req, res, next) => { - console.error(err.stack); + console.error(err); const status = err.status || 500; res.status(status).json({ success: false, error: status === 500 ? "Internal server error" : err.message, - stack: err.stack, }); }; diff --git a/server/src/middleware/rateLimiter.js b/server/src/middleware/rateLimiter.js new file mode 100644 index 0000000..3acd073 --- /dev/null +++ b/server/src/middleware/rateLimiter.js @@ -0,0 +1,14 @@ +const rateLimit = require("express-rate-limit"); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + message: { + success: false, + error: "Too many authentication attempts, please try again later.", + }, + standardHeaders: true, + legacyHeaders: false, +}); + +module.exports = authLimiter; diff --git a/server/src/middleware/validate.js b/server/src/middleware/validate.js new file mode 100644 index 0000000..9f781bb --- /dev/null +++ b/server/src/middleware/validate.js @@ -0,0 +1,16 @@ +const { validationResult } = require("express-validator"); + +const validate = (req, res, next) => { + const errors = validationResult(req); + + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array(), + }); + } + + next(); +}; + +module.exports = validate; \ No newline at end of file diff --git a/server/src/middleware/validators.js b/server/src/middleware/validators.js new file mode 100644 index 0000000..c5f5c8a --- /dev/null +++ b/server/src/middleware/validators.js @@ -0,0 +1,47 @@ +const { param, body, query } = require("express-validator"); + +const uuidParam = (name = "id") => + param(name) + .trim() + .isUUID() + .withMessage(`${name} must be a valid UUID`); + +const amount = (name = "amount") => + body(name) + .isFloat({ gt: 0 }) + .withMessage(`${name} must be greater than 0`); + +const walletId = (name) => + body(name) + .trim() + .isUUID() + .withMessage(`${name} must be a valid UUID`); + +const currency = body("currency") + .optional() + .trim() + .isIn(["USD", "BDT"]) + .withMessage("Invalid currency"); + +const note = body("note") + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage("Note must be at most 500 characters"); + +const description = body("description") + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage("Description must be at most 500 characters"); + +module.exports = { + uuidParam, + amount, + walletId, + currency, + note, + description, + body, + query, +}; \ No newline at end of file diff --git a/server/src/models/merchantModel.js b/server/src/models/merchantModel.js new file mode 100644 index 0000000..d999d95 --- /dev/null +++ b/server/src/models/merchantModel.js @@ -0,0 +1,46 @@ +const pool = require("../config/db"); +const crypto = require("crypto"); + +async function create({ name }) { + const apiKey = "pk_" + crypto.randomBytes(16).toString("hex"); + const r = await pool.query( + "INSERT INTO merchants (name, api_key) VALUES ($1, $2) RETURNING *", + [name, apiKey] + ); + return r.rows[0]; +} + +async function findAll() { + const r = await pool.query("SELECT * FROM merchants ORDER BY created_at DESC"); + return r.rows; +} + +async function findById(id) { + const r = await pool.query("SELECT * FROM merchants WHERE id = $1", [id]); + return r.rows[0]; +} + +async function update(id, name, status) { + const r = await pool.query( + "UPDATE merchants SET name = $1, status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *", + [name, status, id] + ); + return r.rows[0]; +} + +async function remove(id) { + const r = await pool.query("DELETE FROM merchants WHERE id = $1 RETURNING *", [id]); + return r.rows[0]; +} + +// Log a payment in the database (Called during a transaction) +async function logPayment(client, merchantId, walletId, amount, currency) { + const r = await client.query( + `INSERT INTO merchant_payments (merchant_id, wallet_id, amount, currency) + VALUES ($1, $2, $3, $4) RETURNING *`, + [merchantId, walletId, amount, currency] + ); + return r.rows[0]; +} + +module.exports = { create, findAll, findById, update, remove, logPayment }; diff --git a/server/src/models/paymentRequestModel.js b/server/src/models/paymentRequestModel.js new file mode 100644 index 0000000..3398c72 --- /dev/null +++ b/server/src/models/paymentRequestModel.js @@ -0,0 +1,84 @@ +const pool = require("../config/db"); + +async function create({ + requesterWalletId, + payerEmail, + amount, + currency, + note, +}) { + const r = await pool.query( + `INSERT INTO payment_requests (requester_wallet_id, payer_email, amount, currency, note) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [requesterWalletId, payerEmail, amount, currency, note], + ); + return r.rows[0]; +} + +async function listForUser(userId, email) { + const r = await pool.query( + `SELECT pr.*, w.wallet_number AS requester_wallet_number + FROM payment_requests pr + JOIN wallets w ON w.id = pr.requester_wallet_id + WHERE w.user_id = $1 OR pr.payer_email = $2 + ORDER BY pr.created_at DESC`, + [userId, email], + ); + return r.rows; +} + +async function decline(id, payerEmail) { + const r = await pool.query( + `UPDATE payment_requests + SET status = 'declined', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND payer_email = $2 AND status = 'pending' RETURNING *`, + [id, payerEmail], + ); + return r.rows[0]; +} + +// Used inside a DB transaction (client), not standalone +async function findByIdForUpdate(client, id) { + const r = await client.query( + "SELECT * FROM payment_requests WHERE id = $1 FOR UPDATE", + [id], + ); + return r.rows[0]; +} + +async function markPaid(client, id, payerWalletId) { + const r = await client.query( + `UPDATE payment_requests + SET status = 'paid', payer_wallet_id = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 RETURNING *`, + [payerWalletId, id], + ); + return r.rows[0]; +} + + +async function findById(id) { + const r = await pool.query("SELECT * FROM payment_requests WHERE id = $1", [id]); + return r.rows[0]; +} + +async function update(id, amount, note, email) { + const r = await pool.query( + `UPDATE payment_requests + SET amount = $1, note = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND payer_email = $4 AND status = 'pending' RETURNING *`, + [amount, note, id, email] + ); + return r.rows[0]; +} + +async function remove(id, email) { + const r = await pool.query( + `DELETE FROM payment_requests + WHERE id = $1 AND payer_email = $2 AND status = 'pending' RETURNING *`, + [id, email] + ); + return r.rows[0]; +} + +module.exports = { create, listForUser, decline, findByIdForUpdate, markPaid, findById, update, remove }; diff --git a/server/src/models/userModel.js b/server/src/models/userModel.js index b425866..f02b456 100644 --- a/server/src/models/userModel.js +++ b/server/src/models/userModel.js @@ -7,7 +7,7 @@ async function findByEmail(email) { async function findById(id) { const r = await pool.query( - "SELECT id, email, full_name, phone, role, status, created_at FROM users WHERE id = $1", + "SELECT id, email, full_name, phone, status, created_at FROM users WHERE id = $1", [id], ); return r.rows[0]; @@ -17,17 +17,10 @@ async function create({ email, passwordHash, fullName, phone }) { const r = await pool.query( `INSERT INTO users (email, password_hash, full_name, phone) VALUES ($1, $2, $3, $4) - RETURNING id, email, full_name, phone, role, status, created_at`, + RETURNING id, email, full_name, phone, status, created_at`, [email, passwordHash, fullName, phone], ); return r.rows[0]; } -async function findAll() { - const r = await pool.query( - "SELECT id, email, full_name, phone, role, status, created_at FROM users ORDER BY created_at DESC" - ); - return r.rows; -} - -module.exports = { findByEmail, findById, create, findAll }; +module.exports = { findByEmail, findById, create }; diff --git a/server/src/routes/authRoutes.js b/server/src/routes/authRoutes.js index 51891b0..465e4fe 100644 --- a/server/src/routes/authRoutes.js +++ b/server/src/routes/authRoutes.js @@ -1,11 +1,57 @@ const express = require("express"); const router = express.Router(); -const authController = require("../controllers/authController"); -const { requireAuth, requireRole } = require("../middleware/auth"); +const { body, validationResult } = require("express-validator"); -router.post("/register", authController.register); -router.post("/login", authController.login); -router.get("/me", requireAuth, authController.me); -router.get("/users", requireAuth, requireRole("admin"), authController.getAllUsers); +const authLimiter = require("../middleware/rateLimiter"); +const { login, register, me } = require("../controllers/authController"); + +// Helper middleware to check for validation errors +const validate = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + // If there are errors, return a 400 Bad Request with the details + return res.status(400).json({ success: false, errors: errors.array() }); + } + next(); +}; + +// LOGIN ROUTE with sanitization +router.post( + "/login", + authLimiter, + [ + body("email") + .isEmail() + .normalizeEmail() + .withMessage("Invalid email format"), + body("password").trim().notEmpty().withMessage("Password is required"), + ], + validate, + login, +); + +// REGISTER ROUTE with sanitization +router.post( + "/register", + authLimiter, + [ + body("email") + .isEmail() + .normalizeEmail() + .withMessage("Invalid email format"), + body("password") + .trim() + .isLength({ min: 6 }) + .withMessage("Password must be at least 6 characters"), + body("fullName") + .trim() + .escape() + .notEmpty() + .withMessage("Full name is required"), + body("phone").optional().trim().escape(), + ], + validate, + register, +); module.exports = router; diff --git a/server/src/routes/index.js b/server/src/routes/index.js index 3c78460..ee55c37 100644 --- a/server/src/routes/index.js +++ b/server/src/routes/index.js @@ -6,6 +6,8 @@ const authRoutes = require("./authRoutes"); const walletRoutes = require("./walletRoutes"); const moneyRoutes = require("./moneyRoutes"); const requestRoutes = require("./requestRoutes"); +const transactionRoutes = require("./transactionRoutes"); +const merchantRoutes = require("./merchantRoutes"); router.get("/health", (req, res) => res.json({ @@ -21,4 +23,8 @@ router.use("/wallets", walletRoutes); router.use("/transfers", moneyRoutes); router.use("/payment-requests", requestRoutes); +// Mount your new routes +router.use("/history", transactionRoutes); +router.use("/merchants", merchantRoutes); + module.exports = router; diff --git a/server/src/routes/merchantRoutes.js b/server/src/routes/merchantRoutes.js new file mode 100644 index 0000000..cbb608f --- /dev/null +++ b/server/src/routes/merchantRoutes.js @@ -0,0 +1,76 @@ +const express = require('express'); +const router = express.Router(); + +const merchantController = require('../controllers/merchantController'); +const { requireAuth } = require('../middleware/auth'); +const validate = require('../middleware/validate'); + +const { uuidParam, amount, body } = require('../middleware/validators'); + +router.post( + '/', + requireAuth, + body('name') + .trim() + .notEmpty() + .isLength({ max: 255 }) + .withMessage('Merchant name is invalid'), + validate, + merchantController.createMerchant, +); + +router.get('/', requireAuth, merchantController.getAllMerchants); + +router.get( + '/:id', + requireAuth, + uuidParam('id'), + validate, + merchantController.getMerchantById, +); + +router.put( + '/:id', + requireAuth, + [ + uuidParam('id'), + + body('name') + .trim() + .notEmpty() + .isLength({ max: 255 }) + .withMessage('Merchant name is invalid'), + + body('status') + .trim() + .isIn(['active', 'suspended', 'deleted']) + .withMessage('Invalid merchant status'), + ], + validate, + merchantController.updateMerchant, +); + +router.delete( + '/:id', + requireAuth, + uuidParam('id'), + validate, + merchantController.deleteMerchant, +); + +router.post( + '/pay', + requireAuth, + [ + body('merchantId') + .trim() + .isUUID() + .withMessage('merchantId must be a valid UUID'), + + amount('amount'), + ], + validate, + merchantController.payMerchant, +); + +module.exports = router; diff --git a/server/src/routes/moneyRoutes.js b/server/src/routes/moneyRoutes.js index 87e5417..e556096 100644 --- a/server/src/routes/moneyRoutes.js +++ b/server/src/routes/moneyRoutes.js @@ -1,9 +1,33 @@ -const express = require("express"); +const express = require('express'); const router = express.Router(); -const moneyController = require("../controllers/moneyController"); -const { requireAuth } = require("../middleware/auth"); -// Note: top-up and withdraw are on the wallet router, but sendMoney is here -router.post("/", requireAuth, moneyController.sendMoney); +const moneyController = require('../controllers/moneyController'); +const { requireAuth } = require('../middleware/auth'); +const validate = require('../middleware/validate'); -module.exports = router; \ No newline at end of file +const { walletId, amount, body } = require('../middleware/validators'); + +router.post( + '/', + requireAuth, + [ + walletId('fromWalletId'), + + amount('amount'), + + body('toWalletNumber') + .trim() + .matches(/^PAYNX-[0-9]{6}$/) + .withMessage('Invalid wallet number'), + + body('note') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Note is too long'), + ], + validate, + moneyController.sendMoney, +); + +module.exports = router; diff --git a/server/src/routes/requestRoutes.js b/server/src/routes/requestRoutes.js index fc8981e..5913c3a 100644 --- a/server/src/routes/requestRoutes.js +++ b/server/src/routes/requestRoutes.js @@ -1,4 +1,97 @@ -const express = require("express"); +const express = require('express'); const router = express.Router(); +const requestController = require('../controllers/requestController'); +const { requireAuth } = require('../middleware/auth'); +const validate = require('../middleware/validate'); + +const { + uuidParam, + walletId, + amount, + body, +} = require('../middleware/validators'); + +router.post( + '/', + requireAuth, + [ + walletId('requesterWalletId'), + + body('payerEmail') + .trim() + .normalizeEmail() + .isEmail() + .withMessage('Invalid payer email'), + + amount('amount'), + + body('note') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Note is too long'), + ], + validate, + requestController.createRequest, +); + +router.get('/', requireAuth, requestController.myRequests); + +router.get( + '/:id', + requireAuth, + uuidParam('id'), + validate, + requestController.getRequestById, +); + +router.put( + '/:id', + requireAuth, + [ + uuidParam('id'), + amount('amount'), + + body('note') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Note is too long'), + ], + validate, + requestController.updateRequest, +); + +router.delete( + '/:id', + requireAuth, + uuidParam('id'), + validate, + requestController.deleteRequest, +); + +router.post( + '/:id/approve', + requireAuth, + [ + uuidParam('id'), + + body('fromWalletId') + .trim() + .isUUID() + .withMessage('fromWalletId must be a valid UUID'), + ], + validate, + requestController.approveRequest, +); + +router.post( + '/:id/decline', + requireAuth, + uuidParam('id'), + validate, + requestController.declineRequest, +); + module.exports = router; diff --git a/server/src/routes/transactionRoutes.js b/server/src/routes/transactionRoutes.js new file mode 100644 index 0000000..8511b13 --- /dev/null +++ b/server/src/routes/transactionRoutes.js @@ -0,0 +1,29 @@ +const express = require("express"); +const router = express.Router(); + +const transactionController = require("../controllers/transactionController"); +const { requireAuth } = require("../middleware/auth"); +const validate = require("../middleware/validate"); +const { query } = require("../middleware/validators"); + +router.get( + "/", + requireAuth, + [ + query("limit") + .optional() + .isInt({ min: 1, max: 100 }) + .toInt() + .withMessage("Limit must be between 1 and 100"), + + query("offset") + .optional() + .isInt({ min: 0 }) + .toInt() + .withMessage("Offset must be a non-negative integer"), + ], + validate, + transactionController.getHistory +); + +module.exports = router; \ No newline at end of file diff --git a/server/src/routes/walletRoutes.js b/server/src/routes/walletRoutes.js index 293c733..b4531c2 100644 --- a/server/src/routes/walletRoutes.js +++ b/server/src/routes/walletRoutes.js @@ -1,16 +1,112 @@ -const express = require("express"); +const express = require('express'); const router = express.Router(); -const walletController = require("../controllers/walletController"); -const moneyController = require("../controllers/moneyController"); -const { requireAuth } = require("../middleware/auth"); - -router.post("/", requireAuth, walletController.createWallet); -router.get("/", requireAuth, walletController.myWallets); -router.get("/:id", requireAuth, walletController.getWallet); -router.get("/:id/balance", requireAuth, walletController.getBalance); -router.get("/:id/transactions", requireAuth, walletController.getTransactions); - -router.post("/:id/top-up", requireAuth, moneyController.topUp); -router.post("/:id/withdraw", requireAuth, moneyController.withdraw); + +const walletController = require('../controllers/walletController'); +const moneyController = require('../controllers/moneyController'); +const { requireAuth } = require('../middleware/auth'); +const validate = require('../middleware/validate'); + +const { + uuidParam, + amount, + currency, + body, + query, +} = require('../middleware/validators'); + +router.post( + '/', + requireAuth, + currency, + validate, + walletController.createWallet, +); + +router.get('/', requireAuth, walletController.myWallets); + +router.get( + '/:id', + requireAuth, + uuidParam('id'), + validate, + walletController.getWallet, +); + +router.get( + '/:id/balance', + requireAuth, + uuidParam('id'), + validate, + walletController.getBalance, +); + +router.get( + '/:id/transactions', + requireAuth, + [ + uuidParam('id'), + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .toInt() + .withMessage('Limit must be between 1 and 100'), + + query('offset') + .optional() + .isInt({ min: 0 }) + .toInt() + .withMessage('Offset must be a non-negative integer'), + ], + validate, + walletController.getTransactions, +); + +router.post( + '/:id/top-up', + requireAuth, + [ + uuidParam('id'), + amount('amount'), + + body('provider') + .optional() + .trim() + .isLength({ min: 1, max: 50 }) + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Invalid payment provider'), + + body('description') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Description is too long'), + ], + validate, + moneyController.topUp, +); + +router.post( + '/:id/withdraw', + requireAuth, + [ + uuidParam('id'), + amount('amount'), + + body('provider') + .optional() + .trim() + .isLength({ min: 1, max: 50 }) + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Invalid payment provider'), + + body('description') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Description is too long'), + ], + validate, + moneyController.withdraw, +); module.exports = router;