Compare commits

..

No commits in common. "main" and "administration" have entirely different histories.

398 changed files with 9249 additions and 51546 deletions

View File

@ -1,276 +0,0 @@
name: CD Production
# Production pipeline — Hetzner k3s.
#
# SECURITY: Two mandatory gates before any production deployment:
# 1. quality-gate — lint + unit tests on the exact commit being deployed
# 2. verify-image — confirms preprod-SHA image EXISTS in registry,
# which proves this commit passed the full preprod
# pipeline (lint + unit + integration + docker build).
# If someone merges to main without going through preprod,
# this step fails and the deployment is blocked.
#
# Flow: quality-gate → verify-image → promote → deploy → notify
#
# Secrets required:
# REGISTRY_TOKEN — Scaleway registry (read/write)
# HETZNER_KUBECONFIG — base64: cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0
# PROD_BACKEND_URL — https://api.xpeditis.com
# PROD_FRONTEND_URL — https://app.xpeditis.com
# DISCORD_WEBHOOK_URL
on:
push:
branches: [main]
concurrency:
group: cd-production
cancel-in-progress: false
env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio
NODE_VERSION: '20'
K8S_NAMESPACE: xpeditis-prod
jobs:
# ── 1. Quality Gate ──────────────────────────────────────────────────
# Runs on every prod deployment regardless of what happened in preprod.
backend-quality:
name: Backend — Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- run: npm run lint
frontend-quality:
name: Frontend — Lint & Type-check
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- run: npm run lint
- run: npm run type-check
backend-tests:
name: Backend — Unit Tests
runs-on: ubuntu-latest
needs: backend-quality
defaults:
run:
working-directory: apps/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- run: npm test -- --passWithNoTests
frontend-tests:
name: Frontend — Unit Tests
runs-on: ubuntu-latest
needs: frontend-quality
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- run: npm test -- --passWithNoTests
# ── 2. Image Verification ────────────────────────────────────────────
# Checks that preprod-SHA tags exist for this EXACT commit.
# This is the security gate: if the preprod pipeline never ran for this
# commit (or failed before the docker build step), this job fails and
# the deployment is fully blocked.
verify-image:
name: Verify Preprod Image Exists
runs-on: ubuntu-latest
needs: [backend-tests, frontend-tests]
outputs:
sha: ${{ steps.sha.outputs.short }}
steps:
- name: Short SHA
id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Check backend image preprod-SHA
run: |
TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}"
echo "Verifying: $TAG"
docker buildx imagetools inspect "$TAG" || {
echo ""
echo "BLOCKED: Image $TAG not found in registry."
echo "This commit was not built by the preprod pipeline."
echo "Merge to preprod first and wait for the full pipeline to succeed."
exit 1
}
- name: Check frontend image preprod-SHA
run: |
TAG="${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }}"
echo "Verifying: $TAG"
docker buildx imagetools inspect "$TAG" || {
echo ""
echo "BLOCKED: Image $TAG not found in registry."
echo "This commit was not built by the preprod pipeline."
echo "Merge to preprod first and wait for the full pipeline to succeed."
exit 1
}
# ── 3. Promote Images ────────────────────────────────────────────────
# Re-tags preprod-SHA → latest + prod-SHA within Scaleway.
# No rebuild. No layer transfer. Manifest-level operation only.
promote-images:
name: Promote Images (preprod-SHA → prod)
runs-on: ubuntu-latest
needs: verify-image
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Promote backend
run: |
SHA="${{ needs.verify-image.outputs.sha }}"
docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-backend:latest \
--tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \
${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA}
echo "Backend promoted: preprod-${SHA} → latest + prod-${SHA}"
- name: Promote frontend
run: |
SHA="${{ needs.verify-image.outputs.sha }}"
docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:latest \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA} \
${{ env.REGISTRY }}/xpeditis-frontend:preprod-${SHA}
echo "Frontend promoted: preprod-${SHA} → latest + prod-${SHA}"
# ── 4. Deploy to k3s ─────────────────────────────────────────────────
deploy:
name: Deploy to Production (k3s)
runs-on: ubuntu-latest
needs: [verify-image, promote-images]
environment:
name: production
url: https://app.xpeditis.com
steps:
- name: Configure kubectl
run: |
mkdir -p ~/.kube
echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl cluster-info
kubectl get nodes -o wide
- name: Deploy backend
id: deploy-backend
run: |
SHA="${{ needs.verify-image.outputs.sha }}"
IMAGE="${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA}"
echo "Deploying: $IMAGE"
kubectl set image deployment/xpeditis-backend backend="$IMAGE" -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=300s
echo "Backend rollout complete."
- name: Deploy frontend
id: deploy-frontend
run: |
SHA="${{ needs.verify-image.outputs.sha }}"
IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA}"
echo "Deploying: $IMAGE"
kubectl set image deployment/xpeditis-frontend frontend="$IMAGE" -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=300s
echo "Frontend rollout complete."
- name: Auto-rollback on deployment failure
if: failure()
run: |
echo "Deployment failed — initiating rollback..."
kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }}
kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
echo "Rollback complete. Previous version is live."
# ── Notifications ────────────────────────────────────────────────────
notify-success:
name: Notify Success
runs-on: ubuntu-latest
needs: [verify-image, deploy]
if: success()
steps:
- run: |
curl -s -H "Content-Type: application/json" -d '{
"embeds": [{
"title": "🚀 Production Deployed & Healthy",
"color": 3066993,
"fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true},
{"name": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true},
{"name": "Cluster", "value": "Hetzner k3s — `xpeditis-prod`", "inline": false},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}
],
"footer": {"text": "Xpeditis CI/CD • Production"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}
notify-failure:
name: Notify Failure
runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy]
if: failure()
steps:
- run: |
curl -s -H "Content-Type: application/json" -d '{
"content": "@here PRODUCTION PIPELINE FAILED",
"embeds": [{
"title": "🔴 Production Pipeline Failed",
"description": "Check the workflow for details. Auto-rollback was triggered if the failure was during deploy.",
"color": 15158332,
"fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false},
{"name": "Rollback", "value": "[Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)", "inline": false}
],
"footer": {"text": "Xpeditis CI/CD • Production"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@ -1,316 +0,0 @@
name: CD Preprod
# Full pipeline triggered on every push to preprod.
# Flow: lint → unit tests → integration tests → docker build → deploy → notify
#
# Secrets required:
# REGISTRY_TOKEN — Scaleway registry (read/write)
# NEXT_PUBLIC_API_URL — https://api.preprod.xpeditis.com
# NEXT_PUBLIC_APP_URL — https://preprod.xpeditis.com
# PORTAINER_WEBHOOK_BACKEND — Portainer webhook (preprod backend)
# PORTAINER_WEBHOOK_FRONTEND— Portainer webhook (preprod frontend)
# PREPROD_BACKEND_URL — https://api.preprod.xpeditis.com
# PREPROD_FRONTEND_URL — https://preprod.xpeditis.com
# DISCORD_WEBHOOK_URL
on:
push:
branches: [preprod]
concurrency:
group: cd-preprod
cancel-in-progress: false
env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio
NODE_VERSION: '20'
jobs:
# ── 1. Lint ─────────────────────────────────────────────────────────
backend-quality:
name: Backend — Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- run: npm run lint
frontend-quality:
name: Frontend — Lint & Type-check
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- run: npm run lint
- run: npm run type-check
# ── 2. Unit Tests ────────────────────────────────────────────────────
backend-tests:
name: Backend — Unit Tests
runs-on: ubuntu-latest
needs: backend-quality
defaults:
run:
working-directory: apps/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- run: npm test -- --passWithNoTests
frontend-tests:
name: Frontend — Unit Tests
runs-on: ubuntu-latest
needs: frontend-quality
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- run: npm test -- --passWithNoTests
# ── 3. Integration Tests ─────────────────────────────────────────────
integration-tests:
name: Backend — Integration Tests
runs-on: ubuntu-latest
needs: [backend-tests, frontend-tests]
defaults:
run:
working-directory: apps/backend
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: xpeditis_test
POSTGRES_PASSWORD: xpeditis_test_password
POSTGRES_DB: xpeditis_test
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- name: Run integration tests
env:
NODE_ENV: test
DATABASE_HOST: localhost
DATABASE_PORT: 5432
DATABASE_USER: xpeditis_test
DATABASE_PASSWORD: xpeditis_test_password
DATABASE_NAME: xpeditis_test
DATABASE_SYNCHRONIZE: 'false'
REDIS_HOST: localhost
REDIS_PORT: 6379
REDIS_PASSWORD: ''
JWT_SECRET: test-secret-key-ci
SMTP_HOST: localhost
SMTP_PORT: 1025
SMTP_FROM: test@xpeditis.com
run: npm run test:integration -- --passWithNoTests
# ── 4. Docker Build & Push ───────────────────────────────────────────
# Tags: preprod (latest for this env) + preprod-SHA (used by prod for exact promotion)
build-backend:
name: Build Backend
runs-on: ubuntu-latest
needs: integration-tests
outputs:
sha: ${{ steps.sha.outputs.short }}
steps:
- uses: actions/checkout@v4
- name: Short SHA
id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./apps/backend
file: ./apps/backend/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/xpeditis-backend:preprod
${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache,mode=max
platforms: linux/amd64,linux/arm64
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
needs: integration-tests
outputs:
sha: ${{ steps.sha.outputs.short }}
steps:
- uses: actions/checkout@v4
- name: Short SHA
id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./apps/frontend
file: ./apps/frontend/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/xpeditis-frontend:preprod
${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}
build-log-exporter:
name: Build Log Exporter
runs-on: ubuntu-latest
needs: integration-tests
outputs:
sha: ${{ steps.sha.outputs.short }}
steps:
- uses: actions/checkout@v4
- name: Short SHA
id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./apps/log-exporter
file: ./apps/log-exporter/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/xpeditis-log-exporter:preprod
${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache,mode=max
platforms: linux/amd64,linux/arm64
# ── 5. Deploy via Portainer ──────────────────────────────────────────
deploy:
name: Deploy to Preprod
runs-on: ubuntu-latest
needs: [build-backend, build-frontend, build-log-exporter]
environment: preprod
steps:
- name: Deploy backend
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}")
echo "Portainer response: HTTP $HTTP_CODE"
if [[ "$HTTP_CODE" != "2"* ]]; then
echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Backend webhook triggered."
- name: Wait for backend startup
run: sleep 20
- name: Deploy frontend
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}")
echo "Portainer response: HTTP $HTTP_CODE"
if [[ "$HTTP_CODE" != "2"* ]]; then
echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Frontend webhook triggered."
# ── Notifications ────────────────────────────────────────────────────
notify-success:
name: Notify Success
runs-on: ubuntu-latest
needs: [build-backend, build-frontend, deploy]
if: success()
steps:
- run: |
curl -s -H "Content-Type: application/json" -d '{
"embeds": [{
"title": "✅ Preprod Deployed & Healthy",
"color": 3066993,
"fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true},
{"name": "SHA", "value": "`${{ needs.build-backend.outputs.sha }}`", "inline": true},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}
],
"footer": {"text": "Xpeditis CI/CD • Preprod"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}
notify-failure:
name: Notify Failure
runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy]
if: failure()
steps:
- run: |
curl -s -H "Content-Type: application/json" -d '{
"embeds": [{
"title": "❌ Preprod Pipeline Failed",
"description": "Preprod was NOT deployed.",
"color": 15158332,
"fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}
],
"footer": {"text": "Xpeditis CI/CD • Preprod"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@ -1,103 +1,372 @@
name: Dev CI name: CI/CD Pipeline
on: on:
push: push:
branches: [dev] branches:
pull_request: - preprod
branches: [dev]
env:
concurrency: REGISTRY: rg.fr-par.scw.cloud/weworkstudio
group: dev-ci-${{ github.ref }} NODE_VERSION: '20'
cancel-in-progress: true
jobs:
env: # ============================================
NODE_VERSION: '20' # Backend Build, Test & Deploy
# ============================================
jobs: backend:
backend-quality: name: Backend - Build, Test & Push
name: Backend — Lint runs-on: ubuntu-latest
runs-on: ubuntu-latest defaults:
defaults: run:
run: working-directory: apps/backend
working-directory: apps/backend
steps: steps:
- uses: actions/checkout@v4 - name: Checkout code
- uses: actions/setup-node@v4 uses: actions/checkout@v4
with:
node-version: ${{ env.NODE_VERSION }} - name: Setup Node.js
cache: 'npm' uses: actions/setup-node@v4
cache-dependency-path: apps/backend/package-lock.json with:
- run: npm install --legacy-peer-deps node-version: ${{ env.NODE_VERSION }}
- run: npm run lint
- name: Install dependencies
frontend-quality: run: npm install --legacy-peer-deps
name: Frontend — Lint & Type-check
runs-on: ubuntu-latest - name: Lint code
defaults: run: npm run lint
run:
working-directory: apps/frontend - name: Run unit tests
steps: run: npm test -- --coverage --passWithNoTests
- uses: actions/checkout@v4
- uses: actions/setup-node@v4 - name: Build application
with: run: npm run build
node-version: ${{ env.NODE_VERSION }}
cache: 'npm' - name: Set up Docker Buildx
cache-dependency-path: apps/frontend/package-lock.json uses: docker/setup-buildx-action@v3
- run: npm ci --legacy-peer-deps
- run: npm run lint - name: Login to Scaleway Registry
- run: npm run type-check uses: docker/login-action@v3
with:
backend-tests: registry: rg.fr-par.scw.cloud/weworkstudio
name: Backend — Unit Tests username: nologin
runs-on: ubuntu-latest password: ${{ secrets.REGISTRY_TOKEN }}
needs: backend-quality
defaults: - name: Extract metadata for Docker
run: id: meta
working-directory: apps/backend uses: docker/metadata-action@v5
steps: with:
- uses: actions/checkout@v4 images: ${{ env.REGISTRY }}/xpeditis-backend
- uses: actions/setup-node@v4 tags: |
with: type=ref,event=branch
node-version: ${{ env.NODE_VERSION }} type=raw,value=latest,enable={{is_default_branch}}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json - name: Build and push Backend Docker image
- run: npm install --legacy-peer-deps uses: docker/build-push-action@v5
- run: npm test -- --passWithNoTests with:
context: ./apps/backend
frontend-tests: file: ./apps/backend/Dockerfile
name: Frontend — Unit Tests push: true
runs-on: ubuntu-latest tags: ${{ steps.meta.outputs.tags }}
needs: frontend-quality labels: ${{ steps.meta.outputs.labels }}
defaults: cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache
run: cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache,mode=max
working-directory: apps/frontend platforms: linux/amd64,linux/arm64
steps:
- uses: actions/checkout@v4 # ============================================
- uses: actions/setup-node@v4 # Frontend Build, Test & Deploy
with: # ============================================
node-version: ${{ env.NODE_VERSION }} frontend:
cache: 'npm' name: Frontend - Build, Test & Push
cache-dependency-path: apps/frontend/package-lock.json runs-on: ubuntu-latest
- run: npm ci --legacy-peer-deps defaults:
- run: npm test -- --passWithNoTests run:
working-directory: apps/frontend
notify-failure:
name: Notify Failure steps:
runs-on: ubuntu-latest - name: Checkout code
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests] uses: actions/checkout@v4
if: failure()
steps: - name: Setup Node.js
- name: Discord uses: actions/setup-node@v4
run: | with:
curl -s -H "Content-Type: application/json" -d '{ node-version: ${{ env.NODE_VERSION }}
"embeds": [{ cache: 'npm'
"title": "❌ Dev CI Failed", cache-dependency-path: apps/frontend/package-lock.json
"color": 15158332,
"fields": [ - name: Install dependencies
{"name": "Branch", "value": "`${{ github.ref_name }}`", "inline": true}, run: npm ci --legacy-peer-deps
{"name": "Author", "value": "${{ github.actor }}", "inline": true},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} - name: Lint code
], run: npm run lint
"footer": {"text": "Xpeditis CI • Dev"}
}] - name: Run tests
}' ${{ secrets.DISCORD_WEBHOOK_URL }} run: npm test -- --passWithNoTests || echo "No tests found"
- name: Build application
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:4000' }}
NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }}
NEXT_TELEMETRY_DISABLED: 1
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Scaleway Registry
uses: docker/login-action@v3
with:
registry: rg.fr-par.scw.cloud/weworkstudio
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/xpeditis-frontend
tags: |
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./apps/frontend
file: ./apps/frontend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:4000' }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }}
# ============================================
# Integration Tests (Optional)
# ============================================
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: [backend, frontend]
if: github.event_name == 'pull_request'
defaults:
run:
working-directory: apps/backend
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password
POSTGRES_DB: xpeditis_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Run integration tests
env:
DATABASE_HOST: localhost
DATABASE_PORT: 5432
DATABASE_USER: xpeditis
DATABASE_PASSWORD: xpeditis_dev_password
DATABASE_NAME: xpeditis_test
REDIS_HOST: localhost
REDIS_PORT: 6379
JWT_SECRET: test-secret-key-for-ci
run: npm run test:integration || echo "No integration tests found"
# ============================================
# Deployment Summary
# ============================================
deployment-summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [backend, frontend]
if: success()
steps:
- name: Summary
run: |
echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Backend Image" >> $GITHUB_STEP_SUMMARY
echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Frontend Image" >> $GITHUB_STEP_SUMMARY
echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-frontend\`" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
# ============================================
# Deploy to Portainer via Webhooks
# ============================================
deploy-portainer:
name: Deploy to Portainer
runs-on: ubuntu-latest
needs: [backend, frontend]
if: success() && github.ref == 'refs/heads/preprod'
steps:
- name: Trigger Backend Webhook
run: |
echo "🚀 Deploying Backend to Portainer..."
curl -X POST \
-H "Content-Type: application/json" \
-d '{"data": "backend-deployment"}' \
${{ secrets.PORTAINER_WEBHOOK_BACKEND }}
echo "✅ Backend webhook triggered"
- name: Wait before Frontend deployment
run: sleep 10
- name: Trigger Frontend Webhook
run: |
echo "🚀 Deploying Frontend to Portainer..."
curl -X POST \
-H "Content-Type: application/json" \
-d '{"data": "frontend-deployment"}' \
${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}
echo "✅ Frontend webhook triggered"
# ============================================
# Discord Notification - Success
# ============================================
notify-success:
name: Discord Notification (Success)
runs-on: ubuntu-latest
needs: [backend, frontend, deploy-portainer]
if: success()
steps:
- name: Send Discord notification
run: |
curl -H "Content-Type: application/json" \
-d '{
"embeds": [{
"title": "✅ CI/CD Pipeline Success",
"description": "Deployment completed successfully!",
"color": 3066993,
"fields": [
{
"name": "Repository",
"value": "${{ github.repository }}",
"inline": true
},
{
"name": "Branch",
"value": "${{ github.ref_name }}",
"inline": true
},
{
"name": "Commit",
"value": "[${{ github.sha }}](${{ github.event.head_commit.url }})",
"inline": false
},
{
"name": "Backend Image",
"value": "`${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}`",
"inline": false
},
{
"name": "Frontend Image",
"value": "`${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}`",
"inline": false
},
{
"name": "Workflow",
"value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"inline": false
}
],
"timestamp": "${{ github.event.head_commit.timestamp }}",
"footer": {
"text": "Xpeditis CI/CD"
}
}]
}' \
${{ secrets.DISCORD_WEBHOOK_URL }}
# ============================================
# Discord Notification - Failure
# ============================================
notify-failure:
name: Discord Notification (Failure)
runs-on: ubuntu-latest
needs: [backend, frontend, deploy-portainer]
if: failure()
steps:
- name: Send Discord notification
run: |
curl -H "Content-Type: application/json" \
-d '{
"embeds": [{
"title": "❌ CI/CD Pipeline Failed",
"description": "Deployment failed! Check the logs for details.",
"color": 15158332,
"fields": [
{
"name": "Repository",
"value": "${{ github.repository }}",
"inline": true
},
{
"name": "Branch",
"value": "${{ github.ref_name }}",
"inline": true
},
{
"name": "Commit",
"value": "[${{ github.sha }}](${{ github.event.head_commit.url }})",
"inline": false
},
{
"name": "Workflow",
"value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"inline": false
}
],
"timestamp": "${{ github.event.head_commit.timestamp }}",
"footer": {
"text": "Xpeditis CI/CD"
}
}]
}' \
${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@ -1,145 +0,0 @@
name: PR Checks
# Required status checks — configure these in branch protection rules.
# PRs to preprod : lint + type-check + unit tests + integration tests
# PRs to main : lint + type-check + unit tests only
on:
pull_request:
branches: [preprod, main]
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
jobs:
backend-quality:
name: Backend — Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- run: npm run lint
frontend-quality:
name: Frontend — Lint & Type-check
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- run: npm run lint
- run: npm run type-check
backend-tests:
name: Backend — Unit Tests
runs-on: ubuntu-latest
needs: backend-quality
defaults:
run:
working-directory: apps/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- run: npm test -- --passWithNoTests
frontend-tests:
name: Frontend — Unit Tests
runs-on: ubuntu-latest
needs: frontend-quality
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- run: npm test -- --passWithNoTests
# Integration tests — PRs to preprod only
# Code going to main was already integration-tested when it passed through preprod
integration-tests:
name: Backend — Integration Tests
runs-on: ubuntu-latest
needs: backend-tests
if: github.base_ref == 'preprod'
defaults:
run:
working-directory: apps/backend
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: xpeditis_test
POSTGRES_PASSWORD: xpeditis_test_password
POSTGRES_DB: xpeditis_test
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
- run: npm install --legacy-peer-deps
- name: Run integration tests
env:
NODE_ENV: test
DATABASE_HOST: localhost
DATABASE_PORT: 5432
DATABASE_USER: xpeditis_test
DATABASE_PASSWORD: xpeditis_test_password
DATABASE_NAME: xpeditis_test
DATABASE_SYNCHRONIZE: 'false'
REDIS_HOST: localhost
REDIS_PORT: 6379
REDIS_PASSWORD: ''
JWT_SECRET: test-secret-key-ci
SMTP_HOST: localhost
SMTP_PORT: 1025
SMTP_FROM: test@xpeditis.com
run: npm run test:integration -- --passWithNoTests

View File

@ -1,269 +0,0 @@
name: Rollback
# Emergency rollback — production (Hetzner k3s) and preprod (Portainer).
#
# Production strategies:
# previous — kubectl rollout undo (fastest, reverts to previous ReplicaSet)
# specific-version — kubectl set image to a specific prod-SHA tag
#
# Preprod strategy:
# Re-tags a preprod-SHA image back to :preprod, triggers Portainer webhook.
#
# Secrets required:
# REGISTRY_TOKEN — Scaleway registry
# HETZNER_KUBECONFIG — base64 kubeconfig (production only)
# PORTAINER_WEBHOOK_BACKEND — Portainer webhook preprod backend
# PORTAINER_WEBHOOK_FRONTEND — Portainer webhook preprod frontend
# PROD_BACKEND_URL — https://api.xpeditis.com
# PROD_FRONTEND_URL — https://app.xpeditis.com
# PREPROD_BACKEND_URL — https://api.preprod.xpeditis.com
# PREPROD_FRONTEND_URL — https://preprod.xpeditis.com
# DISCORD_WEBHOOK_URL
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [production, preprod]
strategy:
description: 'Strategy (production only — "previous" = instant kubectl undo)'
required: true
type: choice
options: [previous, specific-version]
version_tag:
description: 'Tag for specific-version (e.g. prod-a1b2c3d or preprod-a1b2c3d)'
required: false
type: string
reason:
description: 'Reason (audit trail)'
required: true
type: string
env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio
K8S_NAMESPACE: xpeditis-prod
jobs:
validate:
name: Validate Inputs
runs-on: ubuntu-latest
steps:
- name: Check inputs
run: |
ENV="${{ github.event.inputs.environment }}"
STRATEGY="${{ github.event.inputs.strategy }}"
TAG="${{ github.event.inputs.version_tag }}"
if [ "$STRATEGY" = "specific-version" ] && [ -z "$TAG" ]; then
echo "ERROR: version_tag is required for specific-version strategy."
exit 1
fi
if [ "$ENV" = "production" ] && [ "$STRATEGY" = "specific-version" ]; then
if [[ ! "$TAG" =~ ^prod- ]]; then
echo "ERROR: Production tag must start with 'prod-' (got: $TAG)"
exit 1
fi
fi
if [ "$ENV" = "preprod" ]; then
if [[ ! "$TAG" =~ ^preprod- ]]; then
echo "ERROR: Preprod tag must start with 'preprod-' (got: $TAG)"
exit 1
fi
fi
echo "Validated — env: $ENV | strategy: $STRATEGY | tag: ${TAG:-N/A} | reason: ${{ github.event.inputs.reason }}"
# ── Production rollback via kubectl ──────────────────────────────────
rollback-production:
name: Rollback Production
runs-on: ubuntu-latest
needs: validate
if: github.event.inputs.environment == 'production'
environment:
name: production
url: https://app.xpeditis.com
steps:
- name: Configure kubectl
run: |
mkdir -p ~/.kube
echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl cluster-info
- name: Rollback — previous version
if: github.event.inputs.strategy == 'previous'
run: |
kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=180s
kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=180s
kubectl get pods -n ${{ env.K8S_NAMESPACE }}
- name: Login to Scaleway (for image verification)
if: github.event.inputs.strategy == 'specific-version'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/setup-buildx-action@v3
if: github.event.inputs.strategy == 'specific-version'
- name: Rollback — specific version
if: github.event.inputs.strategy == 'specific-version'
run: |
TAG="${{ github.event.inputs.version_tag }}"
BACKEND="${{ env.REGISTRY }}/xpeditis-backend:${TAG}"
FRONTEND="${{ env.REGISTRY }}/xpeditis-frontend:${TAG}"
echo "Verifying images exist..."
docker buildx imagetools inspect "$BACKEND" || { echo "ERROR: $BACKEND not found"; exit 1; }
docker buildx imagetools inspect "$FRONTEND" || { echo "ERROR: $FRONTEND not found"; exit 1; }
kubectl set image deployment/xpeditis-backend backend="$BACKEND" -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=180s
kubectl set image deployment/xpeditis-frontend frontend="$FRONTEND" -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=180s
kubectl get pods -n ${{ env.K8S_NAMESPACE }}
- name: Rollout history
if: always()
run: |
kubectl rollout history deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} || true
kubectl rollout history deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} || true
# ── Preprod rollback via Portainer ───────────────────────────────────
rollback-preprod:
name: Rollback Preprod
runs-on: ubuntu-latest
needs: validate
if: github.event.inputs.environment == 'preprod'
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Verify target image exists
run: |
TAG="${{ github.event.inputs.version_tag }}"
docker buildx imagetools inspect "${{ env.REGISTRY }}/xpeditis-backend:${TAG}" || \
{ echo "ERROR: backend image not found: $TAG"; exit 1; }
docker buildx imagetools inspect "${{ env.REGISTRY }}/xpeditis-frontend:${TAG}" || \
{ echo "ERROR: frontend image not found: $TAG"; exit 1; }
- name: Re-tag as preprod
run: |
TAG="${{ github.event.inputs.version_tag }}"
docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-backend:preprod \
${{ env.REGISTRY }}/xpeditis-backend:${TAG}
docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:preprod \
${{ env.REGISTRY }}/xpeditis-frontend:${TAG}
- name: Deploy backend (Portainer)
run: curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}"
- run: sleep 20
- name: Deploy frontend (Portainer)
run: curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}"
# ── Smoke Tests ───────────────────────────────────────────────────────
smoke-tests:
name: Smoke Tests Post-Rollback
runs-on: ubuntu-latest
needs: [rollback-production, rollback-preprod]
if: always() && (needs.rollback-production.result == 'success' || needs.rollback-preprod.result == 'success')
steps:
- name: Set URLs
id: urls
run: |
if [ "${{ github.event.inputs.environment }}" = "production" ]; then
echo "backend=${{ secrets.PROD_BACKEND_URL }}/api/v1/health" >> $GITHUB_OUTPUT
echo "frontend=${{ secrets.PROD_FRONTEND_URL }}" >> $GITHUB_OUTPUT
echo "wait=30" >> $GITHUB_OUTPUT
else
echo "backend=${{ secrets.PREPROD_BACKEND_URL }}/api/v1/health" >> $GITHUB_OUTPUT
echo "frontend=${{ secrets.PREPROD_FRONTEND_URL }}" >> $GITHUB_OUTPUT
echo "wait=60" >> $GITHUB_OUTPUT
fi
- run: sleep ${{ steps.urls.outputs.wait }}
- name: Health — Backend
run: |
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"${{ steps.urls.outputs.backend }}" 2>/dev/null || echo "000")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi
sleep 15
done
echo "Backend unhealthy after rollback."
exit 1
- name: Health — Frontend
run: |
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"${{ steps.urls.outputs.frontend }}" 2>/dev/null || echo "000")
echo " Attempt $i: HTTP $STATUS"
if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi
sleep 15
done
echo "Frontend unhealthy after rollback."
exit 1
# ── Notifications ─────────────────────────────────────────────────────
notify:
name: Notify
runs-on: ubuntu-latest
needs: [rollback-production, rollback-preprod, smoke-tests]
if: always()
steps:
- name: Success
if: needs.smoke-tests.result == 'success'
run: |
curl -s -H "Content-Type: application/json" -d '{
"embeds": [{
"title": "↩️ Rollback Successful",
"color": 16776960,
"fields": [
{"name": "Environment", "value": "`${{ github.event.inputs.environment }}`", "inline": true},
{"name": "Strategy", "value": "`${{ github.event.inputs.strategy }}`", "inline": true},
{"name": "Version", "value": "`${{ github.event.inputs.version_tag || 'previous' }}`", "inline": true},
{"name": "By", "value": "${{ github.actor }}", "inline": true},
{"name": "Reason", "value": "${{ github.event.inputs.reason }}", "inline": false}
],
"footer": {"text": "Xpeditis CI/CD • Rollback"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}
- name: Failure
if: needs.smoke-tests.result != 'success'
run: |
curl -s -H "Content-Type: application/json" -d '{
"content": "@here ROLLBACK FAILED — MANUAL INTERVENTION REQUIRED",
"embeds": [{
"title": "🔴 Rollback Failed",
"color": 15158332,
"fields": [
{"name": "Environment", "value": "`${{ github.event.inputs.environment }}`", "inline": true},
{"name": "Attempted", "value": "`${{ github.event.inputs.version_tag || 'previous' }}`", "inline": true},
{"name": "By", "value": "${{ github.actor }}", "inline": true},
{"name": "Reason", "value": "${{ github.event.inputs.reason }}", "inline": false}
],
"footer": {"text": "Xpeditis CI/CD • Rollback"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}

2
.gitignore vendored
View File

@ -44,8 +44,6 @@ lerna-debug.log*
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
stack-portainer.yaml
tmp.stack-portainer.yaml
# Uploads # Uploads
uploads/ uploads/

3761
1536w default.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 MiB

1127
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
### 4. Nettoyage des fichiers CSV ### 4. Nettoyage des fichiers CSV
- ✅ Suppression de la colonne `companyEmail` des fichiers CSV (elle n'est plus nécessaire) - ✅ Suppression de la colonne `companyEmail` des fichiers CSV (elle n'est plus nécessaire)
- ✅ Script Python créé pour automatiser l'ajout/suppression: `scripts/add-email-to-csv.py` - ✅ Script Python créé pour automatiser l'ajout/suppression: `add-email-to-csv.py`
## ✅ Ce qui a été complété (SUITE) ## ✅ Ce qui a été complété (SUITE)

View File

@ -130,16 +130,16 @@ docker push rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:preprod
```bash ```bash
# Rendre le script exécutable # Rendre le script exécutable
chmod +x docker/deploy-to-portainer.sh chmod +x deploy-to-portainer.sh
# Option 1 : Build et push tout # Option 1 : Build et push tout
./docker/deploy-to-portainer.sh all ./deploy-to-portainer.sh all
# Option 2 : Backend seulement # Option 2 : Backend seulement
./docker/deploy-to-portainer.sh backend ./deploy-to-portainer.sh backend
# Option 3 : Frontend seulement # Option 3 : Frontend seulement
./docker/deploy-to-portainer.sh frontend ./deploy-to-portainer.sh frontend
``` ```
Le script fait automatiquement : Le script fait automatiquement :
@ -271,8 +271,8 @@ docker images | grep rg.fr-par.scw.cloud
```bash ```bash
# Plus simple et recommandé # Plus simple et recommandé
chmod +x docker/deploy-to-portainer.sh chmod +x deploy-to-portainer.sh
./docker/deploy-to-portainer.sh all ./deploy-to-portainer.sh all
``` ```
--- ---

View File

@ -2,6 +2,7 @@
node_modules node_modules
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
package-lock.json
yarn.lock yarn.lock
pnpm-lock.yaml pnpm-lock.yaml

View File

@ -37,14 +37,12 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
APP_URL=http://localhost:3000 APP_URL=http://localhost:3000
# Email (SMTP) # Email (SMTP)
SMTP_HOST=smtp-relay.brevo.com SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER=ton-email@brevo.com SMTP_SECURE=false
SMTP_PASS=ta-cle-smtp-brevo SMTP_USER=apikey
SMTP_SECURE=false SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=noreply@xpeditis.com
# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant)
SMTP_FROM=noreply@xpeditis.com
# AWS S3 / Storage (or MinIO for development) # AWS S3 / Storage (or MinIO for development)
AWS_ACCESS_KEY_ID=your-aws-access-key AWS_ACCESS_KEY_ID=your-aws-access-key
@ -76,11 +74,6 @@ ONE_API_URL=https://api.one-line.com/v1
ONE_USERNAME=your-one-username ONE_USERNAME=your-one-username
ONE_PASSWORD=your-one-password ONE_PASSWORD=your-one-password
# Swagger Documentation Access (HTTP Basic Auth)
# Leave empty to disable Swagger in production, or set both to protect with a password
SWAGGER_USERNAME=admin
SWAGGER_PASSWORD=change-this-strong-password
# Security # Security
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
SESSION_TIMEOUT_MS=7200000 SESSION_TIMEOUT_MS=7200000
@ -91,18 +84,3 @@ RATE_LIMIT_MAX=100
# Monitoring # Monitoring
SENTRY_DSN=your-sentry-dsn SENTRY_DSN=your-sentry-dsn
# Frontend URL (for redirects)
FRONTEND_URL=http://localhost:3000
# Stripe (Subscriptions & Payments)
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Stripe Price IDs (create these in Stripe Dashboard)
STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly
STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly
STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly
STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly
STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly

View File

@ -5,22 +5,20 @@ module.exports = {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['@typescript-eslint/eslint-plugin', 'unused-imports'], plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
root: true, root: true,
env: { env: {
node: true, node: true,
jest: true, jest: true,
}, },
ignorePatterns: ['.eslintrc.js', 'dist/**', 'node_modules/**', 'apps/**'], ignorePatterns: ['.eslintrc.js', 'dist/**', 'node_modules/**'],
rules: { rules: {
'@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off', // Désactivé pour projet existant en production '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'off', // Désactivé car remplacé par unused-imports '@typescript-eslint/no-unused-vars': [
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn', 'warn',
{ {
argsIgnorePattern: '^_', argsIgnorePattern: '^_',

View File

@ -14,7 +14,7 @@ COPY package*.json ./
COPY tsconfig*.json ./ COPY tsconfig*.json ./
# Install all dependencies (including dev for build) # Install all dependencies (including dev for build)
RUN npm ci --legacy-peer-deps RUN npm install --legacy-peer-deps
# =============================================== # ===============================================
# Stage 2: Build Application # Stage 2: Build Application

View File

View File

@ -59,9 +59,7 @@
"reflect-metadata": "^0.1.14", "reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^14.14.0", "typeorm": "^0.3.17"
"typeorm": "^0.3.17",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
@ -83,7 +81,6 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^4.3.0",
"ioredis-mock": "^8.13.0", "ioredis-mock": "^8.13.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.1.1", "prettier": "^3.1.1",
@ -8214,22 +8211,6 @@
} }
} }
}, },
"node_modules/eslint-plugin-unused-imports": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
"eslint": "^9.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
"optional": true
}
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "7.2.2", "version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -14572,19 +14553,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stripe": {
"version": "14.25.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",

View File

@ -75,9 +75,7 @@
"reflect-metadata": "^0.1.14", "reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^14.14.0", "typeorm": "^0.3.17"
"typeorm": "^0.3.17",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
@ -99,7 +97,6 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^4.3.0",
"ioredis-mock": "^8.13.0", "ioredis-mock": "^8.13.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.1.1", "prettier": "^3.1.1",

View File

@ -1,55 +0,0 @@
/**
* Script to list all Stripe prices
* Run with: node scripts/list-stripe-prices.js
*/
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
async function listPrices() {
console.log('Fetching Stripe prices...\n');
try {
const prices = await stripe.prices.list({ limit: 50, expand: ['data.product'] });
if (prices.data.length === 0) {
console.log('No prices found. You need to create prices in Stripe Dashboard.');
console.log('\nSteps:');
console.log('1. Go to https://dashboard.stripe.com/products');
console.log('2. Click on each product (Starter, Pro, Enterprise)');
console.log('3. Add a recurring price (monthly and yearly)');
console.log('4. Copy the Price IDs (format: price_xxxxx)');
return;
}
console.log('Available Prices:\n');
console.log('='.repeat(100));
for (const price of prices.data) {
const product = typeof price.product === 'object' ? price.product : { name: price.product };
const interval = price.recurring ? `${price.recurring.interval}ly` : 'one-time';
const amount = (price.unit_amount / 100).toFixed(2);
console.log(`Price ID: ${price.id}`);
console.log(`Product: ${product.name || product.id}`);
console.log(`Amount: ${amount} ${price.currency.toUpperCase()}`);
console.log(`Interval: ${interval}`);
console.log(`Active: ${price.active}`);
console.log('-'.repeat(100));
}
console.log('\n\nCopy the relevant Price IDs to your .env file:');
console.log('STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
} catch (error) {
console.error('Error fetching prices:', error.message);
}
}
listPrices();

View File

@ -19,16 +19,13 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module'; import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module'; import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module'; import { AdminModule } from './application/admin/admin.module';
import { LogsModule } from './application/logs/logs.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module';
import { CacheModule } from './infrastructure/cache/cache.module'; import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module'; import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards // Import global guards
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard'; import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard'; import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({ @Module({
@ -59,30 +56,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
SMTP_PASS: Joi.string().required(), SMTP_PASS: Joi.string().required(),
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'), SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
SMTP_SECURE: Joi.boolean().default(false), SMTP_SECURE: Joi.boolean().default(false),
// Stripe Configuration (optional for development)
STRIPE_SECRET_KEY: Joi.string().optional(),
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'),
}), }),
}), }),
// Logging // Logging
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => { useFactory: (configService: ConfigService) => ({
const isDev = configService.get('NODE_ENV') === 'development'; pinoHttp: {
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail) transport:
const forceJson = configService.get('LOG_FORMAT') === 'json'; configService.get('NODE_ENV') === 'development'
const usePretty = isDev && !forceJson;
return {
pinoHttp: {
transport: usePretty
? { ? {
target: 'pino-pretty', target: 'pino-pretty',
options: { options: {
@ -92,21 +74,9 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
}, },
} }
: undefined, : undefined,
level: isDev ? 'debug' : 'info', level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
// Redact sensitive fields from logs },
redact: { }),
paths: [
'req.headers.authorization',
'req.headers["x-api-key"]',
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
],
censor: '[REDACTED]',
},
},
};
},
inject: [ConfigService], inject: [ConfigService],
}), }),
@ -147,17 +117,14 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
WebhooksModule, WebhooksModule,
GDPRModule, GDPRModule,
AdminModule, AdminModule,
SubscriptionsModule,
ApiKeysModule,
LogsModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium) // Global JWT authentication guard
// All routes are protected by default, use @Public() to bypass // All routes are protected by default, use @Public() to bypass
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: ApiKeyOrJwtGuard, useClass: JwtAuthGuard,
}, },
// Global rate limiting guard // Global rate limiting guard
{ {

View File

@ -1,62 +1,48 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// Controller
// Controller import { AdminController } from '../controllers/admin.controller';
import { AdminController } from '../controllers/admin.controller';
// ORM Entities
// ORM Entities import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity'; import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
// Repositories
// Repositories import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
// Repository tokens
// Repository tokens import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
/**
// SIRET verification * Admin Module
import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port'; *
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter'; * Provides admin-only endpoints for managing all data in the system.
* All endpoints require ADMIN role.
// CSV Booking Service */
import { CsvBookingsModule } from '../csv-bookings.module'; @Module({
imports: [
// Email TypeOrmModule.forFeature([
import { EmailModule } from '@infrastructure/email/email.module'; UserOrmEntity,
OrganizationOrmEntity,
/** CsvBookingOrmEntity,
* Admin Module ]),
* ],
* Provides admin-only endpoints for managing all data in the system. controllers: [AdminController],
* All endpoints require ADMIN role. providers: [
*/ {
@Module({ provide: USER_REPOSITORY,
imports: [ useClass: TypeOrmUserRepository,
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), },
ConfigModule, {
CsvBookingsModule, provide: ORGANIZATION_REPOSITORY,
EmailModule, useClass: TypeOrmOrganizationRepository,
], },
controllers: [AdminController], TypeOrmCsvBookingRepository,
providers: [ ],
{ })
provide: USER_REPOSITORY, export class AdminModule {}
useClass: TypeOrmUserRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
TypeOrmCsvBookingRepository,
{
provide: SIRET_VERIFICATION_PORT,
useClass: PappersSiretAdapter,
},
],
})
export class AdminModule {}

View File

@ -1,81 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { CurrentUser } from '../decorators/current-user.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto';
@ApiTags('API Keys')
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(FeatureFlagGuard)
@RequiresFeature('api_access')
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@Post()
@ApiOperation({
summary: 'Générer une nouvelle clé API',
description:
"Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.",
})
@ApiResponse({
status: 201,
description: 'Clé créée avec succès. La clé complète est dans le champ `fullKey`.',
type: CreateApiKeyResultDto,
})
@ApiResponse({ status: 403, description: 'Abonnement Gold ou Platinium requis' })
async create(
@CurrentUser() user: { id: string; organizationId: string },
@Body() dto: CreateApiKeyDto
): Promise<CreateApiKeyResultDto> {
return this.apiKeysService.generateApiKey(user.id, user.organizationId, dto);
}
@Get()
@ApiOperation({
summary: 'Lister les clés API',
description:
"Retourne toutes les clés API de l'organisation. Les clés complètes ne sont jamais exposées — uniquement le préfixe.",
})
@ApiResponse({ status: 200, type: [ApiKeyDto] })
async list(@CurrentUser() user: { organizationId: string }): Promise<ApiKeyDto[]> {
return this.apiKeysService.listApiKeys(user.organizationId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Révoquer une clé API',
description: 'Désactive immédiatement la clé API. Cette action est irréversible.',
})
@ApiResponse({ status: 204, description: 'Clé révoquée' })
@ApiResponse({ status: 404, description: 'Clé introuvable' })
async revoke(
@CurrentUser() user: { organizationId: string },
@Param('id', ParseUUIDPipe) keyId: string
): Promise<void> {
return this.apiKeysService.revokeApiKey(keyId, user.organizationId);
}
}

View File

@ -1,45 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
// ORM Entities
import { ApiKeyOrmEntity } from '@infrastructure/persistence/typeorm/entities/api-key.orm-entity';
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
// Repositories
import { TypeOrmApiKeyRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository';
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
// Repository tokens
import { API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
// Subscriptions (provides SUBSCRIPTION_REPOSITORY)
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
// Feature flag guard needs SubscriptionRepository (injected via SubscriptionsModule)
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({
imports: [
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
SubscriptionsModule,
],
controllers: [ApiKeysController],
providers: [
ApiKeysService,
FeatureFlagGuard,
{
provide: API_KEY_REPOSITORY,
useClass: TypeOrmApiKeyRepository,
},
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
exports: [ApiKeysService],
})
export class ApiKeysModule {}

View File

@ -1,200 +0,0 @@
/**
* ApiKeys Service
*
* Manages API key lifecycle:
* - Generation (GOLD/PLATINIUM subscribers only)
* - Listing (masked prefix only)
* - Revocation
* - Validation for inbound API key authentication
*/
import {
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { ApiKey } from '@domain/entities/api-key.entity';
import { ApiKeyRepository, API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto';
/** Shape of request.user populated when an API key is used. */
export interface ApiKeyUserContext {
id: string;
email: string;
role: string;
organizationId: string;
firstName: string;
lastName: string;
plan: string;
planFeatures: string[];
}
const KEY_PREFIX_DISPLAY_LENGTH = 18; // "xped_live_" (10) + 8 hex chars
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor(
@Inject(API_KEY_REPOSITORY)
private readonly apiKeyRepository: ApiKeyRepository,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository
) {}
/**
* Generate a new API key for the given user / organisation.
* The full raw key is returned exactly once it is never persisted.
*/
async generateApiKey(
userId: string,
organizationId: string,
dto: CreateApiKeyDto
): Promise<CreateApiKeyResultDto> {
await this.assertApiAccessPlan(organizationId);
const rawKey = this.buildRawKey();
const keyHash = this.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, KEY_PREFIX_DISPLAY_LENGTH);
const apiKey = ApiKey.create({
id: uuidv4(),
organizationId,
userId,
name: dto.name,
keyHash,
keyPrefix,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
});
const saved = await this.apiKeyRepository.save(apiKey);
this.logger.log(`API key created: ${saved.id} for org ${organizationId}`);
return {
id: saved.id,
name: saved.name,
keyPrefix: saved.keyPrefix,
isActive: saved.isActive,
lastUsedAt: saved.lastUsedAt,
expiresAt: saved.expiresAt,
createdAt: saved.createdAt,
fullKey: rawKey,
};
}
/**
* List all API keys for an organisation. Never exposes key hashes.
*/
async listApiKeys(organizationId: string): Promise<ApiKeyDto[]> {
const keys = await this.apiKeyRepository.findByOrganizationId(organizationId);
return keys.map(k => this.toDto(k));
}
/**
* Revoke (deactivate) an API key.
*/
async revokeApiKey(keyId: string, organizationId: string): Promise<void> {
const key = await this.apiKeyRepository.findById(keyId);
if (!key || key.organizationId !== organizationId) {
throw new NotFoundException('Clé API introuvable');
}
const revoked = key.revoke();
await this.apiKeyRepository.save(revoked);
this.logger.log(`API key revoked: ${keyId} for org ${organizationId}`);
}
/**
* Validate an inbound raw API key and return the user context.
* Returns null if the key is invalid, expired, or the plan is insufficient.
* Also asynchronously updates lastUsedAt.
*/
async validateAndGetUser(rawKey: string): Promise<ApiKeyUserContext | null> {
if (!rawKey?.startsWith('xped_live_')) return null;
const keyHash = this.hashKey(rawKey);
const apiKey = await this.apiKeyRepository.findByKeyHash(keyHash);
if (!apiKey || !apiKey.isValid()) return null;
// Real-time plan check — in case the org downgraded after key creation
const subscription = await this.subscriptionRepository.findByOrganizationId(
apiKey.organizationId
);
if (!subscription || !subscription.hasFeature('api_access')) {
this.logger.warn(
`API key used but org ${apiKey.organizationId} no longer has api_access feature`
);
return null;
}
// Update lastUsedAt asynchronously — don't block the request
this.apiKeyRepository
.save(apiKey.recordUsage())
.catch(err => this.logger.warn(`Failed to update lastUsedAt for key ${apiKey.id}: ${err}`));
const user = await this.userRepository.findById(apiKey.userId);
if (!user || !user.isActive) return null;
return {
id: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
firstName: user.firstName,
lastName: user.lastName,
plan: subscription.plan.value,
planFeatures: [...subscription.plan.planFeatures],
};
}
// ── Helpers ─────────────────────────────────────────────────────────────
private async assertApiAccessPlan(organizationId: string): Promise<void> {
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
if (!subscription || !subscription.hasFeature('api_access')) {
throw new ForbiddenException(
"L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API."
);
}
}
/** Format: xped_live_<64 random hex chars> */
private buildRawKey(): string {
return `xped_live_${crypto.randomBytes(32).toString('hex')}`;
}
private hashKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
private toDto(apiKey: ApiKey): ApiKeyDto {
return {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
isActive: apiKey.isActive,
lastUsedAt: apiKey.lastUsedAt,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
};
}
}

View File

@ -17,11 +17,9 @@ import { TypeOrmInvitationTokenRepository } from '../../infrastructure/persisten
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity'; import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity';
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
import { InvitationService } from '../services/invitation.service'; import { InvitationService } from '../services/invitation.service';
import { InvitationsController } from '../controllers/invitations.controller'; import { InvitationsController } from '../controllers/invitations.controller';
import { EmailModule } from '../../infrastructure/email/email.module'; import { EmailModule } from '../../infrastructure/email/email.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
@Module({ @Module({
imports: [ imports: [
@ -41,13 +39,10 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
}), }),
// 👇 Add this to register TypeORM repositories // 👇 Add this to register TypeORM repositories
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]), TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity]),
// Email module for sending invitations // Email module for sending invitations
EmailModule, EmailModule,
// Subscriptions module for license checks
SubscriptionsModule,
], ],
controllers: [AuthController, InvitationsController], controllers: [AuthController, InvitationsController],
providers: [ providers: [

View File

@ -5,34 +5,26 @@ import {
Logger, Logger,
Inject, Inject,
BadRequestException, BadRequestException,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity'; import { User, UserRole } from '@domain/entities/user.entity';
import { import {
OrganizationRepository, OrganizationRepository,
ORGANIZATION_REPOSITORY, ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository'; } from '@domain/ports/out/organization.repository';
import { Organization } from '@domain/entities/organization.entity'; import { Organization, OrganizationType } from '@domain/entities/organization.entity';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed';
import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { RegisterOrganizationDto } from '../dto/auth-login.dto';
import { SubscriptionService } from '../services/subscription.service';
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
export interface JwtPayload { export interface JwtPayload {
sub: string; // user ID sub: string; // user ID
email: string; email: string;
role: string; role: string;
organizationId: string; organizationId: string;
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
planFeatures?: string[]; // plan feature flags
type: 'access' | 'refresh'; type: 'access' | 'refresh';
} }
@ -45,13 +37,8 @@ export class AuthService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) @Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository, private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
@InjectRepository(PasswordResetTokenOrmEntity)
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService
private readonly subscriptionService: SubscriptionService
) {} ) {}
/** /**
@ -114,16 +101,6 @@ export class AuthService {
const savedUser = await this.userRepository.save(user); const savedUser = await this.userRepository.save(user);
// Allocate a license for the new user
try {
await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId);
this.logger.log(`License allocated for user: ${email}`);
} catch (error) {
this.logger.error(`Failed to allocate license for user ${email}:`, error);
// Note: We don't throw here because the user is already created.
// The license check should happen before invitation.
}
const tokens = await this.generateTokens(savedUser); const tokens = await this.generateTokens(savedUser);
this.logger.log(`User registered successfully: ${email}`); this.logger.log(`User registered successfully: ${email}`);
@ -215,85 +192,6 @@ export class AuthService {
} }
} }
/**
* Initiate password reset generates token and sends email
*/
async forgotPassword(email: string): Promise<void> {
this.logger.log(`Password reset requested for: ${email}`);
const user = await this.userRepository.findByEmail(email);
// Silently succeed if user not found (security: don't reveal user existence)
if (!user || !user.isActive) {
return;
}
// Invalidate any existing unused tokens for this user
await this.passwordResetTokenRepository.update(
{ userId: user.id, usedAt: IsNull() },
{ usedAt: new Date() }
);
// Generate a secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await this.passwordResetTokenRepository.save({
userId: user.id,
token,
expiresAt,
usedAt: null,
});
await this.emailService.sendPasswordResetEmail(email, token);
this.logger.log(`Password reset email sent to: ${email}`);
}
/**
* Reset password using token from email
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } });
if (!resetToken) {
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
}
if (resetToken.usedAt) {
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
}
if (resetToken.expiresAt < new Date()) {
throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
}
const user = await this.userRepository.findById(resetToken.userId);
if (!user || !user.isActive) {
throw new NotFoundException('Utilisateur introuvable');
}
const passwordHash = await argon2.hash(newPassword, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
// Update password (mutates in place)
user.updatePassword(passwordHash);
await this.userRepository.save(user);
// Mark token as used
await this.passwordResetTokenRepository.update(
{ id: resetToken.id },
{ usedAt: new Date() }
);
this.logger.log(`Password reset successfully for user: ${user.email}`);
}
/** /**
* Validate user from JWT payload * Validate user from JWT payload
*/ */
@ -311,40 +209,11 @@ export class AuthService {
* Generate access and refresh tokens * Generate access and refresh tokens
*/ */
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get PLATINIUM plan with no expiration
let plan = 'BRONZE';
let planFeatures: string[] = [];
if (user.role === UserRole.ADMIN) {
plan = 'PLATINIUM';
planFeatures = [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
];
} else {
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
plan = subscription.plan.value;
planFeatures = [...subscription.plan.planFeatures];
} catch (error) {
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
}
}
const accessPayload: JwtPayload = { const accessPayload: JwtPayload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
plan,
planFeatures,
type: 'access', type: 'access',
}; };
@ -353,8 +222,6 @@ export class AuthService {
email: user.email, email: user.email,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
plan,
planFeatures,
type: 'refresh', type: 'refresh',
}; };
@ -424,8 +291,6 @@ export class AuthService {
name: organizationData.name, name: organizationData.name,
type: organizationData.type, type: organizationData.type,
scac: organizationData.scac, scac: organizationData.scac,
siren: organizationData.siren,
siret: organizationData.siret,
address: { address: {
street: organizationData.street, street: organizationData.street,
city: organizationData.city, city: organizationData.city,

View File

@ -6,18 +6,15 @@ import { BookingsController } from '../controllers/bookings.controller';
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository'; import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
// Import ORM entities // Import ORM entities
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity'; import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
// Import services and domain // Import services and domain
import { BookingService } from '@domain/services/booking.service'; import { BookingService } from '@domain/services/booking.service';
@ -32,7 +29,6 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { WebhooksModule } from '../webhooks/webhooks.module'; import { WebhooksModule } from '../webhooks/webhooks.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
/** /**
* Bookings Module * Bookings Module
@ -51,7 +47,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
ContainerOrmEntity, ContainerOrmEntity,
RateQuoteOrmEntity, RateQuoteOrmEntity,
UserOrmEntity, UserOrmEntity,
CsvBookingOrmEntity,
]), ]),
EmailModule, EmailModule,
PdfModule, PdfModule,
@ -59,7 +54,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
AuditModule, AuditModule,
NotificationsModule, NotificationsModule,
WebhooksModule, WebhooksModule,
SubscriptionsModule,
], ],
controllers: [BookingsController], controllers: [BookingsController],
providers: [ providers: [
@ -79,10 +73,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,
}, },
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
], ],
exports: [BOOKING_REPOSITORY], exports: [BOOKING_REPOSITORY],
}) })

File diff suppressed because it is too large Load Diff

View File

@ -489,7 +489,6 @@ export class CsvRatesAdminController {
size: fileSize, size: fileSize,
uploadedAt: config.uploadedAt.toISOString(), uploadedAt: config.uploadedAt.toISOString(),
rowCount: config.rowCount, rowCount: config.rowCount,
companyEmail: config.metadata?.companyEmail ?? null,
}; };
}); });

View File

@ -38,7 +38,7 @@ class AuditLogResponseDto {
timestamp: string; timestamp: string;
} }
class _AuditLogQueryDto { class AuditLogQueryDto {
userId?: string; userId?: string;
action?: AuditAction[]; action?: AuditAction[];
status?: AuditStatus[]; status?: AuditStatus[];

View File

@ -8,21 +8,10 @@ import {
Get, Get,
Inject, Inject,
NotFoundException, NotFoundException,
InternalServerErrorException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
LoginDto,
RegisterDto,
AuthResponseDto,
RefreshTokenDto,
ForgotPasswordDto,
ResetPasswordDto,
ContactFormDto,
} from '../dto/auth-login.dto';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -43,13 +32,10 @@ import { InvitationService } from '../services/invitation.service';
@ApiTags('Authentication') @ApiTags('Authentication')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly invitationService: InvitationService, private readonly invitationService: InvitationService
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
) {} ) {}
/** /**
@ -223,113 +209,6 @@ export class AuthController {
return { message: 'Logout successful' }; return { message: 'Logout successful' };
} }
/**
* Contact form forwards message to contact@xpeditis.com
*/
@Public()
@Post('contact')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Contact form',
description: 'Send a contact message to the Xpeditis team.',
})
@ApiResponse({ status: 200, description: 'Message sent successfully' })
async contact(@Body() dto: ContactFormDto): Promise<{ message: string }> {
const subjectLabels: Record<string, string> = {
demo: 'Demande de démonstration',
pricing: 'Questions sur les tarifs',
partnership: 'Partenariat',
support: 'Support technique',
press: 'Relations presse',
careers: 'Recrutement',
other: 'Autre',
};
const subjectLabel = subjectLabels[dto.subject] || dto.subject;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #10183A; padding: 24px; border-radius: 8px 8px 0 0;">
<h1 style="color: #34CCCD; margin: 0; font-size: 20px;">Nouveau message de contact</h1>
</div>
<div style="background: #f9f9f9; padding: 24px; border: 1px solid #e0e0e0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${dto.firstName} ${dto.lastName}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${dto.email}" style="color: #34CCCD;">${dto.email}</a></td>
</tr>
${dto.company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.company}</td></tr>` : ''}
${dto.phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.phone}</td></tr>` : ''}
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
</tr>
</table>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${dto.message}</p>
</div>
</div>
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
<p style="color: #999; font-size: 12px; margin: 0;">Xpeditis Formulaire de contact</p>
</div>
</div>
`;
try {
await this.emailService.send({
to: 'contact@xpeditis.com',
replyTo: dto.email,
subject: `[Contact] ${subjectLabel}${dto.firstName} ${dto.lastName}`,
html,
});
} catch (error) {
this.logger.error(`Failed to send contact email: ${error}`);
throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer.");
}
return { message: 'Message envoyé avec succès.' };
}
/**
* Forgot password sends reset email
*/
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Forgot password',
description: 'Send a password reset email. Always returns 200 to avoid user enumeration.',
})
@ApiResponse({ status: 200, description: 'Reset email sent (if account exists)' })
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> {
await this.authService.forgotPassword(dto.email);
return {
message: 'Si un compte existe avec cet email, vous recevrez un lien de réinitialisation.',
};
}
/**
* Reset password using token from email
*/
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reset password',
description: 'Reset user password using the token received by email.',
})
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> {
await this.authService.resetPassword(dto.token, dto.newPassword);
return { message: 'Mot de passe réinitialisé avec succès.' };
}
/** /**
* Get current user profile * Get current user profile
* *

View File

@ -34,7 +34,7 @@ import {
import { Response } from 'express'; import { Response } from 'express';
import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto'; import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto';
import { BookingFilterDto } from '../dto/booking-filter.dto'; import { BookingFilterDto } from '../dto/booking-filter.dto';
import { BookingExportDto } from '../dto/booking-export.dto'; import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto';
import { BookingMapper } from '../mappers'; import { BookingMapper } from '../mappers';
import { BookingService } from '@domain/services/booking.service'; import { BookingService } from '@domain/services/booking.service';
import { BookingRepository, BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { BookingRepository, BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
@ -48,17 +48,11 @@ import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { ExportService } from '../services/export.service'; import { ExportService } from '../services/export.service';
import { FuzzySearchService } from '../services/fuzzy-search.service'; import { FuzzySearchService } from '../services/fuzzy-search.service';
import { AuditService } from '../services/audit.service'; import { AuditService } from '../services/audit.service';
import { AuditAction } from '@domain/entities/audit-log.entity'; import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { NotificationsGateway } from '../gateways/notifications.gateway'; import { NotificationsGateway } from '../gateways/notifications.gateway';
import { WebhookService } from '../services/webhook.service'; import { WebhookService } from '../services/webhook.service';
import { WebhookEvent } from '@domain/entities/webhook.entity'; import { WebhookEvent } from '@domain/entities/webhook.entity';
import {
ShipmentCounterPort,
SHIPMENT_COUNTER_PORT,
} from '@domain/ports/out/shipment-counter.port';
import { SubscriptionService } from '../services/subscription.service';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
@ApiTags('Bookings') @ApiTags('Bookings')
@Controller('bookings') @Controller('bookings')
@ -76,9 +70,7 @@ export class BookingsController {
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly notificationsGateway: NotificationsGateway, private readonly notificationsGateway: NotificationsGateway,
private readonly webhookService: WebhookService, private readonly webhookService: WebhookService
@Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort,
private readonly subscriptionService: SubscriptionService
) {} ) {}
@Post() @Post()
@ -113,22 +105,6 @@ export class BookingsController {
): Promise<BookingResponseDto> { ): Promise<BookingResponseDto> {
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
// Check shipment limit for Bronze plan
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
user.organizationId,
currentYear
);
if (count >= maxShipments) {
throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments);
}
}
try { try {
// Convert DTO to domain input, using authenticated user's data // Convert DTO to domain input, using authenticated user's data
const input = { const input = {
@ -378,7 +354,7 @@ export class BookingsController {
// ADMIN: Fetch ALL bookings from database // ADMIN: Fetch ALL bookings from database
// Others: Fetch only bookings from their organization // Others: Fetch only bookings from their organization
let bookings: any[]; let bookings: any[];
if (user.role === 'ADMIN') { if (user.role === 'admin') {
this.logger.log(`[ADMIN] Fetching ALL bookings from database`); this.logger.log(`[ADMIN] Fetching ALL bookings from database`);
bookings = await this.bookingRepository.findAll(); bookings = await this.bookingRepository.findAll();
} else { } else {
@ -396,20 +372,14 @@ export class BookingsController {
const endIndex = startIndex + pageSize; const endIndex = startIndex + pageSize;
const paginatedBookings = filteredBookings.slice(startIndex, endIndex); const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
// Fetch rate quotes for all bookings (filter out those with missing rate quotes) // Fetch rate quotes for all bookings
const bookingsWithQuotesRaw = await Promise.all( const bookingsWithQuotes = await Promise.all(
paginatedBookings.map(async (booking: any) => { paginatedBookings.map(async (booking: any) => {
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
return { booking, rateQuote }; return { booking, rateQuote: rateQuote! };
}) })
); );
// Filter out bookings with missing rate quotes to avoid null pointer errors
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
item.rateQuote !== null && item.rateQuote !== undefined
);
// Convert to DTOs // Convert to DTOs
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
@ -470,28 +440,14 @@ export class BookingsController {
); );
// Map ORM entities to domain and fetch rate quotes // Map ORM entities to domain and fetch rate quotes
const bookingsWithQuotesRaw = await Promise.all( const bookingsWithQuotes = await Promise.all(
bookingOrms.map(async bookingOrm => { bookingOrms.map(async bookingOrm => {
const booking = await this.bookingRepository.findById(bookingOrm.id); const booking = await this.bookingRepository.findById(bookingOrm.id);
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId); const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
return { booking, rateQuote }; return { booking: booking!, rateQuote: rateQuote! };
}) })
); );
// Filter out bookings or rate quotes that are null
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
(
item
): item is {
booking: NonNullable<typeof item.booking>;
rateQuote: NonNullable<typeof item.rateQuote>;
} =>
item.booking !== null &&
item.booking !== undefined &&
item.rateQuote !== null &&
item.rateQuote !== undefined
);
// Convert to DTOs // Convert to DTOs
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) => const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
BookingMapper.toDto(booking, rateQuote) BookingMapper.toDto(booking, rateQuote)
@ -531,10 +487,8 @@ export class BookingsController {
// Apply filters // Apply filters
bookings = this.applyFilters(bookings, filter); bookings = this.applyFilters(bookings, filter);
// Sort bookings (use defaults if not provided) // Sort bookings
const sortBy = filter.sortBy || 'createdAt'; bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!);
const sortOrder = filter.sortOrder || 'desc';
bookings = this.sortBookings(bookings, sortBy, sortOrder);
// Total count before pagination // Total count before pagination
const total = bookings.length; const total = bookings.length;
@ -544,20 +498,14 @@ export class BookingsController {
const endIndex = startIndex + (filter.pageSize || 20); const endIndex = startIndex + (filter.pageSize || 20);
const paginatedBookings = bookings.slice(startIndex, endIndex); const paginatedBookings = bookings.slice(startIndex, endIndex);
// Fetch rate quotes (filter out those with missing rate quotes) // Fetch rate quotes
const bookingsWithQuotesRaw = await Promise.all( const bookingsWithQuotes = await Promise.all(
paginatedBookings.map(async booking => { paginatedBookings.map(async booking => {
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
return { booking, rateQuote }; return { booking, rateQuote: rateQuote! };
}) })
); );
// Filter out bookings with missing rate quotes to avoid null pointer errors
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
item.rateQuote !== null && item.rateQuote !== undefined
);
// Convert to DTOs // Convert to DTOs
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
@ -614,20 +562,14 @@ export class BookingsController {
bookings = this.applyFilters(bookings, filter); bookings = this.applyFilters(bookings, filter);
} }
// Fetch rate quotes (filter out those with missing rate quotes) // Fetch rate quotes
const bookingsWithQuotesRaw = await Promise.all( const bookingsWithQuotes = await Promise.all(
bookings.map(async booking => { bookings.map(async booking => {
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
return { booking, rateQuote }; return { booking, rateQuote: rateQuote! };
}) })
); );
// Filter out bookings with missing rate quotes to avoid null pointer errors
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
item.rateQuote !== null && item.rateQuote !== undefined
);
// Generate export file // Generate export file
const exportResult = await this.exportService.exportBookings( const exportResult = await this.exportService.exportBookings(
bookingsWithQuotes, bookingsWithQuotes,

View File

@ -1,12 +1,7 @@
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
import {
CarrierDocumentsResponseDto,
VerifyDocumentAccessDto,
DocumentAccessRequirementsDto,
} from '../dto/carrier-documents.dto';
/** /**
* CSV Booking Actions Controller (Public Routes) * CSV Booking Actions Controller (Public Routes)
@ -93,84 +88,4 @@ export class CsvBookingActionsController {
reason: reason || null, reason: reason || null,
}; };
} }
/**
* Check document access requirements (PUBLIC - token-based)
*
* GET /api/v1/csv-booking-actions/documents/:token/requirements
*/
@Public()
@Get('documents/:token/requirements')
@ApiOperation({
summary: 'Check document access requirements (public)',
description:
'Check if a password is required to access booking documents. Use this before showing the password form.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Access requirements retrieved successfully.',
type: DocumentAccessRequirementsDto,
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
async getDocumentAccessRequirements(
@Param('token') token: string
): Promise<DocumentAccessRequirementsDto> {
return this.csvBookingService.checkDocumentAccessRequirements(token);
}
/**
* Get booking documents for carrier with password verification (PUBLIC - token-based)
*
* POST /api/v1/csv-booking-actions/documents/:token
*/
@Public()
@Post('documents/:token')
@ApiOperation({
summary: 'Get booking documents with password (public)',
description:
'Public endpoint for carriers to access booking documents after acceptance. Requires password verification. Returns booking summary and documents with signed download URLs.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiBody({ type: VerifyDocumentAccessDto })
@ApiResponse({
status: 200,
description: 'Booking documents retrieved successfully.',
type: CarrierDocumentsResponseDto,
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
@ApiResponse({ status: 401, description: 'Invalid password' })
async getBookingDocumentsWithPassword(
@Param('token') token: string,
@Body() dto: VerifyDocumentAccessDto
): Promise<CarrierDocumentsResponseDto> {
return this.csvBookingService.getDocumentsForCarrier(token, dto.password);
}
/**
* Get booking documents for carrier (PUBLIC - token-based) - Legacy without password
* Kept for backward compatibility with bookings created before password protection
*
* GET /api/v1/csv-booking-actions/documents/:token
*/
@Public()
@Get('documents/:token')
@ApiOperation({
summary: 'Get booking documents (public) - Legacy',
description:
'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking documents retrieved successfully.',
type: CarrierDocumentsResponseDto,
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
@ApiResponse({ status: 401, description: 'Password required for this booking' })
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
return this.csvBookingService.getDocumentsForCarrier(token);
}
} }

View File

@ -3,7 +3,6 @@ import {
Post, Post,
Get, Get,
Patch, Patch,
Delete,
Body, Body,
Param, Param,
Query, Query,
@ -12,12 +11,11 @@ import {
UploadedFiles, UploadedFiles,
Request, Request,
BadRequestException, BadRequestException,
ForbiddenException,
ParseIntPipe, ParseIntPipe,
DefaultValuePipe, DefaultValuePipe,
Inject, Res,
HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { import {
ApiTags, ApiTags,
@ -29,22 +27,14 @@ import {
ApiQuery, ApiQuery,
ApiParam, ApiParam,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
import { SubscriptionService } from '../services/subscription.service';
import {
ShipmentCounterPort,
SHIPMENT_COUNTER_PORT,
} from '@domain/ports/out/shipment-counter.port';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
import { import {
CreateCsvBookingDto, CreateCsvBookingDto,
CsvBookingResponseDto, CsvBookingResponseDto,
UpdateCsvBookingStatusDto,
CsvBookingListResponseDto, CsvBookingListResponseDto,
CsvBookingStatsDto, CsvBookingStatsDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
@ -53,27 +43,11 @@ import {
* CSV Bookings Controller * CSV Bookings Controller
* *
* Handles HTTP requests for CSV-based booking requests * Handles HTTP requests for CSV-based booking requests
*
* IMPORTANT: Route order matters in NestJS!
* Static routes MUST come BEFORE parameterized routes.
* Otherwise, `:id` will capture "stats", "organization", etc.
*/ */
@ApiTags('CSV Bookings') @ApiTags('CSV Bookings')
@Controller('csv-bookings') @Controller('csv-bookings')
export class CsvBookingsController { export class CsvBookingsController {
constructor( constructor(private readonly csvBookingService: CsvBookingService) {}
private readonly csvBookingService: CsvBookingService,
private readonly subscriptionService: SubscriptionService,
private readonly configService: ConfigService,
@Inject(SHIPMENT_COUNTER_PORT)
private readonly shipmentCounter: ShipmentCounterPort,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
// ============================================================================
// STATIC ROUTES (must come FIRST)
// ============================================================================
/** /**
* Create a new CSV booking request * Create a new CSV booking request
@ -81,6 +55,7 @@ export class CsvBookingsController {
* POST /api/v1/csv-bookings * POST /api/v1/csv-bookings
*/ */
@Post() @Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10)) @UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ -164,23 +139,6 @@ export class CsvBookingsController {
const userId = req.user.id; const userId = req.user.id;
const organizationId = req.user.organizationId; const organizationId = req.user.organizationId;
// ADMIN users bypass shipment limits
if (req.user.role !== 'ADMIN') {
// Check shipment limit (Bronze plan = 12/year)
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
organizationId,
currentYear
);
if (count >= maxShipments) {
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
}
}
}
// Convert string values to numbers (multipart/form-data sends everything as strings) // Convert string values to numbers (multipart/form-data sends everything as strings)
const sanitizedDto: CreateCsvBookingDto = { const sanitizedDto: CreateCsvBookingDto = {
...dto, ...dto,
@ -197,112 +155,6 @@ export class CsvBookingsController {
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId); return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
} }
/**
* Get current user's bookings (paginated)
*
* GET /api/v1/csv-bookings
*/
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get user bookings',
description: 'Retrieve all bookings for the authenticated user with pagination.',
})
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
@ApiResponse({
status: 200,
description: 'Bookings retrieved successfully',
type: CsvBookingListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUserBookings(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
): Promise<CsvBookingListResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.getUserBookings(userId, page, limit);
}
/**
* Get booking statistics for user
*
* GET /api/v1/csv-bookings/stats/me
*/
@Get('stats/me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get user booking statistics',
description:
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
})
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: CsvBookingStatsDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
const userId = req.user.id;
return await this.csvBookingService.getUserStats(userId);
}
/**
* Get organization booking statistics
*
* GET /api/v1/csv-bookings/stats/organization
*/
@Get('stats/organization')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get organization booking statistics',
description: "Get aggregated statistics for the user's organization. For managers/admins.",
})
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: CsvBookingStatsDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
const organizationId = req.user.organizationId;
return await this.csvBookingService.getOrganizationStats(organizationId);
}
/**
* Get organization bookings (for managers/admins)
*
* GET /api/v1/csv-bookings/organization/all
*/
@Get('organization/all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get organization bookings',
description:
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
})
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
@ApiResponse({
status: 200,
description: 'Organization bookings retrieved successfully',
type: CsvBookingListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getOrganizationBookings(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
): Promise<CsvBookingListResponseDto> {
const organizationId = req.user.organizationId;
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
}
/** /**
* Accept a booking request (PUBLIC - token-based) * Accept a booking request (PUBLIC - token-based)
* *
@ -378,137 +230,10 @@ export class CsvBookingsController {
}; };
} }
/**
* Create Stripe Checkout session for commission payment
*
* POST /api/v1/csv-bookings/:id/pay
*/
@Post(':id/pay')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Pay commission for a booking',
description:
'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Stripe checkout session created',
schema: {
type: 'object',
properties: {
sessionUrl: { type: 'string' },
sessionId: { type: 'string' },
commissionAmountEur: { type: 'number' },
},
},
})
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async payCommission(@Param('id') id: string, @Request() req: any) {
const userId = req.user.id;
const userEmail = req.user.email;
const organizationId = req.user.organizationId;
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
// ADMIN users bypass SIRET verification
if (req.user.role !== 'ADMIN') {
// SIRET verification gate: organization must have a verified SIRET before paying
const organization = await this.organizationRepository.findById(organizationId);
if (!organization || !organization.siretVerified) {
throw new ForbiddenException(
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un paiement. Contactez votre administrateur.'
);
}
}
return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl);
}
/**
* Confirm commission payment after Stripe redirect
*
* POST /api/v1/csv-bookings/:id/confirm-payment
*/
@Post(':id/confirm-payment')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Confirm commission payment',
description:
'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiBody({
schema: {
type: 'object',
required: ['sessionId'],
properties: {
sessionId: { type: 'string', description: 'Stripe Checkout session ID' },
},
},
})
@ApiResponse({
status: 200,
description: 'Payment confirmed, booking activated',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async confirmPayment(
@Param('id') id: string,
@Body('sessionId') sessionId: string,
@Request() req: any
): Promise<CsvBookingResponseDto> {
if (!sessionId) {
throw new BadRequestException('sessionId is required');
}
const userId = req.user.id;
return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId);
}
/**
* Declare bank transfer user confirms they have sent the wire transfer
*
* POST /api/v1/csv-bookings/:id/declare-transfer
*/
@Post(':id/declare-transfer')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Declare bank transfer',
description:
'User confirms they have sent the bank wire transfer. Transitions booking to PENDING_BANK_TRANSFER awaiting admin validation.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Bank transfer declared, booking awaiting admin validation',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async declareTransfer(
@Param('id') id: string,
@Request() req: any
): Promise<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.declareBankTransfer(id, userId);
}
// ============================================================================
// PARAMETERIZED ROUTES (must come LAST)
// ============================================================================
/** /**
* Get a booking by ID * Get a booking by ID
* *
* GET /api/v1/csv-bookings/:id * GET /api/v1/csv-bookings/:id
*
* IMPORTANT: This route MUST be after all static GET routes
* Otherwise it will capture "stats", "organization", etc.
*/ */
@Get(':id') @Get(':id')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ -531,6 +256,59 @@ export class CsvBookingsController {
return await this.csvBookingService.getBookingById(id, userId, carrierId); return await this.csvBookingService.getBookingById(id, userId, carrierId);
} }
/**
* Get current user's bookings (paginated)
*
* GET /api/v1/csv-bookings
*/
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get user bookings',
description: 'Retrieve all bookings for the authenticated user with pagination.',
})
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
@ApiResponse({
status: 200,
description: 'Bookings retrieved successfully',
type: CsvBookingListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUserBookings(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
): Promise<CsvBookingListResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.getUserBookings(userId, page, limit);
}
/**
* Get booking statistics for user
*
* GET /api/v1/csv-bookings/stats/me
*/
@Get('stats/me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get user booking statistics',
description:
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
})
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: CsvBookingStatsDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
const userId = req.user.id;
return await this.csvBookingService.getUserStats(userId);
}
/** /**
* Cancel a booking (user action) * Cancel a booking (user action)
* *
@ -561,165 +339,55 @@ export class CsvBookingsController {
} }
/** /**
* Add documents to an existing booking * Get organization bookings (for managers/admins)
* *
* POST /api/v1/csv-bookings/:id/documents * GET /api/v1/csv-bookings/organization/all
*/ */
@Post(':id/documents') @Get('organization/all')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data')
@ApiOperation({ @ApiOperation({
summary: 'Add documents to an existing booking', summary: 'Get organization bookings',
description: description:
'Upload additional documents to a pending booking. Only the booking owner can add documents.', "Retrieve all bookings for the user's organization with pagination. For managers/admins.",
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiBody({
schema: {
type: 'object',
properties: {
documents: {
type: 'array',
items: { type: 'string', format: 'binary' },
description: 'Documents to add (max 10 files)',
},
},
},
}) })
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Documents added successfully', description: 'Organization bookings retrieved successfully',
schema: { type: CsvBookingListResponseDto,
type: 'object',
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: 'Documents added successfully' },
documentsAdded: { type: 'number', example: 2 },
},
},
}) })
@ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Booking not found' }) async getOrganizationBookings(
async addDocuments( @Request() req: any,
@Param('id') id: string, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@UploadedFiles() files: Express.Multer.File[], @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
@Request() req: any ): Promise<CsvBookingListResponseDto> {
) { const organizationId = req.user.organizationId;
if (!files || files.length === 0) { return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
throw new BadRequestException('At least one document is required');
}
const userId = req.user.id;
return await this.csvBookingService.addDocuments(id, files, userId);
} }
/** /**
* Replace a document in a booking * Get organization booking statistics
* *
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId * GET /api/v1/csv-bookings/stats/organization
*/ */
@Patch(':bookingId/documents/:documentId') @Get('stats/organization')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@UseInterceptors(FilesInterceptor('document', 1))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Replace a document in a booking',
description:
'Replace an existing document with a new one. Only the booking owner can replace documents.',
})
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
@ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' })
@ApiBody({
schema: {
type: 'object',
properties: {
document: {
type: 'string',
format: 'binary',
description: 'New document file to replace the existing one',
},
},
},
})
@ApiResponse({
status: 200,
description: 'Document replaced successfully',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: 'Document replaced successfully' },
newDocument: {
type: 'object',
properties: {
id: { type: 'string' },
type: { type: 'string' },
fileName: { type: 'string' },
filePath: { type: 'string' },
mimeType: { type: 'string' },
size: { type: 'number' },
uploadedAt: { type: 'string', format: 'date-time' },
},
},
},
},
})
@ApiResponse({ status: 400, description: 'Invalid request - missing file' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Booking or document not found' })
async replaceDocument(
@Param('bookingId') bookingId: string,
@Param('documentId') documentId: string,
@UploadedFiles() files: Express.Multer.File[],
@Request() req: any
) {
if (!files || files.length === 0) {
throw new BadRequestException('A document file is required');
}
const userId = req.user.id;
return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId);
}
/**
* Delete a document from a booking
*
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
*/
@Delete(':bookingId/documents/:documentId')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: 'Delete a document from a booking', summary: 'Get organization booking statistics',
description: description: "Get aggregated statistics for the user's organization. For managers/admins.",
'Remove a document from a pending booking. Only the booking owner can delete documents.',
}) })
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
@ApiParam({ name: 'documentId', description: 'Document ID (UUID)' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Document deleted successfully', description: 'Statistics retrieved successfully',
schema: { type: CsvBookingStatsDto,
type: 'object',
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: 'Document deleted successfully' },
},
},
}) })
@ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' })
@ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Booking or document not found' }) async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
async deleteDocument( const organizationId = req.user.organizationId;
@Param('bookingId') bookingId: string, return await this.csvBookingService.getOrganizationStats(organizationId);
@Param('documentId') documentId: string,
@Request() req: any
) {
const userId = req.user.id;
return await this.csvBookingService.deleteDocument(bookingId, documentId, userId);
} }
} }

