Compare commits

..

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

239 changed files with 3595 additions and 30311 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
on:
push:
branches: [dev]
pull_request:
branches: [dev]
concurrency:
group: dev-ci-${{ github.ref }}
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
notify-failure:
name: Notify Failure
runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests]
if: failure()
steps:
- name: Discord
run: |
curl -s -H "Content-Type: application/json" -d '{
"embeds": [{
"title": "❌ Dev CI Failed",
"color": 15158332,
"fields": [
{"name": "Branch", "value": "`${{ github.ref_name }}`", "inline": true},
{"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 • Dev"}
}]
}' ${{ secrets.DISCORD_WEBHOOK_URL }}
name: CI/CD Pipeline
on:
push:
branches:
- preprod
env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio
NODE_VERSION: '20'
jobs:
# ============================================
# Backend Build, Test & Deploy
# ============================================
backend:
name: Backend - Build, Test & Push
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: 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
defaults:
run:
working-directory: apps/frontend
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
- name: Install dependencies
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
needs: [backend, frontend]
if: github.event_name == 'pull_request'
defaults:
run:
working-directory: apps/backend
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password
POSTGRES_DB: xpeditis_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Run integration tests
env:
DATABASE_HOST: localhost
DATABASE_PORT: 5432
DATABASE_USER: xpeditis
DATABASE_PASSWORD: xpeditis_dev_password
DATABASE_NAME: xpeditis_test
REDIS_HOST: localhost
REDIS_PORT: 6379
JWT_SECRET: test-secret-key-for-ci
run: npm run test:integration || echo "No integration tests found"
# ============================================
# Deployment Summary
# ============================================
deployment-summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [backend, frontend]
if: success()
steps:
- name: Summary
run: |
echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Backend Image" >> $GITHUB_STEP_SUMMARY
echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Frontend Image" >> $GITHUB_STEP_SUMMARY
echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-frontend\`" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
# ============================================
# Deploy to Portainer via Webhooks
# ============================================
deploy-portainer:
name: Deploy to Portainer
runs-on: ubuntu-latest
needs: [backend, frontend]
if: success() && github.ref == 'refs/heads/preprod'
steps:
- name: Trigger Backend Webhook
run: |
echo "🚀 Deploying Backend to Portainer..."
curl -X POST \
-H "Content-Type: application/json" \
-d '{"data": "backend-deployment"}' \
${{ secrets.PORTAINER_WEBHOOK_BACKEND }}
echo "✅ Backend webhook triggered"
- name: Wait before Frontend deployment
run: sleep 10
- name: Trigger Frontend Webhook
run: |
echo "🚀 Deploying Frontend to Portainer..."
curl -X POST \
-H "Content-Type: application/json" \
-d '{"data": "frontend-deployment"}' \
${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}
echo "✅ Frontend webhook triggered"
# ============================================
# Discord Notification - Success
# ============================================
notify-success:
name: Discord Notification (Success)
runs-on: ubuntu-latest
needs: [backend, frontend, deploy-portainer]
if: success()
steps:
- name: Send Discord notification
run: |
curl -H "Content-Type: application/json" \
-d '{
"embeds": [{
"title": "✅ CI/CD Pipeline Success",
"description": "Deployment completed successfully!",
"color": 3066993,
"fields": [
{
"name": "Repository",
"value": "${{ github.repository }}",
"inline": true
},
{
"name": "Branch",
"value": "${{ github.ref_name }}",
"inline": true
},
{
"name": "Commit",
"value": "[${{ github.sha }}](${{ github.event.head_commit.url }})",
"inline": false
},
{
"name": "Backend Image",
"value": "`${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}`",
"inline": false
},
{
"name": "Frontend Image",
"value": "`${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}`",
"inline": false
},
{
"name": "Workflow",
"value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"inline": false
}
],
"timestamp": "${{ github.event.head_commit.timestamp }}",
"footer": {
"text": "Xpeditis CI/CD"
}
}]
}' \
${{ secrets.DISCORD_WEBHOOK_URL }}
# ============================================
# Discord Notification - Failure
# ============================================
notify-failure:
name: Discord Notification (Failure)
runs-on: ubuntu-latest
needs: [backend, frontend, deploy-portainer]
if: failure()
steps:
- name: Send Discord notification
run: |
curl -H "Content-Type: application/json" \
-d '{
"embeds": [{
"title": "❌ CI/CD Pipeline Failed",
"description": "Deployment failed! Check the logs for details.",
"color": 15158332,
"fields": [
{
"name": "Repository",
"value": "${{ github.repository }}",
"inline": true
},
{
"name": "Branch",
"value": "${{ github.ref_name }}",
"inline": true
},
{
"name": "Commit",
"value": "[${{ github.sha }}](${{ github.event.head_commit.url }})",
"inline": false
},
{
"name": "Workflow",
"value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
"inline": false
}
],
"timestamp": "${{ github.event.head_commit.timestamp }}",
"footer": {
"text": "Xpeditis CI/CD"
}
}]
}' \
${{ secrets.DISCORD_WEBHOOK_URL }}

View File

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

View File

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

2
.gitignore vendored
View File

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

239
CLAUDE.md
View File

@ -4,27 +4,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**Xpeditis** is a B2B SaaS maritime freight booking platform. Freight forwarders search and compare real-time shipping rates, book containers, and manage shipments. Monorepo with NestJS 10 backend (Hexagonal Architecture) and Next.js 14 frontend.
**Xpeditis** is a B2B SaaS maritime freight booking platform. Freight forwarders search and compare real-time shipping rates, book containers, and manage shipments. Built as a monorepo with NestJS backend (Hexagonal Architecture) and Next.js 14 frontend.
## Development Commands
All commands run from repo root unless noted otherwise.
```bash
# Infrastructure (PostgreSQL 15 + Redis 7 + MinIO)
# Start infrastructure (PostgreSQL + Redis + MinIO)
docker-compose up -d
# Install all dependencies
npm run install:all
# Install dependencies
npm install && cd apps/backend && npm install && cd ../frontend && npm install
# Environment setup (required on first run)
cp apps/backend/.env.example apps/backend/.env
cp apps/frontend/.env.example apps/frontend/.env
# Database migrations (from apps/backend/)
# Run database migrations
cd apps/backend && npm run migration:run
# Development servers
# Start development servers
npm run backend:dev # http://localhost:4000, Swagger: /api/docs
npm run frontend:dev # http://localhost:3000
```
@ -33,33 +27,15 @@ npm run frontend:dev # http://localhost:3000
```bash
# Backend (from apps/backend/)
npm test # Unit tests (Jest)
npm test -- booking.entity.spec.ts # Single file
npm test -- --testNamePattern="should create" # Filter by test name
npm run test:cov # With coverage
npm run test:integration # Integration tests (needs DB/Redis, 30s timeout)
npm test # Unit tests
npm test -- booking.entity.spec.ts # Single file
npm run test:cov # With coverage
npm run test:integration # Integration tests (needs DB/Redis)
npm run test:e2e # E2E tests
# Frontend (from apps/frontend/)
npm test
npm run test:e2e # Playwright (chromium, firefox, webkit + mobile)
# From root
npm run backend:test
npm run frontend:test
```
Backend test config is in `apps/backend/package.json` (Jest). Integration test config: `apps/backend/jest-integration.json` (covers infrastructure layer, setup in `test/setup-integration.ts`). Frontend E2E config: `apps/frontend/playwright.config.ts`.
### Linting, Formatting & Type Checking
```bash
npm run backend:lint # ESLint backend
npm run frontend:lint # ESLint frontend
npm run format # Prettier (all files)
npm run format:check # Check formatting
# From apps/frontend/
npm run type-check # TypeScript checking (frontend only)
npx playwright test # E2E tests
```
### Database Migrations
@ -74,20 +50,10 @@ npm run migration:revert
### Build
```bash
npm run backend:build # NestJS build with tsc-alias for path resolution
npm run frontend:build # Next.js production build (standalone output)
npm run clean # Remove all node_modules, dist, .next directories
npm run backend:build # Compiles TS with path alias resolution (tsc-alias)
npm run frontend:build # Next.js production build
```
## Local Infrastructure
Docker-compose defaults (no `.env` changes needed for local dev):
- **PostgreSQL**: `xpeditis:xpeditis_dev_password@localhost:5432/xpeditis_dev`
- **Redis**: password `xpeditis_redis_password`, port 6379
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001
Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`) — configured in `next.config.js`.
## Architecture
### Hexagonal Architecture (Backend)
@ -95,183 +61,110 @@ Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`)
```
apps/backend/src/
├── domain/ # CORE - Pure TypeScript, NO framework imports
│ ├── entities/ # Booking, RateQuote, Carrier, Port, Container, Notification, Webhook,
│ │ # AuditLog, User, Organization, Subscription, License, CsvBooking,
│ │ # CsvRate, InvitationToken
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType,
│ │ # Volume, DateRange, Surcharge
│ ├── services/ # Pure domain services (csv-rate-price-calculator)
│ ├── entities/ # Booking, RateQuote, User (with private props, static create())
│ ├── value-objects/# Money, Email, BookingNumber (immutable, validated)
│ ├── services/ # Domain services
│ ├── ports/
│ │ ├── in/ # Use case interfaces with execute() method
│ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository')
│ └── exceptions/ # Domain-specific exceptions
├── application/ # Controllers, DTOs (class-validator), Guards, Decorators, Mappers
│ ├── [feature]/ # Feature modules: auth/, bookings/, csv-bookings, rates/, ports/,
│ │ # organizations/, users/, dashboard/, audit/, notifications/, webhooks/,
│ │ # gdpr/, admin/, subscriptions/
│ ├── controllers/ # REST controllers (also nested under feature folders)
│ ├── services/ # Application services: audit, notification, webhook,
│ │ # booking-automation, export, fuzzy-search, brute-force-protection
│ ├── gateways/ # WebSocket gateways (notifications.gateway.ts via Socket.IO)
│ ├── guards/ # JwtAuthGuard, RolesGuard, CustomThrottlerGuard
│ ├── decorators/ # @Public(), @Roles(), @CurrentUser()
│ ├── dto/ # Request/response DTOs with class-validator
│ ├── mappers/ # Domain ↔ DTO mappers
│ └── interceptors/ # PerformanceMonitoringInterceptor
└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs,
# MinIO/S3, email (MJML+Nodemailer), Stripe, Sentry,
# Pappers (French SIRET registry), PDF generation
│ │ ├── in/ # Use case interfaces
│ │ └── out/ # Repository interfaces (SPIs)
│ └── exceptions/ # Domain exceptions
├── application/ # Controllers, DTOs, Guards (depends ONLY on domain)
└── infrastructure/ # TypeORM, Redis, Carrier APIs (depends ONLY on domain)
```
**Critical dependency rules**:
- Domain layer: zero imports from NestJS, TypeORM, Redis, or any framework
- Dependencies flow inward only: Infrastructure → Application → Domain
- Path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` (defined in `apps/backend/tsconfig.json`)
**Critical Rules**:
- Domain layer: Zero imports from NestJS, TypeORM, Redis
- Dependencies flow inward: Infrastructure → Application → Domain
- Use path aliases: `@domain/*`, `@application/*`, `@infrastructure/*`
- Domain tests run without NestJS TestingModule
- Backend has strict TypeScript: `strict: true`, `strictNullChecks: true` (but `strictPropertyInitialization: false`)
- Env vars validated at startup via Joi schema in `app.module.ts` — required vars include DATABASE_*, REDIS_*, JWT_SECRET, SMTP_*
### NestJS Modules (app.module.ts)
Global guards: JwtAuthGuard (all routes protected by default), CustomThrottlerGuard.
Feature modules: Auth, Rates, Ports, Bookings, CsvBookings, Organizations, Users, Dashboard, Audit, Notifications, Webhooks, GDPR, Admin, Subscriptions.
Infrastructure modules: CacheModule, CarrierModule, SecurityModule, CsvRateModule, StripeModule, PdfModule, StorageModule, EmailModule.
Swagger plugin enabled in `nest-cli.json` — DTOs auto-documented. Logging via `nestjs-pino` (pino-pretty in dev).
### Frontend (Next.js 14 App Router)
```
apps/frontend/
├── app/ # App Router pages (root-level)
│ ├── dashboard/ # Protected routes (bookings, admin, settings, wiki, search)
│ ├── carrier/ # Carrier portal (magic link auth — accept/reject/documents)
│ ├── booking/ # Booking confirmation/rejection flows
│ └── [auth pages] # login, register, forgot-password, verify-email
├── app/ # App Router pages
│ ├── dashboard/ # Protected routes
│ │ ├── bookings/ # Booking management
│ │ ├── admin/ # Admin features (ADMIN role)
│ │ └── settings/ # User/org settings
│ └── carrier/ # Carrier portal (magic link auth)
└── src/
├── app/ # Additional app pages (e.g. rates/csv-search)
├── components/ # React components (ui/, layout/, bookings/, admin/, rate-search/, organization/)
├── hooks/ # useBookings, useNotifications, useCsvRateSearch, useCompanies, useFilterOptions
├── lib/
│ ├── api/ # Fetch-based API client with auto token refresh (client.ts + per-module files)
│ ├── context/ # Auth context, cookie context
│ ├── providers/ # QueryProvider (TanStack Query / React Query)
│ └── fonts.ts # Manrope (headings) + Montserrat (body)
├── types/ # TypeScript type definitions
├── utils/ # Export utilities (Excel, PDF)
└── legacy-pages/ # Archived page components (BookingsManagement, CarrierManagement, CarrierMonitoring)
├── components/ # React components (shadcn/ui in ui/)
├── hooks/ # useBookings, useNotifications
├── lib/api/ # API client modules
└── types/ # TypeScript definitions
```
Path aliases: `@/*``./src/*`, `@/components/*`, `@/lib/*`, `@/app/*``./app/*`, `@/types/*`, `@/hooks/*`, `@/utils/*`
**Note**: Frontend tsconfig has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false` (unlike backend which is strict). Uses TanStack Query (React Query) for server state — wrap new data fetching in hooks, not bare `fetch` calls.
### Brand Design
Colors: Navy `#10183A` (primary), Turquoise `#34CCCD` (accent), Green `#067224` (success), Gray `#F2F2F2`.
Fonts: Manrope (headings), Montserrat (body).
Landing page is in French.
Path aliases: `@/*``./src/*`, `@/components/*`, `@/lib/*`, `@/hooks/*`
## Key Patterns
### Entity Pattern (Domain)
Private constructor + static `create()` factory. Immutable — mutation methods return new instances. Some entities also have `fromPersistence()` for reconstitution and `toObject()` for serialization.
```typescript
export class Booking {
private readonly props: BookingProps;
static create(props: Omit<BookingProps, 'bookingNumber' | 'status'>): Booking { ... }
static create(props: Omit<BookingProps, 'bookingNumber'>): Booking { ... }
updateStatus(newStatus: BookingStatus): Booking { // Returns new instance
return new Booking({ ...this.props, status: newStatus });
}
}
```
### Value Object Pattern
Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR, GBP, CNY, JPY with arithmetic and formatting methods.
### Repository Pattern
- Interface in `domain/ports/out/` with token constant (e.g. `BOOKING_REPOSITORY = 'BookingRepository'`)
- Implementation in `infrastructure/persistence/typeorm/repositories/`
- ORM entities: `infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
- Interface in `domain/ports/out/booking.repository.ts`
- Implementation in `infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts`
- Separate mappers for Domain ↔ ORM entity conversions
### Frontend API Client
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
### Route Protection (Middleware)
`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists:
- `exactPublicPaths`: exact matches (e.g. `/`)
- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.)
All other routes redirect to `/login?redirect=<pathname>` when the cookie is absent.
### Application Decorators
- `@Public()` — skip JWT auth
- `@Roles()` — role-based access control
- `@CurrentUser()` — inject authenticated user
### API Key Authentication
A second auth mechanism alongside JWT. `ApiKey` domain entity (`domain/entities/api-key.entity.ts`) — keys are hashed with Argon2. `ApiKeyGuard` in `application/guards/` checks the `x-api-key` header. Routes can accept either JWT or API key; see `admin.controller.ts` for examples.
### WebSocket (Real-time Notifications)
Socket.IO gateway at `application/gateways/notifications.gateway.ts`. Clients connect to `/` namespace with a JWT bearer token in the handshake auth. Server emits `notification` events. The frontend `useNotifications` hook handles subscriptions.
### Carrier Connectors
Five carrier connectors (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) extending `base-carrier.connector.ts`, each with request/response mappers. Circuit breaker via `opossum` (5s timeout).
### Circuit Breaker (External APIs)
- Library: `opossum`, Timeout: 5s
- Used for carrier API calls (Maersk, MSC, CMA CGM)
### Caching
Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}:{containerType}`.
- Redis with 15-min TTL for rate quotes
- Cache key format: `rate:{origin}:{destination}:{containerType}`
## Business Rules
- Booking number format: `WCM-YYYY-XXXXXX`
- Booking status flow: draft → confirmed → shipped → delivered
- Rate quotes expire after 15 minutes
- Multi-currency: USD, EUR, GBP, CNY, JPY
- Multi-currency: USD, EUR
- RBAC Roles: ADMIN, MANAGER, USER, VIEWER, CARRIER
- JWT: access token 15min, refresh token 7d
- Password hashing: Argon2
- OAuth providers: Google, Microsoft (configured via passport strategies)
- Organizations can be validated via Pappers API (French SIRET/company registry) at `infrastructure/external/pappers-siret.adapter.ts`
### Carrier Portal Workflow
1. Admin creates CSV booking → assigns carrier
2. Email with magic link sent (1-hour expiry)
3. Carrier auto-login → accept/reject booking
4. Activity logged in `carrier_activities` table (via `CarrierProfile` + `CarrierActivity` ORM entities)
4. Activity logged in `carrier_activities` table
## Tech Stack
**Backend**: NestJS 10, TypeORM 0.3, PostgreSQL 15, Redis 7, Argon2, Pino, Sentry
**Frontend**: Next.js 14, React 18, TanStack Query/Table, React Hook Form + Zod, Tailwind + shadcn/ui, Socket.IO
## Common Pitfalls
- Never import NestJS/TypeORM in domain layer
- Never use `any` type in backend (strict mode enabled)
- Never modify applied migrations — create new ones
- Always validate DTOs with `class-validator` decorators
- Always create separate mappers for Domain ↔ ORM conversions
- ORM entity files must match pattern `*.orm-entity.{ts,js}` (auto-discovered by data-source)
- Migration files must be in `infrastructure/persistence/typeorm/migrations/`
- Database synchronize is hard-coded to `false` — always use migrations
- Never use `any` type (strict mode enabled)
- Never use `DATABASE_SYNC=true` in production
- Never modify applied migrations - create new ones
- Always validate DTOs with `class-validator`
- Always create mappers for Domain ↔ ORM conversions
## Adding a New Feature
1. **Domain Entity**`domain/entities/*.entity.ts` (pure TS, unit tests)
2. **Value Objects**`domain/value-objects/*.vo.ts` (immutable)
3. **In Port (Use Case)**`domain/ports/in/*.use-case.ts` (interface with `execute()`)
4. **Out Port (Repository)**`domain/ports/out/*.repository.ts` (with token constant)
5. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
6. **Migration**`npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName`
7. **Repository Impl**`infrastructure/persistence/typeorm/repositories/`
8. **Mapper**`infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany)
9. **DTOs**`application/dto/` (with class-validator decorators)
10. **Controller**`application/controllers/` (with Swagger decorators)
11. **Module** → Register repository + use-case providers, import in `app.module.ts`
3. **Port Interface**`domain/ports/out/*.repository.ts`
4. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
5. **Generate Migration**`npm run migration:generate -- ...`
6. **Repository Impl**`infrastructure/persistence/typeorm/repositories/`
7. **DTOs**`application/dto/` (with class-validator decorators)
8. **Controller**`application/controllers/` (with Swagger decorators)
9. **Module** → Register and import in `app.module.ts`
## Documentation
- API Docs: http://localhost:4000/api/docs (Swagger, when running)
- Setup guide: `docs/installation/START-HERE.md`
- API Docs: http://localhost:4000/api/docs (Swagger)
- Architecture: `docs/architecture.md`
- Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md`
- Full docs index: `docs/README.md`
- Development roadmap: `TODO.md`
- Infrastructure configs (CI/CD, Docker): `infra/`

View File

@ -1,466 +0,0 @@
# ✅ Sprint 0 - Rapport de Complétion Final
## Xpeditis MVP - Project Setup & Infrastructure
**Date de Complétion** : 7 octobre 2025
**Statut** : ✅ **100% TERMINÉ**
**Durée** : 2 semaines (comme planifié)
---
## 📊 Résumé Exécutif
Sprint 0 a été **complété avec succès à 100%**. Tous les objectifs ont été atteints et le projet Xpeditis MVP est **prêt pour la Phase 1 de développement**.
### Statistiques
| Métrique | Valeur |
|----------|--------|
| **Fichiers Créés** | 60+ fichiers |
| **Documentation** | 14 fichiers Markdown (5000+ lignes) |
| **Code/Config** | 27 fichiers TypeScript/JavaScript/JSON/YAML |
| **Dépendances** | 80+ packages npm |
| **Lignes de Code** | 2000+ lignes |
| **Temps Total** | ~16 heures de travail |
| **Complétion** | 100% ✅ |
---
## 📦 Livrables Créés
### 1. Documentation (14 fichiers)
| Fichier | Lignes | Purpose | Statut |
|---------|--------|---------|--------|
| **START-HERE.md** | 350+ | 🟢 Point d'entrée principal | ✅ |
| README.md | 200+ | Vue d'ensemble du projet | ✅ |
| CLAUDE.md | 650+ | Guide d'architecture hexagonale complet | ✅ |
| PRD.md | 350+ | Exigences produit détaillées | ✅ |
| TODO.md | 1300+ | Roadmap 30 semaines complet | ✅ |
| QUICK-START.md | 250+ | Guide de démarrage rapide | ✅ |
| INSTALLATION-STEPS.md | 400+ | Guide d'installation détaillé | ✅ |
| WINDOWS-INSTALLATION.md | 350+ | Installation spécifique Windows | ✅ |
| NEXT-STEPS.md | 550+ | Prochaines étapes détaillées | ✅ |
| SPRINT-0-FINAL.md | 550+ | Rapport complet Sprint 0 | ✅ |
| SPRINT-0-SUMMARY.md | 500+ | Résumé exécutif | ✅ |
| INDEX.md | 450+ | Index de toute la documentation | ✅ |
| READY.md | 400+ | Confirmation de préparation | ✅ |
| COMPLETION-REPORT.md | Ce fichier | Rapport final de complétion | ✅ |
**Sous-total** : 14 fichiers, ~5000 lignes de documentation
### 2. Backend (NestJS + Architecture Hexagonale)
| Catégorie | Fichiers | Statut |
|-----------|----------|--------|
| **Configuration** | 7 fichiers | ✅ |
| **Code Source** | 6 fichiers | ✅ |
| **Tests** | 2 fichiers | ✅ |
| **Documentation** | 1 fichier (README.md) | ✅ |
**Fichiers Backend** :
- ✅ package.json (50+ dépendances)
- ✅ tsconfig.json (strict mode + path aliases)
- ✅ nest-cli.json
- ✅ .eslintrc.js
- ✅ .env.example (toutes les variables)
- ✅ .gitignore
- ✅ src/main.ts (bootstrap complet)
- ✅ src/app.module.ts (module racine)
- ✅ src/application/controllers/health.controller.ts
- ✅ src/application/controllers/index.ts
- ✅ src/domain/entities/index.ts
- ✅ src/domain/ports/in/index.ts
- ✅ src/domain/ports/out/index.ts
- ✅ test/app.e2e-spec.ts
- ✅ test/jest-e2e.json
- ✅ README.md (guide backend)
**Structure Hexagonale** :
```
src/
├── domain/ ✅ Logique métier pure
│ ├── entities/
│ ├── value-objects/
│ ├── services/
│ ├── ports/in/
│ ├── ports/out/
│ └── exceptions/
├── application/ ✅ Controllers & DTOs
│ ├── controllers/
│ ├── dto/
│ ├── mappers/
│ └── config/
└── infrastructure/ ✅ Adaptateurs externes
├── persistence/
├── cache/
├── carriers/
├── email/
├── storage/
└── config/
```
**Sous-total** : 16 fichiers backend
### 3. Frontend (Next.js 14 + TypeScript)
| Catégorie | Fichiers | Statut |
|-----------|----------|--------|
| **Configuration** | 7 fichiers | ✅ |
| **Code Source** | 4 fichiers | ✅ |
| **Documentation** | 1 fichier (README.md) | ✅ |
**Fichiers Frontend** :
- ✅ package.json (30+ dépendances)
- ✅ tsconfig.json (path aliases)
- ✅ next.config.js
- ✅ tailwind.config.ts (thème complet)
- ✅ postcss.config.js
- ✅ .eslintrc.json
- ✅ .env.example
- ✅ .gitignore
- ✅ app/layout.tsx (layout racine)
- ✅ app/page.tsx (page d'accueil)
- ✅ app/globals.css (Tailwind + variables CSS)
- ✅ lib/utils.ts (helper cn)
- ✅ README.md (guide frontend)
**Sous-total** : 13 fichiers frontend
### 4. Infrastructure & DevOps
| Catégorie | Fichiers | Statut |
|-----------|----------|--------|
| **Docker** | 2 fichiers | ✅ |
| **CI/CD** | 3 fichiers | ✅ |
| **Configuration Racine** | 4 fichiers | ✅ |
**Fichiers Infrastructure** :
- ✅ docker-compose.yml (PostgreSQL + Redis)
- ✅ infra/postgres/init.sql (script d'initialisation)
- ✅ .github/workflows/ci.yml (pipeline CI)
- ✅ .github/workflows/security.yml (audit sécurité)
- ✅ .github/pull_request_template.md
- ✅ package.json (racine, scripts simplifiés)
- ✅ .gitignore (racine)
- ✅ .prettierrc
- ✅ .prettierignore
**Sous-total** : 9 fichiers infrastructure
---
## 🎯 Objectifs Sprint 0 - Tous Atteints
| Objectif | Statut | Notes |
|----------|--------|-------|
| **Structure Monorepo** | ✅ Complete | npm scripts sans workspaces (Windows) |
| **Backend Hexagonal** | ✅ Complete | Domain/Application/Infrastructure |
| **Frontend Next.js 14** | ✅ Complete | App Router + TypeScript |
| **Docker Infrastructure** | ✅ Complete | PostgreSQL 15 + Redis 7 |
| **TypeScript Strict** | ✅ Complete | Tous les projets |
| **Testing Infrastructure** | ✅ Complete | Jest, Supertest, Playwright |
| **CI/CD Pipelines** | ✅ Complete | GitHub Actions |
| **API Documentation** | ✅ Complete | Swagger à /api/docs |
| **Logging Structuré** | ✅ Complete | Pino avec pretty-print |
| **Sécurité** | ✅ Complete | Helmet, JWT, CORS, validation |
| **Validation Env** | ✅ Complete | Joi schema |
| **Health Endpoints** | ✅ Complete | /health, /ready, /live |
| **Documentation** | ✅ Complete | 14 fichiers, 5000+ lignes |
**Score** : 13/13 objectifs atteints (100%)
---
## 🏗️ Architecture Implémentée
### Backend - Architecture Hexagonale
**✅ Strict Separation of Concerns** :
1. **Domain Layer (Core)** :
- ✅ Zero external dependencies
- ✅ Pure TypeScript classes
- ✅ Ports (interfaces) defined
- ✅ Testable without framework
- 🎯 Target: 90%+ test coverage
2. **Application Layer** :
- ✅ Controllers with validation
- ✅ DTOs defined
- ✅ Mappers ready
- ✅ Depends only on domain
- 🎯 Target: 80%+ test coverage
3. **Infrastructure Layer** :
- ✅ TypeORM configured
- ✅ Redis configured
- ✅ Folder structure ready
- ✅ Depends only on domain
- 🎯 Target: 70%+ test coverage
### Frontend - Modern React Stack
**✅ Next.js 14 Configuration** :
- ✅ App Router avec Server Components
- ✅ TypeScript strict mode
- ✅ Tailwind CSS + shadcn/ui ready
- ✅ TanStack Query configured
- ✅ react-hook-form + zod ready
- ✅ Dark mode support (CSS variables)
---
## 🛠️ Stack Technique Complet
### Backend
- **Framework** : NestJS 10.2.10 ✅
- **Language** : TypeScript 5.3.3 ✅
- **Database** : PostgreSQL 15 ✅
- **Cache** : Redis 7 ✅
- **ORM** : TypeORM 0.3.17 ✅
- **Auth** : JWT + Passport ✅
- **Validation** : class-validator + class-transformer ✅
- **API Docs** : Swagger/OpenAPI ✅
- **Logging** : Pino 8.17.1 ✅
- **Testing** : Jest 29.7.0 + Supertest 6.3.3 ✅
- **Security** : Helmet 7.1.0, bcrypt 5.1.1 ✅
- **Circuit Breaker** : opossum 8.1.3 ✅
### Frontend
- **Framework** : Next.js 14.0.4 ✅
- **Language** : TypeScript 5.3.3 ✅
- **Styling** : Tailwind CSS 3.3.6 ✅
- **UI Components** : Radix UI ✅
- **State** : TanStack Query 5.14.2 ✅
- **Forms** : react-hook-form 7.49.2 ✅
- **Validation** : zod 3.22.4 ✅
- **HTTP** : axios 1.6.2 ✅
- **Icons** : lucide-react 0.294.0 ✅
- **Testing** : Jest 29.7.0 + Playwright 1.40.1 ✅
### Infrastructure
- **Database** : PostgreSQL 15-alpine (Docker) ✅
- **Cache** : Redis 7-alpine (Docker) ✅
- **CI/CD** : GitHub Actions ✅
- **Version Control** : Git ✅
---
## 📋 Features Implémentées
### Backend Features
1. **✅ Health Check System**
- `/health` - Overall system health
- `/ready` - Readiness for traffic
- `/live` - Liveness check
2. **✅ Logging System**
- Structured JSON logs (Pino)
- Pretty print en développement
- Request/response logging
- Log levels configurables
3. **✅ Configuration Management**
- Validation des variables d'environnement (Joi)
- Configuration type-safe
- Support multi-environnements
4. **✅ Security Foundations**
- Helmet.js security headers
- CORS configuration
- Rate limiting preparé
- JWT authentication ready
- Password hashing (bcrypt)
- Input validation (class-validator)
5. **✅ API Documentation**
- Swagger UI à `/api/docs`
- Spécification OpenAPI
- Schémas request/response
- Documentation d'authentification
6. **✅ Testing Infrastructure**
- Jest configuré
- Supertest configuré
- E2E tests ready
- Path aliases for tests
### Frontend Features
1. **✅ Modern React Setup**
- Next.js 14 App Router
- Server et client components
- TypeScript strict mode
- Path aliases configurés
2. **✅ UI Framework**
- Tailwind CSS avec thème personnalisé
- shadcn/ui components ready
- Dark mode support (variables CSS)
- Responsive design utilities
3. **✅ State Management**
- TanStack Query configuré
- React hooks ready
- Form state avec react-hook-form
4. **✅ Utilities**
- Helper `cn()` pour className merging
- API client type-safe ready
- Validation Zod ready
---
## 🚀 Prêt pour Phase 1
### Checklist de Préparation
- [x] Code et configuration complets
- [x] Documentation exhaustive
- [x] Architecture hexagonale validée
- [x] Testing infrastructure prête
- [x] CI/CD pipelines configurés
- [x] Docker infrastructure opérationnelle
- [x] Sécurité de base implémentée
- [x] Guide de démarrage créé
- [x] Tous les objectifs Sprint 0 atteints
### Prochaine Phase : Phase 1 (6-8 semaines)
**Sprint 1-2** : Domain Layer (Semaines 1-2)
- Créer les entités métier
- Créer les value objects
- Définir les ports API et SPI
- Implémenter les services métier
- Tests unitaires (90%+)
**Sprint 3-4** : Infrastructure Layer (Semaines 3-4)
- Schéma de base de données
- Repositories TypeORM
- Redis cache adapter
- Connecteur Maersk
**Sprint 5-6** : Application Layer (Semaines 5-6)
- API rate search
- Controllers & DTOs
- Documentation OpenAPI
- Tests E2E
**Sprint 7-8** : Frontend UI (Semaines 7-8)
- Interface de recherche
- Affichage des résultats
- Filtres et tri
- Tests frontend
---
## 📚 Documentation Organisée
### Guide de Navigation
**🟢 Pour Démarrer** (obligatoire) :
1. [START-HERE.md](START-HERE.md) - Point d'entrée principal
2. [QUICK-START.md](QUICK-START.md) - Démarrage rapide
3. [CLAUDE.md](CLAUDE.md) - Architecture (À LIRE ABSOLUMENT)
4. [NEXT-STEPS.md](NEXT-STEPS.md) - Quoi faire ensuite
**🟡 Pour Installation** :
- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Détaillé
- [WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md) - Spécifique Windows
**🔵 Pour Développement** :
- [CLAUDE.md](CLAUDE.md) - Règles d'architecture
- [apps/backend/README.md](apps/backend/README.md) - Backend
- [apps/frontend/README.md](apps/frontend/README.md) - Frontend
- [TODO.md](TODO.md) - Roadmap détaillée
**🟠 Pour Référence** :
- [PRD.md](PRD.md) - Exigences produit
- [INDEX.md](INDEX.md) - Index complet
- [READY.md](READY.md) - Confirmation
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Rapport complet
- [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Résumé
---
## 💻 Installation et Démarrage
### Installation Rapide
```bash
# 1. Installer les dépendances
npm run install:all
# 2. Démarrer Docker
docker-compose up -d
# 3. Configurer l'environnement
cp apps/backend/.env.example apps/backend/.env
cp apps/frontend/.env.example apps/frontend/.env
# 4. Démarrer (2 terminals)
npm run backend:dev # Terminal 1
npm run frontend:dev # Terminal 2
```
### Vérification
- ✅ http://localhost:4000/api/v1/health
- ✅ http://localhost:4000/api/docs
- ✅ http://localhost:3000
---
## 🎊 Conclusion
### Succès Sprint 0
**Tout planifié a été livré** :
- ✅ 100% des objectifs atteints
- ✅ 60+ fichiers créés
- ✅ 5000+ lignes de documentation
- ✅ Architecture hexagonale complète
- ✅ Infrastructure production-ready
- ✅ CI/CD automatisé
- ✅ Sécurité de base
### État du Projet
**Sprint 0** : 🟢 **TERMINÉ** (100%)
**Qualité** : 🟢 **EXCELLENTE**
**Documentation** : 🟢 **COMPLÈTE**
**Prêt pour Phase 1** : 🟢 **OUI**
### Prochaine Étape
**Commencer Phase 1 - Core Search & Carrier Integration**
1. Lire [START-HERE.md](START-HERE.md)
2. Lire [CLAUDE.md](CLAUDE.md) (OBLIGATOIRE)
3. Lire [NEXT-STEPS.md](NEXT-STEPS.md)
4. Commencer Sprint 1-2 (Domain Layer)
---
## 🏆 Félicitations !
**Le projet Xpeditis MVP dispose maintenant d'une fondation solide et production-ready.**
Tous les éléments sont en place pour un développement réussi :
- Architecture propre et maintenable
- Documentation exhaustive
- Tests automatisés
- CI/CD configuré
- Sécurité intégrée
**Bonne chance pour la Phase 1 ! 🚀**
---
*Rapport de Complétion Sprint 0*
*Xpeditis MVP - Maritime Freight Booking Platform*
*7 octobre 2025*
**Statut Final** : ✅ **SPRINT 0 COMPLET À 100%**

348
INDEX.md
View File

@ -1,348 +0,0 @@
# 📑 Xpeditis Documentation Index
Complete guide to all documentation files in the Xpeditis project.
---
## 🚀 Getting Started (Read First)
Start here if you're new to the project:
1. **[README.md](README.md)** - Project overview and quick start
2. **[QUICK-START.md](QUICK-START.md)** ⚡ - Get running in 5 minutes
3. **[INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)** - Detailed installation guide
4. **[NEXT-STEPS.md](NEXT-STEPS.md)** - What to do after setup
---
## 📊 Project Status & Planning
### Sprint 0 (Complete ✅)
- **[SPRINT-0-FINAL.md](SPRINT-0-FINAL.md)** - Complete Sprint 0 report
- All deliverables
- Architecture details
- How to use
- Success criteria
- **[SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)** - Executive summary
- Objectives achieved
- Metrics
- Key features
- Next steps
- **[SPRINT-0-COMPLETE.md](SPRINT-0-COMPLETE.md)** - Technical completion checklist
- Week-by-week breakdown
- Files created
- Remaining tasks
### Project Roadmap
- **[TODO.md](TODO.md)** 📅 - 30-week MVP development roadmap
- Sprint-by-sprint breakdown
- Detailed tasks with checkboxes
- Phase 1-4 planning
- Go-to-market strategy
- **[PRD.md](PRD.md)** 📋 - Product Requirements Document
- Business context
- Functional specifications
- Technical requirements
- Success metrics
---
## 🏗️ Architecture & Development Guidelines
### Core Architecture
- **[CLAUDE.md](CLAUDE.md)** 🏗️ - **START HERE FOR ARCHITECTURE**
- Complete hexagonal architecture guide
- Domain/Application/Infrastructure layers
- Ports & Adapters pattern
- Naming conventions
- Testing strategy
- Common pitfalls
- Complete examples (476 lines)
### Component-Specific Documentation
- **[apps/backend/README.md](apps/backend/README.md)** - Backend (NestJS + Hexagonal)
- Architecture details
- Available scripts
- API endpoints
- Testing guide
- Hexagonal architecture DOs and DON'Ts
- **[apps/frontend/README.md](apps/frontend/README.md)** - Frontend (Next.js 14)
- Tech stack
- Project structure
- API integration
- Forms & validation
- Testing guide
---
## 🛠️ Technical Documentation
### Configuration Files
**Root Level**:
- `package.json` - Workspace configuration
- `.gitignore` - Git ignore rules
- `.prettierrc` - Code formatting
- `docker-compose.yml` - PostgreSQL + Redis
- `tsconfig.json` - TypeScript configuration (per app)
**Backend** (`apps/backend/`):
- `package.json` - Backend dependencies
- `tsconfig.json` - TypeScript strict mode + path aliases
- `nest-cli.json` - NestJS CLI configuration
- `.eslintrc.js` - ESLint rules
- `.env.example` - Environment variables template
**Frontend** (`apps/frontend/`):
- `package.json` - Frontend dependencies
- `tsconfig.json` - TypeScript configuration
- `next.config.js` - Next.js configuration
- `tailwind.config.ts` - Tailwind CSS theme
- `postcss.config.js` - PostCSS configuration
- `.env.example` - Environment variables template
### CI/CD
**GitHub Actions** (`.github/workflows/`):
- `ci.yml` - Continuous Integration
- Lint & format check
- Unit tests (backend + frontend)
- E2E tests
- Build verification
- `security.yml` - Security Audit
- npm audit
- Dependency review
**Templates**:
- `.github/pull_request_template.md` - PR template with hexagonal architecture checklist
---
## 📚 Documentation by Use Case
### I want to...
**...get started quickly**
1. [QUICK-START.md](QUICK-START.md) - 5-minute setup
2. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Detailed steps
3. [NEXT-STEPS.md](NEXT-STEPS.md) - Begin development
**...understand the architecture**
1. [CLAUDE.md](CLAUDE.md) - Complete hexagonal architecture guide
2. [apps/backend/README.md](apps/backend/README.md) - Backend specifics
3. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - See what's implemented
**...know what to build next**
1. [TODO.md](TODO.md) - Full roadmap
2. [NEXT-STEPS.md](NEXT-STEPS.md) - Immediate next tasks
3. [PRD.md](PRD.md) - Business requirements
**...understand the business context**
1. [PRD.md](PRD.md) - Product requirements
2. [README.md](README.md) - Project overview
3. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Executive summary
**...fix an installation issue**
1. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Troubleshooting section
2. [QUICK-START.md](QUICK-START.md) - Common issues
3. [README.md](README.md) - Basic setup
**...write code following best practices**
1. [CLAUDE.md](CLAUDE.md) - Architecture guidelines (READ THIS FIRST)
2. [apps/backend/README.md](apps/backend/README.md) - Backend DOs and DON'Ts
3. [TODO.md](TODO.md) - Task specifications and acceptance criteria
**...run tests**
1. [apps/backend/README.md](apps/backend/README.md) - Testing section
2. [apps/frontend/README.md](apps/frontend/README.md) - Testing section
3. [CLAUDE.md](CLAUDE.md) - Testing strategy
**...deploy to production**
1. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Security checklist
2. [apps/backend/.env.example](apps/backend/.env.example) - All required variables
3. `.github/workflows/ci.yml` - CI/CD pipeline
---
## 📖 Documentation by Role
### For Developers
**Must Read**:
1. [CLAUDE.md](CLAUDE.md) - Architecture principles
2. [apps/backend/README.md](apps/backend/README.md) OR [apps/frontend/README.md](apps/frontend/README.md)
3. [TODO.md](TODO.md) - Current sprint tasks
**Reference**:
- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Setup issues
- [PRD.md](PRD.md) - Business context
### For Architects
**Must Read**:
1. [CLAUDE.md](CLAUDE.md) - Complete architecture
2. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Implementation details
3. [PRD.md](PRD.md) - Technical requirements
**Reference**:
- [TODO.md](TODO.md) - Technical roadmap
- [apps/backend/README.md](apps/backend/README.md) - Backend architecture
### For Project Managers
**Must Read**:
1. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Status overview
2. [TODO.md](TODO.md) - Complete roadmap
3. [PRD.md](PRD.md) - Requirements & KPIs
**Reference**:
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Detailed completion report
- [README.md](README.md) - Project overview
### For DevOps
**Must Read**:
1. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Setup guide
2. [docker-compose.yml](docker-compose.yml) - Infrastructure
3. `.github/workflows/` - CI/CD pipelines
**Reference**:
- [apps/backend/.env.example](apps/backend/.env.example) - Environment variables
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Security checklist
---
## 🗂️ Complete File List
### Documentation (11 files)
| File | Purpose | Length |
|------|---------|--------|
| [README.md](README.md) | Project overview | Medium |
| [CLAUDE.md](CLAUDE.md) | Architecture guide | Long (476 lines) |
| [PRD.md](PRD.md) | Product requirements | Long (352 lines) |
| [TODO.md](TODO.md) | 30-week roadmap | Very Long (1000+ lines) |
| [QUICK-START.md](QUICK-START.md) | 5-minute setup | Short |
| [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) | Detailed setup | Medium |
| [NEXT-STEPS.md](NEXT-STEPS.md) | What's next | Medium |
| [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) | Sprint 0 report | Long |
| [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) | Executive summary | Medium |
| [SPRINT-0-COMPLETE.md](SPRINT-0-COMPLETE.md) | Technical checklist | Short |
| [INDEX.md](INDEX.md) | This file | Medium |
### App-Specific (2 files)
| File | Purpose |
|------|---------|
| [apps/backend/README.md](apps/backend/README.md) | Backend guide |
| [apps/frontend/README.md](apps/frontend/README.md) | Frontend guide |
### Configuration (10+ files)
Root, backend, and frontend configuration files (package.json, tsconfig.json, etc.)
---
## 📊 Documentation Statistics
- **Total Documentation Files**: 13
- **Total Lines**: ~4,000+
- **Coverage**: Setup, Architecture, Development, Testing, Deployment
- **Last Updated**: October 7, 2025
---
## 🎯 Recommended Reading Path
### For New Team Members (Day 1)
**Morning** (2 hours):
1. [README.md](README.md) - 10 min
2. [QUICK-START.md](QUICK-START.md) - 30 min (includes setup)
3. [CLAUDE.md](CLAUDE.md) - 60 min (comprehensive architecture)
4. [PRD.md](PRD.md) - 20 min (business context)
**Afternoon** (2 hours):
5. [apps/backend/README.md](apps/backend/README.md) OR [apps/frontend/README.md](apps/frontend/README.md) - 30 min
6. [TODO.md](TODO.md) - Current sprint section - 30 min
7. [NEXT-STEPS.md](NEXT-STEPS.md) - 30 min
8. Start coding! 🚀
### For Code Review (30 minutes)
1. [CLAUDE.md](CLAUDE.md) - Hexagonal architecture section
2. [apps/backend/README.md](apps/backend/README.md) - DOs and DON'Ts
3. [TODO.md](TODO.md) - Acceptance criteria for the feature
### For Sprint Planning (1 hour)
1. [TODO.md](TODO.md) - Next sprint tasks
2. [PRD.md](PRD.md) - Requirements for the module
3. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Current status
---
## 🔍 Quick Reference
### Common Questions
**Q: How do I get started?**
A: [QUICK-START.md](QUICK-START.md)
**Q: What is hexagonal architecture?**
A: [CLAUDE.md](CLAUDE.md) - Complete guide with examples
**Q: What should I build next?**
A: [NEXT-STEPS.md](NEXT-STEPS.md) then [TODO.md](TODO.md)
**Q: How do I run tests?**
A: [apps/backend/README.md](apps/backend/README.md) or [apps/frontend/README.md](apps/frontend/README.md)
**Q: Where are the business requirements?**
A: [PRD.md](PRD.md)
**Q: What's the project status?**
A: [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)
**Q: Installation failed, what do I do?**
A: [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Troubleshooting section
**Q: Can I change the database/framework?**
A: Yes! That's the point of hexagonal architecture. See [CLAUDE.md](CLAUDE.md)
---
## 📞 Getting Help
If you can't find what you need:
1. **Check this index** - Use Ctrl+F to search
2. **Read CLAUDE.md** - Covers 90% of architecture questions
3. **Check TODO.md** - Has detailed task specifications
4. **Open an issue** - If documentation is unclear or missing
---
## 🎉 Happy Reading!
All documentation is up-to-date as of Sprint 0 completion.
**Quick Links**:
- 🚀 [Get Started](QUICK-START.md)
- 🏗️ [Architecture](CLAUDE.md)
- 📅 [Roadmap](TODO.md)
- 📋 [Requirements](PRD.md)
---
*Xpeditis MVP - Maritime Freight Booking Platform*
*Documentation Index - October 7, 2025*

View File

@ -1,334 +0,0 @@
# ✅ Installation Complete - Xpeditis
Sprint 0 setup is now complete with all dependencies installed and verified!
---
## 📦 What Has Been Installed
### Backend Dependencies ✅
- **Location**: `apps/backend/node_modules`
- **Packages**: 873 packages (871 + nestjs-pino)
- **Key frameworks**:
- NestJS 10.2.10 (framework core)
- TypeORM 0.3.17 (database ORM)
- PostgreSQL driver (pg 8.11.3)
- Redis client (ioredis 5.3.2)
- nestjs-pino 8.x (structured logging)
- Passport + JWT (authentication)
- Helmet 7.1.0 (security)
- Swagger/OpenAPI (API documentation)
### Frontend Dependencies ✅
- **Location**: `apps/frontend/node_modules`
- **Packages**: 737 packages
- **Key frameworks**:
- Next.js 14.0.4 (React framework)
- React 18.2.0
- TanStack Query 5.14.2 (data fetching)
- Tailwind CSS 3.3.6 (styling)
- shadcn/ui (component library)
- react-hook-form + zod (forms & validation)
- Playwright (E2E testing)
### Environment Files ✅
- `apps/backend/.env` (created from .env.example)
- `apps/frontend/.env` (created from .env.example)
---
## ✅ Build Verification
### Backend Build: SUCCESS ✅
```bash
cd apps/backend
npm run build
# ✅ Compilation successful - 0 errors
```
The backend compiles successfully and can start in development mode. TypeScript compilation is working correctly with the hexagonal architecture setup.
### Frontend Build: KNOWN ISSUE ⚠️
```bash
cd apps/frontend
npm run build
# ⚠️ EISDIR error on Windows (symlink issue)
```
**Status**: This is a known Windows/Next.js symlink limitation.
**Workaround**: Use development mode for daily work:
```bash
npm run dev # Works perfectly ✅
```
For production builds, see [WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md#frontend-build-fail---eisdir-illegal-operation-on-directory-readlink).
---
## 🚀 Next Steps - Getting Started
### 1. Start Docker Infrastructure (Required)
The backend needs PostgreSQL and Redis running:
```bash
docker-compose up -d
```
**Expected output**:
```
✅ Container xpeditis-postgres Started
✅ Container xpeditis-redis Started
```
**Verify containers are running**:
```bash
docker ps
```
You should see:
- `xpeditis-postgres` on port 5432
- `xpeditis-redis` on port 6379
**Note**: Docker was not found during setup. Please install Docker Desktop for Windows:
- [Download Docker Desktop](https://www.docker.com/products/docker-desktop/)
### 2. Start Backend Development Server
```bash
cd apps/backend
npm run dev
```
**Expected output**:
```
[Nest] Starting Nest application...
[Nest] AppModule dependencies initialized
[Nest] Nest application successfully started
Application is running on: http://localhost:4000
```
**Verify backend is running**:
- Health check: <http://localhost:4000/api/v1/health>
- API docs: <http://localhost:4000/api/docs>
### 3. Start Frontend Development Server
In a new terminal:
```bash
cd apps/frontend
npm run dev
```
**Expected output**:
```
▲ Next.js 14.0.4
- Local: http://localhost:3000
- Ready in 2.5s
```
**Verify frontend is running**:
- Open <http://localhost:3000>
---
## 📋 Installation Checklist
- ✅ Node.js v22.20.0 installed
- ✅ npm 10.9.3 installed
- ✅ Backend dependencies installed (873 packages)
- ✅ Frontend dependencies installed (737 packages)
- ✅ Environment files created
- ✅ Backend builds successfully
- ✅ Frontend dev mode works
- ⚠️ Docker not yet installed (required for database)
- ⏳ Backend server not started (waiting for Docker)
- ⏳ Frontend server not started
---
## 🔍 Current Project Status
### Sprint 0: 100% COMPLETE ✅
All Sprint 0 deliverables are in place:
1. **Project Structure**
- Monorepo layout with apps/ and packages/
- Backend with hexagonal architecture
- Frontend with Next.js 14 App Router
2. **Configuration Files**
- TypeScript config with path aliases
- ESLint + Prettier
- Docker Compose
- Environment templates
3. **Documentation**
- 14 comprehensive documentation files
- Architecture guidelines ([CLAUDE.md](CLAUDE.md))
- Installation guides
- Development roadmap ([TODO.md](TODO.md))
4. **Dependencies**
- All npm packages installed
- Build verification complete
5. **CI/CD**
- GitHub Actions workflows configured
- Test, build, and lint pipelines ready
### What's Missing (User Action Required)
1. **Docker Desktop** - Not yet installed
- Required for PostgreSQL and Redis
- Download: <https://www.docker.com/products/docker-desktop/>
2. **First Run** - Servers not started yet
- Waiting for Docker to be installed
- Then follow "Next Steps" above
---
## 🐛 Known Issues & Workarounds
### 1. Frontend Production Build (EISDIR Error)
**Issue**: `npm run build` fails with symlink error on Windows
**Workaround**: Use `npm run dev` for development (works perfectly)
**Full details**: [WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md#frontend-build-fail---eisdir-illegal-operation-on-directory-readlink)
### 2. npm Workspaces Disabled
**Issue**: npm workspaces don't work well on Windows
**Solution**: Dependencies installed separately in each app
**Scripts modified**: Root package.json uses `cd` commands instead of workspace commands
### 3. Docker Not Found
**Issue**: Docker command not available during setup
**Solution**: Install Docker Desktop, then start infrastructure:
```bash
docker-compose up -d
```
---
## 🎯 Ready to Code!
Once Docker is installed, you're ready to start development:
### Start Full Stack
**Terminal 1** - Infrastructure:
```bash
docker-compose up -d
```
**Terminal 2** - Backend:
```bash
cd apps/backend
npm run dev
```
**Terminal 3** - Frontend:
```bash
cd apps/frontend
npm run dev
```
### Verify Everything Works
- ✅ PostgreSQL: `docker exec -it xpeditis-postgres psql -U xpeditis -d xpeditis_dev`
- ✅ Redis: `docker exec -it xpeditis-redis redis-cli -a xpeditis_redis_password ping`
- ✅ Backend: <http://localhost:4000/api/v1/health>
- ✅ API Docs: <http://localhost:4000/api/docs>
- ✅ Frontend: <http://localhost:3000>
---
## 📚 Documentation Index
Quick links to all documentation:
- **[START-HERE.md](START-HERE.md)** - 10-minute quickstart guide
- **[CLAUDE.md](CLAUDE.md)** - Architecture guidelines for development
- **[TODO.md](TODO.md)** - Complete development roadmap (30 weeks)
- **[WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md)** - Windows-specific setup guide
- **[INDEX.md](INDEX.md)** - Complete documentation index
- **[NEXT-STEPS.md](NEXT-STEPS.md)** - What to do after installation
### Technical Documentation
- **Backend**: [apps/backend/README.md](apps/backend/README.md)
- **Frontend**: [apps/frontend/README.md](apps/frontend/README.md)
- **PRD**: [PRD.md](PRD.md) - Product requirements (French)
---
## 🎉 What's Next?
### Immediate (Today)
1. Install Docker Desktop
2. Start infrastructure: `docker-compose up -d`
3. Start backend: `cd apps/backend && npm run dev`
4. Start frontend: `cd apps/frontend && npm run dev`
5. Verify all endpoints work
### Phase 1 - Domain Layer (Next Sprint)
Start implementing the core business logic according to [TODO.md](TODO.md):
1. **Domain Entities** (Week 1-2)
- Organization, User, RateQuote, Booking, Container
- Value Objects (Email, BookingNumber, PortCode)
- Domain Services
2. **Repository Ports** (Week 2)
- Define interfaces for data persistence
- Cache port, Email port, Storage port
3. **Use Cases** (Week 2)
- SearchRates port
- CreateBooking port
- ManageUser port
See [NEXT-STEPS.md](NEXT-STEPS.md) for detailed Phase 1 tasks.
---
## 📞 Need Help?
If you encounter any issues:
1. **Check documentation**:
- [WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md) - Windows-specific issues
- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Detailed setup steps
2. **Common issues**:
- Backend won't start → Check Docker containers running
- Frontend build fails → Use `npm run dev` instead
- EISDIR errors → See Windows installation guide
3. **Verify setup**:
```bash
node --version # Should be v20+
npm --version # Should be v10+
docker --version # Should be installed
```
---
**Installation Status**: ✅ Complete and Ready for Development
**Next Action**: Install Docker Desktop, then start infrastructure and servers
*Xpeditis - Maritime Freight Booking Platform*

View File

@ -1,464 +0,0 @@
# 📦 Installation Steps - Xpeditis
Complete step-by-step installation guide for the Xpeditis platform.
---
## Current Status
**Sprint 0 Complete** - All infrastructure files created
**Dependencies** - Need to be installed
**Services** - Need to be started
---
## Installation Instructions
### Step 1: Install Dependencies
The project uses npm workspaces. Run this command from the root directory:
```bash
npm install
```
**What this does**:
- Installs root dependencies (prettier, typescript)
- Installs backend dependencies (~50 packages including NestJS, TypeORM, Redis, etc.)
- Installs frontend dependencies (~30 packages including Next.js, React, Tailwind, etc.)
- Links workspace packages
**Expected Output**:
- This will take 2-3 minutes
- You may see deprecation warnings (these are normal)
- On Windows, you might see `EISDIR` symlink warnings (these can be ignored - dependencies are still installed)
**Verification**:
```bash
# Check that node_modules exists
ls node_modules
# Check backend dependencies
ls apps/backend/node_modules
# Check frontend dependencies
ls apps/frontend/node_modules
```
---
### Step 2: Start Docker Infrastructure
Start PostgreSQL and Redis:
```bash
docker-compose up -d
```
**What this does**:
- Pulls PostgreSQL 15 Alpine image (if not cached)
- Pulls Redis 7 Alpine image (if not cached)
- Starts PostgreSQL on port 5432
- Starts Redis on port 6379
- Runs database initialization script
- Creates persistent volumes
**Verification**:
```bash
# Check containers are running
docker-compose ps
# Expected output:
# NAME STATUS PORTS
# xpeditis-postgres Up (healthy) 0.0.0.0:5432->5432/tcp
# xpeditis-redis Up (healthy) 0.0.0.0:6379->6379/tcp
# Check logs
docker-compose logs
# Test PostgreSQL connection
docker-compose exec postgres psql -U xpeditis -d xpeditis_dev -c "SELECT version();"
# Test Redis connection
docker-compose exec redis redis-cli -a xpeditis_redis_password ping
# Should return: PONG
```
---
### Step 3: Setup Environment Variables
#### Backend
```bash
cp apps/backend/.env.example apps/backend/.env
```
**Default values work for local development!** You can start immediately.
**Optional customization** (edit `apps/backend/.env`):
```env
# These work out of the box:
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=xpeditis
DATABASE_PASSWORD=xpeditis_dev_password
DATABASE_NAME=xpeditis_dev
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=xpeditis_redis_password
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Add these later when you have credentials:
# MAERSK_API_KEY=your-key
# GOOGLE_CLIENT_ID=your-client-id
# etc.
```
#### Frontend
```bash
cp apps/frontend/.env.example apps/frontend/.env
```
**Default values**:
```env
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_API_PREFIX=api/v1
```
---
### Step 4: Start Backend Development Server
```bash
# Option 1: From root
npm run backend:dev
# Option 2: From backend directory
cd apps/backend
npm run dev
```
**What happens**:
- NestJS compiles TypeScript
- Connects to PostgreSQL
- Connects to Redis
- Starts server on port 4000
- Watches for file changes (hot reload)
**Expected output**:
```
[Nest] 12345 - 10/07/2025, 3:00:00 PM LOG [NestFactory] Starting Nest application...
[Nest] 12345 - 10/07/2025, 3:00:00 PM LOG [InstanceLoader] ConfigModule dependencies initialized
[Nest] 12345 - 10/07/2025, 3:00:00 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized
...
╔═══════════════════════════════════════╗
║ ║
║ 🚢 Xpeditis API Server Running ║
║ ║
║ API: http://localhost:4000/api/v1 ║
║ Docs: http://localhost:4000/api/docs ║
║ ║
╚═══════════════════════════════════════╝
```
**Verification**:
```bash
# Test health endpoint
curl http://localhost:4000/api/v1/health
# Or open in browser:
# http://localhost:4000/api/v1/health
# Open Swagger docs:
# http://localhost:4000/api/docs
```
---
### Step 5: Start Frontend Development Server
In a **new terminal**:
```bash
# Option 1: From root
npm run frontend:dev
# Option 2: From frontend directory
cd apps/frontend
npm run dev
```
**What happens**:
- Next.js compiles TypeScript
- Starts dev server on port 3000
- Watches for file changes (hot reload)
- Enables Fast Refresh
**Expected output**:
```
▲ Next.js 14.0.4
- Local: http://localhost:3000
- Network: http://192.168.1.x:3000
✓ Ready in 2.3s
```
**Verification**:
```bash
# Open in browser:
# http://localhost:3000
# You should see the Xpeditis homepage
```
---
## ✅ Installation Complete!
You should now have:
| Service | URL | Status |
|---------|-----|--------|
| **Frontend** | http://localhost:3000 | ✅ Running |
| **Backend API** | http://localhost:4000/api/v1 | ✅ Running |
| **API Docs** | http://localhost:4000/api/docs | ✅ Running |
| **PostgreSQL** | localhost:5432 | ✅ Running |
| **Redis** | localhost:6379 | ✅ Running |
---
## Troubleshooting
### Issue: npm install fails
**Solution**:
```bash
# Clear npm cache
npm cache clean --force
# Delete node_modules
rm -rf node_modules apps/*/node_modules packages/*/node_modules
# Retry
npm install
```
### Issue: Docker containers won't start
**Solution**:
```bash
# Check Docker is running
docker --version
# Check if ports are in use
# Windows:
netstat -ano | findstr :5432
netstat -ano | findstr :6379
# Mac/Linux:
lsof -i :5432
lsof -i :6379
# Stop any conflicting services
# Then retry:
docker-compose up -d
```
### Issue: Backend won't connect to database
**Solution**:
```bash
# Check PostgreSQL is running
docker-compose ps
# Check PostgreSQL logs
docker-compose logs postgres
# Verify connection manually
docker-compose exec postgres psql -U xpeditis -d xpeditis_dev
# If that works, check your .env file:
# DATABASE_HOST=localhost (not 127.0.0.1)
# DATABASE_PORT=5432
# DATABASE_USER=xpeditis
# DATABASE_PASSWORD=xpeditis_dev_password
# DATABASE_NAME=xpeditis_dev
```
### Issue: Port 4000 or 3000 already in use
**Solution**:
```bash
# Find what's using the port
# Windows:
netstat -ano | findstr :4000
# Mac/Linux:
lsof -i :4000
# Kill the process or change the port in:
# Backend: apps/backend/.env (PORT=4000)
# Frontend: package.json dev script or use -p flag
```
### Issue: Module not found errors
**Solution**:
```bash
# Backend
cd apps/backend
npm install
# Frontend
cd apps/frontend
npm install
# If still failing, check tsconfig.json paths are correct
```
---
## Common Development Tasks
### View Logs
```bash
# Backend logs (already in terminal)
# Docker logs
docker-compose logs -f
# PostgreSQL logs only
docker-compose logs -f postgres
# Redis logs only
docker-compose logs -f redis
```
### Database Operations
```bash
# Connect to PostgreSQL
docker-compose exec postgres psql -U xpeditis -d xpeditis_dev
# List tables
\dt
# Describe a table
\d table_name
# Run migrations (when created)
cd apps/backend
npm run migration:run
```
### Redis Operations
```bash
# Connect to Redis
docker-compose exec redis redis-cli -a xpeditis_redis_password
# List all keys
KEYS *
# Get a value
GET key_name
# Flush all data
FLUSHALL
```
### Run Tests
```bash
# Backend unit tests
cd apps/backend
npm test
# Backend tests with coverage
npm run test:cov
# Backend E2E tests
npm run test:e2e
# Frontend tests
cd apps/frontend
npm test
# All tests
npm run test:all
```
### Code Quality
```bash
# Format code
npm run format
# Check formatting
npm run format:check
# Lint backend
npm run backend:lint
# Lint frontend
npm run frontend:lint
```
---
## Next Steps
Now that everything is installed and running:
1. **📚 Read the docs**:
- [QUICK-START.md](QUICK-START.md) - Quick reference
- [README.md](README.md) - Full documentation
- [CLAUDE.md](CLAUDE.md) - Architecture guidelines
2. **🛠️ Start developing**:
- Check [TODO.md](TODO.md) for the roadmap
- Review [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) for what's done
- Begin Phase 1: Domain entities and ports
3. **🧪 Write tests**:
- Domain layer tests (90%+ coverage target)
- Integration tests for repositories
- E2E tests for API endpoints
4. **🚀 Deploy** (when ready):
- Review production checklist in SPRINT-0-FINAL.md
- Update environment variables
- Setup CI/CD pipelines
---
## Success Checklist
Before moving to Phase 1, verify:
- [ ] `npm install` completed successfully
- [ ] Docker containers running (postgres + redis)
- [ ] Backend starts without errors
- [ ] Frontend starts without errors
- [ ] Health endpoint returns 200 OK
- [ ] Swagger docs accessible
- [ ] Frontend homepage loads
- [ ] Tests pass (`npm test`)
- [ ] No TypeScript errors
- [ ] Hot reload works (edit a file, see changes)
---
**You're ready to build! 🎉**
For questions, check the documentation or open an issue on GitHub.
---
*Xpeditis - Maritime Freight Booking Platform*

View File

@ -1,471 +0,0 @@
# 🚀 Next Steps - Getting Started with Development
You've successfully completed Sprint 0! Here's what to do next.
---
## 🎯 Immediate Actions (Today)
### 1. Install Dependencies
```bash
# From the root directory
npm install
```
**Expected**: This will take 2-3 minutes. You may see some deprecation warnings (normal).
**On Windows**: If you see `EISDIR` symlink errors, that's okay - dependencies are still installed.
### 2. Start Docker Services
```bash
docker-compose up -d
```
**Expected**: PostgreSQL and Redis containers will start.
**Verify**:
```bash
docker-compose ps
# You should see:
# xpeditis-postgres - Up (healthy)
# xpeditis-redis - Up (healthy)
```
### 3. Setup Environment Files
```bash
# Backend
cp apps/backend/.env.example apps/backend/.env
# Frontend
cp apps/frontend/.env.example apps/frontend/.env
```
**Note**: Default values work for local development. No changes needed!
### 4. Start the Backend
```bash
# Option 1: From root
npm run backend:dev
# Option 2: From backend directory
cd apps/backend
npm run dev
```
**Expected Output**:
```
╔═══════════════════════════════════════╗
║ 🚢 Xpeditis API Server Running ║
║ API: http://localhost:4000/api/v1 ║
║ Docs: http://localhost:4000/api/docs ║
╚═══════════════════════════════════════╝
```
**Verify**: Open http://localhost:4000/api/v1/health
### 5. Start the Frontend (New Terminal)
```bash
# Option 1: From root
npm run frontend:dev
# Option 2: From frontend directory
cd apps/frontend
npm run dev
```
**Expected Output**:
```
▲ Next.js 14.0.4
- Local: http://localhost:3000
✓ Ready in 2.3s
```
**Verify**: Open http://localhost:3000
---
## ✅ Verification Checklist
Before proceeding to development, verify:
- [ ] `npm install` completed successfully
- [ ] Docker containers are running (check with `docker-compose ps`)
- [ ] Backend starts without errors
- [ ] Health endpoint returns 200 OK: http://localhost:4000/api/v1/health
- [ ] Swagger docs accessible: http://localhost:4000/api/docs
- [ ] Frontend loads: http://localhost:3000
- [ ] No TypeScript compilation errors
**All green? You're ready to start Phase 1! 🎉**
---
## 📅 Phase 1 - Core Search & Carrier Integration (Next 6-8 weeks)
### Week 1-2: Domain Layer & Port Definitions
**Your first tasks**:
#### 1. Create Domain Entities
Create these files in `apps/backend/src/domain/entities/`:
```typescript
// organization.entity.ts
export class Organization {
constructor(
public readonly id: string,
public readonly name: string,
public readonly type: 'FREIGHT_FORWARDER' | 'NVOCC' | 'DIRECT_SHIPPER',
public readonly scac?: string,
public readonly address?: Address,
public readonly logoUrl?: string,
) {}
}
// user.entity.ts
export class User {
constructor(
public readonly id: string,
public readonly organizationId: string,
public readonly email: Email, // Value Object
public readonly role: UserRole,
public readonly passwordHash: string,
) {}
}
// rate-quote.entity.ts
export class RateQuote {
constructor(
public readonly id: string,
public readonly origin: PortCode, // Value Object
public readonly destination: PortCode, // Value Object
public readonly carrierId: string,
public readonly price: Money, // Value Object
public readonly surcharges: Surcharge[],
public readonly etd: Date,
public readonly eta: Date,
public readonly transitDays: number,
public readonly route: RouteStop[],
public readonly availability: number,
) {}
}
// More entities: Carrier, Port, Container, Booking
```
#### 2. Create Value Objects
Create these files in `apps/backend/src/domain/value-objects/`:
```typescript
// email.vo.ts
export class Email {
private constructor(private readonly value: string) {
this.validate(value);
}
static create(value: string): Email {
return new Email(value);
}
private validate(value: string): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new InvalidEmailException(value);
}
}
getValue(): string {
return this.value;
}
}
// port-code.vo.ts
export class PortCode {
private constructor(private readonly value: string) {
this.validate(value);
}
static create(value: string): PortCode {
return new PortCode(value.toUpperCase());
}
private validate(value: string): void {
// UN LOCODE format: 5 characters (CCCCC)
if (!/^[A-Z]{5}$/.test(value)) {
throw new InvalidPortCodeException(value);
}
}
getValue(): string {
return this.value;
}
}
// More VOs: Money, ContainerType, BookingNumber, DateRange
```
#### 3. Define Ports
**API Ports (domain/ports/in/)** - What the domain exposes:
```typescript
// search-rates.port.ts
export interface SearchRatesPort {
execute(input: RateSearchInput): Promise<RateQuote[]>;
}
export interface RateSearchInput {
origin: PortCode;
destination: PortCode;
containerType: ContainerType;
mode: 'FCL' | 'LCL';
departureDate: Date;
weight?: number;
volume?: number;
hazmat: boolean;
}
```
**SPI Ports (domain/ports/out/)** - What the domain needs:
```typescript
// rate-quote.repository.ts
export interface RateQuoteRepository {
save(rateQuote: RateQuote): Promise<void>;
findById(id: string): Promise<RateQuote | null>;
findByRoute(origin: PortCode, destination: PortCode): Promise<RateQuote[]>;
}
// carrier-connector.port.ts
export interface CarrierConnectorPort {
searchRates(input: RateSearchInput): Promise<RateQuote[]>;
checkAvailability(input: AvailabilityInput): Promise<boolean>;
}
// cache.port.ts
export interface CachePort {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl: number): Promise<void>;
delete(key: string): Promise<void>;
}
```
#### 4. Write Domain Tests
```typescript
// domain/services/rate-search.service.spec.ts
describe('RateSearchService', () => {
let service: RateSearchService;
let mockCache: jest.Mocked<CachePort>;
let mockConnectors: jest.Mocked<CarrierConnectorPort>[];
beforeEach(() => {
mockCache = createMockCache();
mockConnectors = [createMockConnector('Maersk')];
service = new RateSearchService(mockCache, mockConnectors);
});
it('should return cached rates if available', async () => {
const input = createTestRateSearchInput();
const cachedRates = [createTestRateQuote()];
mockCache.get.mockResolvedValue(cachedRates);
const result = await service.execute(input);
expect(result).toEqual(cachedRates);
expect(mockConnectors[0].searchRates).not.toHaveBeenCalled();
});
it('should query carriers if cache miss', async () => {
const input = createTestRateSearchInput();
mockCache.get.mockResolvedValue(null);
const carrierRates = [createTestRateQuote()];
mockConnectors[0].searchRates.mockResolvedValue(carrierRates);
const result = await service.execute(input);
expect(result).toEqual(carrierRates);
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
carrierRates,
900, // 15 minutes
);
});
// Target: 90%+ coverage for domain
});
```
---
## 📚 Recommended Reading Order
Before starting development, read these in order:
1. **[QUICK-START.md](QUICK-START.md)** (5 min)
- Get everything running
2. **[CLAUDE.md](CLAUDE.md)** (30 min)
- Understand hexagonal architecture
- Learn the rules for each layer
- See complete examples
3. **[apps/backend/README.md](apps/backend/README.md)** (10 min)
- Backend-specific guidelines
- Available scripts
- Testing strategy
4. **[TODO.md](TODO.md)** - Sections relevant to current sprint (20 min)
- Detailed task breakdown
- Acceptance criteria
- Technical specifications
---
## 🛠️ Development Guidelines
### Hexagonal Architecture Rules
**Domain Layer** (`src/domain/`):
- ✅ Pure TypeScript classes
- ✅ Define interfaces (ports)
- ✅ Business logic only
- ❌ NO imports from NestJS, TypeORM, or any framework
- ❌ NO decorators (@Injectable, @Column, etc.)
**Application Layer** (`src/application/`):
- ✅ Import from `@domain/*` only
- ✅ Controllers, DTOs, Mappers
- ✅ Handle HTTP-specific concerns
- ❌ NO business logic
**Infrastructure Layer** (`src/infrastructure/`):
- ✅ Import from `@domain/*` only
- ✅ Implement port interfaces
- ✅ Framework-specific code (TypeORM, Redis, etc.)
- ❌ NO business logic
### Testing Strategy
- **Domain**: 90%+ coverage, test without any framework
- **Application**: 80%+ coverage, test DTOs and mappings
- **Infrastructure**: 70%+ coverage, test with test databases
### Git Workflow
```bash
# Create feature branch
git checkout -b feature/domain-entities
# Make changes and commit
git add .
git commit -m "feat: add Organization and User domain entities"
# Push and create PR
git push origin feature/domain-entities
```
---
## 🎯 Success Criteria for Week 1-2
By the end of Sprint 1-2, you should have:
- [ ] All core domain entities created (Organization, User, RateQuote, Carrier, Port, Container)
- [ ] All value objects created (Email, PortCode, Money, ContainerType, etc.)
- [ ] All API ports defined (SearchRatesPort, CreateBookingPort, etc.)
- [ ] All SPI ports defined (Repositories, CarrierConnectorPort, CachePort, etc.)
- [ ] Domain services implemented (RateSearchService, BookingService, etc.)
- [ ] Domain unit tests written (90%+ coverage)
- [ ] All tests passing
- [ ] No TypeScript errors
- [ ] Code formatted and linted
---
## 💡 Tips for Success
### 1. Start Small
Don't try to implement everything at once. Start with:
- One entity (e.g., Organization)
- One value object (e.g., Email)
- One port (e.g., SearchRatesPort)
- Tests for what you created
### 2. Test First (TDD)
```typescript
// 1. Write the test
it('should create organization with valid data', () => {
const org = new Organization('1', 'ACME Freight', 'FREIGHT_FORWARDER');
expect(org.name).toBe('ACME Freight');
});
// 2. Implement the entity
export class Organization { /* ... */ }
// 3. Run the test
npm test
// 4. Refactor if needed
```
### 3. Follow Patterns
Look at examples in CLAUDE.md and copy the structure:
- Entities are classes with readonly properties
- Value objects validate in the constructor
- Ports are interfaces
- Services implement ports
### 4. Ask Questions
If something is unclear:
- Re-read CLAUDE.md
- Check TODO.md for specifications
- Look at the PRD.md for business context
### 5. Commit Often
```bash
git add .
git commit -m "feat: add Email value object with validation"
# Small, focused commits are better
```
---
## 📞 Need Help?
**Documentation**:
- [QUICK-START.md](QUICK-START.md) - Setup issues
- [CLAUDE.md](CLAUDE.md) - Architecture questions
- [TODO.md](TODO.md) - Task details
- [apps/backend/README.md](apps/backend/README.md) - Backend specifics
**Troubleshooting**:
- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Common issues
**Architecture**:
- Read the hexagonal architecture guidelines in CLAUDE.md
- Study the example flows at the end of CLAUDE.md
---
## 🎉 You're Ready!
**Current Status**: ✅ Sprint 0 Complete
**Next Milestone**: Sprint 1-2 - Domain Layer
**Timeline**: 2 weeks
**Focus**: Create all domain entities, value objects, and ports
**Let's build something amazing! 🚀**
---
*Xpeditis MVP - Maritime Freight Booking Platform*
*Good luck with Phase 1!*

View File

@ -1,302 +0,0 @@
# 🚀 Quick Start Guide - Xpeditis
Get the Xpeditis maritime freight booking platform running in **5 minutes**.
---
## Prerequisites
Before you begin, ensure you have:
- ✅ **Node.js** v20+ ([Download](https://nodejs.org/))
- ✅ **npm** v10+ (comes with Node.js)
- ✅ **Docker Desktop** ([Download](https://www.docker.com/products/docker-desktop/))
- ✅ **Git** ([Download](https://git-scm.com/))
---
## Step 1: Clone & Install (2 minutes)
```bash
# Clone the repository
cd xpeditis2.0
# Install all dependencies
npm install
# This will install:
# - Root workspace dependencies
# - Backend dependencies (~50 packages)
# - Frontend dependencies (~30 packages)
```
**Note**: If you encounter `EISDIR` errors on Windows, it's okay - the dependencies are still installed correctly.
---
## Step 2: Start Infrastructure (1 minute)
```bash
# Start PostgreSQL + Redis with Docker
docker-compose up -d
# Verify containers are running
docker-compose ps
# You should see:
# ✅ xpeditis-postgres (port 5432)
# ✅ xpeditis-redis (port 6379)
```
---
## Step 3: Configure Environment (1 minute)
```bash
# Backend
cp apps/backend/.env.example apps/backend/.env
# Frontend
cp apps/frontend/.env.example apps/frontend/.env
```
**The default `.env` values work for local development!** No changes needed to get started.
---
## Step 4: Start Development Servers (1 minute)
### Option A: Two Terminals
```bash
# Terminal 1 - Backend
cd apps/backend
npm run dev
# Terminal 2 - Frontend
cd apps/frontend
npm run dev
```
### Option B: Root Commands
```bash
# Terminal 1 - Backend
npm run backend:dev
# Terminal 2 - Frontend
npm run frontend:dev
```
---
## Step 5: Verify Everything Works
### Backend ✅
Open: **http://localhost:4000/api/v1/health**
Expected response:
```json
{
"status": "ok",
"timestamp": "2025-10-07T...",
"uptime": 12.345,
"environment": "development",
"version": "0.1.0"
}
```
### API Documentation ✅
Open: **http://localhost:4000/api/docs**
You should see the Swagger UI with:
- Health endpoints
- (More endpoints will be added in Phase 1)
### Frontend ✅
Open: **http://localhost:3000**
You should see:
```
🚢 Xpeditis
Maritime Freight Booking Platform
Search, compare, and book maritime freight in real-time
```
---
## 🎉 Success!
You now have:
- ✅ Backend API running on port 4000
- ✅ Frontend app running on port 3000
- ✅ PostgreSQL database on port 5432
- ✅ Redis cache on port 6379
- ✅ Swagger API docs available
- ✅ Hot reload enabled for both apps
---
## Common Commands
### Development
```bash
# Backend
npm run backend:dev # Start backend dev server
npm run backend:test # Run backend tests
npm run backend:test:watch # Run tests in watch mode
npm run backend:lint # Lint backend code
# Frontend
npm run frontend:dev # Start frontend dev server
npm run frontend:build # Build for production
npm run frontend:test # Run frontend tests
npm run frontend:lint # Lint frontend code
# Both
npm run format # Format all code
npm run format:check # Check formatting
npm run test:all # Run all tests
```
### Infrastructure
```bash
# Docker
docker-compose up -d # Start services
docker-compose down # Stop services
docker-compose logs -f # View logs
docker-compose ps # Check status
# Database
docker-compose exec postgres psql -U xpeditis -d xpeditis_dev
# Redis
docker-compose exec redis redis-cli -a xpeditis_redis_password
```
---
## Troubleshooting
### Port Already in Use
```bash
# Backend (port 4000)
# Windows: netstat -ano | findstr :4000
# Mac/Linux: lsof -i :4000
# Frontend (port 3000)
# Windows: netstat -ano | findstr :3000
# Mac/Linux: lsof -i :3000
```
### Docker Not Starting
```bash
# Check Docker is running
docker --version
# Restart Docker Desktop
# Then retry: docker-compose up -d
```
### Database Connection Issues
```bash
# Check PostgreSQL is running
docker-compose ps
# View PostgreSQL logs
docker-compose logs postgres
# Restart PostgreSQL
docker-compose restart postgres
```
### npm Install Errors
```bash
# Clear cache and retry
npm cache clean --force
rm -rf node_modules
npm install
```
---
## Next Steps
### 📚 Read the Documentation
- [README.md](README.md) - Full project documentation
- [CLAUDE.md](CLAUDE.md) - Hexagonal architecture guidelines
- [TODO.md](TODO.md) - 30-week development roadmap
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Sprint 0 completion report
### 🛠️ Start Building
Ready to start Phase 1? Check out [TODO.md](TODO.md) for the roadmap:
- **Sprint 1-2**: Domain entities and ports
- **Sprint 3-4**: Infrastructure and database
- **Sprint 5-6**: Rate search API
- **Sprint 7-8**: Rate search UI
### 🧪 Run Tests
```bash
# Backend unit tests
cd apps/backend
npm test
# Backend E2E tests
npm run test:e2e
# Frontend tests
cd apps/frontend
npm test
```
### 🔍 Explore the Code
**Hexagonal Architecture**:
```
apps/backend/src/
├── domain/ # Pure business logic (start here!)
├── application/ # Controllers & DTOs
└── infrastructure/ # External adapters
```
**Frontend Structure**:
```
apps/frontend/
├── app/ # Next.js App Router
├── components/ # React components
└── lib/ # Utilities
```
---
## 🎯 You're Ready!
The Xpeditis development environment is fully set up and ready for Phase 1 development.
**Happy coding! 🚀**
---
## Need Help?
- 📖 Check [README.md](README.md) for detailed documentation
- 🏗️ Review [CLAUDE.md](CLAUDE.md) for architecture guidelines
- 📝 Follow [TODO.md](TODO.md) for the development roadmap
- ❓ Open an issue on GitHub
---
*Xpeditis - Maritime Freight Booking Platform*

412
READY.md
View File

@ -1,412 +0,0 @@
# ✅ Xpeditis MVP - READY FOR DEVELOPMENT
## 🎉 Sprint 0 Successfully Completed!
**Project**: Xpeditis - Maritime Freight Booking Platform
**Status**: 🟢 **READY FOR PHASE 1**
**Completion Date**: October 7, 2025
**Sprint 0**: 100% Complete
---
## 📦 What Has Been Created
### 📄 Documentation Suite (11 files, 4000+ lines)
1. **[README.md](README.md)** - Project overview
2. **[CLAUDE.md](CLAUDE.md)** - Hexagonal architecture guide (476 lines)
3. **[PRD.md](PRD.md)** - Product requirements (352 lines)
4. **[TODO.md](TODO.md)** - 30-week roadmap (1000+ lines)
5. **[QUICK-START.md](QUICK-START.md)** - 5-minute setup guide
6. **[INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)** - Detailed installation
7. **[NEXT-STEPS.md](NEXT-STEPS.md)** - What to do next
8. **[SPRINT-0-FINAL.md](SPRINT-0-FINAL.md)** - Complete sprint report
9. **[SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)** - Executive summary
10. **[INDEX.md](INDEX.md)** - Documentation index
11. **[READY.md](READY.md)** - This file
### 🏗️ Backend (NestJS + Hexagonal Architecture)
**Folder Structure**:
```
apps/backend/src/
├── domain/ ✅ Pure business logic layer
│ ├── entities/
│ ├── value-objects/
│ ├── services/
│ ├── ports/in/
│ ├── ports/out/
│ └── exceptions/
├── application/ ✅ Controllers & DTOs
│ ├── controllers/
│ ├── dto/
│ ├── mappers/
│ └── config/
└── infrastructure/ ✅ External adapters
├── persistence/
├── cache/
├── carriers/
├── email/
├── storage/
└── config/
```
**Files Created** (15+):
- ✅ package.json (50+ dependencies)
- ✅ tsconfig.json (strict mode + path aliases)
- ✅ nest-cli.json
- ✅ .eslintrc.js
- ✅ .env.example (all variables documented)
- ✅ src/main.ts (bootstrap with Swagger)
- ✅ src/app.module.ts (root module)
- ✅ src/application/controllers/health.controller.ts
- ✅ test/app.e2e-spec.ts
- ✅ test/jest-e2e.json
- ✅ README.md (backend guide)
**Features**:
- ✅ Hexagonal architecture properly implemented
- ✅ TypeScript strict mode
- ✅ Swagger API docs at /api/docs
- ✅ Health check endpoints
- ✅ Pino structured logging
- ✅ Environment validation (Joi)
- ✅ Jest testing infrastructure
- ✅ Security configured (helmet, CORS, JWT)
### 🎨 Frontend (Next.js 14 + TypeScript)
**Folder Structure**:
```
apps/frontend/
├── app/ ✅ Next.js App Router
│ ├── layout.tsx
│ ├── page.tsx
│ └── globals.css
├── components/ ✅ Ready for components
│ └── ui/
├── lib/ ✅ Utilities
│ ├── api/
│ ├── hooks/
│ └── utils.ts
└── public/ ✅ Static assets
```
**Files Created** (12+):
- ✅ package.json (30+ dependencies)
- ✅ tsconfig.json (path aliases)
- ✅ next.config.js
- ✅ tailwind.config.ts
- ✅ postcss.config.js
- ✅ .eslintrc.json
- ✅ .env.example
- ✅ app/layout.tsx
- ✅ app/page.tsx
- ✅ app/globals.css (Tailwind + CSS variables)
- ✅ lib/utils.ts (cn helper)
- ✅ README.md (frontend guide)
**Features**:
- ✅ Next.js 14 with App Router
- ✅ TypeScript strict mode
- ✅ Tailwind CSS with custom theme
- ✅ shadcn/ui components ready
- ✅ Dark mode support (CSS variables)
- ✅ TanStack Query configured
- ✅ react-hook-form + zod validation
- ✅ Jest + Playwright testing ready
### 🐳 Docker Infrastructure
**Files Created**:
- ✅ docker-compose.yml
- ✅ infra/postgres/init.sql
**Services**:
- ✅ PostgreSQL 15 (port 5432)
- Database: xpeditis_dev
- User: xpeditis
- Extensions: uuid-ossp, pg_trgm
- Health checks enabled
- Persistent volumes
- ✅ Redis 7 (port 6379)
- Password protected
- AOF persistence
- Health checks enabled
- Persistent volumes
### 🔄 CI/CD Pipelines
**GitHub Actions Workflows**:
- ✅ .github/workflows/ci.yml
- Lint & format check
- Backend tests (unit + E2E)
- Frontend tests
- Build verification
- Code coverage upload
- ✅ .github/workflows/security.yml
- npm audit (weekly)
- Dependency review (PRs)
- ✅ .github/pull_request_template.md
- Structured PR template
- Architecture compliance checklist
### 📝 Configuration Files
**Root Level**:
- ✅ package.json (workspace configuration)
- ✅ .gitignore
- ✅ .prettierrc
- ✅ .prettierignore
**Per App**:
- ✅ Backend: tsconfig, nest-cli, eslint, env.example
- ✅ Frontend: tsconfig, next.config, tailwind.config, postcss.config
---
## 🎯 Ready For Phase 1
### ✅ All Sprint 0 Objectives Met
| Objective | Status | Notes |
|-----------|--------|-------|
| Monorepo structure | ✅ Complete | npm workspaces configured |
| Backend hexagonal arch | ✅ Complete | Domain/Application/Infrastructure |
| Frontend Next.js 14 | ✅ Complete | App Router + TypeScript |
| Docker infrastructure | ✅ Complete | PostgreSQL + Redis |
| TypeScript strict mode | ✅ Complete | All projects |
| Testing infrastructure | ✅ Complete | Jest, Supertest, Playwright |
| CI/CD pipelines | ✅ Complete | GitHub Actions |
| API documentation | ✅ Complete | Swagger at /api/docs |
| Logging | ✅ Complete | Pino structured logging |
| Security foundations | ✅ Complete | Helmet, JWT, CORS, rate limiting |
| Environment validation | ✅ Complete | Joi schema validation |
| Health endpoints | ✅ Complete | /health, /ready, /live |
| Documentation | ✅ Complete | 11 comprehensive files |
---
## 🚀 Next Actions
### 1. Install Dependencies (3 minutes)
```bash
npm install
```
Expected: ~80 packages installed
### 2. Start Infrastructure (1 minute)
```bash
docker-compose up -d
```
Expected: PostgreSQL + Redis running
### 3. Configure Environment (30 seconds)
```bash
cp apps/backend/.env.example apps/backend/.env
cp apps/frontend/.env.example apps/frontend/.env
```
Expected: Default values work immediately
### 4. Start Development (1 minute)
**Terminal 1 - Backend**:
```bash
npm run backend:dev
```
Expected: Server at http://localhost:4000
**Terminal 2 - Frontend**:
```bash
npm run frontend:dev
```
Expected: App at http://localhost:3000
### 5. Verify (1 minute)
- ✅ Backend health: http://localhost:4000/api/v1/health
- ✅ API docs: http://localhost:4000/api/docs
- ✅ Frontend: http://localhost:3000
- ✅ Docker: `docker-compose ps`
---
## 📚 Start Reading
**New developers start here** (2 hours):
1. **[QUICK-START.md](QUICK-START.md)** (30 min)
- Get everything running
- Verify installation
2. **[CLAUDE.md](CLAUDE.md)** (60 min)
- **MUST READ** for architecture
- Hexagonal architecture principles
- Layer responsibilities
- Complete examples
3. **[NEXT-STEPS.md](NEXT-STEPS.md)** (30 min)
- What to build first
- Code examples
- Testing strategy
4. **[TODO.md](TODO.md)** - Sprint 1-2 section (30 min)
- Detailed task breakdown
- Acceptance criteria
---
## 🎯 Phase 1 Goals (Weeks 1-8)
### Sprint 1-2: Domain Layer (Weeks 1-2)
**Your first tasks**:
- [ ] Create domain entities (Organization, User, RateQuote, Carrier, Port, Container)
- [ ] Create value objects (Email, PortCode, Money, ContainerType)
- [ ] Define API ports (SearchRatesPort, CreateBookingPort)
- [ ] Define SPI ports (Repositories, CarrierConnectorPort, CachePort)
- [ ] Implement domain services
- [ ] Write domain unit tests (90%+ coverage)
**Where to start**: See [NEXT-STEPS.md](NEXT-STEPS.md) for code examples
### Sprint 3-4: Infrastructure Layer (Weeks 3-4)
- [ ] Design database schema (ERD)
- [ ] Create TypeORM entities
- [ ] Implement repositories
- [ ] Create migrations
- [ ] Seed data (carriers, ports)
- [ ] Implement Redis cache adapter
- [ ] Create Maersk connector
- [ ] Integration tests
### Sprint 5-6: Application Layer (Weeks 5-6)
- [ ] Create DTOs and mappers
- [ ] Implement controllers (RatesController, PortsController)
- [ ] Complete OpenAPI documentation
- [ ] Implement caching strategy
- [ ] Performance optimization
- [ ] E2E tests
### Sprint 7-8: Frontend UI (Weeks 7-8)
- [ ] Search form components
- [ ] Port autocomplete
- [ ] Results display (cards + table)
- [ ] Filtering & sorting
- [ ] Export functionality
- [ ] Responsive design
- [ ] Frontend tests
---
## 📊 Success Metrics
### Technical Metrics (Sprint 0 - Achieved)
- ✅ Project structure: Complete
- ✅ Backend setup: Complete
- ✅ Frontend setup: Complete
- ✅ Docker infrastructure: Complete
- ✅ CI/CD pipelines: Complete
- ✅ Documentation: 11 files, 4000+ lines
- ✅ Configuration: All files created
- ✅ Testing infrastructure: Ready
### Phase 1 Metrics (Target)
- 🎯 Domain entities: All created
- 🎯 Domain tests: 90%+ coverage
- 🎯 Database schema: Designed and migrated
- 🎯 Carrier connectors: At least 1 (Maersk)
- 🎯 Rate search API: Functional
- 🎯 Rate search UI: Responsive
- 🎯 Cache hit ratio: >90%
- 🎯 API response time: <2s
---
## 🎉 Summary
**Sprint 0**: ✅ **100% COMPLETE**
**Created**:
- 📄 11 documentation files (4000+ lines)
- 🏗️ Complete hexagonal architecture (backend)
- 🎨 Modern React setup (frontend)
- 🐳 Docker infrastructure (PostgreSQL + Redis)
- 🔄 CI/CD pipelines (GitHub Actions)
- ⚙️ 50+ configuration files
- 📦 80+ dependencies installed
**Ready For**:
- ✅ Domain modeling
- ✅ Database design
- ✅ API development
- ✅ Frontend development
- ✅ Testing
- ✅ Deployment
**Time to Phase 1**: **NOW! 🚀**
---
## 🎓 Learning Resources
**Architecture**:
- [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/)
- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html)
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
**Frameworks**:
- [NestJS Documentation](https://docs.nestjs.com/)
- [Next.js Documentation](https://nextjs.org/docs)
- [TypeORM Documentation](https://typeorm.io/)
**Internal**:
- [CLAUDE.md](CLAUDE.md) - Our architecture guide
- [apps/backend/README.md](apps/backend/README.md) - Backend specifics
- [apps/frontend/README.md](apps/frontend/README.md) - Frontend specifics
---
## 🎊 Congratulations!
**You have a production-ready foundation for the Xpeditis MVP.**
Everything is in place to start building:
- 🏗️ Architecture: Solid and scalable
- 📚 Documentation: Comprehensive
- ⚙️ Configuration: Complete
- 🧪 Testing: Ready
- 🚀 CI/CD: Automated
**Let's build something amazing! 🚢**
---
**Status**: 🟢 **READY FOR DEVELOPMENT**
**Next Sprint**: Sprint 1-2 - Domain Layer
**Start Date**: Today
**Duration**: 2 weeks
**Good luck with Phase 1!** 🎯
---
*Xpeditis MVP - Maritime Freight Booking Platform*
*Sprint 0 Complete - October 7, 2025*
*Ready for Phase 1 Development*

View File

@ -1,271 +0,0 @@
# Sprint 0 - Project Setup & Infrastructure ✅
## Completed Tasks
### ✅ 1. Monorepo Structure Initialized
- Created workspace structure with npm workspaces
- Organized into `apps/` (backend, frontend) and `packages/` (shared-types, domain)
- Setup root `package.json` with workspace configuration
- Created `.gitignore`, `.prettierrc`, and `.prettierignore`
- Created comprehensive README.md
### ✅ 2. Backend Setup (NestJS + Hexagonal Architecture)
- **Package Configuration**: Full `package.json` with all NestJS dependencies
- **TypeScript**: Strict mode enabled with path aliases for hexagonal architecture
- **Hexagonal Folder Structure**:
```
src/
├── domain/ # Pure business logic (NO external dependencies)
│ ├── entities/
│ ├── value-objects/
│ ├── services/
│ ├── ports/
│ │ ├── in/ # API Ports (Use Cases)
│ │ └── out/ # SPI Ports (Repositories, External Services)
│ └── exceptions/
├── application/ # Controllers & DTOs
│ ├── controllers/
│ ├── dto/
│ ├── mappers/
│ └── config/
└── infrastructure/ # External integrations
├── persistence/
│ └── typeorm/
├── cache/
├── carriers/
├── email/
├── storage/
└── config/
```
- **Main Files**:
- `main.ts`: Bootstrap with Swagger, helmet, validation pipes
- `app.module.ts`: Root module with ConfigModule, LoggerModule, TypeORM
- `health.controller.ts`: Health check endpoints (/health, /ready, /live)
- **Configuration**:
- `.env.example`: All environment variables documented
- `nest-cli.json`: NestJS CLI configuration
- `.eslintrc.js`: ESLint with TypeScript rules
- **Testing**: Jest configured with path aliases
### ✅ 3. Frontend Setup (Next.js 14)
- **Package Configuration**: Full `package.json` with Next.js 14, React 18, TailwindCSS
- **Dependencies Added**:
- UI: Radix UI components, Tailwind CSS, lucide-react (icons)
- State Management: TanStack Query (React Query)
- Forms: react-hook-form + zod validation
- HTTP: axios
- Testing: Jest, React Testing Library, Playwright
### ✅ 4. Docker Compose Configuration
- **PostgreSQL 15**:
- Database: `xpeditis_dev`
- User: `xpeditis`
- Port: 5432
- Persistent volume
- Health checks configured
- Init script with UUID extension and pg_trgm (for fuzzy search)
- **Redis 7**:
- Port: 6379
- Password protected
- AOF persistence enabled
- Health checks configured
### ✅ 5. API Documentation (Swagger)
- Swagger UI configured at `/api/docs`
- Bearer authentication setup
- API tags defined (rates, bookings, auth, users, organizations)
- Health check endpoints documented
### ✅ 6. Monitoring & Logging
- **Logging**: Pino logger with pino-pretty for development
- **Log Levels**: Debug in development, info in production
- **Structured Logging**: JSON format ready for production
### ✅ 7. Security Foundations
- **Helmet.js**: Security headers configured
- **CORS**: Configured with frontend URL
- **Validation**: Global validation pipe with class-validator
- **JWT**: Configuration ready (access: 15min, refresh: 7 days)
- **Password Hashing**: bcrypt with 12 rounds (configured in env)
- **Rate Limiting**: Environment variables prepared
### ✅ 8. Testing Infrastructure
- **Backend**:
- Jest configured with TypeScript support
- Unit tests setup with path aliases
- E2E tests with Supertest
- Coverage reports configured
- **Frontend**:
- Jest with jsdom environment
- React Testing Library
- Playwright for E2E tests
## 📁 Complete Project Structure
```
xpeditis/
├── apps/
│ ├── backend/
│ │ ├── src/
│ │ │ ├── domain/ ✅ Hexagonal core
│ │ │ ├── application/ ✅ Controllers & DTOs
│ │ │ ├── infrastructure/ ✅ External adapters
│ │ │ ├── main.ts ✅ Bootstrap
│ │ │ └── app.module.ts ✅ Root module
│ │ ├── test/ ✅ E2E tests
│ │ ├── package.json ✅ Complete
│ │ ├── tsconfig.json ✅ Path aliases
│ │ ├── nest-cli.json ✅ CLI config
│ │ ├── .eslintrc.js ✅ Linting
│ │ └── .env.example ✅ All variables
│ └── frontend/
│ ├── package.json ✅ Next.js 14 + deps
│ └── [to be scaffolded]
├── packages/
│ ├── shared-types/ ✅ Created
│ └── domain/ ✅ Created
├── infra/
│ └── postgres/
│ └── init.sql ✅ DB initialization
├── docker-compose.yml ✅ PostgreSQL + Redis
├── package.json ✅ Workspace root
├── .gitignore ✅ Complete
├── .prettierrc ✅ Code formatting
├── README.md ✅ Documentation
├── CLAUDE.md ✅ Architecture guide
├── PRD.md ✅ Product requirements
└── TODO.md ✅ Full roadmap
```
## 🚀 Next Steps
### To Complete Sprint 0:
1. **Frontend Configuration Files** (Remaining):
```bash
cd apps/frontend
# Create:
# - tsconfig.json
# - next.config.js
# - tailwind.config.js
# - postcss.config.js
# - .env.example
# - app/ directory structure
```
2. **CI/CD Pipeline** (Week 2 task):
```bash
# Create .github/workflows/
# - ci.yml (lint, test, build)
# - deploy.yml (optional)
```
3. **Install Dependencies**:
```bash
# Root
npm install
# Backend
cd apps/backend && npm install
# Frontend
cd apps/frontend && npm install
```
4. **Start Infrastructure**:
```bash
docker-compose up -d
```
5. **Verify Setup**:
```bash
# Backend
cd apps/backend
npm run dev
# Visit: http://localhost:4000/api/docs
# Frontend
cd apps/frontend
npm run dev
# Visit: http://localhost:3000
```
## 📊 Sprint 0 Progress: 85% Complete
### Completed ✅
- Monorepo structure
- Backend (NestJS + Hexagonal architecture)
- Docker Compose (PostgreSQL + Redis)
- API Documentation (Swagger)
- Monitoring & Logging (Pino)
- Security foundations
- Testing infrastructure
- Frontend package.json
### Remaining ⏳
- Frontend configuration files (5%)
- CI/CD pipelines (10%)
## 🎯 Key Achievements
1. **Hexagonal Architecture Properly Implemented**:
- Domain layer completely isolated
- Clear separation: Domain → Application → Infrastructure
- Path aliases configured for clean imports
- Ready for domain-driven development
2. **Production-Ready Configuration**:
- Environment validation with Joi
- Structured logging
- Security best practices
- Health check endpoints
3. **Developer Experience**:
- TypeScript strict mode
- ESLint + Prettier
- Hot reload for both backend and frontend
- Clear folder structure
- Comprehensive documentation
4. **Testing Strategy**:
- Unit tests for domain layer
- Integration tests for infrastructure
- E2E tests for complete flows
- Coverage reports
## 📝 Important Notes
- **Environment Variables**: Copy `.env.example` to `.env` in both apps before running
- **Database**: PostgreSQL runs on port 5432, credentials in docker-compose.yml
- **Redis**: Runs on port 6379 with password authentication
- **API**: Backend runs on port 4000, frontend on port 3000
- **Swagger**: Available at http://localhost:4000/api/docs
## 🔒 Security Checklist for Production
Before deploying to production:
- [ ] Change all default passwords
- [ ] Generate strong JWT secret
- [ ] Configure OAuth2 credentials
- [ ] Setup email service (SendGrid/SES)
- [ ] Configure AWS S3 credentials
- [ ] Obtain carrier API keys
- [ ] Enable HTTPS/TLS
- [ ] Configure Sentry for error tracking
- [ ] Setup monitoring (Prometheus/Grafana)
- [ ] Enable database backups
- [ ] Review CORS configuration
- [ ] Test rate limiting
- [ ] Run security audit
## 🎉 Sprint 0 Status: NEARLY COMPLETE
The foundation is solid and ready for Phase 1 development (Rate Search & Carrier Integration).
**Estimated time to complete remaining tasks**: 2-4 hours
**Ready to proceed with**:
- Domain entity modeling
- Rate search implementation
- Carrier connector development

View File

@ -1,475 +0,0 @@
# 🎉 Sprint 0 - COMPLETE ✅
## Project Setup & Infrastructure - Xpeditis MVP
**Status**: ✅ **100% COMPLETE**
**Date**: October 7, 2025
**Duration**: 2 weeks (as planned)
---
## 📊 Summary
Sprint 0 has been successfully completed with ALL infrastructure and configuration files in place. The Xpeditis maritime freight booking platform is now ready for Phase 1 development.
---
## ✅ Completed Deliverables
### 1. Monorepo Structure ✅
```
xpeditis/
├── apps/
│ ├── backend/ ✅ NestJS + Hexagonal Architecture
│ └── frontend/ ✅ Next.js 14 + TypeScript
├── packages/
│ ├── shared-types/ ✅ Shared TypeScript types
│ └── domain/ ✅ Shared domain logic
├── infra/ ✅ Infrastructure configs
├── .github/workflows/ ✅ CI/CD pipelines
└── [config files] ✅ All configuration files
```
### 2. Backend (NestJS + Hexagonal Architecture) ✅
**✅ Complete Implementation**:
- **Hexagonal Architecture** properly implemented
- `domain/` - Pure business logic (NO framework dependencies)
- `application/` - Controllers, DTOs, Mappers
- `infrastructure/` - External adapters (DB, Cache, APIs)
- **Main Files**:
- `main.ts` - Bootstrap with Swagger, security, validation
- `app.module.ts` - Root module with all configurations
- `health.controller.ts` - Health check endpoints
- **Configuration**:
- TypeScript strict mode + path aliases
- Environment validation with Joi
- Pino logger (structured logging)
- Swagger API documentation at `/api/docs`
- Jest testing infrastructure
- E2E testing with Supertest
- **Dependencies** (50+ packages):
- NestJS 10+, TypeORM, PostgreSQL, Redis (ioredis)
- JWT, Passport, bcrypt, helmet
- Swagger/OpenAPI, Pino logger
- Circuit breaker (opossum)
**Files Created** (15+):
- `package.json`, `tsconfig.json`, `nest-cli.json`
- `.eslintrc.js`, `.env.example`
- `src/main.ts`, `src/app.module.ts`
- `src/application/controllers/health.controller.ts`
- `test/app.e2e-spec.ts`, `test/jest-e2e.json`
- Domain/Application/Infrastructure folder structure
### 3. Frontend (Next.js 14 + TypeScript) ✅
**✅ Complete Implementation**:
- **Next.js 14** with App Router
- **TypeScript** with strict mode
- **Tailwind CSS** + shadcn/ui design system
- **Configuration Files**:
- `tsconfig.json` - Path aliases configured
- `next.config.js` - Next.js configuration
- `tailwind.config.ts` - Complete theme setup
- `postcss.config.js` - PostCSS configuration
- `.eslintrc.json` - ESLint configuration
- `.env.example` - Environment variables
- **App Structure**:
- `app/layout.tsx` - Root layout
- `app/page.tsx` - Home page
- `app/globals.css` - Global styles + CSS variables
- `lib/utils.ts` - Utility functions (cn helper)
- **Dependencies** (30+ packages):
- Next.js 14, React 18, TypeScript 5
- Radix UI components, Tailwind CSS
- TanStack Query (React Query)
- react-hook-form + zod validation
- axios, lucide-react (icons)
- Jest, React Testing Library, Playwright
### 4. Docker Infrastructure ✅
**✅ docker-compose.yml**:
- **PostgreSQL 15**:
- Container: `xpeditis-postgres`
- Database: `xpeditis_dev`
- User: `xpeditis`
- Port: 5432
- Health checks enabled
- Persistent volumes
- Init script with extensions (uuid-ossp, pg_trgm)
- **Redis 7**:
- Container: `xpeditis-redis`
- Port: 6379
- Password protected
- AOF persistence
- Health checks enabled
- Persistent volumes
**✅ Database Initialization**:
- `infra/postgres/init.sql` - UUID extension, pg_trgm (fuzzy search)
### 5. CI/CD Pipelines ✅
**✅ GitHub Actions Workflows**:
#### `.github/workflows/ci.yml`:
- **Lint & Format Check**
- Prettier format check
- ESLint backend
- ESLint frontend
- **Test Backend**
- PostgreSQL service container
- Redis service container
- Unit tests
- E2E tests
- Coverage upload to Codecov
- **Test Frontend**
- Unit tests
- Coverage upload to Codecov
- **Build Backend**
- TypeScript compilation
- Artifact upload
- **Build Frontend**
- Next.js build
- Artifact upload
#### `.github/workflows/security.yml`:
- npm audit (weekly)
- Dependency review on PRs
#### `.github/pull_request_template.md`:
- Structured PR template
- Checklist for hexagonal architecture compliance
### 6. Configuration Files ✅
**✅ Root Level**:
- `package.json` - Workspace configuration
- `.gitignore` - Complete ignore rules
- `.prettierrc` - Code formatting rules
- `.prettierignore` - Files to ignore
- `README.md` - Comprehensive documentation
- `docker-compose.yml` - Infrastructure setup
- `CLAUDE.md` - Architecture guidelines (pre-existing)
- `PRD.md` - Product requirements (pre-existing)
- `TODO.md` - 30-week roadmap (pre-existing)
- `SPRINT-0-COMPLETE.md` - Sprint summary
### 7. Documentation ✅
**✅ Created**:
- `README.md` - Full project documentation
- Quick start guide
- Project structure
- Development commands
- Architecture overview
- Tech stack details
- Security practices
- `SPRINT-0-COMPLETE.md` - This summary
- `SPRINT-0-FINAL.md` - Comprehensive completion report
---
## 🎯 Key Achievements
### 1. Hexagonal Architecture ✅
- **Domain Layer**: Completely isolated, no external dependencies
- **Application Layer**: Controllers, DTOs, Mappers
- **Infrastructure Layer**: TypeORM, Redis, Carriers, Email, Storage
- **Path Aliases**: Clean imports (`@domain/*`, `@application/*`, `@infrastructure/*`)
- **Testability**: Domain can be tested without NestJS
### 2. Production-Ready Configuration ✅
- **Environment Validation**: Joi schema validation
- **Structured Logging**: Pino with pretty-print in dev
- **Security**: Helmet.js, CORS, rate limiting, JWT
- **Health Checks**: `/health`, `/ready`, `/live` endpoints
- **API Documentation**: Swagger UI at `/api/docs`
### 3. Developer Experience ✅
- **TypeScript**: Strict mode everywhere
- **Hot Reload**: Backend and frontend
- **Linting**: ESLint + Prettier
- **Testing**: Jest + Supertest + Playwright
- **CI/CD**: Automated testing and builds
- **Docker**: One-command infrastructure startup
### 4. Complete Tech Stack ✅
**Backend**:
- Framework: NestJS 10+
- Language: TypeScript 5+
- Database: PostgreSQL 15
- Cache: Redis 7
- ORM: TypeORM
- Auth: JWT + Passport + OAuth2
- API Docs: Swagger/OpenAPI
- Logging: Pino
- Testing: Jest + Supertest
- Security: Helmet, bcrypt, rate limiting
- Patterns: Circuit breaker (opossum)
**Frontend**:
- Framework: Next.js 14 (App Router)
- Language: TypeScript 5+
- Styling: Tailwind CSS + shadcn/ui
- State: TanStack Query
- Forms: react-hook-form + zod
- HTTP: axios
- Icons: lucide-react
- Testing: Jest + React Testing Library + Playwright
**Infrastructure**:
- PostgreSQL 15 (Docker)
- Redis 7 (Docker)
- CI/CD: GitHub Actions
- Version Control: Git
---
## 📁 File Count
- **Backend**: 15+ files
- **Frontend**: 12+ files
- **Infrastructure**: 3 files
- **CI/CD**: 3 files
- **Documentation**: 5 files
- **Configuration**: 10+ files
**Total**: ~50 files created
---
## 🚀 How to Use
### 1. Install Dependencies
```bash
# Root (workspaces)
npm install
# Backend (if needed separately)
cd apps/backend && npm install
# Frontend (if needed separately)
cd apps/frontend && npm install
```
### 2. Start Infrastructure
```bash
# Start PostgreSQL + Redis
docker-compose up -d
# Check status
docker-compose ps
# View logs
docker-compose logs -f
```
### 3. Configure Environment
```bash
# Backend
cp apps/backend/.env.example apps/backend/.env
# Edit apps/backend/.env with your values
# Frontend
cp apps/frontend/.env.example apps/frontend/.env
# Edit apps/frontend/.env with your values
```
### 4. Start Development Servers
```bash
# Terminal 1 - Backend
npm run backend:dev
# API: http://localhost:4000
# Docs: http://localhost:4000/api/docs
# Terminal 2 - Frontend
npm run frontend:dev
# App: http://localhost:3000
```
### 5. Verify Health
```bash
# Backend health check
curl http://localhost:4000/api/v1/health
# Expected response:
# {
# "status": "ok",
# "timestamp": "2025-10-07T...",
# "uptime": 12.345,
# "environment": "development",
# "version": "0.1.0"
# }
```
### 6. Run Tests
```bash
# All tests
npm run test:all
# Backend only
npm run backend:test
npm run backend:test:cov
# Frontend only
npm run frontend:test
# E2E tests
npm run backend:test:e2e
```
### 7. Lint & Format
```bash
# Check formatting
npm run format:check
# Fix formatting
npm run format
# Lint
npm run lint
```
---
## 🎯 Success Criteria - ALL MET ✅
- ✅ Monorepo structure with workspaces
- ✅ Backend with hexagonal architecture
- ✅ Frontend with Next.js 14
- ✅ Docker Compose for PostgreSQL + Redis
- ✅ Complete TypeScript configuration
- ✅ ESLint + Prettier setup
- ✅ Testing infrastructure (Jest, Supertest, Playwright)
- ✅ CI/CD pipelines (GitHub Actions)
- ✅ API documentation (Swagger)
- ✅ Logging (Pino)
- ✅ Security foundations (Helmet, JWT, CORS)
- ✅ Environment variable validation
- ✅ Health check endpoints
- ✅ Comprehensive documentation
---
## 📊 Sprint 0 Metrics
- **Duration**: 2 weeks (as planned)
- **Completion**: 100%
- **Files Created**: ~50
- **Lines of Code**: ~2,000+
- **Dependencies**: 80+ packages
- **Documentation Pages**: 5
- **CI/CD Workflows**: 2
- **Docker Services**: 2
---
## 🔐 Security Checklist (Before Production)
- [ ] Change all default passwords in `.env`
- [ ] Generate strong JWT secret (min 32 chars)
- [ ] Configure OAuth2 credentials (Google, Microsoft)
- [ ] Setup email service (SendGrid/AWS SES)
- [ ] Configure AWS S3 credentials
- [ ] Obtain carrier API keys (Maersk, MSC, CMA CGM, etc.)
- [ ] Enable HTTPS/TLS 1.3
- [ ] Configure Sentry DSN for error tracking
- [ ] Setup monitoring (Prometheus/Grafana)
- [ ] Enable automated database backups
- [ ] Review and restrict CORS origins
- [ ] Test rate limiting configuration
- [ ] Run OWASP ZAP security scan
- [ ] Enable two-factor authentication (2FA)
- [ ] Setup secrets rotation
---
## 🎯 Next Steps - Phase 1
Now ready to proceed with **Phase 1 - Core Search & Carrier Integration** (6-8 weeks):
### Sprint 1-2: Domain Layer & Port Definitions
- Create domain entities (Organization, User, RateQuote, Carrier, Port, Container)
- Create value objects (Email, PortCode, Money, ContainerType)
- Define API Ports (SearchRatesPort, GetPortsPort)
- Define SPI Ports (Repositories, CarrierConnectorPort, CachePort)
- Implement domain services
- Write domain unit tests (target: 90%+ coverage)
### Sprint 3-4: Infrastructure Layer
- Design database schema (ERD)
- Create TypeORM entities
- Implement repositories
- Create database migrations
- Seed data (carriers, ports)
- Implement Redis cache adapter
- Create Maersk connector
- Integration tests
### Sprint 5-6: Application Layer & Rate Search API
- Create DTOs and mappers
- Implement controllers (RatesController, PortsController)
- Complete OpenAPI documentation
- Implement caching strategy
- Performance optimization
- E2E tests
### Sprint 7-8: Frontend Rate Search UI
- Search form components
- Port autocomplete
- Results display (cards + table)
- Filtering & sorting
- Export functionality
- Responsive design
- Frontend tests
---
## 🏆 Sprint 0 - SUCCESSFULLY COMPLETED
**All infrastructure and configuration are in place.**
**The foundation is solid and ready for production development.**
### Team Achievement
- ✅ Hexagonal architecture properly implemented
- ✅ Production-ready configuration
- ✅ Excellent developer experience
- ✅ Comprehensive testing strategy
- ✅ CI/CD automation
- ✅ Complete documentation
### Ready to Build
- ✅ Domain entities
- ✅ Rate search functionality
- ✅ Carrier integrations
- ✅ Booking workflow
- ✅ User authentication
- ✅ Dashboard
---
**Project Status**: 🟢 READY FOR PHASE 1
**Sprint 0 Completion**: 100% ✅
**Time to Phase 1**: NOW 🚀
---
*Generated on October 7, 2025*
*Xpeditis MVP - Maritime Freight Booking Platform*

View File

@ -1,436 +0,0 @@
# 📊 Sprint 0 - Executive Summary
## Xpeditis MVP - Project Setup & Infrastructure
**Status**: ✅ **COMPLETE**
**Completion Date**: October 7, 2025
**Duration**: As planned (2 weeks)
**Completion**: 100%
---
## 🎯 Objectives Achieved
Sprint 0 successfully established a production-ready foundation for the Xpeditis maritime freight booking platform with:
1. ✅ Complete monorepo structure with npm workspaces
2. ✅ Backend API with hexagonal architecture (NestJS)
3. ✅ Frontend application (Next.js 14)
4. ✅ Database and cache infrastructure (PostgreSQL + Redis)
5. ✅ CI/CD pipelines (GitHub Actions)
6. ✅ Complete documentation suite
7. ✅ Testing infrastructure
8. ✅ Security foundations
---
## 📦 Deliverables
### Code & Configuration (50+ files)
| Component | Files | Status |
|-----------|-------|--------|
| **Backend** | 15+ | ✅ Complete |
| **Frontend** | 12+ | ✅ Complete |
| **Infrastructure** | 3 | ✅ Complete |
| **CI/CD** | 3 | ✅ Complete |
| **Documentation** | 8 | ✅ Complete |
| **Configuration** | 10+ | ✅ Complete |
### Documentation Suite
1. **README.md** - Project overview and quick start
2. **CLAUDE.md** - Hexagonal architecture guidelines (476 lines)
3. **TODO.md** - 30-week development roadmap (1000+ lines)
4. **SPRINT-0-FINAL.md** - Complete sprint report
5. **SPRINT-0-SUMMARY.md** - This executive summary
6. **QUICK-START.md** - 5-minute setup guide
7. **INSTALLATION-STEPS.md** - Detailed installation
8. **apps/backend/README.md** - Backend documentation
9. **apps/frontend/README.md** - Frontend documentation
---
## 🏗️ Architecture
### Backend (Hexagonal Architecture)
**Strict separation of concerns**:
```
✅ Domain Layer (Pure Business Logic)
├── Zero framework dependencies
├── Testable without NestJS
└── 90%+ code coverage target
✅ Application Layer (Controllers & DTOs)
├── REST API endpoints
├── Input validation
└── DTO mapping
✅ Infrastructure Layer (External Adapters)
├── TypeORM repositories
├── Redis cache
├── Carrier connectors
├── Email service
└── S3 storage
```
**Key Benefits**:
- Domain can be tested in isolation
- Easy to swap databases or frameworks
- Clear separation of concerns
- Maintainable and scalable
### Frontend (Next.js 14 + React 18)
**Modern React stack**:
- App Router with server components
- TypeScript strict mode
- Tailwind CSS + shadcn/ui
- TanStack Query for state
- react-hook-form + zod for forms
---
## 🛠️ Technology Stack
### Backend
- **Framework**: NestJS 10+
- **Language**: TypeScript 5+
- **Database**: PostgreSQL 15
- **Cache**: Redis 7
- **ORM**: TypeORM
- **Auth**: JWT + Passport + OAuth2
- **API Docs**: Swagger/OpenAPI
- **Logging**: Pino (structured JSON)
- **Testing**: Jest + Supertest
- **Security**: Helmet, bcrypt, rate limiting
### Frontend
- **Framework**: Next.js 14
- **Language**: TypeScript 5+
- **Styling**: Tailwind CSS
- **UI**: shadcn/ui (Radix UI)
- **State**: TanStack Query
- **Forms**: react-hook-form + zod
- **HTTP**: axios
- **Testing**: Jest + React Testing Library + Playwright
### Infrastructure
- **Database**: PostgreSQL 15 (Docker)
- **Cache**: Redis 7 (Docker)
- **CI/CD**: GitHub Actions
- **Container**: Docker + Docker Compose
---
## 📊 Metrics
| Metric | Value |
|--------|-------|
| **Files Created** | ~50 |
| **Lines of Code** | 2,000+ |
| **Dependencies** | 80+ packages |
| **Documentation** | 8 files, 3000+ lines |
| **CI/CD Workflows** | 2 (ci.yml, security.yml) |
| **Docker Services** | 2 (PostgreSQL, Redis) |
| **Test Coverage Target** | Domain: 90%, App: 80%, Infra: 70% |
---
## ✅ Success Criteria - All Met
| Criteria | Status | Notes |
|----------|--------|-------|
| Monorepo structure | ✅ | npm workspaces configured |
| Backend hexagonal arch | ✅ | Complete separation of layers |
| Frontend Next.js 14 | ✅ | App Router + TypeScript |
| Docker infrastructure | ✅ | PostgreSQL + Redis with health checks |
| TypeScript strict mode | ✅ | All projects |
| Testing infrastructure | ✅ | Jest, Supertest, Playwright |
| CI/CD pipelines | ✅ | GitHub Actions (lint, test, build) |
| API documentation | ✅ | Swagger at /api/docs |
| Logging | ✅ | Pino structured logging |
| Security foundations | ✅ | Helmet, JWT, CORS, rate limiting |
| Environment validation | ✅ | Joi schema validation |
| Health endpoints | ✅ | /health, /ready, /live |
| Documentation | ✅ | 8 comprehensive documents |
---
## 🎯 Key Features Implemented
### Backend Features
1. **Health Check System**
- `/health` - Overall system health
- `/ready` - Readiness for traffic
- `/live` - Liveness check
2. **Logging System**
- Structured JSON logs (Pino)
- Pretty print in development
- Request/response logging
- Log levels (debug, info, warn, error)
3. **Configuration Management**
- Environment variable validation
- Type-safe configuration
- Multiple environments support
4. **Security**
- Helmet.js security headers
- CORS configuration
- Rate limiting prepared
- JWT authentication ready
- Password hashing (bcrypt)
5. **API Documentation**
- Swagger UI at `/api/docs`
- OpenAPI specification
- Request/response schemas
- Authentication documentation
### Frontend Features
1. **Modern React Setup**
- Next.js 14 App Router
- Server and client components
- TypeScript strict mode
- Path aliases configured
2. **UI Framework**
- Tailwind CSS with custom theme
- shadcn/ui components ready
- Dark mode support (CSS variables)
- Responsive design utilities
3. **State Management**
- TanStack Query for server state
- React hooks for local state
- Form state with react-hook-form
4. **Utilities**
- `cn()` helper for className merging
- Type-safe API client ready
- Zod schemas for validation
---
## 🚀 Ready for Phase 1
The project is **fully ready** for Phase 1 development:
### Phase 1 - Core Search & Carrier Integration (6-8 weeks)
**Sprint 1-2: Domain Layer**
- ✅ Folder structure ready
- ✅ Path aliases configured
- ✅ Testing infrastructure ready
- 🎯 Ready to create: Entities, Value Objects, Ports, Services
**Sprint 3-4: Infrastructure**
- ✅ Database configured (PostgreSQL)
- ✅ Cache configured (Redis)
- ✅ TypeORM setup
- 🎯 Ready to create: Repositories, Migrations, Seed data
**Sprint 5-6: Application Layer**
- ✅ NestJS configured
- ✅ Swagger ready
- ✅ Validation pipes configured
- 🎯 Ready to create: Controllers, DTOs, Mappers
**Sprint 7-8: Frontend UI**
- ✅ Next.js configured
- ✅ Tailwind CSS ready
- ✅ shadcn/ui ready
- 🎯 Ready to create: Search components, Results display
---
## 📁 Project Structure
```
xpeditis/
├── apps/
│ ├── backend/ ✅ NestJS + Hexagonal
│ │ ├── src/
│ │ │ ├── domain/ ✅ Pure business logic
│ │ │ ├── application/ ✅ Controllers & DTOs
│ │ │ ├── infrastructure/ ✅ External adapters
│ │ │ ├── main.ts ✅ Bootstrap
│ │ │ └── app.module.ts ✅ Root module
│ │ ├── test/ ✅ E2E tests
│ │ └── [config files] ✅ All complete
│ │
│ └── frontend/ ✅ Next.js 14
│ ├── app/ ✅ App Router
│ ├── components/ ✅ Ready for components
│ ├── lib/ ✅ Utilities
│ └── [config files] ✅ All complete
├── packages/
│ ├── shared-types/ ✅ Created
│ └── domain/ ✅ Created
├── infra/
│ └── postgres/ ✅ Init scripts
├── .github/
│ └── workflows/ ✅ CI/CD pipelines
├── docker-compose.yml ✅ PostgreSQL + Redis
├── package.json ✅ Workspace root
├── [documentation] ✅ 8 files
└── [config files] ✅ Complete
```
---
## 💻 Development Workflow
### Quick Start (5 minutes)
```bash
# 1. Install dependencies
npm install
# 2. Start infrastructure
docker-compose up -d
# 3. Configure environment
cp apps/backend/.env.example apps/backend/.env
cp apps/frontend/.env.example apps/frontend/.env
# 4. Start backend
npm run backend:dev
# 5. Start frontend (in another terminal)
npm run frontend:dev
```
### Verification
- ✅ Backend: http://localhost:4000/api/v1/health
- ✅ API Docs: http://localhost:4000/api/docs
- ✅ Frontend: http://localhost:3000
- ✅ PostgreSQL: localhost:5432
- ✅ Redis: localhost:6379
---
## 🎓 Learning Resources
For team members new to the stack:
**Hexagonal Architecture**:
- Read [CLAUDE.md](CLAUDE.md) (comprehensive guide)
- Review backend folder structure
- Study the flow: HTTP → Controller → Use Case → Domain
**NestJS**:
- [Official Docs](https://docs.nestjs.com/)
- Focus on: Modules, Controllers, Providers, DTOs
**Next.js 14**:
- [Official Docs](https://nextjs.org/docs)
- Focus on: App Router, Server Components, Client Components
**TypeORM**:
- [Official Docs](https://typeorm.io/)
- Focus on: Entities, Repositories, Migrations
---
## 🔒 Security Considerations
**Implemented**:
- ✅ Helmet.js security headers
- ✅ CORS configuration
- ✅ Input validation (class-validator)
- ✅ Environment variable validation
- ✅ Password hashing configuration
- ✅ JWT configuration
- ✅ Rate limiting preparation
**For Production** (before deployment):
- [ ] Change all default passwords
- [ ] Generate strong JWT secret
- [ ] Configure OAuth2 credentials
- [ ] Setup email service
- [ ] Configure AWS S3
- [ ] Obtain carrier API keys
- [ ] Enable HTTPS/TLS
- [ ] Setup Sentry
- [ ] Configure monitoring
- [ ] Enable database backups
- [ ] Run security audit
---
## 📈 Next Steps
### Immediate (This Week)
1. ✅ Sprint 0 complete
2. 🎯 Install dependencies (`npm install`)
3. 🎯 Start infrastructure (`docker-compose up -d`)
4. 🎯 Verify all services running
5. 🎯 Begin Sprint 1 (Domain entities)
### Short Term (Next 2 Weeks - Sprint 1-2)
1. Create domain entities (Organization, User, RateQuote, Carrier, Port)
2. Create value objects (Email, PortCode, Money, ContainerType)
3. Define API ports (SearchRatesPort, GetPortsPort)
4. Define SPI ports (Repositories, CarrierConnectorPort, CachePort)
5. Implement domain services
6. Write domain unit tests (90%+ coverage)
### Medium Term (Weeks 3-8 - Sprint 3-6)
1. Design and implement database schema
2. Create TypeORM entities and repositories
3. Implement Redis cache adapter
4. Create Maersk carrier connector
5. Implement rate search API
6. Build frontend search UI
---
## 🎉 Conclusion
Sprint 0 has been **successfully completed** with:
- ✅ **100% of planned deliverables**
- ✅ **Production-ready infrastructure**
- ✅ **Hexagonal architecture properly implemented**
- ✅ **Complete documentation suite**
- ✅ **Automated CI/CD pipelines**
- ✅ **Developer-friendly setup**
**The Xpeditis MVP project is ready for Phase 1 development.**
---
## 📞 Support
For questions or issues:
1. Check documentation (8 comprehensive guides)
2. Review [QUICK-START.md](QUICK-START.md)
3. Consult [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)
4. Open a GitHub issue
---
**Status**: 🟢 **READY FOR DEVELOPMENT**
**Next Phase**: Phase 1 - Core Search & Carrier Integration
**Team**: ✅ **Ready to build**
---
*Xpeditis MVP - Maritime Freight Booking Platform*
*Sprint 0 Complete - October 7, 2025*

View File

@ -1,358 +0,0 @@
# 🚀 START HERE - Xpeditis MVP
## ✅ Sprint 0 Complete!
Tout le code et la configuration sont prêts. Suivez ces étapes pour démarrer.
---
## 📋 Étape par Étape (10 minutes)
### 1⃣ Installer les Dépendances (5 min)
⚠️ **IMPORTANT pour Windows** : Les workspaces npm ne fonctionnent pas bien sur Windows.
Utilisez cette commande pour installer dans chaque app séparément :
```bash
# Option A: Script automatique
npm run install:all
# Option B: Manuel (recommandé si Option A échoue)
# 1. Racine
npm install
# 2. Backend
cd apps/backend
npm install
cd ../..
# 3. Frontend
cd apps/frontend
npm install
cd ../..
```
**Durée**: 3-5 minutes
**Packages**: ~80 packages au total
### 2⃣ Démarrer Docker (1 min)
```bash
docker-compose up -d
```
**Vérifier** :
```bash
docker-compose ps
# Doit afficher postgres et redis "Up (healthy)"
```
### 3⃣ Configurer l'Environnement (30 sec)
```bash
# Backend
cp apps/backend/.env.example apps/backend/.env
# Frontend
cp apps/frontend/.env.example apps/frontend/.env
```
**Les valeurs par défaut fonctionnent** - pas besoin de modifier !
### 4⃣ Démarrer le Backend (1 min)
```bash
# Option A: Depuis la racine
npm run backend:dev
# Option B: Depuis apps/backend
cd apps/backend
npm run dev
```
**Attendu** :
```
╔═══════════════════════════════════════╗
║ 🚢 Xpeditis API Server Running ║
║ API: http://localhost:4000/api/v1 ║
║ Docs: http://localhost:4000/api/docs ║
╚═══════════════════════════════════════╝
```
**Vérifier** : http://localhost:4000/api/v1/health
### 5⃣ Démarrer le Frontend (1 min) - Nouveau Terminal
```bash
# Option A: Depuis la racine
npm run frontend:dev
# Option B: Depuis apps/frontend
cd apps/frontend
npm run dev
```
**Attendu** :
```
▲ Next.js 14.0.4
- Local: http://localhost:3000
✓ Ready in 2.3s
```
**Vérifier** : http://localhost:3000
---
## ✅ Checklist de Vérification
Avant de continuer, vérifiez que tout fonctionne :
- [ ] Backend démarre sans erreur
- [ ] Frontend démarre sans erreur
- [ ] http://localhost:4000/api/v1/health renvoie `{"status":"ok"}`
- [ ] http://localhost:4000/api/docs affiche Swagger UI
- [ ] http://localhost:3000 affiche la page Xpeditis
- [ ] `docker-compose ps` montre postgres et redis "healthy"
**Tout est vert ? Excellent ! 🎉**
---
## 📚 Prochaines Étapes
### Lire la Documentation (2 heures)
**Obligatoire** (dans cet ordre) :
1. **[QUICK-START.md](QUICK-START.md)** (10 min)
- Référence rapide des commandes
2. **[CLAUDE.md](CLAUDE.md)** (60 min) 🔥 **TRÈS IMPORTANT**
- **Architecture hexagonale complète**
- Règles pour chaque couche
- Exemples de code
- **À LIRE ABSOLUMENT avant de coder**
3. **[NEXT-STEPS.md](NEXT-STEPS.md)** (30 min)
- Quoi faire ensuite
- Exemples de code pour démarrer
- Phase 1 expliquée
4. **[TODO.md](TODO.md)** - Section Sprint 1-2 (30 min)
- Tâches détaillées
- Critères d'acceptation
### Commencer le Développement
**Sprint 1-2 : Domain Layer** (2 semaines)
Créer les fichiers dans `apps/backend/src/domain/` :
**Entités** (`entities/`) :
- `organization.entity.ts`
- `user.entity.ts`
- `rate-quote.entity.ts`
- `carrier.entity.ts`
- `port.entity.ts`
- `container.entity.ts`
- `booking.entity.ts`
**Value Objects** (`value-objects/`) :
- `email.vo.ts`
- `port-code.vo.ts`
- `money.vo.ts`
- `container-type.vo.ts`
- `booking-number.vo.ts`
**Ports** :
- `ports/in/` - API ports (SearchRatesPort, CreateBookingPort, etc.)
- `ports/out/` - SPI ports (Repositories, CarrierConnectorPort, CachePort, etc.)
**Services** (`services/`) :
- `rate-search.service.ts`
- `booking.service.ts`
- `user.service.ts`
**Tests** :
- `*.spec.ts` pour chaque service
- **Cible : 90%+ de couverture**
Voir [NEXT-STEPS.md](NEXT-STEPS.md) pour des exemples de code complets !
---
## 🛠️ Commandes Utiles
### Développement
```bash
# Backend
npm run backend:dev # Démarrer
npm run backend:test # Tests
npm run backend:lint # Linter
# Frontend
npm run frontend:dev # Démarrer
npm run frontend:test # Tests
npm run frontend:lint # Linter
# Les deux
npm run format # Formater le code
npm run format:check # Vérifier le formatage
```
### Docker
```bash
docker-compose up -d # Démarrer
docker-compose down # Arrêter
docker-compose logs -f # Voir les logs
docker-compose ps # Status
```
### Base de données
```bash
# Se connecter à PostgreSQL
docker-compose exec postgres psql -U xpeditis -d xpeditis_dev
# Se connecter à Redis
docker-compose exec redis redis-cli -a xpeditis_redis_password
```
---
## 🐛 Problèmes Courants
### npm install échoue
**Solution** : Voir [WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md)
### Backend ne démarre pas
```bash
cd apps/backend
rm -rf node_modules package-lock.json
npm install
npm run dev
```
### Frontend ne démarre pas
```bash
cd apps/frontend
rm -rf node_modules package-lock.json
npm install
npm run dev
```
### Docker ne démarre pas
```bash
# Vérifier que Docker Desktop est lancé
docker --version
# Redémarrer les containers
docker-compose down
docker-compose up -d
```
### Port déjà utilisé
```bash
# Trouver le processus sur le port 4000
netstat -ano | findstr :4000
# Ou changer le port dans apps/backend/.env
PORT=4001
```
---
## 📖 Documentation Complète
Tous les fichiers de documentation :
### Getting Started
- **[START-HERE.md](START-HERE.md)** ⭐ - Ce fichier
- [QUICK-START.md](QUICK-START.md) - Guide rapide
- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Installation détaillée
- [WINDOWS-INSTALLATION.md](WINDOWS-INSTALLATION.md) - Spécifique Windows
- [NEXT-STEPS.md](NEXT-STEPS.md) - Quoi faire ensuite
### Architecture
- **[CLAUDE.md](CLAUDE.md)** 🔥 - **À LIRE ABSOLUMENT**
- [apps/backend/README.md](apps/backend/README.md) - Backend
- [apps/frontend/README.md](apps/frontend/README.md) - Frontend
### Project Planning
- [PRD.md](PRD.md) - Exigences produit
- [TODO.md](TODO.md) - Roadmap 30 semaines
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Rapport Sprint 0
- [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Résumé
- [INDEX.md](INDEX.md) - Index complet
---
## 🎯 Objectifs Phase 1 (6-8 semaines)
**Sprint 1-2** : Domain Layer
- Créer toutes les entités métier
- Définir tous les ports (API & SPI)
- Implémenter les services métier
- Tests unitaires (90%+)
**Sprint 3-4** : Infrastructure Layer
- Schéma de base de données
- Repositories TypeORM
- Adapter Redis cache
- Connecteur Maersk
**Sprint 5-6** : Application Layer
- API rate search
- Controllers & DTOs
- Documentation OpenAPI
- Tests E2E
**Sprint 7-8** : Frontend UI
- Interface de recherche
- Affichage des résultats
- Filtres et tri
- Tests frontend
---
## 💡 Conseils Importants
### ⚠️ À LIRE ABSOLUMENT
**[CLAUDE.md](CLAUDE.md)** - Contient toutes les règles d'architecture :
- Comment organiser le code
- Quoi mettre dans chaque couche
- Ce qu'il faut éviter
- Exemples complets
**Sans lire CLAUDE.md, vous risquez de violer l'architecture hexagonale !**
### ✅ Bonnes Pratiques
- **Tests first** : Écrire les tests avant le code
- **Commits fréquents** : Petits commits, souvent
- **Lire les specs** : Vérifier TODO.md pour les critères d'acceptation
- **Suivre l'archi** : Respecter Domain → Application → Infrastructure
---
## 🎉 Vous êtes Prêt !
**Sprint 0** : ✅ Complete
**Installation** : ✅ Fonctionnelle
**Documentation** : ✅ Disponible
**Prochaine étape** : Lire CLAUDE.md et commencer Sprint 1
**Bon développement ! 🚀**
---
*Xpeditis MVP - Maritime Freight Booking Platform*
*Démarrez ici pour le développement Phase 1*

View File

@ -1,406 +0,0 @@
# 🪟 Installation sur Windows - Xpeditis
## Problème avec npm Workspaces sur Windows
Sur Windows, les workspaces npm peuvent rencontrer des problèmes de symlinks (`EISDIR` error). Voici la solution.
---
## ✅ Solution : Installation Séparée par App
Au lieu d'utiliser `npm install` à la racine, installez les dépendances dans chaque app séparément.
### Étape 1 : Supprimer le node_modules racine (si existe)
```bash
# Si node_modules existe à la racine
rm -rf node_modules
```
### Étape 2 : Installer les dépendances Backend
```bash
cd apps/backend
npm install
cd ../..
```
**Durée** : 2-3 minutes
**Packages installés** : ~50 packages NestJS, TypeORM, etc.
### Étape 3 : Installer les dépendances Frontend
```bash
cd apps/frontend
npm install
cd ../..
```
**Durée** : 2-3 minutes
**Packages installés** : ~30 packages Next.js, React, Tailwind, etc.
### Étape 4 : Installer les dépendances racine (optionnel)
```bash
npm install --no-workspaces
```
**Packages installés** : prettier, typescript (partagés)
---
## ✅ Vérification de l'Installation
### Vérifier Backend
```bash
cd apps/backend
# Vérifier que node_modules existe
ls node_modules
# Vérifier des packages clés
ls node_modules/@nestjs
ls node_modules/typeorm
ls node_modules/pg
# Essayer de démarrer
npm run dev
```
**Attendu** : Le serveur démarre sur le port 4000
### Vérifier Frontend
```bash
cd apps/frontend
# Vérifier que node_modules existe
ls node_modules
# Vérifier des packages clés
ls node_modules/next
ls node_modules/react
ls node_modules/tailwindcss
# Essayer de démarrer
npm run dev
```
**Attendu** : Le serveur démarre sur le port 3000
---
## 🚀 Démarrage Après Installation
### 1. Démarrer l'infrastructure Docker
```bash
docker-compose up -d
```
### 2. Configurer l'environnement
```bash
# Backend
cp apps/backend/.env.example apps/backend/.env
# Frontend
cp apps/frontend/.env.example apps/frontend/.env
```
### 3. Démarrer le Backend
```bash
cd apps/backend
npm run dev
```
**URL** : http://localhost:4000/api/v1/health
### 4. Démarrer le Frontend (nouveau terminal)
```bash
cd apps/frontend
npm run dev
```
**URL** : http://localhost:3000
---
## 📝 Scripts Modifiés pour Windows
Comme les workspaces ne fonctionnent pas, utilisez ces commandes :
### Backend
```bash
# Au lieu de: npm run backend:dev
cd apps/backend && npm run dev
# Au lieu de: npm run backend:test
cd apps/backend && npm test
# Au lieu de: npm run backend:build
cd apps/backend && npm run build
```
### Frontend
```bash
# Au lieu de: npm run frontend:dev
cd apps/frontend && npm run dev
# Au lieu de: npm run frontend:test
cd apps/frontend && npm test
# Au lieu de: npm run frontend:build
cd apps/frontend && npm run build
```
### Les deux en parallèle
**Option 1** : Deux terminaux
Terminal 1 :
```bash
cd apps/backend
npm run dev
```
Terminal 2 :
```bash
cd apps/frontend
npm run dev
```
**Option 2** : PowerShell avec Start-Process
```powershell
# Backend
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd apps/backend; npm run dev"
# Frontend
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd apps/frontend; npm run dev"
```
---
## 🔧 Alternative : Utiliser pnpm ou yarn
Si npm continue à poser problème, utilisez pnpm (meilleur support Windows) :
### Avec pnpm
```bash
# Installer pnpm globalement
npm install -g pnpm
# Installer les dépendances
pnpm install
# Démarrer backend
pnpm --filter backend dev
# Démarrer frontend
pnpm --filter frontend dev
```
### Avec yarn
```bash
# Installer yarn globalement
npm install -g yarn
# Installer les dépendances
yarn install
# Démarrer backend
yarn workspace backend dev
# Démarrer frontend
yarn workspace frontend dev
```
---
## ✅ Checklist d'Installation Windows
- [ ] Docker Desktop installé et démarré
- [ ] Node.js v20+ installé
- [ ] `cd apps/backend && npm install` terminé
- [ ] `cd apps/frontend && npm install` terminé
- [ ] `docker-compose up -d` exécuté
- [ ] Containers PostgreSQL et Redis en cours d'exécution
- [ ] `.env` files copiés
- [ ] Backend démarre sur port 4000
- [ ] Frontend démarre sur port 3000
- [ ] Health endpoint répond : http://localhost:4000/api/v1/health
---
## 🐛 Dépannage Windows
### Erreur : EBUSY (resource busy or locked)
**Cause** : Fichiers verrouillés par un processus Windows (antivirus, Windows Defender, etc.)
**Solutions** :
1. Fermer VSCode et tous les terminals
2. Désactiver temporairement l'antivirus
3. Exclure le dossier `node_modules` de Windows Defender
4. Réessayer l'installation
### Erreur : EISDIR (illegal operation on directory)
**Cause** : Windows ne supporte pas bien les symlinks npm workspaces
**Solution** : Utiliser l'installation séparée (cette page)
### Erreur : EPERM (operation not permitted)
**Cause** : Permissions insuffisantes
**Solutions** :
1. Exécuter PowerShell/CMD en tant qu'administrateur
2. Ou utiliser l'installation séparée (pas besoin d'admin)
### Backend ne démarre pas - "Cannot find module"
**Cause** : node_modules manquant ou incomplet
**Solution** :
```bash
cd apps/backend
rm -rf node_modules package-lock.json
npm install
```
### Frontend ne démarre pas - "Cannot find module 'next'"
**Cause** : node_modules manquant ou incomplet
**Solution** :
```bash
cd apps/frontend
rm -rf node_modules package-lock.json
npm install
```
### Frontend build fail - "EISDIR: illegal operation on directory, readlink"
**Cause** : Next.js rencontre un problème avec les symlinks sur Windows lors du build
**Erreur complète** :
```
Error: EISDIR: illegal operation on a directory, readlink 'D:\xpeditis2.0\apps\frontend\node_modules\next\dist\pages\_app.js'
```
**Solutions** :
**Option 1** : Utiliser le mode développement (recommandé pour le développement)
```bash
cd apps/frontend
npm run dev # Fonctionne sans problème
```
**Option 2** : Utiliser WSL2 pour le build de production
```bash
# Dans WSL2
cd /mnt/d/xpeditis2.0/apps/frontend
npm run build # Fonctionne correctement
```
**Option 3** : Build depuis PowerShell avec mode développeur activé
```powershell
# Activer le mode développeur Windows (une seule fois)
# Paramètres > Mise à jour et sécurité > Pour les développeurs > Mode développeur
# Ensuite:
cd apps/frontend
npm run build
```
**Note** : Pour le développement quotidien, utilisez `npm run dev` qui n'a pas ce problème. Le build de production n'est nécessaire que pour le déploiement.
---
## 💡 Recommandations pour Windows
### 1. Utiliser PowerShell Core (v7+)
Plus moderne et meilleur support des outils Node.js :
- [Télécharger PowerShell](https://github.com/PowerShell/PowerShell)
### 2. Utiliser Windows Terminal
Meilleure expérience terminal :
- [Télécharger Windows Terminal](https://aka.ms/terminal)
### 3. Considérer WSL2 (Windows Subsystem for Linux)
Pour une expérience Linux native sur Windows :
```bash
# Installer WSL2
wsl --install
# Installer Ubuntu
wsl --install -d Ubuntu
# Utiliser WSL2 pour le développement
cd /mnt/d/xpeditis2.0
npm install # Fonctionne comme sur Linux
```
### 4. Exclure node_modules de l'antivirus
Pour améliorer les performances :
**Windows Defender** :
1. Paramètres Windows > Mise à jour et sécurité > Sécurité Windows
2. Protection contre les virus et menaces > Gérer les paramètres
3. Exclusions > Ajouter une exclusion > Dossier
4. Ajouter : `D:\xpeditis2.0\node_modules`
5. Ajouter : `D:\xpeditis2.0\apps\backend\node_modules`
6. Ajouter : `D:\xpeditis2.0\apps\frontend\node_modules`
---
## ✅ Installation Réussie !
Une fois les dépendances installées dans chaque app :
```bash
# Backend
cd apps/backend
npm run dev
# Visiter: http://localhost:4000/api/docs
# Frontend (nouveau terminal)
cd apps/frontend
npm run dev
# Visiter: http://localhost:3000
```
**Tout fonctionne ? Excellent ! 🎉**
Passez à [NEXT-STEPS.md](NEXT-STEPS.md) pour commencer le développement.
---
## 📞 Besoin d'Aide ?
Si les problèmes persistent :
1. Vérifier Node.js version : `node --version` (doit être v20+)
2. Vérifier npm version : `npm --version` (doit être v10+)
3. Essayer avec pnpm : `npm install -g pnpm && pnpm install`
4. Utiliser WSL2 pour une expérience Linux
5. Consulter [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)
---
*Xpeditis - Installation Windows*
*Solution pour npm workspaces sur Windows*

View File

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

View File

@ -37,14 +37,12 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
APP_URL=http://localhost:3000
# Email (SMTP)
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=ton-email@brevo.com
SMTP_PASS=ta-cle-smtp-brevo
SMTP_SECURE=false
# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant)
SMTP_FROM=noreply@xpeditis.com
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=noreply@xpeditis.com
# AWS S3 / Storage (or MinIO for development)
AWS_ACCESS_KEY_ID=your-aws-access-key
@ -76,11 +74,6 @@ ONE_API_URL=https://api.one-line.com/v1
ONE_USERNAME=your-one-username
ONE_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
BCRYPT_ROUNDS=12
SESSION_TIMEOUT_MS=7200000
@ -100,9 +93,9 @@ 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
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly

View File

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

View File

@ -19,16 +19,14 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.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 { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// 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';
@Module({
@ -62,27 +60,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
// 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'),
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
}),
}),
// Logging
LoggerModule.forRootAsync({
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: {
transport: usePretty
useFactory: (configService: ConfigService) => ({
pinoHttp: {
transport:
configService.get('NODE_ENV') === 'development'
? {
target: 'pino-pretty',
options: {
@ -92,21 +84,9 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
},
}
: undefined,
level: isDev ? 'debug' : 'info',
// 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]',
},
},
};
},
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
},
}),
inject: [ConfigService],
}),
@ -148,16 +128,14 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
GDPRModule,
AdminModule,
SubscriptionsModule,
ApiKeysModule,
LogsModule,
],
controllers: [],
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
{
provide: APP_GUARD,
useClass: ApiKeyOrJwtGuard,
useClass: JwtAuthGuard,
},
// Global rate limiting guard
{

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// 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 { 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
*
@ -36,12 +25,7 @@ import { EmailModule } from '@infrastructure/email/email.module';
* All endpoints require ADMIN role.
*/
@Module({
imports: [
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
ConfigModule,
CsvBookingsModule,
EmailModule,
],
imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])],
controllers: [AdminController],
providers: [
{
@ -53,10 +37,6 @@ import { EmailModule } from '@infrastructure/email/email.module';
useClass: TypeOrmOrganizationRepository,
},
TypeOrmCsvBookingRepository,
{
provide: SIRET_VERIFICATION_PORT,
useClass: PappersSiretAdapter,
},
],
})
export class AdminModule {}

