Compare commits
No commits in common. "main" and "pagination_booking" have entirely different histories.
main
...
pagination
276
.github/workflows/cd-main.yml
vendored
276
.github/workflows/cd-main.yml
vendored
@ -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 }}
|
|
||||||
316
.github/workflows/cd-preprod.yml
vendored
316
.github/workflows/cd-preprod.yml
vendored
@ -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 }}
|
|
||||||
389
.github/workflows/ci.yml
vendored
389
.github/workflows/ci.yml
vendored
@ -1,103 +1,372 @@
|
|||||||
name: Dev CI
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches:
|
||||||
pull_request:
|
- preprod
|
||||||
branches: [dev]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: dev-ci-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
REGISTRY: rg.fr-par.scw.cloud/weworkstudio
|
||||||
NODE_VERSION: '20'
|
NODE_VERSION: '20'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-quality:
|
# ============================================
|
||||||
name: Backend — Lint
|
# Backend Build, Test & Deploy
|
||||||
|
# ============================================
|
||||||
|
backend:
|
||||||
|
name: Backend - Build, Test & Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
- uses: actions/setup-node@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: apps/backend/package-lock.json
|
|
||||||
- run: npm install --legacy-peer-deps
|
|
||||||
- run: npm run lint
|
|
||||||
|
|
||||||
frontend-quality:
|
- name: Install dependencies
|
||||||
name: Frontend — Lint & Type-check
|
run: npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Lint code
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test -- --coverage --passWithNoTests
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Scaleway Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: rg.fr-par.scw.cloud/weworkstudio
|
||||||
|
username: nologin
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/xpeditis-backend
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Backend Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./apps/backend
|
||||||
|
file: ./apps/backend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-backend:buildcache,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Frontend Build, Test & Deploy
|
||||||
|
# ============================================
|
||||||
|
frontend:
|
||||||
|
name: Frontend - Build, Test & Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
- uses: actions/setup-node@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
- run: npm ci --legacy-peer-deps
|
|
||||||
- run: npm run lint
|
|
||||||
- run: npm run type-check
|
|
||||||
|
|
||||||
backend-tests:
|
- name: Install dependencies
|
||||||
name: Backend — Unit Tests
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Lint code
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test -- --passWithNoTests || echo "No tests found"
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:4000' }}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }}
|
||||||
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Scaleway Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: rg.fr-par.scw.cloud/weworkstudio
|
||||||
|
username: nologin
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/xpeditis-frontend
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Frontend Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./apps/frontend
|
||||||
|
file: ./apps/frontend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL || 'http://localhost:4000' }}
|
||||||
|
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Integration Tests (Optional)
|
||||||
|
# ============================================
|
||||||
|
integration-tests:
|
||||||
|
name: Integration Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: backend-quality
|
needs: [backend, frontend]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: xpeditis
|
||||||
|
POSTGRES_PASSWORD: xpeditis_dev_password
|
||||||
|
POSTGRES_DB: xpeditis_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
- uses: actions/setup-node@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: apps/backend/package-lock.json
|
|
||||||
- run: npm install --legacy-peer-deps
|
|
||||||
- run: npm test -- --passWithNoTests
|
|
||||||
|
|
||||||
frontend-tests:
|
- name: Install dependencies
|
||||||
name: Frontend — Unit Tests
|
run: npm install --legacy-peer-deps
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: frontend-quality
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: apps/frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
|
||||||
- run: npm ci --legacy-peer-deps
|
|
||||||
- run: npm test -- --passWithNoTests
|
|
||||||
|
|
||||||
notify-failure:
|
- name: Run integration tests
|
||||||
name: Notify Failure
|
env:
|
||||||
|
DATABASE_HOST: localhost
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_USER: xpeditis
|
||||||
|
DATABASE_PASSWORD: xpeditis_dev_password
|
||||||
|
DATABASE_NAME: xpeditis_test
|
||||||
|
REDIS_HOST: localhost
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
JWT_SECRET: test-secret-key-for-ci
|
||||||
|
run: npm run test:integration || echo "No integration tests found"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Deployment Summary
|
||||||
|
# ============================================
|
||||||
|
deployment-summary:
|
||||||
|
name: Deployment Summary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests]
|
needs: [backend, frontend]
|
||||||
if: failure()
|
if: success()
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Discord
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
curl -s -H "Content-Type: application/json" -d '{
|
echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Backend Image" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-backend\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Frontend Image" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Registry: \`${{ env.REGISTRY }}/xpeditis-frontend\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "docker pull ${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "docker pull ${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Deploy to Portainer via Webhooks
|
||||||
|
# ============================================
|
||||||
|
deploy-portainer:
|
||||||
|
name: Deploy to Portainer
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend]
|
||||||
|
if: success() && github.ref == 'refs/heads/preprod'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger Backend Webhook
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deploying Backend to Portainer..."
|
||||||
|
curl -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data": "backend-deployment"}' \
|
||||||
|
${{ secrets.PORTAINER_WEBHOOK_BACKEND }}
|
||||||
|
echo "✅ Backend webhook triggered"
|
||||||
|
|
||||||
|
- name: Wait before Frontend deployment
|
||||||
|
run: sleep 10
|
||||||
|
|
||||||
|
- name: Trigger Frontend Webhook
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deploying Frontend to Portainer..."
|
||||||
|
curl -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data": "frontend-deployment"}' \
|
||||||
|
${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}
|
||||||
|
echo "✅ Frontend webhook triggered"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Discord Notification - Success
|
||||||
|
# ============================================
|
||||||
|
notify-success:
|
||||||
|
name: Discord Notification (Success)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend, deploy-portainer]
|
||||||
|
if: success()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Send Discord notification
|
||||||
|
run: |
|
||||||
|
curl -H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
"embeds": [{
|
"embeds": [{
|
||||||
"title": "❌ Dev CI Failed",
|
"title": "✅ CI/CD Pipeline Success",
|
||||||
|
"description": "Deployment completed successfully!",
|
||||||
|
"color": 3066993,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Repository",
|
||||||
|
"value": "${{ github.repository }}",
|
||||||
|
"inline": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Branch",
|
||||||
|
"value": "${{ github.ref_name }}",
|
||||||
|
"inline": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Commit",
|
||||||
|
"value": "[${{ github.sha }}](${{ github.event.head_commit.url }})",
|
||||||
|
"inline": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Backend Image",
|
||||||
|
"value": "`${{ env.REGISTRY }}/xpeditis-backend:${{ github.ref_name }}`",
|
||||||
|
"inline": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frontend Image",
|
||||||
|
"value": "`${{ env.REGISTRY }}/xpeditis-frontend:${{ github.ref_name }}`",
|
||||||
|
"inline": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Workflow",
|
||||||
|
"value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
|
||||||
|
"inline": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": "${{ github.event.head_commit.timestamp }}",
|
||||||
|
"footer": {
|
||||||
|
"text": "Xpeditis CI/CD"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}' \
|
||||||
|
${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Discord Notification - Failure
|
||||||
|
# ============================================
|
||||||
|
notify-failure:
|
||||||
|
name: Discord Notification (Failure)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend, deploy-portainer]
|
||||||
|
if: failure()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Send Discord notification
|
||||||
|
run: |
|
||||||
|
curl -H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"embeds": [{
|
||||||
|
"title": "❌ CI/CD Pipeline Failed",
|
||||||
|
"description": "Deployment failed! Check the logs for details.",
|
||||||
"color": 15158332,
|
"color": 15158332,
|
||||||
"fields": [
|
"fields": [
|
||||||
{"name": "Branch", "value": "`${{ github.ref_name }}`", "inline": true},
|
{
|
||||||
{"name": "Author", "value": "${{ github.actor }}", "inline": true},
|
"name": "Repository",
|
||||||
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}
|
"value": "${{ github.repository }}",
|
||||||
|
"inline": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Branch",
|
||||||
|
"value": "${{ github.ref_name }}",
|
||||||
|
"inline": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Commit",
|
||||||
|
"value": "[${{ github.sha }}](${{ github.event.head_commit.url }})",
|
||||||
|
"inline": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Workflow",
|
||||||
|
"value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})",
|
||||||
|
"inline": false
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"footer": {"text": "Xpeditis CI • Dev"}
|
"timestamp": "${{ github.event.head_commit.timestamp }}",
|
||||||
|
"footer": {
|
||||||
|
"text": "Xpeditis CI/CD"
|
||||||
|
}
|
||||||
}]
|
}]
|
||||||
}' ${{ secrets.DISCORD_WEBHOOK_URL }}
|
}' \
|
||||||
|
${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
|||||||
145
.github/workflows/pr-checks.yml
vendored
145
.github/workflows/pr-checks.yml
vendored
@ -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
|
|
||||||
269
.github/workflows/rollback.yml
vendored
269
.github/workflows/rollback.yml
vendored
@ -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
2
.gitignore
vendored
@ -44,8 +44,6 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
stack-portainer.yaml
|
|
||||||
tmp.stack-portainer.yaml
|
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
uploads/
|
uploads/
|
||||||
|
|||||||
3761
1536w default.svg
Normal file
3761
1536w default.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 MiB |
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
### 4. Nettoyage des fichiers CSV
|
### 4. Nettoyage des fichiers CSV
|
||||||
- ✅ Suppression de la colonne `companyEmail` des fichiers CSV (elle n'est plus nécessaire)
|
- ✅ Suppression de la colonne `companyEmail` des fichiers CSV (elle n'est plus nécessaire)
|
||||||
- ✅ Script Python créé pour automatiser l'ajout/suppression: `scripts/add-email-to-csv.py`
|
- ✅ Script Python créé pour automatiser l'ajout/suppression: `add-email-to-csv.py`
|
||||||
|
|
||||||
## ✅ Ce qui a été complété (SUITE)
|
## ✅ Ce qui a été complété (SUITE)
|
||||||
|
|
||||||
@ -130,16 +130,16 @@ docker push rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:preprod
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Rendre le script exécutable
|
# Rendre le script exécutable
|
||||||
chmod +x docker/deploy-to-portainer.sh
|
chmod +x deploy-to-portainer.sh
|
||||||
|
|
||||||
# Option 1 : Build et push tout
|
# Option 1 : Build et push tout
|
||||||
./docker/deploy-to-portainer.sh all
|
./deploy-to-portainer.sh all
|
||||||
|
|
||||||
# Option 2 : Backend seulement
|
# Option 2 : Backend seulement
|
||||||
./docker/deploy-to-portainer.sh backend
|
./deploy-to-portainer.sh backend
|
||||||
|
|
||||||
# Option 3 : Frontend seulement
|
# Option 3 : Frontend seulement
|
||||||
./docker/deploy-to-portainer.sh frontend
|
./deploy-to-portainer.sh frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Le script fait automatiquement :
|
Le script fait automatiquement :
|
||||||
@ -271,8 +271,8 @@ docker images | grep rg.fr-par.scw.cloud
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Plus simple et recommandé
|
# Plus simple et recommandé
|
||||||
chmod +x docker/deploy-to-portainer.sh
|
chmod +x deploy-to-portainer.sh
|
||||||
./docker/deploy-to-portainer.sh all
|
./deploy-to-portainer.sh all
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -2,6 +2,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
|||||||
@ -37,13 +37,11 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
|
|||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:3000
|
||||||
|
|
||||||
# Email (SMTP)
|
# Email (SMTP)
|
||||||
SMTP_HOST=smtp-relay.brevo.com
|
SMTP_HOST=smtp.sendgrid.net
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=ton-email@brevo.com
|
|
||||||
SMTP_PASS=ta-cle-smtp-brevo
|
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=apikey
|
||||||
# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant)
|
SMTP_PASS=your-sendgrid-api-key
|
||||||
SMTP_FROM=noreply@xpeditis.com
|
SMTP_FROM=noreply@xpeditis.com
|
||||||
|
|
||||||
# AWS S3 / Storage (or MinIO for development)
|
# AWS S3 / Storage (or MinIO for development)
|
||||||
@ -76,11 +74,6 @@ ONE_API_URL=https://api.one-line.com/v1
|
|||||||
ONE_USERNAME=your-one-username
|
ONE_USERNAME=your-one-username
|
||||||
ONE_PASSWORD=your-one-password
|
ONE_PASSWORD=your-one-password
|
||||||
|
|
||||||
# Swagger Documentation Access (HTTP Basic Auth)
|
|
||||||
# Leave empty to disable Swagger in production, or set both to protect with a password
|
|
||||||
SWAGGER_USERNAME=admin
|
|
||||||
SWAGGER_PASSWORD=change-this-strong-password
|
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
SESSION_TIMEOUT_MS=7200000
|
SESSION_TIMEOUT_MS=7200000
|
||||||
@ -91,18 +84,3 @@ RATE_LIMIT_MAX=100
|
|||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
SENTRY_DSN=your-sentry-dsn
|
SENTRY_DSN=your-sentry-dsn
|
||||||
|
|
||||||
# Frontend URL (for redirects)
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Stripe (Subscriptions & Payments)
|
|
||||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
|
||||||
|
|
||||||
# Stripe Price IDs (create these in Stripe Dashboard)
|
|
||||||
STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly
|
|
||||||
STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly
|
|
||||||
STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly
|
|
||||||
STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly
|
|
||||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly
|
|
||||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly
|
|
||||||
|
|||||||
@ -5,22 +5,20 @@ module.exports = {
|
|||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint/eslint-plugin', 'unused-imports'],
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
|
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
|
||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
},
|
},
|
||||||
ignorePatterns: ['.eslintrc.js', 'dist/**', 'node_modules/**', 'apps/**'],
|
ignorePatterns: ['.eslintrc.js', 'dist/**', 'node_modules/**'],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off', // Désactivé pour projet existant en production
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'@typescript-eslint/no-unused-vars': 'off', // Désactivé car remplacé par unused-imports
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'unused-imports/no-unused-imports': 'error',
|
|
||||||
'unused-imports/no-unused-vars': [
|
|
||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
|
|||||||
@ -14,7 +14,7 @@ COPY package*.json ./
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including dev for build)
|
# Install all dependencies (including dev for build)
|
||||||
RUN npm ci --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# ===============================================
|
# ===============================================
|
||||||
# Stage 2: Build Application
|
# Stage 2: Build Application
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
# MinIO Document Storage Setup Summary
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
Documents uploaded to MinIO were returning `AccessDenied` errors when users tried to download them from the admin documents page.
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
The `xpeditis-documents` bucket did not have a public read policy configured, which prevented direct URL access to uploaded documents.
|
|
||||||
|
|
||||||
## Solution Implemented
|
|
||||||
|
|
||||||
### 1. Fixed Dummy URLs in Database
|
|
||||||
**Script**: `fix-dummy-urls.js`
|
|
||||||
- Updated 2 bookings that had dummy URLs (`https://dummy-storage.com/...`)
|
|
||||||
- Changed to proper MinIO URLs: `http://localhost:9000/xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{fileName}`
|
|
||||||
|
|
||||||
### 2. Uploaded Test Documents
|
|
||||||
**Script**: `upload-test-documents.js`
|
|
||||||
- Created 54 test PDF documents
|
|
||||||
- Uploaded to MinIO with proper paths matching database records
|
|
||||||
- Files are minimal valid PDFs for testing purposes
|
|
||||||
|
|
||||||
### 3. Set Bucket Policy for Public Read Access
|
|
||||||
**Script**: `set-bucket-policy.js`
|
|
||||||
- Configured the `xpeditis-documents` bucket with a policy allowing public read access
|
|
||||||
- Policy applied:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": "*",
|
|
||||||
"Action": ["s3:GetObject"],
|
|
||||||
"Resource": ["arn:aws:s3:::xpeditis-documents/*"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Test Document Download
|
|
||||||
```bash
|
|
||||||
# Test with curl (should return HTTP 200 OK)
|
|
||||||
curl -I http://localhost:9000/xpeditis-documents/csv-bookings/70f6802a-f789-4f61-ab35-5e0ebf0e29d5/eba1c60f-c749-4b39-8e26-dcc617964237-Document_Export.pdf
|
|
||||||
|
|
||||||
# Download actual file
|
|
||||||
curl -o test.pdf http://localhost:9000/xpeditis-documents/csv-bookings/70f6802a-f789-4f61-ab35-5e0ebf0e29d5/eba1c60f-c749-4b39-8e26-dcc617964237-Document_Export.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Verification
|
|
||||||
1. Navigate to: http://localhost:3000/dashboard/admin/documents
|
|
||||||
2. Click the "Download" button on any document
|
|
||||||
3. Document should download successfully without errors
|
|
||||||
|
|
||||||
## MinIO Console Access
|
|
||||||
- **URL**: http://localhost:9001
|
|
||||||
- **Username**: minioadmin
|
|
||||||
- **Password**: minioadmin
|
|
||||||
|
|
||||||
You can view the bucket policy and uploaded files directly in the MinIO console.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
- `apps/backend/fix-dummy-urls.js` - Updates database URLs from dummy to MinIO
|
|
||||||
- `apps/backend/upload-test-documents.js` - Uploads test PDFs to MinIO
|
|
||||||
- `apps/backend/set-bucket-policy.js` - Configures bucket policy for public read
|
|
||||||
|
|
||||||
## Running the Scripts
|
|
||||||
```bash
|
|
||||||
cd apps/backend
|
|
||||||
|
|
||||||
# 1. Fix database URLs (run once)
|
|
||||||
node fix-dummy-urls.js
|
|
||||||
|
|
||||||
# 2. Upload test documents (run once)
|
|
||||||
node upload-test-documents.js
|
|
||||||
|
|
||||||
# 3. Set bucket policy (run once)
|
|
||||||
node set-bucket-policy.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### Development vs Production
|
|
||||||
- **Current Setup**: Public read access (suitable for development)
|
|
||||||
- **Production**: Consider using signed URLs for better security
|
|
||||||
|
|
||||||
### Signed URLs (Production Recommendation)
|
|
||||||
Instead of public bucket access, generate temporary signed URLs via the backend:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Backend endpoint to generate signed URL
|
|
||||||
@Get('documents/:id/download-url')
|
|
||||||
async getDownloadUrl(@Param('id') documentId: string) {
|
|
||||||
const document = await this.documentsService.findOne(documentId);
|
|
||||||
const signedUrl = await this.storageService.getSignedUrl(document.filePath);
|
|
||||||
return { url: signedUrl };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This approach:
|
|
||||||
- ✅ More secure (temporary URLs that expire)
|
|
||||||
- ✅ Allows access control (check user permissions before generating URL)
|
|
||||||
- ✅ Audit trail (log who accessed what)
|
|
||||||
- ❌ Requires backend API call for each download
|
|
||||||
|
|
||||||
### Current Architecture
|
|
||||||
The `S3StorageAdapter` already has a `getSignedUrl()` method implemented (line 148-162 in `s3-storage.adapter.ts`), so migrating to signed URLs in the future is straightforward.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### AccessDenied Error Returns
|
|
||||||
If you get AccessDenied errors again:
|
|
||||||
1. Check bucket policy: `node -e "const {S3Client,GetBucketPolicyCommand}=require('@aws-sdk/client-s3');const s3=new S3Client({endpoint:'http://localhost:9000',region:'us-east-1',credentials:{accessKeyId:'minioadmin',secretAccessKey:'minioadmin'},forcePathStyle:true});s3.send(new GetBucketPolicyCommand({Bucket:'xpeditis-documents'})).then(r=>console.log(r.Policy))"`
|
|
||||||
2. Re-run: `node set-bucket-policy.js`
|
|
||||||
|
|
||||||
### Document Not Found
|
|
||||||
If document URLs return 404:
|
|
||||||
1. Check MinIO console (http://localhost:9001)
|
|
||||||
2. Verify file exists in bucket
|
|
||||||
3. Check database URL matches MinIO path exactly
|
|
||||||
|
|
||||||
### Documents Not Showing in Admin Page
|
|
||||||
1. Verify bookings exist: `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL`
|
|
||||||
2. Check frontend console for errors
|
|
||||||
3. Verify API endpoint returns data: http://localhost:4000/api/v1/admin/bookings
|
|
||||||
|
|
||||||
## Database Query Examples
|
|
||||||
|
|
||||||
### Check Document URLs
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
booking_id as "bookingId",
|
|
||||||
documents::jsonb->0->>'filePath' as "firstDocumentUrl"
|
|
||||||
FROM csv_bookings
|
|
||||||
WHERE documents IS NOT NULL
|
|
||||||
LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Count Documents by Booking
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
jsonb_array_length(documents::jsonb) as "documentCount"
|
|
||||||
FROM csv_bookings
|
|
||||||
WHERE documents IS NOT NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps (Optional Production Enhancements)
|
|
||||||
|
|
||||||
1. **Implement Signed URLs**
|
|
||||||
- Create backend endpoint for signed URL generation
|
|
||||||
- Update frontend to fetch signed URL before download
|
|
||||||
- Remove public bucket policy
|
|
||||||
|
|
||||||
2. **Add Document Permissions**
|
|
||||||
- Check user permissions before generating download URL
|
|
||||||
- Restrict access based on organization membership
|
|
||||||
|
|
||||||
3. **Implement Audit Trail**
|
|
||||||
- Log document access events
|
|
||||||
- Track who downloaded what and when
|
|
||||||
|
|
||||||
4. **Add Document Scanning**
|
|
||||||
- Virus scanning on upload (ClamAV)
|
|
||||||
- Content validation
|
|
||||||
- File size limits enforcement
|
|
||||||
|
|
||||||
## Status
|
|
||||||
✅ **FIXED** - Documents can now be downloaded from the admin documents page without AccessDenied errors.
|
|
||||||
0
apps/backend/apps/backend/src/main.ts
Normal file
0
apps/backend/apps/backend/src/main.ts
Normal file
@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to delete test documents from MinIO
|
|
||||||
*
|
|
||||||
* Deletes only small test files (< 1000 bytes) created by upload-test-documents.js
|
|
||||||
* Preserves real uploaded documents (larger files)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
|
||||||
const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files
|
|
||||||
|
|
||||||
// Initialize MinIO client
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
region: 'us-east-1',
|
|
||||||
endpoint: MINIO_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deleteTestDocuments() {
|
|
||||||
try {
|
|
||||||
console.log('📋 Listing all files in bucket:', BUCKET_NAME);
|
|
||||||
|
|
||||||
// List all files
|
|
||||||
let allFiles = [];
|
|
||||||
let continuationToken = null;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const command = new ListObjectsV2Command({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
ContinuationToken: continuationToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
|
||||||
|
|
||||||
if (response.Contents) {
|
|
||||||
allFiles = allFiles.concat(response.Contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
|
||||||
} while (continuationToken);
|
|
||||||
|
|
||||||
console.log(`\n📊 Found ${allFiles.length} total files\n`);
|
|
||||||
|
|
||||||
// Filter test files (small files < 1000 bytes)
|
|
||||||
const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD);
|
|
||||||
const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD);
|
|
||||||
|
|
||||||
console.log(`🔍 Analysis:`);
|
|
||||||
console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`);
|
|
||||||
console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`);
|
|
||||||
|
|
||||||
if (testFiles.length === 0) {
|
|
||||||
console.log('✅ No test files to delete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleting ${testFiles.length} test files:\n`);
|
|
||||||
|
|
||||||
let deletedCount = 0;
|
|
||||||
for (const file of testFiles) {
|
|
||||||
console.log(` Deleting: ${file.Key} (${file.Size} bytes)`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await s3Client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: file.Key,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
deletedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` ❌ Failed to delete ${file.Key}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ Deleted ${deletedCount} test files`);
|
|
||||||
console.log(`✅ Preserved ${realFiles.length} real documents\n`);
|
|
||||||
|
|
||||||
console.log('📂 Remaining real documents:');
|
|
||||||
realFiles.forEach(file => {
|
|
||||||
const filename = file.Key.split('/').pop();
|
|
||||||
const sizeMB = (file.Size / 1024 / 1024).toFixed(2);
|
|
||||||
console.log(` - ${filename} (${sizeMB} MB)`);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTestDocuments()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Script completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to fix dummy storage URLs in the database
|
|
||||||
*
|
|
||||||
* This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Client } = require('pg');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
|
||||||
|
|
||||||
async function fixDummyUrls() {
|
|
||||||
const client = new Client({
|
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
console.log('✅ Connected to database');
|
|
||||||
|
|
||||||
// Get all CSV bookings with documents
|
|
||||||
const result = await client.query(
|
|
||||||
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`);
|
|
||||||
|
|
||||||
let updatedCount = 0;
|
|
||||||
|
|
||||||
for (const row of result.rows) {
|
|
||||||
const bookingId = row.id;
|
|
||||||
const documents = row.documents;
|
|
||||||
|
|
||||||
// Update each document URL
|
|
||||||
const updatedDocuments = documents.map((doc) => {
|
|
||||||
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
|
|
||||||
// Extract filename from dummy URL
|
|
||||||
const fileName = doc.fileName || doc.filePath.split('/').pop();
|
|
||||||
const documentId = doc.id;
|
|
||||||
|
|
||||||
// Build proper MinIO URL
|
|
||||||
const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`;
|
|
||||||
|
|
||||||
console.log(` Old: ${doc.filePath}`);
|
|
||||||
console.log(` New: ${newUrl}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
filePath: newUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return doc;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the database
|
|
||||||
await client.query(
|
|
||||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
|
||||||
[JSON.stringify(updatedDocuments), bookingId]
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
|
|
||||||
console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`);
|
|
||||||
console.log(` You can upload test files or re-create the bookings with real file uploads.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
console.log('\n👋 Disconnected from database');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixDummyUrls()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Script completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to fix minio hostname in document URLs
|
|
||||||
*
|
|
||||||
* Changes http://minio:9000 to http://localhost:9000
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Client } = require('pg');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
async function fixMinioHostname() {
|
|
||||||
const client = new Client({
|
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
console.log('✅ Connected to database');
|
|
||||||
|
|
||||||
// Find bookings with minio:9000 in URLs
|
|
||||||
const result = await client.query(
|
|
||||||
`SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`);
|
|
||||||
|
|
||||||
let updatedCount = 0;
|
|
||||||
|
|
||||||
for (const row of result.rows) {
|
|
||||||
const bookingId = row.id;
|
|
||||||
const documents = row.documents;
|
|
||||||
|
|
||||||
// Update each document URL
|
|
||||||
const updatedDocuments = documents.map((doc) => {
|
|
||||||
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
|
|
||||||
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
|
|
||||||
|
|
||||||
console.log(` Booking: ${bookingId}`);
|
|
||||||
console.log(` Old: ${doc.filePath}`);
|
|
||||||
console.log(` New: ${newUrl}\n`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
filePath: newUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return doc;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the database
|
|
||||||
await client.query(
|
|
||||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
|
||||||
[JSON.stringify(updatedDocuments), bookingId]
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
console.log('\n👋 Disconnected from database');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixMinioHostname()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Script completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to list all files in MinIO xpeditis-documents bucket
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
|
||||||
|
|
||||||
// Initialize MinIO client
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
region: 'us-east-1',
|
|
||||||
endpoint: MINIO_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function listFiles() {
|
|
||||||
try {
|
|
||||||
console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`);
|
|
||||||
|
|
||||||
let allFiles = [];
|
|
||||||
let continuationToken = null;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const command = new ListObjectsV2Command({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
ContinuationToken: continuationToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
|
||||||
|
|
||||||
if (response.Contents) {
|
|
||||||
allFiles = allFiles.concat(response.Contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
|
||||||
} while (continuationToken);
|
|
||||||
|
|
||||||
console.log(`Found ${allFiles.length} files total:\n`);
|
|
||||||
|
|
||||||
// Group by booking ID
|
|
||||||
const byBooking = {};
|
|
||||||
allFiles.forEach(file => {
|
|
||||||
const parts = file.Key.split('/');
|
|
||||||
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
|
|
||||||
const bookingId = parts[1];
|
|
||||||
if (!byBooking[bookingId]) {
|
|
||||||
byBooking[bookingId] = [];
|
|
||||||
}
|
|
||||||
byBooking[bookingId].push({
|
|
||||||
key: file.Key,
|
|
||||||
size: file.Size,
|
|
||||||
lastModified: file.LastModified,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log(` Other: ${file.Key} (${file.Size} bytes)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nFiles grouped by booking:\n`);
|
|
||||||
Object.entries(byBooking).forEach(([bookingId, files]) => {
|
|
||||||
console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`);
|
|
||||||
files.forEach(file => {
|
|
||||||
const filename = file.key.split('/').pop();
|
|
||||||
console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`);
|
|
||||||
});
|
|
||||||
console.log('');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n📊 Summary:`);
|
|
||||||
console.log(` Total files: ${allFiles.length}`);
|
|
||||||
console.log(` Bookings with files: ${Object.keys(byBooking).length}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listFiles()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Script completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
34
apps/backend/package-lock.json
generated
34
apps/backend/package-lock.json
generated
@ -59,9 +59,7 @@
|
|||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"typeorm": "^0.3.17"
|
||||||
"typeorm": "^0.3.17",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
@ -83,7 +81,6 @@
|
|||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
"eslint-plugin-unused-imports": "^4.3.0",
|
|
||||||
"ioredis-mock": "^8.13.0",
|
"ioredis-mock": "^8.13.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
@ -8214,22 +8211,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-unused-imports": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
|
|
||||||
"eslint": "^9.0.0 || ^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@typescript-eslint/eslint-plugin": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||||
@ -14572,19 +14553,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stripe": {
|
|
||||||
"version": "14.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
|
|
||||||
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": ">=8.1.0",
|
|
||||||
"qs": "^6.11.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
||||||
|
|||||||
@ -75,9 +75,7 @@
|
|||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"typeorm": "^0.3.17"
|
||||||
"typeorm": "^0.3.17",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
@ -99,7 +97,6 @@
|
|||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
"eslint-plugin-unused-imports": "^4.3.0",
|
|
||||||
"ioredis-mock": "^8.13.0",
|
"ioredis-mock": "^8.13.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to restore document references in database from MinIO files
|
|
||||||
*
|
|
||||||
* Scans MinIO for existing files and creates/updates database references
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
|
||||||
const { Client } = require('pg');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
|
||||||
|
|
||||||
// Initialize MinIO client
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
region: 'us-east-1',
|
|
||||||
endpoint: MINIO_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function restoreDocumentReferences() {
|
|
||||||
const pgClient = new Client({
|
|
||||||
host: process.env.DATABASE_HOST || 'localhost',
|
|
||||||
port: process.env.DATABASE_PORT || 5432,
|
|
||||||
user: process.env.DATABASE_USER || 'xpeditis',
|
|
||||||
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
|
|
||||||
database: process.env.DATABASE_NAME || 'xpeditis_dev',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pgClient.connect();
|
|
||||||
console.log('✅ Connected to database\n');
|
|
||||||
|
|
||||||
// Get all MinIO files
|
|
||||||
console.log('📋 Listing files in MinIO...');
|
|
||||||
let allFiles = [];
|
|
||||||
let continuationToken = null;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const command = new ListObjectsV2Command({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
ContinuationToken: continuationToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
|
||||||
|
|
||||||
if (response.Contents) {
|
|
||||||
allFiles = allFiles.concat(response.Contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
continuationToken = response.NextContinuationToken;
|
|
||||||
} while (continuationToken);
|
|
||||||
|
|
||||||
console.log(` Found ${allFiles.length} files in MinIO\n`);
|
|
||||||
|
|
||||||
// Group files by booking ID
|
|
||||||
const filesByBooking = {};
|
|
||||||
allFiles.forEach(file => {
|
|
||||||
const parts = file.Key.split('/');
|
|
||||||
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
|
|
||||||
const bookingId = parts[1];
|
|
||||||
const documentId = parts[2].split('-')[0]; // Extract UUID from filename
|
|
||||||
const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash)
|
|
||||||
|
|
||||||
if (!filesByBooking[bookingId]) {
|
|
||||||
filesByBooking[bookingId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
filesByBooking[bookingId].push({
|
|
||||||
key: file.Key,
|
|
||||||
documentId: documentId,
|
|
||||||
fileName: fileName,
|
|
||||||
size: file.Size,
|
|
||||||
lastModified: file.LastModified,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`);
|
|
||||||
|
|
||||||
let updatedCount = 0;
|
|
||||||
let createdDocsCount = 0;
|
|
||||||
|
|
||||||
for (const [bookingId, files] of Object.entries(filesByBooking)) {
|
|
||||||
// Check if booking exists
|
|
||||||
const bookingResult = await pgClient.query(
|
|
||||||
'SELECT id, documents FROM csv_bookings WHERE id = $1',
|
|
||||||
[bookingId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (bookingResult.rows.length === 0) {
|
|
||||||
console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const booking = bookingResult.rows[0];
|
|
||||||
const existingDocs = booking.documents || [];
|
|
||||||
|
|
||||||
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
|
|
||||||
console.log(` Existing documents in DB: ${existingDocs.length}`);
|
|
||||||
console.log(` Files in MinIO: ${files.length}`);
|
|
||||||
|
|
||||||
// Create document references for files
|
|
||||||
const newDocuments = files.map(file => {
|
|
||||||
// Determine MIME type from file extension
|
|
||||||
const ext = file.fileName.split('.').pop().toLowerCase();
|
|
||||||
const mimeTypeMap = {
|
|
||||||
pdf: 'application/pdf',
|
|
||||||
png: 'image/png',
|
|
||||||
jpg: 'image/jpeg',
|
|
||||||
jpeg: 'image/jpeg',
|
|
||||||
txt: 'text/plain',
|
|
||||||
};
|
|
||||||
const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
|
|
||||||
|
|
||||||
// Determine document type
|
|
||||||
let docType = 'OTHER';
|
|
||||||
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) {
|
|
||||||
docType = 'BILL_OF_LADING';
|
|
||||||
} else if (file.fileName.toLowerCase().includes('packing-list')) {
|
|
||||||
docType = 'PACKING_LIST';
|
|
||||||
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) {
|
|
||||||
docType = 'COMMERCIAL_INVOICE';
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = {
|
|
||||||
id: file.documentId,
|
|
||||||
type: docType,
|
|
||||||
fileName: file.fileName,
|
|
||||||
filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`,
|
|
||||||
mimeType: mimeType,
|
|
||||||
size: file.size,
|
|
||||||
uploadedAt: file.lastModified.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`);
|
|
||||||
return doc;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the booking with new document references
|
|
||||||
await pgClient.query(
|
|
||||||
'UPDATE csv_bookings SET documents = $1 WHERE id = $2',
|
|
||||||
[JSON.stringify(newDocuments), bookingId]
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
createdDocsCount += newDocuments.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n📊 Summary:`);
|
|
||||||
console.log(` Bookings updated: ${updatedCount}`);
|
|
||||||
console.log(` Document references created: ${createdDocsCount}`);
|
|
||||||
console.log(`\n✅ Document references restored`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await pgClient.end();
|
|
||||||
console.log('\n👋 Disconnected from database');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreDocumentReferences()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Script completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to list all Stripe prices
|
|
||||||
* Run with: node scripts/list-stripe-prices.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const Stripe = require('stripe');
|
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
|
|
||||||
|
|
||||||
async function listPrices() {
|
|
||||||
console.log('Fetching Stripe prices...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const prices = await stripe.prices.list({ limit: 50, expand: ['data.product'] });
|
|
||||||
|
|
||||||
if (prices.data.length === 0) {
|
|
||||||
console.log('No prices found. You need to create prices in Stripe Dashboard.');
|
|
||||||
console.log('\nSteps:');
|
|
||||||
console.log('1. Go to https://dashboard.stripe.com/products');
|
|
||||||
console.log('2. Click on each product (Starter, Pro, Enterprise)');
|
|
||||||
console.log('3. Add a recurring price (monthly and yearly)');
|
|
||||||
console.log('4. Copy the Price IDs (format: price_xxxxx)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Available Prices:\n');
|
|
||||||
console.log('='.repeat(100));
|
|
||||||
|
|
||||||
for (const price of prices.data) {
|
|
||||||
const product = typeof price.product === 'object' ? price.product : { name: price.product };
|
|
||||||
const interval = price.recurring ? `${price.recurring.interval}ly` : 'one-time';
|
|
||||||
const amount = (price.unit_amount / 100).toFixed(2);
|
|
||||||
|
|
||||||
console.log(`Price ID: ${price.id}`);
|
|
||||||
console.log(`Product: ${product.name || product.id}`);
|
|
||||||
console.log(`Amount: ${amount} ${price.currency.toUpperCase()}`);
|
|
||||||
console.log(`Interval: ${interval}`);
|
|
||||||
console.log(`Active: ${price.active}`);
|
|
||||||
console.log('-'.repeat(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n\nCopy the relevant Price IDs to your .env file:');
|
|
||||||
console.log('STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching prices:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listPrices();
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to set MinIO bucket policy for public read access
|
|
||||||
*
|
|
||||||
* This allows documents to be downloaded directly via URL without authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
|
|
||||||
const BUCKET_NAME = 'xpeditis-documents';
|
|
||||||
|
|
||||||
// Initialize MinIO client
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
region: 'us-east-1',
|
|
||||||
endpoint: MINIO_ENDPOINT,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function setBucketPolicy() {
|
|
||||||
try {
|
|
||||||
// Policy to allow public read access to all objects in the bucket
|
|
||||||
const policy = {
|
|
||||||
Version: '2012-10-17',
|
|
||||||
Statement: [
|
|
||||||
{
|
|
||||||
Effect: 'Allow',
|
|
||||||
Principal: '*',
|
|
||||||
Action: ['s3:GetObject'],
|
|
||||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📋 Setting bucket policy for:', BUCKET_NAME);
|
|
||||||
console.log('Policy:', JSON.stringify(policy, null, 2));
|
|
||||||
|
|
||||||
// Set the bucket policy
|
|
||||||
await s3Client.send(
|
|
||||||
new PutBucketPolicyCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Policy: JSON.stringify(policy),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('\n✅ Bucket policy set successfully!');
|
|
||||||
console.log(` All objects in ${BUCKET_NAME} are now publicly readable`);
|
|
||||||
|
|
||||||
// Verify the policy was set
|
|
||||||
console.log('\n🔍 Verifying bucket policy...');
|
|
||||||
const getPolicy = await s3Client.send(
|
|
||||||
new GetBucketPolicyCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('✅ Current policy:', getPolicy.Policy);
|
|
||||||
|
|
||||||
console.log('\n📝 Note: This allows public read access to all documents.');
|
|
||||||
console.log(' For production, consider using signed URLs instead.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setBucketPolicy()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n✅ Script completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n❌ Script failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -18,17 +18,13 @@ import { NotificationsModule } from './application/notifications/notifications.m
|
|||||||
import { WebhooksModule } from './application/webhooks/webhooks.module';
|
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 { LogsModule } from './application/logs/logs.module';
|
|
||||||
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
|
||||||
import { ApiKeysModule } from './application/api-keys/api-keys.module';
|
|
||||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||||
import { SecurityModule } from './infrastructure/security/security.module';
|
import { SecurityModule } from './infrastructure/security/security.module';
|
||||||
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
|
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
|
||||||
|
|
||||||
// Import global guards
|
// Import global guards
|
||||||
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
|
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
|
||||||
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -59,30 +55,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
SMTP_PASS: Joi.string().required(),
|
SMTP_PASS: Joi.string().required(),
|
||||||
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||||
SMTP_SECURE: Joi.boolean().default(false),
|
SMTP_SECURE: Joi.boolean().default(false),
|
||||||
// Stripe Configuration (optional for development)
|
|
||||||
STRIPE_SECRET_KEY: Joi.string().optional(),
|
|
||||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
|
||||||
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
useFactory: (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => ({
|
||||||
const isDev = configService.get('NODE_ENV') === 'development';
|
|
||||||
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail)
|
|
||||||
const forceJson = configService.get('LOG_FORMAT') === 'json';
|
|
||||||
const usePretty = isDev && !forceJson;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
transport: usePretty
|
transport:
|
||||||
|
configService.get('NODE_ENV') === 'development'
|
||||||
? {
|
? {
|
||||||
target: 'pino-pretty',
|
target: 'pino-pretty',
|
||||||
options: {
|
options: {
|
||||||
@ -92,21 +73,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],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -146,18 +115,14 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
GDPRModule,
|
GDPRModule,
|
||||||
AdminModule,
|
|
||||||
SubscriptionsModule,
|
|
||||||
ApiKeysModule,
|
|
||||||
LogsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
|
// Global JWT authentication guard
|
||||||
// All routes are protected by default, use @Public() to bypass
|
// All routes are protected by default, use @Public() to bypass
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: ApiKeyOrJwtGuard,
|
useClass: JwtAuthGuard,
|
||||||
},
|
},
|
||||||
// Global rate limiting guard
|
// Global rate limiting guard
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
|
|
||||||
// Controller
|
|
||||||
import { AdminController } from '../controllers/admin.controller';
|
|
||||||
|
|
||||||
// ORM Entities
|
|
||||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
|
||||||
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
|
||||||
import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
|
||||||
import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
|
||||||
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
|
||||||
|
|
||||||
// Repository tokens
|
|
||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
|
||||||
|
|
||||||
// SIRET verification
|
|
||||||
import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port';
|
|
||||||
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter';
|
|
||||||
|
|
||||||
// CSV Booking Service
|
|
||||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
|
||||||
|
|
||||||
// Email
|
|
||||||
import { EmailModule } from '@infrastructure/email/email.module';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Module
|
|
||||||
*
|
|
||||||
* Provides admin-only endpoints for managing all data in the system.
|
|
||||||
* All endpoints require ADMIN role.
|
|
||||||
*/
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
|
||||||
ConfigModule,
|
|
||||||
CsvBookingsModule,
|
|
||||||
EmailModule,
|
|
||||||
],
|
|
||||||
controllers: [AdminController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: USER_REPOSITORY,
|
|
||||||
useClass: TypeOrmUserRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ORGANIZATION_REPOSITORY,
|
|
||||||
useClass: TypeOrmOrganizationRepository,
|
|
||||||
},
|
|
||||||
TypeOrmCsvBookingRepository,
|
|
||||||
{
|
|
||||||
provide: SIRET_VERIFICATION_PORT,
|
|
||||||
useClass: PappersSiretAdapter,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AdminModule {}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiSecurity,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
|
|
||||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
|
||||||
import { RequiresFeature } from '../decorators/requires-feature.decorator';
|
|
||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
|
||||||
|
|
||||||
import { ApiKeysService } from './api-keys.service';
|
|
||||||
import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto';
|
|
||||||
|
|
||||||
@ApiTags('API Keys')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiSecurity('x-api-key')
|
|
||||||
@UseGuards(FeatureFlagGuard)
|
|
||||||
@RequiresFeature('api_access')
|
|
||||||
@Controller('api-keys')
|
|
||||||
export class ApiKeysController {
|
|
||||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Générer une nouvelle clé API',
|
|
||||||
description:
|
|
||||||
"Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 201,
|
|
||||||
description: 'Clé créée avec succès. La clé complète est dans le champ `fullKey`.',
|
|
||||||
type: CreateApiKeyResultDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 403, description: 'Abonnement Gold ou Platinium requis' })
|
|
||||||
async create(
|
|
||||||
@CurrentUser() user: { id: string; organizationId: string },
|
|
||||||
@Body() dto: CreateApiKeyDto
|
|
||||||
): Promise<CreateApiKeyResultDto> {
|
|
||||||
return this.apiKeysService.generateApiKey(user.id, user.organizationId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Lister les clés API',
|
|
||||||
description:
|
|
||||||
"Retourne toutes les clés API de l'organisation. Les clés complètes ne sont jamais exposées — uniquement le préfixe.",
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, type: [ApiKeyDto] })
|
|
||||||
async list(@CurrentUser() user: { organizationId: string }): Promise<ApiKeyDto[]> {
|
|
||||||
return this.apiKeysService.listApiKeys(user.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Révoquer une clé API',
|
|
||||||
description: 'Désactive immédiatement la clé API. Cette action est irréversible.',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 204, description: 'Clé révoquée' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Clé introuvable' })
|
|
||||||
async revoke(
|
|
||||||
@CurrentUser() user: { organizationId: string },
|
|
||||||
@Param('id', ParseUUIDPipe) keyId: string
|
|
||||||
): Promise<void> {
|
|
||||||
return this.apiKeysService.revokeApiKey(keyId, user.organizationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
import { ApiKeysController } from './api-keys.controller';
|
|
||||||
import { ApiKeysService } from './api-keys.service';
|
|
||||||
|
|
||||||
// ORM Entities
|
|
||||||
import { ApiKeyOrmEntity } from '@infrastructure/persistence/typeorm/entities/api-key.orm-entity';
|
|
||||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
import { TypeOrmApiKeyRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository';
|
|
||||||
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
|
||||||
|
|
||||||
// Repository tokens
|
|
||||||
import { API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository';
|
|
||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
|
|
||||||
// Subscriptions (provides SUBSCRIPTION_REPOSITORY)
|
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|
||||||
|
|
||||||
// Feature flag guard needs SubscriptionRepository (injected via SubscriptionsModule)
|
|
||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
|
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
|
||||||
controllers: [ApiKeysController],
|
|
||||||
providers: [
|
|
||||||
ApiKeysService,
|
|
||||||
FeatureFlagGuard,
|
|
||||||
{
|
|
||||||
provide: API_KEY_REPOSITORY,
|
|
||||||
useClass: TypeOrmApiKeyRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: USER_REPOSITORY,
|
|
||||||
useClass: TypeOrmUserRepository,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [ApiKeysService],
|
|
||||||
})
|
|
||||||
export class ApiKeysModule {}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* ApiKeys Service
|
|
||||||
*
|
|
||||||
* Manages API key lifecycle:
|
|
||||||
* - Generation (GOLD/PLATINIUM subscribers only)
|
|
||||||
* - Listing (masked — prefix only)
|
|
||||||
* - Revocation
|
|
||||||
* - Validation for inbound API key authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import { ApiKey } from '@domain/entities/api-key.entity';
|
|
||||||
import { ApiKeyRepository, API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
import {
|
|
||||||
SubscriptionRepository,
|
|
||||||
SUBSCRIPTION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/subscription.repository';
|
|
||||||
|
|
||||||
import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto';
|
|
||||||
|
|
||||||
/** Shape of request.user populated when an API key is used. */
|
|
||||||
export interface ApiKeyUserContext {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
organizationId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
plan: string;
|
|
||||||
planFeatures: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY_PREFIX_DISPLAY_LENGTH = 18; // "xped_live_" (10) + 8 hex chars
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApiKeysService {
|
|
||||||
private readonly logger = new Logger(ApiKeysService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(API_KEY_REPOSITORY)
|
|
||||||
private readonly apiKeyRepository: ApiKeyRepository,
|
|
||||||
@Inject(USER_REPOSITORY)
|
|
||||||
private readonly userRepository: UserRepository,
|
|
||||||
@Inject(SUBSCRIPTION_REPOSITORY)
|
|
||||||
private readonly subscriptionRepository: SubscriptionRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a new API key for the given user / organisation.
|
|
||||||
* The full raw key is returned exactly once — it is never persisted.
|
|
||||||
*/
|
|
||||||
async generateApiKey(
|
|
||||||
userId: string,
|
|
||||||
organizationId: string,
|
|
||||||
dto: CreateApiKeyDto
|
|
||||||
): Promise<CreateApiKeyResultDto> {
|
|
||||||
await this.assertApiAccessPlan(organizationId);
|
|
||||||
|
|
||||||
const rawKey = this.buildRawKey();
|
|
||||||
const keyHash = this.hashKey(rawKey);
|
|
||||||
const keyPrefix = rawKey.substring(0, KEY_PREFIX_DISPLAY_LENGTH);
|
|
||||||
|
|
||||||
const apiKey = ApiKey.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
organizationId,
|
|
||||||
userId,
|
|
||||||
name: dto.name,
|
|
||||||
keyHash,
|
|
||||||
keyPrefix,
|
|
||||||
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await this.apiKeyRepository.save(apiKey);
|
|
||||||
|
|
||||||
this.logger.log(`API key created: ${saved.id} for org ${organizationId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: saved.id,
|
|
||||||
name: saved.name,
|
|
||||||
keyPrefix: saved.keyPrefix,
|
|
||||||
isActive: saved.isActive,
|
|
||||||
lastUsedAt: saved.lastUsedAt,
|
|
||||||
expiresAt: saved.expiresAt,
|
|
||||||
createdAt: saved.createdAt,
|
|
||||||
fullKey: rawKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all API keys for an organisation. Never exposes key hashes.
|
|
||||||
*/
|
|
||||||
async listApiKeys(organizationId: string): Promise<ApiKeyDto[]> {
|
|
||||||
const keys = await this.apiKeyRepository.findByOrganizationId(organizationId);
|
|
||||||
return keys.map(k => this.toDto(k));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke (deactivate) an API key.
|
|
||||||
*/
|
|
||||||
async revokeApiKey(keyId: string, organizationId: string): Promise<void> {
|
|
||||||
const key = await this.apiKeyRepository.findById(keyId);
|
|
||||||
|
|
||||||
if (!key || key.organizationId !== organizationId) {
|
|
||||||
throw new NotFoundException('Clé API introuvable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const revoked = key.revoke();
|
|
||||||
await this.apiKeyRepository.save(revoked);
|
|
||||||
|
|
||||||
this.logger.log(`API key revoked: ${keyId} for org ${organizationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an inbound raw API key and return the user context.
|
|
||||||
* Returns null if the key is invalid, expired, or the plan is insufficient.
|
|
||||||
* Also asynchronously updates lastUsedAt.
|
|
||||||
*/
|
|
||||||
async validateAndGetUser(rawKey: string): Promise<ApiKeyUserContext | null> {
|
|
||||||
if (!rawKey?.startsWith('xped_live_')) return null;
|
|
||||||
|
|
||||||
const keyHash = this.hashKey(rawKey);
|
|
||||||
const apiKey = await this.apiKeyRepository.findByKeyHash(keyHash);
|
|
||||||
|
|
||||||
if (!apiKey || !apiKey.isValid()) return null;
|
|
||||||
|
|
||||||
// Real-time plan check — in case the org downgraded after key creation
|
|
||||||
const subscription = await this.subscriptionRepository.findByOrganizationId(
|
|
||||||
apiKey.organizationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription || !subscription.hasFeature('api_access')) {
|
|
||||||
this.logger.warn(
|
|
||||||
`API key used but org ${apiKey.organizationId} no longer has api_access feature`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update lastUsedAt asynchronously — don't block the request
|
|
||||||
this.apiKeyRepository
|
|
||||||
.save(apiKey.recordUsage())
|
|
||||||
.catch(err => this.logger.warn(`Failed to update lastUsedAt for key ${apiKey.id}: ${err}`));
|
|
||||||
|
|
||||||
const user = await this.userRepository.findById(apiKey.userId);
|
|
||||||
if (!user || !user.isActive) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
organizationId: user.organizationId,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
plan: subscription.plan.value,
|
|
||||||
planFeatures: [...subscription.plan.planFeatures],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async assertApiAccessPlan(organizationId: string): Promise<void> {
|
|
||||||
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
|
|
||||||
|
|
||||||
if (!subscription || !subscription.hasFeature('api_access')) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
"L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format: xped_live_<64 random hex chars> */
|
|
||||||
private buildRawKey(): string {
|
|
||||||
return `xped_live_${crypto.randomBytes(32).toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashKey(rawKey: string): string {
|
|
||||||
return crypto.createHash('sha256').update(rawKey).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
private toDto(apiKey: ApiKey): ApiKeyDto {
|
|
||||||
return {
|
|
||||||
id: apiKey.id,
|
|
||||||
name: apiKey.name,
|
|
||||||
keyPrefix: apiKey.keyPrefix,
|
|
||||||
isActive: apiKey.isActive,
|
|
||||||
lastUsedAt: apiKey.lastUsedAt,
|
|
||||||
expiresAt: apiKey.expiresAt,
|
|
||||||
createdAt: apiKey.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,11 +17,9 @@ import { TypeOrmInvitationTokenRepository } from '../../infrastructure/persisten
|
|||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity';
|
import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity';
|
||||||
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
|
|
||||||
import { InvitationService } from '../services/invitation.service';
|
import { InvitationService } from '../services/invitation.service';
|
||||||
import { InvitationsController } from '../controllers/invitations.controller';
|
import { InvitationsController } from '../controllers/invitations.controller';
|
||||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -41,13 +39,10 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// 👇 Add this to register TypeORM repositories
|
// 👇 Add this to register TypeORM repositories
|
||||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]),
|
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity]),
|
||||||
|
|
||||||
// Email module for sending invitations
|
// Email module for sending invitations
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
|
||||||
// Subscriptions module for license checks
|
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AuthController, InvitationsController],
|
controllers: [AuthController, InvitationsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -5,34 +5,23 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Inject,
|
Inject,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository, IsNull } from 'typeorm';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { User, UserRole } from '@domain/entities/user.entity';
|
import { User, UserRole } from '@domain/entities/user.entity';
|
||||||
import {
|
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
OrganizationRepository,
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
ORGANIZATION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/organization.repository';
|
|
||||||
import { Organization } from '@domain/entities/organization.entity';
|
|
||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed';
|
||||||
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string; // user ID
|
sub: string; // user ID
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
|
||||||
planFeatures?: string[]; // plan feature flags
|
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,13 +34,8 @@ export class AuthService {
|
|||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
private readonly organizationRepository: OrganizationRepository,
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
@Inject(EMAIL_PORT)
|
|
||||||
private readonly emailService: EmailPort,
|
|
||||||
@InjectRepository(PasswordResetTokenOrmEntity)
|
|
||||||
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService
|
||||||
private readonly subscriptionService: SubscriptionService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,16 +98,6 @@ export class AuthService {
|
|||||||
|
|
||||||
const savedUser = await this.userRepository.save(user);
|
const savedUser = await this.userRepository.save(user);
|
||||||
|
|
||||||
// Allocate a license for the new user
|
|
||||||
try {
|
|
||||||
await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId);
|
|
||||||
this.logger.log(`License allocated for user: ${email}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to allocate license for user ${email}:`, error);
|
|
||||||
// Note: We don't throw here because the user is already created.
|
|
||||||
// The license check should happen before invitation.
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await this.generateTokens(savedUser);
|
const tokens = await this.generateTokens(savedUser);
|
||||||
|
|
||||||
this.logger.log(`User registered successfully: ${email}`);
|
this.logger.log(`User registered successfully: ${email}`);
|
||||||
@ -215,85 +189,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate password reset — generates token and sends email
|
|
||||||
*/
|
|
||||||
async forgotPassword(email: string): Promise<void> {
|
|
||||||
this.logger.log(`Password reset requested for: ${email}`);
|
|
||||||
|
|
||||||
const user = await this.userRepository.findByEmail(email);
|
|
||||||
|
|
||||||
// Silently succeed if user not found (security: don't reveal user existence)
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate any existing unused tokens for this user
|
|
||||||
await this.passwordResetTokenRepository.update(
|
|
||||||
{ userId: user.id, usedAt: IsNull() },
|
|
||||||
{ usedAt: new Date() }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate a secure random token
|
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
||||||
|
|
||||||
await this.passwordResetTokenRepository.save({
|
|
||||||
userId: user.id,
|
|
||||||
token,
|
|
||||||
expiresAt,
|
|
||||||
usedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.emailService.sendPasswordResetEmail(email, token);
|
|
||||||
|
|
||||||
this.logger.log(`Password reset email sent to: ${email}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset password using token from email
|
|
||||||
*/
|
|
||||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
|
||||||
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } });
|
|
||||||
|
|
||||||
if (!resetToken) {
|
|
||||||
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetToken.usedAt) {
|
|
||||||
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetToken.expiresAt < new Date()) {
|
|
||||||
throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.userRepository.findById(resetToken.userId);
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
throw new NotFoundException('Utilisateur introuvable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(newPassword, {
|
|
||||||
type: argon2.argon2id,
|
|
||||||
memoryCost: 65536,
|
|
||||||
timeCost: 3,
|
|
||||||
parallelism: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update password (mutates in place)
|
|
||||||
user.updatePassword(passwordHash);
|
|
||||||
await this.userRepository.save(user);
|
|
||||||
|
|
||||||
// Mark token as used
|
|
||||||
await this.passwordResetTokenRepository.update(
|
|
||||||
{ id: resetToken.id },
|
|
||||||
{ usedAt: new Date() }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Password reset successfully for user: ${user.email}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate user from JWT payload
|
* Validate user from JWT payload
|
||||||
*/
|
*/
|
||||||
@ -311,40 +206,11 @@ export class AuthService {
|
|||||||
* Generate access and refresh tokens
|
* Generate access and refresh tokens
|
||||||
*/
|
*/
|
||||||
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
// ADMIN users always get PLATINIUM plan with no expiration
|
|
||||||
let plan = 'BRONZE';
|
|
||||||
let planFeatures: string[] = [];
|
|
||||||
|
|
||||||
if (user.role === UserRole.ADMIN) {
|
|
||||||
plan = 'PLATINIUM';
|
|
||||||
planFeatures = [
|
|
||||||
'dashboard',
|
|
||||||
'wiki',
|
|
||||||
'user_management',
|
|
||||||
'csv_export',
|
|
||||||
'api_access',
|
|
||||||
'custom_interface',
|
|
||||||
'dedicated_kam',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
|
||||||
user.organizationId
|
|
||||||
);
|
|
||||||
plan = subscription.plan.value;
|
|
||||||
planFeatures = [...subscription.plan.planFeatures];
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessPayload: JwtPayload = {
|
const accessPayload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
plan,
|
|
||||||
planFeatures,
|
|
||||||
type: 'access',
|
type: 'access',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -353,8 +219,6 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
plan,
|
|
||||||
planFeatures,
|
|
||||||
type: 'refresh',
|
type: 'refresh',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -424,8 +288,6 @@ export class AuthService {
|
|||||||
name: organizationData.name,
|
name: organizationData.name,
|
||||||
type: organizationData.type,
|
type: organizationData.type,
|
||||||
scac: organizationData.scac,
|
scac: organizationData.scac,
|
||||||
siren: organizationData.siren,
|
|
||||||
siret: organizationData.siret,
|
|
||||||
address: {
|
address: {
|
||||||
street: organizationData.street,
|
street: organizationData.street,
|
||||||
city: organizationData.city,
|
city: organizationData.city,
|
||||||
@ -439,9 +301,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const savedOrganization = await this.organizationRepository.save(newOrganization);
|
const savedOrganization = await this.organizationRepository.save(newOrganization);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`);
|
||||||
`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return savedOrganization.id;
|
return savedOrganization.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,15 @@ import { BookingsController } from '../controllers/bookings.controller';
|
|||||||
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
||||||
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
|
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
|
||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
|
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
|
||||||
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
|
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
|
||||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||||
import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
|
||||||
|
|
||||||
// Import ORM entities
|
// Import ORM entities
|
||||||
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||||
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
|
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
|
||||||
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
|
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
|
||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
|
||||||
|
|
||||||
// Import services and domain
|
// Import services and domain
|
||||||
import { BookingService } from '@domain/services/booking.service';
|
import { BookingService } from '@domain/services/booking.service';
|
||||||
@ -32,7 +29,6 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { WebhooksModule } from '../webhooks/webhooks.module';
|
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookings Module
|
* Bookings Module
|
||||||
@ -51,7 +47,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
ContainerOrmEntity,
|
ContainerOrmEntity,
|
||||||
RateQuoteOrmEntity,
|
RateQuoteOrmEntity,
|
||||||
UserOrmEntity,
|
UserOrmEntity,
|
||||||
CsvBookingOrmEntity,
|
|
||||||
]),
|
]),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
PdfModule,
|
PdfModule,
|
||||||
@ -59,7 +54,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [
|
providers: [
|
||||||
@ -79,10 +73,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
provide: USER_REPOSITORY,
|
provide: USER_REPOSITORY,
|
||||||
useClass: TypeOrmUserRepository,
|
useClass: TypeOrmUserRepository,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: SHIPMENT_COUNTER_PORT,
|
|
||||||
useClass: TypeOrmShipmentCounterRepository,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
exports: [BOOKING_REPOSITORY],
|
exports: [BOOKING_REPOSITORY],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,914 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Patch,
|
|
||||||
Delete,
|
|
||||||
Param,
|
|
||||||
Body,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Logger,
|
|
||||||
UsePipes,
|
|
||||||
ValidationPipe,
|
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
UseGuards,
|
|
||||||
Inject,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiNotFoundResponse,
|
|
||||||
ApiParam,
|
|
||||||
ApiBearerAuth,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
|
||||||
|
|
||||||
// User imports
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
import { UserMapper } from '../mappers/user.mapper';
|
|
||||||
import { UpdateUserDto, UserResponseDto, UserListResponseDto } from '../dto/user.dto';
|
|
||||||
|
|
||||||
// Organization imports
|
|
||||||
import {
|
|
||||||
OrganizationRepository,
|
|
||||||
ORGANIZATION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/organization.repository';
|
|
||||||
import { OrganizationMapper } from '../mappers/organization.mapper';
|
|
||||||
import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/organization.dto';
|
|
||||||
|
|
||||||
// CSV Booking imports
|
|
||||||
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
|
||||||
|
|
||||||
// SIRET verification imports
|
|
||||||
import {
|
|
||||||
SiretVerificationPort,
|
|
||||||
SIRET_VERIFICATION_PORT,
|
|
||||||
} from '@domain/ports/out/siret-verification.port';
|
|
||||||
|
|
||||||
// Email imports
|
|
||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Controller
|
|
||||||
*
|
|
||||||
* Dedicated controller for admin-only endpoints that provide access to ALL data
|
|
||||||
* in the database without organization filtering.
|
|
||||||
*
|
|
||||||
* All endpoints require ADMIN role.
|
|
||||||
*/
|
|
||||||
@ApiTags('Admin')
|
|
||||||
@Controller('admin')
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles('admin')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
export class AdminController {
|
|
||||||
private readonly logger = new Logger(AdminController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
|
||||||
private readonly organizationRepository: OrganizationRepository,
|
|
||||||
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
|
|
||||||
private readonly csvBookingService: CsvBookingService,
|
|
||||||
@Inject(SIRET_VERIFICATION_PORT)
|
|
||||||
private readonly siretVerificationPort: SiretVerificationPort,
|
|
||||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ==================== USERS ENDPOINTS ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ALL users from database (admin only)
|
|
||||||
*
|
|
||||||
* Returns all users regardless of status (active/inactive) or organization
|
|
||||||
*/
|
|
||||||
@Get('users')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get all users (Admin only)',
|
|
||||||
description:
|
|
||||||
'Retrieve ALL users from the database without any filters. Includes active and inactive users from all organizations.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'All users retrieved successfully',
|
|
||||||
type: UserListResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - missing or invalid token',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin role',
|
|
||||||
})
|
|
||||||
async getAllUsers(@CurrentUser() user: UserPayload): Promise<UserListResponseDto> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL users from database`);
|
|
||||||
|
|
||||||
let users = await this.userRepository.findAll();
|
|
||||||
|
|
||||||
// Security: Non-admin users (MANAGER and below) cannot see ADMIN users
|
|
||||||
if (user.role !== 'ADMIN') {
|
|
||||||
users = users.filter(u => u.role !== 'ADMIN');
|
|
||||||
this.logger.log(`[SECURITY] Non-admin user ${user.email} - filtered out ADMIN users`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userDtos = UserMapper.toDtoArray(users);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] Retrieved ${users.length} users from database`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: userDtos,
|
|
||||||
total: users.length,
|
|
||||||
page: 1,
|
|
||||||
pageSize: users.length,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user by ID (admin only)
|
|
||||||
*/
|
|
||||||
@Get('users/:id')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get user by ID (Admin only)',
|
|
||||||
description: 'Retrieve a specific user by ID',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'User ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'User retrieved successfully',
|
|
||||||
type: UserResponseDto,
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'User not found',
|
|
||||||
})
|
|
||||||
async getUserById(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<UserResponseDto> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching user: ${id}`);
|
|
||||||
|
|
||||||
const foundUser = await this.userRepository.findById(id);
|
|
||||||
if (!foundUser) {
|
|
||||||
throw new NotFoundException(`User ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return UserMapper.toDto(foundUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user (admin only)
|
|
||||||
*/
|
|
||||||
@Patch('users/:id')
|
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Update user (Admin only)',
|
|
||||||
description: 'Update user details (any user, any organization)',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'User ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'User updated successfully',
|
|
||||||
type: UserResponseDto,
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'User not found',
|
|
||||||
})
|
|
||||||
async updateUser(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: UpdateUserDto,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<UserResponseDto> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Updating user: ${id}`);
|
|
||||||
|
|
||||||
const foundUser = await this.userRepository.findById(id);
|
|
||||||
if (!foundUser) {
|
|
||||||
throw new NotFoundException(`User ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security: Prevent users from changing their own role
|
|
||||||
if (dto.role && id === user.id) {
|
|
||||||
this.logger.warn(`[SECURITY] User ${user.email} attempted to change their own role`);
|
|
||||||
throw new BadRequestException('You cannot change your own role');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates
|
|
||||||
if (dto.firstName) {
|
|
||||||
foundUser.updateFirstName(dto.firstName);
|
|
||||||
}
|
|
||||||
if (dto.lastName) {
|
|
||||||
foundUser.updateLastName(dto.lastName);
|
|
||||||
}
|
|
||||||
if (dto.role) {
|
|
||||||
foundUser.updateRole(dto.role);
|
|
||||||
}
|
|
||||||
if (dto.isActive !== undefined) {
|
|
||||||
if (dto.isActive) {
|
|
||||||
foundUser.activate();
|
|
||||||
} else {
|
|
||||||
foundUser.deactivate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await this.userRepository.update(foundUser);
|
|
||||||
this.logger.log(`[ADMIN] User updated successfully: ${updatedUser.id}`);
|
|
||||||
|
|
||||||
return UserMapper.toDto(updatedUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete user (admin only)
|
|
||||||
*/
|
|
||||||
@Delete('users/:id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Delete user (Admin only)',
|
|
||||||
description: 'Permanently delete a user from the database',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'User ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NO_CONTENT,
|
|
||||||
description: 'User deleted successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'User not found',
|
|
||||||
})
|
|
||||||
async deleteUser(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<void> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`);
|
|
||||||
|
|
||||||
const foundUser = await this.userRepository.findById(id);
|
|
||||||
if (!foundUser) {
|
|
||||||
throw new NotFoundException(`User ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.userRepository.deleteById(id);
|
|
||||||
this.logger.log(`[ADMIN] User deleted successfully: ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== ORGANIZATIONS ENDPOINTS ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ALL organizations from database (admin only)
|
|
||||||
*
|
|
||||||
* Returns all organizations regardless of status (active/inactive)
|
|
||||||
*/
|
|
||||||
@Get('organizations')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get all organizations (Admin only)',
|
|
||||||
description:
|
|
||||||
'Retrieve ALL organizations from the database without any filters. Includes active and inactive organizations.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'All organizations retrieved successfully',
|
|
||||||
type: OrganizationListResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - missing or invalid token',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin role',
|
|
||||||
})
|
|
||||||
async getAllOrganizations(
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<OrganizationListResponseDto> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL organizations from database`);
|
|
||||||
|
|
||||||
const organizations = await this.organizationRepository.findAll();
|
|
||||||
const orgDtos = OrganizationMapper.toDtoArray(organizations);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] Retrieved ${organizations.length} organizations from database`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
organizations: orgDtos,
|
|
||||||
total: organizations.length,
|
|
||||||
page: 1,
|
|
||||||
pageSize: organizations.length,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get organization by ID (admin only)
|
|
||||||
*/
|
|
||||||
@Get('organizations/:id')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get organization by ID (Admin only)',
|
|
||||||
description: 'Retrieve a specific organization by ID',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Organization ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Organization retrieved successfully',
|
|
||||||
type: OrganizationResponseDto,
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'Organization not found',
|
|
||||||
})
|
|
||||||
async getOrganizationById(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<OrganizationResponseDto> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return OrganizationMapper.toDto(organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify SIRET number for an organization (admin only)
|
|
||||||
*
|
|
||||||
* Calls Pappers API to verify the SIRET, then marks the organization as verified.
|
|
||||||
*/
|
|
||||||
@Post('organizations/:id/verify-siret')
|
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Verify organization SIRET (Admin only)',
|
|
||||||
description:
|
|
||||||
'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Organization ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'SIRET verification result',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
verified: { type: 'boolean' },
|
|
||||||
companyName: { type: 'string' },
|
|
||||||
address: { type: 'string' },
|
|
||||||
message: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'Organization not found',
|
|
||||||
})
|
|
||||||
async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const siret = organization.siret;
|
|
||||||
if (!siret) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Organization has no SIRET number. Please set a SIRET number before verification.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.siretVerificationPort.verify(siret);
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`);
|
|
||||||
return {
|
|
||||||
verified: false,
|
|
||||||
message: `Le numero SIRET ${siret} est invalide ou introuvable.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as verified and save
|
|
||||||
organization.markSiretVerified();
|
|
||||||
await this.organizationRepository.update(organization);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
verified: true,
|
|
||||||
companyName: result.companyName,
|
|
||||||
address: result.address,
|
|
||||||
message: `SIRET ${siret} verifie avec succes.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually approve SIRET/SIREN for an organization (admin only)
|
|
||||||
*
|
|
||||||
* Marks the organization's SIRET as verified without calling the external API.
|
|
||||||
*/
|
|
||||||
@Post('organizations/:id/approve-siret')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Approve SIRET/SIREN (Admin only)',
|
|
||||||
description:
|
|
||||||
'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'SIRET approved successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({ description: 'Organization not found' })
|
|
||||||
async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organization.siret && !organization.siren) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
organization.markSiretVerified();
|
|
||||||
await this.organizationRepository.update(organization);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
approved: true,
|
|
||||||
message: 'SIRET/SIREN approuve manuellement avec succes.',
|
|
||||||
organizationId: id,
|
|
||||||
organizationName: organization.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject SIRET/SIREN for an organization (admin only)
|
|
||||||
*
|
|
||||||
* Resets the verification flag to false.
|
|
||||||
*/
|
|
||||||
@Post('organizations/:id/reject-siret')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Reject SIRET/SIREN (Admin only)',
|
|
||||||
description:
|
|
||||||
'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'SIRET rejected successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({ description: 'Organization not found' })
|
|
||||||
async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset SIRET verification to false by updating the SIRET (which resets siretVerified)
|
|
||||||
// If no SIRET, just update directly
|
|
||||||
if (organization.siret) {
|
|
||||||
organization.updateSiret(organization.siret); // This resets siretVerified to false
|
|
||||||
}
|
|
||||||
await this.organizationRepository.update(organization);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
rejected: true,
|
|
||||||
message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.",
|
|
||||||
organizationId: id,
|
|
||||||
organizationName: organization.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== CSV BOOKINGS ENDPOINTS ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ALL csv bookings from database (admin only)
|
|
||||||
*
|
|
||||||
* Returns all csv bookings from all organizations
|
|
||||||
*/
|
|
||||||
@Get('bookings')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get all CSV bookings (Admin only)',
|
|
||||||
description:
|
|
||||||
'Retrieve ALL CSV bookings from the database without any filters. Includes bookings from all organizations and all statuses.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'All CSV bookings retrieved successfully',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - missing or invalid token',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin role',
|
|
||||||
})
|
|
||||||
async getAllBookings(@CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL csv bookings from database`);
|
|
||||||
|
|
||||||
const csvBookings = await this.csvBookingRepository.findAll();
|
|
||||||
const bookingDtos = csvBookings.map(booking => this.csvBookingToDto(booking));
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] Retrieved ${csvBookings.length} csv bookings from database`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
bookings: bookingDtos,
|
|
||||||
total: csvBookings.length,
|
|
||||||
page: 1,
|
|
||||||
pageSize: csvBookings.length,
|
|
||||||
totalPages: csvBookings.length > 0 ? 1 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get csv booking by ID (admin only)
|
|
||||||
*/
|
|
||||||
@Get('bookings/:id')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get CSV booking by ID (Admin only)',
|
|
||||||
description: 'Retrieve a specific CSV booking by ID',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Booking ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'CSV booking retrieved successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'CSV booking not found',
|
|
||||||
})
|
|
||||||
async getBookingById(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching csv booking: ${id}`);
|
|
||||||
|
|
||||||
const csvBooking = await this.csvBookingRepository.findById(id);
|
|
||||||
if (!csvBooking) {
|
|
||||||
throw new NotFoundException(`CSV booking ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.csvBookingToDto(csvBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update csv booking (admin only)
|
|
||||||
*/
|
|
||||||
@Patch('bookings/:id')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Update CSV booking (Admin only)',
|
|
||||||
description: 'Update CSV booking status or details',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Booking ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'CSV booking updated successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'CSV booking not found',
|
|
||||||
})
|
|
||||||
async updateBooking(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() updateDto: any,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Updating csv booking: ${id}`);
|
|
||||||
|
|
||||||
const csvBooking = await this.csvBookingRepository.findById(id);
|
|
||||||
if (!csvBooking) {
|
|
||||||
throw new NotFoundException(`CSV booking ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates to the domain entity
|
|
||||||
// Note: This is a simplified version. You may want to add proper domain methods
|
|
||||||
const updatedBooking = await this.csvBookingRepository.update(csvBooking);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] CSV booking updated successfully: ${id}`);
|
|
||||||
return this.csvBookingToDto(updatedBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend carrier email for a booking (admin only)
|
|
||||||
*
|
|
||||||
* Manually sends the booking request email to the carrier.
|
|
||||||
* Useful when the automatic email failed (SMTP error) or for testing without Stripe.
|
|
||||||
*/
|
|
||||||
@Post('bookings/:id/resend-carrier-email')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Resend carrier email (Admin only)',
|
|
||||||
description:
|
|
||||||
'Manually resend the booking request email to the carrier. Works regardless of payment status.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Email sent to carrier' })
|
|
||||||
@ApiNotFoundResponse({ description: 'Booking not found' })
|
|
||||||
async resendCarrierEmail(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`);
|
|
||||||
await this.csvBookingService.resendCarrierEmail(id);
|
|
||||||
return { success: true, message: 'Email sent to carrier' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate bank transfer for a booking (admin only)
|
|
||||||
*
|
|
||||||
* Transitions booking from PENDING_BANK_TRANSFER → PENDING and sends email to carrier
|
|
||||||
*/
|
|
||||||
@Post('bookings/:id/validate-transfer')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Validate bank transfer (Admin only)',
|
|
||||||
description:
|
|
||||||
'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' })
|
|
||||||
@ApiNotFoundResponse({ description: 'Booking not found' })
|
|
||||||
async validateBankTransfer(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`);
|
|
||||||
return this.csvBookingService.validateBankTransfer(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete csv booking (admin only)
|
|
||||||
*/
|
|
||||||
@Delete('bookings/:id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Delete CSV booking (Admin only)',
|
|
||||||
description: 'Permanently delete a CSV booking from the database',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Booking ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NO_CONTENT,
|
|
||||||
description: 'CSV booking deleted successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'CSV booking not found',
|
|
||||||
})
|
|
||||||
async deleteBooking(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<void> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Deleting csv booking: ${id}`);
|
|
||||||
|
|
||||||
const csvBooking = await this.csvBookingRepository.findById(id);
|
|
||||||
if (!csvBooking) {
|
|
||||||
throw new NotFoundException(`CSV booking ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.csvBookingRepository.delete(id);
|
|
||||||
this.logger.log(`[ADMIN] CSV booking deleted successfully: ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper method to convert CSV booking domain entity to DTO
|
|
||||||
*/
|
|
||||||
private csvBookingToDto(booking: any) {
|
|
||||||
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: booking.id,
|
|
||||||
bookingNumber: booking.bookingNumber || null,
|
|
||||||
userId: booking.userId,
|
|
||||||
organizationId: booking.organizationId,
|
|
||||||
carrierName: booking.carrierName,
|
|
||||||
carrierEmail: booking.carrierEmail,
|
|
||||||
origin: booking.origin.getValue(),
|
|
||||||
destination: booking.destination.getValue(),
|
|
||||||
volumeCBM: booking.volumeCBM,
|
|
||||||
weightKG: booking.weightKG,
|
|
||||||
palletCount: booking.palletCount,
|
|
||||||
priceUSD: booking.priceUSD,
|
|
||||||
priceEUR: booking.priceEUR,
|
|
||||||
primaryCurrency: booking.primaryCurrency,
|
|
||||||
transitDays: booking.transitDays,
|
|
||||||
containerType: booking.containerType,
|
|
||||||
status: booking.status,
|
|
||||||
documents: booking.documents || [],
|
|
||||||
confirmationToken: booking.confirmationToken,
|
|
||||||
requestedAt: booking.requestedAt,
|
|
||||||
respondedAt: booking.respondedAt || null,
|
|
||||||
notes: booking.notes,
|
|
||||||
rejectionReason: booking.rejectionReason,
|
|
||||||
routeDescription: booking.getRouteDescription(),
|
|
||||||
isExpired: booking.isExpired(),
|
|
||||||
price: booking.getPriceInCurrency(primaryCurrency),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== EMAIL TEST ENDPOINT ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a test email to verify SMTP configuration (admin only)
|
|
||||||
*
|
|
||||||
* Returns the exact SMTP error in the response instead of only logging it.
|
|
||||||
*/
|
|
||||||
@Post('test-email')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Send test email (Admin only)',
|
|
||||||
description:
|
|
||||||
'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Email sent successfully' })
|
|
||||||
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
|
|
||||||
async sendTestEmail(
|
|
||||||
@Body() body: { to: string },
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
) {
|
|
||||||
if (!body?.to) {
|
|
||||||
throw new BadRequestException('Field "to" is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.emailPort.send({
|
|
||||||
to: body.to,
|
|
||||||
subject: '[Xpeditis] Test SMTP',
|
|
||||||
html: `<p>Email de test envoyé depuis le panel admin par <strong>${user.email}</strong>.</p><p>Si vous lisez ceci, la configuration SMTP fonctionne correctement.</p>`,
|
|
||||||
text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`);
|
|
||||||
return { success: true, message: `Email envoyé avec succès à ${body.to}` };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack);
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` +
|
|
||||||
`Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DOCUMENTS ENDPOINTS ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ALL documents from all organizations (admin only)
|
|
||||||
*
|
|
||||||
* Returns documents grouped by organization
|
|
||||||
*/
|
|
||||||
@Get('documents')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get all documents (Admin only)',
|
|
||||||
description: 'Retrieve ALL documents from all organizations in the database.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'All documents retrieved successfully',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - missing or invalid token',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin role',
|
|
||||||
})
|
|
||||||
async getAllDocuments(@CurrentUser() user: UserPayload): Promise<any> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL documents from database`);
|
|
||||||
|
|
||||||
// Get all organizations
|
|
||||||
const organizations = await this.organizationRepository.findAll();
|
|
||||||
|
|
||||||
// Extract documents from all organizations
|
|
||||||
const allDocuments = organizations.flatMap(org =>
|
|
||||||
org.documents.map(doc => ({
|
|
||||||
...doc,
|
|
||||||
organizationId: org.id,
|
|
||||||
organizationName: org.name,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[ADMIN] Retrieved ${allDocuments.length} documents from ${organizations.length} organizations`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents: allDocuments,
|
|
||||||
total: allDocuments.length,
|
|
||||||
organizationCount: organizations.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get documents for a specific organization (admin only)
|
|
||||||
*/
|
|
||||||
@Get('organizations/:id/documents')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get organization documents (Admin only)',
|
|
||||||
description: 'Retrieve all documents for a specific organization',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Organization ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Organization documents retrieved successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'Organization not found',
|
|
||||||
})
|
|
||||||
async getOrganizationDocuments(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<any> {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Fetching documents for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
organizationId: organization.id,
|
|
||||||
organizationName: organization.name,
|
|
||||||
documents: organization.documents,
|
|
||||||
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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -181,7 +181,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
||||||
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
||||||
const filePathToValidate = conversionResult.convertedPath;
|
let filePathToValidate = conversionResult.convertedPath;
|
||||||
|
|
||||||
if (conversionResult.wasConverted) {
|
if (conversionResult.wasConverted) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@ -204,11 +204,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
// Load rates to verify parsing using the converted path
|
// Load rates to verify parsing using the converted path
|
||||||
// Pass company name from form to override CSV column value
|
// Pass company name from form to override CSV column value
|
||||||
const rates = await this.csvLoader.loadRatesFromCsv(
|
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate, dto.companyEmail, dto.companyName);
|
||||||
filePathToValidate,
|
|
||||||
dto.companyEmail,
|
|
||||||
dto.companyName
|
|
||||||
);
|
|
||||||
const ratesCount = rates.length;
|
const ratesCount = rates.length;
|
||||||
|
|
||||||
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||||
@ -249,9 +245,7 @@ export class CsvRatesAdminController {
|
|||||||
minioObjectKey = objectKey;
|
minioObjectKey = objectKey;
|
||||||
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
|
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`);
|
||||||
`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`
|
|
||||||
);
|
|
||||||
// Don't fail the entire operation if MinIO upload fails
|
// Don't fail the entire operation if MinIO upload fails
|
||||||
// The file is still available locally
|
// The file is still available locally
|
||||||
}
|
}
|
||||||
@ -439,8 +433,7 @@ export class CsvRatesAdminController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List all CSV files (ADMIN only)',
|
summary: 'List all CSV files (ADMIN only)',
|
||||||
description:
|
description: 'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.',
|
||||||
'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.',
|
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.OK,
|
status: HttpStatus.OK,
|
||||||
@ -469,7 +462,7 @@ export class CsvRatesAdminController {
|
|||||||
const configs = await this.csvConfigRepository.findAll();
|
const configs = await this.csvConfigRepository.findAll();
|
||||||
|
|
||||||
// Map configs to file info format expected by frontend
|
// Map configs to file info format expected by frontend
|
||||||
const files = configs.map(config => {
|
const files = configs.map((config) => {
|
||||||
const filePath = path.join(
|
const filePath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||||
@ -489,7 +482,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,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -529,7 +521,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
// Find config by file path
|
// Find config by file path
|
||||||
const configs = await this.csvConfigRepository.findAll();
|
const configs = await this.csvConfigRepository.findAll();
|
||||||
const config = configs.find(c => c.csvFilePath === filename);
|
const config = configs.find((c) => c.csvFilePath === filename);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new BadRequestException(`No configuration found for file: ${filename}`);
|
throw new BadRequestException(`No configuration found for file: ${filename}`);
|
||||||
|
|||||||
@ -38,7 +38,7 @@ class AuditLogResponseDto {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AuditLogQueryDto {
|
class AuditLogQueryDto {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
action?: AuditAction[];
|
action?: AuditAction[];
|
||||||
status?: AuditStatus[];
|
status?: AuditStatus[];
|
||||||
|
|||||||
@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@ -34,7 +34,7 @@ import {
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto';
|
import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto';
|
||||||
import { BookingFilterDto } from '../dto/booking-filter.dto';
|
import { BookingFilterDto } from '../dto/booking-filter.dto';
|
||||||
import { BookingExportDto } from '../dto/booking-export.dto';
|
import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto';
|
||||||
import { BookingMapper } from '../mappers';
|
import { BookingMapper } from '../mappers';
|
||||||
import { BookingService } from '@domain/services/booking.service';
|
import { BookingService } from '@domain/services/booking.service';
|
||||||
import { BookingRepository, BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
import { BookingRepository, BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
||||||
@ -48,17 +48,11 @@ import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|||||||
import { ExportService } from '../services/export.service';
|
import { ExportService } from '../services/export.service';
|
||||||
import { FuzzySearchService } from '../services/fuzzy-search.service';
|
import { FuzzySearchService } from '../services/fuzzy-search.service';
|
||||||
import { AuditService } from '../services/audit.service';
|
import { AuditService } from '../services/audit.service';
|
||||||
import { AuditAction } from '@domain/entities/audit-log.entity';
|
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
|
||||||
import { NotificationService } from '../services/notification.service';
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { NotificationsGateway } from '../gateways/notifications.gateway';
|
import { NotificationsGateway } from '../gateways/notifications.gateway';
|
||||||
import { WebhookService } from '../services/webhook.service';
|
import { WebhookService } from '../services/webhook.service';
|
||||||
import { WebhookEvent } from '@domain/entities/webhook.entity';
|
import { WebhookEvent } from '@domain/entities/webhook.entity';
|
||||||
import {
|
|
||||||
ShipmentCounterPort,
|
|
||||||
SHIPMENT_COUNTER_PORT,
|
|
||||||
} from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
|
|
||||||
|
|
||||||
@ApiTags('Bookings')
|
@ApiTags('Bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@ -76,9 +70,7 @@ export class BookingsController {
|
|||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly notificationsGateway: NotificationsGateway,
|
private readonly notificationsGateway: NotificationsGateway,
|
||||||
private readonly webhookService: WebhookService,
|
private readonly webhookService: WebhookService
|
||||||
@Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort,
|
|
||||||
private readonly subscriptionService: SubscriptionService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -113,22 +105,6 @@ export class BookingsController {
|
|||||||
): Promise<BookingResponseDto> {
|
): Promise<BookingResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
|
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
|
||||||
|
|
||||||
// Check shipment limit for Bronze plan
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
|
||||||
user.organizationId
|
|
||||||
);
|
|
||||||
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
|
||||||
if (maxShipments !== -1) {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
|
|
||||||
user.organizationId,
|
|
||||||
currentYear
|
|
||||||
);
|
|
||||||
if (count >= maxShipments) {
|
|
||||||
throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert DTO to domain input, using authenticated user's data
|
// Convert DTO to domain input, using authenticated user's data
|
||||||
const input = {
|
const input = {
|
||||||
@ -375,16 +351,11 @@ export class BookingsController {
|
|||||||
`[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}`
|
`[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// ADMIN: Fetch ALL bookings from database
|
// Use authenticated user's organization ID
|
||||||
// Others: Fetch only bookings from their organization
|
const organizationId = user.organizationId;
|
||||||
let bookings: any[];
|
|
||||||
if (user.role === 'ADMIN') {
|
// Fetch bookings for the user's organization
|
||||||
this.logger.log(`[ADMIN] Fetching ALL bookings from database`);
|
const bookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||||
bookings = await this.bookingRepository.findAll();
|
|
||||||
} else {
|
|
||||||
this.logger.log(`[User] Fetching bookings from organization: ${user.organizationId}`);
|
|
||||||
bookings = await this.bookingRepository.findByOrganization(user.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status if provided
|
// Filter by status if provided
|
||||||
const filteredBookings = status
|
const filteredBookings = status
|
||||||
@ -396,20 +367,14 @@ export class BookingsController {
|
|||||||
const endIndex = startIndex + pageSize;
|
const endIndex = startIndex + pageSize;
|
||||||
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Fetch rate quotes for all bookings (filter out those with missing rate quotes)
|
// Fetch rate quotes for all bookings
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async (booking: any) => {
|
paginatedBookings.map(async (booking: any) => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
@ -470,28 +435,14 @@ export class BookingsController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Map ORM entities to domain and fetch rate quotes
|
// Map ORM entities to domain and fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookingOrms.map(async bookingOrm => {
|
bookingOrms.map(async bookingOrm => {
|
||||||
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking: booking!, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings or rate quotes that are null
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(
|
|
||||||
item
|
|
||||||
): item is {
|
|
||||||
booking: NonNullable<typeof item.booking>;
|
|
||||||
rateQuote: NonNullable<typeof item.rateQuote>;
|
|
||||||
} =>
|
|
||||||
item.booking !== null &&
|
|
||||||
item.booking !== undefined &&
|
|
||||||
item.rateQuote !== null &&
|
|
||||||
item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
||||||
BookingMapper.toDto(booking, rateQuote)
|
BookingMapper.toDto(booking, rateQuote)
|
||||||
@ -531,10 +482,8 @@ export class BookingsController {
|
|||||||
// Apply filters
|
// Apply filters
|
||||||
bookings = this.applyFilters(bookings, filter);
|
bookings = this.applyFilters(bookings, filter);
|
||||||
|
|
||||||
// Sort bookings (use defaults if not provided)
|
// Sort bookings
|
||||||
const sortBy = filter.sortBy || 'createdAt';
|
bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!);
|
||||||
const sortOrder = filter.sortOrder || 'desc';
|
|
||||||
bookings = this.sortBookings(bookings, sortBy, sortOrder);
|
|
||||||
|
|
||||||
// Total count before pagination
|
// Total count before pagination
|
||||||
const total = bookings.length;
|
const total = bookings.length;
|
||||||
@ -544,20 +493,14 @@ export class BookingsController {
|
|||||||
const endIndex = startIndex + (filter.pageSize || 20);
|
const endIndex = startIndex + (filter.pageSize || 20);
|
||||||
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async booking => {
|
paginatedBookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
@ -614,20 +557,14 @@ export class BookingsController {
|
|||||||
bookings = this.applyFilters(bookings, filter);
|
bookings = this.applyFilters(bookings, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookings.map(async booking => {
|
bookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate export file
|
// Generate export file
|
||||||
const exportResult = await this.exportService.exportBookings(
|
const exportResult = await this.exportService.exportBookings(
|
||||||
bookingsWithQuotes,
|
bookingsWithQuotes,
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
import {
|
|
||||||
CarrierDocumentsResponseDto,
|
|
||||||
VerifyDocumentAccessDto,
|
|
||||||
DocumentAccessRequirementsDto,
|
|
||||||
} from '../dto/carrier-documents.dto';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Actions Controller (Public Routes)
|
* CSV Booking Actions Controller (Public Routes)
|
||||||
@ -17,7 +12,9 @@ import {
|
|||||||
@ApiTags('CSV Booking Actions')
|
@ApiTags('CSV Booking Actions')
|
||||||
@Controller('csv-booking-actions')
|
@Controller('csv-booking-actions')
|
||||||
export class CsvBookingActionsController {
|
export class CsvBookingActionsController {
|
||||||
constructor(private readonly csvBookingService: CsvBookingService) {}
|
constructor(
|
||||||
|
private readonly csvBookingService: CsvBookingService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a booking request (PUBLIC - token-based)
|
* Accept a booking request (PUBLIC - token-based)
|
||||||
@ -93,84 +90,4 @@ export class CsvBookingActionsController {
|
|||||||
reason: reason || null,
|
reason: reason || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check document access requirements (PUBLIC - token-based)
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-booking-actions/documents/:token/requirements
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('documents/:token/requirements')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Check document access requirements (public)',
|
|
||||||
description:
|
|
||||||
'Check if a password is required to access booking documents. Use this before showing the password form.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Access requirements retrieved successfully.',
|
|
||||||
type: DocumentAccessRequirementsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
|
||||||
async getDocumentAccessRequirements(
|
|
||||||
@Param('token') token: string
|
|
||||||
): Promise<DocumentAccessRequirementsDto> {
|
|
||||||
return this.csvBookingService.checkDocumentAccessRequirements(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get booking documents for carrier with password verification (PUBLIC - token-based)
|
|
||||||
*
|
|
||||||
* POST /api/v1/csv-booking-actions/documents/:token
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Post('documents/:token')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get booking documents with password (public)',
|
|
||||||
description:
|
|
||||||
'Public endpoint for carriers to access booking documents after acceptance. Requires password verification. Returns booking summary and documents with signed download URLs.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
|
||||||
@ApiBody({ type: VerifyDocumentAccessDto })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Booking documents retrieved successfully.',
|
|
||||||
type: CarrierDocumentsResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Invalid password' })
|
|
||||||
async getBookingDocumentsWithPassword(
|
|
||||||
@Param('token') token: string,
|
|
||||||
@Body() dto: VerifyDocumentAccessDto
|
|
||||||
): Promise<CarrierDocumentsResponseDto> {
|
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token, dto.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get booking documents for carrier (PUBLIC - token-based) - Legacy without password
|
|
||||||
* Kept for backward compatibility with bookings created before password protection
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-booking-actions/documents/:token
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('documents/:token')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get booking documents (public) - Legacy',
|
|
||||||
description:
|
|
||||||
'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Booking documents retrieved successfully.',
|
|
||||||
type: CarrierDocumentsResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Password required for this booking' })
|
|
||||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user