From 8a527f4dfea976e777da0a3e45e7c7c8b4954524 Mon Sep 17 00:00:00 2001 From: Rukayat Zakariyau Date: Sun, 28 Jun 2026 11:31:05 +0100 Subject: [PATCH] feat: implement BE-03, BE-04, BE-05, BE-09 BE-03 (Docker + CI): - Add multi-stage backend/Dockerfile (node:20-alpine, EXPOSE 6006) - Add multi-stage frontend/Dockerfile (standalone Next.js, EXPOSE 3000) - Add docker-compose.prod.yml with postgres, redis, backend, frontend services - Add .github/workflows/backend-ci.yml (lint, test, build, migration dry-run) - Add .github/workflows/frontend-ci.yml (type-check, lint, build) - Add backend/.npmrc for legacy-peer-deps compatibility BE-04 (Security): - Add HTTP security headers to frontend/next.config.ts (CSP, X-Frame-Options, HSTS, etc.) - Add output:standalone and Cloudinary remotePattern to next.config.ts - Add backend/src/api-keys/ module (entity, service, guard, controller) - Update env-validation with JWT_SECRET as required, add REDIS_HOST/PORT/PASSWORD, Twilio, Web Push, Stellar contract vars, and PLATFORM_FEE_PERCENT - Add .env.example with all environment variables documented BE-05 (BullMQ + Cron Jobs): - Install @nestjs/bullmq and bullmq - Add backend/src/queue/ module with three named queues: stellar-anchor, email-send, pdf-generate with 3-attempt exponential backoff - Add processor classes with updateProgress(25/50/75/100) pattern - Add backend/src/tasks/ module with stuck-shipment check (daily 2AM) and temp-file cleanup (daily 3AM) cron jobs - Add GET /admin/queue/stats endpoint returning job counts per queue - Register BullModule.forRootAsync in AppModule using REDIS_HOST/PORT BE-09 (Swagger DTOs): - Create BidResponseDto, AddressResponseDto, ReviewResponseDto, WebhookResponseDto, AuditLogResponseDto - Add @ApiResponse({ type }) to GET endpoints in bids, addresses, reviews, webhooks, audit-log controllers Fixes: correct HealthIndicatorResult return type in cloudinary, smtp, and db health indicators (was HealthCheckResult) Closes #960 Closes #961 Closes #962 Closes #969 --- .env.example | 58 +++++ .github/workflows/backend-ci.yml | 64 +++++ .github/workflows/frontend-ci.yml | 39 +++ backend/.npmrc | 1 + backend/Dockerfile | 15 ++ backend/package-lock.json | 234 +++++++++++++++++- backend/package.json | 2 + backend/src/addresses/addresses.controller.ts | 8 +- .../src/addresses/dto/address-response.dto.ts | 30 +++ backend/src/admin/admin.controller.ts | 45 ++++ backend/src/admin/admin.module.ts | 2 + backend/src/analytics/analytics.service.ts | 22 +- backend/src/api-keys/api-key.guard.ts | 29 +++ backend/src/api-keys/api-keys.controller.ts | 77 ++++++ backend/src/api-keys/api-keys.module.ts | 14 ++ backend/src/api-keys/api-keys.service.ts | 73 ++++++ .../src/api-keys/dto/api-key-response.dto.ts | 29 +++ .../src/api-keys/dto/create-api-key.dto.ts | 14 ++ .../src/api-keys/entities/api-key.entity.ts | 40 +++ backend/src/app.module.ts | 18 ++ backend/src/audit-log/audit-log.controller.ts | 9 +- .../audit-log/dto/audit-log-response.dto.ts | 24 ++ backend/src/bids/bids.controller.ts | 2 + backend/src/bids/dto/bid-response.dto.ts | 40 +++ backend/src/health/health.controller.spec.ts | 20 +- backend/src/health/health.module.ts | 6 +- .../cloudinary.health.indicator.spec.ts | 8 +- .../indicators/cloudinary.health.indicator.ts | 11 +- .../health/indicators/db.health.indicator.ts | 8 +- .../indicators/smtp.health.indicator.ts | 12 +- .../queue/processors/email-send.processor.ts | 35 +++ .../processors/pdf-generate.processor.ts | 37 +++ .../processors/stellar-anchor.processor.ts | 37 +++ backend/src/queue/queue.constants.ts | 3 + backend/src/queue/queue.module.ts | 30 +++ .../src/reviews/dto/review-response.dto.ts | 24 ++ backend/src/reviews/reviews.controller.ts | 9 +- backend/src/tasks/tasks.module.ts | 11 + backend/src/tasks/tasks.service.ts | 82 ++++++ .../src/webhooks/dto/webhook-response.dto.ts | 27 ++ backend/src/webhooks/webhooks.controller.ts | 8 +- docker-compose.prod.yml | 70 ++++++ frontend/Dockerfile | 18 ++ frontend/next.config.ts | 11 +- .../env-validation/env-validation.module.ts | 17 +- 45 files changed, 1338 insertions(+), 35 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 backend/.npmrc create mode 100644 backend/Dockerfile create mode 100644 backend/src/addresses/dto/address-response.dto.ts create mode 100644 backend/src/api-keys/api-key.guard.ts create mode 100644 backend/src/api-keys/api-keys.controller.ts create mode 100644 backend/src/api-keys/api-keys.module.ts create mode 100644 backend/src/api-keys/api-keys.service.ts create mode 100644 backend/src/api-keys/dto/api-key-response.dto.ts create mode 100644 backend/src/api-keys/dto/create-api-key.dto.ts create mode 100644 backend/src/api-keys/entities/api-key.entity.ts create mode 100644 backend/src/audit-log/dto/audit-log-response.dto.ts create mode 100644 backend/src/bids/dto/bid-response.dto.ts create mode 100644 backend/src/queue/processors/email-send.processor.ts create mode 100644 backend/src/queue/processors/pdf-generate.processor.ts create mode 100644 backend/src/queue/processors/stellar-anchor.processor.ts create mode 100644 backend/src/queue/queue.constants.ts create mode 100644 backend/src/queue/queue.module.ts create mode 100644 backend/src/reviews/dto/review-response.dto.ts create mode 100644 backend/src/tasks/tasks.module.ts create mode 100644 backend/src/tasks/tasks.service.ts create mode 100644 backend/src/webhooks/dto/webhook-response.dto.ts create mode 100644 docker-compose.prod.yml create mode 100644 frontend/Dockerfile diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b43d8fad --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# ── Application ────────────────────────────────────────────────────────────── +NODE_ENV=development +PORT=6006 + +# ── Database (required) ─────────────────────────────────────────────────────── +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=changeme +DATABASE_NAME=freightflow + +# ── Auth (required) ─────────────────────────────────────────────────────────── +JWT_SECRET=change-me-in-production +JWT_EXPIRES_IN=15m +JWT_REFRESH_SECRET=change-me-in-production-refresh + +# ── Redis (required) ────────────────────────────────────────────────────────── +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_URL=redis://localhost:6379 + +# ── Frontend ────────────────────────────────────────────────────────────────── +FRONTEND_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:6006 + +# ── Cloudinary (required for image upload) ──────────────────────────────────── +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# ── Mailer ──────────────────────────────────────────────────────────────────── +MAILER_HOST=smtp.example.com +MAILER_PORT=587 +MAILER_USER=noreply@example.com +MAILER_PASS= + +# ── Twilio (optional – SMS notifications) ───────────────────────────────────── +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_NUMBER= + +# ── Web Push (optional) ─────────────────────────────────────────────────────── +WEB_PUSH_PUBLIC_KEY= +WEB_PUSH_PRIVATE_KEY= +WEB_PUSH_EMAIL= + +# ── Stellar / Soroban (optional) ────────────────────────────────────────────── +STELLAR_NETWORK=testnet +STELLAR_SECRET_KEY= +STELLAR_SHIPMENT_CONTRACT= +STELLAR_ESCROW_CONTRACT= +STELLAR_DOCUMENT_CONTRACT= +STELLAR_REPUTATION_CONTRACT= +STELLAR_IDENTITY_CONTRACT= + +# ── Platform ────────────────────────────────────────────────────────────────── +PLATFORM_FEE_PERCENT=2.5 diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..bb8ba314 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,64 @@ +name: Backend CI + +on: + pull_request: + branches: [main] + +jobs: + backend: + name: Backend (NestJS) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: freightflow_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + defaults: + run: + working-directory: backend + + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USERNAME: postgres + DATABASE_PASSWORD: postgres + DATABASE_NAME: freightflow_test + JWT_SECRET: ci-test-secret + REDIS_URL: redis://localhost:6379 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm run test -- --passWithNoTests + + - name: Build + run: npm run build + + - name: Migration dry-run + run: npm run migration:run diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 00000000..f7de972e --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,39 @@ +name: Frontend CI + +on: + pull_request: + branches: [main] + +jobs: + frontend: + name: Frontend (Next.js) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type-check + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..ff365a92 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package*.json ./ +RUN npm ci --omit=dev +USER node +EXPOSE 6006 +CMD ["sh", "-c", "npm run migration:run && node dist/main.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 4b8e3d96..5a5946a7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.0", + "@nestjs/bullmq": "^11.0.4", "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", @@ -33,6 +34,7 @@ "@types/qrcode": "^1.5.6", "@willsoto/nestjs-prometheus": "^6.0.2", "bcrypt": "^5.1.1", + "bullmq": "^5.79.2", "cache-manager": "^6.4.3", "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", @@ -2975,6 +2977,84 @@ "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/nice": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", @@ -3404,6 +3484,34 @@ "rxjs": "^7.0.0" } }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.4.tgz", + "integrity": "sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@nestjs/cache-manager": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.3.tgz", @@ -7529,6 +7637,61 @@ "node": ">=0.2.0" } }, + "node_modules/bullmq": { + "version": "5.79.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz", + "integrity": "sha512-FebD+8XCZl/hnS1R4to24L4EAN70XSndKZO0776M36vGRk5MKVhKlNFM8/34zXLXKyYB4QaeIPFhVXSYYGTHpQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -8458,6 +8621,18 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12859,6 +13034,12 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -13895,6 +14076,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/multer": { "version": "1.4.5-lts.1", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", @@ -14023,7 +14235,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -14060,6 +14271,21 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -16257,9 +16483,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/backend/package.json b/backend/package.json index 3e1c9b31..f448c833 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.0", + "@nestjs/bullmq": "^11.0.4", "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", @@ -48,6 +49,7 @@ "@types/qrcode": "^1.5.6", "@willsoto/nestjs-prometheus": "^6.0.2", "bcrypt": "^5.1.1", + "bullmq": "^5.79.2", "cache-manager": "^6.4.3", "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", diff --git a/backend/src/addresses/addresses.controller.ts b/backend/src/addresses/addresses.controller.ts index a29c2647..a5b72e1d 100644 --- a/backend/src/addresses/addresses.controller.ts +++ b/backend/src/addresses/addresses.controller.ts @@ -19,6 +19,7 @@ import { import { AddressesService } from './addresses.service'; import { CreateAddressDto } from './dto/create-address.dto'; import { UpdateAddressDto } from './dto/update-address.dto'; +import { AddressResponseDto } from './dto/address-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { User } from '../users/entities/user.entity'; @@ -30,13 +31,18 @@ export class AddressesController { @Post() @ApiOperation({ summary: 'Save a new address' }) - @ApiResponse({ status: 201, description: 'Address created' }) + @ApiResponse({ + status: 201, + type: AddressResponseDto, + description: 'Address created', + }) create(@CurrentUser() user: User, @Body() dto: CreateAddressDto) { return this.addressesService.create(user.id, dto); } @Get() @ApiOperation({ summary: 'List all saved addresses' }) + @ApiResponse({ status: 200, type: [AddressResponseDto] }) findAll(@CurrentUser() user: User) { return this.addressesService.findAll(user.id); } diff --git a/backend/src/addresses/dto/address-response.dto.ts b/backend/src/addresses/dto/address-response.dto.ts new file mode 100644 index 00000000..23350bb9 --- /dev/null +++ b/backend/src/addresses/dto/address-response.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AddressResponseDto { + @ApiProperty({ example: 'uuid-v4' }) + id: string; + + @ApiProperty({ example: 'user-uuid' }) + userId: string; + + @ApiProperty({ example: 'Main Warehouse' }) + label: string; + + @ApiProperty({ example: '123 Freight Ave' }) + address: string; + + @ApiProperty({ example: 'Lagos' }) + city: string; + + @ApiProperty({ example: 'Nigeria' }) + country: string; + + @ApiPropertyOptional({ example: false }) + isDefault: boolean; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 9f6dc977..efc368f9 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -16,6 +16,8 @@ import { ApiResponse, ApiBearerAuth, } from '@nestjs/swagger'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; import { AdminService } from './admin.service'; import { CarrierCertificationsService } from '../carriers/carrier-certifications.service'; import { QueryUsersDto } from './dto/query-users.dto'; @@ -28,6 +30,11 @@ import { UserRole } from '../common/enums/role.enum'; import { User } from '../users/entities/user.entity'; import { IsEnum } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { + QUEUE_EMAIL_SEND, + QUEUE_PDF_GENERATE, + QUEUE_STELLAR_ANCHOR, +} from '../queue/queue.constants'; class ChangeRoleDto { @ApiProperty({ enum: UserRole }) @@ -44,6 +51,9 @@ export class AdminController { constructor( private readonly adminService: AdminService, private readonly certificationsService: CarrierCertificationsService, + @InjectQueue(QUEUE_STELLAR_ANCHOR) private readonly stellarQueue: Queue, + @InjectQueue(QUEUE_EMAIL_SEND) private readonly emailQueue: Queue, + @InjectQueue(QUEUE_PDF_GENERATE) private readonly pdfQueue: Queue, ) {} // ── Stats ──────────────────────────────────────────────────────────────────── @@ -55,6 +65,41 @@ export class AdminController { return this.adminService.getStats(); } + @Get('queue/stats') + @ApiOperation({ summary: 'Get job counts for all queues (admin only)' }) + @ApiResponse({ status: 200, description: 'Queue job counts' }) + async getQueueStats() { + const [stellar, email, pdf] = await Promise.all([ + this.stellarQueue.getJobCounts( + 'waiting', + 'active', + 'completed', + 'failed', + 'delayed', + ), + this.emailQueue.getJobCounts( + 'waiting', + 'active', + 'completed', + 'failed', + 'delayed', + ), + this.pdfQueue.getJobCounts( + 'waiting', + 'active', + 'completed', + 'failed', + 'delayed', + ), + ]); + + return [ + { queueName: QUEUE_STELLAR_ANCHOR, ...stellar }, + { queueName: QUEUE_EMAIL_SEND, ...email }, + { queueName: QUEUE_PDF_GENERATE, ...pdf }, + ]; + } + // ── Users ──────────────────────────────────────────────────────────────────── @Get('users') diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index fa6f3dc1..4dab5668 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -6,12 +6,14 @@ import { AdminController } from './admin.controller'; import { User } from '../users/entities/user.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; import { AdminStatsModule } from '../admin-stats/admin-stats.module'; +import { QueueModule } from '../queue/queue.module'; @Module({ imports: [ TypeOrmModule.forFeature([User, Shipment]), CarriersModule, AdminStatsModule, + QueueModule, ], controllers: [AdminController], providers: [AdminService], diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index 6a63785f..860ba5b3 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -15,9 +15,21 @@ export interface ShipmentAnalytics { export class AnalyticsService { private readonly logger = new Logger(AnalyticsService.name); - async getUserAnalytics(userId: string, from: string, to: string): Promise { + async getUserAnalytics( + userId: string, + from: string, + to: string, + ): Promise { this.logger.log(`Analytics user=${userId} ${from}–${to}`); - return { userId, totalShipments: 0, totalSpend: 0, totalRevenue: 0, onTimeRate: 0, cancelledCount: 0, dateRange: { from, to } }; + return { + userId, + totalShipments: 0, + totalSpend: 0, + totalRevenue: 0, + onTimeRate: 0, + cancelledCount: 0, + dateRange: { from, to }, + }; } async exportCsv(userId: string): Promise { @@ -25,7 +37,11 @@ export class AnalyticsService { return 'id,shipmentId,status,amount,createdAt\n'; } - async getPlatformStats(): Promise<{ totalUsers: number; totalShipments: number; totalRevenue: number }> { + async getPlatformStats(): Promise<{ + totalUsers: number; + totalShipments: number; + totalRevenue: number; + }> { return { totalUsers: 0, totalShipments: 0, totalRevenue: 0 }; } } diff --git a/backend/src/api-keys/api-key.guard.ts b/backend/src/api-keys/api-key.guard.ts new file mode 100644 index 00000000..6361414a --- /dev/null +++ b/backend/src/api-keys/api-key.guard.ts @@ -0,0 +1,29 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { ApiKeysService } from './api-keys.service'; +import { User } from '../users/entities/user.entity'; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private readonly apiKeysService: ApiKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest(); + const rawKey = request.headers['x-api-key'] as string | undefined; + + if (!rawKey) throw new UnauthorizedException('API key required'); + + const apiKey = await this.apiKeysService.validate(rawKey); + if (!apiKey) throw new UnauthorizedException('Invalid or expired API key'); + + request.user = apiKey.user; + return true; + } +} diff --git a/backend/src/api-keys/api-keys.controller.ts b/backend/src/api-keys/api-keys.controller.ts new file mode 100644 index 00000000..60e8b8b4 --- /dev/null +++ b/backend/src/api-keys/api-keys.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { ApiKeysService } from './api-keys.service'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { + ApiKeyResponseDto, + CreateApiKeyResponseDto, +} from './dto/api-key-response.dto'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '../users/entities/user.entity'; +import { UserRole } from '../common/enums/role.enum'; + +@ApiTags('api-keys') +@ApiBearerAuth() +@Controller('api-keys') +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + @ApiOperation({ summary: 'Generate a new API key (returned once only)' }) + @ApiResponse({ + status: 201, + type: CreateApiKeyResponseDto, + description: + 'Key created — store the full key now, it will not be shown again', + }) + async create( + @CurrentUser() user: User, + @Body() dto: CreateApiKeyDto, + ): Promise { + const { key, apiKey } = await this.apiKeysService.create(user.id, dto); + return { + id: apiKey.id, + name: apiKey.name, + prefix: apiKey.prefix, + key, + expiresAt: apiKey.expiresAt, + lastUsedAt: apiKey.lastUsedAt, + createdAt: apiKey.createdAt, + }; + } + + @Get() + @ApiOperation({ + summary: "List caller's API keys (prefix only, no full key)", + }) + @ApiResponse({ status: 200, type: [ApiKeyResponseDto] }) + findAll(@CurrentUser() user: User): Promise { + return this.apiKeysService.findAll(user.id) as Promise; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Revoke an API key (owner or admin only)' }) + @ApiResponse({ status: 204, description: 'Key revoked' }) + async revoke( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + await this.apiKeysService.revoke(id, user.id, user.role === UserRole.ADMIN); + } +} diff --git a/backend/src/api-keys/api-keys.module.ts b/backend/src/api-keys/api-keys.module.ts new file mode 100644 index 00000000..7ff6df80 --- /dev/null +++ b/backend/src/api-keys/api-keys.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApiKey } from './entities/api-key.entity'; +import { ApiKeysService } from './api-keys.service'; +import { ApiKeyGuard } from './api-key.guard'; +import { ApiKeysController } from './api-keys.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApiKey])], + controllers: [ApiKeysController], + providers: [ApiKeysService, ApiKeyGuard], + exports: [ApiKeysService, ApiKeyGuard], +}) +export class ApiKeysModule {} diff --git a/backend/src/api-keys/api-keys.service.ts b/backend/src/api-keys/api-keys.service.ts new file mode 100644 index 00000000..e00ebfad --- /dev/null +++ b/backend/src/api-keys/api-keys.service.ts @@ -0,0 +1,73 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; +import { ApiKey } from './entities/api-key.entity'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; + +@Injectable() +export class ApiKeysService { + constructor( + @InjectRepository(ApiKey) + private readonly apiKeyRepo: Repository, + ) {} + + async create( + userId: string, + dto: CreateApiKeyDto, + ): Promise<{ key: string; apiKey: ApiKey }> { + const rawKey = crypto.randomBytes(32).toString('hex'); + const prefix = rawKey.substring(0, 8); + const keyHash = await bcrypt.hash(rawKey, 12); + + const apiKey = this.apiKeyRepo.create({ + userId, + name: dto.name, + keyHash, + prefix, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + lastUsedAt: null, + }); + + const saved = await this.apiKeyRepo.save(apiKey); + return { key: rawKey, apiKey: saved }; + } + + findAll(userId: string): Promise { + return this.apiKeyRepo.find({ + where: { userId }, + select: ['id', 'name', 'prefix', 'expiresAt', 'lastUsedAt', 'createdAt'], + }); + } + + async revoke(id: string, userId: string, isAdmin: boolean): Promise { + const key = await this.apiKeyRepo.findOne({ where: { id } }); + if (!key) throw new NotFoundException('API key not found'); + if (!isAdmin && key.userId !== userId) + throw new ForbiddenException('Not your API key'); + await this.apiKeyRepo.delete(id); + } + + async validate(rawKey: string): Promise { + const prefix = rawKey.substring(0, 8); + const candidates = await this.apiKeyRepo.find({ + where: { prefix }, + relations: ['user'], + }); + + for (const apiKey of candidates) { + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) continue; + const isValid = await bcrypt.compare(rawKey, apiKey.keyHash); + if (isValid) { + await this.apiKeyRepo.update(apiKey.id, { lastUsedAt: new Date() }); + return apiKey; + } + } + return null; + } +} diff --git a/backend/src/api-keys/dto/api-key-response.dto.ts b/backend/src/api-keys/dto/api-key-response.dto.ts new file mode 100644 index 00000000..e6e3621a --- /dev/null +++ b/backend/src/api-keys/dto/api-key-response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ApiKeyResponseDto { + @ApiProperty({ example: 'uuid-v4' }) + id: string; + + @ApiProperty({ example: 'Payment Integration Key' }) + name: string; + + @ApiProperty({ example: 'a1b2c3d4', description: 'First 8 chars of the key' }) + prefix: string; + + @ApiPropertyOptional({ nullable: true }) + expiresAt: Date | null; + + @ApiPropertyOptional({ nullable: true }) + lastUsedAt: Date | null; + + @ApiProperty() + createdAt: Date; +} + +export class CreateApiKeyResponseDto extends ApiKeyResponseDto { + @ApiProperty({ + example: 'a1b2c3d4e5f6...', + description: 'Full API key — shown once only, store it securely', + }) + key: string; +} diff --git a/backend/src/api-keys/dto/create-api-key.dto.ts b/backend/src/api-keys/dto/create-api-key.dto.ts new file mode 100644 index 00000000..80d990a0 --- /dev/null +++ b/backend/src/api-keys/dto/create-api-key.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateApiKeyDto { + @ApiProperty({ example: 'Payment Integration Key' }) + @IsString() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ example: '2027-01-01T00:00:00Z' }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/backend/src/api-keys/entities/api-key.entity.ts b/backend/src/api-keys/entities/api-key.entity.ts new file mode 100644 index 00000000..0342c4bb --- /dev/null +++ b/backend/src/api-keys/entities/api-key.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('api_keys') +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { eager: false, nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ length: 100 }) + name: string; + + @Column({ name: 'key_hash' }) + keyHash: string; + + @Column({ length: 8 }) + prefix: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date | null; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d961999e..9ea8df5d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -42,6 +42,10 @@ import { ReputationCalculatorModule } from './reputation-calculator/reputation-c import { LocationUpdatesModule } from './location-updates/location-updates.module'; import { ETAModule } from './eta/eta.module'; import { BidExpiryModule } from './bid-expiry/bid-expiry.module'; +import { BullModule } from '@nestjs/bullmq'; +import { QueueModule } from './queue/queue.module'; +import { TasksModule } from './tasks/tasks.module'; +import { ApiKeysModule } from './api-keys/api-keys.module'; const shipmentCreateTracker = (context: ExecutionContext): string => { const request = context.switchToHttp().getRequest<{ @@ -94,6 +98,17 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { }, ], }), + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST') ?? 'localhost', + port: configService.get('REDIS_PORT') ?? 6379, + password: configService.get('REDIS_PASSWORD'), + }, + }), + }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -139,6 +154,9 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { LocationUpdatesModule, ETAModule, BidExpiryModule, + QueueModule, + TasksModule, + ApiKeysModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/audit-log/audit-log.controller.ts b/backend/src/audit-log/audit-log.controller.ts index de2bd0c6..aaf955dc 100644 --- a/backend/src/audit-log/audit-log.controller.ts +++ b/backend/src/audit-log/audit-log.controller.ts @@ -1,7 +1,13 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { AuditLogService } from './audit-log.service'; import { QueryAuditLogDto } from './dto/query-audit-log.dto'; +import { AuditLogResponseDto } from './dto/audit-log-response.dto'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums/role.enum'; @@ -18,6 +24,7 @@ export class AuditLogController { @ApiOperation({ summary: 'Get paginated admin audit logs (filterable by action)', }) + @ApiResponse({ status: 200, type: [AuditLogResponseDto] }) findAll(@Query() query: QueryAuditLogDto) { return this.auditLogService.findAll(query); } diff --git a/backend/src/audit-log/dto/audit-log-response.dto.ts b/backend/src/audit-log/dto/audit-log-response.dto.ts new file mode 100644 index 00000000..cab03b82 --- /dev/null +++ b/backend/src/audit-log/dto/audit-log-response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AuditLogResponseDto { + @ApiProperty({ example: 'uuid-v4' }) + id: string; + + @ApiProperty({ example: 'admin-uuid' }) + adminId: string; + + @ApiProperty({ example: 'USER_DEACTIVATED' }) + action: string; + + @ApiPropertyOptional({ example: 'User', nullable: true }) + targetType: string | null; + + @ApiPropertyOptional({ example: 'target-uuid', nullable: true }) + targetId: string | null; + + @ApiPropertyOptional({ nullable: true, additionalProperties: true }) + metadata: Record | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/bids/bids.controller.ts b/backend/src/bids/bids.controller.ts index 69b3101c..7b5cbd99 100644 --- a/backend/src/bids/bids.controller.ts +++ b/backend/src/bids/bids.controller.ts @@ -20,6 +20,7 @@ import { import { BidsService } from './bids.service'; import { CreateBidDto } from './dto/create-bid.dto'; import { CounterBidDto } from './dto/counter-bid.dto'; +import { BidResponseDto } from './dto/bid-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -51,6 +52,7 @@ export class BidsController { @Roles(UserRole.SHIPPER, UserRole.ADMIN) @ApiOperation({ summary: 'Shipper views all bids on their shipment' }) @ApiParam({ name: 'id', description: 'Shipment ID' }) + @ApiResponse({ status: 200, type: [BidResponseDto] }) getBids( @Param('id', ParseUUIDPipe) shipmentId: string, @CurrentUser() user: User, diff --git a/backend/src/bids/dto/bid-response.dto.ts b/backend/src/bids/dto/bid-response.dto.ts new file mode 100644 index 00000000..bcf2816e --- /dev/null +++ b/backend/src/bids/dto/bid-response.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BidStatus } from '../entities/bid.entity'; + +export class BidResponseDto { + @ApiProperty({ example: 'uuid-v4' }) + id: string; + + @ApiProperty({ example: 'shipment-uuid' }) + shipmentId: string; + + @ApiProperty({ example: 'carrier-uuid' }) + carrierId: string; + + @ApiProperty({ example: 1500.0 }) + proposedPrice: number; + + @ApiPropertyOptional({ + example: 'I can deliver within 3 days.', + nullable: true, + }) + message: string | null; + + @ApiProperty({ enum: BidStatus, example: BidStatus.PENDING }) + status: BidStatus; + + @ApiPropertyOptional({ example: 1200.0, nullable: true }) + counterPrice: number | null; + + @ApiPropertyOptional({ + example: 'We can meet in the middle.', + nullable: true, + }) + counterMessage: string | null; + + @ApiPropertyOptional({ nullable: true }) + expiresAt: Date | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/health/health.controller.spec.ts b/backend/src/health/health.controller.spec.ts index cc7f3722..7ebb9b1b 100644 --- a/backend/src/health/health.controller.spec.ts +++ b/backend/src/health/health.controller.spec.ts @@ -64,9 +64,13 @@ describe('HealthController', () => { }, }; - dbHealthIndicator.isHealthy.mockResolvedValue({ database: { status: 'up' } }); + dbHealthIndicator.isHealthy.mockResolvedValue({ + database: { status: 'up' }, + }); smtpHealthIndicator.isHealthy.mockResolvedValue({ smtp: { status: 'up' } }); - cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ cloudinary: { status: 'up' } }); + cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ + cloudinary: { status: 'up' }, + }); healthCheckService.check.mockResolvedValue(mockHealthResult); const result = await controller.check(); @@ -85,9 +89,15 @@ describe('HealthController', () => { }, }; - dbHealthIndicator.isHealthy.mockResolvedValue({ database: { status: 'up' } }); - smtpHealthIndicator.isHealthy.mockResolvedValue({ smtp: { status: 'down', message: 'SMTP connection failed' } }); - cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ cloudinary: { status: 'up' } }); + dbHealthIndicator.isHealthy.mockResolvedValue({ + database: { status: 'up' }, + }); + smtpHealthIndicator.isHealthy.mockResolvedValue({ + smtp: { status: 'down', message: 'SMTP connection failed' }, + }); + cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ + cloudinary: { status: 'up' }, + }); healthCheckService.check.mockResolvedValue(mockHealthResult); const result = await controller.check(); diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts index dad69945..5cc64a60 100644 --- a/backend/src/health/health.module.ts +++ b/backend/src/health/health.module.ts @@ -8,6 +8,10 @@ import { CloudinaryHealthIndicator } from './indicators/cloudinary.health.indica @Module({ imports: [TerminusModule], controllers: [HealthController], - providers: [DbHealthIndicator, SmtpHealthIndicator, CloudinaryHealthIndicator], + providers: [ + DbHealthIndicator, + SmtpHealthIndicator, + CloudinaryHealthIndicator, + ], }) export class HealthModule {} diff --git a/backend/src/health/indicators/cloudinary.health.indicator.spec.ts b/backend/src/health/indicators/cloudinary.health.indicator.spec.ts index 6f374a9d..e9c66a35 100644 --- a/backend/src/health/indicators/cloudinary.health.indicator.spec.ts +++ b/backend/src/health/indicators/cloudinary.health.indicator.spec.ts @@ -22,7 +22,9 @@ describe('CloudinaryHealthIndicator', () => { ], }).compile(); - indicator = module.get(CloudinaryHealthIndicator); + indicator = module.get( + CloudinaryHealthIndicator, + ); configService = module.get(ConfigService); jest.clearAllMocks(); @@ -101,7 +103,9 @@ describe('CloudinaryHealthIndicator', () => { return config[key]; }); - (cloudinary.api.ping as jest.Mock).mockRejectedValue(new Error('API Error')); + (cloudinary.api.ping as jest.Mock).mockRejectedValue( + new Error('API Error'), + ); const result = await indicator.isHealthy('cloudinary'); diff --git a/backend/src/health/indicators/cloudinary.health.indicator.ts b/backend/src/health/indicators/cloudinary.health.indicator.ts index b10a2baf..f418f6a5 100644 --- a/backend/src/health/indicators/cloudinary.health.indicator.ts +++ b/backend/src/health/indicators/cloudinary.health.indicator.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HealthCheckResult } from '@nestjs/terminus'; +import { HealthIndicatorResult } from '@nestjs/terminus'; import { v2 as cloudinary } from 'cloudinary'; @Injectable() @@ -13,9 +13,9 @@ export class CloudinaryHealthIndicator { }); } - async isHealthy(key: string): Promise { + async isHealthy(key: string): Promise { try { - const result = await cloudinary.api.ping(); + const result = (await cloudinary.api.ping()) as { status: string }; if (result && result.status === 'ok') { return { [key]: { @@ -33,7 +33,10 @@ export class CloudinaryHealthIndicator { return { [key]: { status: 'down', - message: error instanceof Error ? error.message : 'Cloudinary connection failed', + message: + error instanceof Error + ? error.message + : 'Cloudinary connection failed', }, }; } diff --git a/backend/src/health/indicators/db.health.indicator.ts b/backend/src/health/indicators/db.health.indicator.ts index 151713e9..41bb2831 100644 --- a/backend/src/health/indicators/db.health.indicator.ts +++ b/backend/src/health/indicators/db.health.indicator.ts @@ -1,12 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { TypeOrmHealthIndicator } from '@nestjs/terminus'; -import { HealthCheckResult } from '@nestjs/terminus'; +import { + TypeOrmHealthIndicator, + HealthIndicatorResult, +} from '@nestjs/terminus'; @Injectable() export class DbHealthIndicator { constructor(private db: TypeOrmHealthIndicator) {} - async isHealthy(key: string): Promise { + async isHealthy(key: string): Promise { return this.db.pingCheck(key); } } diff --git a/backend/src/health/indicators/smtp.health.indicator.ts b/backend/src/health/indicators/smtp.health.indicator.ts index 46a7a7b0..61f896d4 100644 --- a/backend/src/health/indicators/smtp.health.indicator.ts +++ b/backend/src/health/indicators/smtp.health.indicator.ts @@ -1,16 +1,19 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HealthCheckResult } from '@nestjs/terminus'; +import { HealthIndicatorResult } from '@nestjs/terminus'; import * as nodemailer from 'nodemailer'; @Injectable() export class SmtpHealthIndicator { constructor(private configService: ConfigService) {} - async isHealthy(key: string): Promise { + async isHealthy(key: string): Promise { try { const transporter = nodemailer.createTransport({ - host: this.configService.get('MAIL_HOST', 'sandbox.smtp.mailtrap.io'), + host: this.configService.get( + 'MAIL_HOST', + 'sandbox.smtp.mailtrap.io', + ), port: this.configService.get('MAIL_PORT', 2525), auth: { user: this.configService.get('MAIL_USER'), @@ -28,7 +31,8 @@ export class SmtpHealthIndicator { return { [key]: { status: 'down', - message: error instanceof Error ? error.message : 'SMTP connection failed', + message: + error instanceof Error ? error.message : 'SMTP connection failed', }, }; } diff --git a/backend/src/queue/processors/email-send.processor.ts b/backend/src/queue/processors/email-send.processor.ts new file mode 100644 index 00000000..94e1cf82 --- /dev/null +++ b/backend/src/queue/processors/email-send.processor.ts @@ -0,0 +1,35 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_EMAIL_SEND } from '../queue.constants'; + +@Processor(QUEUE_EMAIL_SEND) +export class EmailSendProcessor extends WorkerHost { + private readonly logger = new Logger(EmailSendProcessor.name); + + async process(job: Job): Promise { + this.logger.log(`Processing email-send job ${job.id} (type: ${job.name})`); + + try { + await job.updateProgress(25); + // Render email template — consumed by BE-10 + await job.updateProgress(50); + // Send via mailer transport + await job.updateProgress(75); + // Record delivery receipt + await job.updateProgress(100); + + this.logger.log(`Completed email-send job ${job.id}`); + } catch (err) { + this.logger.error({ + message: 'email-send job failed', + jobId: job.id, + type: job.name, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + attemptsMade: job.attemptsMade, + }); + throw err; + } + } +} diff --git a/backend/src/queue/processors/pdf-generate.processor.ts b/backend/src/queue/processors/pdf-generate.processor.ts new file mode 100644 index 00000000..49ef6f55 --- /dev/null +++ b/backend/src/queue/processors/pdf-generate.processor.ts @@ -0,0 +1,37 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_PDF_GENERATE } from '../queue.constants'; + +@Processor(QUEUE_PDF_GENERATE) +export class PdfGenerateProcessor extends WorkerHost { + private readonly logger = new Logger(PdfGenerateProcessor.name); + + async process(job: Job): Promise { + this.logger.log( + `Processing pdf-generate job ${job.id} (type: ${job.name})`, + ); + + try { + await job.updateProgress(25); + // Fetch shipment/invoice data — consumed by BE-08 + await job.updateProgress(50); + // Render PDF template + await job.updateProgress(75); + // Upload to storage and store Document record + await job.updateProgress(100); + + this.logger.log(`Completed pdf-generate job ${job.id}`); + } catch (err) { + this.logger.error({ + message: 'pdf-generate job failed', + jobId: job.id, + type: job.name, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + attemptsMade: job.attemptsMade, + }); + throw err; + } + } +} diff --git a/backend/src/queue/processors/stellar-anchor.processor.ts b/backend/src/queue/processors/stellar-anchor.processor.ts new file mode 100644 index 00000000..7d65ba5a --- /dev/null +++ b/backend/src/queue/processors/stellar-anchor.processor.ts @@ -0,0 +1,37 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_STELLAR_ANCHOR } from '../queue.constants'; + +@Processor(QUEUE_STELLAR_ANCHOR) +export class StellarAnchorProcessor extends WorkerHost { + private readonly logger = new Logger(StellarAnchorProcessor.name); + + async process(job: Job): Promise { + this.logger.log( + `Processing stellar-anchor job ${job.id} (type: ${job.name})`, + ); + + try { + await job.updateProgress(25); + // Contract call preparation — consumed by BE-34/BE-35/BE-36 + await job.updateProgress(50); + // Submit transaction to Soroban + await job.updateProgress(75); + // Await confirmation + await job.updateProgress(100); + + this.logger.log(`Completed stellar-anchor job ${job.id}`); + } catch (err) { + this.logger.error({ + message: 'stellar-anchor job failed', + jobId: job.id, + type: job.name, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + attemptsMade: job.attemptsMade, + }); + throw err; + } + } +} diff --git a/backend/src/queue/queue.constants.ts b/backend/src/queue/queue.constants.ts new file mode 100644 index 00000000..581812e9 --- /dev/null +++ b/backend/src/queue/queue.constants.ts @@ -0,0 +1,3 @@ +export const QUEUE_STELLAR_ANCHOR = 'stellar-anchor'; +export const QUEUE_EMAIL_SEND = 'email-send'; +export const QUEUE_PDF_GENERATE = 'pdf-generate'; diff --git a/backend/src/queue/queue.module.ts b/backend/src/queue/queue.module.ts new file mode 100644 index 00000000..0f5a8f27 --- /dev/null +++ b/backend/src/queue/queue.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { + QUEUE_EMAIL_SEND, + QUEUE_PDF_GENERATE, + QUEUE_STELLAR_ANCHOR, +} from './queue.constants'; +import { StellarAnchorProcessor } from './processors/stellar-anchor.processor'; +import { EmailSendProcessor } from './processors/email-send.processor'; +import { PdfGenerateProcessor } from './processors/pdf-generate.processor'; + +const defaultJobOptions = { + attempts: 3, + backoff: { type: 'exponential' as const, delay: 1000 }, + removeOnComplete: { count: 100 }, + removeOnFail: { count: 200 }, +}; + +@Module({ + imports: [ + BullModule.registerQueue( + { name: QUEUE_STELLAR_ANCHOR, defaultJobOptions }, + { name: QUEUE_EMAIL_SEND, defaultJobOptions }, + { name: QUEUE_PDF_GENERATE, defaultJobOptions }, + ), + ], + providers: [StellarAnchorProcessor, EmailSendProcessor, PdfGenerateProcessor], + exports: [BullModule], +}) +export class QueueModule {} diff --git a/backend/src/reviews/dto/review-response.dto.ts b/backend/src/reviews/dto/review-response.dto.ts new file mode 100644 index 00000000..f7fff6f0 --- /dev/null +++ b/backend/src/reviews/dto/review-response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ReviewResponseDto { + @ApiProperty({ example: 'uuid-v4' }) + id: string; + + @ApiProperty({ example: 'shipment-uuid' }) + shipmentId: string; + + @ApiProperty({ example: 'reviewer-uuid' }) + reviewerId: string; + + @ApiProperty({ example: 'reviewee-uuid' }) + revieweeId: string; + + @ApiProperty({ example: 5, minimum: 1, maximum: 5 }) + rating: number; + + @ApiPropertyOptional({ example: 'Excellent service!', nullable: true }) + comment: string | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/reviews/reviews.controller.ts b/backend/src/reviews/reviews.controller.ts index 43a50419..2ea8f8cd 100644 --- a/backend/src/reviews/reviews.controller.ts +++ b/backend/src/reviews/reviews.controller.ts @@ -6,9 +6,15 @@ import { ParseUUIDPipe, Post, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { ReviewsService } from './reviews.service'; import { CreateReviewDto } from './dto/create-review.dto'; +import { ReviewResponseDto } from './dto/review-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { User } from '../users/entities/user.entity'; @@ -20,6 +26,7 @@ export class ReviewsController { @Post(':id/review') @ApiOperation({ summary: 'Leave a review for a completed shipment' }) + @ApiResponse({ status: 201, type: ReviewResponseDto }) create( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, diff --git a/backend/src/tasks/tasks.module.ts b/backend/src/tasks/tasks.module.ts new file mode 100644 index 00000000..26e32d49 --- /dev/null +++ b/backend/src/tasks/tasks.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Shipment } from '../shipments/entities/shipment.entity'; +import { Notification } from '../notifications/entities/notification.entity'; +import { TasksService } from './tasks.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Shipment, Notification])], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/backend/src/tasks/tasks.service.ts b/backend/src/tasks/tasks.service.ts new file mode 100644 index 00000000..3fc21d02 --- /dev/null +++ b/backend/src/tasks/tasks.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThan, Repository } from 'typeorm'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Shipment } from '../shipments/entities/shipment.entity'; +import { Notification } from '../notifications/entities/notification.entity'; +import { ShipmentStatus } from '../common/enums/shipment-status.enum'; +import { NotificationType } from '../notifications/entities/notification.entity'; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); + +@Injectable() +export class TasksService { + private readonly logger = new Logger(TasksService.name); + + constructor( + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, + @InjectRepository(Notification) + private readonly notificationRepo: Repository, + ) {} + + @Cron('0 2 * * *') + async flagStuckShipments(): Promise { + const threshold = new Date(Date.now() - THIRTY_DAYS_MS); + + const stuck = await this.shipmentRepo.find({ + where: { + status: ShipmentStatus.IN_TRANSIT, + updatedAt: LessThan(threshold), + }, + select: ['id', 'shipperId', 'trackingNumber'], + }); + + if (stuck.length === 0) return; + + const ids = stuck.map((s) => s.id); + this.logger.warn( + `Found ${stuck.length} stuck shipments: ${ids.join(', ')}`, + ); + + const notifications = stuck.map((shipment) => + this.notificationRepo.create({ + userId: shipment.shipperId, + type: NotificationType.GENERAL, + title: 'Shipment stuck in transit', + message: `Shipment ${shipment.trackingNumber} has been in transit for more than 30 days.`, + isRead: false, + }), + ); + + await this.notificationRepo.save(notifications); + } + + @Cron('0 3 * * *') + async cleanupTempFiles(): Promise { + if (!fs.existsSync(UPLOADS_DIR)) return; + + const cutoff = Date.now() - SEVEN_DAYS_MS; + let deleted = 0; + + try { + const entries = fs.readdirSync(UPLOADS_DIR); + for (const entry of entries) { + const filePath = path.join(UPLOADS_DIR, entry); + const stat = fs.statSync(filePath); + if (stat.isFile() && stat.mtimeMs < cutoff) { + fs.unlinkSync(filePath); + deleted++; + } + } + } catch (err) { + this.logger.error('Temp file cleanup failed', err); + } + + this.logger.log(`Temp file cleanup: removed ${deleted} old upload(s)`); + } +} diff --git a/backend/src/webhooks/dto/webhook-response.dto.ts b/backend/src/webhooks/dto/webhook-response.dto.ts new file mode 100644 index 00000000..b0db11ad --- /dev/null +++ b/backend/src/webhooks/dto/webhook-response.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class WebhookResponseDto { + @ApiProperty({ example: 'uuid-v4' }) + id: string; + + @ApiProperty({ example: 'user-uuid' }) + userId: string; + + @ApiProperty({ example: 'https://partner.example.com/webhooks/freightflow' }) + url: string; + + @ApiPropertyOptional({ type: [String], example: ['shipment.status_changed'] }) + events: string[] | null; + + @ApiProperty({ example: true }) + active: boolean; + + @ApiPropertyOptional({ nullable: true }) + lastDeliveryStatus: string | null; + + @ApiPropertyOptional({ nullable: true }) + lastDeliveryAt: Date | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/webhooks/webhooks.controller.ts b/backend/src/webhooks/webhooks.controller.ts index e1cb5889..bd8fcb77 100644 --- a/backend/src/webhooks/webhooks.controller.ts +++ b/backend/src/webhooks/webhooks.controller.ts @@ -21,6 +21,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { UserRole } from '../common/enums/role.enum'; import { User } from '../users/entities/user.entity'; import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { WebhookResponseDto } from './dto/webhook-response.dto'; import { WebhooksService } from './webhooks.service'; @ApiTags('webhooks') @@ -35,7 +36,11 @@ export class WebhooksController { @ApiOperation({ summary: 'Register a webhook URL for shipment status changes', }) - @ApiResponse({ status: 201, description: 'Webhook created' }) + @ApiResponse({ + status: 201, + type: WebhookResponseDto, + description: 'Webhook created', + }) create(@CurrentUser() user: User, @Body() dto: CreateWebhookDto) { return this.webhooksService.create(user.id, dto); } @@ -44,6 +49,7 @@ export class WebhooksController { @UseGuards(RolesGuard) @Roles(UserRole.SHIPPER, UserRole.ADMIN) @ApiOperation({ summary: 'List my registered webhooks' }) + @ApiResponse({ status: 200, type: [WebhookResponseDto] }) findAll(@CurrentUser() user: User) { return this.webhooksService.findAllForUser(user.id); } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..fa02e672 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,70 @@ +services: + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DATABASE_NAME} + POSTGRES_USER: ${DATABASE_USERNAME} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME} -d ${DATABASE_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - freightflow + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - freightflow + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + env_file: .env.production + ports: + - "6006:6006" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - freightflow + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + restart: unless-stopped + environment: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + ports: + - "3000:3000" + depends_on: + - backend + networks: + - freightflow + +volumes: + postgres_data: + redis_data: + +networks: + freightflow: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..e79e3877 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index b6df6d1c..ea8b09ea 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -29,12 +29,11 @@ const securityHeaders = [ "connect-src 'self' wss://localhost:* https://horizon-testnet.stellar.org", ].join("; "), }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, ]; const nextConfig: NextConfig = { - async headers() { - return [{ source: "/(.*)", headers: securityHeaders }]; - }, + output: "standalone", images: { remotePatterns: [ @@ -42,6 +41,10 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "*.amazonaws.com" }, ], }, + + async headers() { + return [{ source: "/(.*)", headers: securityHeaders }]; + }, }; -module.exports = withBundleAnalyzer(nextConfig); \ No newline at end of file +module.exports = withBundleAnalyzer(nextConfig); diff --git a/package/env-validation/env-validation.module.ts b/package/env-validation/env-validation.module.ts index d394789a..745c6170 100644 --- a/package/env-validation/env-validation.module.ts +++ b/package/env-validation/env-validation.module.ts @@ -16,9 +16,12 @@ import * as Joi from 'joi'; DATABASE_USERNAME: Joi.string().default('postgres'), DATABASE_PASSWORD: Joi.string().default('postgres'), DATABASE_NAME: Joi.string().default('freightflow'), - JWT_SECRET: Joi.string().default('secret'), + JWT_SECRET: Joi.string().required(), JWT_EXPIRES_IN: Joi.string().default('15m'), REDIS_URL: Joi.string().default('redis://localhost:6379'), + REDIS_HOST: Joi.string().default('localhost'), + REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().optional().allow(''), FRONTEND_URL: Joi.string().default('http://localhost:3000'), CLOUDINARY_CLOUD_NAME: Joi.string().optional(), CLOUDINARY_API_KEY: Joi.string().optional(), @@ -27,8 +30,20 @@ import * as Joi from 'joi'; MAILER_PORT: Joi.number().optional(), MAILER_USER: Joi.string().optional(), MAILER_PASS: Joi.string().optional(), + TWILIO_ACCOUNT_SID: Joi.string().optional(), + TWILIO_AUTH_TOKEN: Joi.string().optional(), + TWILIO_PHONE_NUMBER: Joi.string().optional(), + WEB_PUSH_PUBLIC_KEY: Joi.string().optional(), + WEB_PUSH_PRIVATE_KEY: Joi.string().optional(), + WEB_PUSH_EMAIL: Joi.string().optional(), STELLAR_SECRET: Joi.string().optional(), STELLAR_NETWORK: Joi.string().optional(), + STELLAR_SHIPMENT_CONTRACT: Joi.string().optional(), + STELLAR_ESCROW_CONTRACT: Joi.string().optional(), + STELLAR_DOCUMENT_CONTRACT: Joi.string().optional(), + STELLAR_REPUTATION_CONTRACT: Joi.string().optional(), + STELLAR_IDENTITY_CONTRACT: Joi.string().optional(), + PLATFORM_FEE_PERCENT: Joi.number().default(2.5), }), validationOptions: { allowUnknown: true,