Compare commits

..

No commits in common. "main" and "api-documentation-and-access-api" have entirely different histories.

82 changed files with 1261 additions and 11050 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

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

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

@ -19,7 +19,6 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module'; import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module'; import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module'; import { AdminModule } from './application/admin/admin.module';
import { LogsModule } from './application/logs/logs.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module'; import { ApiKeysModule } from './application/api-keys/api-keys.module';
import { CacheModule } from './infrastructure/cache/cache.module'; import { CacheModule } from './infrastructure/cache/cache.module';
@ -68,21 +67,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'),
}), }),
}), }),
// Logging // Logging
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => { useFactory: (configService: ConfigService) => ({
const isDev = configService.get('NODE_ENV') === 'development'; pinoHttp: {
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail) transport:
const forceJson = configService.get('LOG_FORMAT') === 'json'; configService.get('NODE_ENV') === 'development'
const usePretty = isDev && !forceJson;
return {
pinoHttp: {
transport: usePretty
? { ? {
target: 'pino-pretty', target: 'pino-pretty',
options: { options: {
@ -92,21 +85,9 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
}, },
} }
: undefined, : undefined,
level: isDev ? 'debug' : 'info', level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
// Redact sensitive fields from logs },
redact: { }),
paths: [
'req.headers.authorization',
'req.headers["x-api-key"]',
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
],
censor: '[REDACTED]',
},
},
};
},
inject: [ConfigService], inject: [ConfigService],
}), }),
@ -149,7 +130,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
AdminModule, AdminModule,
SubscriptionsModule, SubscriptionsModule,
ApiKeysModule, ApiKeysModule,
LogsModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

View File

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

View File

@ -5,14 +5,10 @@ import {
Logger, Logger,
Inject, Inject,
BadRequestException, BadRequestException,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity'; import { User, UserRole } from '@domain/entities/user.entity';
import { import {
@ -20,11 +16,9 @@ import {
ORGANIZATION_REPOSITORY, ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository'; } from '@domain/ports/out/organization.repository';
import { Organization } from '@domain/entities/organization.entity'; import { Organization } from '@domain/entities/organization.entity';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { RegisterOrganizationDto } from '../dto/auth-login.dto';
import { SubscriptionService } from '../services/subscription.service'; import { SubscriptionService } from '../services/subscription.service';
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
export interface JwtPayload { export interface JwtPayload {
sub: string; // user ID sub: string; // user ID
@ -45,10 +39,6 @@ export class AuthService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) @Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository, private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
@InjectRepository(PasswordResetTokenOrmEntity)
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
@ -215,85 +205,6 @@ export class AuthService {
} }
} }
/**
* Initiate password reset generates token and sends email
*/
async forgotPassword(email: string): Promise<void> {
this.logger.log(`Password reset requested for: ${email}`);
const user = await this.userRepository.findByEmail(email);
// Silently succeed if user not found (security: don't reveal user existence)
if (!user || !user.isActive) {
return;
}
// Invalidate any existing unused tokens for this user
await this.passwordResetTokenRepository.update(
{ userId: user.id, usedAt: IsNull() },
{ usedAt: new Date() }
);
// Generate a secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await this.passwordResetTokenRepository.save({
userId: user.id,
token,
expiresAt,
usedAt: null,
});
await this.emailService.sendPasswordResetEmail(email, token);
this.logger.log(`Password reset email sent to: ${email}`);
}
/**
* Reset password using token from email
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } });
if (!resetToken) {
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
}
if (resetToken.usedAt) {
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
}
if (resetToken.expiresAt < new Date()) {
throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
}
const user = await this.userRepository.findById(resetToken.userId);
if (!user || !user.isActive) {
throw new NotFoundException('Utilisateur introuvable');
}
const passwordHash = await argon2.hash(newPassword, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
// Update password (mutates in place)
user.updatePassword(passwordHash);
await this.userRepository.save(user);
// Mark token as used
await this.passwordResetTokenRepository.update(
{ id: resetToken.id },
{ usedAt: new Date() }
);
this.logger.log(`Password reset successfully for user: ${user.email}`);
}
/** /**
* Validate user from JWT payload * Validate user from JWT payload
*/ */
@ -425,7 +336,6 @@ export class AuthService {
type: organizationData.type, type: organizationData.type,
scac: organizationData.scac, scac: organizationData.scac,
siren: organizationData.siren, siren: organizationData.siren,
siret: organizationData.siret,
address: { address: {
street: organizationData.street, street: organizationData.street,
city: organizationData.city, city: organizationData.city,

View File

@ -860,55 +860,4 @@ export class AdminController {
total: organization.documents.length, total: organization.documents.length,
}; };
} }
/**
* Delete a document from a CSV booking (admin only)
* Bypasses ownership and status restrictions
*/
@Delete('bookings/:bookingId/documents/:documentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Delete document from CSV booking (Admin only)',
description: 'Remove a document from a booking, bypassing ownership and status restrictions.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Document deleted successfully',
})
async deleteDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: UserPayload
): Promise<{ success: boolean; message: string }> {
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document ${documentId} not found`);
}
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
return { success: true, message: 'Document deleted successfully' };
}
} }

View File

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

View File

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

View File

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

View File

@ -7,7 +7,6 @@ import {
IsEnum, IsEnum,
MaxLength, MaxLength,
Matches, Matches,
IsBoolean,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@ -23,81 +22,12 @@ export class LoginDto {
@ApiProperty({ @ApiProperty({
example: 'SecurePassword123!', example: 'SecurePassword123!',
description: 'Password', description: 'Password (minimum 12 characters)',
})
@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)',
minLength: 12, minLength: 12,
}) })
@IsString() @IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' }) @MinLength(12, { message: 'Password must be at least 12 characters' })
newPassword: string; password: string;
} }
/** /**
@ -176,19 +106,6 @@ export class RegisterOrganizationDto {
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren: string; 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({ @ApiPropertyOptional({
example: 'MAEU', example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',

View File

@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator'; import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator';
export enum InvitationRole { export enum InvitationRole {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER', MANAGER = 'MANAGER',
USER = 'USER', USER = 'USER',
VIEWER = 'VIEWER', VIEWER = 'VIEWER',

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

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

View File

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

View File

@ -55,7 +55,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
name: 'Silver', name: 'Silver',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 249, monthlyPriceEur: 249,
yearlyPriceEur: 2739, yearlyPriceEur: 2739, // 249 * 11 months
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 3, commissionRatePercent: 3,
statusBadge: 'silver', statusBadge: 'silver',
@ -75,7 +75,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
name: 'Gold', name: 'Gold',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 899, monthlyPriceEur: 899,
yearlyPriceEur: 9889, yearlyPriceEur: 9889, // 899 * 11 months
maxShipmentsPerYear: -1, maxShipmentsPerYear: -1,
commissionRatePercent: 2, commissionRatePercent: 2,
statusBadge: 'gold', statusBadge: 'gold',
@ -225,35 +225,59 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].planFeatures; return PLAN_DETAILS[this.plan].planFeatures;
} }
/**
* Check if this plan includes a specific feature
*/
hasFeature(feature: PlanFeature): boolean { hasFeature(feature: PlanFeature): boolean {
return this.planFeatures.includes(feature); return this.planFeatures.includes(feature);
} }
/**
* Returns true if this plan has unlimited licenses
*/
isUnlimited(): boolean { isUnlimited(): boolean {
return this.maxLicenses === -1; return this.maxLicenses === -1;
} }
/**
* Returns true if this plan has unlimited shipments
*/
hasUnlimitedShipments(): boolean { hasUnlimitedShipments(): boolean {
return this.maxShipmentsPerYear === -1; return this.maxShipmentsPerYear === -1;
} }
/**
* Returns true if this is a paid plan
*/
isPaid(): boolean { isPaid(): boolean {
return this.plan !== 'BRONZE'; return this.plan !== 'BRONZE';
} }
/**
* Returns true if this is the free (Bronze) plan
*/
isFree(): boolean { isFree(): boolean {
return this.plan === 'BRONZE'; return this.plan === 'BRONZE';
} }
/**
* Returns true if this plan has custom pricing (Platinium)
*/
isCustomPricing(): boolean { isCustomPricing(): boolean {
return this.plan === 'PLATINIUM'; return this.plan === 'PLATINIUM';
} }
/**
* Check if a given number of users can be accommodated by this plan
*/
canAccommodateUsers(userCount: number): boolean { canAccommodateUsers(userCount: number): boolean {
if (this.isUnlimited()) return true; if (this.isUnlimited()) return true;
return userCount <= this.maxLicenses; return userCount <= this.maxLicenses;
} }
/**
* Check if upgrade to target plan is allowed
*/
canUpgradeTo(targetPlan: SubscriptionPlan): boolean { canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
@ -261,12 +285,15 @@ export class SubscriptionPlan {
return targetIndex > currentIndex; return targetIndex > currentIndex;
} }
/**
* Check if downgrade to target plan is allowed given current user count
*/
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);
if (targetIndex >= currentIndex) return false; if (targetIndex >= currentIndex) return false; // Not a downgrade
return targetPlan.canAccommodateUsers(currentUserCount); return targetPlan.canAccommodateUsers(currentUserCount);
} }

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

@ -5,20 +5,7 @@
*/ */
import { Subscription } from '@domain/entities/subscription.entity'; import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity'; import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
/** Maps canonical domain plan names back to the values stored in the DB. */
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
// Pass-through for any value already in ORM format
BRONZE: 'BRONZE',
SILVER: 'SILVER',
GOLD: 'GOLD',
PLATINIUM: 'PLATINIUM',
};
export class SubscriptionOrmMapper { export class SubscriptionOrmMapper {
/** /**
@ -30,7 +17,7 @@ export class SubscriptionOrmMapper {
orm.id = props.id; orm.id = props.id;
orm.organizationId = props.organizationId; orm.organizationId = props.organizationId;
orm.plan = DOMAIN_TO_ORM_PLAN[props.plan] ?? 'BRONZE'; orm.plan = props.plan;
orm.status = props.status; orm.status = props.status;
orm.stripeCustomerId = props.stripeCustomerId; orm.stripeCustomerId = props.stripeCustomerId;
orm.stripeSubscriptionId = props.stripeSubscriptionId; orm.stripeSubscriptionId = props.stripeSubscriptionId;

View File

@ -1,31 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreatePasswordResetTokens1741500000001 implements MigrationInterface {
name = 'CreatePasswordResetTokens1741500000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "password_reset_tokens" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"user_id" uuid NOT NULL,
"token" character varying(255) NOT NULL,
"expires_at" TIMESTAMP NOT NULL,
"used_at" TIMESTAMP,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_password_reset_tokens" PRIMARY KEY ("id"),
CONSTRAINT "UQ_password_reset_tokens_token" UNIQUE ("token")
)
`);
await queryRunner.query(
`CREATE INDEX "IDX_password_reset_tokens_token" ON "password_reset_tokens" ("token")`
);
await queryRunner.query(
`CREATE INDEX "IDX_password_reset_tokens_user_id" ON "password_reset_tokens" ("user_id")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "password_reset_tokens"`);
}
}

View File