View File

@ -14,15 +14,13 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Res, Res,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Response, Request } from 'express'; import { Response } from 'express';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator'; import { CurrentUser } from '../decorators/current-user.decorator';
import { UserPayload } from '../decorators/current-user.decorator'; import { UserPayload } from '../decorators/current-user.decorator';
import { GDPRService } from '../services/gdpr.service'; import { GDPRService, ConsentData } from '../services/gdpr.service';
import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
@ApiTags('GDPR') @ApiTags('GDPR')
@Controller('gdpr') @Controller('gdpr')
@ -79,13 +77,6 @@ export class GDPRController {
csv += `User Data,${key},"${value}"\n`; csv += `User Data,${key},"${value}"\n`;
}); });
// Cookie consent data
if (exportData.cookieConsent) {
Object.entries(exportData.cookieConsent).forEach(([key, value]) => {
csv += `Cookie Consent,${key},"${value}"\n`;
});
}
// Set headers // Set headers
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader( res.setHeader(
@ -128,26 +119,22 @@ export class GDPRController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Record user consent', summary: 'Record user consent',
description: 'Record consent for cookies (GDPR Article 7)', description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Consent recorded', description: 'Consent recorded',
type: ConsentResponseDto,
}) })
async recordConsent( async recordConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: UpdateConsentDto, @Body() body: Omit<ConsentData, 'userId'>
@Req() req: Request ): Promise<{ success: boolean }> {
): Promise<ConsentResponseDto> { await this.gdprService.recordConsent({
// Add IP and user agent from request if not provided
const consentData: UpdateConsentDto = {
...body, ...body,
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress, userId: user.id,
userAgent: body.userAgent || req.headers['user-agent'], });
};
return this.gdprService.recordConsent(user.id, consentData); return { success: true };
} }
/** /**
@ -157,18 +144,19 @@ export class GDPRController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Withdraw consent', summary: 'Withdraw consent',
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)', description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Consent withdrawn', description: 'Consent withdrawn',
type: ConsentResponseDto,
}) })
async withdrawConsent( async withdrawConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: WithdrawConsentDto @Body() body: { consentType: 'marketing' | 'analytics' }
): Promise<ConsentResponseDto> { ): Promise<{ success: boolean }> {
return this.gdprService.withdrawConsent(user.id, body.consentType); await this.gdprService.withdrawConsent(user.id, body.consentType);
return { success: true };
} }
/** /**
@ -182,9 +170,8 @@ export class GDPRController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Consent status retrieved', description: 'Consent status retrieved',
type: ConsentResponseDto,
}) })
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> { async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
return this.gdprService.getConsentStatus(user.id); return this.gdprService.getConsentStatus(user.id);
} }
} }

View File

@ -1,8 +1,6 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
@Public()
@ApiTags('health') @ApiTags('health')
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {

View File

@ -2,17 +2,21 @@ import {
Controller, Controller,
Post, Post,
Get, Get,
Delete,
Body, Body,
UseGuards, UseGuards,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
Param, Param,
ParseUUIDPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { InvitationService } from '../services/invitation.service'; import { InvitationService } from '../services/invitation.service';
import { CreateInvitationDto, InvitationResponseDto } from '../dto/invitation.dto'; import {
CreateInvitationDto,
InvitationResponseDto,
VerifyInvitationDto,
} from '../dto/invitation.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
@ -72,8 +76,7 @@ export class InvitationsController {
dto.lastName, dto.lastName,
dto.role as unknown as UserRole, dto.role as unknown as UserRole,
user.organizationId, user.organizationId,
user.id, user.id
user.role
); );
return { return {
@ -138,29 +141,6 @@ export class InvitationsController {
}; };
} }
/**
* Cancel (delete) a pending invitation
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Cancel invitation',
description: 'Delete a pending invitation. Admin/manager only.',
})
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
@ApiResponse({ status: 404, description: 'Invitation not found' })
@ApiResponse({ status: 400, description: 'Invitation already used' })
async cancelInvitation(
@Param('id') id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
await this.invitationService.cancelInvitation(id, user.organizationId);
}
/** /**
* List organization invitations * List organization invitations
*/ */

