Compare commits

..

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

397 changed files with 9249 additions and 51519 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]
concurrency:
group: dev-ci-${{ github.ref }}
cancel-in-progress: true
env: env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio
NODE_VERSION: '20' NODE_VERSION: '20'
jobs: jobs:
backend-quality: # ============================================
name: Backend — Lint # Backend Build, Test & Deploy
# ============================================
backend:
name: Backend - Build, Test & Push
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
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} 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: Install dependencies
name: Frontend — Lint & Type-check run: npm install --legacy-peer-deps
- name: Lint code
run: npm run lint
- name: Run unit tests
run: npm test -- --coverage --passWithNoTests
- name: Build application
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-backend
tags: |
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
with:
context: ./apps/backend
file: ./apps/backend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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
# ============================================
# Frontend Build, Test & Deploy
# ============================================
frontend:
name: Frontend - Build, Test & Push
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: apps/frontend working-directory: apps/frontend
steps: steps:
- uses: actions/checkout@v4 - name: Checkout code
- uses: actions/setup-node@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'npm' cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json 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: Install dependencies
name: Backend — Unit Tests run: npm ci --legacy-peer-deps
- name: Lint code
run: npm run lint
- name: Run tests
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 runs-on: ubuntu-latest
needs: backend-quality needs: [backend, frontend]
if: github.event_name == 'pull_request'
defaults: defaults:
run: run:
working-directory: apps/backend 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: steps:
- uses: actions/checkout@v4 - name: Checkout code
- uses: actions/setup-node@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} 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: Install dependencies
name: Frontend — Unit Tests run: npm install --legacy-peer-deps
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
notify-failure: - name: Run integration tests
name: Notify Failure 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 runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests] needs: [backend, frontend]
if: failure() if: success()
steps: steps:
- name: Discord - name: Summary
run: | run: |
curl -s -H "Content-Type: application/json" -d '{ 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": [{ "embeds": [{
"title": "❌ Dev CI Failed", "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, "color": 15158332,
"fields": [ "fields": [
{"name": "Branch", "value": "`${{ github.ref_name }}`", "inline": true}, {
{"name": "Author", "value": "${{ github.actor }}", "inline": true}, "name": "Repository",
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} "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
}
], ],
"footer": {"text": "Xpeditis CI • Dev"} "timestamp": "${{ github.event.head_commit.timestamp }}",
"footer": {
"text": "Xpeditis CI/CD"
}
}] }]
}' ${{ secrets.DISCORD_WEBHOOK_URL }} }' \
${{ 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

1135
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,13 +37,11 @@ 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_PASS=ta-cle-smtp-brevo
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=apikey
# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant) SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=noreply@xpeditis.com SMTP_FROM=noreply@xpeditis.com
# AWS S3 / Storage (or MinIO for development) # AWS S3 / Storage (or MinIO for development)
@ -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';
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail)
const forceJson = configService.get('LOG_FORMAT') === 'json';
const usePretty = isDev && !forceJson;
return {
pinoHttp: { pinoHttp: {
transport: usePretty transport:
configService.get('NODE_ENV') === 'development'
? { ? {
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,6 +1,5 @@
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';
@ -19,16 +18,6 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm
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
import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port';
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter';
// CSV Booking Service
import { CsvBookingsModule } from '../csv-bookings.module';
// Email
import { EmailModule } from '@infrastructure/email/email.module';
/** /**
* Admin Module * Admin Module
* *
@ -37,10 +26,11 @@ import { EmailModule } from '@infrastructure/email/email.module';
*/ */
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), TypeOrmModule.forFeature([
ConfigModule, UserOrmEntity,
CsvBookingsModule, OrganizationOrmEntity,
EmailModule, CsvBookingOrmEntity,
]),
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [ providers: [
@ -53,10 +43,6 @@ import { EmailModule } from '@infrastructure/email/email.module';
useClass: TypeOrmOrganizationRepository, useClass: TypeOrmOrganizationRepository,
}, },
TypeOrmCsvBookingRepository, TypeOrmCsvBookingRepository,
{
provide: SIRET_VERIFICATION_PORT,
useClass: PappersSiretAdapter,
},
], ],
}) })
export class AdminModule {} 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],
}) })

View File