@ -19,7 +19,6 @@ import {
ArrowRight, ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { sendContactForm } from '@/lib/api/auth';
export default function ContactPage() { export default function ContactPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -52,22 +51,11 @@ export default function ContactPage() {
setError(''); setError('');
setIsSubmitting(true); setIsSubmitting(true);
try { // Simulate form submission
await sendContactForm({ await new Promise((resolve) => setTimeout(resolve, 1500));
firstName: formData.firstName,
lastName: formData.lastName, setIsSubmitting(false);
email: formData.email, setIsSubmitted(true);
company: formData.company || undefined,
phone: formData.phone || undefined,
subject: formData.subject,
message: formData.message,
});
setIsSubmitted(true);
} catch (err: any) {
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
} finally {
setIsSubmitting(false);
}
}; };
const handleChange = ( const handleChange = (
@ -85,6 +73,7 @@ export default function ContactPage() {
title: 'Email', title: 'Email',
description: 'Envoyez-nous un email', description: 'Envoyez-nous un email',
value: 'contact@xpeditis.com', value: 'contact@xpeditis.com',
link: 'mailto:contact@xpeditis.com',
color: 'from-blue-500 to-cyan-500', color: 'from-blue-500 to-cyan-500',
}, },
{ {
@ -92,6 +81,7 @@ export default function ContactPage() {
title: 'Téléphone', title: 'Téléphone',
description: 'Appelez-nous', description: 'Appelez-nous',
value: '+33 1 23 45 67 89', value: '+33 1 23 45 67 89',
link: 'tel:+33123456789',
color: 'from-green-500 to-emerald-500', color: 'from-green-500 to-emerald-500',
}, },
{ {
@ -99,13 +89,15 @@ export default function ContactPage() {
title: 'Chat en direct', title: 'Chat en direct',
description: 'Discutez avec notre équipe', description: 'Discutez avec notre équipe',
value: 'Disponible 24/7', value: 'Disponible 24/7',
link: '#chat',
color: 'from-purple-500 to-pink-500', color: 'from-purple-500 to-pink-500',
}, },
{ {
icon: Headphones, icon: Headphones,
title: 'Support', title: 'Support',
description: 'Support client', description: 'Centre d\'aide',
value: 'support@xpeditis.com', value: 'support.xpeditis.com',
link: 'https://support.xpeditis.com',
color: 'from-orange-500 to-red-500', color: 'from-orange-500 to-red-500',
}, },
]; ];
@ -119,6 +111,22 @@ export default function ContactPage() {
email: 'paris@xpeditis.com', email: 'paris@xpeditis.com',
isHQ: true, isHQ: true,
}, },
{
city: 'Rotterdam',
address: 'Wilhelminakade 123',
postalCode: '3072 AP Rotterdam, Netherlands',
phone: '+31 10 123 4567',
email: 'rotterdam@xpeditis.com',
isHQ: false,
},
{
city: 'Hambourg',
address: 'Am Sandtorkai 50',
postalCode: '20457 Hamburg, Germany',
phone: '+49 40 123 4567',
email: 'hamburg@xpeditis.com',
isHQ: false,
},
]; ];
const subjects = [ const subjects = [
@ -219,20 +227,22 @@ export default function ContactPage() {
{contactMethods.map((method, index) => { {contactMethods.map((method, index) => {
const IconComponent = method.icon; const IconComponent = method.icon;
return ( return (
<motion.div <motion.a
key={index} key={index}
href={method.link}
variants={itemVariants} variants={itemVariants}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100" whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
> >
<div <div
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4`} className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
> >
<IconComponent className="w-6 h-6 text-white" /> <IconComponent className="w-6 h-6 text-white" />
</div> </div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3> <h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
<p className="text-gray-500 text-sm mb-2">{method.description}</p> <p className="text-gray-500 text-sm mb-2">{method.description}</p>
<p className="text-brand-turquoise font-medium">{method.value}</p> <p className="text-brand-turquoise font-medium">{method.value}</p>
</motion.div> </motion.a>
); );
})} })}
</div> </div>
@ -436,9 +446,9 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}} animate={isFormInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
> >
<h2 className="text-3xl font-bold text-brand-navy mb-6">Notre bureau</h2> <h2 className="text-3xl font-bold text-brand-navy mb-6">Nos bureaux</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">
Retrouvez-nous à Paris ou contactez-nous par email. Retrouvez-nous dans nos bureaux à travers l'Europe ou contactez-nous par email.
</p> </p>
<div className="space-y-6"> <div className="space-y-6">
@ -677,6 +687,39 @@ export default function ContactPage() {
</div> </div>
</section> </section>
{/* Map Section */}
<section className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="text-center mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre présence en Europe</h2>
<p className="text-gray-600">
Des bureaux stratégiquement situés pour mieux vous servir
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="bg-white rounded-2xl shadow-lg overflow-hidden"
>
<div className="aspect-[21/9] bg-gradient-to-br from-brand-navy/5 to-brand-turquoise/5 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-16 h-16 text-brand-turquoise mx-auto mb-4" />
<p className="text-gray-500">Carte interactive bientôt disponible</p>
</div>
</div>
</motion.div>
</div>
</section>
<LandingFooter /> <LandingFooter />
</div> </div>
); );

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin'; import { getAllBookings, validateBankTransfer } from '@/lib/api/admin';
interface Booking { interface Booking {
id: string; id: string;
@ -32,29 +32,11 @@ export default function AdminBookingsPage() {
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [validatingId, setValidatingId] = useState<string | null>(null); const [validatingId, setValidatingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
useEffect(() => { useEffect(() => {
fetchBookings(); fetchBookings();
}, []); }, []);
const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
setDeletingId(bookingId);
try {
await deleteAdminBooking(bookingId);
setBookings(prev => prev.filter(b => b.id !== bookingId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
} finally {
setDeletingId(null);
}
};
const handleValidateTransfer = async (bookingId: string) => { const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return; if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
setValidatingId(bookingId); setValidatingId(bookingId);
@ -304,23 +286,15 @@ export default function AdminBookingsPage() {
{/* Actions */} {/* Actions */}
<td className="px-4 py-4 whitespace-nowrap text-right text-sm"> <td className="px-4 py-4 whitespace-nowrap text-right text-sm">
<button {booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
onClick={(e) => { <button
if (openMenuId === booking.id) { onClick={() => handleValidateTransfer(booking.id)}
setOpenMenuId(null); disabled={validatingId === booking.id}
setMenuPosition(null); className="px-3 py-1 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
} else { >
const rect = e.currentTarget.getBoundingClientRect(); {validatingId === booking.id ? '...' : '✓ Valider virement'}
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 }); </button>
setOpenMenuId(booking.id); )}
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</td> </td>
</tr> </tr>
)) ))
@ -329,220 +303,6 @@ export default function AdminBookingsPage() {
</table> </table>
</div> </div>
</div> </div>
{/* Actions Dropdown Menu */}
{openMenuId && menuPosition && (
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
<button
onClick={() => {
const booking = bookings.find(b => b.id === openMenuId);
if (booking) {
setSelectedBooking(booking);
setShowDetailsModal(true);
}
setOpenMenuId(null);
setMenuPosition(null);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
>
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-sm font-medium text-gray-700">Voir les détails</span>
</button>
{(() => {
const booking = bookings.find(b => b.id === openMenuId);
return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? (
<button
onClick={() => {
const id = openMenuId;
setOpenMenuId(null);
setMenuPosition(null);
if (id) handleValidateTransfer(id);
}}
disabled={validatingId === openMenuId}
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-green-700">Valider virement</span>
</button>
) : null;
})()}
<button
onClick={() => {
const id = openMenuId;
setOpenMenuId(null);
setMenuPosition(null);
if (id) handleDeleteBooking(id);
}}
disabled={deletingId === openMenuId}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
>
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">Supprimer</span>
</button>
</div>
</div>
</>
)}
{/* Details Modal */}
{showDetailsModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">N° Booking</label>
<div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Statut</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
{getStatusLabel(selectedBooking.status)}
</span>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origine</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Type conteneur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
</div>
{selectedBooking.palletCount != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Palettes</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
</div>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Poids</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Volume</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
</div>
)}
</div>
</div>
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Prix</h3>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && (
<div>
<label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} </div>
</div>
)}
{selectedBooking.priceUSD != null && (
<div>
<label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
</div>
)}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Créée le</label>
<div className="mt-1 text-gray-900">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">Mise à jour</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
</div>
)}
</div>
</div>
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
<div className="border-t pt-4">
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
handleValidateTransfer(selectedBooking.id);
}}
disabled={validatingId === selectedBooking.id}
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{validatingId === selectedBooking.id ? 'Validation...' : '✓ Valider le virement'}
</button>
</div>
)}
</div>
<div className="flex justify-end mt-6 pt-4 border-t">
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -81,22 +81,20 @@ export default function AdminCsvRatesPage() {
{/* Configurations Table */} {/* Configurations Table */}
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center justify-between"> <div>
<div> <CardTitle>Configurations CSV actives</CardTitle>
<CardTitle>Configurations CSV actives</CardTitle> <CardDescription>
<CardDescription> Liste de toutes les compagnies avec fichiers CSV configurés
Liste de toutes les compagnies avec fichiers CSV configurés </CardDescription>
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div> </div>
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{error && ( {error && (
@ -122,7 +120,6 @@ export default function AdminCsvRatesPage() {
<TableHead>Taille</TableHead> <TableHead>Taille</TableHead>
<TableHead>Lignes</TableHead> <TableHead>Lignes</TableHead>
<TableHead>Date d'upload</TableHead> <TableHead>Date d'upload</TableHead>
<TableHead>Email</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -145,11 +142,6 @@ export default function AdminCsvRatesPage() {
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')} {new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
</div> </div>
</TableCell> </TableCell>
<TableCell>
<div className="text-xs text-muted-foreground">
{file.companyEmail ?? '—'}
</div>
</TableCell>
<TableCell> <TableCell>
<Button <Button
variant="ghost" variant="ghost"

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin'; import { getAllBookings, getAllUsers } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react'; import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -54,9 +54,6 @@ export default function AdminDocumentsPage() {
const [filterQuoteNumber, setFilterQuoteNumber] = useState(''); const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Helper function to get formatted quote number // Helper function to get formatted quote number
const getQuoteNumber = (booking: Booking): string => { const getQuoteNumber = (booking: Booking): string => {
@ -268,19 +265,6 @@ export default function AdminDocumentsPage() {
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
}; };
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm('Supprimer définitivement ce document ?')) return;
setDeletingId(documentId);
try {
await deleteAdminDocument(bookingId, documentId);
setDocuments(prev => prev.filter(d => d.id !== documentId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
} finally {
setDeletingId(null);
}
};
const handleDownload = async (url: string, fileName: string) => { const handleDownload = async (url: string, fileName: string) => {
try { try {
// Try direct download first // Try direct download first
@ -442,8 +426,8 @@ export default function AdminDocumentsPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisateur Utilisateur
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions Télécharger
</th> </th>
</tr> </tr>
</thead> </thead>
@ -484,24 +468,15 @@ export default function AdminDocumentsPage() {
{doc.userName || doc.userId.substring(0, 8) + '...'} {doc.userName || doc.userId.substring(0, 8) + '...'}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right"> <td className="px-6 py-4 whitespace-nowrap text-center">
<button <button
onClick={(e) => { onClick={() => handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document')}
const menuKey = `${doc.bookingId}::${doc.id}`; className="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors"
if (openMenuId === menuKey) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
setOpenMenuId(menuKey);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
> >
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>
Télécharger
</button> </button>
</td> </td>
</tr> </tr>
@ -611,60 +586,6 @@ export default function AdminDocumentsPage() {
</div> </div>
)} )}
</div> </div>
{/* Actions Dropdown Menu */}
{openMenuId && menuPosition && (
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
{(() => {
const [bookingId, documentId] = openMenuId.split('::');
const doc = documents.find(d => d.bookingId === bookingId && d.id === documentId);
if (!doc) return null;
return (
<>
<button
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document');
}}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
>
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="text-sm font-medium text-gray-700">Télécharger</span>
</button>
<button
onClick={() => {
const bId = doc.bookingId;
const dId = doc.id;
setOpenMenuId(null);
setMenuPosition(null);
handleDeleteDocument(bId, dId);
}}
disabled={deletingId === doc.id}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
>
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">Supprimer</span>
</button>
</>
);
})()}
</div>
</div>
</>
)}
</div> </div>
); );
} }

View File

@ -1,533 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Download,
RefreshCw,
Filter,
Activity,
AlertTriangle,
Info,
Bug,
Server,
} from 'lucide-react';
import { get, download } from '@/lib/api/client';
const LOGS_PREFIX = '/api/v1/logs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface LogEntry {
timestamp: string;
service: string;
level: string;
context: string;
message: string;
reqId: string;
req_method: string;
req_url: string;
res_status: string;
response_time_ms: string;
error: string;
}
interface LogsResponse {
total: number;
query: string;
range: { from: string; to: string };
logs: LogEntry[];
}
interface Filters {
service: string;
level: string;
search: string;
startDate: string;
endDate: string;
limit: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 border border-red-200',
fatal: 'bg-red-200 text-red-900 border border-red-300',
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200',
info: 'bg-blue-100 text-blue-700 border border-blue-200',
debug: 'bg-gray-100 text-gray-600 border border-gray-200',
trace: 'bg-purple-100 text-purple-700 border border-purple-200',
};
const LEVEL_ROW_BG: Record<string, string> = {
error: 'bg-red-50',
fatal: 'bg-red-100',
warn: 'bg-yellow-50',
info: '',
debug: '',
trace: '',
};
function LevelBadge({ level }: { level: string }) {
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}>
{level}
</span>
);
}
function StatCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number | string;
icon: any;
color: string;
}) {
return (
<div className="bg-white rounded-lg border p-4 flex items-center gap-4">
<div className={`p-2 rounded-lg ${color}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-sm text-gray-500">{label}</p>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AdminLogsPage() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [services, setServices] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [filters, setFilters] = useState<Filters>({
service: 'all',
level: 'all',
search: '',
startDate: oneHourAgo.toISOString().slice(0, 16),
endDate: now.toISOString().slice(0, 16),
limit: '500',
});
// Load available services
useEffect(() => {
get<{ services: string[] }>(`${LOGS_PREFIX}/services`)
.then(d => setServices(d.services || []))
.catch(() => {});
}, []);
const buildQueryString = useCallback(
(fmt?: string) => {
const params = new URLSearchParams();
if (filters.service !== 'all') params.set('service', filters.service);
if (filters.level !== 'all') params.set('level', filters.level);
if (filters.search) params.set('search', filters.search);
if (filters.startDate) params.set('start', new Date(filters.startDate).toISOString());
if (filters.endDate) params.set('end', new Date(filters.endDate).toISOString());
params.set('limit', filters.limit);
if (fmt) params.set('format', fmt);
return params.toString();
},
[filters],
);
const fetchLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await get<LogsResponse>(
`${LOGS_PREFIX}/export?${buildQueryString('json')}`,
);
setLogs(data.logs || []);
setTotal(data.total || 0);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [buildQueryString]);
useEffect(() => {
fetchLogs();
}, []);
const handleExport = async (format: 'json' | 'csv') => {
setExportLoading(true);
try {
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
await download(
`${LOGS_PREFIX}/export?${buildQueryString(format)}`,
filename,
);
} catch (err: any) {
setError(err.message);
} finally {
setExportLoading(false);
}
};
// Stats
const countByLevel = (level: string) =>
logs.filter(l => l.level === level).length;
const setFilter = (key: keyof Filters, value: string) =>
setFilters(prev => ({ ...prev, [key]: value }));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Logs système</h1>
<p className="mt-1 text-sm text-gray-500">
Visualisation et export des logs applicatifs en temps réel
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchLogs}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Actualiser
</button>
<div className="relative group">
<button
disabled={exportLoading || loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
{exportLoading ? 'Export...' : 'Exporter'}
</button>
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
<button
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger CSV
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger JSON
</button>
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Total logs"
value={total}
icon={Activity}
color="bg-blue-100 text-blue-600"
/>
<StatCard
label="Erreurs"
value={countByLevel('error') + countByLevel('fatal')}
icon={AlertTriangle}
color="bg-red-100 text-red-600"
/>
<StatCard
label="Warnings"
value={countByLevel('warn')}
icon={AlertTriangle}
color="bg-yellow-100 text-yellow-600"
/>
<StatCard
label="Info"
value={countByLevel('info')}
icon={Info}
color="bg-green-100 text-green-600"
/>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border p-4">
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700">Filtres</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Service */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Service</label>
<select
value={filters.service}
onChange={e => setFilter('service', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">Tous</option>
{services.map(s => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Level */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Niveau</label>
<select
value={filters.level}
onChange={e => setFilter('level', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">Tous</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
<option value="warn">Warn</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</div>
{/* Search */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Recherche</label>
<input
type="text"
placeholder="Texte libre..."
value={filters.search}
onChange={e => setFilter('search', e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* Start */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Début</label>
<input
type="datetime-local"
value={filters.startDate}
onChange={e => setFilter('startDate', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* End */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Fin</label>
<input
type="datetime-local"
value={filters.endDate}
onChange={e => setFilter('endDate', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* Limit + Apply */}
<div className="flex flex-col justify-end gap-2">
<label className="block text-xs font-medium text-gray-500">Limite</label>
<div className="flex gap-2">
<select
value={filters.limit}
onChange={e => setFilter('limit', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:outline-none"
>
<option value="100">100</option>
<option value="500">500</option>
<option value="1000">1000</option>
<option value="5000">5000</option>
</select>
<button
onClick={fetchLogs}
disabled={loading}
className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap"
>
Filtrer
</button>
</div>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Impossible de contacter le log-exporter : <strong>{error}</strong>
<br />
<span className="text-xs text-red-500">
Vérifiez que le backend et le log-exporter sont démarrés.
</span>
</span>
</div>
)}
{/* Table */}
<div className="bg-white rounded-lg border overflow-hidden">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`}
</span>
</div>
{!loading && logs.length > 0 && (
<span className="text-xs text-gray-400">
Cliquer sur une ligne pour les détails
</span>
)}
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#34CCCD]" />
</div>
) : logs.length === 0 && !error ? (
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
<Bug className="h-8 w-8" />
<p className="text-sm">Aucun log trouvé pour ces filtres</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Timestamp
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Service
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Niveau
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Contexte
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Req / Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log, i) => (
<>
<tr
key={i}
onClick={() => setExpandedRow(expandedRow === i ? null : i)}
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<span className="px-2 py-0.5 bg-[#10183A] text-white text-xs rounded font-mono">
{log.service}
</span>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<LevelBadge level={log.level} />
</td>
<td className="px-4 py-2 text-xs text-gray-500 whitespace-nowrap">
{log.context || '—'}
</td>
<td className="px-4 py-2 max-w-xs">
<span className="line-clamp-1 text-gray-800">
{log.error ? (
<span className="text-red-600">{log.error}</span>
) : (
log.message
)}
</span>
</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{log.req_method && (
<span>
<span className="font-semibold">{log.req_method}</span>{' '}
{log.req_url}{' '}
{log.res_status && (
<span
className={
String(log.res_status).startsWith('5')
? 'text-red-500 font-bold'
: String(log.res_status).startsWith('4')
? 'text-yellow-600 font-bold'
: 'text-green-600'
}
>
{log.res_status}
</span>
)}
</span>
)}
</td>
</tr>
{/* Expanded detail row */}
{expandedRow === i && (
<tr key={`detail-${i}`} className="bg-gray-50">
<td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<span className="font-semibold text-gray-600">Timestamp</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
</div>
{log.reqId && (
<div>
<span className="font-semibold text-gray-600">Request ID</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
</div>
)}
{log.response_time_ms && (
<div>
<span className="font-semibold text-gray-600">Durée</span>
<p className="font-mono text-gray-800 mt-0.5">
{log.response_time_ms} ms
</p>
</div>
)}
<div className="col-span-2 md:col-span-4">
<span className="font-semibold text-gray-600">Message complet</span>
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
{log.error
? `[ERROR] ${log.error}\n\n${log.message}`
: log.message}
</pre>
</div>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -80,7 +80,7 @@ export default function UsersManagementPage() {
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER', role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
@ -635,8 +635,12 @@ export default function UsersManagementPage() {
> >
<option value="USER">Utilisateur</option> <option value="USER">Utilisateur</option>
<option value="MANAGER">Manager</option> <option value="MANAGER">Manager</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
<option value="VIEWER">Lecteur</option> <option value="VIEWER">Lecteur</option>
</select> </select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">Seuls les administrateurs peuvent attribuer le rôle ADMIN</p>
)}
</div> </div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> <div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button <button

View File

@ -1,9 +1,13 @@
/**
* Forgot Password Page
*
* Request password reset
*/
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { forgotPassword } from '@/lib/api/auth';
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -17,173 +21,97 @@ export default function ForgotPasswordPage() {
setLoading(true); setLoading(true);
try { try {
await forgotPassword(email); // TODO: Implement forgotPassword API endpoint
await new Promise(resolve => setTimeout(resolve, 1000));
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Une erreur est survenue. Veuillez réessayer.'); setError(err.response?.data?.message || 'Failed to send reset email. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( if (success) {
<div className="min-h-screen flex"> return (
{/* Left Side - Form */} <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full lg:w-1/2 flex flex-col justify-center px-8 sm:px-12 lg:px-16 xl:px-24 bg-white"> <div className="max-w-md w-full space-y-8">
<div className="max-w-md w-full mx-auto"> <div>
{/* Logo */} <h1 className="text-center text-4xl font-bold text-blue-600">Xpeditis</h1>
<div className="mb-10"> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<Link href="/"> Check your email
<Image </h2>
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={50}
height={60}
priority
className="h-auto"
/>
</Link>
</div> </div>
{success ? ( <div className="rounded-md bg-green-50 p-4">
<> <div className="text-sm text-green-800">
<div className="mb-8"> We've sent a password reset link to <strong>{email}</strong>. Please check your inbox
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6"> and follow the instructions.
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h1 className="text-h1 text-brand-navy mb-2">Email envoyé</h1>
<p className="text-body text-neutral-600">
Si un compte est associé à <strong>{email}</strong>, vous recevrez un email avec
les instructions pour réinitialiser votre mot de passe.
</p>
<p className="text-body-sm text-neutral-500 mt-3">
Pensez à vérifier vos spams si vous ne recevez rien d'ici quelques minutes.
</p>
</div>
<Link href="/login" className="btn-primary w-full text-center block text-lg">
Retour à la connexion
</Link>
</>
) : (
<>
{/* Header */}
<div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">Mot de passe oublié ?</h1>
<p className="text-body text-neutral-600">
Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-body-sm text-red-800">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="label">
Adresse email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="input w-full"
placeholder="votre.email@entreprise.com"
autoComplete="email"
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Envoi en cours...' : 'Envoyer le lien de réinitialisation'}
</button>
</form>
<div className="mt-8 text-center">
<Link href="/login" className="text-body-sm link flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour à la connexion
</Link>
</div>
</>
)}
{/* Footer Links */}
<div className="mt-8 pt-8 border-t border-neutral-200">
<div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500">
<Link href="/contact" className="hover:text-accent transition-colors">
Contactez-nous
</Link>
<Link href="/privacy" className="hover:text-accent transition-colors">
Confidentialité
</Link>
<Link href="/terms" className="hover:text-accent transition-colors">
Conditions
</Link>
</div> </div>
</div> </div>
<div className="text-center">
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</Link>
</div>
</div> </div>
</div> </div>
);
}
{/* Right Side - Brand */} return (
<div className="hidden lg:block lg:w-1/2 relative bg-brand-navy"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-800 opacity-95"></div> <div className="max-w-md w-full space-y-8">
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white"> <div>
<div className="max-w-xl"> <h1 className="text-center text-4xl font-bold text-blue-600">Xpeditis</h1>
<h2 className="text-display-sm mb-6 text-white">Sécurité avant tout</h2> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<p className="text-body-lg text-neutral-200 mb-12"> Reset your password
La protection de votre compte est notre priorité. Réinitialisez votre mot de passe en toute sécurité. </h2>
</p> <p className="mt-2 text-center text-sm text-gray-600">
<div className="space-y-6"> Enter your email address and we'll send you a link to reset your password.
<div className="flex items-start"> </p>
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center"> </div>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
</svg> {error && (
</div> <div className="rounded-md bg-red-50 p-4">
<div className="ml-4"> <div className="text-sm text-red-800">{error}</div>
<h3 className="text-h5 mb-1 text-white">Lien sécurisé</h3>
<p className="text-body-sm text-neutral-300">Le lien expire après 1 heure pour votre sécurité</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Email de confirmation</h3>
<p className="text-body-sm text-neutral-300">Vérifiez votre boîte de réception et vos spams</p>
</div>
</div>
</div> </div>
)}
<div>
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div> </div>
</div>
<div className="absolute bottom-0 right-0 opacity-10"> <div>
<svg width="400" height="400" viewBox="0 0 400 400" fill="none"> <button
<circle cx="200" cy="200" r="150" stroke="currentColor" strokeWidth="2" className="text-white" /> type="submit"
<circle cx="200" cy="200" r="100" stroke="currentColor" strokeWidth="2" className="text-white" /> disabled={loading}
<circle cx="200" cy="200" r="50" stroke="currentColor" strokeWidth="2" className="text-white" /> className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
</svg> >
</div> {loading ? 'Sending...' : 'Send reset link'}
</button>
</div>
<div className="text-center text-sm">
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</Link>
</div>
</form>
</div> </div>
</div> </div>
); );

View File

@ -129,7 +129,7 @@ function LoginPageContent() {
setIsLoading(true); setIsLoading(true);
try { try {
await login(email, password, redirectTo, rememberMe); await login(email, password, redirectTo);
// Navigation is handled by the login function in auth context // Navigation is handled by the login function in auth context
} catch (err: any) { } catch (err: any) {
const { message, field } = getErrorMessage(err); const { message, field } = getErrorMessage(err);
@ -308,6 +308,9 @@ function LoginPageContent() {
{/* Footer Links */} {/* Footer Links */}
<div className="mt-8 pt-8 border-t border-neutral-200"> <div className="mt-8 pt-8 border-t border-neutral-200">
<div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500"> <div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500">
<Link href="/help" className="hover:text-accent transition-colors">
Centre d'aide
</Link>
<Link href="/contact" className="hover:text-accent transition-colors"> <Link href="/contact" className="hover:text-accent transition-colors">
Contactez-nous Contactez-nous
</Link> </Link>

View File

@ -1,6 +1,12 @@
/**
* Register Page - Xpeditis
*
* Modern registration page with split-screen design
*/
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
@ -8,25 +14,20 @@ import { register } from '@/lib/api';
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
import type { OrganizationType } from '@/types/api'; import type { OrganizationType } from '@/types/api';
function RegisterPageContent() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// Step management
const [step, setStep] = useState<1 | 2>(1);
// Step 1 — Personal info
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
// Step 2 — Organization // Organization fields
const [organizationName, setOrganizationName] = useState(''); const [organizationName, setOrganizationName] = useState('');
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER'); const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
const [siren, setSiren] = useState(''); const [siren, setSiren] = useState('');
const [siret, setSiret] = useState('');
const [street, setStreet] = useState(''); const [street, setStreet] = useState('');
const [city, setCity] = useState(''); const [city, setCity] = useState('');
const [state, setState] = useState(''); const [state, setState] = useState('');
@ -36,11 +37,12 @@ function RegisterPageContent() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Invitation state // Invitation-related state
const [invitationToken, setInvitationToken] = useState<string | null>(null); const [invitationToken, setInvitationToken] = useState<string | null>(null);
const [invitation, setInvitation] = useState<InvitationResponse | null>(null); const [invitation, setInvitation] = useState<InvitationResponse | null>(null);
const [isVerifyingInvitation, setIsVerifyingInvitation] = useState(false); const [isVerifyingInvitation, setIsVerifyingInvitation] = useState(false);
// Verify invitation token on mount
useEffect(() => { useEffect(() => {
const token = searchParams.get('token'); const token = searchParams.get('token');
if (token) { if (token) {
@ -49,12 +51,13 @@ function RegisterPageContent() {
.then(invitationData => { .then(invitationData => {
setInvitation(invitationData); setInvitation(invitationData);
setInvitationToken(token); setInvitationToken(token);
// Pre-fill user information from invitation
setEmail(invitationData.email); setEmail(invitationData.email);
setFirstName(invitationData.firstName); setFirstName(invitationData.firstName);
setLastName(invitationData.lastName); setLastName(invitationData.lastName);
}) })
.catch(() => { .catch(err => {
setError("Le lien d'invitation est invalide ou expiré."); setError('Le lien d\'invitation est invalide ou expiré.');
}) })
.finally(() => { .finally(() => {
setIsVerifyingInvitation(false); setIsVerifyingInvitation(false);
@ -62,58 +65,41 @@ function RegisterPageContent() {
} }
}, [searchParams]); }, [searchParams]);
// ---- Step 1 validation ---- const handleSubmit = async (e: React.FormEvent) => {
const validateStep1 = (): string | null => {
if (!firstName.trim() || firstName.trim().length < 2) return 'Le prénom doit contenir au moins 2 caractères';
if (!lastName.trim() || lastName.trim().length < 2) return 'Le nom doit contenir au moins 2 caractères';
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "L'adresse email n'est pas valide";
if (password.length < 12) return 'Le mot de passe doit contenir au moins 12 caractères';
if (password !== confirmPassword) return 'Les mots de passe ne correspondent pas';
return null;
};
const handleStep1 = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
const err = validateStep1();
if (err) { // Validate passwords match
setError(err); if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas');
return; return;
} }
// If invitation — submit directly (no org step)
if (invitationToken) {
handleFinalSubmit();
} else {
setStep(2);
}
};
// ---- Step 2 validation ---- // Validate password length
const validateStep2 = (): string | null => { if (password.length < 12) {
if (!organizationName.trim()) return "Le nom de l'organisation est requis"; setError('Le mot de passe doit contenir au moins 12 caractères');
if (!/^[0-9]{9}$/.test(siren)) return 'Le numéro SIREN est requis (9 chiffres)';
if (siret && !/^[0-9]{14}$/.test(siret)) return 'Le numéro SIRET doit contenir 14 chiffres';
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
return "Tous les champs d'adresse sont requis";
}
return null;
};
const handleStep2 = (e: React.FormEvent) => {
e.preventDefault();
setError('');
const err = validateStep2();
if (err) {
setError(err);
return; return;
} }
handleFinalSubmit();
};
// ---- Final submit ---- // Validate organization fields only if NOT using invitation
const handleFinalSubmit = async () => { if (!invitationToken) {
if (!organizationName.trim()) {
setError('Le nom de l\'organisation est requis');
return;
}
if (!siren.trim() || !/^[0-9]{9}$/.test(siren)) {
setError('Le numero SIREN est requis (9 chiffres)');
return;
}
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
setError('Tous les champs d\'adresse sont requis');
return;
}
}
setIsLoading(true); setIsLoading(true);
setError('');
try { try {
await register({ await register({
@ -121,6 +107,7 @@ function RegisterPageContent() {
password, password,
firstName, firstName,
lastName, lastName,
// If invitation token exists, use it; otherwise provide organization data
...(invitationToken ...(invitationToken
? { invitationToken } ? { invitationToken }
: { : {
@ -128,7 +115,6 @@ function RegisterPageContent() {
name: organizationName, name: organizationName,
type: organizationType, type: organizationType,
siren, siren,
siret: siret || undefined,
street, street,
city, city,
state: state || undefined, state: state || undefined,
@ -140,92 +126,18 @@ function RegisterPageContent() {
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Erreur lors de la création du compte'); setError(err.message || 'Erreur lors de la création du compte');
// On error at step 2, stay on step 2; at invitation (step 1), stay on step 1
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// ---- Right panel content ----
const rightPanel = (
<div className="hidden lg:block lg:w-1/2 relative bg-brand-navy">
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-800 opacity-95"></div>
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
<div className="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">
{invitation ? 'Rejoignez votre équipe' : 'Rejoignez des milliers d\'entreprises'}
</h2>
<p className="text-body-lg text-neutral-200 mb-12">
Simplifiez votre logistique maritime et gagnez du temps sur chaque expédition.
</p>
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Essai gratuit de 30 jours</h3>
<p className="text-body-sm text-neutral-300">Testez toutes les fonctionnalités sans engagement</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Sécurité maximale</h3>
<p className="text-body-sm text-neutral-300">Vos données sont protégées et chiffrées</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Support 24/7</h3>
<p className="text-body-sm text-neutral-300">Notre équipe est pour vous accompagner</p>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700">
<div>
<div className="text-display-sm text-brand-turquoise">2k+</div>
<div className="text-body-sm text-neutral-300 mt-1">Entreprises</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">150+</div>
<div className="text-body-sm text-neutral-300 mt-1">Pays couverts</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">24/7</div>
<div className="text-body-sm text-neutral-300 mt-1">Support</div>
</div>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 opacity-10">
<svg width="400" height="400" viewBox="0 0 400 400" fill="none">
<circle cx="200" cy="200" r="150" stroke="currentColor" strokeWidth="2" className="text-white" />
<circle cx="200" cy="200" r="100" stroke="currentColor" strokeWidth="2" className="text-white" />
<circle cx="200" cy="200" r="50" stroke="currentColor" strokeWidth="2" className="text-white" />
</svg>
</div>
</div>
);
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
{/* Left Side - Form */} {/* Left Side - Form */}
<div className="w-full lg:w-1/2 flex flex-col justify-center px-8 sm:px-12 lg:px-16 xl:px-24 bg-white"> <div className="w-full lg:w-1/2 flex flex-col justify-center px-8 sm:px-12 lg:px-16 xl:px-24 bg-white">
<div className="max-w-md w-full mx-auto"> <div className="max-w-md w-full mx-auto">
{/* Logo */} {/* Logo */}
<div className="mb-8"> <div className="mb-10">
<Link href="/"> <Link href="/">
<Image <Image
src="/assets/logos/logo-black.svg" src="/assets/logos/logo-black.svg"
@ -238,179 +150,142 @@ function RegisterPageContent() {
</Link> </Link>
</div> </div>
{/* Progress indicator (only for self-registration, 2 steps) */} {/* Header */}
{!invitation && ( <div className="mb-8">
<div className="mb-8"> <h1 className="text-h1 text-brand-navy mb-2">
<div className="flex items-center gap-3"> {invitation ? 'Accepter l\'invitation' : 'Créer un compte'}
<div className="flex items-center gap-2"> </h1>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${ <p className="text-body text-neutral-600">
step >= 1 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400' {invitation
}`}> ? `Vous avez été invité à rejoindre une organisation`
{step > 1 ? ( : 'Commencez votre essai gratuit dès aujourd\'hui'}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </p>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /> </div>
</svg>
) : '1'} {/* Verifying Invitation Loading */}
</div> {isVerifyingInvitation && (
<span className={`text-body-sm font-medium ${step >= 1 ? 'text-brand-navy' : 'text-neutral-400'}`}> <div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
Votre compte <p className="text-body-sm text-blue-800">Vérification de l'invitation...</p>
</span>
</div>
<div className={`flex-1 h-0.5 transition-colors ${step >= 2 ? 'bg-brand-navy' : 'bg-neutral-200'}`} />
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
step >= 2 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400'
}`}>
2
</div>
<span className={`text-body-sm font-medium ${step >= 2 ? 'text-brand-navy' : 'text-neutral-400'}`}>
Votre organisation
</span>
</div>
</div>
</div> </div>
)} )}
{/* Header */} {/* Success Message for Invitation */}
<div className="mb-6"> {invitation && !error && (
{isVerifyingInvitation ? ( <div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-body text-neutral-600">Vérification de l'invitation...</p> <p className="text-body-sm text-green-800">
) : invitation ? ( Invitation valide ! Créez votre mot de passe pour rejoindre l'organisation.
<> </p>
<h1 className="text-h1 text-brand-navy mb-2">Accepter l'invitation</h1> </div>
<div className="p-3 bg-green-50 border border-green-200 rounded-lg"> )}
<p className="text-body-sm text-green-800">
Invitation valide créez votre mot de passe pour rejoindre l'organisation.
</p>
</div>
</>
) : step === 1 ? (
<>
<h1 className="text-h1 text-brand-navy mb-2">Créer un compte</h1>
<p className="text-body text-neutral-600">Commencez votre essai gratuit dès aujourd'hui</p>
</>
) : (
<>
<h1 className="text-h1 text-brand-navy mb-2">Votre organisation</h1>
<p className="text-body text-neutral-600">Renseignez les informations de votre entreprise</p>
</>
)}
</div>
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3"> <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-body-sm text-red-800">{error}</p> <p className="text-body-sm text-red-800">{error}</p>
</div> </div>
)} )}
{/* ---- STEP 1: Personal info ---- */} {/* Form */}
{(step === 1 || invitation) && !isVerifyingInvitation && ( <form onSubmit={handleSubmit} className="space-y-5">
<form onSubmit={handleStep1} className="space-y-5"> {/* First Name & Last Name */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="label">Prénom</label>
<input
id="firstName"
type="text"
required
value={firstName}
onChange={e => setFirstName(e.target.value)}
className="input w-full"
placeholder="Jean"
disabled={isLoading || !!invitation}
/>
</div>
<div>
<label htmlFor="lastName" className="label">Nom</label>
<input
id="lastName"
type="text"
required
value={lastName}
onChange={e => setLastName(e.target.value)}
className="input w-full"
placeholder="Dupont"
disabled={isLoading || !!invitation}
/>
</div>
</div>
<div> <div>
<label htmlFor="email" className="label">Adresse email</label> <label htmlFor="firstName" className="label">
Prénom
</label>
<input <input
id="email" id="firstName"
type="email" type="text"
required required
value={email} value={firstName}
onChange={e => setEmail(e.target.value)} onChange={e => setFirstName(e.target.value)}
className="input w-full" className="input w-full"
placeholder="jean.dupont@entreprise.com" placeholder="Jean"
autoComplete="email"
disabled={isLoading || !!invitation} disabled={isLoading || !!invitation}
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="label">Mot de passe</label> <label htmlFor="lastName" className="label">
Nom
</label>
<input <input
id="password" id="lastName"
type="password" type="text"
required required
value={password} value={lastName}
onChange={e => setPassword(e.target.value)} onChange={e => setLastName(e.target.value)}
className="input w-full" className="input w-full"
placeholder="••••••••••••" placeholder="Dupont"
autoComplete="new-password" disabled={isLoading || !!invitation}
disabled={isLoading}
/>
<p className="mt-1.5 text-body-xs text-neutral-500">Au moins 12 caractères</p>
</div>
<div>
<label htmlFor="confirmPassword" className="label">Confirmer le mot de passe</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="input w-full"
placeholder="••••••••••••"
autoComplete="new-password"
disabled={isLoading}
/> />
</div> </div>
</div>
<button {/* Email */}
type="submit" <div>
<label htmlFor="email" className="label">
Adresse email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="input w-full"
placeholder="jean.dupont@entreprise.com"
autoComplete="email"
disabled={isLoading || !!invitation}
/>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="label">
Mot de passe
</label>
<input
id="password"
type="password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="input w-full"
placeholder="••••••••••••"
autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed mt-2" />
> <p className="mt-1.5 text-body-xs text-neutral-500">Au moins 12 caractères</p>
{isLoading </div>
? 'Création du compte...'
: invitation
? 'Créer mon compte'
: 'Continuer'}
</button>
<p className="text-body-xs text-center text-neutral-500"> {/* Confirm Password */}
En créant un compte, vous acceptez nos{' '} <div>
<Link href="/terms" className="link">Conditions d'utilisation</Link>{' '} <label htmlFor="confirmPassword" className="label">
et notre{' '} Confirmer le mot de passe
<Link href="/privacy" className="link">Politique de confidentialité</Link> </label>
</p> <input
</form> id="confirmPassword"
)} type="password"
required
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="input w-full"
placeholder="••••••••••••"
autoComplete="new-password"
disabled={isLoading}
/>
</div>
{/* ---- STEP 2: Organization info ---- */} {/* Organization Section - Only show if NOT using invitation */}
{step === 2 && !invitation && ( {!invitation && (
<form onSubmit={handleStep2} className="space-y-5"> <div className="pt-4 border-t border-neutral-200">
<div> <h3 className="text-h5 text-brand-navy mb-4">Informations de votre organisation</h3>
<label htmlFor="organizationName" className="label">Nom de l'organisation *</label>
{/* Organization Name */}
<div className="mb-4">
<label htmlFor="organizationName" className="label">
Nom de l'organisation
</label>
<input <input
id="organizationName" id="organizationName"
type="text" type="text"
@ -423,8 +298,11 @@ function RegisterPageContent() {
/> />
</div> </div>
<div> {/* Organization Type */}
<label htmlFor="organizationType" className="label">Type d'organisation *</label> <div className="mb-4">
<label htmlFor="organizationType" className="label">
Type d'organisation
</label>
<select <select
id="organizationType" id="organizationType"
value={organizationType} value={organizationType}
@ -438,40 +316,30 @@ function RegisterPageContent() {
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* SIREN */}
<div> <div className="mb-4">
<label htmlFor="siren" className="label">SIREN *</label> <label htmlFor="siren" className="label">
<input Numero SIREN *
id="siren" </label>
type="text" <input
required id="siren"
value={siren} type="text"
onChange={e => setSiren(e.target.value.replace(/\D/g, '').slice(0, 9))} required
className="input w-full" value={siren}
placeholder="123456789" onChange={e => setSiren(e.target.value.replace(/\D/g, ''))}
maxLength={9} className="input w-full"
disabled={isLoading} placeholder="123456789"
/> maxLength={9}
<p className="mt-1 text-body-xs text-neutral-500">9 chiffres</p> disabled={isLoading}
</div> />
<div> <p className="mt-1.5 text-body-xs text-neutral-500">9 chiffres, obligatoire pour toute organisation</p>
<label htmlFor="siret" className="label">SIRET <span className="text-neutral-400 font-normal">(optionnel)</span></label>
<input
id="siret"
type="text"
value={siret}
onChange={e => setSiret(e.target.value.replace(/\D/g, '').slice(0, 14))}
className="input w-full"
placeholder="12345678900014"
maxLength={14}
disabled={isLoading}
/>
<p className="mt-1 text-body-xs text-neutral-500">14 chiffres</p>
</div>
</div> </div>
<div> {/* Street Address */}
<label htmlFor="street" className="label">Adresse *</label> <div className="mb-4">
<label htmlFor="street" className="label">
Adresse
</label>
<input <input
id="street" id="street"
type="text" type="text"
@ -484,9 +352,12 @@ function RegisterPageContent() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* City & Postal Code */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div> <div>
<label htmlFor="city" className="label">Ville *</label> <label htmlFor="city" className="label">
Ville
</label>
<input <input
id="city" id="city"
type="text" type="text"
@ -499,7 +370,9 @@ function RegisterPageContent() {
/> />
</div> </div>
<div> <div>
<label htmlFor="postalCode" className="label">Code postal *</label> <label htmlFor="postalCode" className="label">
Code postal
</label>
<input <input
id="postalCode" id="postalCode"
type="text" type="text"
@ -513,10 +386,11 @@ function RegisterPageContent() {
</div> </div>
</div> </div>
{/* State & Country */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="state" className="label"> <label htmlFor="state" className="label">
Région <span className="text-neutral-400 font-normal">(optionnel)</span> Région (optionnel)
</label> </label>
<input <input
id="state" id="state"
@ -529,77 +403,209 @@ function RegisterPageContent() {
/> />
</div> </div>
<div> <div>
<label htmlFor="country" className="label">Pays *</label> <label htmlFor="country" className="label">
Pays (code ISO)
</label>
<input <input
id="country" id="country"
type="text" type="text"
required required
value={country} value={country}
onChange={e => setCountry(e.target.value.toUpperCase().slice(0, 2))} onChange={e => setCountry(e.target.value)}
className="input w-full" className="input w-full"
placeholder="FR" placeholder="FR"
maxLength={2} maxLength={2}
disabled={isLoading} disabled={isLoading}
/> />
<p className="mt-1 text-body-xs text-neutral-500">Code ISO 2 lettres</p>
</div> </div>
</div> </div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => { setStep(1); setError(''); }}
disabled={isLoading}
className="btn-secondary flex-1 text-lg disabled:opacity-50"
>
Retour
</button>
<button
type="submit"
disabled={isLoading}
className="btn-primary flex-2 flex-1 text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Création...' : 'Créer mon compte'}
</button>
</div> </div>
)}
<p className="text-body-xs text-center text-neutral-500"> {/* Submit Button */}
En créant un compte, vous acceptez nos{' '} <button
<Link href="/terms" className="link">Conditions d'utilisation</Link>{' '} type="submit"
et notre{' '} disabled={isLoading}
<Link href="/privacy" className="link">Politique de confidentialité</Link> className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed mt-6"
</p> >
</form> {isLoading ? 'Création du compte...' : 'Créer mon compte'}
)} </button>
{/* Terms */}
<p className="text-body-xs text-center text-neutral-500 mt-4">
En créant un compte, vous acceptez nos{' '}
<Link href="/terms" className="link">
Conditions d'utilisation
</Link>{' '}
et notre{' '}
<Link href="/privacy" className="link">
Politique de confidentialité
</Link>
</p>
</form>
{/* Sign In Link */} {/* Sign In Link */}
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<p className="text-body text-neutral-600"> <p className="text-body text-neutral-600">
Vous avez déjà un compte ?{' '} Vous avez déjà un compte ?{' '}
<Link href="/login" className="link font-semibold">Se connecter</Link> <Link href="/login" className="link font-semibold">
Se connecter
</Link>
</p> </p>
</div> </div>
{/* Footer Links */} {/* Footer Links */}
<div className="mt-6 pt-6 border-t border-neutral-200"> <div className="mt-8 pt-8 border-t border-neutral-200">
<div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500"> <div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500">
<Link href="/contact" className="hover:text-accent transition-colors">Contactez-nous</Link> <Link href="/help" className="hover:text-accent transition-colors">
<Link href="/privacy" className="hover:text-accent transition-colors">Confidentialité</Link> Centre d'aide
<Link href="/terms" className="hover:text-accent transition-colors">Conditions</Link> </Link>
<Link href="/contact" className="hover:text-accent transition-colors">
Contactez-nous
</Link>
<Link href="/privacy" className="hover:text-accent transition-colors">
Confidentialité
</Link>
<Link href="/terms" className="hover:text-accent transition-colors">
Conditions
</Link>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{rightPanel} {/* Right Side - Brand Features (same as login) */}
<div className="hidden lg:block lg:w-1/2 relative bg-brand-navy">
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-800 opacity-95"></div>
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white">
<div className="max-w-xl">
<h2 className="text-display-sm mb-6 text-white">
Rejoignez des milliers d'entreprises
</h2>
<p className="text-body-lg text-neutral-200 mb-12">
Simplifiez votre logistique maritime et gagnez du temps sur chaque expédition.
</p>
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Essai gratuit de 30 jours</h3>
<p className="text-body-sm text-neutral-300">
Testez toutes les fonctionnalités sans engagement
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Sécurité maximale</h3>
<p className="text-body-sm text-neutral-300">
Vos données sont protégées et chiffrées
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div className="ml-4">
<h3 className="text-h5 mb-1 text-white">Support 24/7</h3>
<p className="text-body-sm text-neutral-300">
Notre équipe est pour vous accompagner
</p>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700">
<div>
<div className="text-display-sm text-brand-turquoise">2k+</div>
<div className="text-body-sm text-neutral-300 mt-1">Entreprises</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">150+</div>
<div className="text-body-sm text-neutral-300 mt-1">Pays couverts</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">24/7</div>
<div className="text-body-sm text-neutral-300 mt-1">Support</div>
</div>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 opacity-10">
<svg width="400" height="400" viewBox="0 0 400 400" fill="none">
<circle
cx="200"
cy="200"
r="150"
stroke="currentColor"
strokeWidth="2"
className="text-white"
/>
<circle
cx="200"
cy="200"
r="100"
stroke="currentColor"
strokeWidth="2"
className="text-white"
/>
<circle
cx="200"
cy="200"
r="50"
stroke="currentColor"
strokeWidth="2"
className="text-white"
/>
</svg>
</div>
</div>
</div> </div>
); );
} }
export default function RegisterPage() {
return (
<Suspense>
<RegisterPageContent />
</Suspense>
);
}

View File

@ -1,12 +1,16 @@
/**
* Reset Password Page
*
* Reset password with token from email
*/
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { resetPassword } from '@/lib/api/auth';
function ResetPasswordContent() { export default function ResetPasswordPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [token, setToken] = useState(''); const [token, setToken] = useState('');
@ -15,14 +19,13 @@ function ResetPasswordContent() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tokenError, setTokenError] = useState(false);
useEffect(() => { useEffect(() => {
const tokenFromUrl = searchParams.get('token'); const tokenFromUrl = searchParams.get('token');
if (tokenFromUrl) { if (tokenFromUrl) {
setToken(tokenFromUrl); setToken(tokenFromUrl);
} else { } else {
setTokenError(true); setError('Invalid reset link. Please request a new password reset.');
} }
}, [searchParams]); }, [searchParams]);
@ -30,218 +33,139 @@ function ResetPasswordContent() {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
// Validate passwords match
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas'); setError('Passwords do not match');
return; return;
} }
// Validate password length
if (password.length < 12) { if (password.length < 12) {
setError('Le mot de passe doit contenir au moins 12 caractères'); setError('Password must be at least 12 characters long');
return;
}
if (!token) {
setError('Invalid reset token');
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await resetPassword(token, password); // TODO: Implement resetPassword API endpoint
await new Promise(resolve => setTimeout(resolve, 1000));
setSuccess(true); setSuccess(true);
setTimeout(() => router.push('/login'), 3000); setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Le lien de réinitialisation est invalide ou expiré.'); setError(
err.response?.data?.message || 'Failed to reset password. The link may have expired.'
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( if (success) {
<div className="min-h-screen flex"> return (
{/* Left Side - Form */} <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full lg:w-1/2 flex flex-col justify-center px-8 sm:px-12 lg:px-16 xl:px-24 bg-white"> <div className="max-w-md w-full space-y-8">
<div className="max-w-md w-full mx-auto"> <div>
{/* Logo */} <h1 className="text-center text-4xl font-bold text-blue-600">Xpeditis</h1>
<div className="mb-10"> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<Link href="/"> Password reset successful
<Image </h2>
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={50}
height={60}
priority
className="h-auto"
/>
</Link>
</div> </div>
{tokenError ? ( <div className="rounded-md bg-green-50 p-4">
<> <div className="text-sm text-green-800">
<div className="mb-8"> Your password has been reset successfully. You will be redirected to the login page in
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-6"> a few seconds...
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-h1 text-brand-navy mb-2">Lien invalide</h1>
<p className="text-body text-neutral-600">
Ce lien de réinitialisation est invalide. Veuillez faire une nouvelle demande.
</p>
</div>
<Link href="/forgot-password" className="btn-primary w-full text-center block text-lg">
Demander un nouveau lien
</Link>
</>
) : success ? (
<>
<div className="mb-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-h1 text-brand-navy mb-2">Mot de passe réinitialisé !</h1>
<p className="text-body text-neutral-600">
Votre mot de passe a é modifié avec succès. Vous allez être redirigé vers la page de connexion...
</p>
</div>
<Link href="/login" className="btn-primary w-full text-center block text-lg">
Se connecter maintenant
</Link>
</>
) : (
<>
{/* Header */}
<div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">Nouveau mot de passe</h1>
<p className="text-body text-neutral-600">
Choisissez un nouveau mot de passe sécurisé pour votre compte.
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-body-sm text-red-800">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="password" className="label">
Nouveau mot de passe
</label>
<input
id="password"
type="password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="input w-full"
placeholder="••••••••••••"
autoComplete="new-password"
disabled={loading}
/>
<p className="mt-1.5 text-body-xs text-neutral-500">Au moins 12 caractères</p>
</div>
<div>
<label htmlFor="confirmPassword" className="label">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="input w-full"
placeholder="••••••••••••"
autoComplete="new-password"
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Réinitialisation...' : 'Réinitialiser le mot de passe'}
</button>
</form>
<div className="mt-8 text-center">
<Link href="/login" className="text-body-sm link flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour à la connexion
</Link>
</div>
</>
)}
{/* Footer Links */}
<div className="mt-8 pt-8 border-t border-neutral-200">
<div className="flex flex-wrap justify-center gap-6 text-body-sm text-neutral-500">
<Link href="/contact" className="hover:text-accent transition-colors">
Contactez-nous
</Link>
<Link href="/privacy" className="hover:text-accent transition-colors">
Confidentialité
</Link>
<Link href="/terms" className="hover:text-accent transition-colors">
Conditions
</Link>
</div> </div>
</div> </div>
<div className="text-center">
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Go to login now
</Link>
</div>
</div> </div>
</div> </div>
);
}
{/* Right Side - Brand */} return (
<div className="hidden lg:block lg:w-1/2 relative bg-brand-navy"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-800 opacity-95"></div> <div className="max-w-md w-full space-y-8">
<div className="absolute inset-0 flex flex-col justify-center px-16 xl:px-24 text-white"> <div>
<div className="max-w-xl"> <h1 className="text-center text-4xl font-bold text-blue-600">Xpeditis</h1>
<h2 className="text-display-sm mb-6 text-white">Votre sécurité, notre priorité</h2> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<p className="text-body-lg text-neutral-200 mb-12"> Set new password
Choisissez un mot de passe fort pour protéger votre compte et vos données. </h2>
</p> <p className="mt-2 text-center text-sm text-gray-600">Please enter your new password.</p>
<div className="space-y-4"> </div>
{[
'Au moins 12 caractères', <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
'Mélangez lettres, chiffres et symboles', {error && (
'Évitez les mots du dictionnaire', <div className="rounded-md bg-red-50 p-4">
'N\'utilisez pas le même mot de passe ailleurs', <div className="text-sm text-red-800">{error}</div>
].map((tip) => ( </div>
<div key={tip} className="flex items-center gap-3"> )}
<svg className="w-5 h-5 text-brand-turquoise flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <div className="space-y-4">
</svg> <div>
<p className="text-body-sm text-neutral-300">{tip}</p> <label htmlFor="password" className="block text-sm font-medium text-gray-700">
</div> New Password
))} </label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="mt-1 appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<p className="mt-1 text-xs text-gray-500">Must be at least 12 characters long</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm New Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="mt-1 appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div> </div>
</div> </div>
</div>
<div className="absolute bottom-0 right-0 opacity-10"> <div>
<svg width="400" height="400" viewBox="0 0 400 400" fill="none"> <button
<circle cx="200" cy="200" r="150" stroke="currentColor" strokeWidth="2" className="text-white" /> type="submit"
<circle cx="200" cy="200" r="100" stroke="currentColor" strokeWidth="2" className="text-white" /> disabled={loading || !token}
<circle cx="200" cy="200" r="50" stroke="currentColor" strokeWidth="2" className="text-white" /> className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
</svg> >
</div> {loading ? 'Resetting password...' : 'Reset password'}
</button>
</div>
<div className="text-center text-sm">
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Back to sign in
</Link>
</div>
</form>
</div> </div>
</div> </div>
); );
} }
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordContent />
</Suspense>
);
}

View File

@ -77,7 +77,7 @@ test.describe('Complete Booking Workflow', () => {
// Step 4: Select a Rate and Create Booking // Step 4: Select a Rate and Create Booking
await test.step('Select Rate and Create Booking', async () => { await test.step('Select Rate and Create Booking', async () => {
// Select first available rate // Select first available rate
await page.locator('.rate-card').first().locator('button:has-text("Book")').click(); await page.locator('.rate-card').first().click('button:has-text("Book")');
// Should navigate to booking form // Should navigate to booking form
await expect(page).toHaveURL(/.*bookings\/create/); await expect(page).toHaveURL(/.*bookings\/create/);

View File

@ -1,24 +0,0 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({ dir: './' });
/** @type {import('jest').Config} */
const customConfig = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: [
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}',
'<rootDir>/src/**/__tests__/**/*.{spec,test}.{ts,tsx}',
],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/.next/',
'<rootDir>/e2e/',
],
moduleNameMapper: {
'^@/app/(.*)$': '<rootDir>/app/$1',
'^@/(.*)$': '<rootDir>/src/$1',
},
};
module.exports = createJestConfig(customConfig);

View File

@ -1 +0,0 @@
import '@testing-library/jest-dom';

View File

@ -44,7 +44,6 @@
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/jest": "^29.5.12",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
@ -2768,52 +2767,6 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/jest": {
"version": "29.5.14",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/jest/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@types/jest/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsdom": { "node_modules/@types/jsdom": {
"version": "20.0.1", "version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",

View File

@ -8,7 +8,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "type-check": "tsc --noEmit",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:e2e": "playwright test" "test:e2e": "playwright test"
@ -49,7 +49,6 @@
"@playwright/test": "^1.56.0", "@playwright/test": "^1.56.0",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.12",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",

View File

@ -1,143 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCompanies } from '@/hooks/useCompanies';
import { getAvailableCompanies } from '@/lib/api/csv-rates';
jest.mock('@/lib/api/csv-rates', () => ({
getAvailableCompanies: jest.fn(),
}));
const mockGetAvailableCompanies = jest.mocked(getAvailableCompanies);
const MOCK_COMPANIES = ['Maersk', 'MSC', 'CMA CGM', 'Hapag-Lloyd'];
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCompanies', () => {
describe('initial state', () => {
it('starts with loading=true', () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
const { result } = renderHook(() => useCompanies());
expect(result.current.loading).toBe(true);
});
it('starts with an empty companies array', () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
const { result } = renderHook(() => useCompanies());
expect(result.current.companies).toEqual([]);
});
it('starts with error=null', () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
const { result } = renderHook(() => useCompanies());
expect(result.current.error).toBeNull();
});
});
describe('on mount — success', () => {
it('fetches companies automatically on mount', async () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
renderHook(() => useCompanies());
await waitFor(() => {
expect(mockGetAvailableCompanies).toHaveBeenCalledTimes(1);
});
});
it('populates companies after a successful fetch', async () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
const { result } = renderHook(() => useCompanies());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.companies).toEqual(MOCK_COMPANIES);
expect(result.current.error).toBeNull();
});
it('handles an empty companies list', async () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: [], total: 0 });
const { result } = renderHook(() => useCompanies());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.companies).toEqual([]);
});
});
describe('on mount — error', () => {
it('sets error when the API call fails', async () => {
mockGetAvailableCompanies.mockRejectedValue(new Error('Service unavailable'));
const { result } = renderHook(() => useCompanies());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Service unavailable');
expect(result.current.companies).toEqual([]);
});
it('uses a default error message when the error has no message', async () => {
mockGetAvailableCompanies.mockRejectedValue({});
const { result } = renderHook(() => useCompanies());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Failed to fetch companies');
});
});
describe('refetch', () => {
it('exposes a refetch function', async () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
const { result } = renderHook(() => useCompanies());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(typeof result.current.refetch).toBe('function');
});
it('re-triggers the API call when refetch is invoked', async () => {
mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 });
const { result } = renderHook(() => useCompanies());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.refetch();
});
expect(mockGetAvailableCompanies).toHaveBeenCalledTimes(2);
});
it('updates companies with fresh data on refetch', async () => {
mockGetAvailableCompanies
.mockResolvedValueOnce({ companies: ['Maersk'], total: 1 })
.mockResolvedValueOnce({ companies: ['Maersk', 'MSC'], total: 2 });
const { result } = renderHook(() => useCompanies());
await waitFor(() => expect(result.current.companies).toEqual(['Maersk']));
await act(async () => {
await result.current.refetch();
});
expect(result.current.companies).toEqual(['Maersk', 'MSC']);
});
});
});

View File

@ -1,198 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCsvRateSearch } from '@/hooks/useCsvRateSearch';
import { searchCsvRates } from '@/lib/api/csv-rates';
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters';
jest.mock('@/lib/api/csv-rates', () => ({
searchCsvRates: jest.fn(),
}));
const mockSearchCsvRates = jest.mocked(searchCsvRates);
const mockRequest: CsvRateSearchRequest = {
origin: 'Le Havre',
destination: 'Shanghai',
volumeCBM: 10,
weightKG: 5000,
};
const mockResponse: CsvRateSearchResponse = {
results: [
{
companyName: 'Maersk',
origin: 'Le Havre',
destination: 'Shanghai',
containerType: '40ft',
priceUSD: 2500,
priceEUR: 2300,
primaryCurrency: 'USD',
hasSurcharges: false,
surchargeDetails: null,
transitDays: 30,
validUntil: '2024-12-31',
source: 'CSV',
matchScore: 95,
},
],
totalResults: 1,
searchedFiles: ['maersk-rates.csv'],
searchedAt: '2024-03-01T10:00:00Z',
appliedFilters: {},
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCsvRateSearch', () => {
describe('initial state', () => {
it('starts with data=null', () => {
const { result } = renderHook(() => useCsvRateSearch());
expect(result.current.data).toBeNull();
});
it('starts with loading=false', () => {
const { result } = renderHook(() => useCsvRateSearch());
expect(result.current.loading).toBe(false);
});
it('starts with error=null', () => {
const { result } = renderHook(() => useCsvRateSearch());
expect(result.current.error).toBeNull();
});
it('exposes a search function', () => {
const { result } = renderHook(() => useCsvRateSearch());
expect(typeof result.current.search).toBe('function');
});
it('exposes a reset function', () => {
const { result } = renderHook(() => useCsvRateSearch());
expect(typeof result.current.reset).toBe('function');
});
});
describe('search — success path', () => {
it('sets loading=true while the request is in flight', async () => {
let resolveSearch: (v: CsvRateSearchResponse) => void;
mockSearchCsvRates.mockReturnValue(
new Promise(resolve => {
resolveSearch = resolve;
})
);
const { result } = renderHook(() => useCsvRateSearch());
act(() => {
result.current.search(mockRequest);
});
expect(result.current.loading).toBe(true);
await act(async () => {
resolveSearch!(mockResponse);
});
});
it('sets data and clears loading after a successful search', async () => {
mockSearchCsvRates.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useCsvRateSearch());
await act(async () => {
await result.current.search(mockRequest);
});
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockResponse);
expect(result.current.error).toBeNull();
});
it('calls searchCsvRates with the given request', async () => {
mockSearchCsvRates.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useCsvRateSearch());
await act(async () => {
await result.current.search(mockRequest);
});
expect(mockSearchCsvRates).toHaveBeenCalledWith(mockRequest);
});
it('clears a previous error when a new search starts', async () => {
mockSearchCsvRates.mockRejectedValueOnce(new Error('first error'));
mockSearchCsvRates.mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useCsvRateSearch());
// First search fails
await act(async () => {
await result.current.search(mockRequest);
});
expect(result.current.error).toBe('first error');
// Second search succeeds — error must be cleared
await act(async () => {
await result.current.search(mockRequest);
});
expect(result.current.error).toBeNull();
});
});
describe('search — error path', () => {
it('sets error and clears data when the API throws', async () => {
mockSearchCsvRates.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useCsvRateSearch());
await act(async () => {
await result.current.search(mockRequest);
});
expect(result.current.error).toBe('Network error');
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
});
it('uses a default error message when the error has no message', async () => {
mockSearchCsvRates.mockRejectedValue({});
const { result } = renderHook(() => useCsvRateSearch());
await act(async () => {
await result.current.search(mockRequest);
});
expect(result.current.error).toBe('Failed to search rates');
});
});
describe('reset', () => {
it('clears data, error, and loading', async () => {
mockSearchCsvRates.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useCsvRateSearch());
await act(async () => {
await result.current.search(mockRequest);
});
act(() => {
result.current.reset();
});
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
});
it('can be called before any search without throwing', () => {
const { result } = renderHook(() => useCsvRateSearch());
expect(() => {
act(() => result.current.reset());
}).not.toThrow();
});
});
});

View File

@ -1,186 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useFilterOptions } from '@/hooks/useFilterOptions';
import { getFilterOptions } from '@/lib/api/csv-rates';
import type { FilterOptions } from '@/types/rate-filters';
jest.mock('@/lib/api/csv-rates', () => ({
getFilterOptions: jest.fn(),
}));
const mockGetFilterOptions = jest.mocked(getFilterOptions);
const MOCK_OPTIONS: FilterOptions = {
companies: ['Maersk', 'MSC', 'CMA CGM'],
containerTypes: ['20ft', '40ft', '40ft HC'],
currencies: ['USD', 'EUR'],
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('useFilterOptions', () => {
describe('initial state', () => {
it('starts with loading=true', () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
expect(result.current.loading).toBe(true);
});
it('starts with empty companies array', () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
expect(result.current.companies).toEqual([]);
});
it('starts with empty containerTypes array', () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
expect(result.current.containerTypes).toEqual([]);
});
it('starts with empty currencies array', () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
expect(result.current.currencies).toEqual([]);
});
it('starts with error=null', () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
expect(result.current.error).toBeNull();
});
});
describe('on mount — success', () => {
it('fetches options automatically on mount', async () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
renderHook(() => useFilterOptions());
await waitFor(() => {
expect(mockGetFilterOptions).toHaveBeenCalledTimes(1);
});
});
it('populates all option arrays after a successful fetch', async () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.companies).toEqual(MOCK_OPTIONS.companies);
expect(result.current.containerTypes).toEqual(MOCK_OPTIONS.containerTypes);
expect(result.current.currencies).toEqual(MOCK_OPTIONS.currencies);
});
it('sets loading=false after a successful fetch', async () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
});
it('handles an API response with empty arrays', async () => {
mockGetFilterOptions.mockResolvedValue({
companies: [],
containerTypes: [],
currencies: [],
});
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.companies).toEqual([]);
expect(result.current.containerTypes).toEqual([]);
expect(result.current.currencies).toEqual([]);
});
});
describe('on mount — error', () => {
it('sets error when the API call fails', async () => {
mockGetFilterOptions.mockRejectedValue(new Error('Gateway timeout'));
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('Gateway timeout');
});
it('uses a fallback message when the error has no message', async () => {
mockGetFilterOptions.mockRejectedValue({});
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('Failed to fetch filter options');
});
it('preserves the empty option arrays on error', async () => {
mockGetFilterOptions.mockRejectedValue(new Error('error'));
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.companies).toEqual([]);
expect(result.current.containerTypes).toEqual([]);
expect(result.current.currencies).toEqual([]);
});
});
describe('refetch', () => {
it('exposes a refetch function', async () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(typeof result.current.refetch).toBe('function');
});
it('re-triggers the fetch when refetch is invoked', async () => {
mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS);
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.refetch();
});
expect(mockGetFilterOptions).toHaveBeenCalledTimes(2);
});
it('updates options with fresh data on refetch', async () => {
const updatedOptions: FilterOptions = {
companies: ['Maersk', 'MSC', 'ONE'],
containerTypes: ['20ft', '40ft'],
currencies: ['USD'],
};
mockGetFilterOptions
.mockResolvedValueOnce(MOCK_OPTIONS)
.mockResolvedValueOnce(updatedOptions);
const { result } = renderHook(() => useFilterOptions());
await waitFor(() => expect(result.current.companies).toEqual(MOCK_OPTIONS.companies));
await act(async () => {
await result.current.refetch();
});
expect(result.current.companies).toEqual(updatedOptions.companies);
});
});
});

View File

@ -1,86 +0,0 @@
import {
AssetPaths,
getImagePath,
getLogoPath,
getIconPath,
Images,
Logos,
Icons,
} from '@/lib/assets';
describe('AssetPaths constants', () => {
it('has the correct images base path', () => {
expect(AssetPaths.images).toBe('/assets/images');
});
it('has the correct logos base path', () => {
expect(AssetPaths.logos).toBe('/assets/logos');
});
it('has the correct icons base path', () => {
expect(AssetPaths.icons).toBe('/assets/icons');
});
});
describe('getImagePath', () => {
it('returns the correct full path for a given filename', () => {
expect(getImagePath('hero-banner.jpg')).toBe('/assets/images/hero-banner.jpg');
});
it('handles filenames without extension', () => {
expect(getImagePath('background')).toBe('/assets/images/background');
});
it('handles filenames with multiple dots', () => {
expect(getImagePath('my.image.v2.png')).toBe('/assets/images/my.image.v2.png');
});
it('starts with a slash', () => {
expect(getImagePath('test.jpg')).toMatch(/^\//);
});
});
describe('getLogoPath', () => {
it('returns the correct full path for a logo', () => {
expect(getLogoPath('xpeditis-logo.svg')).toBe('/assets/logos/xpeditis-logo.svg');
});
it('handles a dark variant logo', () => {
expect(getLogoPath('xpeditis-logo-dark.svg')).toBe('/assets/logos/xpeditis-logo-dark.svg');
});
it('starts with a slash', () => {
expect(getLogoPath('icon.svg')).toMatch(/^\//);
});
});
describe('getIconPath', () => {
it('returns the correct full path for an icon', () => {
expect(getIconPath('shipping-icon.svg')).toBe('/assets/icons/shipping-icon.svg');
});
it('handles a PNG icon', () => {
expect(getIconPath('notification.png')).toBe('/assets/icons/notification.png');
});
it('starts with a slash', () => {
expect(getIconPath('arrow.svg')).toMatch(/^\//);
});
});
describe('pre-defined asset collections', () => {
it('Images is a defined object', () => {
expect(Images).toBeDefined();
expect(typeof Images).toBe('object');
});
it('Logos is a defined object', () => {
expect(Logos).toBeDefined();
expect(typeof Logos).toBe('object');
});
it('Icons is a defined object', () => {
expect(Icons).toBeDefined();
expect(typeof Icons).toBe('object');
});
});

View File

@ -1,78 +0,0 @@
import { cn } from '@/lib/utils';
describe('cn — class name merger', () => {
describe('basic merging', () => {
it('returns an empty string when called with no arguments', () => {
expect(cn()).toBe('');
});
it('returns the class when given a single string', () => {
expect(cn('foo')).toBe('foo');
});
it('joins multiple class strings with a space', () => {
expect(cn('foo', 'bar', 'baz')).toBe('foo bar baz');
});
it('ignores falsy values', () => {
expect(cn('foo', undefined, null, false, 'bar')).toBe('foo bar');
});
it('handles an empty string argument', () => {
expect(cn('', 'foo')).toBe('foo');
});
});
describe('conditional classes', () => {
it('includes a class when its condition is true', () => {
expect(cn('base', true && 'active')).toBe('base active');
});
it('excludes a class when its condition is false', () => {
expect(cn('base', false && 'active')).toBe('base');
});
it('supports object syntax — includes keys whose value is truthy', () => {
expect(cn({ foo: true, bar: false, baz: true })).toBe('foo baz');
});
it('supports array syntax', () => {
expect(cn(['foo', 'bar'])).toBe('foo bar');
});
it('supports mixed input types', () => {
expect(cn('base', { active: true, disabled: false }, ['extra'])).toBe('base active extra');
});
});
describe('Tailwind conflict resolution', () => {
it('resolves padding conflicts — last padding wins', () => {
expect(cn('p-4', 'p-8')).toBe('p-8');
});
it('resolves text-size conflicts — last size wins', () => {
expect(cn('text-sm', 'text-lg')).toBe('text-lg');
});
it('resolves background-color conflicts', () => {
expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500');
});
it('keeps non-conflicting utility classes', () => {
const result = cn('p-4', 'text-sm', 'font-bold');
expect(result).toContain('p-4');
expect(result).toContain('text-sm');
expect(result).toContain('font-bold');
});
it('resolves margin conflicts', () => {
expect(cn('mt-2', 'mt-4')).toBe('mt-4');
});
it('does not remove classes that do not conflict', () => {
expect(cn('flex', 'items-center', 'justify-between')).toBe(
'flex items-center justify-between'
);
});
});
});

View File

@ -1,3 +0,0 @@
// This file is intentionally empty — the real setup is in jest.setup.ts at the root.
// It exists only to avoid breaking imports. Jest will skip it (no tests inside).
export {};

View File

@ -1,94 +0,0 @@
import {
BookingStatus,
ContainerType,
ExportFormat,
} from '@/types/booking';
describe('BookingStatus enum', () => {
it('has DRAFT value', () => {
expect(BookingStatus.DRAFT).toBe('draft');
});
it('has CONFIRMED value', () => {
expect(BookingStatus.CONFIRMED).toBe('confirmed');
});
it('has IN_PROGRESS value', () => {
expect(BookingStatus.IN_PROGRESS).toBe('in_progress');
});
it('has COMPLETED value', () => {
expect(BookingStatus.COMPLETED).toBe('completed');
});
it('has CANCELLED value', () => {
expect(BookingStatus.CANCELLED).toBe('cancelled');
});
it('has exactly 5 statuses', () => {
const values = Object.values(BookingStatus);
expect(values).toHaveLength(5);
});
it('all values are lowercase strings', () => {
Object.values(BookingStatus).forEach(v => {
expect(v).toBe(v.toLowerCase());
});
});
});
describe('ContainerType enum', () => {
it('has DRY_20 value', () => {
expect(ContainerType.DRY_20).toBe('20ft');
});
it('has DRY_40 value', () => {
expect(ContainerType.DRY_40).toBe('40ft');
});
it('has HIGH_CUBE_40 value', () => {
expect(ContainerType.HIGH_CUBE_40).toBe('40ft HC');
});
it('has REEFER_20 value', () => {
expect(ContainerType.REEFER_20).toBe('20ft Reefer');
});
it('has REEFER_40 value', () => {
expect(ContainerType.REEFER_40).toBe('40ft Reefer');
});
it('has exactly 5 container types', () => {
expect(Object.values(ContainerType)).toHaveLength(5);
});
it('all standard (non-reefer) values start with a size prefix', () => {
expect(ContainerType.DRY_20).toMatch(/^\d+ft/);
expect(ContainerType.DRY_40).toMatch(/^\d+ft/);
expect(ContainerType.HIGH_CUBE_40).toMatch(/^\d+ft/);
});
});
describe('ExportFormat enum', () => {
it('has CSV value', () => {
expect(ExportFormat.CSV).toBe('csv');
});
it('has EXCEL value', () => {
expect(ExportFormat.EXCEL).toBe('excel');
});
it('has JSON value', () => {
expect(ExportFormat.JSON).toBe('json');
});
it('has exactly 3 formats', () => {
expect(Object.values(ExportFormat)).toHaveLength(3);
});
it('all values are lowercase', () => {
Object.values(ExportFormat).forEach(v => {
expect(v).toBe(v.toLowerCase());
});
});
});

View File

@ -1,345 +0,0 @@
import { exportToCSV, exportToExcel, exportToJSON, exportBookings, ExportField } from '@/utils/export';
import { Booking, BookingStatus, ContainerType } from '@/types/booking';
// ── Mocks ─────────────────────────────────────────────────────────────────────
const mockSaveAs = jest.fn();
jest.mock('file-saver', () => ({
saveAs: (...args: unknown[]) => mockSaveAs(...args),
}));
const mockAoaToSheet = jest.fn().mockReturnValue({ '!ref': 'A1:K2' });
const mockBookNew = jest.fn().mockReturnValue({});
const mockBookAppendSheet = jest.fn();
const mockWrite = jest.fn().mockReturnValue(new ArrayBuffer(8));
jest.mock('xlsx', () => ({
utils: {
aoa_to_sheet: (...args: unknown[]) => mockAoaToSheet(...args),
book_new: () => mockBookNew(),
book_append_sheet: (...args: unknown[]) => mockBookAppendSheet(...args),
},
write: (...args: unknown[]) => mockWrite(...args),
}));
// ── Blob capture helper ────────────────────────────────────────────────────────
// blob.text() is not available in all jsdom versions; instead we intercept the
// Blob constructor to capture the raw string before it's wrapped.
const OriginalBlob = global.Blob;
let capturedBlobParts: string[] = [];
beforeEach(() => {
jest.clearAllMocks();
capturedBlobParts = [];
global.Blob = jest.fn().mockImplementation(
(parts?: BlobPart[], options?: BlobPropertyBag) => {
const content = (parts ?? []).map(p => (typeof p === 'string' ? p : '')).join('');
capturedBlobParts.push(content);
return { type: options?.type ?? '', size: content.length } as Blob;
}
) as unknown as typeof Blob;
});
afterEach(() => {
global.Blob = OriginalBlob;
});
// ── Fixtures ──────────────────────────────────────────────────────────────────
const makeBooking = (overrides: Partial<Booking> = {}): Booking => ({
id: 'b-1',
bookingNumber: 'WCM-2024-ABC001',
status: BookingStatus.CONFIRMED,
shipper: {
name: 'Acme Corp',
street: '1 rue de la Paix',
city: 'Paris',
postalCode: '75001',
country: 'France',
},
consignee: {
name: 'Beta Ltd',
street: '42 Main St',
city: 'Shanghai',
postalCode: '200000',
country: 'China',
},
containers: [
{ id: 'c-1', type: ContainerType.DRY_40 },
{ id: 'c-2', type: ContainerType.HIGH_CUBE_40 },
],
rateQuote: {
id: 'rq-1',
carrierName: 'Maersk',
carrierScac: 'MAEU',
origin: 'Le Havre',
destination: 'Shanghai',
priceValue: 2500,
priceCurrency: 'USD',
etd: '2024-03-01T00:00:00Z',
eta: '2024-04-01T00:00:00Z',
transitDays: 31,
},
createdAt: '2024-01-15T10:00:00Z',
updatedAt: '2024-01-15T10:00:00Z',
...overrides,
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('exportToCSV', () => {
it('calls saveAs once', () => {
exportToCSV([makeBooking()]);
expect(mockSaveAs).toHaveBeenCalledTimes(1);
});
it('passes a Blob as the first saveAs argument', () => {
exportToCSV([makeBooking()]);
const [blob] = mockSaveAs.mock.calls[0];
expect(blob).toBeDefined();
expect(blob.type).toContain('text/csv');
});
it('uses the default filename', () => {
exportToCSV([makeBooking()]);
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('bookings-export.csv');
});
it('uses a custom filename when provided', () => {
exportToCSV([makeBooking()], undefined, 'my-export.csv');
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('my-export.csv');
});
it('generates a CSV header with default field labels', () => {
exportToCSV([makeBooking()]);
const csv = capturedBlobParts[0];
expect(csv).toContain('Booking Number');
expect(csv).toContain('Status');
expect(csv).toContain('Carrier');
expect(csv).toContain('Origin');
expect(csv).toContain('Destination');
});
it('includes booking data in the CSV rows', () => {
exportToCSV([makeBooking()]);
const csv = capturedBlobParts[0];
expect(csv).toContain('WCM-2024-ABC001');
expect(csv).toContain('confirmed');
expect(csv).toContain('Maersk');
expect(csv).toContain('Le Havre');
expect(csv).toContain('Shanghai');
});
it('applies custom fields and their labels', () => {
const customFields: ExportField[] = [
{ key: 'bookingNumber', label: 'Number' },
{ key: 'status', label: 'State' },
];
exportToCSV([makeBooking()], customFields);
const csv = capturedBlobParts[0];
expect(csv).toContain('Number');
expect(csv).toContain('State');
expect(csv).not.toContain('Carrier');
});
it('applies field formatters', () => {
const customFields: ExportField[] = [
{ key: 'status', label: 'Status', formatter: (v: string) => v.toUpperCase() },
];
exportToCSV([makeBooking()], customFields);
expect(capturedBlobParts[0]).toContain('CONFIRMED');
});
it('extracts nested values with dot-notation keys', () => {
const customFields: ExportField[] = [
{ key: 'rateQuote.carrierName', label: 'Carrier' },
{ key: 'shipper.name', label: 'Shipper' },
];
exportToCSV([makeBooking()], customFields);
const csv = capturedBlobParts[0];
expect(csv).toContain('Maersk');
expect(csv).toContain('Acme Corp');
});
it('extracts deeply nested values', () => {
const customFields: ExportField[] = [
{ key: 'consignee.city', label: 'Consignee City' },
];
exportToCSV([makeBooking()], customFields);
expect(capturedBlobParts[0]).toContain('Shanghai');
});
it('generates only the header row when data is empty', () => {
exportToCSV([]);
const lines = capturedBlobParts[0].split('\n');
expect(lines).toHaveLength(1);
});
it('generates one data row per booking', () => {
exportToCSV([
makeBooking(),
makeBooking({ id: 'b-2', bookingNumber: 'WCM-2024-ABC002' }),
]);
const lines = capturedBlobParts[0].trim().split('\n');
expect(lines).toHaveLength(3); // header + 2 rows
});
it('wraps all cell values in double quotes', () => {
const customFields: ExportField[] = [
{ key: 'bookingNumber', label: 'Number' },
];
exportToCSV([makeBooking()], customFields);
const dataLine = capturedBlobParts[0].split('\n')[1];
expect(dataLine).toMatch(/^".*"$/);
});
it('escapes double quotes inside cell values', () => {
const customFields: ExportField[] = [
{ key: 'shipper.name', label: 'Shipper' },
];
const booking = makeBooking({
shipper: {
name: 'He said "hello"',
street: '1 st',
city: 'Paris',
postalCode: '75001',
country: 'France',
},
});
exportToCSV([booking], customFields);
// Original `"` should be escaped as `""`
expect(capturedBlobParts[0]).toContain('He said ""hello""');
});
it('returns undefined', () => {
expect(exportToCSV([makeBooking()])).toBeUndefined();
});
});
describe('exportToExcel', () => {
it('calls saveAs with the default filename', () => {
exportToExcel([makeBooking()]);
expect(mockSaveAs).toHaveBeenCalledTimes(1);
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('bookings-export.xlsx');
});
it('uses a custom filename', () => {
exportToExcel([makeBooking()], undefined, 'report.xlsx');
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('report.xlsx');
});
it('calls aoa_to_sheet with worksheet data', () => {
exportToExcel([makeBooking()]);
expect(mockAoaToSheet).toHaveBeenCalledTimes(1);
const [wsData] = mockAoaToSheet.mock.calls[0];
expect(Array.isArray(wsData[0])).toBe(true);
});
it('places the header labels in the first row', () => {
exportToExcel([makeBooking()]);
const [wsData] = mockAoaToSheet.mock.calls[0];
const headers = wsData[0];
expect(headers).toContain('Booking Number');
expect(headers).toContain('Carrier');
expect(headers).toContain('Status');
});
it('creates a new workbook', () => {
exportToExcel([makeBooking()]);
expect(mockBookNew).toHaveBeenCalledTimes(1);
});
it('appends the worksheet with the name "Bookings"', () => {
exportToExcel([makeBooking()]);
expect(mockBookAppendSheet).toHaveBeenCalledTimes(1);
const [, , sheetName] = mockBookAppendSheet.mock.calls[0];
expect(sheetName).toBe('Bookings');
});
it('calls XLSX.write with bookType "xlsx"', () => {
exportToExcel([makeBooking()]);
expect(mockWrite).toHaveBeenCalledTimes(1);
const [, opts] = mockWrite.mock.calls[0];
expect(opts.bookType).toBe('xlsx');
});
it('produces a row for each booking (plus one header)', () => {
exportToExcel([makeBooking(), makeBooking({ id: 'b-2' })]);
const [wsData] = mockAoaToSheet.mock.calls[0];
expect(wsData).toHaveLength(3); // 1 header + 2 data rows
});
});
describe('exportToJSON', () => {
it('calls saveAs with the default filename', () => {
exportToJSON([makeBooking()]);
expect(mockSaveAs).toHaveBeenCalledTimes(1);
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('bookings-export.json');
});
it('uses a custom filename', () => {
exportToJSON([makeBooking()], 'data.json');
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('data.json');
});
it('creates a Blob with application/json type', () => {
exportToJSON([makeBooking()]);
const [blob] = mockSaveAs.mock.calls[0];
expect(blob.type).toContain('application/json');
});
it('serialises bookings as valid JSON', () => {
const booking = makeBooking();
exportToJSON([booking]);
const json = capturedBlobParts[0];
const parsed = JSON.parse(json);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed[0].bookingNumber).toBe('WCM-2024-ABC001');
});
it('produces pretty-printed JSON (2-space indent)', () => {
exportToJSON([makeBooking()]);
expect(capturedBlobParts[0]).toContain('\n ');
});
});
describe('exportBookings dispatcher', () => {
it('routes "csv" to exportToCSV', () => {
exportBookings([makeBooking()], 'csv');
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('bookings-export.csv');
});
it('routes "excel" to exportToExcel', () => {
exportBookings([makeBooking()], 'excel');
expect(mockAoaToSheet).toHaveBeenCalledTimes(1);
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('bookings-export.xlsx');
});
it('routes "json" to exportToJSON', () => {
exportBookings([makeBooking()], 'json');
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('bookings-export.json');
});
it('throws for an unknown format', () => {
expect(() => exportBookings([makeBooking()], 'pdf' as any)).toThrow(
'Unsupported export format: pdf'
);
});
it('passes a custom filename through to the underlying exporter', () => {
exportBookings([makeBooking()], 'csv', undefined, 'custom.csv');
const [, filename] = mockSaveAs.mock.calls[0];
expect(filename).toBe('custom.csv');
});
});

View File

@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Users, Building2, Package, FileText, BarChart3, Settings, ScrollText, type LucideIcon } from 'lucide-react'; import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
interface AdminMenuItem { interface AdminMenuItem {
name: string; name: string;
@ -43,12 +43,6 @@ const adminMenuItems: AdminMenuItem[] = [
icon: BarChart3, icon: BarChart3,
description: 'Importer et gérer les tarifs CSV', description: 'Importer et gérer les tarifs CSV',
}, },
{
name: 'Logs système',
href: '/dashboard/admin/logs',
icon: ScrollText,
description: 'Visualiser et télécharger les logs applicatifs',
},
]; ];
export default function AdminPanelDropdown() { export default function AdminPanelDropdown() {

View File

@ -144,26 +144,6 @@ export async function validateBankTransfer(bookingId: string): Promise<BookingRe
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {}); return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
} }
/**
* Delete a booking (admin only)
* DELETE /api/v1/admin/bookings/:id
* Permanently deletes a booking from the database
* Requires: ADMIN role
*/
export async function deleteAdminBooking(bookingId: string): Promise<void> {
return del<void>(`/api/v1/admin/bookings/${bookingId}`);
}
/**
* Delete a document from a booking (admin only)
* DELETE /api/v1/admin/bookings/:bookingId/documents/:documentId
* Bypasses ownership and status restrictions
* Requires: ADMIN role
*/
export async function deleteAdminDocument(bookingId: string, documentId: string): Promise<void> {
return del<void>(`/api/v1/admin/bookings/${bookingId}/documents/${documentId}`);
}
// ==================== DOCUMENTS ==================== // ==================== DOCUMENTS ====================
/** /**

View File

@ -16,7 +16,6 @@ export interface CsvFileInfo {
size: number; size: number;
uploadedAt: string; uploadedAt: string;
rowCount?: number; rowCount?: number;
companyEmail?: string | null;
} }
export interface CsvFileListResponse { export interface CsvFileListResponse {

View File

@ -31,12 +31,11 @@ export async function register(data: RegisterRequest): Promise<AuthResponse> {
* User login * User login
* POST /api/v1/auth/login * POST /api/v1/auth/login
*/ */
export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise<AuthResponse> { export async function login(data: LoginRequest): Promise<AuthResponse> {
const { rememberMe, ...loginData } = data; const response = await post<AuthResponse>('/api/v1/auth/login', data, false);
const response = await post<AuthResponse>('/api/v1/auth/login', loginData, false);
// Store tokens — localStorage if rememberMe, sessionStorage otherwise // Store tokens
setAuthTokens(response.accessToken, response.refreshToken, rememberMe ?? false); setAuthTokens(response.accessToken, response.refreshToken);
return response; return response;
} }
@ -70,35 +69,3 @@ export async function logout(): Promise<SuccessResponse> {
export async function getCurrentUser(): Promise<UserPayload> { export async function getCurrentUser(): Promise<UserPayload> {
return get<UserPayload>('/api/v1/auth/me'); return get<UserPayload>('/api/v1/auth/me');
} }
/**
* Contact form send message to contact@xpeditis.com
* POST /api/v1/auth/contact
*/
export async function sendContactForm(data: {
firstName: string;
lastName: string;
email: string;
company?: string;
phone?: string;
subject: string;
message: string;
}): Promise<{ message: string }> {
return post<{ message: string }>('/api/v1/auth/contact', data, false);
}
/**
* Forgot password request reset email
* POST /api/v1/auth/forgot-password
*/
export async function forgotPassword(email: string): Promise<{ message: string }> {
return post<{ message: string }>('/api/v1/auth/forgot-password', { email }, false);
}
/**
* Reset password with token from email
* POST /api/v1/auth/reset-password
*/
export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
return post<{ message: string }>('/api/v1/auth/reset-password', { token, newPassword }, false);
}

View File

@ -11,46 +11,40 @@ let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = []; let refreshSubscribers: Array<(token: string) => void> = [];
/** /**
* Get authentication token checks localStorage first (remember me), then sessionStorage * Get authentication token from localStorage
*/ */
export function getAuthToken(): string | null { export function getAuthToken(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); return localStorage.getItem('access_token');
} }
/** /**
* Get refresh token checks localStorage first (remember me), then sessionStorage * Get refresh token from localStorage
*/ */
export function getRefreshToken(): string | null { export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return localStorage.getItem('refresh_token') || sessionStorage.getItem('refresh_token'); return localStorage.getItem('refresh_token');
} }
/** /**
* Set authentication tokens. * Set authentication tokens
* rememberMe=true localStorage (persists across browser sessions)
* rememberMe=false sessionStorage (cleared when browser closes)
*/ */
export function setAuthTokens(accessToken: string, refreshToken: string, rememberMe = false): void { export function setAuthTokens(accessToken: string, refreshToken: string): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const storage = rememberMe ? localStorage : sessionStorage; localStorage.setItem('access_token', accessToken);
storage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken);
storage.setItem('refresh_token', refreshToken);
// Sync to cookie so Next.js middleware can read it for route protection // Sync to cookie so Next.js middleware can read it for route protection
document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`; document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`;
} }
/** /**
* Clear authentication tokens from both storages * Clear authentication tokens
*/ */
export function clearAuthTokens(): void { export function clearAuthTokens(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
localStorage.removeItem('user'); localStorage.removeItem('user');
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token');
sessionStorage.removeItem('user');
// Expire the middleware cookie // Expire the middleware cookie
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
} }
@ -101,10 +95,9 @@ async function refreshAccessToken(): Promise<string> {
const data = await response.json(); const data = await response.json();
const newAccessToken = data.accessToken; const newAccessToken = data.accessToken;
// Update access token in the same storage that holds the refresh token // Update access token in localStorage and cookie (keep same refresh token)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const storage = localStorage.getItem('refresh_token') ? localStorage : sessionStorage; localStorage.setItem('access_token', newAccessToken);
storage.setItem('access_token', newAccessToken);
document.cookie = `accessToken=${newAccessToken}; path=/; SameSite=Lax`; document.cookie = `accessToken=${newAccessToken}; path=/; SameSite=Lax`;
} }

View File

@ -24,8 +24,8 @@ export {
ApiError, ApiError,
} from './client'; } from './client';
// Authentication (8 endpoints) // Authentication (5 endpoints)
export { register, login, refreshToken, logout, getCurrentUser, forgotPassword, resetPassword, sendContactForm } from './auth'; export { register, login, refreshToken, logout, getCurrentUser } from './auth';
// Rates (4 endpoints) // Rates (4 endpoints)
export { searchRates, searchCsvRates, getAvailableCompanies, getFilterOptions } from './rates'; export { searchRates, searchCsvRates, getAvailableCompanies, getFilterOptions } from './rates';

View File

@ -20,7 +20,7 @@ import type { UserPayload } from '@/types/api';
interface AuthContextType { interface AuthContextType {
user: UserPayload | null; user: UserPayload | null;
loading: boolean; loading: boolean;
login: (email: string, password: string, redirectTo?: string, rememberMe?: boolean) => Promise<void>; login: (email: string, password: string, redirectTo?: string) => Promise<void>;
register: (data: { register: (data: {
email: string; email: string;
password: string; password: string;
@ -106,16 +106,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return () => clearInterval(tokenCheckInterval); return () => clearInterval(tokenCheckInterval);
}, []); }, []);
const login = async (email: string, password: string, redirectTo = '/dashboard', rememberMe = false) => { const login = async (email: string, password: string, redirectTo = '/dashboard') => {
try { try {
await apiLogin({ email, password, rememberMe }); await apiLogin({ email, password });
// Fetch complete user profile after login // Fetch complete user profile after login
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
setUser(currentUser); setUser(currentUser);
// Store user in the same storage as the tokens // Store user in localStorage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const storage = rememberMe ? localStorage : sessionStorage; localStorage.setItem('user', JSON.stringify(currentUser));
storage.setItem('user', JSON.stringify(currentUser));
} }
router.push(redirectTo); router.push(redirectTo);
} catch (error) { } catch (error) {

View File

@ -12,7 +12,6 @@ export interface RegisterOrganizationData {
name: string; name: string;
type: OrganizationType; type: OrganizationType;
siren: string; siren: string;
siret?: string;
street: string; street: string;
city: string; city: string;
state?: string; state?: string;
@ -26,8 +25,7 @@ export interface RegisterRequest {
password: string; password: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
invitationToken?: string; // For invited users (token-based) organizationId?: string; // For invited users
organizationId?: string; // For invited users (ID-based)
organization?: RegisterOrganizationData; // For new users organization?: RegisterOrganizationData; // For new users
} }

View File

@ -31,13 +31,5 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": [ "exclude": ["node_modules"]
"node_modules",
"src/__tests__",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"jest.setup.ts"
]
} }

View File

@ -1,18 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom", "node"],
"jsx": "react-jsx"
},
"include": [
"next-env.d.ts",
"jest.setup.ts",
"src/__tests__/**/*.ts",
"src/__tests__/**/*.tsx",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"exclude": ["node_modules"]
}

View File

@ -1,14 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src/ ./src/
EXPOSE 3200
USER node
CMD ["node", "src/index.js"]

View File

@ -1,15 +0,0 @@
{
"name": "xpeditis-log-exporter",
"version": "1.0.0",
"description": "Log export API for Xpeditis - queries Loki and exports logs as CSV/JSON",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"node-fetch": "^3.3.2",
"json2csv": "^6.0.0-alpha.2"
}
}

View File

@ -1,319 +0,0 @@
'use strict';
const express = require('express');
const { Transform } = require('stream');
const app = express();
const PORT = process.env.PORT || 3200;
const LOKI_URL = process.env.LOKI_URL || 'http://loki:3100';
const API_KEY = process.env.LOG_EXPORTER_API_KEY;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Simple API key middleware (optional, enabled when LOG_EXPORTER_API_KEY is set).
*/
function authMiddleware(req, res, next) {
if (!API_KEY) return next();
const key = req.headers['x-api-key'] || req.query.apiKey;
if (key !== API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
/**
* Build a Loki LogQL query from request params.
* Supports: service, level, search (free text filter)
*/
function buildLogQLQuery({ service, level, search }) {
const labelFilters = [];
if (service && service !== 'all') {
const services = service.split(',').map((s) => s.trim()).filter(Boolean);
if (services.length === 1) {
labelFilters.push(`service="${services[0]}"`);
} else {
labelFilters.push(`service=~"${services.join('|')}"`);
}
}
if (level && level !== 'all') {
const levels = level.split(',').map((l) => l.trim()).filter(Boolean);
if (levels.length === 1) {
labelFilters.push(`level="${levels[0]}"`);
} else {
labelFilters.push(`level=~"${levels.join('|')}"`);
}
}
const streamSelector = labelFilters.length > 0
? `{${labelFilters.join(', ')}}`
: `{service=~".+"}`;
const lineFilters = search ? ` |= \`${search}\`` : '';
return `${streamSelector}${lineFilters}`;
}
/**
* Query Loki's query_range endpoint and return flattened log entries.
*/
async function queryLoki({ query, start, end, limit = 5000 }) {
const params = new URLSearchParams({
query,
start: String(start),
end: String(end),
limit: String(Math.min(limit, 5000)),
direction: 'BACKWARD',
});
const url = `${LOKI_URL}/loki/api/v1/query_range?${params}`;
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Loki query failed (${response.status}): ${body}`);
}
const data = await response.json();
if (data.status !== 'success') {
throw new Error(`Loki returned status: ${data.status}`);
}
// Flatten streams → individual log entries
const entries = [];
for (const stream of data.data.result || []) {
const labels = stream.stream || {};
for (const [tsNano, line] of stream.values || []) {
let parsed = {};
try {
parsed = JSON.parse(line);
} catch {
parsed = { msg: line };
}
entries.push({
timestamp: new Date(Math.floor(Number(tsNano) / 1e6)).toISOString(),
service: labels.service || labels.container || 'unknown',
level: labels.level || parsed.level || 'info',
context: labels.context || parsed.context || '',
message: parsed.msg || parsed.message || line,
reqId: parsed.reqId || '',
req_method: parsed.req?.method || '',
req_url: parsed.req?.url || '',
res_status: parsed.res?.statusCode || '',
response_time_ms: parsed.responseTime || '',
error: parsed.err?.message || '',
raw: line,
});
}
}
// Sort by timestamp ascending
entries.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
return entries;
}
/**
* Convert array of objects to CSV string.
*/
function toCSV(entries) {
if (entries.length === 0) return '';
const headers = [
'timestamp', 'service', 'level', 'context',
'message', 'reqId', 'req_method', 'req_url',
'res_status', 'response_time_ms', 'error',
];
const escape = (val) => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const rows = [headers.join(',')];
for (const entry of entries) {
rows.push(headers.map((h) => escape(entry[h])).join(','));
}
return rows.join('\n');
}
// ─── Routes ──────────────────────────────────────────────────────────────────
app.use(express.json());
// Rate limiting (basic — 60 requests/min per IP)
const requestCounts = new Map();
setInterval(() => requestCounts.clear(), 60000);
app.use((req, res, next) => {
const ip = req.ip;
const count = (requestCounts.get(ip) || 0) + 1;
requestCounts.set(ip, count);
if (count > 60) {
return res.status(429).json({ error: 'Too Many Requests' });
}
next();
});
// CORS for Grafana / frontend
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
/**
* GET /health
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', loki: LOKI_URL });
});
/**
* GET /api/logs/services
* Returns the list of services currently emitting logs.
*/
app.get('/api/logs/services', authMiddleware, async (req, res) => {
try {
const response = await fetch(`${LOKI_URL}/loki/api/v1/label/service/values`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
const data = await response.json();
res.json({ services: data.data || [] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/logs/labels
* Returns all available label names.
*/
app.get('/api/logs/labels', authMiddleware, async (req, res) => {
try {
const response = await fetch(`${LOKI_URL}/loki/api/v1/labels`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
const data = await response.json();
res.json({ labels: data.data || [] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/logs/export
*
* Query params:
* - start : ISO date or Unix timestamp in ns (default: 1h ago)
* - end : ISO date or Unix timestamp in ns (default: now)
* - service : comma-separated service names (default: all)
* - level : comma-separated levels: error,warn,info,debug (default: all)
* - search : free-text search string
* - limit : max number of log lines (default: 5000, max: 5000)
* - format : "json" | "csv" (default: json)
*
* Examples:
* GET /api/logs/export?service=backend&level=error&format=csv
* GET /api/logs/export?start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&format=json
*/
app.get('/api/logs/export', authMiddleware, async (req, res) => {
try {
const now = Date.now();
const ONE_HOUR_NS = 3600 * 1e9;
const nowNs = BigInt(now) * 1000000n;
const oneHourAgoNs = nowNs - BigInt(ONE_HOUR_NS);
// Parse time range
const parseTime = (val, defaultNs) => {
if (!val) return defaultNs;
// Already in nanoseconds (large number)
if (/^\d{18,}$/.test(val)) return BigInt(val);
// Unix timestamp in seconds or ms
const n = Number(val);
if (!isNaN(n)) {
// seconds → ns
if (n < 1e12) return BigInt(Math.floor(n * 1e9));
// ms → ns
if (n < 1e15) return BigInt(n) * 1000000n;
return BigInt(n);
}
// ISO date string
const ms = Date.parse(val);
if (isNaN(ms)) throw new Error(`Invalid time value: ${val}`);
return BigInt(ms) * 1000000n;
};
const startNs = parseTime(req.query.start, oneHourAgoNs);
const endNs = parseTime(req.query.end, nowNs);
if (endNs <= startNs) {
return res.status(400).json({ error: '"end" must be after "start"' });
}
const format = (req.query.format || 'json').toLowerCase();
if (!['json', 'csv'].includes(format)) {
return res.status(400).json({ error: 'format must be "json" or "csv"' });
}
const limit = Math.min(parseInt(req.query.limit, 10) || 5000, 5000);
const query = buildLogQLQuery({
service: req.query.service,
level: req.query.level,
search: req.query.search,
});
const entries = await queryLoki({
query,
start: startNs.toString(),
end: endNs.toString(),
limit,
});
if (format === 'csv') {
const csv = toCSV(entries);
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(csv);
}
// JSON response
res.json({
total: entries.length,
query,
range: {
from: new Date(Number(startNs / 1000000n)).toISOString(),
to: new Date(Number(endNs / 1000000n)).toISOString(),
},
logs: entries,
});
} catch (err) {
console.error('[log-exporter] Export error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`[log-exporter] Listening on port ${PORT}`);
console.log(`[log-exporter] Loki URL: ${LOKI_URL}`);
console.log(`[log-exporter] API key protection: ${API_KEY ? 'enabled' : 'disabled'}`);
});

View File

@ -50,10 +50,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: xpeditis-backend-dev container_name: xpeditis-backend-dev
ports: ports:
- "4000:4000" - "4001:4000"
labels:
logging: promtail
logging.service: backend
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -61,8 +58,6 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
NODE_ENV: development NODE_ENV: development
# Force JSON logs in Docker so Promtail can parse them
LOG_FORMAT: json
PORT: 4000 PORT: 4000
API_PREFIX: api/v1 API_PREFIX: api/v1
@ -94,10 +89,10 @@ services:
AWS_S3_BUCKET: xpeditis-csv-rates AWS_S3_BUCKET: xpeditis-csv-rates
# CORS - Allow both localhost (browser) and container network # CORS - Allow both localhost (browser) and container network
CORS_ORIGIN: "http://localhost:3000,http://localhost:4000" CORS_ORIGIN: "http://localhost:3001,http://localhost:4001"
# Application URL # Application URL
APP_URL: http://localhost:3000 APP_URL: http://localhost:3001
# Security # Security
BCRYPT_ROUNDS: 10 BCRYPT_ROUNDS: 10
@ -107,30 +102,19 @@ services:
RATE_LIMIT_TTL: 60 RATE_LIMIT_TTL: 60
RATE_LIMIT_MAX: 100 RATE_LIMIT_MAX: 100
# SMTP (Brevo)
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587
SMTP_USER: 9637ef001@smtp-brevo.com
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
SMTP_SECURE: "false"
SMTP_FROM: noreply@xpeditis.com
frontend: frontend:
build: build:
context: ./apps/frontend context: ./apps/frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_API_URL: http://localhost:4000 NEXT_PUBLIC_API_URL: http://localhost:4001
container_name: xpeditis-frontend-dev container_name: xpeditis-frontend-dev
ports: ports:
- "3000:3000" - "3001:3000"
labels:
logging: promtail
logging.service: frontend
depends_on: depends_on:
- backend - backend
environment: environment:
NEXT_PUBLIC_API_URL: http://localhost:4000 NEXT_PUBLIC_API_URL: http://localhost:4001
volumes: volumes:
postgres_data: postgres_data:

View File

@ -1,254 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Xpeditis — Full Dev Stack (infrastructure + app + logging)
#
# Usage:
# docker-compose -f docker-compose.full.yml up -d
#
# Exposed ports:
# - Frontend: http://localhost:3000
# - Backend: http://localhost:4000 (Swagger: /api/docs)
# - Grafana: http://localhost:3030 (admin / xpeditis_grafana)
# - Loki: http://localhost:3100 (internal)
# - Promtail: http://localhost:9080 (internal)
# - log-exporter: http://localhost:3200
# - MinIO: http://localhost:9001 (console)
# ─────────────────────────────────────────────────────────────────────────────
version: '3.8'
services:
# ─── Infrastructure ────────────────────────────────────────────────────────
postgres:
image: postgres:15-alpine
container_name: xpeditis-postgres-dev
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: xpeditis_dev
POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xpeditis"]
interval: 10s
timeout: 5s
retries: 5
networks:
- xpeditis-network
redis:
image: redis:7-alpine
container_name: xpeditis-redis-dev
ports:
- "6379:6379"
command: redis-server --requirepass xpeditis_redis_password
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- xpeditis-network
minio:
image: minio/minio:latest
container_name: xpeditis-minio-dev
ports:
- "9000:9000"
- "9001:9001"
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
networks:
- xpeditis-network
# ─── Application ───────────────────────────────────────────────────────────
backend:
build:
context: ./apps/backend
dockerfile: Dockerfile
container_name: xpeditis-backend-dev
ports:
- "4000:4000"
labels:
logging: promtail
logging.service: backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: development
LOG_FORMAT: json
PORT: 4000
API_PREFIX: api/v1
# Database
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USER: xpeditis
DATABASE_PASSWORD: xpeditis_dev_password
DATABASE_NAME: xpeditis_dev
DATABASE_SYNC: false
DATABASE_LOGGING: true
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: xpeditis_redis_password
REDIS_DB: 0
# JWT
JWT_SECRET: dev-secret-jwt-key-for-docker
JWT_ACCESS_EXPIRATION: 15m
JWT_REFRESH_EXPIRATION: 7d
# S3/MinIO
AWS_S3_ENDPOINT: http://minio:9000
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_S3_BUCKET: xpeditis-csv-rates
# CORS
CORS_ORIGIN: "http://localhost:3000,http://localhost:4000"
# Application URL
APP_URL: http://localhost:3000
# Security
BCRYPT_ROUNDS: 10
SESSION_TIMEOUT_MS: 7200000
# Rate Limiting
RATE_LIMIT_TTL: 60
RATE_LIMIT_MAX: 100
# SMTP (Brevo)
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587
SMTP_USER: 9637ef001@smtp-brevo.com
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
SMTP_SECURE: "false"
SMTP_FROM: noreply@xpeditis.com
networks:
- xpeditis-network
frontend:
build:
context: ./apps/frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost:4000
container_name: xpeditis-frontend-dev
ports:
- "3000:3000"
labels:
logging: promtail
logging.service: frontend
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://localhost:4000
networks:
- xpeditis-network
# ─── Logging Stack ─────────────────────────────────────────────────────────
loki:
image: grafana/loki:3.0.0
container_name: xpeditis-loki
restart: unless-stopped
ports:
- '3100:3100'
volumes:
- ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
promtail:
image: grafana/promtail:3.0.0
container_name: xpeditis-promtail
restart: unless-stopped
ports:
- '9080:9080'
volumes:
- ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
grafana:
image: grafana/grafana:11.0.0
container_name: xpeditis-grafana
restart: unless-stopped
ports:
- '3030:3000'
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_AUTH_ANONYMOUS_ENABLED: 'false'
GF_SERVER_ROOT_URL: http://localhost:3030
GF_ANALYTICS_REPORTING_ENABLED: 'false'
GF_ANALYTICS_CHECK_FOR_UPDATES: 'false'
volumes:
- ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
log-exporter:
build:
context: ./apps/log-exporter
dockerfile: Dockerfile
container_name: xpeditis-log-exporter
restart: unless-stopped
ports:
- '3200:3200'
environment:
PORT: 3200
LOKI_URL: http://loki:3100
# Optional: set LOG_EXPORTER_API_KEY to require x-api-key header
# LOG_EXPORTER_API_KEY: your-secret-key-here
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
volumes:
postgres_data:
redis_data:
minio_data:
loki_data:
driver: local
grafana_data:
driver: local
networks:
xpeditis-network:
name: xpeditis-network

View File

@ -1,115 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Xpeditis — Centralized Logging Stack
#
# Usage (standalone):
# docker-compose -f docker-compose.yml -f docker-compose.logging.yml up -d
#
# Usage (full dev environment with logging):
# docker-compose -f docker-compose.dev.yml -f docker-compose.logging.yml up -d
#
# Exposed ports:
# - Grafana: http://localhost:3000 (admin / xpeditis_grafana)
# - Loki: http://localhost:3100 (internal use only)
# - Promtail: http://localhost:9080 (internal use only)
# - log-exporter: http://localhost:3200 (export API)
# ─────────────────────────────────────────────────────────────────────────────
services:
# ─── Loki — Log storage & query engine ────────────────────────────────────
loki:
image: grafana/loki:3.0.0
container_name: xpeditis-loki
restart: unless-stopped
ports:
- '3100:3100'
volumes:
- ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
# ─── Promtail — Docker log collector ──────────────────────────────────────
promtail:
image: grafana/promtail:3.0.0
container_name: xpeditis-promtail
restart: unless-stopped
ports:
- '9080:9080'
volumes:
- ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
# Note: /var/lib/docker/containers is not needed with docker_sd_configs (uses Docker API)
command: -config.file=/etc/promtail/config.yml
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
# ─── Grafana — Visualization ───────────────────────────────────────────────
grafana:
image: grafana/grafana:11.0.0
container_name: xpeditis-grafana
restart: unless-stopped
ports:
- '3030:3000'
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_AUTH_ANONYMOUS_ENABLED: 'false'
GF_SERVER_ROOT_URL: http://localhost:3030
# Disable telemetry
GF_ANALYTICS_REPORTING_ENABLED: 'false'
GF_ANALYTICS_CHECK_FOR_UPDATES: 'false'
volumes:
- ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
# ─── log-exporter — REST export API ───────────────────────────────────────
log-exporter:
build:
context: ./apps/log-exporter
dockerfile: Dockerfile
container_name: xpeditis-log-exporter
restart: unless-stopped
ports:
- '3200:3200'
environment:
PORT: 3200
LOKI_URL: http://loki:3100
# Optional: set LOG_EXPORTER_API_KEY to require x-api-key header
# LOG_EXPORTER_API_KEY: your-secret-key-here
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
volumes:
loki_data:
driver: local
grafana_data:
driver: local
networks:
xpeditis-network:
name: xpeditis-network
# Re-uses the network created by docker-compose.yml / docker-compose.dev.yml.
# If starting this stack alone, the network is created automatically.

View File

@ -327,7 +327,7 @@ spec:
# Pull depuis GHCR (GitHub Container Registry) # Pull depuis GHCR (GitHub Container Registry)
imagePullSecrets: imagePullSecrets:
- name: scaleway-registry - name: ghcr-credentials
# Redémarrage automatique # Redémarrage automatique
restartPolicy: Always restartPolicy: Always
@ -441,7 +441,7 @@ spec:
command: ["/bin/sh", "-c", "sleep 5"] command: ["/bin/sh", "-c", "sleep 5"]
imagePullSecrets: imagePullSecrets:
- name: scaleway-registry - name: ghcr-credentials
restartPolicy: Always restartPolicy: Always
``` ```
@ -678,18 +678,20 @@ spec:
--- ---
## Secret Scaleway Container Registry ## Secret GHCR (GitHub Container Registry)
Pour que Kubernetes puisse pull les images depuis le registry Scaleway : Pour que Kubernetes puisse pull les images depuis GHCR :
```bash ```bash
# REGISTRY_TOKEN = token Scaleway (Settings → API Keys → Container Registry) # Créer un Personal Access Token GitHub avec scope: read:packages
# https://github.com/settings/tokens/new
kubectl create secret docker-registry scaleway-registry \ kubectl create secret docker-registry ghcr-credentials \
--namespace xpeditis-prod \ --namespace xpeditis-prod \
--docker-server=rg.fr-par.scw.cloud \ --docker-server=ghcr.io \
--docker-username=nologin \ --docker-username=<VOTRE_USERNAME_GITHUB> \
--docker-password=<REGISTRY_TOKEN> --docker-password=<VOTRE_GITHUB_PAT> \
--docker-email=<VOTRE_EMAIL>
``` ```
--- ---
@ -755,7 +757,7 @@ spec:
- secretRef: - secretRef:
name: backend-secrets name: backend-secrets
imagePullSecrets: imagePullSecrets:
- name: scaleway-registry - name: ghcr-credentials
EOF EOF
kubectl apply -f /tmp/migration-job.yaml kubectl apply -f /tmp/migration-job.yaml

View File

@ -279,14 +279,14 @@ kubectl apply -f k8s/00-namespaces.yaml
gpg --decrypt "$HOME/.xpeditis-secrets-backup/k8s-secrets-XXXXXXXX.tar.gz.gpg" | tar xzf - gpg --decrypt "$HOME/.xpeditis-secrets-backup/k8s-secrets-XXXXXXXX.tar.gz.gpg" | tar xzf -
kubectl apply -f /tmp/backend-secrets-*.yaml kubectl apply -f /tmp/backend-secrets-*.yaml
kubectl apply -f /tmp/frontend-secrets-*.yaml kubectl apply -f /tmp/frontend-secrets-*.yaml
kubectl apply -f /tmp/scaleway-creds-*.yaml kubectl apply -f /tmp/ghcr-creds-*.yaml
# Recréer le secret Scaleway # Recréer le secret GHCR
kubectl create secret docker-registry scaleway-registry \ kubectl create secret docker-registry ghcr-credentials \
--namespace xpeditis-prod \ --namespace xpeditis-prod \
--docker-server=rg.fr-par.scw.cloud \ --docker-server=ghcr.io \
--docker-username=nologin \ --docker-username=<GITHUB_USERNAME> \
--docker-password=<REGISTRY_TOKEN> --docker-password=<GITHUB_PAT>
``` ```
### Étape 3 : Restaurer les services (15 min) ### Étape 3 : Restaurer les services (15 min)

View File

@ -1,12 +0,0 @@
apiVersion: 1
providers:
- name: Xpeditis Dashboards
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false

View File

@ -1,532 +0,0 @@
{
"__inputs": [
{
"name": "DS_LOKI",
"label": "Loki",
"description": "Loki datasource",
"type": "datasource",
"pluginId": "loki",
"pluginName": "Loki"
}
],
"__requires": [
{ "type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.0.0" },
{ "type": "datasource", "id": "loki", "name": "Loki", "version": "1.0.0" },
{ "type": "panel", "id": "stat", "name": "Stat", "version": "" },
{ "type": "panel", "id": "timeseries", "name": "Time series", "version": "" },
{ "type": "panel", "id": "piechart", "name": "Pie chart", "version": "" },
{ "type": "panel", "id": "bargauge", "name": "Bar gauge", "version": "" },
{ "type": "panel", "id": "logs", "name": "Logs", "version": "" }
],
"title": "Xpeditis — Logs & KPIs",
"uid": "xpeditis-logs-kpis",
"description": "Logs applicatifs, KPIs HTTP, temps de réponse et erreurs — Backend & Frontend",
"tags": ["xpeditis", "logs", "monitoring", "backend"],
"timezone": "Europe/Paris",
"refresh": "30s",
"schemaVersion": 39,
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"graphTooltip": 1,
"editable": true,
"version": 1,
"links": [],
"annotations": { "list": [] },
"templating": {
"list": [
{
"name": "service",
"label": "Service",
"type": "query",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"query": "label_values(service)",
"refresh": 2,
"includeAll": true,
"allValue": ".+",
"multi": true,
"current": {},
"hide": 0,
"sort": 1
},
{
"name": "level",
"label": "Niveau",
"type": "query",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"query": "label_values(level)",
"refresh": 2,
"includeAll": true,
"allValue": ".+",
"multi": true,
"current": {},
"hide": 0,
"sort": 1
}
]
},
"panels": [
{
"id": 1,
"title": "Requêtes totales",
"type": "stat",
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed", "fixedColor": "#10183A" },
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "#10183A", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum(count_over_time({service=~\"$service\"} | json | req_method != \"\" [$__range]))",
"legendFormat": "Requêtes",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 2,
"title": "Erreurs (error + fatal)",
"type": "stat",
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed", "fixedColor": "red" },
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))",
"legendFormat": "Erreurs",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 3,
"title": "Warnings",
"type": "stat",
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed", "fixedColor": "orange" },
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "orange", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))",
"legendFormat": "Warnings",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 4,
"title": "Taux d'erreur",
"type": "stat",
"gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"color": { "mode": "thresholds" }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum(rate({service=~\"$service\", level=~\"error|fatal\"} [$__interval])) / sum(rate({service=~\"$service\"} [$__interval]))",
"legendFormat": "Taux d'erreur",
"instant": false,
"range": true,
"refId": "A"
}
]
},
{
"id": 5,
"title": "Trafic par service (req/s)",
"type": "timeseries",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"gradientMode": "opacity",
"spanNulls": false
}
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum by(service) (rate({service=~\"$service\"} | json | req_method != \"\" [$__interval]))",
"legendFormat": "{{service}}",
"instant": false,
"range": true,
"refId": "A"
}
]
},
{
"id": 6,
"title": "Erreurs & Warnings dans le temps",
"type": "timeseries",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "short",
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 15,
"gradientMode": "opacity"
}
},
"overrides": [
{
"matcher": { "id": "byName", "options": "error" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
},
{
"matcher": { "id": "byName", "options": "fatal" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }]
},
{
"matcher": { "id": "byName", "options": "warn" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }]
}
]
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum by(level) (rate({service=~\"$service\", level=~\"error|fatal|warn\"} [$__interval]))",
"legendFormat": "{{level}}",
"instant": false,
"range": true,
"refId": "A"
}
]
},
{
"id": 7,
"title": "Temps de réponse Backend",
"type": "timeseries",
"gridPos": { "x": 0, "y": 12, "w": 16, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "ms",
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"gradientMode": "opacity"
},
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 500 },
{ "color": "red", "value": 1000 }
]
}
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Pire cas (1% des requêtes)" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
},
{
"matcher": { "id": "byName", "options": "Lent (5% des requêtes)" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }]
},
{
"matcher": { "id": "byName", "options": "Temps médian (requête typique)" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }]
}
]
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "quantile_over_time(0.50, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])",
"legendFormat": "Temps médian (requête typique)",
"instant": false,
"range": true,
"refId": "A"
},
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "quantile_over_time(0.95, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])",
"legendFormat": "Lent (5% des requêtes)",
"instant": false,
"range": true,
"refId": "B"
},
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "quantile_over_time(0.99, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])",
"legendFormat": "Pire cas (1% des requêtes)",
"instant": false,
"range": true,
"refId": "C"
}
]
},
{
"id": 8,
"title": "Répartition par niveau de log",
"type": "piechart",
"gridPos": { "x": 16, "y": 12, "w": 8, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"pieType": "donut",
"tooltip": { "mode": "single" },
"legend": { "displayMode": "list", "placement": "bottom", "values": ["percent"] }
},
"fieldConfig": {
"defaults": { "unit": "short", "color": { "mode": "palette-classic" } },
"overrides": [
{ "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] },
{ "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }] },
{ "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] },
{ "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }] },
{ "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] }
]
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum by(level) (count_over_time({service=~\"$service\", level=~\"$level\"} [$__range]))",
"legendFormat": "{{level}}",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 9,
"title": "Codes HTTP (5m)",
"type": "bargauge",
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"displayMode": "gradient",
"valueMode": "color",
"showUnfilled": true,
"minVizWidth": 10,
"minVizHeight": 10
},
"fieldConfig": {
"defaults": {
"unit": "short",
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 1 }
]
}
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "sum by(status_code) (count_over_time({service=\"backend\"} | json | res_statusCode != \"\" | label_format status_code=\"{{res_statusCode}}\" [$__range]))",
"legendFormat": "HTTP {{status_code}}",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 10,
"title": "Top erreurs par contexte NestJS",
"type": "bargauge",
"gridPos": { "x": 12, "y": 20, "w": 12, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"displayMode": "gradient",
"showUnfilled": true
},
"fieldConfig": {
"defaults": {
"unit": "short",
"color": { "mode": "fixed", "fixedColor": "red" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "topk(10, sum by(context) (count_over_time({service=\"backend\", level=~\"error|fatal\"} | json | context != \"\" [$__range]) ))",
"legendFormat": "{{context}}",
"instant": true,
"range": false,
"refId": "A"
}
]
},
{
"id": 11,
"title": "Logs — Backend",
"type": "logs",
"gridPos": { "x": 0, "y": 28, "w": 24, "h": 12 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "{service=\"backend\", level=~\"$level\"}",
"legendFormat": "",
"instant": false,
"range": true,
"refId": "A",
"maxLines": 500
}
]
},
{
"id": 12,
"title": "Logs — Frontend",
"type": "logs",
"gridPos": { "x": 0, "y": 40, "w": 24, "h": 10 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"expr": "{service=\"frontend\"}",
"legendFormat": "",
"instant": false,
"range": true,
"refId": "A",
"maxLines": 200
}
]
}
]
}

View File

@ -1,19 +0,0 @@
apiVersion: 1
datasources:
- name: Loki
uid: loki-xpeditis
type: loki
access: proxy
url: http://xpeditis-loki:3100
isDefault: true
version: 1
editable: false
jsonData:
maxLines: 1000
timeout: 60
derivedFields:
- datasourceUid: ''
matcherRegex: '"reqId":"([^"]+)"'
name: RequestID
url: ''

View File

@ -1,62 +0,0 @@
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
log_level: warn
# Memberlist-based ring coordination — required for single-node Loki 3.x
memberlist:
bind_port: 7946
join_members:
- 127.0.0.1:7946
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: memberlist
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
allow_structured_metadata: true
volume_enabled: true
retention_period: 744h # 31 days
reject_old_samples: true
reject_old_samples_max_age: 168h # Accept logs up to 7 days old
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
max_entries_limit_per_query: 5000
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
delete_request_store: filesystem
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
analytics:
reporting_enabled: false

View File

@ -1,58 +0,0 @@
server:
http_listen_port: 9080
log_level: warn
positions:
filename: /tmp/positions.yaml
clients:
- url: http://xpeditis-loki:3100/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
timeout: 10s
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
filters:
- name: label
values: ['logging=promtail']
relabel_configs:
- source_labels: ['__meta_docker_container_label_logging_service']
target_label: service
- source_labels: ['__meta_docker_container_name']
regex: '/?(.*)'
replacement: '${1}'
target_label: container
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
pipeline_stages:
- drop:
older_than: 15m
drop_counter_reason: entry_too_old
- drop:
expression: 'GET /(health|metrics|minio/health)'
- json:
expressions:
level: level
msg: msg
context: context
reqId: reqId
- labels:
level:
context:
- template:
source: level
template: >-
{{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }}
- labels:
level:

69
package-lock.json generated
View File

@ -1,69 +0,0 @@
{
"name": "xpeditis",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xpeditis",
"version": "0.1.0",
"license": "UNLICENSED",
"devDependencies": {
"@types/node": "^20.10.0",
"prettier": "^3.1.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}