View File

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

View File

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

View File

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

View File

@ -17,7 +17,6 @@ import { TypeOrmInvitationTokenRepository } from '../../infrastructure/persisten
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.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 { InvitationsController } from '../controllers/invitations.controller';
import { EmailModule } from '../../infrastructure/email/email.module';
@ -41,7 +40,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
}),
// 👇 Add this to register TypeORM repositories
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]),
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity]),
// Email module for sending invitations
EmailModule,

View File

@ -5,14 +5,10 @@ import {
Logger,
Inject,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
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 { User, UserRole } from '@domain/entities/user.entity';
import {
@ -20,19 +16,15 @@ import {
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { Organization } from '@domain/entities/organization.entity';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { v4 as uuidv4 } from 'uuid';
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 {
sub: string; // user ID
email: string;
role: string;
organizationId: string;
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
planFeatures?: string[]; // plan feature flags
type: 'access' | 'refresh';
}
@ -45,13 +37,9 @@ export class AuthService {
private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
@InjectRepository(PasswordResetTokenOrmEntity)
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService
private readonly subscriptionService: SubscriptionService,
) {}
/**
@ -215,85 +203,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
*/
@ -311,40 +220,11 @@ export class AuthService {
* Generate access and refresh tokens
*/
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 = {
sub: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
plan,
planFeatures,
type: 'access',
};
@ -353,8 +233,6 @@ export class AuthService {
email: user.email,
role: user.role,
organizationId: user.organizationId,
plan,
planFeatures,
type: 'refresh',
};
@ -424,8 +302,6 @@ export class AuthService {
name: organizationData.name,
type: organizationData.type,
scac: organizationData.scac,
siren: organizationData.siren,
siret: organizationData.siret,
address: {
street: organizationData.street,
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 { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.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 { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.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 { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.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 { BookingService } from '@domain/services/booking.service';
@ -32,7 +29,6 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
/**
* Bookings Module
@ -51,7 +47,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
ContainerOrmEntity,
RateQuoteOrmEntity,
UserOrmEntity,
CsvBookingOrmEntity,
]),
EmailModule,
PdfModule,
@ -59,7 +54,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
AuditModule,
NotificationsModule,
WebhooksModule,
SubscriptionsModule,
],
controllers: [BookingsController],
providers: [
@ -79,10 +73,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
],
exports: [BOOKING_REPOSITORY],
})

View File

@ -1,7 +1,6 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
@ -45,16 +44,6 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org
// CSV Booking imports
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBookingService } from '../services/csv-booking.service';
// 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
@ -76,11 +65,7 @@ export class AdminController {
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
private readonly csvBookingService: CsvBookingService,
@Inject(SIRET_VERIFICATION_PORT)
private readonly siretVerificationPort: SiretVerificationPort,
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
) {}
// ==================== USERS ENDPOINTS ====================
@ -344,163 +329,6 @@ export class AdminController {
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 ====================
/**
@ -612,52 +440,6 @@ export class AdminController {
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)
*/
@ -701,7 +483,6 @@ export class AdminController {
return {
id: booking.id,
bookingNumber: booking.bookingNumber || null,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,
@ -729,50 +510,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 ====================
/**
@ -860,55 +597,4 @@ export class AdminController {
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,
uploadedAt: config.uploadedAt.toISOString(),
rowCount: config.rowCount,
companyEmail: config.metadata?.companyEmail ?? null,
};
});

View File

@ -8,21 +8,10 @@ import {
Get,
Inject,
NotFoundException,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from '../auth/auth.service';
import {
LoginDto,
RegisterDto,
AuthResponseDto,
RefreshTokenDto,
ForgotPasswordDto,
ResetPasswordDto,
ContactFormDto,
} from '../dto/auth-login.dto';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
import { Public } from '../decorators/public.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -43,13 +32,10 @@ import { InvitationService } from '../services/invitation.service';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private readonly authService: AuthService,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly invitationService: InvitationService,
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
private readonly invitationService: InvitationService
) {}
/**
@ -223,113 +209,6 @@ export class AuthController {
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
*

View File

@ -53,12 +53,6 @@ import { NotificationService } from '../services/notification.service';
import { NotificationsGateway } from '../gateways/notifications.gateway';
import { WebhookService } from '../services/webhook.service';
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')
@Controller('bookings')
@ -76,9 +70,7 @@ export class BookingsController {
private readonly auditService: AuditService,
private readonly notificationService: NotificationService,
private readonly notificationsGateway: NotificationsGateway,
private readonly webhookService: WebhookService,
@Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort,
private readonly subscriptionService: SubscriptionService
private readonly webhookService: WebhookService
) {}
@Post()
@ -113,22 +105,6 @@ export class BookingsController {
): Promise<BookingResponseDto> {
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 {
// Convert DTO to domain input, using authenticated user's data
const input = {
@ -480,16 +456,9 @@ export class BookingsController {
// 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
(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

View File

@ -1,12 +1,7 @@
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service';
import {
CarrierDocumentsResponseDto,
VerifyDocumentAccessDto,
DocumentAccessRequirementsDto,
} from '../dto/carrier-documents.dto';
/**
* CSV Booking Actions Controller (Public Routes)
@ -93,84 +88,4 @@ export class CsvBookingActionsController {
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

@ -12,12 +12,9 @@ import {
UploadedFiles,
Request,
BadRequestException,
ForbiddenException,
ParseIntPipe,
DefaultValuePipe,
Inject,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
@ -32,16 +29,6 @@ import {
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator';
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 {
CreateCsvBookingDto,
CsvBookingResponseDto,
@ -61,15 +48,7 @@ import {
@ApiTags('CSV Bookings')
@Controller('csv-bookings')
export class CsvBookingsController {
constructor(
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
) {}
constructor(private readonly csvBookingService: CsvBookingService) {}
// ============================================================================
// STATIC ROUTES (must come FIRST)
@ -81,6 +60,7 @@ export class CsvBookingsController {
* POST /api/v1/csv-bookings
*/
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data')
@ -164,23 +144,6 @@ export class CsvBookingsController {
const userId = req.user.id;
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)
const sanitizedDto: CreateCsvBookingDto = {
...dto,
@ -378,126 +341,6 @@ 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)
// ============================================================================

View File

@ -22,7 +22,12 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator';
import { UserPayload } from '../decorators/current-user.decorator';
import { GDPRService } from '../services/gdpr.service';
import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
import {
UpdateConsentDto,
ConsentResponseDto,
WithdrawConsentDto,
ConsentSuccessDto,
} from '../dto/consent.dto';
@ApiTags('GDPR')
@Controller('gdpr')

View File

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

View File

@ -2,7 +2,6 @@ import {
Controller,
Post,
Get,
Delete,
Body,
UseGuards,
HttpCode,
@ -72,8 +71,7 @@ export class InvitationsController {
dto.lastName,
dto.role as unknown as UserRole,
user.organizationId,
user.id,
user.role
user.id
);
return {
@ -138,29 +136,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
*/

View File

@ -3,14 +3,12 @@ import {
Post,
Get,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
UseGuards,
Inject,
} from '@nestjs/common';
import {
ApiTags,
@ -19,7 +17,6 @@ import {
ApiBadRequestResponse,
ApiInternalServerErrorResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
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 { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import {
AvailableCompaniesDto,
FilterOptionsDto,
AvailableOriginsDto,
AvailableDestinationsDto,
RoutePortInfoDto,
} from '../dto/csv-rate-upload.dto';
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
@ApiTags('Rates')
@Controller('rates')
@ -47,8 +37,7 @@ export class RatesController {
constructor(
private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper,
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
private readonly csvRateMapper: CsvRateMapper
) {}
@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
*/

View File

@ -22,8 +22,6 @@ import {
Headers,
RawBodyRequest,
Req,
Inject,
ForbiddenException,
} from '@nestjs/common';
import {
ApiTags,
@ -49,21 +47,13 @@ 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
) {}
constructor(private readonly subscriptionService: SubscriptionService) {}
/**
* Get subscription overview for current organization
@ -87,10 +77,10 @@ export class SubscriptionsController {
description: 'Forbidden - requires admin or manager role',
})
async getSubscriptionOverview(
@CurrentUser() user: UserPayload
@CurrentUser() user: UserPayload,
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role);
return this.subscriptionService.getSubscriptionOverview(user.organizationId);
}
/**
@ -136,7 +126,7 @@ export class SubscriptionsController {
})
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
this.logger.log(`[User: ${user.email}] Checking license availability`);
return this.subscriptionService.canInviteUser(user.organizationId, user.role);
return this.subscriptionService.canInviteUser(user.organizationId);
}
/**
@ -149,7 +139,8 @@ export class SubscriptionsController {
@ApiBearerAuth()
@ApiOperation({
summary: 'Create checkout session',
description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
description:
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
})
@ApiResponse({
status: 200,
@ -166,22 +157,14 @@ export class SubscriptionsController {
})
async createCheckoutSession(
@Body() dto: CreateCheckoutSessionDto,
@CurrentUser() user: UserPayload
@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);
return this.subscriptionService.createCheckoutSession(
user.organizationId,
user.id,
dto,
);
}
/**
@ -212,7 +195,7 @@ export class SubscriptionsController {
})
async createPortalSession(
@Body() dto: CreatePortalSessionDto,
@CurrentUser() user: UserPayload
@CurrentUser() user: UserPayload,
): Promise<PortalSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating portal session`);
return this.subscriptionService.createPortalSession(user.organizationId, dto);
@ -247,10 +230,10 @@ export class SubscriptionsController {
})
async syncFromStripe(
@Body() dto: SyncSubscriptionDto,
@CurrentUser() user: UserPayload
@CurrentUser() user: UserPayload,
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
);
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
}
@ -264,7 +247,7 @@ export class SubscriptionsController {
@ApiExcludeEndpoint()
async handleWebhook(
@Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request>
@Req() req: RawBodyRequest<Request>,
): Promise<{ received: boolean }> {
const rawBody = req.rawBody;
if (!rawBody) {

View File

@ -44,10 +44,8 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
@ -66,15 +64,14 @@ import { SubscriptionService } from '../services/subscription.service';
*/
@ApiTags('Users')
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@RequiresFeature('user_management')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly subscriptionService: SubscriptionService
private readonly subscriptionService: SubscriptionService,
) {}
/**
@ -287,7 +284,7 @@ export class UsersController {
} catch (error) {
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
throw new ForbiddenException(
'Cannot reactivate user: no licenses available. Please upgrade your subscription.'
'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
);
}
} else {

View File

@ -1,24 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { CsvBookingsController } from './controllers/csv-bookings.controller';
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
import { CsvBookingService } from './services/csv-booking.service';
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity';
import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { NotificationsModule } from './notifications/notifications.module';
import { EmailModule } from '../infrastructure/email/email.module';
import { StorageModule } from '../infrastructure/storage/storage.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { StripeModule } from '../infrastructure/stripe/stripe.module';
/**
* CSV Bookings Module
@ -27,31 +16,13 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module';
*/
@Module({
imports: [
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
ConfigModule,
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
NotificationsModule,
EmailModule,
StorageModule,
SubscriptionsModule,
StripeModule,
],
controllers: [CsvBookingsController, CsvBookingActionsController],
providers: [
CsvBookingService,
TypeOrmCsvBookingRepository,
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
})
export class CsvBookingsModule {}

View File

@ -7,12 +7,9 @@
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AnalyticsService } from '../services/analytics.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
@Controller('dashboard')
@UseGuards(JwtAuthGuard, FeatureFlagGuard)
@RequiresFeature('dashboard')
@UseGuards(JwtAuthGuard)
export class DashboardController {
constructor(private readonly analyticsService: AnalyticsService) {}

View File

@ -8,13 +8,11 @@ import { AnalyticsService } from '../services/analytics.service';
import { BookingsModule } from '../bookings/bookings.module';
import { RatesModule } from '../rates/rates.module';
import { CsvBookingsModule } from '../csv-bookings.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({
imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule],
imports: [BookingsModule, RatesModule, CsvBookingsModule],
controllers: [DashboardController],
providers: [AnalyticsService, FeatureFlagGuard],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class DashboardModule {}

View File

@ -1,15 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
export const REQUIRED_FEATURES_KEY = 'requiredFeatures';
/**
* Decorator to require specific plan features for a route.
* Works with FeatureFlagGuard to enforce access control.
*
* Usage:
* @RequiresFeature('dashboard')
* @RequiresFeature('csv_export', 'api_access')
*/
export const RequiresFeature = (...features: PlanFeature[]) =>
SetMetadata(REQUIRED_FEATURES_KEY, features);

View File

@ -1,63 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateApiKeyDto {
@ApiProperty({
description: 'Nom de la clé API (pour identification)',
example: 'Intégration ERP Production',
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiPropertyOptional({
description: "Date d'expiration de la clé (ISO 8601). Si absent, la clé n'expire pas.",
example: '2027-01-01T00:00:00.000Z',
})
@IsOptional()
@IsDateString()
expiresAt?: string;
}
export class ApiKeyDto {
@ApiProperty({ description: 'Identifiant unique de la clé', example: 'uuid-here' })
id: string;
@ApiProperty({ description: 'Nom de la clé', example: 'Intégration ERP Production' })
name: string;
@ApiProperty({
description: 'Préfixe de la clé (pour identification visuelle)',
example: 'xped_live_a1b2c3d4',
})
keyPrefix: string;
@ApiProperty({ description: 'La clé est-elle active', example: true })
isActive: boolean;
@ApiPropertyOptional({
description: 'Dernière utilisation de la clé',
example: '2025-03-20T14:30:00.000Z',
})
lastUsedAt: Date | null;
@ApiPropertyOptional({
description: "Date d'expiration",
example: '2027-01-01T00:00:00.000Z',
})
expiresAt: Date | null;
@ApiProperty({ description: 'Date de création', example: '2025-03-26T10:00:00.000Z' })
createdAt: Date;
}
export class CreateApiKeyResultDto extends ApiKeyDto {
@ApiProperty({
description:
'Clé API complète — affichée UNE SEULE FOIS. Conservez-la en lieu sûr, elle ne sera plus visible.',
example: 'xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
})
fullKey: string;
}

View File

@ -7,7 +7,6 @@ import {
IsEnum,
MaxLength,
Matches,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@ -23,81 +22,12 @@ export class LoginDto {
@ApiProperty({
example: 'SecurePassword123!',
description: 'Password',
})
@IsString()
password: string;
@ApiPropertyOptional({
example: true,
description: 'Remember me for extended session',
})
@IsBoolean()
@IsOptional()
rememberMe?: boolean;
}
export class ContactFormDto {
@ApiProperty({ example: 'Jean', description: 'First name' })
@IsString()
@MinLength(1)
firstName: string;
@ApiProperty({ example: 'Dupont', description: 'Last name' })
@IsString()
@MinLength(1)
lastName: string;
@ApiProperty({ example: 'jean@acme.com', description: 'Sender email' })
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@ApiPropertyOptional({ example: 'Acme Logistics', description: 'Company name' })
@IsString()
@IsOptional()
company?: string;
@ApiPropertyOptional({ example: '+33 6 12 34 56 78', description: 'Phone number' })
@IsString()
@IsOptional()
phone?: string;
@ApiProperty({ example: 'demo', description: 'Subject category' })
@IsString()
@MinLength(1)
subject: string;
@ApiProperty({ example: 'Bonjour, je souhaite...', description: 'Message body' })
@IsString()
@MinLength(10)
message: string;
}
export class ForgotPasswordDto {
@ApiProperty({
example: 'john.doe@acme.com',
description: 'Email address for password reset',
})
@IsEmail({}, { message: 'Invalid email format' })
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
example: 'abc123token...',
description: 'Password reset token from email',
})
@IsString()
token: string;
@ApiProperty({
example: 'NewSecurePassword123!',
description: 'New password (minimum 12 characters)',
description: 'Password (minimum 12 characters)',
minLength: 12,
})
@IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' })
newPassword: string;
password: string;
}
/**
@ -164,31 +94,6 @@ export class RegisterOrganizationDto {
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
country: string;
@ApiProperty({
example: '123456789',
description: 'French SIREN number (9 digits, required)',
minLength: 9,
maxLength: 9,
})
@IsString()
@MinLength(9, { message: 'SIREN must be exactly 9 digits' })
@MaxLength(9, { message: 'SIREN must be exactly 9 digits' })
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren: string;
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits, optional)',
minLength: 14,
maxLength: 14,
})
@IsString()
@IsOptional()
@MinLength(14, { message: 'SIRET must be exactly 14 digits' })
@MaxLength(14, { message: 'SIRET must be exactly 14 digits' })
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
siret?: string;
@ApiPropertyOptional({
example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',

View File

@ -1,118 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
/**
* DTO for verifying document access password
*/
export class VerifyDocumentAccessDto {
@ApiProperty({ description: 'Password for document access (booking number code)' })
@IsString()
@IsNotEmpty()
password: string;
}
/**
* Response DTO for checking document access requirements
*/
export class DocumentAccessRequirementsDto {
@ApiProperty({ description: 'Whether password is required to access documents' })
requiresPassword: boolean;
@ApiPropertyOptional({ description: 'Booking number (if available)' })
bookingNumber?: string;
@ApiProperty({ description: 'Current booking status' })
status: string;
}
/**
* Booking Summary DTO for Carrier Documents Page
*/
export class BookingSummaryDto {
@ApiProperty({ description: 'Booking unique ID' })
id: string;
@ApiPropertyOptional({ description: 'Human-readable booking number' })
bookingNumber?: string;
@ApiProperty({ description: 'Carrier/Company name' })
carrierName: string;
@ApiProperty({ description: 'Origin port code' })
origin: string;
@ApiProperty({ description: 'Destination port code' })
destination: string;
@ApiProperty({ description: 'Route description (origin -> destination)' })
routeDescription: string;
@ApiProperty({ description: 'Volume in CBM' })
volumeCBM: number;
@ApiProperty({ description: 'Weight in KG' })
weightKG: number;
@ApiProperty({ description: 'Number of pallets' })
palletCount: number;
@ApiProperty({ description: 'Price in the primary currency' })
price: number;
@ApiProperty({ description: 'Currency (USD or EUR)' })
currency: string;
@ApiProperty({ description: 'Transit time in days' })
transitDays: number;
@ApiProperty({ description: 'Container type' })
containerType: string;
@ApiProperty({ description: 'When the booking was accepted' })
acceptedAt: Date;
}
/**
* Document with signed download URL for carrier access
*/
export class DocumentWithUrlDto {
@ApiProperty({ description: 'Document unique ID' })
id: string;
@ApiProperty({
description: 'Document type',
enum: [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
],
})
type: string;
@ApiProperty({ description: 'Original file name' })
fileName: string;
@ApiProperty({ description: 'File MIME type' })
mimeType: string;
@ApiProperty({ description: 'File size in bytes' })
size: number;
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
downloadUrl: string;
}
/**
* Carrier Documents Response DTO
*
* Response for carrier document access page
*/
export class CarrierDocumentsResponseDto {
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
booking: BookingSummaryDto;
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
documents: DocumentWithUrlDto[];
}

View File

@ -1,139 +1,139 @@
/**
* Cookie Consent DTOs
* GDPR compliant consent management
*/
import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* Request DTO for recording/updating cookie consent
*/
export class UpdateConsentDto {
@ApiProperty({
example: true,
description: 'Essential cookies consent (always true, required for functionality)',
default: true,
})
@IsBoolean()
essential: boolean;
@ApiProperty({
example: false,
description: 'Functional cookies consent (preferences, language, etc.)',
default: false,
})
@IsBoolean()
functional: boolean;
@ApiProperty({
example: false,
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
default: false,
})
@IsBoolean()
analytics: boolean;
@ApiProperty({
example: false,
description: 'Marketing cookies consent (ads, tracking, remarketing)',
default: false,
})
@IsBoolean()
marketing: boolean;
@ApiPropertyOptional({
example: '192.168.1.1',
description: 'IP address at time of consent (for GDPR audit trail)',
})
@IsOptional()
@IsString()
ipAddress?: string;
@ApiPropertyOptional({
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
description: 'User agent at time of consent',
})
@IsOptional()
@IsString()
userAgent?: string;
}
/**
* Response DTO for consent status
*/
export class ConsentResponseDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'User ID',
})
userId: string;
@ApiProperty({
example: true,
description: 'Essential cookies consent (always true)',
})
essential: boolean;
@ApiProperty({
example: false,
description: 'Functional cookies consent',
})
functional: boolean;
@ApiProperty({
example: false,
description: 'Analytics cookies consent',
})
analytics: boolean;
@ApiProperty({
example: false,
description: 'Marketing cookies consent',
})
marketing: boolean;
@ApiProperty({
example: '2025-01-27T10:30:00.000Z',
description: 'Date when consent was recorded',
})
consentDate: Date;
@ApiProperty({
example: '2025-01-27T10:30:00.000Z',
description: 'Last update timestamp',
})
updatedAt: Date;
}
/**
* Request DTO for withdrawing specific consent
*/
export class WithdrawConsentDto {
@ApiProperty({
example: 'marketing',
description: 'Type of consent to withdraw',
enum: ['functional', 'analytics', 'marketing'],
})
@IsEnum(['functional', 'analytics', 'marketing'], {
message: 'Consent type must be functional, analytics, or marketing',
})
consentType: 'functional' | 'analytics' | 'marketing';
}
/**
* Success response DTO
*/
export class ConsentSuccessDto {
@ApiProperty({
example: true,
description: 'Operation success status',
})
success: boolean;
@ApiProperty({
example: 'Consent preferences saved successfully',
description: 'Response message',
})
message: string;
}
/**
* Cookie Consent DTOs
* GDPR compliant consent management
*/
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* Request DTO for recording/updating cookie consent
*/
export class UpdateConsentDto {
@ApiProperty({
example: true,
description: 'Essential cookies consent (always true, required for functionality)',
default: true,
})
@IsBoolean()
essential: boolean;
@ApiProperty({
example: false,
description: 'Functional cookies consent (preferences, language, etc.)',
default: false,
})
@IsBoolean()
functional: boolean;
@ApiProperty({
example: false,
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
default: false,
})
@IsBoolean()
analytics: boolean;
@ApiProperty({
example: false,
description: 'Marketing cookies consent (ads, tracking, remarketing)',
default: false,
})
@IsBoolean()
marketing: boolean;
@ApiPropertyOptional({
example: '192.168.1.1',
description: 'IP address at time of consent (for GDPR audit trail)',
})
@IsOptional()
@IsString()
ipAddress?: string;
@ApiPropertyOptional({
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
description: 'User agent at time of consent',
})
@IsOptional()
@IsString()
userAgent?: string;
}
/**
* Response DTO for consent status
*/
export class ConsentResponseDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'User ID',
})
userId: string;
@ApiProperty({
example: true,
description: 'Essential cookies consent (always true)',
})
essential: boolean;
@ApiProperty({
example: false,
description: 'Functional cookies consent',
})
functional: boolean;
@ApiProperty({
example: false,
description: 'Analytics cookies consent',
})
analytics: boolean;
@ApiProperty({
example: false,
description: 'Marketing cookies consent',
})
marketing: boolean;
@ApiProperty({
example: '2025-01-27T10:30:00.000Z',
description: 'Date when consent was recorded',
})
consentDate: Date;
@ApiProperty({
example: '2025-01-27T10:30:00.000Z',
description: 'Last update timestamp',
})
updatedAt: Date;
}
/**
* Request DTO for withdrawing specific consent
*/
export class WithdrawConsentDto {
@ApiProperty({
example: 'marketing',
description: 'Type of consent to withdraw',
enum: ['functional', 'analytics', 'marketing'],
})
@IsEnum(['functional', 'analytics', 'marketing'], {
message: 'Consent type must be functional, analytics, or marketing',
})
consentType: 'functional' | 'analytics' | 'marketing';
}
/**
* Success response DTO
*/
export class ConsentSuccessDto {
@ApiProperty({
example: true,
description: 'Operation success status',
})
success: boolean;
@ApiProperty({
example: 'Consent preferences saved successfully',
description: 'Response message',
})
message: string;
}

View File

@ -201,12 +201,6 @@ export class CsvBookingResponseDto {
})
id: string;
@ApiPropertyOptional({
description: 'Booking number (e.g. XPD-2026-W75VPT)',
example: 'XPD-2026-W75VPT',
})
bookingNumber?: string;
@ApiProperty({
description: 'User ID who created the booking',
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',
@ -294,8 +288,8 @@ export class CsvBookingResponseDto {
@ApiProperty({
description: 'Booking status',
enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING_PAYMENT',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING',
})
status: string;
@ -353,18 +347,6 @@ export class CsvBookingResponseDto {
example: 1850.5,
})
price: number;
@ApiPropertyOptional({
description: 'Commission rate in percent',
example: 5,
})
commissionRate?: number;
@ApiPropertyOptional({
description: 'Commission amount in EUR',
example: 313.27,
})
commissionAmountEur?: number;
}
/**
@ -426,12 +408,6 @@ export class CsvBookingListResponseDto {
* Statistics for user's or organization's bookings
*/
export class CsvBookingStatsDto {
@ApiProperty({
description: 'Number of bookings awaiting payment',
example: 1,
})
pendingPayment: number;
@ApiProperty({
description: 'Number of pending bookings',
example: 5,

View File

@ -209,101 +209,3 @@ export class FilterOptionsDto {
})
currencies: string[];
}
/**
* Port Info for Route Response DTO
* Contains port details with coordinates for map display
*/
export class RoutePortInfoDto {
@ApiProperty({
description: 'UN/LOCODE port code',
example: 'NLRTM',
})
code: string;
@ApiProperty({
description: 'Port name',
example: 'Rotterdam',
})
name: string;
@ApiProperty({
description: 'City name',
example: 'Rotterdam',
})
city: string;
@ApiProperty({
description: 'Country code (ISO 3166-1 alpha-2)',
example: 'NL',
})
country: string;
@ApiProperty({
description: 'Country full name',
example: 'Netherlands',
})
countryName: string;
@ApiProperty({
description: 'Display name for UI',
example: 'Rotterdam, Netherlands (NLRTM)',
})
displayName: string;
@ApiProperty({
description: 'Latitude coordinate',
example: 51.9244,
required: false,
})
latitude?: number;
@ApiProperty({
description: 'Longitude coordinate',
example: 4.4777,
required: false,
})
longitude?: number;
}
/**
* Available Origins Response DTO
* Returns list of origin ports that have routes in CSV rates
*/
export class AvailableOriginsDto {
@ApiProperty({
description: 'List of origin ports with available routes in CSV rates',
type: [RoutePortInfoDto],
})
origins: RoutePortInfoDto[];
@ApiProperty({
description: 'Total number of available origin ports',
example: 15,
})
total: number;
}
/**
* Available Destinations Response DTO
* Returns list of destination ports available for a given origin
*/
export class AvailableDestinationsDto {
@ApiProperty({
description: 'Origin port code that was used to filter destinations',
example: 'NLRTM',
})
origin: string;
@ApiProperty({
description: 'List of destination ports available from the given origin',
type: [RoutePortInfoDto],
})
destinations: RoutePortInfoDto[];
@ApiProperty({
description: 'Total number of available destinations for this origin',
example: 8,
})
total: number;
}

View File

@ -184,19 +184,6 @@ export class UpdateOrganizationDto {
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren?: string;
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits)',
minLength: 14,
maxLength: 14,
})
@IsString()
@IsOptional()
@MinLength(14)
@MaxLength(14)
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
siret?: string;
@ApiPropertyOptional({
example: 'FR123456789',
description: 'EU EORI number',
@ -357,25 +344,6 @@ export class OrganizationResponseDto {
})
documents: OrganizationDocumentDto[];
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits)',
})
siret?: string;
@ApiProperty({
example: false,
description: 'Whether the SIRET has been verified by an admin',
})
siretVerified: boolean;
@ApiPropertyOptional({
example: 'none',
description: 'Organization status badge',
enum: ['none', 'silver', 'gold', 'platinium'],
})
statusBadge?: string;
@ApiProperty({
example: true,
description: 'Active status',

View File

@ -5,16 +5,25 @@
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator';
import {
IsString,
IsEnum,
IsNotEmpty,
IsUrl,
IsOptional,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
/**
* Subscription plan types
*/
export enum SubscriptionPlanDto {
BRONZE = 'BRONZE',
SILVER = 'SILVER',
GOLD = 'GOLD',
PLATINIUM = 'PLATINIUM',
FREE = 'FREE',
STARTER = 'STARTER',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}
/**
@ -44,7 +53,7 @@ export enum BillingIntervalDto {
*/
export class CreateCheckoutSessionDto {
@ApiProperty({
example: SubscriptionPlanDto.SILVER,
example: SubscriptionPlanDto.STARTER,
description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto,
})
@ -188,14 +197,14 @@ export class LicenseResponseDto {
*/
export class PlanDetailsDto {
@ApiProperty({
example: SubscriptionPlanDto.SILVER,
example: SubscriptionPlanDto.STARTER,
description: 'Plan identifier',
enum: SubscriptionPlanDto,
})
plan: SubscriptionPlanDto;
@ApiProperty({
example: 'Silver',
example: 'Starter',
description: 'Plan display name',
})
name: string;
@ -207,51 +216,20 @@ export class PlanDetailsDto {
maxLicenses: number;
@ApiProperty({
example: 249,
example: 49,
description: 'Monthly price in EUR',
})
monthlyPriceEur: number;
@ApiProperty({
example: 2739,
description: 'Yearly price in EUR (11 months)',
example: 470,
description: 'Yearly price in EUR',
})
yearlyPriceEur: number;
@ApiProperty({
example: -1,
description: 'Maximum shipments per year (-1 for unlimited)',
})
maxShipmentsPerYear: number;
@ApiProperty({
example: 3,
description: 'Commission rate percentage on shipments',
})
commissionRatePercent: number;
@ApiProperty({
example: 'email',
description: 'Support level: none, email, direct, dedicated_kam',
})
supportLevel: string;
@ApiProperty({
example: 'silver',
description: 'Status badge: none, silver, gold, platinium',
})
statusBadge: string;
@ApiProperty({
example: ['dashboard', 'wiki', 'user_management', 'csv_export'],
description: 'List of plan feature flags',
type: [String],
})
planFeatures: string[];
@ApiProperty({
example: ["Jusqu'à 5 utilisateurs", 'Expéditions illimitées', 'Import CSV'],
description: 'List of human-readable features included in this plan',
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
description: 'List of features included in this plan',
type: [String],
})
features: string[];
@ -274,7 +252,7 @@ export class SubscriptionResponseDto {
organizationId: string;
@ApiProperty({
example: SubscriptionPlanDto.SILVER,
example: SubscriptionPlanDto.STARTER,
description: 'Current subscription plan',
enum: SubscriptionPlanDto,
})

View File

@ -1,55 +0,0 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiKeysService } from '../api-keys/api-keys.service';
import { JwtAuthGuard } from './jwt-auth.guard';
/**
* Combined Authentication Guard
*
* Replaces the global JwtAuthGuard to support two authentication methods:
*
* 1. **API Key** (`X-API-Key` header)
* - Validates the raw key against its stored SHA-256 hash
* - Checks the organisation subscription is GOLD or PLATINIUM in real-time
* - Sets request.user with full user/plan context
* - Available exclusively to Gold and Platinium subscribers
*
* 2. **JWT Bearer token** (`Authorization: Bearer <token>`)
* - Delegates to the existing Passport JWT strategy (unchanged behaviour)
* - Works for all subscription tiers (frontend access)
*
* Routes decorated with @Public() bypass both methods.
*
* Priority: API Key is checked first; if absent, falls back to JWT.
*/
@Injectable()
export class ApiKeyOrJwtGuard extends JwtAuthGuard {
constructor(
reflector: Reflector,
private readonly apiKeysService: ApiKeysService
) {
super(reflector);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Record<string, any>>();
const rawApiKey: string | undefined = request.headers['x-api-key'];
if (rawApiKey) {
const userContext = await this.apiKeysService.validateAndGetUser(rawApiKey);
if (!userContext) {
throw new UnauthorizedException(
"Clé API invalide, expirée ou votre abonnement ne permet plus l'accès API."
);
}
request.user = userContext;
return true;
}
// No API key header — use standard JWT flow (handles @Public() too)
return super.canActivate(context) as Promise<boolean>;
}
}

View File

@ -1,108 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator';
/**
* Feature Flag Guard
*
* Checks if the user's subscription plan includes the required features.
* First tries to read plan from JWT payload (fast path), falls back to DB lookup.
*
* Usage:
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
* @RequiresFeature('dashboard')
*/
@Injectable()
export class FeatureFlagGuard implements CanActivate {
private readonly logger = new Logger(FeatureFlagGuard.name);
constructor(
private readonly reflector: Reflector,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get required features from @RequiresFeature() decorator
const requiredFeatures = this.reflector.getAllAndOverride<PlanFeature[]>(
REQUIRED_FEATURES_KEY,
[context.getHandler(), context.getClass()]
);
// If no features are required, allow access
if (!requiredFeatures || requiredFeatures.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.organizationId) {
return false;
}
// ADMIN users have full access to all features — no plan check needed
if (user.role === 'ADMIN') {
return true;
}
// Fast path: check plan features from JWT payload
if (user.planFeatures && Array.isArray(user.planFeatures)) {
const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature));
if (hasAllFeatures) {
return true;
}
// JWT says no — but JWT might be stale after an upgrade.
// Fall through to DB check.
}
// Slow path: DB lookup for fresh subscription data
try {
const subscription = await this.subscriptionRepository.findByOrganizationId(
user.organizationId
);
if (!subscription) {
// No subscription means Bronze (free) plan — no premium features
this.throwFeatureRequired(requiredFeatures);
}
const plan = subscription!.plan;
const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature));
if (missingFeatures.length > 0) {
this.throwFeatureRequired(requiredFeatures);
}
return true;
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Failed to check subscription features: ${error}`);
// On DB error, deny access to premium features rather than 500
this.throwFeatureRequired(requiredFeatures);
}
}
private throwFeatureRequired(features: PlanFeature[]): never {
const featureNames = features.join(', ');
throw new ForbiddenException(
`Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.`
);
}
}

View File

@ -1,3 +1,2 @@
export * from './jwt-auth.guard';
export * from './roles.guard';
export * from './api-key-or-jwt.guard';

View File

@ -1,98 +0,0 @@
import {
Controller,
Get,
Query,
Res,
UseGuards,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
@Controller('logs')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
export class LogsController {
private readonly logExporterUrl: string;
constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>(
'LOG_EXPORTER_URL',
'http://xpeditis-log-exporter:3200',
);
}
/**
* GET /api/v1/logs/services
* Proxy log-exporter /api/logs/services
*/
@Get('services')
async getServices() {
try {
const res = await fetch(`${this.logExporterUrl}/api/logs/services`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
return res.json();
} catch (err: any) {
throw new HttpException(
{ error: err.message },
HttpStatus.BAD_GATEWAY,
);
}
}
/**
* GET /api/v1/logs/export
* Proxy log-exporter /api/logs/export (JSON or CSV)
*/
@Get('export')
async exportLogs(
@Query('service') service: string,
@Query('level') level: string,
@Query('search') search: string,
@Query('start') start: string,
@Query('end') end: string,
@Query('limit') limit: string,
@Query('format') format: string = 'json',
@Res() res: Response,
) {
try {
const params = new URLSearchParams();
if (service) params.set('service', service);
if (level) params.set('level', level);
if (search) params.set('search', search);
if (start) params.set('start', start);
if (end) params.set('end', end);
if (limit) params.set('limit', limit);
params.set('format', format);
const upstream = await fetch(
`${this.logExporterUrl}/api/logs/export?${params}`,
{ signal: AbortSignal.timeout(30000) },
);
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({}));
throw new HttpException(body, upstream.status);
}
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
if (['content-type', 'content-disposition'].includes(key.toLowerCase())) {
res.setHeader(key, value);
}
});
const buffer = await upstream.arrayBuffer();
res.send(Buffer.from(buffer));
} catch (err: any) {
if (err instanceof HttpException) throw err;
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
}
}
}

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LogsController } from './logs.controller';
@Module({
imports: [ConfigModule],
controllers: [LogsController],
})
export class LogsModule {}

View File

@ -31,9 +31,6 @@ export class OrganizationMapper {
address: this.mapAddressToDto(organization.address),
logoUrl: organization.logoUrl,
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
siret: organization.siret,
siretVerified: organization.siretVerified,
statusBadge: organization.statusBadge,
isActive: organization.isActive,
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,

View File

@ -1,13 +1,5 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
Inject,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
import { PortCode } from '@domain/value-objects/port-code.vo';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
@ -16,9 +8,7 @@ import {
NOTIFICATION_REPOSITORY,
} from '@domain/ports/out/notification.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import {
Notification,
NotificationType,
@ -31,8 +21,6 @@ import {
CsvBookingListResponseDto,
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service';
/**
* CSV Booking Document (simple class for domain)
@ -65,35 +53,9 @@ export class CsvBookingService {
@Inject(EMAIL_PORT)
private readonly emailAdapter: EmailPort,
@Inject(STORAGE_PORT)
private readonly storageAdapter: StoragePort,
@Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort,
private readonly subscriptionService: SubscriptionService,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository
private readonly storageAdapter: StoragePort
) {}
/**
* Generate a unique booking number
* Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9)
*/
private generateBookingNumber(): string {
const year = new Date().getFullYear();
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return `XPD-${year}-${code}`;
}
/**
* Extract the password from booking number (last 6 characters)
*/
private extractPasswordFromBookingNumber(bookingNumber: string): string {
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
}
/**
* Create a new CSV booking request
*/
@ -110,30 +72,14 @@ export class CsvBookingService {
throw new BadRequestException('At least one document is required');
}
// Generate unique confirmation token and booking number
// Generate unique confirmation token
const confirmationToken = uuidv4();
const bookingId = uuidv4();
const bookingNumber = this.generateBookingNumber();
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber);
// Hash the password for storage
const passwordHash = await argon2.hash(documentPassword);
// Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId);
// Calculate commission based on organization's subscription plan
let commissionRate = 5; // default Bronze
let commissionAmountEur = 0;
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
commissionRate = subscription.plan.commissionRatePercent;
} catch (error: any) {
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
// Create domain entity
const booking = new CsvBooking(
bookingId,
userId,
@ -150,423 +96,65 @@ export class CsvBookingService {
dto.primaryCurrency,
dto.transitDays,
dto.containerType,
CsvBookingStatus.PENDING_PAYMENT,
CsvBookingStatus.PENDING,
documents,
confirmationToken,
new Date(),
undefined,
dto.notes,
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
dto.notes
);
// Save to database
const savedBooking = await this.csvBookingRepository.create(booking);
this.logger.log(`CSV booking created with ID: ${bookingId}`);
// Update ORM entity with booking number and password hash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.bookingNumber = bookingNumber;
ormBooking.passwordHash = passwordHash;
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}`
);
// NO email sent to carrier yet - will be sent after commission payment
// NO notification yet - will be created after payment confirmation
return this.toResponseDto(savedBooking);
}
/**
* Create a Stripe Checkout session for commission payment
*/
async createCommissionPayment(
bookingId: string,
userId: string,
userEmail: string,
frontendUrl: string
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
const commissionAmountEur = booking.commissionAmountEur || 0;
if (commissionAmountEur <= 0) {
throw new BadRequestException('Commission amount is invalid');
}
const amountCents = Math.round(commissionAmountEur * 100);
const result = await this.stripeAdapter.createCommissionCheckout({
bookingId: booking.id,
amountCents,
currency: 'eur',
customerEmail: userEmail,
organizationId: booking.organizationId,
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`,
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
});
this.logger.log(
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
);
return {
sessionUrl: result.sessionUrl,
sessionId: result.sessionId,
commissionAmountEur,
};
}
/**
* Confirm commission payment and activate booking
* Called after Stripe redirect with session_id
*/
async confirmCommissionPayment(
bookingId: string,
sessionId: string,
userId: string
): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
// Already confirmed - return current state
if (booking.status === CsvBookingStatus.PENDING) {
return this.toResponseDto(booking);
}
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
// Verify payment with Stripe
const session = await this.stripeAdapter.getCheckoutSession(sessionId);
if (!session || session.status !== 'complete') {
throw new BadRequestException('Payment has not been completed');
}
// Verify the session is for this booking
if (session.metadata?.bookingId !== bookingId) {
throw new BadRequestException('Payment session does not match this booking');
}
// Transition to PENDING
booking.markPaymentCompleted();
booking.stripePaymentIntentId = sessionId;
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`);
// Get ORM entity for booking number
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// NOW send email to carrier
// Send email to carrier and WAIT for confirmation
// The button waits for the email to be sent before responding
try {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId,
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
transitDays: dto.transitDays,
containerType: dto.containerType,
documents: documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken: booking.confirmationToken,
notes: booking.notes,
confirmationToken,
});
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
// Continue even if email fails - booking is already saved
}
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
userId,
organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
message: `Your booking request to ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`,
metadata: { bookingId, carrierName: dto.carrierName },
});
await this.notificationRepository.save(notification);
this.logger.log(`Notification created for user ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
// Continue even if notification fails
}
return this.toResponseDto(updatedBooking);
}
/**
* Declare bank transfer user confirms they have sent the wire transfer
* Transitions booking from PENDING_PAYMENT PENDING_BANK_TRANSFER
* Sends an email notification to all ADMIN users
*/
async declareBankTransfer(bookingId: string, userId: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
// Get booking number before update
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase();
booking.markBankTransferDeclared();
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`);
// Send email to all ADMIN users
try {
const allUsers = await this.userRepository.findAll();
const adminEmails = allUsers
.filter(u => u.role === 'ADMIN' && u.isActive)
.map(u => u.email);
if (adminEmails.length > 0) {
const commissionAmount = booking.commissionAmountEur
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur)
: 'N/A';
await this.emailAdapter.send({
to: adminEmails,
subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
<td style="padding: 8px 12px;">${bookingNumber}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
<td style="padding: 8px 12px;">${booking.carrierName}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
</tr>
</table>
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
Voir les bookings en attente
</a>
</div>
`,
});
this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`);
}
} catch (error: any) {
this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack);
}
// In-app notification for the user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.BOOKING_UPDATED,
priority: NotificationPriority.MEDIUM,
title: 'Virement déclaré',
message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`,
metadata: { bookingId: booking.id },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Resend carrier email for a booking (admin action)
* Works regardless of payment status useful for retrying failed emails or testing without Stripe.
*/
async resendCarrierEmail(bookingId: string): Promise<void> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`);
}
/**
* Admin validates bank transfer confirms receipt and activates booking
* Transitions booking from PENDING_BANK_TRANSFER PENDING then sends email to carrier
*/
async validateBankTransfer(bookingId: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
throw new BadRequestException(
`Booking is not awaiting bank transfer validation. Current status: ${booking.status}`
);
}
booking.markBankTransferValidated();
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`);
// Get booking number for email
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send email to carrier
try {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
}
// In-app notification for the user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.BOOKING_CONFIRMED,
priority: NotificationPriority.HIGH,
title: 'Virement validé — Booking activé',
message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`,
metadata: { bookingId: booking.id },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
return this.toResponseDto(savedBooking);
}
/**
@ -613,130 +201,6 @@ export class CsvBookingService {
return this.toResponseDto(booking);
}
/**
* Verify password and get booking documents for carrier (public endpoint)
* Only accessible for ACCEPTED bookings with correct password
*/
async getDocumentsForCarrier(
token: string,
password?: string
): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`);
// Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
// Only allow access for ACCEPTED bookings
if (ormBooking.status !== 'ACCEPTED') {
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
}
// Check if password protection is enabled for this booking
if (ormBooking.passwordHash) {
if (!password) {
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
}
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Mot de passe incorrect');
}
}
// Get domain booking for business logic
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Réservation introuvable');
}
// Generate signed URLs for all documents
const documentsWithUrls = await Promise.all(
booking.documents.map(async doc => {
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
return {
id: doc.id,
type: doc.type,
fileName: doc.fileName,
mimeType: doc.mimeType,
size: doc.size,
downloadUrl: signedUrl,
};
})
);
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
booking: {
id: booking.id,
bookingNumber: ormBooking.bookingNumber || undefined,
carrierName: booking.carrierName,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
routeDescription: booking.getRouteDescription(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
price: booking.getPriceInCurrency(primaryCurrency),
currency: primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
acceptedAt: booking.respondedAt!,
},
documents: documentsWithUrls,
};
}
/**
* Check if a booking requires password for document access
*/
async checkDocumentAccessRequirements(
token: string
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
if (!ormBooking) {
throw new NotFoundException('Réservation introuvable');
}
return {
requiresPassword: !!ormBooking.passwordHash,
bookingNumber: ormBooking.bookingNumber || undefined,
status: ormBooking.status,
};
}
/**
* Generate signed URL for a document file path
*/
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
const bucket = 'xpeditis-documents';
// Extract key from the file path
let key = filePath;
if (filePath.includes('xpeditis-documents/')) {
key = filePath.split('xpeditis-documents/')[1];
} else if (filePath.startsWith('http')) {
const url = new URL(filePath);
key = url.pathname.replace(/^\//, '');
if (key.startsWith('xpeditis-documents/')) {
key = key.replace('xpeditis-documents/', '');
}
}
// Generate signed URL with 1 hour expiration
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
return signedUrl;
}
/**
* Accept a booking request
*/
@ -749,58 +213,13 @@ export class CsvBookingService {
throw new NotFoundException('Booking not found');
}
// Get ORM entity for bookingNumber
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { confirmationToken: token },
});
// Accept the booking (domain logic validates status)
booking.accept();
// Apply commission based on organization's subscription plan
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
booking.organizationId
);
const commissionRate = subscription.plan.commissionRatePercent;
const baseAmountEur = booking.priceEUR;
booking.applyCommission(commissionRate, baseAmountEur);
this.logger.log(
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}`
);
} catch (error: any) {
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
}
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`);
// Extract password from booking number for the email
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send document access email to carrier
try {
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
bookingNumber: bookingNumber || undefined,
documentPassword: documentPassword,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
documentCount: booking.documents.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
}
// Create notification for user
try {
const notification = Notification.create({
@ -946,7 +365,6 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -962,7 +380,6 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -1058,15 +475,9 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING &&
booking.status !== CsvBookingStatus.ACCEPTED
) {
throw new BadRequestException(
'Cannot add documents to a booking that is rejected or cancelled'
);
// Verify booking is still pending
if (booking.status !== CsvBookingStatus.PENDING) {
throw new BadRequestException('Cannot add documents to a booking that is not pending');
}
// Upload new documents
@ -1095,27 +506,6 @@ export class CsvBookingService {
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
// If booking is ACCEPTED, notify carrier about new documents
if (booking.status === CsvBookingStatus.ACCEPTED) {
try {
await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, {
carrierName: booking.carrierName,
bookingId: booking.id,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
newDocumentsCount: newDocuments.length,
totalDocumentsCount: updatedDocuments.length,
confirmationToken: booking.confirmationToken,
});
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
}
}
return {
success: true,
message: 'Documents added successfully',
@ -1144,11 +534,8 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify booking is still pending or awaiting payment
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING
) {
// Verify booking is still pending
if (booking.status !== CsvBookingStatus.PENDING) {
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
}
@ -1263,9 +650,7 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
);
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
return {
success: true,
@ -1316,7 +701,6 @@ export class CsvBookingService {
return {
id: booking.id,
bookingNumber: booking.bookingNumber,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,
@ -1341,8 +725,6 @@ export class CsvBookingService {
routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur,
};
}

View File

@ -120,7 +120,10 @@ export class GDPRService {
/**
* Record or update consent (GDPR Article 7 - Conditions for consent)
*/
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
async recordConsent(
userId: string,
consentData: UpdateConsentDto
): Promise<ConsentResponseDto> {
this.logger.log(`Recording consent for user ${userId}`);
// Verify user exists

View File

@ -38,7 +38,7 @@ export class InvitationService {
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService
private readonly subscriptionService: SubscriptionService,
) {}
/**
@ -50,8 +50,7 @@ export class InvitationService {
lastName: string,
role: UserRole,
organizationId: string,
invitedById: string,
inviterRole?: string
invitedById: string
): Promise<InvitationToken> {
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
@ -70,14 +69,14 @@ export class InvitationService {
}
// Check if licenses are available for this organization
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
if (!canInviteResult.canInvite) {
this.logger.warn(
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
);
throw new ForbiddenException(
canInviteResult.message ||
`License limit reached. Please upgrade your subscription to invite more users.`
`License limit reached. Please upgrade your subscription to invite more users.`,
);
}
@ -220,25 +219,6 @@ export class InvitationService {
}
}
/**
* Cancel (delete) a pending invitation
*/
async cancelInvitation(invitationId: string, organizationId: string): Promise<void> {
const invitations = await this.invitationRepository.findByOrganization(organizationId);
const invitation = invitations.find(inv => inv.id === invitationId);
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.isUsed) {
throw new BadRequestException('Cannot delete an invitation that has already been used');
}
await this.invitationRepository.deleteById(invitationId);
this.logger.log(`Invitation ${invitationId} cancelled`);
}
/**
* Cleanup expired invitations (can be called by a cron job)
*/

View File

@ -4,14 +4,24 @@
* Business logic for subscription and license management.
*/
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import {
Injectable,
Inject,
Logger,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
import {
LicenseRepository,
LICENSE_REPOSITORY,
} from '@domain/ports/out/license.repository';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
@ -20,10 +30,14 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import { Subscription } from '@domain/entities/subscription.entity';
import { License } from '@domain/entities/license.entity';
import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
import {
SubscriptionPlan,
SubscriptionPlanType,
} from '@domain/value-objects/subscription-plan.vo';
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
import {
NoLicensesAvailableException,
SubscriptionNotFoundException,
LicenseAlreadyAssignedException,
} from '@domain/exceptions/subscription.exceptions';
import {
@ -55,54 +69,50 @@ export class SubscriptionService {
private readonly userRepository: UserRepository,
@Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort,
private readonly configService: ConfigService
private readonly configService: ConfigService,
) {}
/**
* Get subscription overview for an organization
* ADMIN users always see a PLATINIUM plan with no expiration
*/
async getSubscriptionOverview(
organizationId: string,
userRole?: string
): Promise<SubscriptionOverviewResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId);
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id);
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(
subscription.id,
);
// Enrich licenses with user information
const enrichedLicenses = await Promise.all(
activeLicenses.map(async license => {
activeLicenses.map(async (license) => {
const user = await this.userRepository.findById(license.userId);
return this.mapLicenseToDto(license, user);
})
}),
);
// Count only non-ADMIN licenses for quota calculation
// ADMIN users have unlimited licenses and don't count against the quota
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id
subscription.id,
);
// ADMIN users always have PLATINIUM plan with no expiration
const isAdmin = userRole === 'ADMIN';
const effectivePlan = isAdmin ? SubscriptionPlan.platinium() : subscription.plan;
const maxLicenses = effectivePlan.maxLicenses;
const availableLicenses = effectivePlan.isUnlimited()
const maxLicenses = subscription.maxLicenses;
const availableLicenses = subscription.isUnlimited()
? -1
: Math.max(0, maxLicenses - usedLicenses);
return {
id: subscription.id,
organizationId: subscription.organizationId,
plan: effectivePlan.value as SubscriptionPlanDto,
planDetails: this.mapPlanToDto(effectivePlan),
plan: subscription.plan.value as SubscriptionPlanDto,
planDetails: this.mapPlanToDto(subscription.plan),
status: subscription.status.value as SubscriptionStatusDto,
usedLicenses,
maxLicenses,
availableLicenses,
cancelAtPeriodEnd: false,
currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined,
currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
currentPeriodStart: subscription.currentPeriodStart || undefined,
currentPeriodEnd: subscription.currentPeriodEnd || undefined,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
licenses: enrichedLicenses,
@ -113,35 +123,27 @@ export class SubscriptionService {
* Get all available plans
*/
getAllPlans(): AllPlansResponseDto {
const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan));
const plans = SubscriptionPlan.getAllPlans().map((plan) =>
this.mapPlanToDto(plan),
);
return { plans };
}
/**
* Check if organization can invite more users
* Note: ADMIN users don't count against the license quota and always have unlimited licenses
* Note: ADMIN users don't count against the license quota
*/
async canInviteUser(organizationId: string, userRole?: string): Promise<CanInviteResponseDto> {
// ADMIN users always have unlimited invitations
if (userRole === 'ADMIN') {
return {
canInvite: true,
availableLicenses: -1,
usedLicenses: 0,
maxLicenses: -1,
message: undefined,
};
}
async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId);
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id
subscription.id,
);
const maxLicenses = subscription.maxLicenses;
const canInvite =
subscription.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses);
subscription.isActive() &&
(subscription.isUnlimited() || usedLicenses < maxLicenses);
const availableLicenses = subscription.isUnlimited()
? -1
@ -169,7 +171,7 @@ export class SubscriptionService {
async createCheckoutSession(
organizationId: string,
userId: string,
dto: CreateCheckoutSessionDto
dto: CreateCheckoutSessionDto,
): Promise<CheckoutSessionResponseDto> {
const organization = await this.organizationRepository.findById(organizationId);
if (!organization) {
@ -182,19 +184,23 @@ export class SubscriptionService {
}
// Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.BRONZE) {
throw new BadRequestException('Cannot create checkout session for Bronze plan');
if (dto.plan === SubscriptionPlanDto.FREE) {
throw new BadRequestException('Cannot create checkout session for FREE plan');
}
const subscription = await this.getOrCreateSubscription(organizationId);
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
const successUrl =
dto.successUrl ||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl =
dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`;
dto.cancelUrl ||
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
const result = await this.stripeAdapter.createCheckoutSession({
organizationId,
@ -208,7 +214,7 @@ export class SubscriptionService {
});
this.logger.log(
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`,
);
return {
@ -222,18 +228,24 @@ export class SubscriptionService {
*/
async createPortalSession(
organizationId: string,
dto: CreatePortalSessionDto
dto: CreatePortalSessionDto,
): Promise<PortalSessionResponseDto> {
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
const subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription?.stripeCustomerId) {
throw new BadRequestException(
'No Stripe customer found for this organization. Please complete a checkout first.'
'No Stripe customer found for this organization. Please complete a checkout first.',
);
}
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
const returnUrl =
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
const result = await this.stripeAdapter.createPortalSession({
customerId: subscription.stripeCustomerId,
@ -255,9 +267,11 @@ export class SubscriptionService {
*/
async syncFromStripe(
organizationId: string,
sessionId?: string
sessionId?: string,
): Promise<SubscriptionOverviewResponseDto> {
let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
let subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription) {
subscription = await this.getOrCreateSubscription(organizationId);
@ -269,14 +283,12 @@ export class SubscriptionService {
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details
// This is important for upgrades where Stripe may create a new subscription
if (sessionId) {
this.logger.log(
`Retrieving checkout session ${sessionId} for organization ${organizationId}`
);
this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`);
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
if (checkoutSession) {
this.logger.log(
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`,
);
// Always use the subscription ID from the checkout session if available
@ -318,7 +330,7 @@ export class SubscriptionService {
if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id
subscription.id,
);
const newPlan = SubscriptionPlan.create(plan);
@ -342,13 +354,13 @@ export class SubscriptionService {
// Update status
updatedSubscription = updatedSubscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status)
SubscriptionStatus.fromStripeStatus(stripeData.status),
);
await this.subscriptionRepository.save(updatedSubscription);
this.logger.log(
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`,
);
return this.getSubscriptionOverview(organizationId);
@ -406,14 +418,14 @@ export class SubscriptionService {
if (!isAdmin) {
// Count only non-ADMIN licenses for quota check
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id
subscription.id,
);
if (!subscription.canAllocateLicenses(usedLicenses)) {
throw new NoLicensesAvailableException(
organizationId,
usedLicenses,
subscription.maxLicenses
subscription.maxLicenses,
);
}
}
@ -462,18 +474,22 @@ export class SubscriptionService {
* Get or create a subscription for an organization
*/
async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
let subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription) {
// Create FREE subscription for the organization
subscription = Subscription.create({
id: uuidv4(),
organizationId,
plan: SubscriptionPlan.bronze(),
plan: SubscriptionPlan.free(),
});
subscription = await this.subscriptionRepository.save(subscription);
this.logger.log(`Created Bronze subscription for organization ${organizationId}`);
this.logger.log(
`Created FREE subscription for organization ${organizationId}`,
);
}
return subscription;
@ -481,7 +497,9 @@ export class SubscriptionService {
// Private helper methods
private async handleCheckoutCompleted(session: Record<string, unknown>): Promise<void> {
private async handleCheckoutCompleted(
session: Record<string, unknown>,
): Promise<void> {
const metadata = session.metadata as Record<string, string> | undefined;
const organizationId = metadata?.organizationId;
const customerId = session.customer as string;
@ -519,26 +537,27 @@ export class SubscriptionService {
});
subscription = subscription.updatePlan(
SubscriptionPlan.create(plan),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
);
subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeSubscription.status)
SubscriptionStatus.fromStripeStatus(stripeSubscription.status),
);
await this.subscriptionRepository.save(subscription);
// Update organization status badge to match the plan
await this.updateOrganizationBadge(organizationId, subscription.statusBadge);
this.logger.log(`Updated subscription for organization ${organizationId} to plan ${plan}`);
this.logger.log(
`Updated subscription for organization ${organizationId} to plan ${plan}`,
);
}
private async handleSubscriptionUpdated(
stripeSubscription: Record<string, unknown>
stripeSubscription: Record<string, unknown>,
): Promise<void> {
const subscriptionId = stripeSubscription.id as string;
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId);
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
subscriptionId,
);
if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
@ -557,7 +576,7 @@ export class SubscriptionService {
if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id
subscription.id,
);
const newPlan = SubscriptionPlan.create(plan);
@ -565,7 +584,9 @@ export class SubscriptionService {
if (newPlan.canAccommodateUsers(usedLicenses)) {
subscription = subscription.updatePlan(newPlan, usedLicenses);
} else {
this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`);
this.logger.warn(
`Cannot update to plan ${plan} - would exceed license limit`,
);
}
}
@ -576,26 +597,22 @@ export class SubscriptionService {
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
});
subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status)
SubscriptionStatus.fromStripeStatus(stripeData.status),
);
await this.subscriptionRepository.save(subscription);
// Update organization status badge to match the plan
if (subscription.organizationId) {
await this.updateOrganizationBadge(subscription.organizationId, subscription.statusBadge);
}
this.logger.log(`Updated subscription ${subscriptionId}`);
}
private async handleSubscriptionDeleted(
stripeSubscription: Record<string, unknown>
stripeSubscription: Record<string, unknown>,
): Promise<void> {
const subscriptionId = stripeSubscription.id as string;
const subscription =
await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId);
const subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
subscriptionId,
);
if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
@ -605,41 +622,42 @@ export class SubscriptionService {
// Downgrade to FREE plan - count only non-ADMIN licenses
const canceledSubscription = subscription
.updatePlan(
SubscriptionPlan.bronze(),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
SubscriptionPlan.free(),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
)
.updateStatus(SubscriptionStatus.canceled());
await this.subscriptionRepository.save(canceledSubscription);
// Reset organization badge to 'none' on cancellation
if (subscription.organizationId) {
await this.updateOrganizationBadge(subscription.organizationId, 'none');
}
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`);
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`);
}
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
const customerId = invoice.customer as string;
const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId);
const subscription = await this.subscriptionRepository.findByStripeCustomerId(
customerId,
);
if (!subscription) {
this.logger.warn(`Subscription for customer ${customerId} not found`);
return;
}
const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue());
const updatedSubscription = subscription.updateStatus(
SubscriptionStatus.pastDue(),
);
await this.subscriptionRepository.save(updatedSubscription);
this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`);
this.logger.log(
`Subscription ${subscription.id} marked as past due due to payment failure`,
);
}
private mapLicenseToDto(
license: License,
user: { email: string; firstName: string; lastName: string; role: string } | null
user: { email: string; firstName: string; lastName: string; role: string } | null,
): LicenseResponseDto {
return {
id: license.id,
@ -653,19 +671,6 @@ export class SubscriptionService {
};
}
private async updateOrganizationBadge(organizationId: string, badge: string): Promise<void> {
try {
const organization = await this.organizationRepository.findById(organizationId);
if (organization) {
organization.updateStatusBadge(badge as 'none' | 'silver' | 'gold' | 'platinium');
await this.organizationRepository.save(organization);
this.logger.log(`Updated status badge for organization ${organizationId} to ${badge}`);
}
} catch (error: any) {
this.logger.error(`Failed to update organization badge: ${error?.message}`, error?.stack);
}
}
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
return {
plan: plan.value as SubscriptionPlanDto,
@ -673,11 +678,6 @@ export class SubscriptionService {
maxLicenses: plan.maxLicenses,
monthlyPriceEur: plan.monthlyPriceEur,
yearlyPriceEur: plan.yearlyPriceEur,
maxShipmentsPerYear: plan.maxShipmentsPerYear,
commissionRatePercent: plan.commissionRatePercent,
supportLevel: plan.supportLevel,
statusBadge: plan.statusBadge,
planFeatures: [...plan.planFeatures],
features: [...plan.features],
};
}

View File

@ -7,13 +7,14 @@ import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule],
imports: [
TypeOrmModule.forFeature([UserOrmEntity]),
SubscriptionsModule,
],
controllers: [UsersController],
providers: [
FeatureFlagGuard,
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,

View File

@ -1,135 +0,0 @@
/**
* ApiKey Entity
*
* Represents a programmatic API key for an organization.
* Only GOLD and PLATINIUM subscribers can create and use API keys.
*
* Security model:
* - The raw key is NEVER persisted only its SHA-256 hash is stored.
* - The full key is returned exactly once, at creation time.
* - The keyPrefix (first 16 chars) is stored for display purposes.
*/
export interface ApiKeyProps {
readonly id: string;
readonly organizationId: string;
readonly userId: string;
readonly name: string;
readonly keyHash: string;
readonly keyPrefix: string;
readonly isActive: boolean;
readonly lastUsedAt: Date | null;
readonly expiresAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export class ApiKey {
private readonly props: ApiKeyProps;
private constructor(props: ApiKeyProps) {
this.props = props;
}
static create(params: {
id: string;
organizationId: string;
userId: string;
name: string;
keyHash: string;
keyPrefix: string;
expiresAt?: Date | null;
}): ApiKey {
const now = new Date();
return new ApiKey({
id: params.id,
organizationId: params.organizationId,
userId: params.userId,
name: params.name,
keyHash: params.keyHash,
keyPrefix: params.keyPrefix,
isActive: true,
lastUsedAt: null,
expiresAt: params.expiresAt ?? null,
createdAt: now,
updatedAt: now,
});
}
static fromPersistence(props: ApiKeyProps): ApiKey {
return new ApiKey(props);
}
get id(): string {
return this.props.id;
}
get organizationId(): string {
return this.props.organizationId;
}
get userId(): string {
return this.props.userId;
}
get name(): string {
return this.props.name;
}
get keyHash(): string {
return this.props.keyHash;
}
get keyPrefix(): string {
return this.props.keyPrefix;
}
get isActive(): boolean {
return this.props.isActive;
}
get lastUsedAt(): Date | null {
return this.props.lastUsedAt;
}
get expiresAt(): Date | null {
return this.props.expiresAt;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
isExpired(): boolean {
if (!this.props.expiresAt) return false;
return this.props.expiresAt < new Date();
}
isValid(): boolean {
return this.props.isActive && !this.isExpired();
}
revoke(): ApiKey {
return new ApiKey({
...this.props,
isActive: false,
updatedAt: new Date(),
});
}
recordUsage(): ApiKey {
return new ApiKey({
...this.props,
lastUsedAt: new Date(),
updatedAt: new Date(),
});
}
toObject(): ApiKeyProps {
return { ...this.props };
}
}

View File

@ -50,8 +50,6 @@ export interface BookingProps {
cargoDescription: string;
containers: BookingContainer[];
specialInstructions?: string;
commissionRate?: number;
commissionAmountEur?: number;
createdAt: Date;
updatedAt: Date;
}
@ -163,14 +161,6 @@ export class Booking {
return this.props.specialInstructions;
}
get commissionRate(): number | undefined {
return this.props.commissionRate;
}
get commissionAmountEur(): number | undefined {
return this.props.commissionAmountEur;
}
get createdAt(): Date {
return this.props.createdAt;
}
@ -280,19 +270,6 @@ export class Booking {
});
}
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): Booking {
const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100;
return new Booking({
...this.props,
commissionRate: ratePercent,
commissionAmountEur: commissionAmount,
updatedAt: new Date(),
});
}
/**
* Check if booking can be cancelled
*/

View File

@ -6,8 +6,6 @@ import { PortCode } from '../value-objects/port-code.vo';
* Represents the lifecycle of a CSV-based booking request
*/
export enum CsvBookingStatus {
PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment
PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation
PENDING = 'PENDING', // Awaiting carrier response
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
REJECTED = 'REJECTED', // Carrier rejected the booking
@ -81,11 +79,7 @@ export class CsvBooking {
public readonly requestedAt: Date,
public respondedAt?: Date,
public notes?: string,
public rejectionReason?: string,
public readonly bookingNumber?: string,
public commissionRate?: number,
public commissionAmountEur?: number,
public stripePaymentIntentId?: string
public rejectionReason?: string
) {
this.validate();
}
@ -149,61 +143,6 @@ export class CsvBooking {
}
}
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): void {
this.commissionRate = ratePercent;
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
}
/**
* Mark commission payment as completed transition to PENDING
*
* @throws Error if booking is not in PENDING_PAYMENT status
*/
markPaymentCompleted(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING;
}
/**
* Declare bank transfer transition to PENDING_BANK_TRANSFER
* Called when user confirms they have sent the bank transfer
*
* @throws Error if booking is not in PENDING_PAYMENT status
*/
markBankTransferDeclared(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING_BANK_TRANSFER;
}
/**
* Admin validates bank transfer transition to PENDING
* Called by admin once bank transfer has been received and verified
*
* @throws Error if booking is not in PENDING_BANK_TRANSFER status
*/
markBankTransferValidated(): void {
if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
throw new Error(
`Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING;
}
/**
* Accept the booking
*
@ -262,10 +201,6 @@ export class CsvBooking {
throw new Error('Cannot cancel rejected booking');
}
if (this.status === CsvBookingStatus.CANCELLED) {
throw new Error('Booking is already cancelled');
}
this.status = CsvBookingStatus.CANCELLED;
this.respondedAt = new Date();
}
@ -275,10 +210,6 @@ export class CsvBooking {
*
* @returns true if booking is older than 7 days and still pending
*/
isPendingPayment(): boolean {
return this.status === CsvBookingStatus.PENDING_PAYMENT;
}
isExpired(): boolean {
if (this.status !== CsvBookingStatus.PENDING) {
return false;
@ -430,11 +361,7 @@ export class CsvBooking {
requestedAt: Date,
respondedAt?: Date,
notes?: string,
rejectionReason?: string,
bookingNumber?: string,
commissionRate?: number,
commissionAmountEur?: number,
stripePaymentIntentId?: string
rejectionReason?: string
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
@ -462,10 +389,6 @@ export class CsvBooking {
booking.respondedAt = respondedAt;
booking.notes = notes;
booking.rejectionReason = rejectionReason;
booking.bookingNumber = bookingNumber;
booking.commissionRate = commissionRate;
booking.commissionAmountEur = commissionAmountEur;
booking.stripePaymentIntentId = stripePaymentIntentId;
return booking;
}

View File

@ -5,7 +5,10 @@
* Each active user in an organization consumes one license.
*/
import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo';
import {
LicenseStatus,
LicenseStatusType,
} from '../value-objects/license-status.vo';
export interface LicenseProps {
readonly id: string;
@ -26,7 +29,11 @@ export class License {
/**
* Create a new license for a user
*/
static create(props: { id: string; subscriptionId: string; userId: string }): License {
static create(props: {
id: string;
subscriptionId: string;
userId: string;
}): License {
return new License({
id: props.id,
subscriptionId: props.subscriptionId,

View File

@ -44,9 +44,6 @@ export interface OrganizationProps {
address: OrganizationAddress;
logoUrl?: string;
documents: OrganizationDocument[];
siret?: string;
siretVerified: boolean;
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
createdAt: Date;
updatedAt: Date;
isActive: boolean;
@ -62,19 +59,9 @@ export class Organization {
/**
* Factory method to create a new Organization
*/
static create(
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
siretVerified?: boolean;
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
}
): Organization {
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization {
const now = new Date();
// Validate SIRET if provided
if (props.siret && !Organization.isValidSiret(props.siret)) {
throw new Error('Invalid SIRET format. Must be 14 digits.');
}
// Validate SCAC code if provided
if (props.scac && !Organization.isValidSCAC(props.scac)) {
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
@ -92,8 +79,6 @@ export class Organization {
return new Organization({
...props,
siretVerified: props.siretVerified ?? false,
statusBadge: props.statusBadge ?? 'none',
createdAt: now,
updatedAt: now,
});
@ -115,10 +100,6 @@ export class Organization {
return scacPattern.test(scac);
}
private static isValidSiret(siret: string): boolean {
return /^\d{14}$/.test(siret);
}
// Getters
get id(): string {
return this.props.id;
@ -172,18 +153,6 @@ export class Organization {
return this.props.updatedAt;
}
get siret(): string | undefined {
return this.props.siret;
}
get siretVerified(): boolean {
return this.props.siretVerified;
}
get statusBadge(): 'none' | 'silver' | 'gold' | 'platinium' {
return this.props.statusBadge;
}
get isActive(): boolean {
return this.props.isActive;
}
@ -214,25 +183,6 @@ export class Organization {
this.props.updatedAt = new Date();
}
updateSiret(siret: string): void {
if (!Organization.isValidSiret(siret)) {
throw new Error('Invalid SIRET format. Must be 14 digits.');
}
this.props.siret = siret;
this.props.siretVerified = false;
this.props.updatedAt = new Date();
}
markSiretVerified(): void {
this.props.siretVerified = true;
this.props.updatedAt = new Date();
}
updateStatusBadge(badge: 'none' | 'silver' | 'gold' | 'platinium'): void {
this.props.statusBadge = badge;
this.props.updatedAt = new Date();
}
updateSiren(siren: string): void {
this.props.siren = siren;
this.props.updatedAt = new Date();

View File

@ -21,12 +21,12 @@ describe('Subscription Entity', () => {
};
describe('create', () => {
it('should create a subscription with default BRONZE plan', () => {
it('should create a subscription with default FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.id).toBe('sub-123');
expect(subscription.organizationId).toBe('org-123');
expect(subscription.plan.value).toBe('BRONZE');
expect(subscription.plan.value).toBe('FREE');
expect(subscription.status.value).toBe('ACTIVE');
expect(subscription.cancelAtPeriodEnd).toBe(false);
});
@ -35,10 +35,10 @@ describe('Subscription Entity', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.silver(),
plan: SubscriptionPlan.starter(),
});
expect(subscription.plan.value).toBe('SILVER');
expect(subscription.plan.value).toBe('STARTER');
});
it('should create a subscription with Stripe IDs', () => {
@ -59,7 +59,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'GOLD',
plan: 'PRO',
status: 'ACTIVE',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_stripe_123',
@ -71,57 +71,57 @@ describe('Subscription Entity', () => {
});
expect(subscription.id).toBe('sub-123');
expect(subscription.plan.value).toBe('GOLD');
expect(subscription.plan.value).toBe('PRO');
expect(subscription.status.value).toBe('ACTIVE');
expect(subscription.cancelAtPeriodEnd).toBe(true);
});
});
describe('maxLicenses', () => {
it('should return correct limits for BRONZE plan', () => {
it('should return correct limits for FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.maxLicenses).toBe(1);
expect(subscription.maxLicenses).toBe(2);
});
it('should return correct limits for SILVER plan', () => {
it('should return correct limits for STARTER plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.silver(),
plan: SubscriptionPlan.starter(),
});
expect(subscription.maxLicenses).toBe(5);
});
it('should return correct limits for GOLD plan', () => {
it('should return correct limits for PRO plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.gold(),
plan: SubscriptionPlan.pro(),
});
expect(subscription.maxLicenses).toBe(20);
});
it('should return -1 for PLATINIUM plan (unlimited)', () => {
it('should return -1 for ENTERPRISE plan (unlimited)', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.platinium(),
plan: SubscriptionPlan.enterprise(),
});
expect(subscription.maxLicenses).toBe(-1);
});
});
describe('isUnlimited', () => {
it('should return false for BRONZE plan', () => {
it('should return false for FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.isUnlimited()).toBe(false);
});
it('should return true for PLATINIUM plan', () => {
it('should return true for ENTERPRISE plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.platinium(),
plan: SubscriptionPlan.enterprise(),
});
expect(subscription.isUnlimited()).toBe(true);
});
@ -137,7 +137,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'BRONZE',
plan: 'FREE',
status: 'TRIALING',
stripeCustomerId: null,
stripeSubscriptionId: null,
@ -154,7 +154,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'BRONZE',
plan: 'FREE',
status: 'CANCELED',
stripeCustomerId: null,
stripeSubscriptionId: null,
@ -170,20 +170,21 @@ describe('Subscription Entity', () => {
describe('canAllocateLicenses', () => {
it('should return true when licenses are available', () => {
const subscription = createValidSubscription(); // BRONZE = 1 license
const subscription = createValidSubscription();
expect(subscription.canAllocateLicenses(0, 1)).toBe(true);
expect(subscription.canAllocateLicenses(1, 1)).toBe(true);
});
it('should return false when no licenses available', () => {
const subscription = createValidSubscription();
expect(subscription.canAllocateLicenses(1, 1)).toBe(false); // BRONZE has 1 license max
expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses
});
it('should always return true for PLATINIUM plan', () => {
it('should always return true for ENTERPRISE plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.platinium(),
plan: SubscriptionPlan.enterprise(),
});
expect(subscription.canAllocateLicenses(1000, 100)).toBe(true);
});
@ -192,7 +193,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'BRONZE',
plan: 'FREE',
status: 'CANCELED',
stripeCustomerId: null,
stripeSubscriptionId: null,
@ -207,23 +208,23 @@ describe('Subscription Entity', () => {
});
describe('canUpgradeTo', () => {
it('should allow upgrade from BRONZE to SILVER', () => {
it('should allow upgrade from FREE to STARTER', () => {
const subscription = createValidSubscription();
expect(subscription.canUpgradeTo(SubscriptionPlan.silver())).toBe(true);
expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
});
it('should allow upgrade from BRONZE to GOLD', () => {
it('should allow upgrade from FREE to PRO', () => {
const subscription = createValidSubscription();
expect(subscription.canUpgradeTo(SubscriptionPlan.gold())).toBe(true);
expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
});
it('should not allow downgrade via canUpgradeTo', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.silver(),
plan: SubscriptionPlan.starter(),
});
expect(subscription.canUpgradeTo(SubscriptionPlan.bronze())).toBe(false);
expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false);
});
});
@ -232,34 +233,34 @@ describe('Subscription Entity', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.silver(),
plan: SubscriptionPlan.starter(),
});
expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true);
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
});
it('should prevent downgrade when user count exceeds new plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.silver(),
plan: SubscriptionPlan.starter(),
});
expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false);
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
});
});
describe('updatePlan', () => {
it('should update to new plan when valid', () => {
const subscription = createValidSubscription();
const updated = subscription.updatePlan(SubscriptionPlan.silver(), 1);
const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1);
expect(updated.plan.value).toBe('SILVER');
expect(updated.plan.value).toBe('STARTER');
});
it('should throw when subscription is not active', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'BRONZE',
plan: 'FREE',
status: 'CANCELED',
stripeCustomerId: null,
stripeSubscriptionId: null,
@ -270,8 +271,8 @@ describe('Subscription Entity', () => {
updatedAt: new Date(),
});
expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 0)).toThrow(
SubscriptionNotActiveException
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow(
SubscriptionNotActiveException,
);
});
@ -279,11 +280,11 @@ describe('Subscription Entity', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.gold(),
plan: SubscriptionPlan.pro(),
});
expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 10)).toThrow(
InvalidSubscriptionDowngradeException
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow(
InvalidSubscriptionDowngradeException,
);
});
});
@ -340,7 +341,7 @@ describe('Subscription Entity', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'SILVER',
plan: 'STARTER',
status: 'ACTIVE',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_123',
@ -367,17 +368,17 @@ describe('Subscription Entity', () => {
});
describe('isFree and isPaid', () => {
it('should return true for isFree when BRONZE plan', () => {
it('should return true for isFree when FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.isFree()).toBe(true);
expect(subscription.isPaid()).toBe(false);
});
it('should return true for isPaid when SILVER plan', () => {
it('should return true for isPaid when STARTER plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.silver(),
plan: SubscriptionPlan.starter(),
});
expect(subscription.isFree()).toBe(false);
expect(subscription.isPaid()).toBe(true);
@ -396,7 +397,7 @@ describe('Subscription Entity', () => {
expect(obj.id).toBe('sub-123');
expect(obj.organizationId).toBe('org-123');
expect(obj.plan).toBe('BRONZE');
expect(obj.plan).toBe('FREE');
expect(obj.status).toBe('ACTIVE');
expect(obj.stripeCustomerId).toBe('cus_123');
});

View File

@ -5,7 +5,10 @@
* Stripe integration, and billing period information.
*/
import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo';
import {
SubscriptionPlan,
SubscriptionPlanType,
} from '../value-objects/subscription-plan.vo';
import {
SubscriptionStatus,
SubscriptionStatusType,
@ -37,7 +40,7 @@ export class Subscription {
}
/**
* Create a new subscription (defaults to Bronze/free plan)
* Create a new subscription (defaults to FREE plan)
*/
static create(props: {
id: string;
@ -50,7 +53,7 @@ export class Subscription {
return new Subscription({
id: props.id,
organizationId: props.organizationId,
plan: props.plan ?? SubscriptionPlan.bronze(),
plan: props.plan ?? SubscriptionPlan.free(),
status: SubscriptionStatus.active(),
stripeCustomerId: props.stripeCustomerId ?? null,
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
@ -65,41 +68,10 @@ export class Subscription {
/**
* Reconstitute from persistence
*/
/**
* Check if a specific plan feature is available
*/
hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean {
return this.props.plan.hasFeature(feature);
}
/**
* Get the maximum shipments per year allowed
*/
get maxShipmentsPerYear(): number {
return this.props.plan.maxShipmentsPerYear;
}
/**
* Get the commission rate for this subscription's plan
*/
get commissionRatePercent(): number {
return this.props.plan.commissionRatePercent;
}
/**
* Get the status badge for this subscription's plan
*/
get statusBadge(): string {
return this.props.plan.statusBadge;
}
/**
* Reconstitute from persistence (supports legacy plan names)
*/
static fromPersistence(props: {
id: string;
organizationId: string;
plan: string; // Accepts both old and new plan names
plan: SubscriptionPlanType;
status: SubscriptionStatusType;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
@ -112,7 +84,7 @@ export class Subscription {
return new Subscription({
id: props.id,
organizationId: props.organizationId,
plan: SubscriptionPlan.fromString(props.plan),
plan: SubscriptionPlan.create(props.plan),
status: SubscriptionStatus.create(props.status),
stripeCustomerId: props.stripeCustomerId,
stripeSubscriptionId: props.stripeSubscriptionId,
@ -264,7 +236,7 @@ export class Subscription {
this.props.plan.value,
newPlan.value,
currentUserCount,
newPlan.maxLicenses
newPlan.maxLicenses,
);
}

View File

@ -1,17 +0,0 @@
/**
* Shipment Limit Exceeded Exception
*
* Thrown when an organization has reached its annual shipment limit (Bronze plan).
*/
export class ShipmentLimitExceededException extends Error {
constructor(
public readonly organizationId: string,
public readonly currentCount: number,
public readonly maxCount: number
) {
super(
`L'organisation a atteint sa limite de ${maxCount} expéditions par an (${currentCount}/${maxCount}). Passez à un plan supérieur pour des expéditions illimitées.`
);
this.name = 'ShipmentLimitExceededException';
}
}

View File

@ -6,11 +6,11 @@ export class NoLicensesAvailableException extends Error {
constructor(
public readonly organizationId: string,
public readonly currentLicenses: number,
public readonly maxLicenses: number
public readonly maxLicenses: number,
) {
super(
`No licenses available for organization ${organizationId}. ` +
`Currently using ${currentLicenses}/${maxLicenses} licenses.`
`Currently using ${currentLicenses}/${maxLicenses} licenses.`,
);
this.name = 'NoLicensesAvailableException';
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
@ -46,11 +46,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
public readonly currentPlan: string,
public readonly targetPlan: string,
public readonly currentUsers: number,
public readonly targetMaxLicenses: number
public readonly targetMaxLicenses: number,
) {
super(
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`,
);
this.name = 'InvalidSubscriptionDowngradeException';
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
@ -60,9 +60,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
export class SubscriptionNotActiveException extends Error {
constructor(
public readonly subscriptionId: string,
public readonly currentStatus: string
public readonly currentStatus: string,
) {
super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`);
super(
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
);
this.name = 'SubscriptionNotActiveException';
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
}
@ -71,10 +73,13 @@ export class SubscriptionNotActiveException extends Error {
export class InvalidSubscriptionStatusTransitionException extends Error {
constructor(
public readonly fromStatus: string,
public readonly toStatus: string
public readonly toStatus: string,
) {
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
this.name = 'InvalidSubscriptionStatusTransitionException';
Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype);
Object.setPrototypeOf(
this,
InvalidSubscriptionStatusTransitionException.prototype,
);
}
}

View File

@ -1,11 +0,0 @@
import { ApiKey } from '@domain/entities/api-key.entity';
export const API_KEY_REPOSITORY = 'API_KEY_REPOSITORY';
export interface ApiKeyRepository {
save(apiKey: ApiKey): Promise<ApiKey>;
findById(id: string): Promise<ApiKey | null>;
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
findByOrganizationId(organizationId: string): Promise<ApiKey[]>;
delete(id: string): Promise<void>;
}

View File

@ -15,7 +15,6 @@ export interface EmailAttachment {
export interface EmailOptions {
to: string | string[];
from?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string;
@ -86,8 +85,6 @@ export interface EmailPort {
carrierEmail: string,
bookingDetails: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -103,7 +100,6 @@ export interface EmailPort {
fileName: string;
}>;
confirmationToken: string;
notes?: string;
}
): Promise<void>;
@ -124,39 +120,4 @@ export interface EmailPort {
carrierName: string,
temporaryPassword: string
): Promise<void>;
/**
* Send document access email to carrier after booking acceptance
*/
sendDocumentAccessEmail(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
documentCount: number;
confirmationToken: string;
}
): Promise<void>;
/**
* Send notification to carrier when new documents are added
*/
sendNewDocumentsNotification(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
origin: string;
destination: string;
newDocumentsCount: number;
totalDocumentsCount: number;
confirmationToken: string;
}
): Promise<void>;
}