View File

@ -185,7 +185,7 @@ export class OrganizationsController {
} }
// Authorization: Users can only view their own organization (unless admin) // Authorization: Users can only view their own organization (unless admin)
if (user.role !== 'ADMIN' && organization.id !== user.organizationId) { if (user.role !== 'admin' && organization.id !== user.organizationId) {
throw new ForbiddenException('You can only view your own organization'); throw new ForbiddenException('You can only view your own organization');
} }
@ -340,7 +340,7 @@ export class OrganizationsController {
// Fetch organizations // Fetch organizations
let organizations: Organization[]; let organizations: Organization[];
if (user.role === 'ADMIN') { if (user.role === 'admin') {
// Admins can see all organizations // Admins can see all organizations
organizations = await this.organizationRepository.findAll(); organizations = await this.organizationRepository.findAll();
} else { } else {

View File

@ -3,14 +3,12 @@ import {
Post, Post,
Get, Get,
Body, Body,
Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
UseGuards, UseGuards,
Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@ -19,7 +17,6 @@ import {
ApiBadRequestResponse, ApiBadRequestResponse,
ApiInternalServerErrorResponse, ApiInternalServerErrorResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto'; import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
import { RateQuoteMapper } from '../mappers'; import { RateQuoteMapper } from '../mappers';
@ -28,15 +25,8 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import { import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
AvailableCompaniesDto,
FilterOptionsDto,
AvailableOriginsDto,
AvailableDestinationsDto,
RoutePortInfoDto,
} from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper'; import { CsvRateMapper } from '../mappers/csv-rate.mapper';
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
@ApiTags('Rates') @ApiTags('Rates')
@Controller('rates') @Controller('rates')
@ -47,8 +37,7 @@ export class RatesController {
constructor( constructor(
private readonly rateSearchService: RateSearchService, private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService, private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper, private readonly csvRateMapper: CsvRateMapper
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
) {} ) {}
@Post('search') @Post('search')
@ -282,168 +271,6 @@ export class RatesController {
} }
} }
/**
* Get available origin ports from CSV rates
* Returns only ports that have routes defined in CSV files
*/
@Get('available-routes/origins')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get available origin ports from CSV rates',
description:
'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available origin ports with details',
type: AvailableOriginsDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
async getAvailableOrigins(): Promise<AvailableOriginsDto> {
this.logger.log('Fetching available origin ports from CSV rates');
try {
// Get unique origin port codes from CSV rates
const originCodes = await this.csvRateSearchService.getAvailableOrigins();
// Fetch port details from database
const ports = await this.portRepository.findByCodes(originCodes);
// Map to response DTO with port details
const origins: RoutePortInfoDto[] = originCodes.map(code => {
const port = ports.find(p => p.code === code);
if (port) {
return {
code: port.code,
name: port.name,
city: port.city,
country: port.country,
countryName: port.countryName,
displayName: port.getDisplayName(),
latitude: port.coordinates.latitude,
longitude: port.coordinates.longitude,
};
}
// Fallback if port not found in database
return {
code,
name: code,
city: '',
country: code.substring(0, 2),
countryName: '',
displayName: code,
};
});
// Sort by display name
origins.sort((a, b) => a.displayName.localeCompare(b.displayName));
return {
origins,
total: origins.length,
};
} catch (error: any) {
this.logger.error(
`Failed to fetch available origins: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/**
* Get available destination ports for a given origin
* Returns only destinations that have routes from the specified origin in CSV files
*/
@Get('available-routes/destinations')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get available destination ports for a given origin',
description:
'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.',
})
@ApiQuery({
name: 'origin',
required: true,
description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)',
example: 'NLRTM',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available destination ports with details',
type: AvailableDestinationsDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Origin port code is required',
})
async getAvailableDestinations(
@Query('origin') origin: string
): Promise<AvailableDestinationsDto> {
this.logger.log(`Fetching available destinations for origin: ${origin}`);
if (!origin) {
throw new Error('Origin port code is required');
}
try {
// Get destination port codes for this origin from CSV rates
const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin);
// Fetch port details from database
const ports = await this.portRepository.findByCodes(destinationCodes);
// Map to response DTO with port details
const destinations: RoutePortInfoDto[] = destinationCodes.map(code => {
const port = ports.find(p => p.code === code);
if (port) {
return {
code: port.code,
name: port.name,
city: port.city,
country: port.country,
countryName: port.countryName,
displayName: port.getDisplayName(),
latitude: port.coordinates.latitude,
longitude: port.coordinates.longitude,
};
}
// Fallback if port not found in database
return {
code,
name: code,
city: '',
country: code.substring(0, 2),
countryName: '',
displayName: code,
};
});
// Sort by display name
destinations.sort((a, b) => a.displayName.localeCompare(b.displayName));
return {
origin: origin.toUpperCase(),
destinations,
total: destinations.length,
};
} catch (error: any) {
this.logger.error(
`Failed to fetch available destinations: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/** /**
* Get available companies * Get available companies
*/ */

Some files were not shown because too many files have changed in this diff Show More