@ -21,6 +21,7 @@ import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBadRequestResponse,
ApiNotFoundResponse, ApiNotFoundResponse,
ApiParam, ApiParam,
ApiBearerAuth, ApiBearerAuth,
@ -31,9 +32,17 @@ import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
// User imports // User imports
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import {
UserRepository,
USER_REPOSITORY,
} from '@domain/ports/out/user.repository';
import { UserMapper } from '../mappers/user.mapper'; import { UserMapper } from '../mappers/user.mapper';
import { UpdateUserDto, UserResponseDto, UserListResponseDto } from '../dto/user.dto'; import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
UserListResponseDto,
} from '../dto/user.dto';
// Organization imports // Organization imports
import { import {
@ -41,20 +50,14 @@ import {
ORGANIZATION_REPOSITORY, ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository'; } from '@domain/ports/out/organization.repository';
import { OrganizationMapper } from '../mappers/organization.mapper'; import { OrganizationMapper } from '../mappers/organization.mapper';
import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/organization.dto'; import {
OrganizationResponseDto,
OrganizationListResponseDto,
} from '../dto/organization.dto';
// CSV Booking imports // CSV Booking imports
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingMapper } from '@infrastructure/persistence/typeorm/mappers/csv-booking.mapper';
// SIRET verification imports
import {
SiretVerificationPort,
SIRET_VERIFICATION_PORT,
} from '@domain/ports/out/siret-verification.port';
// Email imports
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
/** /**
* Admin Controller * Admin Controller
@ -74,13 +77,8 @@ export class AdminController {
constructor( constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
private readonly organizationRepository: OrganizationRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository, private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
private readonly csvBookingService: CsvBookingService,
@Inject(SIRET_VERIFICATION_PORT)
private readonly siretVerificationPort: SiretVerificationPort,
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
) {} ) {}
// ==================== USERS ENDPOINTS ==================== // ==================== USERS ENDPOINTS ====================
@ -93,8 +91,7 @@ export class AdminController {
@Get('users') @Get('users')
@ApiOperation({ @ApiOperation({
summary: 'Get all users (Admin only)', summary: 'Get all users (Admin only)',
description: description: 'Retrieve ALL users from the database without any filters. Includes active and inactive users from all organizations.',
'Retrieve ALL users from the database without any filters. Includes active and inactive users from all organizations.',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
@ -155,7 +152,7 @@ export class AdminController {
}) })
async getUserById( async getUserById(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching user: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Fetching user: ${id}`);
@ -191,7 +188,7 @@ export class AdminController {
async updateUser( async updateUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto, @Body() dto: UpdateUserDto,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Updating user: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Updating user: ${id}`);
@ -252,7 +249,7 @@ export class AdminController {
}) })
async deleteUser( async deleteUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<void> { ): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`);
@ -275,8 +272,7 @@ export class AdminController {
@Get('organizations') @Get('organizations')
@ApiOperation({ @ApiOperation({
summary: 'Get all organizations (Admin only)', summary: 'Get all organizations (Admin only)',
description: description: 'Retrieve ALL organizations from the database without any filters. Includes active and inactive organizations.',
'Retrieve ALL organizations from the database without any filters. Includes active and inactive organizations.',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
@ -291,9 +287,7 @@ export class AdminController {
status: 403, status: 403,
description: 'Forbidden - requires admin role', description: 'Forbidden - requires admin role',
}) })
async getAllOrganizations( async getAllOrganizations(@CurrentUser() user: UserPayload): Promise<OrganizationListResponseDto> {
@CurrentUser() user: UserPayload
): Promise<OrganizationListResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL organizations from database`); this.logger.log(`[ADMIN: ${user.email}] Fetching ALL organizations from database`);
const organizations = await this.organizationRepository.findAll(); const organizations = await this.organizationRepository.findAll();
@ -332,7 +326,7 @@ export class AdminController {
}) })
async getOrganizationById( async getOrganizationById(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<OrganizationResponseDto> { ): Promise<OrganizationResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching organization: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Fetching organization: ${id}`);
@ -344,163 +338,6 @@ export class AdminController {
return OrganizationMapper.toDto(organization); return OrganizationMapper.toDto(organization);
} }
/**
* Verify SIRET number for an organization (admin only)
*
* Calls Pappers API to verify the SIRET, then marks the organization as verified.
*/
@Post('organizations/:id/verify-siret')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Verify organization SIRET (Admin only)',
description:
'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'SIRET verification result',
schema: {
type: 'object',
properties: {
verified: { type: 'boolean' },
companyName: { type: 'string' },
address: { type: 'string' },
message: { type: 'string' },
},
},
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
const siret = organization.siret;
if (!siret) {
throw new BadRequestException(
'Organization has no SIRET number. Please set a SIRET number before verification.'
);
}
const result = await this.siretVerificationPort.verify(siret);
if (!result.valid) {
this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`);
return {
verified: false,
message: `Le numero SIRET ${siret} est invalide ou introuvable.`,
};
}
// Mark as verified and save
organization.markSiretVerified();
await this.organizationRepository.update(organization);
this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`);
return {
verified: true,
companyName: result.companyName,
address: result.address,
message: `SIRET ${siret} verifie avec succes.`,
};
}
/**
* Manually approve SIRET/SIREN for an organization (admin only)
*
* Marks the organization's SIRET as verified without calling the external API.
*/
@Post('organizations/:id/approve-siret')
@ApiOperation({
summary: 'Approve SIRET/SIREN (Admin only)',
description:
'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.',
})
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
@ApiResponse({
status: HttpStatus.OK,
description: 'SIRET approved successfully',
})
@ApiNotFoundResponse({ description: 'Organization not found' })
async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
if (!organization.siret && !organization.siren) {
throw new BadRequestException(
"L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation."
);
}
organization.markSiretVerified();
await this.organizationRepository.update(organization);
this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`);
return {
approved: true,
message: 'SIRET/SIREN approuve manuellement avec succes.',
organizationId: id,
organizationName: organization.name,
};
}
/**
* Reject SIRET/SIREN for an organization (admin only)
*
* Resets the verification flag to false.
*/
@Post('organizations/:id/reject-siret')
@ApiOperation({
summary: 'Reject SIRET/SIREN (Admin only)',
description:
'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.',
})
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
@ApiResponse({
status: HttpStatus.OK,
description: 'SIRET rejected successfully',
})
@ApiNotFoundResponse({ description: 'Organization not found' })
async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
// Reset SIRET verification to false by updating the SIRET (which resets siretVerified)
// If no SIRET, just update directly
if (organization.siret) {
organization.updateSiret(organization.siret); // This resets siretVerified to false
}
await this.organizationRepository.update(organization);
this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`);
return {
rejected: true,
message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.",
organizationId: id,
organizationName: organization.name,
};
}
// ==================== CSV BOOKINGS ENDPOINTS ==================== // ==================== CSV BOOKINGS ENDPOINTS ====================
/** /**
@ -511,8 +348,7 @@ export class AdminController {
@Get('bookings') @Get('bookings')
@ApiOperation({ @ApiOperation({
summary: 'Get all CSV bookings (Admin only)', summary: 'Get all CSV bookings (Admin only)',
description: description: 'Retrieve ALL CSV bookings from the database without any filters. Includes bookings from all organizations and all statuses.',
'Retrieve ALL CSV bookings from the database without any filters. Includes bookings from all organizations and all statuses.',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
@ -562,7 +398,10 @@ export class AdminController {
@ApiNotFoundResponse({ @ApiNotFoundResponse({
description: 'CSV booking not found', description: 'CSV booking not found',
}) })
async getBookingById(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { async getBookingById(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
) {
this.logger.log(`[ADMIN: ${user.email}] Fetching csv booking: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Fetching csv booking: ${id}`);
const csvBooking = await this.csvBookingRepository.findById(id); const csvBooking = await this.csvBookingRepository.findById(id);
@ -595,7 +434,7 @@ export class AdminController {
async updateBooking( async updateBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: any, @Body() updateDto: any,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
) { ) {
this.logger.log(`[ADMIN: ${user.email}] Updating csv booking: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Updating csv booking: ${id}`);
@ -612,52 +451,6 @@ export class AdminController {
return this.csvBookingToDto(updatedBooking); return this.csvBookingToDto(updatedBooking);
} }
/**
* Resend carrier email for a booking (admin only)
*
* Manually sends the booking request email to the carrier.
* Useful when the automatic email failed (SMTP error) or for testing without Stripe.
*/
@Post('bookings/:id/resend-carrier-email')
@ApiOperation({
summary: 'Resend carrier email (Admin only)',
description:
'Manually resend the booking request email to the carrier. Works regardless of payment status.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Email sent to carrier' })
@ApiNotFoundResponse({ description: 'Booking not found' })
async resendCarrierEmail(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`);
await this.csvBookingService.resendCarrierEmail(id);
return { success: true, message: 'Email sent to carrier' };
}
/**
* Validate bank transfer for a booking (admin only)
*
* Transitions booking from PENDING_BANK_TRANSFER PENDING and sends email to carrier
*/
@Post('bookings/:id/validate-transfer')
@ApiOperation({
summary: 'Validate bank transfer (Admin only)',
description:
'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' })
@ApiNotFoundResponse({ description: 'Booking not found' })
async validateBankTransfer(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`);
return this.csvBookingService.validateBankTransfer(id);
}
/** /**
* Delete csv booking (admin only) * Delete csv booking (admin only)
*/ */
@ -680,7 +473,7 @@ export class AdminController {
}) })
async deleteBooking( async deleteBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<void> { ): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Deleting csv booking: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Deleting csv booking: ${id}`);
@ -701,7 +494,6 @@ export class AdminController {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber || null,
userId: booking.userId, userId: booking.userId,
organizationId: booking.organizationId, organizationId: booking.organizationId,
carrierName: booking.carrierName, carrierName: booking.carrierName,
@ -729,50 +521,6 @@ export class AdminController {
}; };
} }
// ==================== EMAIL TEST ENDPOINT ====================
/**
* Send a test email to verify SMTP configuration (admin only)
*
* Returns the exact SMTP error in the response instead of only logging it.
*/
@Post('test-email')
@ApiOperation({
summary: 'Send test email (Admin only)',
description:
'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.',
})
@ApiResponse({ status: 200, description: 'Email sent successfully' })
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
async sendTestEmail(
@Body() body: { to: string },
@CurrentUser() user: UserPayload
) {
if (!body?.to) {
throw new BadRequestException('Field "to" is required');
}
this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`);
try {
await this.emailPort.send({
to: body.to,
subject: '[Xpeditis] Test SMTP',
html: `<p>Email de test envoyé depuis le panel admin par <strong>${user.email}</strong>.</p><p>Si vous lisez ceci, la configuration SMTP fonctionne correctement.</p>`,
text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`,
});
this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`);
return { success: true, message: `Email envoyé avec succès à ${body.to}` };
} catch (error: any) {
this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack);
throw new BadRequestException(
`Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` +
`Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}`
);
}
}
// ==================== DOCUMENTS ENDPOINTS ==================== // ==================== DOCUMENTS ENDPOINTS ====================
/** /**
@ -812,9 +560,7 @@ export class AdminController {
})) }))
); );
this.logger.log( this.logger.log(`[ADMIN] Retrieved ${allDocuments.length} documents from ${organizations.length} organizations`);
`[ADMIN] Retrieved ${allDocuments.length} documents from ${organizations.length} organizations`
);
return { return {
documents: allDocuments, documents: allDocuments,
@ -844,7 +590,7 @@ export class AdminController {
}) })
async getOrganizationDocuments( async getOrganizationDocuments(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<any> { ): Promise<any> {
this.logger.log(`[ADMIN: ${user.email}] Fetching documents for organization: ${id}`); this.logger.log(`[ADMIN: ${user.email}] Fetching documents for organization: ${id}`);
@ -860,55 +606,4 @@ export class AdminController {
total: organization.documents.length, total: organization.documents.length,
}; };
} }
/**
* Delete a document from a CSV booking (admin only)
* Bypasses ownership and status restrictions
*/
@Delete('bookings/:bookingId/documents/:documentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Delete document from CSV booking (Admin only)',
description: 'Remove a document from a booking, bypassing ownership and status restrictions.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Document deleted successfully',
})
async deleteDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: UserPayload
): Promise<{ success: boolean; message: string }> {
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document ${documentId} not found`);
}
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
return { success: true, message: 'Document deleted successfully' };
}
} }

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 = {
@ -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

@ -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
*/ */

View File

@ -1,283 +0,0 @@
/**
* Subscriptions Controller
*
* Handles subscription management endpoints:
* - GET /subscriptions - Get subscription overview
* - GET /subscriptions/plans - Get all available plans
* - GET /subscriptions/can-invite - Check if can invite users
* - POST /subscriptions/checkout - Create Stripe checkout session
* - POST /subscriptions/portal - Create Stripe portal session
* - POST /subscriptions/webhook - Handle Stripe webhooks
*/
import {
Controller,
Get,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
Logger,
Headers,
RawBodyRequest,
Req,
Inject,
ForbiddenException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiExcludeEndpoint,
} from '@nestjs/swagger';
import { Request } from 'express';
import { SubscriptionService } from '../services/subscription.service';
import {
CreateCheckoutSessionDto,
CreatePortalSessionDto,
SyncSubscriptionDto,
SubscriptionOverviewResponseDto,
CanInviteResponseDto,
CheckoutSessionResponseDto,
PortalSessionResponseDto,
AllPlansResponseDto,
} from '../dto/subscription.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Public } from '../decorators/public.decorator';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
@ApiTags('Subscriptions')
@Controller('subscriptions')
export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name);
constructor(
private readonly subscriptionService: SubscriptionService,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
/**
* Get subscription overview for current organization
*/
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Get subscription overview',
description:
'Get the subscription details including licenses for the current organization. Admin/manager only.',
})
@ApiResponse({
status: 200,
description: 'Subscription overview retrieved successfully',
type: SubscriptionOverviewResponseDto,
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async getSubscriptionOverview(
@CurrentUser() user: UserPayload
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role);
}
/**
* Get all available plans
*/
@Get('plans')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get all plans',
description: 'Get details of all available subscription plans.',
})
@ApiResponse({
status: 200,
description: 'Plans retrieved successfully',
type: AllPlansResponseDto,
})
getAllPlans(): AllPlansResponseDto {
this.logger.log('Getting all subscription plans');
return this.subscriptionService.getAllPlans();
}
/**
* Check if organization can invite more users
*/
@Get('can-invite')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Check license availability',
description:
'Check if the organization can invite more users based on license availability. Admin/manager only.',
})
@ApiResponse({
status: 200,
description: 'License availability check result',
type: CanInviteResponseDto,
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
this.logger.log(`[User: ${user.email}] Checking license availability`);
return this.subscriptionService.canInviteUser(user.organizationId, user.role);
}
/**
* Create Stripe Checkout session for subscription upgrade
*/
@Post('checkout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Create checkout session',
description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
})
@ApiResponse({
status: 200,
description: 'Checkout session created successfully',
type: CheckoutSessionResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - invalid plan or already subscribed',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async createCheckoutSession(
@Body() dto: CreateCheckoutSessionDto,
@CurrentUser() user: UserPayload
): Promise<CheckoutSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
// ADMIN users bypass all payment restrictions
if (user.role !== 'ADMIN') {
// SIRET verification gate: organization must have a verified SIRET before purchasing
const organization = await this.organizationRepository.findById(user.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 achat. Contactez votre administrateur.'
);
}
}
return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto);
}
/**
* Create Stripe Customer Portal session
*/
@Post('portal')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Create portal session',
description:
'Create a Stripe Customer Portal session for subscription management. Admin/Manager only.',
})
@ApiResponse({
status: 200,
description: 'Portal session created successfully',
type: PortalSessionResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - no Stripe customer found',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async createPortalSession(
@Body() dto: CreatePortalSessionDto,
@CurrentUser() user: UserPayload
): Promise<PortalSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating portal session`);
return this.subscriptionService.createPortalSession(user.organizationId, dto);
}
/**
* Sync subscription from Stripe
* Useful when webhooks are not available (e.g., local development)
*/
@Post('sync')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Sync subscription from Stripe',
description:
'Manually sync subscription data from Stripe. Useful when webhooks are not working (local dev). Pass sessionId after checkout to sync new subscription. Admin/Manager only.',
})
@ApiResponse({
status: 200,
description: 'Subscription synced successfully',
type: SubscriptionOverviewResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - no Stripe subscription found',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async syncFromStripe(
@Body() dto: SyncSubscriptionDto,
@CurrentUser() user: UserPayload
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`
);
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
}
/**
* Handle Stripe webhook events
*/
@Post('webhook')
@Public()
@HttpCode(HttpStatus.OK)
@ApiExcludeEndpoint()
async handleWebhook(
@Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request>
): Promise<{ received: boolean }> {
const rawBody = req.rawBody;
if (!rawBody) {
this.logger.error('No raw body found in request');
return { received: false };
}
try {
await this.subscriptionService.handleStripeWebhook(rawBody, signature);
return { received: true };
} catch (error) {
this.logger.error('Webhook processing failed', error);
return { received: false };
}
}
}

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