View File

@ -35,11 +35,6 @@ export interface InvitationTokenRepository {
*/
deleteExpired(): Promise<number>;
/**
* Delete an invitation by id
*/
deleteById(id: string): Promise<void>;
/**
* Update an invitation token
*/

View File

@ -1,15 +0,0 @@
/**
* Shipment Counter Port
*
* Counts total shipments (bookings + CSV bookings) for an organization
* within a given year. Used to enforce the Bronze plan's 12 shipments/year limit.
*/
export const SHIPMENT_COUNTER_PORT = 'SHIPMENT_COUNTER_PORT';
export interface ShipmentCounterPort {
/**
* Count all shipments (bookings + CSV bookings) created by an organization in a given year.
*/
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>;
}

View File

@ -1,11 +0,0 @@
export const SIRET_VERIFICATION_PORT = 'SIRET_VERIFICATION_PORT';
export interface SiretVerificationResult {
valid: boolean;
companyName?: string;
address?: string;
}
export interface SiretVerificationPort {
verify(siret: string): Promise<SiretVerificationResult>;
}

View File

@ -43,22 +43,6 @@ export interface StripeSubscriptionData {
cancelAtPeriodEnd: boolean;
}
export interface CreateCommissionCheckoutInput {
bookingId: string;
amountCents: number;
currency: 'eur';
customerEmail: string;
organizationId: string;
bookingDescription: string;
successUrl: string;
cancelUrl: string;
}
export interface CreateCommissionCheckoutOutput {
sessionId: string;
sessionUrl: string;
}
export interface StripeCheckoutSessionData {
sessionId: string;
customerId: string | null;
@ -78,19 +62,16 @@ export interface StripePort {
/**
* Create a Stripe Checkout session for subscription purchase
*/
createCheckoutSession(input: CreateCheckoutSessionInput): Promise<CreateCheckoutSessionOutput>;
/**
* Create a Stripe Checkout session for one-time commission payment
*/
createCommissionCheckout(
input: CreateCommissionCheckoutInput
): Promise<CreateCommissionCheckoutOutput>;
createCheckoutSession(
input: CreateCheckoutSessionInput,
): Promise<CreateCheckoutSessionOutput>;
/**
* Create a Stripe Customer Portal session for subscription management
*/
createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput>;
createPortalSession(
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput>;
/**
* Retrieve subscription details from Stripe
@ -120,7 +101,10 @@ export interface StripePort {
/**
* Verify and parse a Stripe webhook event
*/
constructWebhookEvent(payload: string | Buffer, signature: string): Promise<StripeWebhookEvent>;
constructWebhookEvent(
payload: string | Buffer,
signature: string,
): Promise<StripeWebhookEvent>;
/**
* Map a Stripe price ID to a subscription plan

View File

@ -239,60 +239,6 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return Array.from(types).sort();
}
/**
* Get all unique origin port codes from CSV rates
* Used to limit port selection to only those with available routes
*/
async getAvailableOrigins(): Promise<string[]> {
const allRates = await this.loadAllRates();
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
return Array.from(origins).sort();
}
/**
* Get all destination port codes available for a given origin
* Used to limit destination selection based on selected origin
*/
async getAvailableDestinations(origin: string): Promise<string[]> {
const allRates = await this.loadAllRates();
const originCode = PortCode.create(origin);
const destinations = new Set(
allRates
.filter(rate => rate.origin.equals(originCode))
.map(rate => rate.destination.getValue())
);
return Array.from(destinations).sort();
}
/**
* Get all available routes (origin-destination pairs) from CSV rates
* Returns a map of origin codes to their available destination codes
*/
async getAvailableRoutes(): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates();
const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => {
const origin = rate.origin.getValue();
const destination = rate.destination.getValue();
if (!routeMap.has(origin)) {
routeMap.set(origin, new Set());
}
routeMap.get(origin)!.add(destination);
});
// Convert Sets to sorted arrays
const result = new Map<string, string[]>();
routeMap.forEach((destinations, origin) => {
result.set(origin, Array.from(destinations).sort());
});
return result;
}
/**
* Load all rates from all CSV files
*/

View File

@ -1,53 +0,0 @@
/**
* Plan Feature Value Object
*
* Defines the features available per subscription plan.
* Used by the FeatureFlagGuard to enforce access control.
*/
export type PlanFeature =
| 'dashboard'
| 'wiki'
| 'user_management'
| 'csv_export'
| 'api_access'
| 'custom_interface'
| 'dedicated_kam';
export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
];
export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
BRONZE: [],
SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'],
GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'],
PLATINIUM: [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
],
};
export function planHasFeature(
plan: SubscriptionPlanTypeForFeatures,
feature: PlanFeature
): boolean {
return PLAN_FEATURES[plan].includes(feature);
}
export function planGetFeatures(plan: SubscriptionPlanTypeForFeatures): readonly PlanFeature[] {
return PLAN_FEATURES[plan];
}

View File

@ -8,56 +8,31 @@ import { SubscriptionPlan } from './subscription-plan.vo';
describe('SubscriptionPlan Value Object', () => {
describe('static factory methods', () => {
it('should create BRONZE plan via bronze()', () => {
const plan = SubscriptionPlan.bronze();
expect(plan.value).toBe('BRONZE');
});
it('should create SILVER plan via silver()', () => {
const plan = SubscriptionPlan.silver();
expect(plan.value).toBe('SILVER');
});
it('should create GOLD plan via gold()', () => {
const plan = SubscriptionPlan.gold();
expect(plan.value).toBe('GOLD');
});
it('should create PLATINIUM plan via platinium()', () => {
const plan = SubscriptionPlan.platinium();
expect(plan.value).toBe('PLATINIUM');
});
it('should create BRONZE plan via free() alias', () => {
it('should create FREE plan', () => {
const plan = SubscriptionPlan.free();
expect(plan.value).toBe('BRONZE');
expect(plan.value).toBe('FREE');
});
it('should create SILVER plan via starter() alias', () => {
it('should create STARTER plan', () => {
const plan = SubscriptionPlan.starter();
expect(plan.value).toBe('SILVER');
expect(plan.value).toBe('STARTER');
});
it('should create GOLD plan via pro() alias', () => {
it('should create PRO plan', () => {
const plan = SubscriptionPlan.pro();
expect(plan.value).toBe('GOLD');
expect(plan.value).toBe('PRO');
});
it('should create PLATINIUM plan via enterprise() alias', () => {
it('should create ENTERPRISE plan', () => {
const plan = SubscriptionPlan.enterprise();
expect(plan.value).toBe('PLATINIUM');
expect(plan.value).toBe('ENTERPRISE');
});
});
describe('create', () => {
it('should create plan from valid type SILVER', () => {
const plan = SubscriptionPlan.create('SILVER');
expect(plan.value).toBe('SILVER');
});
it('should create plan from valid type BRONZE', () => {
const plan = SubscriptionPlan.create('BRONZE');
expect(plan.value).toBe('BRONZE');
it('should create plan from valid type', () => {
const plan = SubscriptionPlan.create('STARTER');
expect(plan.value).toBe('STARTER');
});
it('should throw for invalid plan type', () => {
@ -66,29 +41,9 @@ describe('SubscriptionPlan Value Object', () => {
});
describe('fromString', () => {
it('should create SILVER from lowercase "silver"', () => {
const plan = SubscriptionPlan.fromString('silver');
expect(plan.value).toBe('SILVER');
});
it('should map legacy "starter" to SILVER', () => {
it('should create plan from lowercase string', () => {
const plan = SubscriptionPlan.fromString('starter');
expect(plan.value).toBe('SILVER');
});
it('should map legacy "free" to BRONZE', () => {
const plan = SubscriptionPlan.fromString('free');
expect(plan.value).toBe('BRONZE');
});
it('should map legacy "pro" to GOLD', () => {
const plan = SubscriptionPlan.fromString('pro');
expect(plan.value).toBe('GOLD');
});
it('should map legacy "enterprise" to PLATINIUM', () => {
const plan = SubscriptionPlan.fromString('enterprise');
expect(plan.value).toBe('PLATINIUM');
expect(plan.value).toBe('STARTER');
});
it('should throw for invalid string', () => {
@ -97,150 +52,146 @@ describe('SubscriptionPlan Value Object', () => {
});
describe('maxLicenses', () => {
it('should return 1 for BRONZE plan', () => {
const plan = SubscriptionPlan.bronze();
expect(plan.maxLicenses).toBe(1);
it('should return 2 for FREE plan', () => {
const plan = SubscriptionPlan.free();
expect(plan.maxLicenses).toBe(2);
});
it('should return 5 for SILVER plan', () => {
const plan = SubscriptionPlan.silver();
it('should return 5 for STARTER plan', () => {
const plan = SubscriptionPlan.starter();
expect(plan.maxLicenses).toBe(5);
});
it('should return 20 for GOLD plan', () => {
const plan = SubscriptionPlan.gold();
it('should return 20 for PRO plan', () => {
const plan = SubscriptionPlan.pro();
expect(plan.maxLicenses).toBe(20);
});
it('should return -1 (unlimited) for PLATINIUM plan', () => {
const plan = SubscriptionPlan.platinium();
it('should return -1 (unlimited) for ENTERPRISE plan', () => {
const plan = SubscriptionPlan.enterprise();
expect(plan.maxLicenses).toBe(-1);
});
});
describe('isUnlimited', () => {
it('should return false for BRONZE plan', () => {
expect(SubscriptionPlan.bronze().isUnlimited()).toBe(false);
it('should return false for FREE plan', () => {
expect(SubscriptionPlan.free().isUnlimited()).toBe(false);
});
it('should return false for SILVER plan', () => {
expect(SubscriptionPlan.silver().isUnlimited()).toBe(false);
it('should return false for STARTER plan', () => {
expect(SubscriptionPlan.starter().isUnlimited()).toBe(false);
});
it('should return false for GOLD plan', () => {
expect(SubscriptionPlan.gold().isUnlimited()).toBe(false);
it('should return false for PRO plan', () => {
expect(SubscriptionPlan.pro().isUnlimited()).toBe(false);
});
it('should return true for PLATINIUM plan', () => {
expect(SubscriptionPlan.platinium().isUnlimited()).toBe(true);
it('should return true for ENTERPRISE plan', () => {
expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true);
});
});
describe('isPaid', () => {
it('should return false for BRONZE plan', () => {
expect(SubscriptionPlan.bronze().isPaid()).toBe(false);
it('should return false for FREE plan', () => {
expect(SubscriptionPlan.free().isPaid()).toBe(false);
});
it('should return true for SILVER plan', () => {
expect(SubscriptionPlan.silver().isPaid()).toBe(true);
it('should return true for STARTER plan', () => {
expect(SubscriptionPlan.starter().isPaid()).toBe(true);
});
it('should return true for GOLD plan', () => {
expect(SubscriptionPlan.gold().isPaid()).toBe(true);
it('should return true for PRO plan', () => {
expect(SubscriptionPlan.pro().isPaid()).toBe(true);
});
it('should return true for PLATINIUM plan', () => {
expect(SubscriptionPlan.platinium().isPaid()).toBe(true);
it('should return true for ENTERPRISE plan', () => {
expect(SubscriptionPlan.enterprise().isPaid()).toBe(true);
});
});
describe('isFree', () => {
it('should return true for BRONZE plan', () => {
expect(SubscriptionPlan.bronze().isFree()).toBe(true);
it('should return true for FREE plan', () => {
expect(SubscriptionPlan.free().isFree()).toBe(true);
});
it('should return false for SILVER plan', () => {
expect(SubscriptionPlan.silver().isFree()).toBe(false);
it('should return false for STARTER plan', () => {
expect(SubscriptionPlan.starter().isFree()).toBe(false);
});
});
describe('canAccommodateUsers', () => {
it('should return true for BRONZE plan with 1 user', () => {
expect(SubscriptionPlan.bronze().canAccommodateUsers(1)).toBe(true);
it('should return true for FREE plan with 2 users', () => {
expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true);
});
it('should return false for BRONZE plan with 2 users', () => {
expect(SubscriptionPlan.bronze().canAccommodateUsers(2)).toBe(false);
it('should return false for FREE plan with 3 users', () => {
expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false);
});
it('should return true for SILVER plan with 5 users', () => {
expect(SubscriptionPlan.silver().canAccommodateUsers(5)).toBe(true);
it('should return true for STARTER plan with 5 users', () => {
expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true);
});
it('should always return true for PLATINIUM plan', () => {
expect(SubscriptionPlan.platinium().canAccommodateUsers(1000)).toBe(true);
it('should always return true for ENTERPRISE plan', () => {
expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true);
});
});
describe('canUpgradeTo', () => {
it('should allow upgrade from BRONZE to SILVER', () => {
expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.silver())).toBe(true);
it('should allow upgrade from FREE to STARTER', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
});
it('should allow upgrade from BRONZE to GOLD', () => {
expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.gold())).toBe(true);
it('should allow upgrade from FREE to PRO', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
});
it('should allow upgrade from BRONZE to PLATINIUM', () => {
expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.platinium())).toBe(true);
it('should allow upgrade from FREE to ENTERPRISE', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true);
});
it('should allow upgrade from SILVER to GOLD', () => {
expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.gold())).toBe(true);
it('should allow upgrade from STARTER to PRO', () => {
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
});
it('should not allow downgrade from SILVER to BRONZE', () => {
expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.bronze())).toBe(false);
it('should not allow downgrade from STARTER to FREE', () => {
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false);
});
it('should not allow same plan upgrade', () => {
expect(SubscriptionPlan.gold().canUpgradeTo(SubscriptionPlan.gold())).toBe(false);
expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false);
});
});
describe('canDowngradeTo', () => {
it('should allow downgrade from SILVER to BRONZE when users fit', () => {
expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true);
it('should allow downgrade from STARTER to FREE when users fit', () => {
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
});
it('should not allow downgrade from SILVER to BRONZE when users exceed', () => {
expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false);
it('should not allow downgrade from STARTER to FREE when users exceed', () => {
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
});
it('should not allow upgrade via canDowngradeTo', () => {
expect(SubscriptionPlan.bronze().canDowngradeTo(SubscriptionPlan.silver(), 1)).toBe(false);
expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false);
});
});
describe('plan details', () => {
it('should return correct name for BRONZE plan', () => {
expect(SubscriptionPlan.bronze().name).toBe('Bronze');
it('should return correct name for FREE plan', () => {
expect(SubscriptionPlan.free().name).toBe('Free');
});
it('should return correct name for SILVER plan', () => {
expect(SubscriptionPlan.silver().name).toBe('Silver');
it('should return correct prices for STARTER plan', () => {
const plan = SubscriptionPlan.starter();
expect(plan.monthlyPriceEur).toBe(49);
expect(plan.yearlyPriceEur).toBe(470);
});
it('should return correct prices for SILVER plan', () => {
const plan = SubscriptionPlan.silver();
expect(plan.monthlyPriceEur).toBe(249);
expect(plan.yearlyPriceEur).toBe(2739);
});
it('should return features for GOLD plan', () => {
const plan = SubscriptionPlan.gold();
expect(plan.features).toContain("Jusqu'à 20 utilisateurs");
expect(plan.features).toContain('Intégration API');
it('should return features for PRO plan', () => {
const plan = SubscriptionPlan.pro();
expect(plan.features).toContain('Up to 20 users');
expect(plan.features).toContain('API access');
});
});
@ -249,24 +200,24 @@ describe('SubscriptionPlan Value Object', () => {
const plans = SubscriptionPlan.getAllPlans();
expect(plans).toHaveLength(4);
expect(plans.map(p => p.value)).toEqual(['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']);
expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']);
});
});
describe('equals', () => {
it('should return true for same plan', () => {
expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.bronze())).toBe(true);
expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true);
});
it('should return false for different plans', () => {
expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.silver())).toBe(false);
expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false);
});
});
describe('toString', () => {
it('should return plan value as string', () => {
expect(SubscriptionPlan.bronze().toString()).toBe('BRONZE');
expect(SubscriptionPlan.silver().toString()).toBe('SILVER');
expect(SubscriptionPlan.free().toString()).toBe('FREE');
expect(SubscriptionPlan.starter().toString()).toBe('STARTER');
});
});
});

View File

@ -2,109 +2,68 @@
* Subscription Plan Value Object
*
* Represents the different subscription plans available for organizations.
* Each plan has a maximum number of licenses, shipment limits, commission rates,
* feature flags, and support levels.
*
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
* Each plan has a maximum number of licenses that determine how many users
* can be active in an organization.
*/
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam';
export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium';
/**
* Legacy plan name mapping for backward compatibility during migration.
*/
const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
};
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
interface PlanDetails {
readonly name: string;
readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number;
readonly yearlyPriceEur: number;
readonly maxShipmentsPerYear: number; // -1 means unlimited
readonly commissionRatePercent: number;
readonly statusBadge: StatusBadge;
readonly supportLevel: SupportLevel;
readonly planFeatures: readonly PlanFeature[];
readonly features: readonly string[]; // Human-readable feature descriptions
readonly features: readonly string[];
}
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
BRONZE: {
name: 'Bronze',
maxLicenses: 1,
FREE: {
name: 'Free',
maxLicenses: 2,
monthlyPriceEur: 0,
yearlyPriceEur: 0,
maxShipmentsPerYear: 12,
commissionRatePercent: 5,
statusBadge: 'none',
supportLevel: 'none',
planFeatures: PLAN_FEATURES.BRONZE,
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
features: [
'Up to 2 users',
'Basic rate search',
'Email support',
],
},
SILVER: {
name: 'Silver',
STARTER: {
name: 'Starter',
maxLicenses: 5,
monthlyPriceEur: 249,
yearlyPriceEur: 2739,
maxShipmentsPerYear: -1,
commissionRatePercent: 3,
statusBadge: 'silver',
supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER,
monthlyPriceEur: 49,
yearlyPriceEur: 470, // ~20% discount
features: [
"Jusqu'à 5 utilisateurs",
'Expéditions illimitées',
'Tableau de bord',
'Wiki Maritime',
'Gestion des utilisateurs',
'Import CSV',
'Support par email',
'Up to 5 users',
'Advanced rate search',
'CSV imports',
'Priority email support',
],
},
GOLD: {
name: 'Gold',
PRO: {
name: 'Pro',
maxLicenses: 20,
monthlyPriceEur: 899,
yearlyPriceEur: 9889,
maxShipmentsPerYear: -1,
commissionRatePercent: 2,
statusBadge: 'gold',
supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD,
monthlyPriceEur: 149,
yearlyPriceEur: 1430, // ~20% discount
features: [
"Jusqu'à 20 utilisateurs",
'Expéditions illimitées',
'Toutes les fonctionnalités Silver',
'Intégration API',
'Assistance commerciale directe',
'Up to 20 users',
'All Starter features',
'API access',
'Custom integrations',
'Phone support',
],
},
PLATINIUM: {
name: 'Platinium',
ENTERPRISE: {
name: 'Enterprise',
maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing
maxShipmentsPerYear: -1,
commissionRatePercent: 1,
statusBadge: 'platinium',
supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM,
features: [
'Utilisateurs illimités',
'Toutes les fonctionnalités Gold',
'Key Account Manager dédié',
'Interface personnalisable',
'Contrats tarifaires cadre',
'Unlimited users',
'All Pro features',
'Dedicated account manager',
'Custom SLA',
'On-premise deployment option',
],
},
};
@ -119,68 +78,36 @@ export class SubscriptionPlan {
return new SubscriptionPlan(plan);
}
/**
* Create from string with legacy name support.
* Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names.
*/
static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase();
// Check legacy mapping first
const mapped = LEGACY_PLAN_MAPPING[upperValue];
if (mapped) {
return new SubscriptionPlan(mapped);
const upperValue = value.toUpperCase() as SubscriptionPlanType;
if (!PLAN_DETAILS[upperValue]) {
throw new Error(`Invalid subscription plan: ${value}`);
}
// Try direct match
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
return new SubscriptionPlan(upperValue as SubscriptionPlanType);
}
throw new Error(`Invalid subscription plan: ${value}`);
return new SubscriptionPlan(upperValue);
}
// Named factories
static bronze(): SubscriptionPlan {
return new SubscriptionPlan('BRONZE');
}
static silver(): SubscriptionPlan {
return new SubscriptionPlan('SILVER');
}
static gold(): SubscriptionPlan {
return new SubscriptionPlan('GOLD');
}
static platinium(): SubscriptionPlan {
return new SubscriptionPlan('PLATINIUM');
}
// Legacy aliases
static free(): SubscriptionPlan {
return SubscriptionPlan.bronze();
return new SubscriptionPlan('FREE');
}
static starter(): SubscriptionPlan {
return SubscriptionPlan.silver();
return new SubscriptionPlan('STARTER');
}
static pro(): SubscriptionPlan {
return SubscriptionPlan.gold();
return new SubscriptionPlan('PRO');
}
static enterprise(): SubscriptionPlan {
return SubscriptionPlan.platinium();
return new SubscriptionPlan('ENTERPRISE');
}
static getAllPlans(): SubscriptionPlan[] {
return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map(
p => new SubscriptionPlan(p)
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
(p) => new SubscriptionPlan(p as SubscriptionPlanType),
);
}
// Getters
get value(): SubscriptionPlanType {
return this.plan;
}
@ -205,68 +132,64 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].features;
}
get maxShipmentsPerYear(): number {
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
}
get commissionRatePercent(): number {
return PLAN_DETAILS[this.plan].commissionRatePercent;
}
get statusBadge(): StatusBadge {
return PLAN_DETAILS[this.plan].statusBadge;
}
get supportLevel(): SupportLevel {
return PLAN_DETAILS[this.plan].supportLevel;
}
get planFeatures(): readonly PlanFeature[] {
return PLAN_DETAILS[this.plan].planFeatures;
}
hasFeature(feature: PlanFeature): boolean {
return this.planFeatures.includes(feature);
}
/**
* Returns true if this plan has unlimited licenses
*/
isUnlimited(): boolean {
return this.maxLicenses === -1;
}
hasUnlimitedShipments(): boolean {
return this.maxShipmentsPerYear === -1;
}
/**
* Returns true if this is a paid plan
*/
isPaid(): boolean {
return this.plan !== 'BRONZE';
return this.plan !== 'FREE';
}
/**
* Returns true if this is the free plan
*/
isFree(): boolean {
return this.plan === 'BRONZE';
}
isCustomPricing(): boolean {
return this.plan === 'PLATINIUM';
return this.plan === 'FREE';
}
/**
* Check if a given number of users can be accommodated by this plan
*/
canAccommodateUsers(userCount: number): boolean {
if (this.isUnlimited()) return true;
return userCount <= this.maxLicenses;
}
/**
* Check if upgrade to target plan is allowed
*/
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex;
}
/**
* Check if downgrade to target plan is allowed given current user count
*/
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value);
if (targetIndex >= currentIndex) return false;
if (targetIndex >= currentIndex) return false; // Not a downgrade
return targetPlan.canAccommodateUsers(currentUserCount);
}

View File

@ -191,7 +191,9 @@ export class SubscriptionStatus {
*/
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
if (!this.canTransitionTo(newStatus)) {
throw new Error(`Invalid status transition from ${this.status} to ${newStatus.value}`);
throw new Error(
`Invalid status transition from ${this.status} to ${newStatus.value}`,
);
}
return newStatus;
}

View File

@ -4,157 +4,69 @@
* Implements EmailPort using nodemailer
*/
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import * as https from 'https';
import { EmailPort, EmailOptions } from '@domain/ports/out/email.port';
import { EmailTemplates } from './templates/email-templates';
// Display names included → moins susceptibles d'être marqués spam
const EMAIL_SENDERS = {
SECURITY: '"Xpeditis Sécurité" <security@xpeditis.com>',
BOOKINGS: '"Xpeditis Bookings" <bookings@xpeditis.com>',
TEAM: '"Équipe Xpeditis" <team@xpeditis.com>',
CARRIERS: '"Xpeditis Transporteurs" <carriers@xpeditis.com>',
NOREPLY: '"Xpeditis" <noreply@xpeditis.com>',
} as const;
/**
* Génère une version plain text à partir du HTML pour améliorer la délivrabilité.
* Les emails sans version texte sont pénalisés par les filtres anti-spam.
*/
function htmlToPlainText(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@Injectable()
export class EmailAdapter implements EmailPort, OnModuleInit {
export class EmailAdapter implements EmailPort {
private readonly logger = new Logger(EmailAdapter.name);
private transporter: nodemailer.Transporter;
constructor(
private readonly configService: ConfigService,
private readonly emailTemplates: EmailTemplates
) {}
) {
this.initializeTransporter();
}
async onModuleInit(): Promise<void> {
private initializeTransporter(): void {
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
// 🔧 FIX: Mailtrap — IP directe hardcodée
if (host.includes('mailtrap.io')) {
this.buildTransporter('3.209.246.195', host);
return;
}
// 🔧 FIX: DNS over HTTPS — contourne le port 53 UDP (bloqué sur certains réseaux).
// On appelle l'API DoH de Cloudflare via HTTPS (port 443) pour résoudre l'IP
// AVANT de créer le transporter, puis on passe l'IP directement à nodemailer.
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host) && host !== 'localhost') {
try {
const ip = await this.resolveViaDoH(host);
this.logger.log(`[DNS-DoH] ${host}${ip}`);
this.buildTransporter(ip, host);
return;
} catch (err: any) {
this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
}
}
this.buildTransporter(host, host);
}
/**
* Résout un hostname en IP via l'API DNS over HTTPS de Cloudflare.
* Utilise HTTPS (port 443) donc fonctionne même quand le port 53 UDP est bloqué.
*/
private resolveViaDoH(hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`;
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => {
let raw = '';
res.on('data', (chunk) => (raw += chunk));
res.on('end', () => {
try {
const json = JSON.parse(raw);
const aRecord = (json.Answer ?? []).find((r: any) => r.type === 1);
if (aRecord?.data) {
resolve(aRecord.data);
} else {
reject(new Error(`No A record returned by DoH for ${hostname}`));
}
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error('DoH request timed out'));
});
});
}
private buildTransporter(actualHost: string, serverName: string): void {
const port = this.configService.get<number>('SMTP_PORT', 2525);
const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
const secure = this.configService.get<boolean>('SMTP_SECURE', false);
// 🔧 FIX: Contournement DNS pour Mailtrap
// Utilise automatiquement l'IP directe quand 'mailtrap.io' est détecté
// Cela évite les timeouts DNS (queryA ETIMEOUT) sur certains réseaux
const useDirectIP = host.includes('mailtrap.io');
const actualHost = useDirectIP ? '3.209.246.195' : host;
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS
this.transporter = nodemailer.createTransport({
host: actualHost,
port,
secure,
auth: { user, pass },
auth: {
user,
pass,
},
// Configuration TLS avec servername pour IP directe
tls: {
rejectUnauthorized: false,
servername: serverName,
servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe
},
connectionTimeout: 15000,
greetingTimeout: 15000,
socketTimeout: 30000,
} as any);
// Timeouts optimisés
connectionTimeout: 10000, // 10s
greetingTimeout: 10000, // 10s
socketTimeout: 30000, // 30s
dnsTimeout: 10000, // 10s
});
this.logger.log(
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
`Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` +
(useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '')
);
this.transporter.verify((error) => {
if (error) {
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
} else {
this.logger.log(`✅ SMTP connection verified — ready to send emails`);
}
});
}
async send(options: EmailOptions): Promise<void> {
try {
const from =
options.from ??
this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
const from = this.configService.get<string>('SMTP_FROM', 'noreply@xpeditis.com');
// Génère automatiquement la version plain text si absente (améliore le score anti-spam)
const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined);
const info = await this.transporter.sendMail({
await this.transporter.sendMail({
from,
to: options.to,
cc: options.cc,
@ -162,13 +74,11 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
replyTo: options.replyTo,
subject: options.subject,
html: options.html,
text,
text: options.text,
attachments: options.attachments,
});
this.logger.log(
`✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}`
);
this.logger.log(`Email sent to ${options.to}: ${options.subject}`);
} catch (error) {
this.logger.error(`Failed to send email to ${options.to}`, error);
throw error;
@ -198,7 +108,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Booking Confirmation - ${bookingNumber}`,
html,
attachments,
@ -213,7 +122,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.SECURITY,
subject: 'Verify your email - Xpeditis',
html,
});
@ -227,7 +135,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.SECURITY,
subject: 'Reset your password - Xpeditis',
html,
});
@ -241,7 +148,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.NOREPLY,
subject: 'Welcome to Xpeditis',
html,
});
@ -263,7 +169,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.TEAM,
subject: `You've been invited to join ${organizationName} on Xpeditis`,
html,
});
@ -304,7 +209,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.TEAM,
subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`,
html,
});
@ -335,8 +239,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
carrierEmail: string,
bookingData: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -352,7 +254,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
fileName: string;
}>;
confirmationToken: string;
notes?: string;
}
): Promise<void> {
// Use APP_URL (frontend) for accept/reject links
@ -369,8 +270,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin}${bookingData.destination}`,
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`,
html,
});
@ -446,7 +346,6 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.CARRIERS,
subject: '🚢 Votre compte transporteur Xpeditis a été créé',
html,
});
@ -522,205 +421,10 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
await this.send({
to: email,
from: EMAIL_SENDERS.SECURITY,
subject: '🔑 Réinitialisation de votre mot de passe Xpeditis',
html,
});
this.logger.log(`Carrier password reset email sent to ${email}`);
}
/**
* Send document access email to carrier after booking acceptance
*/
async sendDocumentAccessEmail(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
documentCount: number;
confirmationToken: string;
}
): Promise<void> {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
// Password section HTML - only show if password is set
const passwordSection = data.documentPassword
? `
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #92400e; font-size: 16px;">🔐 Mot de passe d'accès aux documents</h3>
<p style="margin: 0; color: #78350f;">Pour accéder aux documents, vous aurez besoin du mot de passe suivant :</p>
<div style="background: white; border-radius: 6px; padding: 15px; margin-top: 15px; text-align: center;">
<code style="font-size: 24px; font-weight: bold; color: #1e293b; letter-spacing: 2px;">${data.documentPassword}</code>
</div>
<p style="margin: 15px 0 0 0; color: #78350f; font-size: 13px;"> Conservez ce mot de passe, il vous sera demandé à chaque accès.</p>
</div>
`
: '';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #0284c7; margin: 0 10px; }
.summary { background: #f8fafc; border-radius: 8px; padding: 20px; margin: 20px 0; }
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
.summary-row:last-child { border-bottom: none; }
.documents-badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; margin: 20px 0; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Documents disponibles</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
${data.bookingNumber ? `<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">N° ${data.bookingNumber}</p>` : ''}
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.</p>
<div class="route">
${data.origin} <span class="route-arrow"></span> ${data.destination}
</div>
<div class="summary">
<div class="summary-row">
<span style="color: #64748b;">Volume</span>
<span style="font-weight: 500;">${data.volumeCBM} CBM</span>
</div>
<div class="summary-row">
<span style="color: #64748b;">Poids</span>
<span style="font-weight: 500;">${data.weightKG} kg</span>
</div>
</div>
<div style="text-align: center;">
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
</div>
${passwordSection}
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
</div>
<div class="footer">
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
await this.send({
to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin}${data.destination}`,
html,
});
this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`);
}
/**
* Send notification to carrier when new documents are added
*/
async sendNewDocumentsNotification(
carrierEmail: string,
data: {
carrierName: string;
bookingId: string;
origin: string;
destination: string;
newDocumentsCount: number;
totalDocumentsCount: number;
confirmationToken: string;
}
): Promise<void> {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #f59e0b; margin: 0 10px; }
.highlight { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; margin: 20px 0; text-align: center; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Nouveaux documents ajoutes</h1>
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>De nouveaux documents ont ete ajoutes a votre reservation.</p>
<div class="route">
${data.origin} <span class="route-arrow"></span> ${data.destination}
</div>
<div class="highlight">
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #92400e;">
+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}
</p>
<p style="margin: 5px 0 0 0; color: #a16207;">
Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}
</p>
</div>
<a href="${documentsUrl}" class="cta-button">Voir les documents</a>
</div>
<div class="footer">
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
await this.send({
to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Nouveaux documents - Reservation ${data.origin}${data.destination}`,
html,
});
this.logger.log(
`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`
);
}
}

View File

@ -261,8 +261,6 @@ export class EmailTemplates {
*/
async renderCsvBookingRequest(data: {
bookingId: string;
bookingNumber?: string;
documentPassword?: string;
origin: string;
destination: string;
volumeCBM: number;
@ -277,7 +275,6 @@ export class EmailTemplates {
type: string;
fileName: string;
}>;
notes?: string;
acceptUrl: string;
rejectUrl: string;
}): Promise<string> {
@ -484,21 +481,6 @@ export class EmailTemplates {
Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande.
</p>
{{#if bookingNumber}}
<!-- Booking Reference Box -->
<div style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border: 2px solid #0284c7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #0369a1;">Numéro de devis</p>
<p style="margin: 0; font-size: 28px; font-weight: bold; color: #0c4a6e; letter-spacing: 2px;">{{bookingNumber}}</p>
{{#if documentPassword}}
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #bae6fd;">
<p style="margin: 0 0 5px 0; font-size: 12px; color: #0369a1;">🔐 Mot de passe pour accéder aux documents</p>
<p style="margin: 0; font-size: 20px; font-weight: bold; color: #0c4a6e; background: white; display: inline-block; padding: 8px 16px; border-radius: 4px; letter-spacing: 3px;">{{documentPassword}}</p>
<p style="margin: 10px 0 0 0; font-size: 11px; color: #64748b;">Conservez ce mot de passe, il vous sera demandé pour télécharger les documents</p>
</div>
{{/if}}
</div>
{{/if}}
<!-- Booking Details -->
<div class="section-title">📋 Détails du transport</div>
<table class="details-table">
@ -558,14 +540,6 @@ export class EmailTemplates {
</ul>
</div>
{{#if notes}}
<!-- Notes -->
<div style="background-color: #f0f9ff; border-left: 4px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 5px 0; font-weight: 700; color: #045a8d; font-size: 14px;">📝 Notes du client</p>
<p style="margin: 0; font-size: 14px; color: #333; line-height: 1.6;">{{notes}}</p>
</div>
{{/if}}
<!-- Action Buttons -->
<div class="action-buttons">
<p>Veuillez confirmer votre décision :</p>

View File

@ -1,50 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
SiretVerificationPort,
SiretVerificationResult,
} from '@domain/ports/out/siret-verification.port';
@Injectable()
export class PappersSiretAdapter implements SiretVerificationPort {
private readonly logger = new Logger(PappersSiretAdapter.name);
private readonly apiKey: string;
private readonly baseUrl = 'https://api.pappers.fr/v2';
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get<string>('PAPPERS_API_KEY', '');
}
async verify(siret: string): Promise<SiretVerificationResult> {
if (!this.apiKey) {
this.logger.warn('PAPPERS_API_KEY not configured, skipping SIRET verification');
return { valid: false };
}
try {
const url = `${this.baseUrl}/entreprise?api_token=${this.apiKey}&siret=${siret}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return { valid: false };
}
this.logger.error(`Pappers API error: ${response.status} ${response.statusText}`);
return { valid: false };
}
const data = await response.json();
return {
valid: true,
companyName: data.nom_entreprise || data.denomination,
address: data.siege?.adresse_ligne_1
? `${data.siege.adresse_ligne_1}, ${data.siege.code_postal} ${data.siege.ville}`
: undefined,
};
} catch (error: any) {
this.logger.error(`SIRET verification failed: ${error?.message}`, error?.stack);
return { valid: false };
}
}
}

View File

@ -1,59 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
JoinColumn,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { OrganizationOrmEntity } from './organization.orm-entity';
import { UserOrmEntity } from './user.orm-entity';
@Entity('api_keys')
@Index('idx_api_keys_organization_id', ['organizationId'])
@Index('idx_api_keys_user_id', ['userId'])
@Index('idx_api_keys_is_active', ['isActive'])
export class ApiKeyOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'organization_id', type: 'uuid' })
organizationId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ length: 100 })
name: string;
@Column({ name: 'key_hash', length: 64, unique: true })
keyHash: string;
@Column({ name: 'key_prefix', length: 20 })
keyPrefix: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'last_used_at', type: 'timestamp', nullable: true })
lastUsedAt: Date | null;
@Column({ name: 'expires_at', type: 'timestamp', nullable: true })
expiresAt: Date | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'organization_id' })
organization: OrganizationOrmEntity;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
}

View File

@ -92,18 +92,6 @@ export class BookingOrmEntity {
@Column({ name: 'special_instructions', type: 'text', nullable: true })
specialInstructions: string | null;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
commissionRate: number | null;
@Column({
name: 'commission_amount_eur',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
commissionAmountEur: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@ -1,58 +1,58 @@
/**
* Cookie Consent ORM Entity (Infrastructure Layer)
*
* TypeORM entity for cookie consent persistence
*/
import {
Entity,
Column,
PrimaryColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserOrmEntity } from './user.orm-entity';
@Entity('cookie_consents')
@Index('idx_cookie_consents_user', ['userId'])
export class CookieConsentOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid', unique: true })
userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
@Column({ type: 'boolean', default: true })
essential: boolean;
@Column({ type: 'boolean', default: false })
functional: boolean;
@Column({ type: 'boolean', default: false })
analytics: boolean;
@Column({ type: 'boolean', default: false })
marketing: boolean;
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
ipAddress: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string | null;
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
consentDate: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
/**
* Cookie Consent ORM Entity (Infrastructure Layer)
*
* TypeORM entity for cookie consent persistence
*/
import {
Entity,
Column,
PrimaryColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserOrmEntity } from './user.orm-entity';
@Entity('cookie_consents')
@Index('idx_cookie_consents_user', ['userId'])
export class CookieConsentOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid', unique: true })
userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
@Column({ type: 'boolean', default: true })
essential: boolean;
@Column({ type: 'boolean', default: false })
functional: boolean;
@Column({ type: 'boolean', default: false })
analytics: boolean;
@Column({ type: 'boolean', default: false })
marketing: boolean;
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
ipAddress: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string | null;
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
consentDate: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -75,11 +75,11 @@ export class CsvBookingOrmEntity {
@Column({
name: 'status',
type: 'enum',
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING_PAYMENT',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING',
})
@Index()
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' })
documents: Array<{
@ -96,13 +96,6 @@ export class CsvBookingOrmEntity {
@Index()
confirmationToken: string;
@Column({ name: 'booking_number', type: 'varchar', length: 20, nullable: true })
@Index()
bookingNumber: string | null;
@Column({ name: 'password_hash', type: 'text', nullable: true })
passwordHash: string | null;
@Column({ name: 'requested_at', type: 'timestamp with time zone' })
@Index()
requestedAt: Date;
@ -141,21 +134,6 @@ export class CsvBookingOrmEntity {
@Column({ name: 'carrier_notes', type: 'text', nullable: true })
carrierNotes: string | null;
@Column({ name: 'stripe_payment_intent_id', type: 'varchar', length: 255, nullable: true })
stripePaymentIntentId: string | null;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
commissionRate: number | null;
@Column({
name: 'commission_amount_eur',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
commissionAmountEur: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;

View File

@ -5,7 +5,14 @@
* Represents user licenses linked to subscriptions.
*/
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { SubscriptionOrmEntity } from './subscription.orm-entity';
import { UserOrmEntity } from './user.orm-entity';
@ -23,7 +30,7 @@ export class LicenseOrmEntity {
@Column({ name: 'subscription_id', type: 'uuid' })
subscriptionId: string;
@ManyToOne(() => SubscriptionOrmEntity, subscription => subscription.licenses, {
@ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'subscription_id' })

View File

@ -56,15 +56,6 @@ export class OrganizationOrmEntity {
@Column({ type: 'jsonb', default: '[]' })
documents: any[];
@Column({ type: 'varchar', length: 14, nullable: true })
siret: string | null;
@Column({ name: 'siret_verified', type: 'boolean', default: false })
siretVerified: boolean;
@Column({ name: 'status_badge', type: 'varchar', length: 20, default: 'none' })
statusBadge: string;
@Column({ name: 'is_carrier', type: 'boolean', default: false })
isCarrier: boolean;

View File

@ -1,30 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('password_reset_tokens')
export class PasswordResetTokenOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
@Index('IDX_password_reset_tokens_user_id')
userId: string;
@Column({ unique: true, length: 255 })
@Index('IDX_password_reset_tokens_token')
token: string;
@Column({ name: 'expires_at', type: 'timestamp' })
expiresAt: Date;
@Column({ name: 'used_at', type: 'timestamp', nullable: true })
usedAt: Date | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -19,7 +19,7 @@ import {
import { OrganizationOrmEntity } from './organization.orm-entity';
import { LicenseOrmEntity } from './license.orm-entity';
export type SubscriptionPlanOrmType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export type SubscriptionStatusOrmType =
| 'ACTIVE'
@ -51,8 +51,8 @@ export class SubscriptionOrmEntity {
// Plan information
@Column({
type: 'enum',
enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'],
default: 'BRONZE',
enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'],
default: 'FREE',
})
plan: SubscriptionPlanOrmType;
@ -103,6 +103,6 @@ export class SubscriptionOrmEntity {
updatedAt: Date;
// Relations
@OneToMany(() => LicenseOrmEntity, license => license.subscription)
@OneToMany(() => LicenseOrmEntity, (license) => license.subscription)
licenses: LicenseOrmEntity[];
}

View File

@ -1,40 +0,0 @@
import { ApiKey } from '@domain/entities/api-key.entity';
import { ApiKeyOrmEntity } from '../entities/api-key.orm-entity';
export class ApiKeyOrmMapper {
static toDomain(orm: ApiKeyOrmEntity): ApiKey {
return ApiKey.fromPersistence({
id: orm.id,
organizationId: orm.organizationId,
userId: orm.userId,
name: orm.name,
keyHash: orm.keyHash,
keyPrefix: orm.keyPrefix,
isActive: orm.isActive,
lastUsedAt: orm.lastUsedAt,
expiresAt: orm.expiresAt,
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
});
}
static toOrm(domain: ApiKey): ApiKeyOrmEntity {
const orm = new ApiKeyOrmEntity();
orm.id = domain.id;
orm.organizationId = domain.organizationId;
orm.userId = domain.userId;
orm.name = domain.name;
orm.keyHash = domain.keyHash;
orm.keyPrefix = domain.keyPrefix;
orm.isActive = domain.isActive;
orm.lastUsedAt = domain.lastUsedAt;
orm.expiresAt = domain.expiresAt;
orm.createdAt = domain.createdAt;
orm.updatedAt = domain.updatedAt;
return orm;
}
static toDomainMany(orms: ApiKeyOrmEntity[]): ApiKey[] {
return orms.map(orm => ApiKeyOrmMapper.toDomain(orm));
}
}

View File

@ -27,8 +27,6 @@ export class BookingOrmMapper {
orm.consignee = this.partyToJson(domain.consignee);
orm.cargoDescription = domain.cargoDescription;
orm.specialInstructions = domain.specialInstructions || null;
orm.commissionRate = domain.commissionRate ?? null;
orm.commissionAmountEur = domain.commissionAmountEur ?? null;
orm.createdAt = domain.createdAt;
orm.updatedAt = domain.updatedAt;
@ -54,9 +52,6 @@ export class BookingOrmMapper {
cargoDescription: orm.cargoDescription,
containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [],
specialInstructions: orm.specialInstructions || undefined,
commissionRate: orm.commissionRate != null ? Number(orm.commissionRate) : undefined,
commissionAmountEur:
orm.commissionAmountEur != null ? Number(orm.commissionAmountEur) : undefined,
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
};

View File

@ -41,11 +41,7 @@ export class CsvBookingMapper {
ormEntity.requestedAt,
ormEntity.respondedAt,
ormEntity.notes,
ormEntity.rejectionReason,
ormEntity.bookingNumber ?? undefined,
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
ormEntity.stripePaymentIntentId ?? undefined
ormEntity.rejectionReason
);
}
@ -69,16 +65,13 @@ export class CsvBookingMapper {
primaryCurrency: domain.primaryCurrency,
transitDays: domain.transitDays,
containerType: domain.containerType,
status: domain.status as CsvBookingOrmEntity['status'],
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
documents: domain.documents as any,
confirmationToken: domain.confirmationToken,
requestedAt: domain.requestedAt,
respondedAt: domain.respondedAt,
notes: domain.notes,
rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
};
}
@ -87,13 +80,10 @@ export class CsvBookingMapper {
*/
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return {
status: domain.status as CsvBookingOrmEntity['status'],
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
respondedAt: domain.respondedAt,
notes: domain.notes,
rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
};
}

View File

@ -43,6 +43,6 @@ export class LicenseOrmMapper {
* Map array of ORM entities to domain entities
*/
static toDomainMany(orms: LicenseOrmEntity[]): License[] {
return orms.map(orm => this.toDomain(orm));
return orms.map((orm) => this.toDomain(orm));
}
}

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