fix v0.2
Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Failing after 5m31s
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m42s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Has been skipped

This commit is contained in:
David 2025-11-12 18:00:33 +01:00
parent a9bbbede4a
commit 890bc189ee
47 changed files with 8897 additions and 119 deletions

View File

@ -1,47 +1,7 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"\\d organizations\")", "Bash(docker-compose:*)"
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"\nINSERT INTO organizations (id, name, type, address_street, address_city, address_postal_code, address_country, is_active)\nVALUES (\n ''00000000-0000-0000-0000-000000000001'',\n ''Default Organization'',\n ''FREIGHT_FORWARDER'',\n ''123 Main Street'',\n ''New York'',\n ''10001'',\n ''US'',\n true\n);\nSELECT id, name FROM organizations;\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, name FROM organizations WHERE id = ''00000000-0000-0000-0000-000000000001'';\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"\nINSERT INTO organizations (id, name, type, address_street, address_city, address_postal_code, address_country, is_active)\nVALUES (\n ''a1234567-0000-4000-8000-000000000001'',\n ''Test Organization'',\n ''FREIGHT_FORWARDER'',\n ''123 Main Street'',\n ''New York'',\n ''10001'',\n ''US'',\n true\n)\nON CONFLICT (id) DO NOTHING;\nSELECT id, name FROM organizations LIMIT 2;\")",
"Bash(ACCESS_TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJ1c2VyIiwib3JnYW5pemF0aW9uSWQiOiJhMTIzNDU2Ny0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxMDczOTg4LCJleHAiOjE3NjEwNzQ4ODh9.-kmaFPj8vbhyEKQJr-kuM-WR_HvrYt6547BfLg0-HQs\")",
"Bash(npm run dev:*)",
"Bash(curl -s -X POST http://localhost:4000/api/v1/auth/register -H \"Content-Type: application/json\" -d '{\"\"\"\"email\"\"\"\":\"\"\"\"finaltest@xpeditis.com\"\"\"\",\"\"\"\"password\"\"\"\":\"\"\"\"TestPassword123\"\"\"\",\"\"\"\"firstName\"\"\"\":\"\"\"\"Final\"\"\"\",\"\"\"\"lastName\"\"\"\":\"\"\"\"Test\"\"\"\",\"\"\"\"organizationId\"\"\"\":\"\"\"\"a1234567-0000-4000-8000-000000000001\"\"\"\"}')",
"Bash(curl -s -X POST http://localhost:4000/api/v1/auth/login -H \"Content-Type: application/json\" -d '{\"\"email\"\":\"\"test4@xpeditis.com\"\",\"\"password\"\":\"\"SecurePassword123\"\"}')",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJ1c2VyIiwib3JnYW5pemF0aW9uSWQiOiJhMTIzNDU2Ny0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxMDc1MDI3LCJleHAiOjE3NjEwNzU5Mjd9.dl2mLi0LrXcl-PwdkijW1ZQ3muboTgX9gGU65mlAq1U\")",
"Bash(echo \"Test 1: GET /auth/me\" curl -s -X GET http://localhost:4000/api/v1/auth/me -H \"Authorization: Bearer $TOKEN\")",
"Bash(echo \"Test 2: GET /users\" curl -s -o /dev/null -w \"Status: %{http_code}\\n\" -X GET http://localhost:4000/api/v1/api/v1/users -H \"Authorization: Bearer $TOKEN\")",
"Bash(echo \"Test 3: GET /bookings\" curl -s -o /dev/null -w \"Status: %{http_code}\\n\" -X GET http://localhost:4000/api/v1/api/v1/bookings -H \"Authorization: Bearer $TOKEN\")",
"Bash(echo \"Test 4: GET /dashboard/kpis\" curl -s -o /dev/null -w \"Status: %{http_code}\\n\" -X GET http://localhost:4000/api/v1/api/v1/dashboard/kpis -H \"Authorization: Bearer $TOKEN\")",
"Bash(echo \"Test 5: GET /notifications\" curl -s -o /dev/null -w \"Status: %{http_code}\\n\" -X GET http://localhost:4000/api/v1/api/v1/notifications -H \"Authorization: Bearer $TOKEN\")",
"Bash(echo \"Test 6: GET /organizations\" curl -s -o /dev/null -w \"Status: %{http_code}\\n\" -X GET http://localhost:4000/api/v1/api/v1/organizations -H \"Authorization: Bearer $TOKEN\")",
"Bash(curl:*)",
"Bash(cp:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGM4NzQ2Mi1hNThlLTQ2ODgtOTE5OS0xYzMyM2Q4MDA1N2IiLCJlbWFpbCI6InRlc3Rmcm9udGVuZEB4cGVkaXRpcy5jb20iLCJyb2xlIjoidXNlciIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTA3NTk3OCwiZXhwIjoxNzYxMDc2ODc4fQ.UOfZG-koAfETtmyxXtlpRfibtO4bD9i_KqQ1Ex6mbh8\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, name FROM organizations LIMIT 5;\")",
"Read(//Users/david/Documents/xpeditis/**)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJ1c2VyIiwib3JnYW5pemF0aW9uSWQiOiJhMTIzNDU2Ny0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNTkwNzYxLCJleHAiOjE3NjE1OTE2NjF9.Jr9BbldL3TGW4pbXXc1XomzVMBRHn4lIgkKJ7XyjJgw\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, email, role FROM users LIMIT 5;\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''test4@xpeditis.com''; SELECT id, email, role FROM users WHERE email = ''test4@xpeditis.com'';\")",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5MDg0OSwiZXhwIjoxNzYxNTkxNzQ5fQ.CPFhvgASXuklZ81FiuX_XwYZfh8xKG4tNG70JQ4Dv8M\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''dharnaud77@hotmail.fr''; SELECT id, email, role FROM users WHERE email = ''dharnaud77@hotmail.fr'';\")",
"Bash(npm run format:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5Njk0MywiZXhwIjoxNzYxNTk3ODQzfQ.cwvInoHK_vR24aRRlkJGBv_VBkgyfpCwpXyrAhulQYI\")",
"Read(//Users/david/Downloads/drive-download-20251023T120052Z-1-001/**)",
"Bash(bash:*)",
"Read(//Users/david/Downloads/**)",
"Bash(npm run type-check:*)",
"Bash(npx tsc:*)",
"Bash(find:*)",
"Bash(npm run backend:dev:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTkyNzc5OCwiZXhwIjoxNzYxOTI4Njk4fQ.fD6rTwj5Kc4PxnczmEgkLW-PA95VXufogo4vFBbsuMY\")",
"Bash(docker-compose up:*)",
"Bash(npm run frontend:dev:*)",
"Bash(python3:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MjI5MjI1NCwiZXhwIjoxNzYyMjkzMTU0fQ.aCVXH9_UbfBm3-rH5PnBc0jGMqCOBSOkmqmv6UJP9xs\" curl -s -X POST http://localhost:4000/api/v1/rates/search-csv -H \"Content-Type: application/json\" -H \"Authorization: Bearer $TOKEN\" -d '{\"\"\"\"origin\"\"\"\":\"\"\"\"NLRTM\"\"\"\",\"\"\"\"destination\"\"\"\":\"\"\"\"USNYC\"\"\"\",\"\"\"\"volumeCBM\"\"\"\":5,\"\"\"\"weightKG\"\"\"\":1000,\"\"\"\"palletCount\"\"\"\":3}')"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

524
.github/CI-CD-WORKFLOW.md vendored Normal file
View File

@ -0,0 +1,524 @@
# CI/CD Workflow - Xpeditis PreProd
Ce document décrit le pipeline CI/CD automatisé pour déployer Xpeditis sur l'environnement de préproduction.
## Vue d'Ensemble
Le pipeline CI/CD s'exécute automatiquement à chaque push ou pull request sur la branche `preprod`. Il effectue les opérations suivantes :
```
┌─────────────────────────────────────────────────────────────────┐
│ TRIGGER: Push sur preprod │
└────────────────────────┬────────────────────────────────────────┘
┌───────────────┴───────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Backend Build │ │ Frontend Build │
& Test │ │ & Test │
│ │ │ │
│ • ESLint │ │ • ESLint │
│ • Unit Tests │ │ • Type Check │
│ • Integration │ │ • Build Next.js │
│ • Build NestJS │ │ │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Backend Docker │ │ Frontend Docker │
│ Build & Push │ │ Build & Push │
│ │ │ │
│ • Build Image │ │ • Build Image │
│ • Push to SCW │ │ • Push to SCW │
│ • Tag: preprod │ │ • Tag: preprod │
└────────┬─────────┘ └────────┬─────────┘
│ │
└───────────────┬───────────────┘
┌────────────────┐
│ Deploy PreProd │
│ │
│ • Portainer │
│ Webhook │
│ • Health Check │
│ • Notification │
└────────┬───────┘
┌────────────────┐
│ Smoke Tests │
│ │
│ • API Health │
│ • Endpoints │
│ • Frontend │
└────────────────┘
```
## Jobs Détaillés
### 1. Backend Build & Test (~5-7 minutes)
**Objectif** : Valider le code backend et s'assurer qu'il compile sans erreur
**Étapes** :
1. **Checkout** : Récupère le code source
2. **Setup Node.js** : Configure Node.js 20 avec cache npm
3. **Install Dependencies** : `npm ci` dans `apps/backend`
4. **ESLint** : Vérifie le style et la qualité du code
5. **Unit Tests** : Exécute les tests unitaires (domaine)
6. **Integration Tests** : Lance PostgreSQL + Redis et exécute les tests d'intégration
7. **Build** : Compile TypeScript → JavaScript
8. **Upload Artifacts** : Sauvegarde le dossier `dist` pour inspection
**Technologies** :
- Node.js 20
- PostgreSQL 15 (container)
- Redis 7 (container)
- Jest
- TypeScript
**Conditions d'échec** :
- ❌ Erreurs de syntaxe TypeScript
- ❌ Tests unitaires échoués
- ❌ Tests d'intégration échoués
- ❌ Erreurs ESLint
---
### 2. Frontend Build & Test (~4-6 minutes)
**Objectif** : Valider le code frontend et s'assurer qu'il compile sans erreur
**Étapes** :
1. **Checkout** : Récupère le code source
2. **Setup Node.js** : Configure Node.js 20 avec cache npm
3. **Install Dependencies** : `npm ci` dans `apps/frontend`
4. **ESLint** : Vérifie le style et la qualité du code
5. **Type Check** : Vérifie les types TypeScript (`tsc --noEmit`)
6. **Build** : Compile Next.js avec les variables d'environnement preprod
7. **Upload Artifacts** : Sauvegarde le dossier `.next` pour inspection
**Technologies** :
- Node.js 20
- Next.js 14
- TypeScript
- Tailwind CSS
**Variables d'environnement** :
```bash
NEXT_PUBLIC_API_URL=https://api-preprod.xpeditis.com
NEXT_PUBLIC_WS_URL=wss://api-preprod.xpeditis.com
```
**Conditions d'échec** :
- ❌ Erreurs de syntaxe TypeScript
- ❌ Erreurs de compilation Next.js
- ❌ Erreurs ESLint
- ❌ Type errors
---
### 3. Backend Docker Build & Push (~3-5 minutes)
**Objectif** : Construire l'image Docker du backend et la pousser vers le registre Scaleway
**Étapes** :
1. **Checkout** : Récupère le code source
2. **Setup QEMU** : Support multi-plateforme (ARM64, AMD64)
3. **Setup Buildx** : Builder Docker avancé avec cache
4. **Login Registry** : Authentification Scaleway Container Registry
5. **Extract Metadata** : Génère les tags pour l'image (preprod, preprod-SHA)
6. **Build & Push** : Construit et pousse l'image avec cache layers
7. **Docker Cleanup** : Nettoie les images temporaires
**Image produite** :
```
rg.fr-par.scw.cloud/xpeditis/backend:preprod
rg.fr-par.scw.cloud/xpeditis/backend:preprod-abc1234
```
**Cache** :
- ✅ Cache des layers Docker pour accélérer les builds suivants
- ✅ Cache des dépendances npm
**Taille estimée** : ~800 MB (Node.js Alpine + dépendances)
---
### 4. Frontend Docker Build & Push (~3-5 minutes)
**Objectif** : Construire l'image Docker du frontend et la pousser vers le registre Scaleway
**Étapes** :
1. **Checkout** : Récupère le code source
2. **Setup QEMU** : Support multi-plateforme
3. **Setup Buildx** : Builder Docker avancé avec cache
4. **Login Registry** : Authentification Scaleway Container Registry
5. **Extract Metadata** : Génère les tags pour l'image
6. **Build & Push** : Construit et pousse l'image avec build args
7. **Docker Cleanup** : Nettoie les images temporaires
**Build Args** :
```dockerfile
NODE_ENV=production
NEXT_PUBLIC_API_URL=https://api-preprod.xpeditis.com
NEXT_PUBLIC_WS_URL=wss://api-preprod.xpeditis.com
```
**Image produite** :
```
rg.fr-par.scw.cloud/xpeditis/frontend:preprod
rg.fr-par.scw.cloud/xpeditis/frontend:preprod-abc1234
```
**Taille estimée** : ~500 MB (Node.js Alpine + Next.js build)
---
### 5. Deploy to PreProd (~2-3 minutes)
**Objectif** : Déployer les nouvelles images sur le serveur preprod via Portainer
**Étapes** :
#### 5.1 Trigger Backend Webhook
```bash
POST https://portainer.xpeditis.com/api/webhooks/xxx-backend
{
"service": "backend",
"image": "rg.fr-par.scw.cloud/xpeditis/backend:preprod",
"timestamp": "2025-01-15T10:30:00Z"
}
```
**Ce qui se passe côté Portainer** :
1. Portainer reçoit le webhook
2. Pull la nouvelle image `backend:preprod`
3. Effectue un rolling update du service `xpeditis-backend`
4. Démarre les nouveaux conteneurs
5. Arrête les anciens conteneurs (0 downtime)
#### 5.2 Wait for Backend Deployment
- Attend 30 secondes pour que le backend démarre
#### 5.3 Trigger Frontend Webhook
```bash
POST https://portainer.xpeditis.com/api/webhooks/xxx-frontend
{
"service": "frontend",
"image": "rg.fr-par.scw.cloud/xpeditis/frontend:preprod",
"timestamp": "2025-01-15T10:30:00Z"
}
```
#### 5.4 Wait for Frontend Deployment
- Attend 30 secondes pour que le frontend démarre
#### 5.5 Health Check Backend
```bash
# Vérifie que l'API répond (max 10 tentatives)
GET https://api-preprod.xpeditis.com/health
# Expected: HTTP 200 OK
```
#### 5.6 Health Check Frontend
```bash
# Vérifie que le frontend répond (max 10 tentatives)
GET https://app-preprod.xpeditis.com
# Expected: HTTP 200 OK
```
#### 5.7 Send Notification
Envoie une notification Discord (si configuré) avec :
- ✅ Statut du déploiement (SUCCESS / FAILED)
- 📝 Message du commit
- 👤 Auteur du commit
- 🔗 URLs des services
- ⏰ Timestamp
**Exemple de notification Discord** :
```
✅ Deployment PreProd - SUCCESS
Branch: preprod
Commit: abc1234
Author: David
Message: feat: add CSV booking workflow
Backend: https://api-preprod.xpeditis.com
Frontend: https://app-preprod.xpeditis.com
Timestamp: 2025-01-15T10:30:00Z
```
---
### 6. Smoke Tests (~1-2 minutes)
**Objectif** : Vérifier que les services déployés fonctionnent correctement
**Tests Backend** :
1. **Health Endpoint**
```bash
GET https://api-preprod.xpeditis.com/health
Expected: HTTP 200 OK
```
2. **Swagger Documentation**
```bash
GET https://api-preprod.xpeditis.com/api/docs
Expected: HTTP 200 or 301
```
3. **Rate Search Endpoint**
```bash
POST https://api-preprod.xpeditis.com/api/v1/rates/search-csv
Body: {
"origin": "NLRTM",
"destination": "USNYC",
"volumeCBM": 5,
"weightKG": 1000,
"palletCount": 3
}
Expected: HTTP 200 or 401 (unauthorized)
```
**Tests Frontend** :
1. **Homepage**
```bash
GET https://app-preprod.xpeditis.com
Expected: HTTP 200 OK
```
2. **Login Page**
```bash
GET https://app-preprod.xpeditis.com/login
Expected: HTTP 200 OK
```
**Résultat** :
```
================================================
✅ All smoke tests passed successfully!
================================================
Backend API: https://api-preprod.xpeditis.com
Frontend App: https://app-preprod.xpeditis.com
Swagger Docs: https://api-preprod.xpeditis.com/api/docs
================================================
```
---
## Durée Totale du Pipeline
**Temps estimé** : ~18-26 minutes
| Job | Durée | Parallèle |
|------------------------|----------|-----------|
| Backend Build & Test | 5-7 min | ✅ |
| Frontend Build & Test | 4-6 min | ✅ |
| Backend Docker | 3-5 min | ✅ |
| Frontend Docker | 3-5 min | ✅ |
| Deploy PreProd | 2-3 min | ❌ |
| Smoke Tests | 1-2 min | ❌ |
**Avec parallélisation** :
- Build & Test (parallèle) : ~7 min
- Docker (parallèle) : ~5 min
- Deploy : ~3 min
- Tests : ~2 min
- **Total** : ~17 minutes
---
## Variables d'Environnement
### Backend (Production)
```bash
NODE_ENV=production
PORT=4000
DATABASE_HOST=xpeditis-db
DATABASE_PORT=5432
DATABASE_USER=xpeditis
DATABASE_PASSWORD=*** (secret Portainer)
DATABASE_NAME=xpeditis_prod
DATABASE_SSL=false
REDIS_HOST=xpeditis-redis
REDIS_PORT=6379
REDIS_PASSWORD=*** (secret Portainer)
JWT_SECRET=*** (secret Portainer)
AWS_S3_ENDPOINT=http://xpeditis-minio:9000
AWS_ACCESS_KEY_ID=*** (secret Portainer)
AWS_SECRET_ACCESS_KEY=*** (secret Portainer)
CORS_ORIGIN=https://app-preprod.xpeditis.com
FRONTEND_URL=https://app-preprod.xpeditis.com
API_URL=https://api-preprod.xpeditis.com
```
### Frontend (Build Time)
```bash
NODE_ENV=production
NEXT_PUBLIC_API_URL=https://api-preprod.xpeditis.com
NEXT_PUBLIC_WS_URL=wss://api-preprod.xpeditis.com
```
---
## Rollback en Cas d'Échec
Si un déploiement échoue, vous pouvez facilement revenir à la version précédente :
### Option 1 : Via Portainer UI
1. Allez dans **Stacks** → **xpeditis**
2. Sélectionnez le service (backend ou frontend)
3. Cliquez sur **Rollback**
4. Sélectionnez la version précédente
5. Cliquez sur **Apply**
### Option 2 : Via Portainer API
```bash
# Rollback backend
curl -X POST "https://portainer.xpeditis.com/api/services/xpeditis_xpeditis-backend/update?version=123" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"rollback": {"force": true}}'
# Rollback frontend
curl -X POST "https://portainer.xpeditis.com/api/services/xpeditis_xpeditis-frontend/update?version=456" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"rollback": {"force": true}}'
```
### Option 3 : Redéployer une Version Précédente
```bash
# Sur votre machine locale
git checkout preprod
git log # Trouver le SHA du commit précédent
# Revenir à un commit précédent
git reset --hard abc1234
git push origin preprod --force
# Le CI/CD va automatiquement déployer cette version
```
---
## Monitoring du Pipeline
### Voir les Logs GitHub Actions
1. Allez sur GitHub : `https://github.com/VOTRE_USERNAME/xpeditis/actions`
2. Cliquez sur le workflow en cours
3. Cliquez sur un job pour voir ses logs détaillés
### Voir les Logs des Services Déployés
```bash
# Logs backend
docker service logs xpeditis_xpeditis-backend -f --tail 100
# Logs frontend
docker service logs xpeditis_xpeditis-frontend -f --tail 100
```
### Vérifier les Health Checks
```bash
# Backend
curl https://api-preprod.xpeditis.com/health
# Frontend
curl https://app-preprod.xpeditis.com
```
---
## Optimisations Possibles
### 1. Cache des Dépendances npm
**Déjà implémenté** : Les dépendances npm sont cachées via `actions/setup-node@v4`
### 2. Cache des Layers Docker
**Déjà implémenté** : Utilise `cache-from` et `cache-to` de Buildx
### 3. Parallélisation des Jobs
**Déjà implémenté** : Backend et Frontend build/test en parallèle
### 4. Skip Tests pour Hotfix (Non recommandé)
```yaml
# Ajouter dans le workflow
if: "!contains(github.event.head_commit.message, '[skip tests]')"
```
Puis commit avec :
```bash
git commit -m "hotfix: fix critical bug [skip tests]"
```
⚠️ **Attention** : Utiliser uniquement en cas d'urgence absolue !
---
## Troubleshooting
### Le pipeline échoue sur "Backend Build & Test"
**Causes possibles** :
- Tests unitaires échoués
- Tests d'intégration échoués
- Erreurs TypeScript
**Solution** :
```bash
# Lancer les tests localement
cd apps/backend
npm run test
npm run test:integration
# Vérifier la compilation
npm run build
```
---
### Le pipeline échoue sur "Docker Build & Push"
**Causes possibles** :
- Token Scaleway invalide
- Dockerfile incorrect
- Dépendances manquantes
**Solution** :
```bash
# Tester le build localement
docker build -t test -f apps/backend/Dockerfile .
# Vérifier les logs GitHub Actions pour plus de détails
```
---
### Le déploiement échoue sur "Health Check"
**Causes possibles** :
- Service ne démarre pas correctement
- Variables d'environnement incorrectes
- Base de données non accessible
**Solution** :
1. Vérifier les logs Portainer
2. Vérifier les variables d'environnement dans la stack
3. Vérifier que PostgreSQL, Redis, MinIO sont opérationnels
---
## Support
Pour plus d'informations :
- [Configuration des Secrets GitHub](GITHUB-SECRETS-SETUP.md)
- [Guide de Déploiement Portainer](../docker/PORTAINER-DEPLOYMENT-GUIDE.md)
- [Documentation GitHub Actions](https://docs.github.com/en/actions)

289
.github/GITHUB-SECRETS-SETUP.md vendored Normal file
View File

@ -0,0 +1,289 @@
# Configuration des Secrets GitHub pour CI/CD
Ce guide explique comment configurer les secrets GitHub nécessaires pour le pipeline CI/CD de Xpeditis.
## Secrets Requis
Vous devez configurer les secrets suivants dans votre repository GitHub.
### Accès Repository GitHub
1. Allez sur votre repository GitHub : `https://github.com/VOTRE_USERNAME/xpeditis`
2. Cliquez sur **Settings** (Paramètres)
3. Dans le menu latéral, cliquez sur **Secrets and variables** → **Actions**
4. Cliquez sur **New repository secret**
## Liste des Secrets à Configurer
### 1. REGISTRY_TOKEN (Obligatoire)
**Description** : Token d'authentification pour le registre Docker Scaleway
**Comment l'obtenir** :
1. Connectez-vous à la console Scaleway : https://console.scaleway.com
2. Allez dans **Container Registry** (Registre de conteneurs)
3. Sélectionnez ou créez votre namespace `xpeditis`
4. Cliquez sur **API Keys** ou **Generate token**
5. Créez un nouveau token avec les permissions :
- ✅ Read (Lecture)
- ✅ Write (Écriture)
- ✅ Delete (Suppression)
6. Copiez le token généré
**Configuration GitHub** :
- **Name** : `REGISTRY_TOKEN`
- **Value** : `scw_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
---
### 2. PORTAINER_WEBHOOK_BACKEND (Obligatoire)
**Description** : URL du webhook Portainer pour redéployer le service backend
**Comment l'obtenir** :
1. Connectez-vous à Portainer : `https://portainer.votre-domaine.com`
2. Allez dans **Stacks** → Sélectionnez la stack `xpeditis`
3. Cliquez sur le service **xpeditis-backend**
4. Cliquez sur **Webhooks** (ou **Service webhooks**)
5. Cliquez sur **Add webhook**
6. Copiez l'URL générée (format : `https://portainer.example.com/api/webhooks/xxxxx`)
**Alternative - Créer via API** :
```bash
# Obtenir l'ID de la stack
curl -X GET "https://portainer.example.com/api/stacks" \
-H "X-API-Key: YOUR_PORTAINER_API_KEY"
# Créer le webhook pour le backend
curl -X POST "https://portainer.example.com/api/webhooks" \
-H "X-API-Key: YOUR_PORTAINER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ResourceID": "xpeditis_xpeditis-backend",
"EndpointID": 1,
"WebhookType": 1
}'
```
**Configuration GitHub** :
- **Name** : `PORTAINER_WEBHOOK_BACKEND`
- **Value** : `https://portainer.xpeditis.com/api/webhooks/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
---
### 3. PORTAINER_WEBHOOK_FRONTEND (Obligatoire)
**Description** : URL du webhook Portainer pour redéployer le service frontend
**Comment l'obtenir** : Même procédure que pour `PORTAINER_WEBHOOK_BACKEND` mais pour le service **xpeditis-frontend**
**Configuration GitHub** :
- **Name** : `PORTAINER_WEBHOOK_FRONTEND`
- **Value** : `https://portainer.xpeditis.com/api/webhooks/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy`
---
### 4. DISCORD_WEBHOOK_URL (Optionnel)
**Description** : URL du webhook Discord pour recevoir les notifications de déploiement
**Comment l'obtenir** :
1. Ouvrez Discord et allez sur votre serveur
2. Cliquez sur **Paramètres du serveur** → **Intégrations**
3. Cliquez sur **Webhooks** → **Nouveau Webhook**
4. Donnez un nom au webhook : `Xpeditis CI/CD`
5. Sélectionnez le canal où envoyer les notifications (ex: `#deployments`)
6. Cliquez sur **Copier l'URL du Webhook**
**Configuration GitHub** :
- **Name** : `DISCORD_WEBHOOK_URL`
- **Value** : `https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890`
---
## Vérification des Secrets
Une fois tous les secrets configurés, vous devriez avoir :
```
✅ REGISTRY_TOKEN (Scaleway Container Registry)
✅ PORTAINER_WEBHOOK_BACKEND (Webhook Portainer Backend)
✅ PORTAINER_WEBHOOK_FRONTEND (Webhook Portainer Frontend)
⚠️ DISCORD_WEBHOOK_URL (Optionnel - Notifications Discord)
```
Pour vérifier, allez dans **Settings****Secrets and variables****Actions** de votre repository.
## Test du Pipeline CI/CD
### 1. Créer la branche preprod
```bash
# Sur votre machine locale
cd /chemin/vers/xpeditis2.0
# Créer et pousser la branche preprod
git checkout -b preprod
git push origin preprod
```
### 2. Effectuer un commit de test
```bash
# Faire un petit changement
echo "# Test CI/CD" >> README.md
# Commit et push
git add .
git commit -m "test: trigger CI/CD pipeline"
git push origin preprod
```
### 3. Vérifier l'exécution du pipeline
1. Allez sur GitHub : `https://github.com/VOTRE_USERNAME/xpeditis/actions`
2. Vous devriez voir le workflow **"CI/CD Pipeline - Xpeditis PreProd"** en cours d'exécution
3. Cliquez dessus pour voir les détails de chaque job
### 4. Ordre d'exécution des jobs
```
1. backend-build-test │ Compile et teste le backend
2. frontend-build-test │ Compile et teste le frontend
↓ │
3. backend-docker │ Build image Docker backend
4. frontend-docker │ Build image Docker frontend
↓ │
5. deploy-preprod │ Déploie sur le serveur preprod
↓ │
6. smoke-tests │ Tests de santé post-déploiement
```
## Dépannage
### Erreur : "Invalid login credentials"
**Problème** : Le token Scaleway est invalide ou expiré
**Solution** :
1. Vérifiez que le secret `REGISTRY_TOKEN` est correctement configuré
2. Régénérez un nouveau token dans Scaleway
3. Mettez à jour le secret dans GitHub
---
### Erreur : "Failed to trigger webhook"
**Problème** : L'URL du webhook Portainer est invalide ou le service n'est pas accessible
**Solution** :
1. Vérifiez que Portainer est accessible depuis GitHub Actions
2. Testez le webhook manuellement :
```bash
curl -X POST \
-H "Content-Type: application/json" \
-d '{"test": "true"}' \
https://portainer.xpeditis.com/api/webhooks/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
3. Vérifiez que le webhook existe dans Portainer
4. Recréez le webhook si nécessaire
---
### Erreur : "Health check failed"
**Problème** : Le service déployé ne répond pas après le déploiement
**Solution** :
1. Vérifiez les logs du service dans Portainer
2. Vérifiez que les variables d'environnement sont correctes
3. Vérifiez que les certificats SSL sont valides
4. Vérifiez que les DNS pointent vers le bon serveur
---
### Erreur : "Docker build failed"
**Problème** : Échec de la construction de l'image Docker
**Solution** :
1. Vérifiez les logs du job dans GitHub Actions
2. Testez le build localement :
```bash
docker build -t test -f apps/backend/Dockerfile .
docker build -t test -f apps/frontend/Dockerfile .
```
3. Vérifiez que les Dockerfiles sont corrects
4. Vérifiez que toutes les dépendances sont disponibles
---
## Notifications Discord (Optionnel)
Si vous avez configuré le webhook Discord, vous recevrez des notifications avec :
- ✅ **Statut du déploiement** (Success / Failed)
- 📝 **Message du commit**
- 👤 **Auteur du commit**
- 🔗 **Liens vers Backend et Frontend**
- ⏰ **Horodatage du déploiement**
Exemple de notification :
```
✅ Deployment PreProd - SUCCESS
Branch: preprod
Commit: abc1234
Author: David
Message: feat: add CSV booking workflow
Backend: https://api-preprod.xpeditis.com
Frontend: https://app-preprod.xpeditis.com
Timestamp: 2025-01-15T10:30:00Z
```
---
## Configuration Avancée
### Ajouter des Secrets au Niveau de l'Organisation
Si vous avez plusieurs repositories, vous pouvez définir les secrets au niveau de l'organisation GitHub :
1. Allez dans **Organization settings**
2. Cliquez sur **Secrets and variables** → **Actions**
3. Cliquez sur **New organization secret**
4. Sélectionnez les repositories qui peuvent accéder au secret
### Utiliser des Environnements GitHub
Pour séparer preprod et production avec des secrets différents :
1. Dans **Settings** → **Environments**
2. Créez un environnement `preprod`
3. Ajoutez les secrets spécifiques à preprod
4. Ajoutez des règles de protection (ex: approbation manuelle)
Puis dans le workflow :
```yaml
jobs:
deploy-preprod:
environment: preprod # Utilise les secrets de l'environnement preprod
runs-on: ubuntu-latest
steps:
- name: Deploy
run: echo "Deploying to preprod..."
```
---
## Support
Pour toute question ou problème, consultez :
- [Documentation GitHub Actions](https://docs.github.com/en/actions)
- [Documentation Portainer Webhooks](https://docs.portainer.io/api/webhooks)
- [Documentation Scaleway Container Registry](https://www.scaleway.com/en/docs/containers/container-registry/)

451
.github/workflows/deploy-preprod.yml vendored Normal file
View File

@ -0,0 +1,451 @@
name: CI/CD Pipeline - Xpeditis PreProd
on:
push:
branches:
- preprod
pull_request:
branches:
- preprod
env:
REGISTRY: rg.fr-par.scw.cloud/xpeditis
BACKEND_IMAGE: rg.fr-par.scw.cloud/xpeditis/backend
FRONTEND_IMAGE: rg.fr-par.scw.cloud/xpeditis/frontend
NODE_VERSION: '20'
jobs:
# ============================================================================
# JOB 1: Backend - Build and Test
# ============================================================================
backend-build-test:
name: Backend - Build & Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/backend
steps:
# Checkout code
- name: Checkout Code
uses: actions/checkout@v4
# Setup Node.js
- name: Set up Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/backend/package-lock.json
# Install dependencies
- name: Install Dependencies
run: npm ci
# Run linter
- name: Run ESLint
run: npm run lint
# Run unit tests
- name: Run Unit Tests
run: npm run test
env:
NODE_ENV: test
# Run integration tests (with PostgreSQL and Redis)
- name: Start Test Services (PostgreSQL + Redis)
run: |
docker compose -f ../../docker-compose.test.yml up -d postgres redis
sleep 10
- name: Run Integration Tests
run: npm run test:integration
env:
NODE_ENV: test
DATABASE_HOST: localhost
DATABASE_PORT: 5432
DATABASE_USER: xpeditis_test
DATABASE_PASSWORD: xpeditis_test_password
DATABASE_NAME: xpeditis_test
REDIS_HOST: localhost
REDIS_PORT: 6379
- name: Stop Test Services
if: always()
run: docker compose -f ../../docker-compose.test.yml down -v
# Build backend
- name: Build Backend
run: npm run build
# Upload build artifacts
- name: Upload Backend Build Artifacts
uses: actions/upload-artifact@v4
with:
name: backend-dist
path: apps/backend/dist
retention-days: 1
# ============================================================================
# JOB 2: Frontend - Build and Test
# ============================================================================
frontend-build-test:
name: Frontend - Build & Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/frontend
steps:
# Checkout code
- name: Checkout Code
uses: actions/checkout@v4
# Setup Node.js
- name: Set up Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.json
# Install dependencies
- name: Install Dependencies
run: npm ci
# Run linter
- name: Run ESLint
run: npm run lint
# Type check
- name: TypeScript Type Check
run: npm run type-check
# Build frontend
- name: Build Frontend
run: npm run build
env:
NEXT_PUBLIC_API_URL: https://api-preprod.xpeditis.com
NEXT_PUBLIC_WS_URL: wss://api-preprod.xpeditis.com
# Upload build artifacts
- name: Upload Frontend Build Artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: apps/frontend/.next
retention-days: 1
# ============================================================================
# JOB 3: Backend - Docker Build & Push
# ============================================================================
backend-docker:
name: Backend - Docker Build & Push
runs-on: ubuntu-latest
needs: [backend-build-test]
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Setup QEMU for multi-platform builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Setup Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Login to Scaleway Registry
- name: Login to Scaleway Registry
uses: docker/login-action@v3
with:
registry: rg.fr-par.scw.cloud/xpeditis
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
# Extract metadata for Docker
- name: Extract Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.BACKEND_IMAGE }}
tags: |
type=raw,value=preprod
type=sha,prefix=preprod-
# Build and push Docker image
- name: Build and Push Backend Image
uses: docker/build-push-action@v5
with:
context: .
file: ./apps/backend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.BACKEND_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.BACKEND_IMAGE }}:buildcache,mode=max
build-args: |
NODE_ENV=production
# Cleanup
- name: Docker Cleanup
if: always()
run: docker system prune -af
# ============================================================================
# JOB 4: Frontend - Docker Build & Push
# ============================================================================
frontend-docker:
name: Frontend - Docker Build & Push
runs-on: ubuntu-latest
needs: [frontend-build-test]
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Setup QEMU for multi-platform builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Setup Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Login to Scaleway Registry
- name: Login to Scaleway Registry
uses: docker/login-action@v3
with:
registry: rg.fr-par.scw.cloud/xpeditis
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
# Extract metadata for Docker
- name: Extract Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FRONTEND_IMAGE }}
tags: |
type=raw,value=preprod
type=sha,prefix=preprod-
# Build and push Docker image
- name: Build and Push Frontend Image
uses: docker/build-push-action@v5
with:
context: .
file: ./apps/frontend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.FRONTEND_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.FRONTEND_IMAGE }}:buildcache,mode=max
build-args: |
NODE_ENV=production
NEXT_PUBLIC_API_URL=https://api-preprod.xpeditis.com
NEXT_PUBLIC_WS_URL=wss://api-preprod.xpeditis.com
# Cleanup
- name: Docker Cleanup
if: always()
run: docker system prune -af
# ============================================================================
# JOB 5: Deploy to PreProd Server (Portainer Webhook)
# ============================================================================
deploy-preprod:
name: Deploy to PreProd Server
runs-on: ubuntu-latest
needs: [backend-docker, frontend-docker]
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Trigger Portainer Webhook to redeploy stack
- name: Trigger Portainer Webhook - Backend
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d '{"service": "backend", "image": "${{ env.BACKEND_IMAGE }}:preprod", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' \
${{ secrets.PORTAINER_WEBHOOK_BACKEND }}
- name: Wait for Backend Deployment
run: sleep 30
- name: Trigger Portainer Webhook - Frontend
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d '{"service": "frontend", "image": "${{ env.FRONTEND_IMAGE }}:preprod", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' \
${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}
- name: Wait for Frontend Deployment
run: sleep 30
# Health check
- name: Health Check - Backend API
run: |
MAX_RETRIES=10
RETRY_COUNT=0
echo "Waiting for backend API to be healthy..."
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api-preprod.xpeditis.com/health || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ Backend API is healthy (HTTP $HTTP_CODE)"
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "⏳ Attempt $RETRY_COUNT/$MAX_RETRIES - Backend API returned HTTP $HTTP_CODE, retrying in 10s..."
sleep 10
done
echo "❌ Backend API health check failed after $MAX_RETRIES attempts"
exit 1
- name: Health Check - Frontend
run: |
MAX_RETRIES=10
RETRY_COUNT=0
echo "Waiting for frontend to be healthy..."
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://app-preprod.xpeditis.com || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ Frontend is healthy (HTTP $HTTP_CODE)"
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "⏳ Attempt $RETRY_COUNT/$MAX_RETRIES - Frontend returned HTTP $HTTP_CODE, retrying in 10s..."
sleep 10
done
echo "❌ Frontend health check failed after $MAX_RETRIES attempts"
exit 1
# Send deployment notification
- name: Send Deployment Notification
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then
STATUS_EMOJI="✅"
STATUS_TEXT="SUCCESS"
COLOR="3066993"
else
STATUS_EMOJI="❌"
STATUS_TEXT="FAILED"
COLOR="15158332"
fi
COMMIT_SHA="${{ github.sha }}"
COMMIT_SHORT="${COMMIT_SHA:0:7}"
COMMIT_MSG="${{ github.event.head_commit.message }}"
AUTHOR="${{ github.event.head_commit.author.name }}"
# Webhook Discord (si configuré)
if [ -n "${{ secrets.DISCORD_WEBHOOK_URL }}" ]; then
curl -H "Content-Type: application/json" \
-d "{
\"embeds\": [{
\"title\": \"$STATUS_EMOJI Deployment PreProd - $STATUS_TEXT\",
\"description\": \"**Branch:** preprod\n**Commit:** [\`$COMMIT_SHORT\`](https://github.com/${{ github.repository }}/commit/$COMMIT_SHA)\n**Author:** $AUTHOR\n**Message:** $COMMIT_MSG\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Backend\", \"value\": \"https://api-preprod.xpeditis.com\", \"inline\": true},
{\"name\": \"Frontend\", \"value\": \"https://app-preprod.xpeditis.com\", \"inline\": true}
],
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}]
}" \
${{ secrets.DISCORD_WEBHOOK_URL }}
fi
# ============================================================================
# JOB 6: Run Smoke Tests (Post-Deployment)
# ============================================================================
smoke-tests:
name: Run Smoke Tests
runs-on: ubuntu-latest
needs: [deploy-preprod]
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Test Backend API Endpoints
- name: Test Backend API - Health
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api-preprod.xpeditis.com/health)
if [ "$HTTP_CODE" != "200" ]; then
echo "❌ Health endpoint failed (HTTP $HTTP_CODE)"
exit 1
fi
echo "✅ Health endpoint OK"
- name: Test Backend API - Swagger Docs
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api-preprod.xpeditis.com/api/docs)
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "301" ]; then
echo "❌ Swagger docs failed (HTTP $HTTP_CODE)"
exit 1
fi
echo "✅ Swagger docs OK"
- name: Test Backend API - Rate Search Endpoint
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST https://api-preprod.xpeditis.com/api/v1/rates/search-csv \
-H "Content-Type: application/json" \
-d '{
"origin": "NLRTM",
"destination": "USNYC",
"volumeCBM": 5,
"weightKG": 1000,
"palletCount": 3
}')
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "401" ]; then
echo "❌ Rate search endpoint failed (HTTP $HTTP_CODE)"
exit 1
fi
echo "✅ Rate search endpoint OK (HTTP $HTTP_CODE)"
# Test Frontend
- name: Test Frontend - Homepage
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://app-preprod.xpeditis.com)
if [ "$HTTP_CODE" != "200" ]; then
echo "❌ Frontend homepage failed (HTTP $HTTP_CODE)"
exit 1
fi
echo "✅ Frontend homepage OK"
- name: Test Frontend - Login Page
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://app-preprod.xpeditis.com/login)
if [ "$HTTP_CODE" != "200" ]; then
echo "❌ Frontend login page failed (HTTP $HTTP_CODE)"
exit 1
fi
echo "✅ Frontend login page OK"
# Summary
- name: Tests Summary
run: |
echo "================================================"
echo "✅ All smoke tests passed successfully!"
echo "================================================"
echo "Backend API: https://api-preprod.xpeditis.com"
echo "Frontend App: https://app-preprod.xpeditis.com"
echo "Swagger Docs: https://api-preprod.xpeditis.com/api/docs"
echo "================================================"

600
BOOKING_WORKFLOW_TODO.md Normal file
View File

@ -0,0 +1,600 @@
# Booking Workflow - Todo List
Ce document détaille toutes les tâches nécessaires pour implémenter le workflow complet de booking avec système d'acceptation/refus par email et notifications.
## Vue d'ensemble
Le workflow permet à un utilisateur de:
1. Sélectionner une option de transport depuis les résultats de recherche
2. Remplir un formulaire avec les documents nécessaires
3. Envoyer une demande de booking par email au transporteur
4. Le transporteur peut accepter ou refuser via des boutons dans l'email
5. L'utilisateur reçoit une notification sur son dashboard
---
## Backend - Domain Layer (3 tâches)
### ✅ Task 2: Créer l'entité Booking dans le domain
**Fichier**: `apps/backend/src/domain/entities/booking.entity.ts` (à créer)
**Actions**:
- Créer l'enum `BookingStatus` (PENDING, ACCEPTED, REJECTED, CANCELLED)
- Créer la classe `Booking` avec:
- `id: string`
- `userId: string`
- `organizationId: string`
- `carrierName: string`
- `carrierEmail: string`
- `origin: PortCode`
- `destination: PortCode`
- `volumeCBM: number`
- `weightKG: number`
- `priceEUR: number`
- `transitDays: number`
- `status: BookingStatus`
- `documents: Document[]` (Bill of Lading, Packing List, Commercial Invoice, Certificate of Origin)
- `confirmationToken: string` (pour les liens email)
- `requestedAt: Date`
- `respondedAt?: Date`
- `notes?: string`
- Méthodes: `accept()`, `reject()`, `cancel()`, `isExpired()`
---
### ✅ Task 3: Créer l'entité Notification dans le domain
**Fichier**: `apps/backend/src/domain/entities/notification.entity.ts` (à créer)
**Actions**:
- Créer l'enum `NotificationType` (BOOKING_ACCEPTED, BOOKING_REJECTED, BOOKING_CREATED)
- Créer la classe `Notification` avec:
- `id: string`
- `userId: string`
- `type: NotificationType`
- `title: string`
- `message: string`
- `bookingId?: string`
- `isRead: boolean`
- `createdAt: Date`
- Méthodes: `markAsRead()`, `isRecent()`
---
## Backend - Infrastructure Layer (4 tâches)
### ✅ Task 4: Mettre à jour le CSV loader pour passer companyEmail
**Fichier**: `apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts`
**Actions**:
- ✅ Interface `CsvRow` déjà mise à jour avec `companyEmail`
- Modifier la méthode `mapToCsvRate()` pour passer `record.companyEmail` au constructeur de `CsvRate`
- Ajouter `'companyEmail'` dans le tableau `requiredColumns` de `validateCsvStructure()`
**Code à modifier** (ligne ~267):
```typescript
return new CsvRate(
record.companyName.trim(),
record.companyEmail.trim(), // NOUVEAU
PortCode.create(record.origin),
// ... reste
)
```
---
### ✅ Task 5: Créer le repository BookingRepository
**Fichiers à créer**:
- `apps/backend/src/domain/ports/out/booking.repository.ts` (interface)
- `apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts`
- `apps/backend/src/infrastructure/persistence/typeorm/repositories/booking.repository.ts`
**Actions**:
- Créer l'interface du port avec méthodes:
- `create(booking: Booking): Promise<Booking>`
- `findById(id: string): Promise<Booking | null>`
- `findByUserId(userId: string): Promise<Booking[]>`
- `findByToken(token: string): Promise<Booking | null>`
- `update(booking: Booking): Promise<Booking>`
- Créer l'entité ORM avec décorateurs TypeORM
- Implémenter le repository avec TypeORM
---
### ✅ Task 6: Créer le repository NotificationRepository
**Fichiers à créer**:
- `apps/backend/src/domain/ports/out/notification.repository.ts` (interface)
- `apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts`
- `apps/backend/src/infrastructure/persistence/typeorm/repositories/notification.repository.ts`
**Actions**:
- Créer l'interface du port avec méthodes:
- `create(notification: Notification): Promise<Notification>`
- `findByUserId(userId: string, unreadOnly?: boolean): Promise<Notification[]>`
- `markAsRead(id: string): Promise<void>`
- `markAllAsRead(userId: string): Promise<void>`
- Créer l'entité ORM
- Implémenter le repository
---
### ✅ Task 7: Créer le service d'envoi d'email
**Fichier**: `apps/backend/src/infrastructure/email/email.service.ts` (à créer)
**Actions**:
- Utiliser `nodemailer` ou un service comme SendGrid/Mailgun
- Créer la méthode `sendBookingRequest(booking: Booking, acceptUrl: string, rejectUrl: string)`
- Créer le template HTML avec:
- Récapitulatif du booking (origine, destination, volume, poids, prix)
- Liste des documents joints
- 2 boutons CTA: "Accepter la demande" (vert) et "Refuser la demande" (rouge)
- Design responsive
**Template email**:
```html
<!DOCTYPE html>
<html>
<head>
<style>
/* Styles inline pour compatibilité email */
</style>
</head>
<body>
<h1>Nouvelle demande de réservation - Xpeditis</h1>
<div class="summary">
<h2>Détails du transport</h2>
<p><strong>Route:</strong> {{origin}} → {{destination}}</p>
<p><strong>Volume:</strong> {{volumeCBM}} CBM</p>
<p><strong>Poids:</strong> {{weightKG}} kg</p>
<p><strong>Prix:</strong> {{priceEUR}} EUR</p>
<p><strong>Transit:</strong> {{transitDays}} jours</p>
</div>
<div class="documents">
<h3>Documents fournis:</h3>
<ul>
{{#each documents}}
<li>{{this.name}}</li>
{{/each}}
</ul>
</div>
<div class="actions">
<a href="{{acceptUrl}}" class="btn btn-accept">✓ Accepter la demande</a>
<a href="{{rejectUrl}}" class="btn btn-reject">✗ Refuser la demande</a>
</div>
</body>
</html>
```
---
## Backend - Application Layer (5 tâches)
### ✅ Task 8: Ajouter companyEmail dans le DTO de réponse
**Fichier**: `apps/backend/src/application/dto/csv-rate-search.dto.ts`
**Actions**:
- Ajouter `@ApiProperty() companyEmail: string;` dans `CsvRateSearchResultDto`
- Mettre à jour le mapper pour inclure `companyEmail`
---
### ✅ Task 9: Créer les DTOs pour créer un booking
**Fichier**: `apps/backend/src/application/dto/booking.dto.ts` (à créer)
**Actions**:
- Créer `CreateBookingDto` avec validation:
```typescript
export class CreateBookingDto {
@ApiProperty()
@IsString()
carrierName: string;
@ApiProperty()
@IsEmail()
carrierEmail: string;
@ApiProperty()
@IsString()
origin: string;
@ApiProperty()
@IsString()
destination: string;
@ApiProperty()
@IsNumber()
@Min(0)
volumeCBM: number;
@ApiProperty()
@IsNumber()
@Min(0)
weightKG: number;
@ApiProperty()
@IsNumber()
@Min(0)
priceEUR: number;
@ApiProperty()
@IsNumber()
@Min(1)
transitDays: number;
@ApiProperty({ type: 'array', items: { type: 'string', format: 'binary' } })
documents: Express.Multer.File[];
@ApiProperty({ required: false })
@IsOptional()
@IsString()
notes?: string;
}
```
- Créer `BookingResponseDto`
- Créer `NotificationDto`
---
### ✅ Task 10: Créer l'endpoint POST /api/v1/bookings
**Fichier**: `apps/backend/src/application/controllers/booking.controller.ts` (à créer)
**Actions**:
- Créer le controller avec méthode `createBooking()`
- Utiliser `@UseInterceptors(FilesInterceptor('documents'))` pour l'upload
- Générer un `confirmationToken` unique (UUID)
- Sauvegarder les documents sur le système de fichiers ou S3
- Créer le booking avec status PENDING
- Générer les URLs d'acceptation/refus
- Envoyer l'email au transporteur
- Créer une notification pour l'utilisateur (BOOKING_CREATED)
- Retourner le booking créé
**Endpoint**:
```typescript
@Post()
@UseGuards(JwtAuthGuard)
@UseInterceptors(FilesInterceptor('documents', 10))
@ApiOperation({ summary: 'Create a new booking request' })
@ApiResponse({ status: 201, type: BookingResponseDto })
async createBooking(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req
): Promise<BookingResponseDto> {
// Implementation
}
```
---
### ✅ Task 11: Créer l'endpoint GET /api/v1/bookings/:id/accept
**Fichier**: `apps/backend/src/application/controllers/booking.controller.ts`
**Actions**:
- Endpoint PUBLIC (pas de auth guard)
- Vérifier le token de confirmation
- Trouver le booking par token
- Vérifier que le status est PENDING
- Mettre à jour le status à ACCEPTED
- Créer une notification pour l'utilisateur (BOOKING_ACCEPTED)
- Rediriger vers `/booking/confirm/:token` (frontend)
**Endpoint**:
```typescript
@Get(':id/accept')
@ApiOperation({ summary: 'Accept a booking request (public endpoint)' })
async acceptBooking(
@Param('id') bookingId: string,
@Query('token') token: string
): Promise<void> {
// Validation + Update + Notification + Redirect
}
```
---
### ✅ Task 12: Créer l'endpoint GET /api/v1/bookings/:id/reject
**Fichier**: `apps/backend/src/application/controllers/booking.controller.ts`
**Actions**:
- Endpoint PUBLIC (pas de auth guard)
- Même logique que accept mais avec status REJECTED
- Créer une notification BOOKING_REJECTED
- Rediriger vers `/booking/reject/:token` (frontend)
---
### ✅ Task 13: Créer l'endpoint GET /api/v1/notifications
**Fichier**: `apps/backend/src/application/controllers/notification.controller.ts` (à créer)
**Actions**:
- Endpoint protégé (JwtAuthGuard)
- Query param optionnel `?unreadOnly=true`
- Retourner les notifications de l'utilisateur
**Endpoints supplémentaires**:
- `PATCH /api/v1/notifications/:id/read` - Marquer comme lu
- `PATCH /api/v1/notifications/read-all` - Tout marquer comme lu
---
## Frontend (9 tâches)
### ✅ Task 14: Modifier la page results pour rendre les boutons Sélectionner cliquables
**Fichier**: `apps/frontend/app/dashboard/search/results/page.tsx`
**Actions**:
- Modifier le bouton "Sélectionner cette option" pour rediriger vers `/dashboard/booking/new`
- Passer les données du rate via query params ou state
- Exemple: `/dashboard/booking/new?rateData=${encodeURIComponent(JSON.stringify(option))}`
---
### ✅ Task 15: Créer la page /dashboard/booking/new avec formulaire multi-étapes
**Fichier**: `apps/frontend/app/dashboard/booking/new/page.tsx` (à créer)
**Actions**:
- Créer un formulaire en 3 étapes:
1. **Étape 1**: Confirmation des détails du transport (lecture seule)
2. **Étape 2**: Upload des documents (Bill of Lading, Packing List, Commercial Invoice, Certificate of Origin)
3. **Étape 3**: Révision et envoi
**Structure**:
```typescript
interface BookingForm {
// Données du rate (pré-remplies)
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
priceEUR: number;
transitDays: number;
// Documents à uploader
documents: {
billOfLading?: File;
packingList?: File;
commercialInvoice?: File;
certificateOfOrigin?: File;
};
// Notes optionnelles
notes?: string;
}
```
---
### ✅ Task 16: Ajouter upload de documents
**Fichier**: `apps/frontend/app/dashboard/booking/new/page.tsx`
**Actions**:
- Utiliser `<input type="file" multiple accept=".pdf,.doc,.docx" />`
- Afficher la liste des fichiers sélectionnés avec possibilité de supprimer
- Validation: taille max 5MB par fichier, formats acceptés (PDF, DOC, DOCX)
- Preview des noms de fichiers
**Composant**:
```typescript
<div className="space-y-4">
<div>
<label>Bill of Lading *</label>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => handleFileChange('billOfLading', e.target.files?.[0])}
/>
</div>
{/* Répéter pour les autres documents */}
</div>
```
---
### ✅ Task 17: Créer l'API client pour les bookings
**Fichier**: `apps/frontend/src/lib/api/bookings.ts` (à créer)
**Actions**:
- Créer `createBooking(formData: FormData): Promise<BookingResponse>`
- Créer `getBookings(): Promise<Booking[]>`
- Utiliser `upload()` de `client.ts` pour les fichiers
---
### ✅ Task 18: Créer la page /booking/confirm/:token (acceptation publique)
**Fichier**: `apps/frontend/app/booking/confirm/[token]/page.tsx` (à créer)
**Actions**:
- Page publique (pas de layout dashboard)
- Afficher un message de succès avec animation
- Afficher le récapitulatif du booking accepté
- Message: "Merci d'avoir accepté cette demande de transport. Le client a été notifié."
- Design: card centrée avec icône ✓ verte
---
### ✅ Task 19: Créer la page /booking/reject/:token (refus publique)
**Fichier**: `apps/frontend/app/booking/reject/[token]/page.tsx` (à créer)
**Actions**:
- Page publique
- Formulaire optionnel pour raison du refus
- Message: "Vous avez refusé cette demande de transport. Le client a été notifié."
- Design: card centrée avec icône ✗ rouge
---
### ✅ Task 20: Ajouter le composant NotificationBell dans le dashboard
**Fichier**: `apps/frontend/src/components/NotificationBell.tsx` (à créer)
**Actions**:
- Icône de cloche dans le header du dashboard
- Badge rouge avec le nombre de notifications non lues
- Dropdown au clic avec liste des notifications
- Marquer comme lu au clic
- Lien vers le booking concerné
**Intégration**:
- Ajouter dans `apps/frontend/app/dashboard/layout.tsx` dans le header (ligne ~154, à côté du User Role Badge)
---
### ✅ Task 21: Créer le hook useNotifications pour polling
**Fichier**: `apps/frontend/src/hooks/useNotifications.ts` (à créer)
**Actions**:
- Hook custom qui fait du polling toutes les 30 secondes
- Retourne: `{ notifications, unreadCount, markAsRead, markAllAsRead, isLoading }`
- Utiliser `useQuery` de TanStack Query avec `refetchInterval: 30000`
**Code**:
```typescript
export function useNotifications() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['notifications'],
queryFn: () => notificationsApi.getNotifications(),
refetchInterval: 30000, // 30 seconds
});
const markAsRead = async (id: string) => {
await notificationsApi.markAsRead(id);
refetch();
};
return {
notifications: data?.notifications || [],
unreadCount: data?.unreadCount || 0,
markAsRead,
isLoading,
};
}
```
---
### ✅ Task 22: Tester le workflow complet end-to-end
**Actions**:
1. Lancer le backend et le frontend
2. Se connecter au dashboard
3. Faire une recherche de tarifs
4. Cliquer sur "Sélectionner cette option"
5. Remplir le formulaire de booking
6. Uploader des documents (fichiers de test)
7. Soumettre le booking
8. Vérifier que l'email est envoyé (vérifier les logs ou mailhog si configuré)
9. Cliquer sur "Accepter" dans l'email
10. Vérifier la page de confirmation
11. Vérifier que la notification apparaît dans le dashboard
12. Répéter avec "Refuser"
**Checklist de test**:
- [ ] Création de booking réussie
- [ ] Email reçu avec les bonnes informations
- [ ] Bouton Accepter fonctionne et redirige correctement
- [ ] Bouton Refuser fonctionne et redirige correctement
- [ ] Notifications apparaissent dans le dashboard
- [ ] Badge de notification se met à jour
- [ ] Documents sont bien stockés
- [ ] Données cohérentes en base de données
---
## Dépendances NPM à ajouter
### Backend
```bash
cd apps/backend
npm install nodemailer @types/nodemailer
npm install handlebars # Pour les templates email
npm install uuid @types/uuid
```
### Frontend
```bash
cd apps/frontend
# Tout est déjà installé (React Hook Form, TanStack Query, etc.)
```
---
## Configuration requise
### Variables d'environnement backend
Ajouter dans `apps/backend/.env`:
```env
# Email configuration (exemple avec Gmail)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_SECURE=false
EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-app-password
EMAIL_FROM=noreply@xpeditis.com
# Frontend URL for email links
FRONTEND_URL=http://localhost:3000
# File upload
MAX_FILE_SIZE=5242880 # 5MB
UPLOAD_DEST=./uploads/documents
```
---
## Migrations de base de données
### Backend - TypeORM migrations
```bash
cd apps/backend
# Générer les migrations
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateBookingAndNotification
# Appliquer les migrations
npm run migration:run
```
**Tables à créer**:
- `bookings` (id, user_id, organization_id, carrier_name, carrier_email, origin, destination, volume_cbm, weight_kg, price_eur, transit_days, status, confirmation_token, documents_path, notes, requested_at, responded_at, created_at, updated_at)
- `notifications` (id, user_id, type, title, message, booking_id, is_read, created_at)
---
## Estimation de temps
| Partie | Tâches | Temps estimé |
|--------|--------|--------------|
| Backend - Domain | 3 | 2-3 heures |
| Backend - Infrastructure | 4 | 3-4 heures |
| Backend - Application | 5 | 3-4 heures |
| Frontend | 8 | 4-5 heures |
| Testing & Debug | 1 | 2-3 heures |
| **TOTAL** | **22** | **14-19 heures** |
---
## Notes importantes
1. **Sécurité des tokens**: Utiliser des UUID v4 pour les confirmation tokens
2. **Expiration des liens**: Ajouter une expiration (ex: 48h) pour les liens d'acceptation/refus
3. **Rate limiting**: Limiter les appels aux endpoints publics (accept/reject)
4. **Stockage des documents**: Considérer S3 pour la production au lieu du filesystem local
5. **Email fallback**: Si l'envoi échoue, logger et permettre un retry
6. **Notifications temps réel**: Pour une V2, considérer WebSockets au lieu du polling
---
## Prochaines étapes
Une fois cette fonctionnalité complète, on pourra ajouter:
- [ ] Page de liste des bookings (`/dashboard/bookings`)
- [ ] Filtres et recherche dans les bookings
- [ ] Export des bookings en PDF/Excel
- [ ] Historique des statuts (timeline)
- [ ] Chat intégré avec le transporteur
- [ ] Système de rating après livraison

View File

@ -0,0 +1,690 @@
# CSV Booking Workflow - End-to-End Test Plan
## Overview
This document provides a comprehensive test plan for the CSV booking workflow feature. The workflow allows users to search CSV rates, create booking requests, and carriers to accept/reject bookings via email.
## Prerequisites
### Backend Setup
✅ Backend running at http://localhost:4000
✅ Database connected (PostgreSQL)
✅ Redis connected for caching
✅ Email service configured (SMTP)
### Frontend Setup
✅ Frontend running at http://localhost:3000
✅ User authenticated (dharnaud77@hotmail.fr)
### Test Data Required
- Valid user account with ADMIN role
- CSV rate data uploaded to database
- Test documents (PDF, DOC, images) for upload
- Valid origin/destination port codes (e.g., NLRTM → USNYC)
## Test Scenarios
### ✅ Scenario 1: Complete Happy Path (Acceptance)
#### Step 1: Login to Dashboard
**Action**: Navigate to http://localhost:3000/login
- Enter email: dharnaud77@hotmail.fr
- Enter password: [user password]
- Click "Se connecter"
**Expected Result**:
- ✅ Redirect to /dashboard
- ✅ User role badge shows "ADMIN"
- ✅ Notification bell icon visible in header
**Status**: ✅ COMPLETED (User logged in successfully)
---
#### Step 2: Search for CSV Rates
**Action**: Navigate to Advanced Search
- Click "Recherche avancée" in sidebar
- Fill search form:
- Origin: NLRTM (Rotterdam)
- Destination: USNYC (New York)
- Volume: 5 CBM
- Weight: 1000 KG
- Pallets: 3
- Click "Rechercher les tarifs"
**Expected Result**:
- Redirect to /dashboard/search-advanced/results
- Display "Meilleurs choix" cards (top 3 results)
- Display full results table with company info
- Each result shows "Sélectionner" button
- Results show price in USD and EUR
- Transit days displayed
**How to Verify**:
```bash
# Check backend logs for rate search
# Should see: POST /api/v1/rates/search-csv
```
---
#### Step 3: Select a Rate
**Action**: Click "Sélectionner" button on any result
**Expected Result**:
- Redirect to /dashboard/booking/new with rate data in query params
- URL format: `/dashboard/booking/new?rateData=<encoded_json>`
- Form auto-populated with rate information:
- Carrier name
- Carrier email
- Origin/destination
- Volume, weight, pallets
- Price (USD and EUR)
- Transit days
- Container type
**How to Verify**:
- Check browser console for no errors
- Verify all fields are read-only and pre-filled
---
#### Step 4: Upload Documents (Step 2)
**Action**: Click "Suivant" to go to step 2
- Click "Parcourir" or drag files into upload zone
- Upload test documents:
- Bill of Lading (PDF)
- Packing List (DOC/DOCX)
- Commercial Invoice (PDF)
**Expected Result**:
- Files appear in preview list with names and sizes
- File validation works:
- ✅ Max 5MB per file
- ✅ Only PDF, DOC, DOCX, JPG, JPEG, PNG accepted
- ❌ Error message for invalid files
- Delete button (trash icon) works for each file
- Notes textarea available (optional)
**How to Verify**:
```javascript
// Check console for validation errors
// Try uploading:
// - Large file (>5MB) → Should show error
// - Invalid format (.txt, .exe) → Should show error
// - Valid files → Should add to list
```
---
#### Step 5: Review and Submit (Step 3)
**Action**: Click "Suivant" to go to step 3
- Review all information
- Check "J'ai lu et j'accepte les conditions générales"
- Click "Confirmer et créer le booking"
**Expected Result**:
- Loading spinner appears
- Submit button shows "Envoi en cours..."
- After 2-3 seconds:
- Redirect to /dashboard/bookings?success=true&id=<booking_id>
- Success message displayed
- New booking appears in bookings list
**How to Verify**:
```bash
# Backend logs should show:
# 1. POST /api/v1/csv-bookings (multipart/form-data)
# 2. Documents uploaded to S3/MinIO
# 3. Email sent to carrier
# 4. Notification created for user
# Database check:
psql -h localhost -U xpeditis -d xpeditis_dev -c "
SELECT id, booking_id, carrier_name, status, created_at
FROM csv_bookings
ORDER BY created_at DESC
LIMIT 1;
"
# Should return:
# - status = 'PENDING'
# - booking_id in format 'WCM-YYYY-XXXXXX'
# - created_at = recent timestamp
```
---
#### Step 6: Verify Email Sent
**Action**: Check carrier email inbox (or backend logs)
**Expected Result**:
Email received with:
- Subject: "Nouvelle demande de transport maritime - [Booking ID]"
- From: noreply@xpeditis.com
- To: [carrier email from CSV]
- Content:
- Booking details (origin, destination, volume, weight)
- Price offered
- Document attachments or links
- Two prominent buttons:
- ✅ "Accepter cette demande" → Links to /booking/confirm/:token
- ❌ "Refuser cette demande" → Links to /booking/reject/:token
**How to Verify**:
```bash
# Check backend logs for email sending:
grep "Email sent" logs/backend.log
# If using MailHog (dev):
# Open http://localhost:8025
# Check for latest email
```
---
#### Step 7: Carrier Accepts Booking
**Action**: Click "Accepter cette demande" button in email
**Expected Result**:
- Open browser to: http://localhost:3000/booking/confirm/:token
- Page shows:
- ✅ Green checkmark icon with animation
- "Demande acceptée!" heading
- "Merci d'avoir accepté cette demande de transport"
- "Le client a été notifié par email"
- Full booking summary:
- Booking ID
- Route (origin → destination)
- Volume, weight, pallets
- Container type
- Transit days
- Price (primary + secondary currency)
- Notes (if any)
- Documents list with download links
- "Prochaines étapes" info box
- Contact info (support@xpeditis.com)
**How to Verify**:
```bash
# Backend logs should show:
# POST /api/v1/csv-bookings/:token/accept
# Database check:
psql -h localhost -U xpeditis -d xpeditis_dev -c "
SELECT id, status, accepted_at, email_sent_at
FROM csv_bookings
WHERE confirmation_token = '<token>';
"
# Should return:
# - status = 'ACCEPTED'
# - accepted_at = recent timestamp
# - email_sent_at = not null
```
---
#### Step 8: Verify User Notification
**Action**: Return to dashboard at http://localhost:3000/dashboard
**Expected Result**:
- ✅ Red badge appears on notification bell (count: 1)
- Click bell icon to open dropdown
- New notification visible:
- Title: "Booking accepté"
- Message: "Votre demande de transport [Booking ID] a été acceptée par [Carrier]"
- Type icon: ✅
- Priority badge: "high"
- Time: "Just now" or "1m ago"
- Unread indicator (blue dot)
- Click notification:
- Mark as read automatically
- Blue dot disappears
- Badge count decreases
- Redirect to booking details (if actionUrl set)
**How to Verify**:
```bash
# Database check:
psql -h localhost -U xpeditis -d xpeditis_dev -c "
SELECT id, type, title, message, read, priority
FROM notifications
WHERE user_id = '<user_id>'
ORDER BY created_at DESC
LIMIT 1;
"
# Should return:
# - type = 'BOOKING_CONFIRMED' or 'CSV_BOOKING_ACCEPTED'
# - read = false (initially)
# - priority = 'high'
```
---
### ✅ Scenario 2: Rejection Flow
#### Steps 1-6: Same as Acceptance Flow
Follow steps 1-6 from Scenario 1 to create a booking and receive email.
---
#### Step 7: Carrier Rejects Booking
**Action**: Click "Refuser cette demande" button in email
**Expected Result**:
- Open browser to: http://localhost:3000/booking/reject/:token
- Page shows:
- ⚠️ Orange warning icon
- "Refuser cette demande" heading
- "Vous êtes sur le point de refuser cette demande de transport"
- Optional reason field (expandable):
- Button: "Ajouter une raison (optionnel)"
- Click to expand textarea
- Placeholder: "Ex: Prix trop élevé, délais trop courts..."
- Character counter: "0/500"
- Warning message: "Cette action est irréversible"
- Two buttons:
- ❌ "Confirmer le refus" (red, primary)
- 📧 "Contacter le support" (white, secondary)
**Action**: Add optional reason and click "Confirmer le refus"
- Type reason: "Prix trop élevé pour cette route"
- Click "Confirmer le refus"
**Expected Result**:
- Loading spinner appears
- Button shows "Refus en cours..."
- After 2-3 seconds:
- Success screen appears:
- ❌ Red X icon with animation
- "Demande refusée" heading
- "Vous avez refusé cette demande de transport"
- "Le client a été notifié par email"
- Booking summary (same format as acceptance)
- Reason displayed in card: "Raison du refus: Prix trop élevé..."
- Info box about next steps
**How to Verify**:
```bash
# Backend logs:
# POST /api/v1/csv-bookings/:token/reject
# Body: { "reason": "Prix trop élevé pour cette route" }
# Database check:
psql -h localhost -U xpeditis -d xpeditis_dev -c "
SELECT id, status, rejected_at, rejection_reason
FROM csv_bookings
WHERE confirmation_token = '<token>';
"
# Should return:
# - status = 'REJECTED'
# - rejected_at = recent timestamp
# - rejection_reason = "Prix trop élevé pour cette route"
```
---
#### Step 8: Verify User Notification (Rejection)
**Action**: Return to dashboard
**Expected Result**:
- ✅ Red badge on notification bell
- New notification:
- Title: "Booking refusé"
- Message: "Votre demande [Booking ID] a été refusée par [Carrier]. Raison: Prix trop élevé..."
- Type icon: ❌
- Priority: "high"
- Time: "Just now"
---
### ✅ Scenario 3: Error Handling
#### Test 3.1: Invalid File Upload
**Action**: Try uploading invalid files
- Upload .txt file → Should show error
- Upload file > 5MB → Should show "Fichier trop volumineux"
- Upload .exe file → Should show "Type de fichier non accepté"
**Expected Result**: Error messages displayed, files not added to list
---
#### Test 3.2: Submit Without Documents
**Action**: Try to proceed to step 3 without uploading documents
**Expected Result**:
- "Suivant" button disabled OR
- Error message: "Veuillez ajouter au moins un document"
---
#### Test 3.3: Invalid/Expired Token
**Action**: Try accessing with invalid token
- Visit: http://localhost:3000/booking/confirm/invalid-token-12345
**Expected Result**:
- Error page displays:
- ❌ Red X icon
- "Erreur de confirmation" heading
- Error message explaining token is invalid
- "Raisons possibles" list:
- Le lien a expiré
- La demande a déjà été acceptée ou refusée
- Le token est invalide
---
#### Test 3.4: Double Acceptance/Rejection
**Action**: After accepting a booking, try to access reject link (or vice versa)
**Expected Result**:
- Error message: "Cette demande a déjà été traitée"
- Status shown: "ACCEPTED" or "REJECTED"
---
### ✅ Scenario 4: Notification Polling
#### Test 4.1: Real-Time Updates
**Action**:
1. Open dashboard
2. Wait 30 seconds (polling interval)
3. Accept a booking from another tab/email
**Expected Result**:
- Within 30 seconds, notification bell badge updates automatically
- No page refresh required
- New notification appears in dropdown
---
#### Test 4.2: Mark as Read
**Action**:
1. Open notification dropdown
2. Click on an unread notification
**Expected Result**:
- Blue dot disappears
- Badge count decreases by 1
- Background color changes from blue-50 to white
- Dropdown closes
- If actionUrl exists, redirect to that page
---
#### Test 4.3: Mark All as Read
**Action**:
1. Open dropdown with multiple unread notifications
2. Click "Mark all as read"
**Expected Result**:
- All blue dots disappear
- Badge shows 0
- All notification backgrounds change to white
- Dropdown remains open
---
## Test Checklist Summary
### ✅ Core Functionality
- [ ] User can search CSV rates
- [ ] "Sélectionner" buttons redirect to booking form
- [ ] Rate data pre-populates form correctly
- [ ] Multi-step form navigation works (steps 1-3)
- [ ] File upload validates size and format
- [ ] File deletion works
- [ ] Form submission creates booking
- [ ] Redirect to bookings list after success
### ✅ Email & Notifications
- [ ] Email sent to carrier with correct data
- [ ] Accept button in email works
- [ ] Reject button in email works
- [ ] Acceptance page displays correctly
- [ ] Rejection page displays correctly
- [ ] User receives notification on acceptance
- [ ] User receives notification on rejection
- [ ] Notification badge updates in real-time
- [ ] Mark as read functionality works
- [ ] Mark all as read works
### ✅ Database Integrity
- [ ] csv_bookings table has correct data
- [ ] status changes correctly (PENDING → ACCEPTED/REJECTED)
- [ ] accepted_at / rejected_at timestamps are set
- [ ] rejection_reason is stored (if provided)
- [ ] confirmation_token is unique and valid
- [ ] documents array is populated correctly
- [ ] notifications table has entries for user
### ✅ Error Handling
- [ ] Invalid file types show error
- [ ] Files > 5MB show error
- [ ] Invalid token shows error page
- [ ] Expired token shows error page
- [ ] Double acceptance/rejection prevented
- [ ] Network errors handled gracefully
### ✅ UI/UX
- [ ] Loading states show during async operations
- [ ] Success messages display after actions
- [ ] Error messages are clear and helpful
- [ ] Animations work (checkmark, X icon)
- [ ] Responsive design works on mobile
- [ ] Colors match design (green for success, red for error)
- [ ] Notifications poll every 30 seconds
- [ ] Dropdown closes when clicking outside
---
## Backend API Endpoints to Test
### CSV Bookings
```bash
# Create booking
POST /api/v1/csv-bookings
Content-Type: multipart/form-data
Authorization: Bearer <token>
# Get booking
GET /api/v1/csv-bookings/:id
Authorization: Bearer <token>
# List bookings
GET /api/v1/csv-bookings?page=1&limit=10&status=PENDING
Authorization: Bearer <token>
# Get stats
GET /api/v1/csv-bookings/stats
Authorization: Bearer <token>
# Accept booking (public)
POST /api/v1/csv-bookings/:token/accept
# Reject booking (public)
POST /api/v1/csv-bookings/:token/reject
Body: { "reason": "Optional reason" }
# Cancel booking
PATCH /api/v1/csv-bookings/:id/cancel
Authorization: Bearer <token>
```
### Notifications
```bash
# List notifications
GET /api/v1/notifications?limit=10&read=false
Authorization: Bearer <token>
# Mark as read
PATCH /api/v1/notifications/:id/read
Authorization: Bearer <token>
# Mark all as read
POST /api/v1/notifications/read-all
Authorization: Bearer <token>
# Get unread count
GET /api/v1/notifications/unread/count
Authorization: Bearer <token>
```
---
## Manual Testing Commands
### Create Test Booking via API
```bash
TOKEN="<your_access_token>"
curl -X POST http://localhost:4000/api/v1/csv-bookings \
-H "Authorization: Bearer $TOKEN" \
-F "carrierName=Test Carrier" \
-F "carrierEmail=carrier@example.com" \
-F "origin=NLRTM" \
-F "destination=USNYC" \
-F "volumeCBM=5" \
-F "weightKG=1000" \
-F "palletCount=3" \
-F "priceUSD=1500" \
-F "priceEUR=1350" \
-F "primaryCurrency=USD" \
-F "transitDays=25" \
-F "containerType=20FT" \
-F "documents=@/path/to/document.pdf" \
-F "notes=Test booking for development"
```
### Accept Booking via Token
```bash
TOKEN="<confirmation_token_from_database>"
curl -X POST http://localhost:4000/api/v1/csv-bookings/$TOKEN/accept
```
### Reject Booking via Token
```bash
TOKEN="<confirmation_token_from_database>"
curl -X POST http://localhost:4000/api/v1/csv-bookings/$TOKEN/reject \
-H "Content-Type: application/json" \
-d '{"reason":"Prix trop élevé"}'
```
---
## Known Issues / TODO
⚠️ **Backend CSV Bookings Module Not Implemented**
- The backend routes for `/api/v1/csv-bookings` do not exist yet
- Need to implement:
- `CsvBookingsModule`
- `CsvBookingsController`
- `CsvBookingsService`
- `CsvBooking` entity
- Database migrations
- Email templates
- Document upload to S3/MinIO
⚠️ **Email Service Configuration**
- SMTP credentials needed in .env
- Email templates need to be created (MJML)
- Carrier email addresses must be valid
⚠️ **Document Storage**
- S3/MinIO bucket must be configured
- Public URLs for document download in emails
- Presigned URLs for secure access
---
## Success Criteria
This feature is considered complete when:
- ✅ All test scenarios pass
- ✅ No console errors in browser or backend
- ✅ Database integrity maintained
- ✅ Emails delivered successfully
- ✅ Notifications work in real-time
- ✅ Error handling covers edge cases
- ✅ UI/UX matches design specifications
- ✅ Performance is acceptable (<2s for form submission)
---
## Actual Test Results
### Test Run 1: [DATE]
**Tester**: [NAME]
**Environment**: Local Development
| Test Scenario | Status | Notes |
|---------------|--------|-------|
| Login & Dashboard | ✅ PASS | User logged in successfully |
| Search CSV Rates | ⏸️ PENDING | Backend endpoint not implemented |
| Select Rate | ⏸️ PENDING | Depends on rate search |
| Upload Documents | ✅ PASS | Frontend validation works |
| Submit Booking | ⏸️ PENDING | Backend endpoint not implemented |
| Email Sent | ⏸️ PENDING | Backend not implemented |
| Accept Booking | ✅ PASS | Frontend page complete |
| Reject Booking | ✅ PASS | Frontend page complete |
| Notifications | ✅ PASS | Polling works, mark as read works |
**Overall Status**: ⏸️ PENDING BACKEND IMPLEMENTATION
**Next Steps**:
1. Implement backend CSV bookings module
2. Create database migrations
3. Configure email service
4. Set up document storage
5. Re-run full test suite
---
## Test Data
### Sample Test Documents
- `test-bill-of-lading.pdf` (500KB)
- `test-packing-list.docx` (120KB)
- `test-commercial-invoice.pdf` (800KB)
- `test-certificate-origin.jpg` (1.2MB)
### Sample Port Codes
- **Origin**: NLRTM, BEANR, FRPAR, DEHAM
- **Destination**: USNYC, USLAX, CNSHA, SGSIN
### Sample Carrier Data
```json
{
"companyName": "Maersk Line",
"companyEmail": "bookings@maersk.com",
"origin": "NLRTM",
"destination": "USNYC",
"priceUSD": 1500,
"priceEUR": 1350,
"transitDays": 25,
"containerType": "20FT"
}
```
---
## Conclusion
The CSV Booking Workflow frontend is **100% complete** and ready for testing. The backend implementation is required before end-to-end testing can be completed.
**Frontend Completion Status**: ✅ 100% (Tasks 14-21)
- ✅ Task 14: Select buttons functional
- ✅ Task 15: Multi-step booking form
- ✅ Task 16: Document upload
- ✅ Task 17: API client functions
- ✅ Task 18: Acceptance page
- ✅ Task 19: Rejection page
- ✅ Task 20: Notification bell (already existed)
- ✅ Task 21: useNotifications hook
**Backend Completion Status**: ⏸️ 0% (Tasks 7-13 not yet implemented)

View File

@ -0,0 +1,154 @@
# Implémentation du champ email pour les transporteurs - Statut
## ✅ Ce qui a été fait
### 1. Ajout du champ email dans le DTO d'upload CSV
**Fichier**: `apps/backend/src/application/dto/csv-rate-upload.dto.ts`
- ✅ Ajout de la propriété `companyEmail` avec validation `@IsEmail()`
- ✅ Documentation Swagger mise à jour
### 2. Mise à jour du controller d'upload
**Fichier**: `apps/backend/src/application/controllers/admin/csv-rates.controller.ts`
- ✅ Ajout de `companyEmail` dans les required fields du Swagger
- ✅ Sauvegarde de l'email dans `metadata.companyEmail` lors de la création/mise à jour de la config
### 3. Mise à jour du DTO de réponse de recherche
**Fichier**: `apps/backend/src/application/dto/csv-rate-search.dto.ts`
- ✅ Ajout de la propriété `companyEmail` dans `CsvRateResultDto`
### 4. Nettoyage des fichiers CSV
- ✅ Suppression de la colonne `companyEmail` des fichiers CSV (elle n'est plus nécessaire)
- ✅ Script Python créé pour automatiser l'ajout/suppression: `add-email-to-csv.py`
## ✅ Ce qui a été complété (SUITE)
### 5. ✅ Modification de l'entité domain CsvRate
**Fichier**: `apps/backend/src/domain/entities/csv-rate.entity.ts`
- Ajout du paramètre `companyEmail` dans le constructeur
- Ajout de la validation de l'email (requis et non vide)
### 6. ✅ Modification du CSV loader
**Fichier**: `apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts`
- Suppression de `companyEmail` de l'interface `CsvRow`
- Modification de `loadRatesFromCsv()` pour accepter `companyEmail` en paramètre
- Modification de `mapToCsvRate()` pour recevoir l'email en paramètre
- Mise à jour de `validateCsvFile()` pour utiliser un email fictif pendant la validation
### 7. ✅ Modification du port CSV Loader
**Fichier**: `apps/backend/src/domain/ports/out/csv-rate-loader.port.ts`
- Mise à jour de l'interface pour accepter `companyEmail` en paramètre
### 8. ✅ Modification du service de recherche CSV
**Fichier**: `apps/backend/src/domain/services/csv-rate-search.service.ts`
- Ajout de l'interface `CsvRateConfigRepositoryPort` pour éviter les dépendances circulaires
- Modification du constructeur pour accepter le repository de config (optionnel)
- Modification de `loadAllRates()` pour récupérer l'email depuis les configs
- Fallback sur 'bookings@example.com' si l'email n'est pas dans la metadata
### 9. ✅ Modification du module CSV Rate
**Fichier**: `apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts`
- Mise à jour de la factory pour injecter `TypeOrmCsvRateConfigRepository`
- Le service reçoit maintenant le loader ET le repository de config
### 10. ✅ Modification du mapper
**Fichier**: `apps/backend/src/application/mappers/csv-rate.mapper.ts`
- Ajout de `companyEmail: rate.companyEmail` dans `mapSearchResultToDto()`
### 11. ✅ Création du type frontend
**Fichier**: `apps/frontend/src/types/rates.ts`
- Création complète du fichier avec tous les types nécessaires
- Ajout de `companyEmail` dans `CsvRateSearchResult`
### 12. ✅ Tests et vérification
**Statut**: Backend compilé avec succès (0 erreurs TypeScript)
**Prochaines étapes de test**:
1. Réuploader un CSV avec email via l'API admin
2. Vérifier que la config contient l'email dans metadata
3. Faire une recherche de tarifs
4. Vérifier que `companyEmail` apparaît dans les résultats
5. Tester sur le frontend que l'email est bien affiché
## 📝 Notes importantes
### Pourquoi ce changement?
- **Avant**: L'email était stocké dans chaque ligne du CSV (redondant, difficile à maintenir)
- **Après**: L'email est fourni une seule fois lors de l'upload et stocké dans la metadata de la config
### Avantages
1. ✅ **Moins de redondance**: Un email par transporteur, pas par ligne de tarif
2. ✅ **Plus facile à mettre à jour**: Modifier l'email en réuploadant le CSV avec le nouvel email
3. ✅ **CSV plus propre**: Les fichiers CSV contiennent uniquement les données de tarification
4. ✅ **Validation centralisée**: L'email est validé une fois au niveau de l'API
### Migration des données existantes
Pour les fichiers CSV déjà uploadés, il faudra:
1. Réuploader chaque CSV avec le bon email via l'API admin
2. Ou créer un script de migration pour ajouter l'email dans la metadata des configs existantes
Script de migration (à exécuter une fois):
```typescript
// apps/backend/src/scripts/migrate-emails.ts
const DEFAULT_EMAILS = {
'MSC': 'bookings@msc.com',
'SSC Consolidation': 'bookings@sscconsolidation.com',
'ECU Worldwide': 'bookings@ecuworldwide.com',
'TCC Logistics': 'bookings@tcclogistics.com',
'NVO Consolidation': 'bookings@nvoconsolidation.com',
};
// Mettre à jour chaque config
for (const [companyName, email] of Object.entries(DEFAULT_EMAILS)) {
const config = await csvConfigRepository.findByCompanyName(companyName);
if (config && !config.metadata?.companyEmail) {
await csvConfigRepository.update(config.id, {
metadata: {
...config.metadata,
companyEmail: email,
},
});
}
}
```
## 🎯 Estimation
- **Temps restant**: 2-3 heures
- **Complexité**: Moyenne (modifications à travers 5 couches de l'architecture hexagonale)
- **Tests**: 1 heure supplémentaire pour tester le workflow complet
## 🔄 Ordre d'implémentation recommandé
1. ✅ DTOs (déjà fait)
2. ✅ Controller upload (déjà fait)
3. ❌ Entité domain CsvRate
4. ❌ CSV Loader (adapter)
5. ❌ Service de recherche CSV
6. ❌ Mapper
7. ❌ Type frontend
8. ❌ Migration des données existantes
9. ❌ Tests
---
**Date**: 2025-11-05
**Statut**: ✅ 100% complété
**Prochaine étape**: Tests manuels et validation du workflow complet
## 🎉 Implémentation terminée !
Tous les fichiers ont été modifiés avec succès:
- ✅ Backend compile sans erreurs
- ✅ Domain layer: entité CsvRate avec email
- ✅ Infrastructure layer: CSV loader avec paramètre email
- ✅ Application layer: DTOs, controller, mapper mis à jour
- ✅ Frontend: types TypeScript créés
- ✅ Injection de dépendances: module configuré pour passer le repository
Le système est maintenant prêt à :
1. Accepter l'email lors de l'upload CSV (via API)
2. Stocker l'email dans la metadata de la config
3. Charger les rates avec l'email depuis la config
4. Retourner l'email dans les résultats de recherche
5. Afficher l'email sur le frontend

61
add-email-to-csv.py Normal file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Script to add email column to all CSV rate files
"""
import csv
import os
# Company email mapping
COMPANY_EMAILS = {
'MSC': 'bookings@msc.com',
'SSC Consolidation': 'bookings@sscconsolidation.com',
'ECU Worldwide': 'bookings@ecuworldwide.com',
'TCC Logistics': 'bookings@tcclogistics.com',
'NVO Consolidation': 'bookings@nvoconsolidation.com',
'Test Maritime Express': 'bookings@testmaritime.com'
}
csv_dir = 'apps/backend/src/infrastructure/storage/csv-storage/rates'
# Process each CSV file
for filename in os.listdir(csv_dir):
if not filename.endswith('.csv'):
continue
filepath = os.path.join(csv_dir, filename)
print(f'Processing {filename}...')
# Read existing data
rows = []
with open(filepath, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
# Check if email column already exists
if 'companyEmail' in fieldnames:
print(f' - Email column already exists, skipping')
continue
# Add email column header
new_fieldnames = list(fieldnames)
# Insert email after companyName
company_name_index = new_fieldnames.index('companyName')
new_fieldnames.insert(company_name_index + 1, 'companyEmail')
# Read all rows and add email
for row in reader:
company_name = row['companyName']
company_email = COMPANY_EMAILS.get(company_name, f'bookings@{company_name.lower().replace(" ", "")}.com')
row['companyEmail'] = company_email
rows.append(row)
# Write back with new column
with open(filepath, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=new_fieldnames)
writer.writeheader()
writer.writerows(rows)
print(f' - Added companyEmail column ({len(rows)} rows updated)')
print('\nDone! All CSV files updated.')

View File

@ -16,6 +16,7 @@ import { AuditModule } from './application/audit/audit.module';
import { NotificationsModule } from './application/notifications/notifications.module'; import { NotificationsModule } from './application/notifications/notifications.module';
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 { 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';
@ -78,7 +79,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
password: configService.get('DATABASE_PASSWORD'), password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'), database: configService.get('DATABASE_NAME'),
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'], entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
synchronize: configService.get('DATABASE_SYNC', false), synchronize: false, // ✅ Force false - use migrations instead
logging: configService.get('DATABASE_LOGGING', false), logging: configService.get('DATABASE_LOGGING', false),
autoLoadEntities: true, // Auto-load entities from forFeature() autoLoadEntities: true, // Auto-load entities from forFeature()
}), }),
@ -95,6 +96,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
AuthModule, AuthModule,
RatesModule, RatesModule,
BookingsModule, BookingsModule,
CsvBookingsModule,
OrganizationsModule, OrganizationsModule,
UsersModule, UsersModule,
DashboardModule, DashboardModule,

View File

@ -101,13 +101,19 @@ export class CsvRatesAdminController {
@ApiBody({ @ApiBody({
schema: { schema: {
type: 'object', type: 'object',
required: ['companyName', 'file'], required: ['companyName', 'companyEmail', 'file'],
properties: { properties: {
companyName: { companyName: {
type: 'string', type: 'string',
description: 'Carrier company name', description: 'Carrier company name',
example: 'SSC Consolidation', example: 'SSC Consolidation',
}, },
companyEmail: {
type: 'string',
format: 'email',
description: 'Email address for booking requests',
example: 'bookings@sscconsolidation.com',
},
file: { file: {
type: 'string', type: 'string',
format: 'binary', format: 'binary',
@ -165,7 +171,7 @@ export class CsvRatesAdminController {
} }
// Load rates to verify parsing using the converted path // Load rates to verify parsing using the converted path
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate); const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate, dto.companyEmail);
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}`);
@ -183,6 +189,7 @@ export class CsvRatesAdminController {
lastValidatedAt: new Date(), lastValidatedAt: new Date(),
metadata: { metadata: {
...existingConfig.metadata, ...existingConfig.metadata,
companyEmail: dto.companyEmail, // Store email in metadata
lastUpload: { lastUpload: {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
by: user.email, by: user.email,
@ -208,6 +215,7 @@ export class CsvRatesAdminController {
metadata: { metadata: {
uploadedBy: user.email, uploadedBy: user.email,
description: `${dto.companyName} shipping rates`, description: `${dto.companyName} shipping rates`,
companyEmail: dto.companyEmail, // Store email in metadata
}, },
}); });

View File

@ -0,0 +1,374 @@
import {
Controller,
Post,
Get,
Patch,
Body,
Param,
Query,
UseGuards,
UseInterceptors,
UploadedFiles,
Request,
BadRequestException,
ParseIntPipe,
DefaultValuePipe,
Res,
HttpStatus,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiConsumes,
ApiBody,
ApiBearerAuth,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service';
import {
CreateCsvBookingDto,
CsvBookingResponseDto,
UpdateCsvBookingStatusDto,
CsvBookingListResponseDto,
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
/**
* CSV Bookings Controller
*
* Handles HTTP requests for CSV-based booking requests
*/
@ApiTags('CSV Bookings')
@Controller('csv-bookings')
export class CsvBookingsController {
constructor(private readonly csvBookingService: CsvBookingService) {}
/**
* Create a new CSV booking request
*
* POST /api/v1/csv-bookings
*/
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Create a new CSV booking request',
description:
'Creates a new booking request from CSV rate selection. Uploads documents, sends email to carrier, and creates a notification for the user.',
})
@ApiBody({
schema: {
type: 'object',
required: [
'carrierName',
'carrierEmail',
'origin',
'destination',
'volumeCBM',
'weightKG',
'palletCount',
'priceUSD',
'priceEUR',
'primaryCurrency',
'transitDays',
'containerType',
],
properties: {
carrierName: { type: 'string', example: 'SSC Consolidation' },
carrierEmail: { type: 'string', format: 'email', example: 'bookings@sscconsolidation.com' },
origin: { type: 'string', example: 'NLRTM' },
destination: { type: 'string', example: 'USNYC' },
volumeCBM: { type: 'number', example: 25.5 },
weightKG: { type: 'number', example: 3500 },
palletCount: { type: 'number', example: 10 },
priceUSD: { type: 'number', example: 1850.5 },
priceEUR: { type: 'number', example: 1665.45 },
primaryCurrency: { type: 'string', enum: ['USD', 'EUR'], example: 'USD' },
transitDays: { type: 'number', example: 28 },
containerType: { type: 'string', example: 'LCL' },
notes: { type: 'string', example: 'Handle with care' },
documents: {
type: 'array',
items: { type: 'string', format: 'binary' },
description: 'Shipping documents (Bill of Lading, Packing List, Invoice, etc.)',
},
},
},
})
@ApiResponse({
status: 201,
description: 'Booking created successfully',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Invalid request data or missing documents' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async createBooking(
@Body() dto: CreateCsvBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req: any,
): Promise<CsvBookingResponseDto> {
// Debug: Log request details
console.log('=== CSV Booking Request Debug ===');
console.log('req.user:', req.user);
console.log('req.body:', req.body);
console.log('dto:', dto);
console.log('files:', files?.length);
console.log('================================');
if (!files || files.length === 0) {
throw new BadRequestException('At least one document is required');
}
// Validate user authentication
if (!req.user || !req.user.id) {
throw new BadRequestException('User authentication failed - no user info in request');
}
if (!req.user.organizationId) {
throw new BadRequestException('Organization ID is required');
}
const userId = req.user.id;
const organizationId = req.user.organizationId;
// Convert string values to numbers (multipart/form-data sends everything as strings)
const sanitizedDto: CreateCsvBookingDto = {
...dto,
volumeCBM: typeof dto.volumeCBM === 'string' ? parseFloat(dto.volumeCBM) : dto.volumeCBM,
weightKG: typeof dto.weightKG === 'string' ? parseFloat(dto.weightKG) : dto.weightKG,
palletCount: typeof dto.palletCount === 'string' ? parseInt(dto.palletCount, 10) : dto.palletCount,
priceUSD: typeof dto.priceUSD === 'string' ? parseFloat(dto.priceUSD) : dto.priceUSD,
priceEUR: typeof dto.priceEUR === 'string' ? parseFloat(dto.priceEUR) : dto.priceEUR,
transitDays: typeof dto.transitDays === 'string' ? parseInt(dto.transitDays, 10) : dto.transitDays,
};
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
}
/**
* Get a booking by ID
*
* GET /api/v1/csv-bookings/:id
*/
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get booking by ID',
description: 'Retrieve a specific CSV booking by its ID. Only accessible by the booking owner.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking retrieved successfully',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 404, description: 'Booking not found' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getBooking(@Param('id') id: string, @Request() req: any): Promise<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.getBookingById(id, userId);
}
/**
* Get current user's bookings (paginated)
*
* GET /api/v1/csv-bookings
*/
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get user bookings',
description: 'Retrieve all bookings for the authenticated user with pagination.',
})
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
@ApiResponse({
status: 200,
description: 'Bookings retrieved successfully',
type: CsvBookingListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUserBookings(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
): Promise<CsvBookingListResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.getUserBookings(userId, page, limit);
}
/**
* Get booking statistics for user
*
* GET /api/v1/csv-bookings/stats/me
*/
@Get('stats/me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get user booking statistics',
description: 'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
})
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: CsvBookingStatsDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
const userId = req.user.id;
return await this.csvBookingService.getUserStats(userId);
}
/**
* Accept a booking request (PUBLIC - token-based)
*
* GET /api/v1/csv-bookings/:token/accept
*/
@Public()
@Get(':token/accept')
@ApiOperation({
summary: 'Accept booking request (public)',
description:
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking accepted successfully. Redirects to confirmation page.',
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking cannot be accepted (invalid status or expired)' })
async acceptBooking(@Param('token') token: string, @Res() res: Response): Promise<void> {
const booking = await this.csvBookingService.acceptBooking(token);
// Redirect to frontend confirmation page
const frontendUrl = process.env.APP_URL || 'http://localhost:3000';
res.redirect(HttpStatus.FOUND, `${frontendUrl}/csv-bookings/${booking.id}/confirmed?action=accepted`);
}
/**
* Reject a booking request (PUBLIC - token-based)
*
* GET /api/v1/csv-bookings/:token/reject
*/
@Public()
@Get(':token/reject')
@ApiOperation({
summary: 'Reject booking request (public)',
description:
'Public endpoint for carriers to reject a booking via email link. Updates booking status and notifies the user.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiQuery({
name: 'reason',
required: false,
description: 'Rejection reason',
example: 'No capacity available',
})
@ApiResponse({
status: 200,
description: 'Booking rejected successfully. Redirects to confirmation page.',
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({ status: 400, description: 'Booking cannot be rejected (invalid status or expired)' })
async rejectBooking(
@Param('token') token: string,
@Query('reason') reason: string,
@Res() res: Response,
): Promise<void> {
const booking = await this.csvBookingService.rejectBooking(token, reason);
// Redirect to frontend confirmation page
const frontendUrl = process.env.APP_URL || 'http://localhost:3000';
res.redirect(HttpStatus.FOUND, `${frontendUrl}/csv-bookings/${booking.id}/confirmed?action=rejected`);
}
/**
* Cancel a booking (user action)
*
* PATCH /api/v1/csv-bookings/:id/cancel
*/
@Patch(':id/cancel')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Cancel booking',
description: 'Cancel a pending booking. Only accessible by the booking owner.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking cancelled successfully',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 404, description: 'Booking not found' })
@ApiResponse({ status: 400, description: 'Booking cannot be cancelled (already accepted)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async cancelBooking(@Param('id') id: string, @Request() req: any): Promise<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.cancelBooking(id, userId);
}
/**
* Get organization bookings (for managers/admins)
*
* GET /api/v1/csv-bookings/organization/all
*/
@Get('organization/all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get organization bookings',
description: 'Retrieve all bookings for the user\'s organization with pagination. For managers/admins.',
})
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
@ApiResponse({
status: 200,
description: 'Organization bookings retrieved successfully',
type: CsvBookingListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getOrganizationBookings(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
): Promise<CsvBookingListResponseDto> {
const organizationId = req.user.organizationId;
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
}
/**
* Get organization booking statistics
*
* GET /api/v1/csv-bookings/stats/organization
*/
@Get('stats/organization')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get organization booking statistics',
description: 'Get aggregated statistics for the user\'s organization. For managers/admins.',
})
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: CsvBookingStatsDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
const organizationId = req.user.organizationId;
return await this.csvBookingService.getOrganizationStats(organizationId);
}
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CsvBookingsController } from './controllers/csv-bookings.controller';
import { CsvBookingService } from './services/csv-booking.service';
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { NotificationsModule } from './notifications/notifications.module';
import { EmailModule } from '../infrastructure/email/email.module';
import { StorageModule } from '../infrastructure/storage/storage.module';
/**
* CSV Bookings Module
*
* Handles CSV-based booking workflow with carrier email confirmations
*/
@Module({
imports: [
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
NotificationsModule, // Import NotificationsModule to access NotificationRepository
EmailModule,
StorageModule,
],
controllers: [CsvBookingsController],
providers: [
CsvBookingService,
TypeOrmCsvBookingRepository,
],
exports: [CsvBookingService],
})
export class CsvBookingsModule {}

View File

@ -0,0 +1,445 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsEmail,
IsNumber,
Min,
IsOptional,
IsEnum,
IsArray,
ValidateNested,
IsUUID,
IsDateString,
MinLength,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
/**
* Create CSV Booking DTO
*
* Request body for creating a new CSV-based booking request
* This is sent by the user after selecting a rate from CSV search results
*/
export class CreateCsvBookingDto {
@ApiProperty({
description: 'Carrier/Company name',
example: 'SSC Consolidation',
})
@IsString()
@MinLength(2)
@MaxLength(200)
carrierName: string;
@ApiProperty({
description: 'Carrier email address for booking request',
example: 'bookings@sscconsolidation.com',
})
@IsEmail()
carrierEmail: string;
@ApiProperty({
description: 'Origin port code (UN/LOCODE)',
example: 'NLRTM',
})
@IsString()
@MinLength(5)
@MaxLength(5)
origin: string;
@ApiProperty({
description: 'Destination port code (UN/LOCODE)',
example: 'USNYC',
})
@IsString()
@MinLength(5)
@MaxLength(5)
destination: string;
@ApiProperty({
description: 'Volume in cubic meters (CBM)',
example: 25.5,
minimum: 0.01,
})
@IsNumber()
@Min(0.01)
volumeCBM: number;
@ApiProperty({
description: 'Weight in kilograms',
example: 3500,
minimum: 1,
})
@IsNumber()
@Min(1)
weightKG: number;
@ApiProperty({
description: 'Number of pallets',
example: 10,
minimum: 0,
})
@IsNumber()
@Min(0)
palletCount: number;
@ApiProperty({
description: 'Price in USD',
example: 1850.5,
minimum: 0,
})
@IsNumber()
@Min(0)
priceUSD: number;
@ApiProperty({
description: 'Price in EUR',
example: 1665.45,
minimum: 0,
})
@IsNumber()
@Min(0)
priceEUR: number;
@ApiProperty({
description: 'Primary currency',
enum: ['USD', 'EUR'],
example: 'USD',
})
@IsEnum(['USD', 'EUR'])
primaryCurrency: string;
@ApiProperty({
description: 'Transit time in days',
example: 28,
minimum: 1,
})
@IsNumber()
@Min(1)
transitDays: number;
@ApiProperty({
description: 'Container type',
example: 'LCL',
})
@IsString()
@MinLength(2)
@MaxLength(50)
containerType: string;
@ApiPropertyOptional({
description: 'Additional notes or requirements',
example: 'Please handle with care - fragile goods',
})
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
// Documents will be handled via file upload interceptor
// Not included in DTO validation but processed separately
}
/**
* Document DTO for response
*/
export class CsvBookingDocumentDto {
@ApiProperty({
description: 'Document unique ID',
example: '123e4567-e89b-12d3-a456-426614174000',
})
id: string;
@ApiProperty({
description: 'Document type',
enum: [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
],
example: 'BILL_OF_LADING',
})
type: string;
@ApiProperty({
description: 'Original file name',
example: 'bill-of-lading.pdf',
})
fileName: string;
@ApiProperty({
description: 'File storage path or URL',
example: '/uploads/documents/123e4567-e89b-12d3-a456-426614174000.pdf',
})
filePath: string;
@ApiProperty({
description: 'File MIME type',
example: 'application/pdf',
})
mimeType: string;
@ApiProperty({
description: 'File size in bytes',
example: 245678,
})
size: number;
@ApiProperty({
description: 'Upload timestamp',
example: '2025-10-23T14:30:00Z',
})
uploadedAt: Date;
}
/**
* CSV Booking Response DTO
*
* Response when creating or retrieving a CSV booking
*/
export class CsvBookingResponseDto {
@ApiProperty({
description: 'Booking unique ID',
example: '123e4567-e89b-12d3-a456-426614174000',
})
id: string;
@ApiProperty({
description: 'User ID who created the booking',
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',
})
userId: string;
@ApiProperty({
description: 'Organization ID',
example: 'a1234567-0000-4000-8000-000000000001',
})
organizationId: string;
@ApiProperty({
description: 'Carrier/Company name',
example: 'SSC Consolidation',
})
carrierName: string;
@ApiProperty({
description: 'Carrier email address',
example: 'bookings@sscconsolidation.com',
})
carrierEmail: string;
@ApiProperty({
description: 'Origin port code',
example: 'NLRTM',
})
origin: string;
@ApiProperty({
description: 'Destination port code',
example: 'USNYC',
})
destination: string;
@ApiProperty({
description: 'Volume in CBM',
example: 25.5,
})
volumeCBM: number;
@ApiProperty({
description: 'Weight in KG',
example: 3500,
})
weightKG: number;
@ApiProperty({
description: 'Number of pallets',
example: 10,
})
palletCount: number;
@ApiProperty({
description: 'Price in USD',
example: 1850.5,
})
priceUSD: number;
@ApiProperty({
description: 'Price in EUR',
example: 1665.45,
})
priceEUR: number;
@ApiProperty({
description: 'Primary currency',
enum: ['USD', 'EUR'],
example: 'USD',
})
primaryCurrency: string;
@ApiProperty({
description: 'Transit time in days',
example: 28,
})
transitDays: number;
@ApiProperty({
description: 'Container type',
example: 'LCL',
})
containerType: string;
@ApiProperty({
description: 'Booking status',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING',
})
status: string;
@ApiProperty({
description: 'Uploaded documents',
type: [CsvBookingDocumentDto],
})
documents: CsvBookingDocumentDto[];
@ApiProperty({
description: 'Confirmation token for accept/reject actions',
example: 'abc123-def456-ghi789',
})
confirmationToken: string;
@ApiProperty({
description: 'Booking request timestamp',
example: '2025-10-23T14:30:00Z',
})
requestedAt: Date;
@ApiProperty({
description: 'Response timestamp (when accepted/rejected)',
example: '2025-10-24T09:15:00Z',
nullable: true,
})
respondedAt: Date | null;
@ApiPropertyOptional({
description: 'Additional notes',
example: 'Please handle with care',
})
notes?: string;
@ApiPropertyOptional({
description: 'Rejection reason (if rejected)',
example: 'No capacity available for requested dates',
})
rejectionReason?: string;
@ApiProperty({
description: 'Route description (origin → destination)',
example: 'NLRTM → USNYC',
})
routeDescription: string;
@ApiProperty({
description: 'Whether the booking is expired (7+ days pending)',
example: false,
})
isExpired: boolean;
@ApiProperty({
description: 'Price in the primary currency',
example: 1850.5,
})
price: number;
}
/**
* Update CSV Booking Status DTO
*
* Request body for accepting/rejecting a booking
*/
export class UpdateCsvBookingStatusDto {
@ApiPropertyOptional({
description: 'Rejection reason (required when rejecting)',
example: 'No capacity available',
})
@IsOptional()
@IsString()
@MaxLength(500)
rejectionReason?: string;
}
/**
* CSV Booking List Response DTO
*
* Paginated list of bookings
*/
export class CsvBookingListResponseDto {
@ApiProperty({
description: 'Array of bookings',
type: [CsvBookingResponseDto],
})
bookings: CsvBookingResponseDto[];
@ApiProperty({
description: 'Total number of bookings',
example: 42,
})
total: number;
@ApiProperty({
description: 'Current page number',
example: 1,
})
page: number;
@ApiProperty({
description: 'Number of items per page',
example: 10,
})
limit: number;
@ApiProperty({
description: 'Total number of pages',
example: 5,
})
totalPages: number;
}
/**
* CSV Booking Statistics DTO
*
* Statistics for user's or organization's bookings
*/
export class CsvBookingStatsDto {
@ApiProperty({
description: 'Number of pending bookings',
example: 5,
})
pending: number;
@ApiProperty({
description: 'Number of accepted bookings',
example: 12,
})
accepted: number;
@ApiProperty({
description: 'Number of rejected bookings',
example: 2,
})
rejected: number;
@ApiProperty({
description: 'Number of cancelled bookings',
example: 1,
})
cancelled: number;
@ApiProperty({
description: 'Total number of bookings',
example: 20,
})
total: number;
}

View File

@ -281,6 +281,12 @@ export class CsvRateResultDto {
}) })
companyName: string; companyName: string;
@ApiProperty({
description: 'Company email for booking requests',
example: 'bookings@sscconsolidation.com',
})
companyEmail: string;
@ApiProperty({ @ApiProperty({
description: 'Origin port code', description: 'Origin port code',
example: 'NLRTM', example: 'NLRTM',

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { IsNotEmpty, IsString, MaxLength, IsEmail } from 'class-validator';
/** /**
* CSV Rate Upload DTO * CSV Rate Upload DTO
@ -17,6 +17,16 @@ export class CsvRateUploadDto {
@MaxLength(255) @MaxLength(255)
companyName: string; companyName: string;
@ApiProperty({
description: 'Email address of the carrier company for booking requests',
example: 'bookings@sscconsolidation.com',
maxLength: 255,
})
@IsNotEmpty()
@IsEmail()
@MaxLength(255)
companyEmail: string;
@ApiProperty({ @ApiProperty({
description: 'CSV file containing shipping rates', description: 'CSV file containing shipping rates',
type: 'string', type: 'string',

View File

@ -54,6 +54,7 @@ export class CsvRateMapper {
return { return {
companyName: rate.companyName, companyName: rate.companyName,
companyEmail: rate.companyEmail,
origin: rate.origin.getValue(), origin: rate.origin.getValue(),
destination: rate.destination.getValue(), destination: rate.destination.getValue(),
containerType: rate.containerType.getValue(), containerType: rate.containerType.getValue(),

View File

@ -38,6 +38,6 @@ import { NOTIFICATION_REPOSITORY } from '../../domain/ports/out/notification.rep
useClass: TypeOrmNotificationRepository, useClass: TypeOrmNotificationRepository,
}, },
], ],
exports: [NotificationService, NotificationsGateway], exports: [NotificationService, NotificationsGateway, NOTIFICATION_REPOSITORY],
}) })
export class NotificationsModule {} export class NotificationsModule {}

View File

@ -0,0 +1,479 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { CsvBooking, CsvBookingStatus, DocumentType } from '../../domain/entities/csv-booking.entity';
import { PortCode } from '../../domain/value-objects/port-code.vo';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { NotificationRepository, NOTIFICATION_REPOSITORY } from '../../domain/ports/out/notification.repository';
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port';
import { Notification, NotificationType, NotificationPriority } from '../../domain/entities/notification.entity';
import {
CreateCsvBookingDto,
CsvBookingResponseDto,
CsvBookingDocumentDto,
CsvBookingListResponseDto,
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
/**
* CSV Booking Document (simple class for domain)
*/
class CsvBookingDocumentImpl {
constructor(
public readonly id: string,
public readonly type: DocumentType,
public readonly fileName: string,
public readonly filePath: string,
public readonly mimeType: string,
public readonly size: number,
public readonly uploadedAt: Date,
) {}
}
/**
* CSV Booking Service
*
* Handles business logic for CSV-based booking requests
*/
@Injectable()
export class CsvBookingService {
private readonly logger = new Logger(CsvBookingService.name);
constructor(
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
@Inject(NOTIFICATION_REPOSITORY)
private readonly notificationRepository: NotificationRepository,
@Inject(EMAIL_PORT)
private readonly emailAdapter: EmailPort,
@Inject(STORAGE_PORT)
private readonly storageAdapter: StoragePort,
) {}
/**
* Create a new CSV booking request
*/
async createBooking(
dto: CreateCsvBookingDto,
files: Express.Multer.File[],
userId: string,
organizationId: string,
): Promise<CsvBookingResponseDto> {
this.logger.log(`Creating CSV booking for user ${userId}`);
// Validate minimum document requirement
if (!files || files.length === 0) {
throw new BadRequestException('At least one document is required');
}
// Generate unique confirmation token
const confirmationToken = uuidv4();
const bookingId = uuidv4();
// Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId);
// Create domain entity
const booking = new CsvBooking(
bookingId,
userId,
organizationId,
dto.carrierName,
dto.carrierEmail,
PortCode.create(dto.origin),
PortCode.create(dto.destination),
dto.volumeCBM,
dto.weightKG,
dto.palletCount,
dto.priceUSD,
dto.priceEUR,
dto.primaryCurrency,
dto.transitDays,
dto.containerType,
CsvBookingStatus.PENDING,
documents,
confirmationToken,
new Date(),
undefined,
dto.notes,
);
// Save to database
const savedBooking = await this.csvBookingRepository.create(booking);
this.logger.log(`CSV booking created with ID: ${bookingId}`);
// Send email to carrier
try {
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId,
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
transitDays: dto.transitDays,
containerType: dto.containerType,
documents: documents.map((doc) => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken,
});
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
// Continue even if email fails - booking is created
}
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId,
organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent',
message: `Your booking request to ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`,
metadata: { bookingId, carrierName: dto.carrierName },
});
await this.notificationRepository.save(notification);
this.logger.log(`Notification created for user ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
// Continue even if notification fails
}
return this.toResponseDto(savedBooking);
}
/**
* Get booking by ID
*/
async getBookingById(id: string, userId: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException(`Booking with ID ${id} not found`);
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${id} not found`);
}
return this.toResponseDto(booking);
}
/**
* Get booking by confirmation token (public endpoint)
*/
async getBookingByToken(token: string): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException(`Booking with token ${token} not found`);
}
return this.toResponseDto(booking);
}
/**
* Accept a booking request
*/
async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Accepting booking with token: ${token}`);
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Accept the booking (domain logic validates status)
booking.accept();
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`);
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_ACCEPTED,
priority: NotificationPriority.HIGH,
title: 'Booking Request Accepted',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been accepted!`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Reject a booking request
*/
async rejectBooking(token: string, reason?: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Rejecting booking with token: ${token}`);
const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Reject the booking (domain logic validates status)
booking.reject(reason);
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} rejected`);
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_REJECTED,
priority: NotificationPriority.HIGH,
title: 'Booking Request Rejected',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} was rejected. ${reason ? `Reason: ${reason}` : ''}`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName, rejectionReason: reason },
});
await this.notificationRepository.save(notification);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
}
return this.toResponseDto(updatedBooking);
}
/**
* Cancel a booking (user action)
*/
async cancelBooking(id: string, userId: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Cancelling booking ${id} by user ${userId}`);
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException('Booking not found');
}
// Cancel the booking (domain logic validates status)
booking.cancel();
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${id} cancelled`);
return this.toResponseDto(updatedBooking);
}
/**
* Get bookings for a user (paginated)
*/
async getUserBookings(
userId: string,
page: number = 1,
limit: number = 10,
): Promise<CsvBookingListResponseDto> {
const bookings = await this.csvBookingRepository.findByUserId(userId);
// Simple pagination (in-memory)
const start = (page - 1) * limit;
const end = start + limit;
const paginatedBookings = bookings.slice(start, end);
return {
bookings: paginatedBookings.map((b) => this.toResponseDto(b)),
total: bookings.length,
page,
limit,
totalPages: Math.ceil(bookings.length / limit),
};
}
/**
* Get bookings for an organization (paginated)
*/
async getOrganizationBookings(
organizationId: string,
page: number = 1,
limit: number = 10,
): Promise<CsvBookingListResponseDto> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
// Simple pagination (in-memory)
const start = (page - 1) * limit;
const end = start + limit;
const paginatedBookings = bookings.slice(start, end);
return {
bookings: paginatedBookings.map((b) => this.toResponseDto(b)),
total: bookings.length,
page,
limit,
totalPages: Math.ceil(bookings.length / limit),
};
}
/**
* Get booking statistics for user
*/
async getUserStats(userId: string): Promise<CsvBookingStatsDto> {
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
return {
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
cancelled: stats[CsvBookingStatus.CANCELLED] || 0,
total: Object.values(stats).reduce((sum, count) => sum + count, 0),
};
}
/**
* Get booking statistics for organization
*/
async getOrganizationStats(organizationId: string): Promise<CsvBookingStatsDto> {
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
return {
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
cancelled: stats[CsvBookingStatus.CANCELLED] || 0,
total: Object.values(stats).reduce((sum, count) => sum + count, 0),
};
}
/**
* Upload documents to S3 and create document entities
*/
private async uploadDocuments(
files: Express.Multer.File[],
bookingId: string,
): Promise<CsvBookingDocumentImpl[]> {
const bucket = 'xpeditis-documents'; // You can make this configurable
const documents: CsvBookingDocumentImpl[] = [];
for (const file of files) {
const documentId = uuidv4();
const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`;
// Upload to S3
const uploadResult = await this.storageAdapter.upload({
bucket,
key: fileKey,
body: file.buffer,
contentType: file.mimetype,
});
// Determine document type from filename or default to OTHER
const documentType = this.inferDocumentType(file.originalname);
const document = new CsvBookingDocumentImpl(
documentId,
documentType,
file.originalname,
uploadResult.url,
file.mimetype,
file.size,
new Date(),
);
documents.push(document);
}
this.logger.log(`Uploaded ${documents.length} documents for booking ${bookingId}`);
return documents;
}
/**
* Infer document type from filename
*/
private inferDocumentType(filename: string): DocumentType {
const lowerFilename = filename.toLowerCase();
if (lowerFilename.includes('bill') || lowerFilename.includes('bol') || lowerFilename.includes('lading')) {
return DocumentType.BILL_OF_LADING;
}
if (lowerFilename.includes('packing') || lowerFilename.includes('list')) {
return DocumentType.PACKING_LIST;
}
if (lowerFilename.includes('invoice') || lowerFilename.includes('commercial')) {
return DocumentType.COMMERCIAL_INVOICE;
}
if (lowerFilename.includes('certificate') || lowerFilename.includes('origin')) {
return DocumentType.CERTIFICATE_OF_ORIGIN;
}
return DocumentType.OTHER;
}
/**
* Convert domain entity to response DTO
*/
private toResponseDto(booking: CsvBooking): CsvBookingResponseDto {
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
id: booking.id,
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.map(this.toDocumentDto),
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),
};
}
/**
* Convert domain document to DTO
*/
private toDocumentDto(document: any): CsvBookingDocumentDto {
return {
id: document.id,
type: document.type,
fileName: document.fileName,
filePath: document.filePath,
mimeType: document.mimeType,
size: document.size,
uploadedAt: document.uploadedAt,
};
}
}

View File

@ -0,0 +1,480 @@
import { CsvBooking, CsvBookingStatus, DocumentType, CsvBookingDocument } from './csv-booking.entity';
import { PortCode } from '../value-objects/port-code.vo';
describe('CsvBooking Entity', () => {
// Test data factory
const createValidBooking = (overrides?: Partial<ConstructorParameters<typeof CsvBooking>[0]>): CsvBooking => {
const documents: CsvBookingDocument[] = [
{
id: 'doc-1',
type: DocumentType.BILL_OF_LADING,
fileName: 'bill-of-lading.pdf',
filePath: '/uploads/bill-of-lading.pdf',
mimeType: 'application/pdf',
size: 1024,
uploadedAt: new Date(),
},
{
id: 'doc-2',
type: DocumentType.PACKING_LIST,
fileName: 'packing-list.pdf',
filePath: '/uploads/packing-list.pdf',
mimeType: 'application/pdf',
size: 2048,
uploadedAt: new Date(),
},
{
id: 'doc-3',
type: DocumentType.COMMERCIAL_INVOICE,
fileName: 'invoice.pdf',
filePath: '/uploads/invoice.pdf',
mimeType: 'application/pdf',
size: 3072,
uploadedAt: new Date(),
},
];
return new CsvBooking(
'booking-123',
'user-456',
'org-789',
'SSC Consolidation',
'bookings@sscconsolidation.com',
PortCode.create('NLRTM'),
PortCode.create('USNYC'),
10.5,
1500,
3,
1200.0,
1100.0,
'USD',
15,
'LCL',
CsvBookingStatus.PENDING,
documents,
'token-abc123',
new Date(),
undefined,
'Test booking',
undefined
);
};
describe('Constructor and Validation', () => {
it('should create a valid booking', () => {
const booking = createValidBooking();
expect(booking.id).toBe('booking-123');
expect(booking.userId).toBe('user-456');
expect(booking.organizationId).toBe('org-789');
expect(booking.carrierName).toBe('SSC Consolidation');
expect(booking.carrierEmail).toBe('bookings@sscconsolidation.com');
expect(booking.status).toBe(CsvBookingStatus.PENDING);
expect(booking.documents).toHaveLength(3);
});
it('should throw error if ID is empty', () => {
expect(() => {
const docs: CsvBookingDocument[] = [
{
id: 'doc-1',
type: DocumentType.BILL_OF_LADING,
fileName: 'test.pdf',
filePath: '/test.pdf',
mimeType: 'application/pdf',
size: 1024,
uploadedAt: new Date(),
},
];
new CsvBooking(
'',
'user-456',
'org-789',
'SSC Consolidation',
'bookings@sscconsolidation.com',
PortCode.create('NLRTM'),
PortCode.create('USNYC'),
10.5,
1500,
3,
1200.0,
1100.0,
'USD',
15,
'LCL',
CsvBookingStatus.PENDING,
docs,
'token-abc123',
new Date()
);
}).toThrow('Booking ID is required');
});
it('should throw error if volume is negative', () => {
expect(() => {
const docs: CsvBookingDocument[] = [
{
id: 'doc-1',
type: DocumentType.BILL_OF_LADING,
fileName: 'test.pdf',
filePath: '/test.pdf',
mimeType: 'application/pdf',
size: 1024,
uploadedAt: new Date(),
},
];
new CsvBooking(
'booking-123',
'user-456',
'org-789',
'SSC Consolidation',
'bookings@sscconsolidation.com',
PortCode.create('NLRTM'),
PortCode.create('USNYC'),
-10.5, // Negative volume
1500,
3,
1200.0,
1100.0,
'USD',
15,
'LCL',
CsvBookingStatus.PENDING,
docs,
'token-abc123',
new Date()
);
}).toThrow('Volume must be positive');
});
it('should throw error if email format is invalid', () => {
expect(() => {
const docs: CsvBookingDocument[] = [
{
id: 'doc-1',
type: DocumentType.BILL_OF_LADING,
fileName: 'test.pdf',
filePath: '/test.pdf',
mimeType: 'application/pdf',
size: 1024,
uploadedAt: new Date(),
},
];
new CsvBooking(
'booking-123',
'user-456',
'org-789',
'SSC Consolidation',
'invalid-email', // Invalid email
PortCode.create('NLRTM'),
PortCode.create('USNYC'),
10.5,
1500,
3,
1200.0,
1100.0,
'USD',
15,
'LCL',
CsvBookingStatus.PENDING,
docs,
'token-abc123',
new Date()
);
}).toThrow('Invalid carrier email format');
});
it('should throw error if no documents provided', () => {
expect(() => {
new CsvBooking(
'booking-123',
'user-456',
'org-789',
'SSC Consolidation',
'bookings@sscconsolidation.com',
PortCode.create('NLRTM'),
PortCode.create('USNYC'),
10.5,
1500,
3,
1200.0,
1100.0,
'USD',
15,
'LCL',
CsvBookingStatus.PENDING,
[], // Empty documents
'token-abc123',
new Date()
);
}).toThrow('At least one document is required for booking');
});
});
describe('Accept Method', () => {
it('should accept a pending booking', () => {
const booking = createValidBooking();
expect(booking.status).toBe(CsvBookingStatus.PENDING);
booking.accept();
expect(booking.status).toBe(CsvBookingStatus.ACCEPTED);
expect(booking.respondedAt).toBeDefined();
});
it('should throw error when accepting non-pending booking', () => {
const booking = createValidBooking();
booking.accept(); // First acceptance
expect(() => {
booking.accept(); // Try to accept again
}).toThrow('Cannot accept booking with status ACCEPTED');
});
it('should throw error when accepting expired booking', () => {
const booking = createValidBooking();
// Set requestedAt to 8 days ago (expired)
(booking as any).requestedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
expect(booking.isExpired()).toBe(true);
expect(() => {
booking.accept();
}).toThrow('Cannot accept expired booking');
});
});
describe('Reject Method', () => {
it('should reject a pending booking', () => {
const booking = createValidBooking();
expect(booking.status).toBe(CsvBookingStatus.PENDING);
booking.reject('Capacity full');
expect(booking.status).toBe(CsvBookingStatus.REJECTED);
expect(booking.respondedAt).toBeDefined();
expect(booking.rejectionReason).toBe('Capacity full');
});
it('should reject without reason', () => {
const booking = createValidBooking();
booking.reject();
expect(booking.status).toBe(CsvBookingStatus.REJECTED);
expect(booking.rejectionReason).toBeUndefined();
});
it('should throw error when rejecting non-pending booking', () => {
const booking = createValidBooking();
booking.reject(); // First rejection
expect(() => {
booking.reject(); // Try to reject again
}).toThrow('Cannot reject booking with status REJECTED');
});
});
describe('Cancel Method', () => {
it('should cancel a pending booking', () => {
const booking = createValidBooking();
booking.cancel();
expect(booking.status).toBe(CsvBookingStatus.CANCELLED);
expect(booking.respondedAt).toBeDefined();
});
it('should throw error when cancelling accepted booking', () => {
const booking = createValidBooking();
booking.accept();
expect(() => {
booking.cancel();
}).toThrow('Cannot cancel accepted booking');
});
it('should throw error when cancelling rejected booking', () => {
const booking = createValidBooking();
booking.reject();
expect(() => {
booking.cancel();
}).toThrow('Cannot cancel rejected booking');
});
});
describe('Expiration Logic', () => {
it('should not be expired for recent bookings', () => {
const booking = createValidBooking();
expect(booking.isExpired()).toBe(false);
});
it('should be expired after 7 days', () => {
const booking = createValidBooking();
// Set requestedAt to 8 days ago
(booking as any).requestedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
expect(booking.isExpired()).toBe(true);
});
it('should not be expired if already accepted', () => {
const booking = createValidBooking();
booking.accept();
// Set requestedAt to 8 days ago
(booking as any).requestedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
expect(booking.isExpired()).toBe(false);
});
it('should calculate days until expiration correctly', () => {
const booking = createValidBooking();
const days = booking.getDaysUntilExpiration();
expect(days).toBeGreaterThan(6);
expect(days).toBeLessThanOrEqual(7);
});
it('should return 0 days for accepted bookings', () => {
const booking = createValidBooking();
booking.accept();
expect(booking.getDaysUntilExpiration()).toBe(0);
});
});
describe('Status Check Methods', () => {
it('should correctly identify pending booking', () => {
const booking = createValidBooking();
expect(booking.isPending()).toBe(true);
expect(booking.isAccepted()).toBe(false);
expect(booking.isRejected()).toBe(false);
expect(booking.isCancelled()).toBe(false);
});
it('should correctly identify accepted booking', () => {
const booking = createValidBooking();
booking.accept();
expect(booking.isPending()).toBe(false);
expect(booking.isAccepted()).toBe(true);
expect(booking.isRejected()).toBe(false);
expect(booking.isCancelled()).toBe(false);
});
it('should correctly identify rejected booking', () => {
const booking = createValidBooking();
booking.reject();
expect(booking.isPending()).toBe(false);
expect(booking.isAccepted()).toBe(false);
expect(booking.isRejected()).toBe(true);
expect(booking.isCancelled()).toBe(false);
});
});
describe('Document Methods', () => {
it('should check if document type exists', () => {
const booking = createValidBooking();
expect(booking.hasDocumentType(DocumentType.BILL_OF_LADING)).toBe(true);
expect(booking.hasDocumentType(DocumentType.CERTIFICATE_OF_ORIGIN)).toBe(false);
});
it('should get documents by type', () => {
const booking = createValidBooking();
const billOfLading = booking.getDocumentsByType(DocumentType.BILL_OF_LADING);
expect(billOfLading).toHaveLength(1);
expect(billOfLading[0].fileName).toBe('bill-of-lading.pdf');
});
it('should check if all required documents are present', () => {
const booking = createValidBooking();
expect(booking.hasAllRequiredDocuments()).toBe(true);
});
it('should return false if required documents are missing', () => {
const docs: CsvBookingDocument[] = [
{
id: 'doc-1',
type: DocumentType.BILL_OF_LADING,
fileName: 'bill-of-lading.pdf',
filePath: '/uploads/bill-of-lading.pdf',
mimeType: 'application/pdf',
size: 1024,
uploadedAt: new Date(),
},
];
const booking = new CsvBooking(
'booking-123',
'user-456',
'org-789',
'SSC Consolidation',
'bookings@sscconsolidation.com',
PortCode.create('NLRTM'),
PortCode.create('USNYC'),
10.5,
1500,
3,
1200.0,
1100.0,
'USD',
15,
'LCL',
CsvBookingStatus.PENDING,
docs,
'token-abc123',
new Date()
);
expect(booking.hasAllRequiredDocuments()).toBe(false);
});
});
describe('Helper Methods', () => {
it('should return route description', () => {
const booking = createValidBooking();
expect(booking.getRouteDescription()).toBe('NLRTM → USNYC');
});
it('should return booking summary', () => {
const booking = createValidBooking();
expect(booking.getSummary()).toContain('CSV Booking booking-123');
expect(booking.getSummary()).toContain('SSC Consolidation');
expect(booking.getSummary()).toContain('NLRTM → USNYC');
expect(booking.getSummary()).toContain('PENDING');
});
it('should return price in specified currency', () => {
const booking = createValidBooking();
expect(booking.getPriceInCurrency('USD')).toBe(1200.0);
expect(booking.getPriceInCurrency('EUR')).toBe(1100.0);
});
it('should calculate response time in hours', () => {
const booking = createValidBooking();
// No response yet
expect(booking.getResponseTimeHours()).toBeNull();
// Accept booking
booking.accept();
const responseTime = booking.getResponseTimeHours();
expect(responseTime).toBeGreaterThanOrEqual(0);
expect(responseTime).toBeLessThan(1); // Should be less than 1 hour for this test
});
});
});

View File

@ -0,0 +1,335 @@
import { PortCode } from '../value-objects/port-code.vo';
/**
* CSV Booking Status Enum
*
* Represents the lifecycle of a CSV-based booking request
*/
export enum CsvBookingStatus {
PENDING = 'PENDING', // Awaiting carrier response
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
REJECTED = 'REJECTED', // Carrier rejected the booking
CANCELLED = 'CANCELLED', // User cancelled the booking
}
/**
* Document Interface
*
* Represents a document attached to a booking
*/
export interface CsvBookingDocument {
id: string;
type: DocumentType;
fileName: string;
filePath: string;
mimeType: string;
size: number;
uploadedAt: Date;
}
/**
* Document Type Enum
*
* Types of documents that can be attached to a booking
*/
export enum DocumentType {
BILL_OF_LADING = 'BILL_OF_LADING',
PACKING_LIST = 'PACKING_LIST',
COMMERCIAL_INVOICE = 'COMMERCIAL_INVOICE',
CERTIFICATE_OF_ORIGIN = 'CERTIFICATE_OF_ORIGIN',
OTHER = 'OTHER',
}
/**
* CSV Booking Entity
*
* Domain entity representing a shipping booking request from CSV rate search.
* This is a simplified booking workflow for CSV-based rates where the user
* selects a rate and sends a booking request to the carrier with documents.
*
* Business Rules:
* - Booking can only be accepted/rejected when status is PENDING
* - Once accepted/rejected, status cannot be changed
* - Booking expires after 7 days if not responded to
* - At least one document is required for booking creation
* - Confirmation token is used for email accept/reject links
* - Only carrier can accept/reject via email link
* - User can cancel pending bookings
*/
export class CsvBooking {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly organizationId: string,
public readonly carrierName: string,
public readonly carrierEmail: string,
public readonly origin: PortCode,
public readonly destination: PortCode,
public readonly volumeCBM: number,
public readonly weightKG: number,
public readonly palletCount: number,
public readonly priceUSD: number,
public readonly priceEUR: number,
public readonly primaryCurrency: string,
public readonly transitDays: number,
public readonly containerType: string,
public status: CsvBookingStatus,
public readonly documents: CsvBookingDocument[],
public readonly confirmationToken: string,
public readonly requestedAt: Date,
public respondedAt?: Date,
public notes?: string,
public rejectionReason?: string
) {
this.validate();
}
/**
* Validate booking data
*/
private validate(): void {
if (!this.id || this.id.trim().length === 0) {
throw new Error('Booking ID is required');
}
if (!this.userId || this.userId.trim().length === 0) {
throw new Error('User ID is required');
}
if (!this.organizationId || this.organizationId.trim().length === 0) {
throw new Error('Organization ID is required');
}
if (!this.carrierName || this.carrierName.trim().length === 0) {
throw new Error('Carrier name is required');
}
if (!this.carrierEmail || this.carrierEmail.trim().length === 0) {
throw new Error('Carrier email is required');
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.carrierEmail)) {
throw new Error('Invalid carrier email format');
}
if (this.volumeCBM <= 0) {
throw new Error('Volume must be positive');
}
if (this.weightKG <= 0) {
throw new Error('Weight must be positive');
}
if (this.palletCount < 0) {
throw new Error('Pallet count cannot be negative');
}
if (this.priceUSD < 0 || this.priceEUR < 0) {
throw new Error('Price cannot be negative');
}
if (this.transitDays <= 0) {
throw new Error('Transit days must be positive');
}
if (!this.confirmationToken || this.confirmationToken.trim().length === 0) {
throw new Error('Confirmation token is required');
}
if (this.documents.length === 0) {
throw new Error('At least one document is required for booking');
}
}
/**
* Accept the booking
*
* @throws Error if booking is not in PENDING status
*/
accept(): void {
if (this.status !== CsvBookingStatus.PENDING) {
throw new Error(
`Cannot accept booking with status ${this.status}. Only PENDING bookings can be accepted.`
);
}
if (this.isExpired()) {
throw new Error('Cannot accept expired booking');
}
this.status = CsvBookingStatus.ACCEPTED;
this.respondedAt = new Date();
}
/**
* Reject the booking
*
* @param reason Optional reason for rejection
* @throws Error if booking is not in PENDING status
*/
reject(reason?: string): void {
if (this.status !== CsvBookingStatus.PENDING) {
throw new Error(
`Cannot reject booking with status ${this.status}. Only PENDING bookings can be rejected.`
);
}
if (this.isExpired()) {
throw new Error('Cannot reject expired booking (already expired)');
}
this.status = CsvBookingStatus.REJECTED;
this.respondedAt = new Date();
if (reason) {
this.rejectionReason = reason;
}
}
/**
* Cancel the booking (by user)
*
* @throws Error if booking is already accepted/rejected
*/
cancel(): void {
if (this.status === CsvBookingStatus.ACCEPTED) {
throw new Error('Cannot cancel accepted booking. Contact carrier to cancel.');
}
if (this.status === CsvBookingStatus.REJECTED) {
throw new Error('Cannot cancel rejected booking');
}
this.status = CsvBookingStatus.CANCELLED;
this.respondedAt = new Date();
}
/**
* Check if booking has expired (7 days without response)
*
* @returns true if booking is older than 7 days and still pending
*/
isExpired(): boolean {
if (this.status !== CsvBookingStatus.PENDING) {
return false;
}
const expirationDate = new Date(this.requestedAt);
expirationDate.setDate(expirationDate.getDate() + 7);
return new Date() > expirationDate;
}
/**
* Check if booking is still pending (awaiting response)
*/
isPending(): boolean {
return this.status === CsvBookingStatus.PENDING && !this.isExpired();
}
/**
* Check if booking was accepted
*/
isAccepted(): boolean {
return this.status === CsvBookingStatus.ACCEPTED;
}
/**
* Check if booking was rejected
*/
isRejected(): boolean {
return this.status === CsvBookingStatus.REJECTED;
}
/**
* Check if booking was cancelled
*/
isCancelled(): boolean {
return this.status === CsvBookingStatus.CANCELLED;
}
/**
* Get route description (origin destination)
*/
getRouteDescription(): string {
return `${this.origin.getValue()}${this.destination.getValue()}`;
}
/**
* Get booking summary
*/
getSummary(): string {
return `CSV Booking ${this.id}: ${this.carrierName} - ${this.getRouteDescription()} (${this.status})`;
}
/**
* Get price in specified currency
*/
getPriceInCurrency(currency: 'USD' | 'EUR'): number {
return currency === 'USD' ? this.priceUSD : this.priceEUR;
}
/**
* Get days until expiration (negative if expired)
*/
getDaysUntilExpiration(): number {
if (this.status !== CsvBookingStatus.PENDING) {
return 0;
}
const expirationDate = new Date(this.requestedAt);
expirationDate.setDate(expirationDate.getDate() + 7);
const now = new Date();
const diffTime = expirationDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
/**
* Check if booking has a specific document type
*/
hasDocumentType(type: DocumentType): boolean {
return this.documents.some(doc => doc.type === type);
}
/**
* Get documents by type
*/
getDocumentsByType(type: DocumentType): CsvBookingDocument[] {
return this.documents.filter(doc => doc.type === type);
}
/**
* Check if all required documents are present
*/
hasAllRequiredDocuments(): boolean {
const requiredTypes = [
DocumentType.BILL_OF_LADING,
DocumentType.PACKING_LIST,
DocumentType.COMMERCIAL_INVOICE,
];
return requiredTypes.every(type => this.hasDocumentType(type));
}
/**
* Get response time in hours (if responded)
*/
getResponseTimeHours(): number | null {
if (!this.respondedAt) {
return null;
}
const diffTime = this.respondedAt.getTime() - this.requestedAt.getTime();
const diffHours = diffTime / (1000 * 60 * 60);
return Math.round(diffHours * 100) / 100; // Round to 2 decimals
}
toString(): string {
return this.getSummary();
}
}

View File

@ -45,6 +45,7 @@ export interface RatePricing {
export class CsvRate { export class CsvRate {
constructor( constructor(
public readonly companyName: string, public readonly companyName: string,
public readonly companyEmail: string,
public readonly origin: PortCode, public readonly origin: PortCode,
public readonly destination: PortCode, public readonly destination: PortCode,
public readonly containerType: ContainerType, public readonly containerType: ContainerType,
@ -65,6 +66,10 @@ export class CsvRate {
throw new Error('Company name is required'); throw new Error('Company name is required');
} }
if (!this.companyEmail || this.companyEmail.trim().length === 0) {
throw new Error('Company email is required');
}
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) { if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
throw new Error('Volume range cannot be negative'); throw new Error('Volume range cannot be negative');
} }

View File

@ -14,6 +14,10 @@ export enum NotificationType {
SYSTEM_ANNOUNCEMENT = 'system_announcement', SYSTEM_ANNOUNCEMENT = 'system_announcement',
USER_INVITED = 'user_invited', USER_INVITED = 'user_invited',
ORGANIZATION_UPDATE = 'organization_update', ORGANIZATION_UPDATE = 'organization_update',
// CSV Booking notifications
CSV_BOOKING_ACCEPTED = 'csv_booking_accepted',
CSV_BOOKING_REJECTED = 'csv_booking_rejected',
CSV_BOOKING_REQUEST_SENT = 'csv_booking_request_sent',
} }
export enum NotificationPriority { export enum NotificationPriority {

View File

@ -13,6 +13,25 @@ import {
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service'; import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
/**
* Config Metadata Interface (to avoid circular dependency)
*/
interface CsvRateConfig {
companyName: string;
csvFilePath: string;
metadata?: {
companyEmail?: string;
[key: string]: any;
};
}
/**
* Config Repository Port (simplified interface)
*/
export interface CsvRateConfigRepositoryPort {
findActiveConfigs(): Promise<CsvRateConfig[]>;
}
/** /**
* CSV Rate Search Service * CSV Rate Search Service
* *
@ -24,7 +43,10 @@ import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.servi
export class CsvRateSearchService implements SearchCsvRatesPort { export class CsvRateSearchService implements SearchCsvRatesPort {
private readonly priceCalculator: CsvRatePriceCalculatorService; private readonly priceCalculator: CsvRatePriceCalculatorService;
constructor(private readonly csvRateLoader: CsvRateLoaderPort) { constructor(
private readonly csvRateLoader: CsvRateLoaderPort,
private readonly configRepository?: CsvRateConfigRepositoryPort
) {
this.priceCalculator = new CsvRatePriceCalculatorService(); this.priceCalculator = new CsvRatePriceCalculatorService();
} }
@ -113,8 +135,22 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
* Load all rates from all CSV files * Load all rates from all CSV files
*/ */
private async loadAllRates(): Promise<CsvRate[]> { private async loadAllRates(): Promise<CsvRate[]> {
// If config repository is available, load rates with emails from configs
if (this.configRepository) {
const configs = await this.configRepository.findActiveConfigs();
const ratePromises = configs.map(config => {
const email = config.metadata?.companyEmail || 'bookings@example.com';
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email);
});
const rateArrays = await Promise.all(ratePromises);
return rateArrays.flat();
}
// Fallback: load files without email (use default)
const files = await this.csvRateLoader.getAvailableCsvFiles(); const files = await this.csvRateLoader.getAvailableCsvFiles();
const ratePromises = files.map(file => this.csvRateLoader.loadRatesFromCsv(file)); const ratePromises = files.map(file =>
this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')
);
const rateArrays = await Promise.all(ratePromises); const rateArrays = await Promise.all(ratePromises);
return rateArrays.flat(); return rateArrays.flat();
} }

View File

@ -79,8 +79,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`); this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
} }
async loadRatesFromCsv(filePath: string): Promise<CsvRate[]> { async loadRatesFromCsv(filePath: string, companyEmail: string): Promise<CsvRate[]> {
this.logger.log(`Loading rates from CSV: ${filePath}`); this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail})`);
try { try {
// Read CSV file // Read CSV file
@ -105,7 +105,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
// Map to domain entities // Map to domain entities
const rates = records.map((record, index) => { const rates = records.map((record, index) => {
try { try {
return this.mapToCsvRate(record); return this.mapToCsvRate(record, companyEmail);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`); this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
@ -130,7 +130,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
return []; return [];
} }
return this.loadRatesFromCsv(fileName); // Use placeholder email since we don't have access to config repository here
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, placeholderEmail);
} }
async validateCsvFile( async validateCsvFile(
@ -172,10 +174,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
errors.push(errorMessage); errors.push(errorMessage);
} }
// Validate each row // Validate each row (use dummy email for validation)
records.forEach((record, index) => { records.forEach((record, index) => {
try { try {
this.mapToCsvRate(record); this.mapToCsvRate(record, 'validation@example.com');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Row ${index + 1}: ${errorMessage}`); errors.push(`Row ${index + 1}: ${errorMessage}`);
@ -253,7 +255,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
/** /**
* Map CSV row to CsvRate domain entity * Map CSV row to CsvRate domain entity
*/ */
private mapToCsvRate(record: CsvRow): CsvRate { private mapToCsvRate(record: CsvRow, companyEmail: string): CsvRate {
// Parse surcharges // Parse surcharges
const surcharges = this.parseSurcharges(record); const surcharges = this.parseSurcharges(record);
@ -265,6 +267,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
// Create CsvRate // Create CsvRate
return new CsvRate( return new CsvRate(
record.companyName.trim(), record.companyName.trim(),
companyEmail,
PortCode.create(record.origin), PortCode.create(record.origin),
PortCode.create(record.destination), PortCode.create(record.destination),
ContainerType.create(record.containerType), ContainerType.create(record.containerType),

View File

@ -42,10 +42,25 @@ import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/enti
// Domain Services (with factory to inject dependencies) // Domain Services (with factory to inject dependencies)
{ {
provide: CsvRateSearchService, provide: CsvRateSearchService,
useFactory: (csvRateLoader: CsvRateLoaderAdapter) => { useFactory: (
return new CsvRateSearchService(csvRateLoader); csvRateLoader: CsvRateLoaderAdapter,
configRepository: TypeOrmCsvRateConfigRepository
) => {
// Create adapter that maps ORM entity to domain interface
const configRepositoryAdapter = {
async findActiveConfigs() {
const configs = await configRepository.findActiveConfigs();
// Map ORM entities to domain interface (null -> undefined)
return configs.map(config => ({
companyName: config.companyName,
csvFilePath: config.csvFilePath,
metadata: config.metadata === null ? undefined : config.metadata,
}));
}
};
return new CsvRateSearchService(csvRateLoader, configRepositoryAdapter);
}, },
inject: [CsvRateLoaderAdapter], inject: [CsvRateLoaderAdapter, TypeOrmCsvRateConfigRepository],
}, },
// Application Mappers // Application Mappers

View File

@ -150,4 +150,46 @@ export class EmailAdapter implements EmailPort {
html, html,
}); });
} }
async sendCsvBookingRequest(
carrierEmail: string,
bookingData: {
bookingId: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
documents: Array<{
type: string;
fileName: string;
}>;
confirmationToken: string;
}
): Promise<void> {
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/accept`;
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/reject`;
const html = await this.emailTemplates.renderCsvBookingRequest({
...bookingData,
acceptUrl,
rejectUrl,
});
await this.send({
to: carrierEmail,
subject: `Nouvelle demande de réservation - ${bookingData.origin}${bookingData.destination}`,
html,
});
this.logger.log(
`CSV booking request sent to ${carrierEmail} for booking ${bookingData.bookingId}`
);
}
} }

View File

@ -255,4 +255,247 @@ export class EmailTemplates {
const template = Handlebars.compile(html); const template = Handlebars.compile(html);
return template(data); return template(data);
} }
/**
* Render CSV booking request email
*/
async renderCsvBookingRequest(data: {
bookingId: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
documents: Array<{
type: string;
fileName: string;
}>;
acceptUrl: string;
rejectUrl: string;
}): Promise<string> {
const mjmlTemplate = `
<mjml>
<mj-head>
<mj-attributes>
<mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" />
<mj-text font-size="14px" color="#333333" line-height="1.6" />
</mj-attributes>
<mj-style>
.info-row {
padding: 8px 0;
border-bottom: 1px solid #e0e0e0;
}
.info-label {
font-weight: bold;
color: #0066cc;
}
</mj-style>
</mj-head>
<mj-body background-color="#f4f4f4">
<!-- Header -->
<mj-section background-color="#0066cc" padding="20px">
<mj-column>
<mj-text font-size="28px" font-weight="bold" color="#ffffff" align="center">
Nouvelle demande de réservation
</mj-text>
<mj-text font-size="16px" color="#ffffff" align="center">
Xpeditis
</mj-text>
</mj-column>
</mj-section>
<!-- Introduction -->
<mj-section background-color="#ffffff" padding="20px">
<mj-column>
<mj-text font-size="16px">
Bonjour,
</mj-text>
<mj-text>
Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande.
</mj-text>
</mj-column>
</mj-section>
<!-- Booking Details -->
<mj-section background-color="#ffffff" padding="20px 20px 10px 20px">
<mj-column>
<mj-text font-size="20px" font-weight="bold" color="#0066cc">
Détails du transport
</mj-text>
<mj-divider border-color="#0066cc" border-width="2px" />
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Route</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text>{{origin}} {{destination}}</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Volume</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text>{{volumeCBM}} CBM</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Poids</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text>{{weightKG}} kg</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Palettes</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text>{{palletCount}}</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Type de conteneur</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text>{{containerType}}</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Transit</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text>{{transitDays}} jours</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px 20px 20px">
<mj-column width="40%">
<mj-text css-class="info-label">Prix</mj-text>
</mj-column>
<mj-column width="60%">
<mj-text font-size="18px" font-weight="bold" color="#00aa00">
{{#if (eq primaryCurrency "EUR")}}
{{priceEUR}} EUR
{{else}}
{{priceUSD}} USD
{{/if}}
</mj-text>
<mj-text font-size="12px" color="#666666">
{{#if (eq primaryCurrency "EUR")}}
( {{priceUSD}} USD)
{{else}}
( {{priceEUR}} EUR)
{{/if}}
</mj-text>
</mj-column>
</mj-section>
<!-- Documents Section -->
<mj-section background-color="#f9f9f9" padding="20px">
<mj-column>
<mj-text font-size="18px" font-weight="bold" color="#0066cc">
📄 Documents fournis
</mj-text>
<mj-divider border-color="#0066cc" border-width="2px" />
{{#each documents}}
<mj-text padding="5px 0">
<strong>{{this.type}}:</strong> {{this.fileName}}
</mj-text>
{{/each}}
</mj-column>
</mj-section>
<!-- Action Buttons -->
<mj-section background-color="#ffffff" padding="30px 20px">
<mj-column>
<mj-text font-size="16px" font-weight="bold" align="center">
Veuillez confirmer votre décision:
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px 20px 30px 20px">
<mj-column width="50%" padding="0px 10px">
<mj-button
background-color="#00aa00"
href="{{acceptUrl}}"
font-size="16px"
font-weight="bold"
border-radius="5px"
padding="15px 30px"
>
Accepter la demande
</mj-button>
</mj-column>
<mj-column width="50%" padding="0px 10px">
<mj-button
background-color="#cc0000"
href="{{rejectUrl}}"
font-size="16px"
font-weight="bold"
border-radius="5px"
padding="15px 30px"
>
Refuser la demande
</mj-button>
</mj-column>
</mj-section>
<!-- Important Notice -->
<mj-section background-color="#fff8e1" padding="20px">
<mj-column>
<mj-text font-size="14px" color="#f57c00" font-weight="bold">
Important
</mj-text>
<mj-text font-size="13px" color="#666666">
Cette demande expire automatiquement dans <strong>7 jours</strong> si aucune action n'est prise. Merci de répondre dans les meilleurs délais.
</mj-text>
</mj-column>
</mj-section>
<!-- Footer -->
<mj-section background-color="#f4f4f4" padding="20px">
<mj-column>
<mj-text font-size="12px" color="#666666" align="center">
Référence de réservation: <strong>{{bookingId}}</strong>
</mj-text>
<mj-divider border-color="#cccccc" padding="10px 0" />
<mj-text font-size="12px" color="#666666" align="center">
© 2025 Xpeditis. Tous droits réservés.
</mj-text>
<mj-text font-size="11px" color="#999999" align="center">
Cet email a é envoyé automatiquement. Merci de ne pas y répondre directement.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
`;
// Register Handlebars helper for equality check
Handlebars.registerHelper('eq', function (a, b) {
return a === b;
});
const { html } = mjml2html(mjmlTemplate);
const template = Handlebars.compile(html);
return template(data);
}
} }

View File

@ -0,0 +1,114 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
* CSV Booking ORM Entity
*
* TypeORM entity for csv_bookings table
* Stores booking requests made from CSV rate search results
*/
@Entity('csv_bookings')
@Index(['userId'])
@Index(['organizationId'])
@Index(['status'])
@Index(['carrierEmail'])
@Index(['confirmationToken'])
@Index(['requestedAt'])
export class CsvBookingOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
@Index()
userId: string;
@Column({ name: 'organization_id', type: 'uuid' })
@Index()
organizationId: string;
@Column({ name: 'carrier_name', type: 'varchar', length: 255 })
carrierName: string;
@Column({ name: 'carrier_email', type: 'varchar', length: 255 })
@Index()
carrierEmail: string;
@Column({ name: 'origin', type: 'varchar', length: 5 })
origin: string;
@Column({ name: 'destination', type: 'varchar', length: 5 })
destination: string;
@Column({ name: 'volume_cbm', type: 'decimal', precision: 10, scale: 2 })
volumeCBM: number;
@Column({ name: 'weight_kg', type: 'decimal', precision: 10, scale: 2 })
weightKG: number;
@Column({ name: 'pallet_count', type: 'integer' })
palletCount: number;
@Column({ name: 'price_usd', type: 'decimal', precision: 10, scale: 2 })
priceUSD: number;
@Column({ name: 'price_eur', type: 'decimal', precision: 10, scale: 2 })
priceEUR: number;
@Column({ name: 'primary_currency', type: 'varchar', length: 3 })
primaryCurrency: string;
@Column({ name: 'transit_days', type: 'integer' })
transitDays: number;
@Column({ name: 'container_type', type: 'varchar', length: 50 })
containerType: string;
@Column({
name: 'status',
type: 'enum',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING',
})
@Index()
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' })
documents: Array<{
id: string;
type: string;
fileName: string;
filePath: string;
mimeType: string;
size: number;
uploadedAt: Date;
}>;
@Column({ name: 'confirmation_token', type: 'varchar', length: 255, unique: true })
@Index()
confirmationToken: string;
@Column({ name: 'requested_at', type: 'timestamp with time zone' })
@Index()
requestedAt: Date;
@Column({ name: 'responded_at', type: 'timestamp with time zone', nullable: true })
respondedAt?: Date;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
rejectionReason?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
updatedAt: Date;
}

View File

@ -0,0 +1,89 @@
import { CsvBooking, CsvBookingStatus, CsvBookingDocument } from '@domain/entities/csv-booking.entity';
import { PortCode } from '@domain/value-objects/port-code.vo';
import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
/**
* CSV Booking Mapper
*
* Maps between domain CsvBooking entity and ORM entity
*/
export class CsvBookingMapper {
/**
* Map ORM entity to domain entity
*/
static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking {
return new CsvBooking(
ormEntity.id,
ormEntity.userId,
ormEntity.organizationId,
ormEntity.carrierName,
ormEntity.carrierEmail,
PortCode.create(ormEntity.origin),
PortCode.create(ormEntity.destination),
Number(ormEntity.volumeCBM),
Number(ormEntity.weightKG),
ormEntity.palletCount,
Number(ormEntity.priceUSD),
Number(ormEntity.priceEUR),
ormEntity.primaryCurrency,
ormEntity.transitDays,
ormEntity.containerType,
CsvBookingStatus[ormEntity.status] as CsvBookingStatus,
ormEntity.documents as CsvBookingDocument[],
ormEntity.confirmationToken,
ormEntity.requestedAt,
ormEntity.respondedAt,
ormEntity.notes,
ormEntity.rejectionReason
);
}
/**
* Map domain entity to ORM entity (for creation)
*/
static toOrmCreate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return {
id: domain.id,
userId: domain.userId,
organizationId: domain.organizationId,
carrierName: domain.carrierName,
carrierEmail: domain.carrierEmail,
origin: domain.origin.getValue(),
destination: domain.destination.getValue(),
volumeCBM: domain.volumeCBM,
weightKG: domain.weightKG,
palletCount: domain.palletCount,
priceUSD: domain.priceUSD,
priceEUR: domain.priceEUR,
primaryCurrency: domain.primaryCurrency,
transitDays: domain.transitDays,
containerType: domain.containerType,
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
documents: domain.documents as any,
confirmationToken: domain.confirmationToken,
requestedAt: domain.requestedAt,
respondedAt: domain.respondedAt,
notes: domain.notes,
rejectionReason: domain.rejectionReason,
};
}
/**
* Map domain entity to ORM entity (for update)
*/
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return {
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
respondedAt: domain.respondedAt,
notes: domain.notes,
rejectionReason: domain.rejectionReason,
};
}
/**
* Map array of ORM entities to domain entities
*/
static toDomainArray(ormEntities: CsvBookingOrmEntity[]): CsvBooking[] {
return ormEntities.map(entity => this.toDomain(entity));
}
}

View File

@ -0,0 +1,66 @@
import { Notification, NotificationType, NotificationPriority } from '@domain/entities/notification.entity';
import { NotificationOrmEntity } from '../entities/notification.orm-entity';
/**
* Notification Mapper
*
* Maps between domain Notification entity and ORM entity
*/
export class NotificationMapper {
/**
* Map ORM entity to domain entity
*/
static toDomain(ormEntity: NotificationOrmEntity): Notification {
return Notification.fromPersistence({
id: ormEntity.id,
userId: ormEntity.user_id,
organizationId: ormEntity.organization_id,
type: ormEntity.type as NotificationType,
priority: ormEntity.priority as NotificationPriority,
title: ormEntity.title,
message: ormEntity.message,
metadata: ormEntity.metadata,
read: ormEntity.read,
readAt: ormEntity.read_at,
actionUrl: ormEntity.action_url,
createdAt: ormEntity.created_at,
});
}
/**
* Map domain entity to ORM entity (for creation)
*/
static toOrmCreate(domain: Notification): Partial<NotificationOrmEntity> {
return {
id: domain.id,
user_id: domain.userId,
organization_id: domain.organizationId,
type: domain.type,
priority: domain.priority,
title: domain.title,
message: domain.message,
metadata: domain.metadata,
read: domain.read,
read_at: domain.readAt,
action_url: domain.actionUrl,
created_at: domain.createdAt,
};
}
/**
* Map domain entity to ORM entity (for update)
*/
static toOrmUpdate(domain: Notification): Partial<NotificationOrmEntity> {
return {
read: domain.read,
read_at: domain.readAt,
};
}
/**
* Map array of ORM entities to domain entities
*/
static toDomainArray(ormEntities: NotificationOrmEntity[]): Notification[] {
return ormEntities.map(entity => this.toDomain(entity));
}
}

View File

@ -0,0 +1,276 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from 'typeorm';
/**
* Create CSV Bookings Table
*
* This table stores booking requests made from CSV rate search results.
* Carriers receive email notifications with accept/reject links.
*/
export class CreateCsvBookingsTable1730000000010 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create ENUM type for booking status
await queryRunner.query(`
CREATE TYPE csv_booking_status AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED');
`);
// Create csv_bookings table
await queryRunner.createTable(
new Table({
name: 'csv_bookings',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{
name: 'user_id',
type: 'uuid',
isNullable: false,
},
{
name: 'organization_id',
type: 'uuid',
isNullable: false,
},
{
name: 'carrier_name',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'carrier_email',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'origin',
type: 'varchar',
length: '5',
isNullable: false,
comment: 'UN LOCODE for origin port',
},
{
name: 'destination',
type: 'varchar',
length: '5',
isNullable: false,
comment: 'UN LOCODE for destination port',
},
{
name: 'volume_cbm',
type: 'decimal',
precision: 10,
scale: 2,
isNullable: false,
comment: 'Cargo volume in cubic meters',
},
{
name: 'weight_kg',
type: 'decimal',
precision: 10,
scale: 2,
isNullable: false,
comment: 'Cargo weight in kilograms',
},
{
name: 'pallet_count',
type: 'integer',
isNullable: false,
comment: 'Number of pallets',
},
{
name: 'price_usd',
type: 'decimal',
precision: 10,
scale: 2,
isNullable: false,
comment: 'Price in USD',
},
{
name: 'price_eur',
type: 'decimal',
precision: 10,
scale: 2,
isNullable: false,
comment: 'Price in EUR',
},
{
name: 'primary_currency',
type: 'varchar',
length: '3',
isNullable: false,
comment: 'Primary currency (USD or EUR)',
},
{
name: 'transit_days',
type: 'integer',
isNullable: false,
comment: 'Estimated transit time in days',
},
{
name: 'container_type',
type: 'varchar',
length: '50',
isNullable: false,
comment: 'Container type (LCL, FCL 20, FCL 40, etc.)',
},
{
name: 'status',
type: 'csv_booking_status',
isNullable: false,
default: "'PENDING'",
},
{
name: 'documents',
type: 'jsonb',
isNullable: false,
default: "'[]'",
comment: 'Array of uploaded document metadata',
},
{
name: 'confirmation_token',
type: 'varchar',
length: '255',
isNullable: false,
isUnique: true,
comment: 'Unique token for carrier email confirmation links',
},
{
name: 'requested_at',
type: 'timestamp with time zone',
isNullable: false,
comment: 'When the booking request was created',
},
{
name: 'responded_at',
type: 'timestamp with time zone',
isNullable: true,
comment: 'When the carrier accepted or rejected the booking',
},
{
name: 'notes',
type: 'text',
isNullable: true,
comment: 'Additional notes from the user',
},
{
name: 'rejection_reason',
type: 'text',
isNullable: true,
comment: 'Reason provided by carrier for rejection',
},
{
name: 'created_at',
type: 'timestamp with time zone',
isNullable: false,
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamp with time zone',
isNullable: false,
default: 'CURRENT_TIMESTAMP',
},
],
}),
true,
);
// Create indexes
await queryRunner.createIndex(
'csv_bookings',
new TableIndex({
name: 'IDX_csv_bookings_user_id',
columnNames: ['user_id'],
}),
);
await queryRunner.createIndex(
'csv_bookings',
new TableIndex({
name: 'IDX_csv_bookings_organization_id',
columnNames: ['organization_id'],
}),
);
await queryRunner.createIndex(
'csv_bookings',
new TableIndex({
name: 'IDX_csv_bookings_status',
columnNames: ['status'],
}),
);
await queryRunner.createIndex(
'csv_bookings',
new TableIndex({
name: 'IDX_csv_bookings_carrier_email',
columnNames: ['carrier_email'],
}),
);
await queryRunner.createIndex(
'csv_bookings',
new TableIndex({
name: 'IDX_csv_bookings_confirmation_token',
columnNames: ['confirmation_token'],
}),
);
await queryRunner.createIndex(
'csv_bookings',
new TableIndex({
name: 'IDX_csv_bookings_requested_at',
columnNames: ['requested_at'],
}),
);
// Add foreign key constraints
await queryRunner.createForeignKey(
'csv_bookings',
new TableForeignKey({
columnNames: ['user_id'],
referencedTableName: 'users',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
name: 'FK_csv_bookings_user',
}),
);
await queryRunner.createForeignKey(
'csv_bookings',
new TableForeignKey({
columnNames: ['organization_id'],
referencedTableName: 'organizations',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
name: 'FK_csv_bookings_organization',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop foreign keys
await queryRunner.dropForeignKey('csv_bookings', 'FK_csv_bookings_organization');
await queryRunner.dropForeignKey('csv_bookings', 'FK_csv_bookings_user');
// Drop indexes
await queryRunner.dropIndex('csv_bookings', 'IDX_csv_bookings_requested_at');
await queryRunner.dropIndex('csv_bookings', 'IDX_csv_bookings_confirmation_token');
await queryRunner.dropIndex('csv_bookings', 'IDX_csv_bookings_carrier_email');
await queryRunner.dropIndex('csv_bookings', 'IDX_csv_bookings_status');
await queryRunner.dropIndex('csv_bookings', 'IDX_csv_bookings_organization_id');
await queryRunner.dropIndex('csv_bookings', 'IDX_csv_bookings_user_id');
// Drop table
await queryRunner.dropTable('csv_bookings', true);
// Drop ENUM type
await queryRunner.query(`DROP TYPE csv_booking_status;`);
}
}

View File

@ -0,0 +1,217 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, MoreThan } from 'typeorm';
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
import { CsvBookingRepositoryPort } from '@domain/ports/out/csv-booking.repository';
import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
import { CsvBookingMapper } from '../mappers/csv-booking.mapper';
/**
* TypeORM CSV Booking Repository
*
* Implementation of CsvBookingRepositoryPort using TypeORM
*/
@Injectable()
export class TypeOrmCsvBookingRepository implements CsvBookingRepositoryPort {
private readonly logger = new Logger(TypeOrmCsvBookingRepository.name);
constructor(
@InjectRepository(CsvBookingOrmEntity)
private readonly repository: Repository<CsvBookingOrmEntity>
) {}
async create(booking: CsvBooking): Promise<CsvBooking> {
this.logger.log(`Creating CSV booking: ${booking.id}`);
const ormEntity = CsvBookingMapper.toOrmCreate(booking);
const saved = await this.repository.save(ormEntity);
this.logger.log(`CSV booking created successfully: ${saved.id}`);
return CsvBookingMapper.toDomain(saved);
}
async findById(id: string): Promise<CsvBooking | null> {
this.logger.log(`Finding CSV booking by ID: ${id}`);
const ormEntity = await this.repository.findOne({
where: { id },
});
if (!ormEntity) {
this.logger.log(`CSV booking not found: ${id}`);
return null;
}
return CsvBookingMapper.toDomain(ormEntity);
}
async findByToken(token: string): Promise<CsvBooking | null> {
this.logger.log(`Finding CSV booking by token: ${token}`);
const ormEntity = await this.repository.findOne({
where: { confirmationToken: token },
});
if (!ormEntity) {
this.logger.log(`CSV booking not found for token: ${token}`);
return null;
}
return CsvBookingMapper.toDomain(ormEntity);
}
async findByUserId(userId: string): Promise<CsvBooking[]> {
this.logger.log(`Finding CSV bookings for user: ${userId}`);
const ormEntities = await this.repository.find({
where: { userId },
order: { requestedAt: 'DESC' },
});
this.logger.log(`Found ${ormEntities.length} CSV bookings for user: ${userId}`);
return CsvBookingMapper.toDomainArray(ormEntities);
}
async findByOrganizationId(organizationId: string): Promise<CsvBooking[]> {
this.logger.log(`Finding CSV bookings for organization: ${organizationId}`);
const ormEntities = await this.repository.find({
where: { organizationId },
order: { requestedAt: 'DESC' },
});
this.logger.log(
`Found ${ormEntities.length} CSV bookings for organization: ${organizationId}`
);
return CsvBookingMapper.toDomainArray(ormEntities);
}
async findByStatus(status: string): Promise<CsvBooking[]> {
this.logger.log(`Finding CSV bookings with status: ${status}`);
const ormEntities = await this.repository.find({
where: { status: status as any },
order: { requestedAt: 'DESC' },
});
this.logger.log(`Found ${ormEntities.length} CSV bookings with status: ${status}`);
return CsvBookingMapper.toDomainArray(ormEntities);
}
async findExpiringSoon(daysUntilExpiration: number): Promise<CsvBooking[]> {
this.logger.log(`Finding CSV bookings expiring in ${daysUntilExpiration} days`);
const now = new Date();
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + daysUntilExpiration);
// Find pending bookings requested between 7 days ago and (7 - daysUntilExpiration) days ago
const minRequestDate = new Date();
minRequestDate.setDate(minRequestDate.getDate() - 7);
const maxRequestDate = new Date();
maxRequestDate.setDate(maxRequestDate.getDate() - (7 - daysUntilExpiration));
const ormEntities = await this.repository.find({
where: {
status: 'PENDING',
requestedAt: LessThan(maxRequestDate) && MoreThan(minRequestDate),
},
order: { requestedAt: 'ASC' },
});
this.logger.log(
`Found ${ormEntities.length} CSV bookings expiring in ${daysUntilExpiration} days`
);
return CsvBookingMapper.toDomainArray(ormEntities);
}
async update(booking: CsvBooking): Promise<CsvBooking> {
this.logger.log(`Updating CSV booking: ${booking.id}`);
const existing = await this.repository.findOne({
where: { id: booking.id },
});
if (!existing) {
throw new Error(`CSV booking not found: ${booking.id}`);
}
const updates = CsvBookingMapper.toOrmUpdate(booking);
Object.assign(existing, updates);
const saved = await this.repository.save(existing);
this.logger.log(`CSV booking updated successfully: ${saved.id}`);
return CsvBookingMapper.toDomain(saved);
}
async delete(id: string): Promise<void> {
this.logger.log(`Deleting CSV booking: ${id}`);
const result = await this.repository.delete({ id });
if (result.affected === 0) {
throw new Error(`CSV booking not found: ${id}`);
}
this.logger.log(`CSV booking deleted successfully: ${id}`);
}
async countByStatusForUser(userId: string): Promise<Record<string, number>> {
this.logger.log(`Counting CSV bookings by status for user: ${userId}`);
const results = await this.repository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('booking.userId = :userId', { userId })
.groupBy('booking.status')
.getRawMany();
const counts: Record<string, number> = {
PENDING: 0,
ACCEPTED: 0,
REJECTED: 0,
CANCELLED: 0,
};
results.forEach(result => {
counts[result.status] = parseInt(result.count, 10);
});
this.logger.log(`Counted CSV bookings by status for user ${userId}:`, counts);
return counts;
}
async countByStatusForOrganization(
organizationId: string
): Promise<Record<string, number>> {
this.logger.log(`Counting CSV bookings by status for organization: ${organizationId}`);
const results = await this.repository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('booking.organizationId = :organizationId', { organizationId })
.groupBy('booking.status')
.getRawMany();
const counts: Record<string, number> = {
PENDING: 0,
ACCEPTED: 0,
REJECTED: 0,
CANCELLED: 0,
};
results.forEach(result => {
counts[result.status] = parseInt(result.count, 10);
});
this.logger.log(
`Counted CSV bookings by status for organization ${organizationId}:`,
counts
);
return counts;
}
}

View File

@ -38,6 +38,18 @@ export class S3StorageAdapter implements StoragePort {
const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY_ID'); const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('AWS_SECRET_ACCESS_KEY'); const secretAccessKey = this.configService.get<string>('AWS_SECRET_ACCESS_KEY');
// Check if S3/MinIO is configured
const isConfigured = endpoint || (accessKeyId && secretAccessKey);
if (!isConfigured) {
this.logger.warn(
'S3 Storage adapter is NOT configured (no endpoint or credentials). Storage operations will fail. ' +
'Set AWS_S3_ENDPOINT for MinIO or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY for AWS S3.'
);
// Don't initialize client if not configured
return;
}
this.s3Client = new S3Client({ this.s3Client = new S3Client({
region, region,
endpoint, endpoint,
@ -59,6 +71,10 @@ export class S3StorageAdapter implements StoragePort {
} }
async upload(options: UploadOptions): Promise<StorageObject> { async upload(options: UploadOptions): Promise<StorageObject> {
if (!this.s3Client) {
throw new Error('S3 Storage is not configured. Set AWS_S3_ENDPOINT or AWS credentials in .env');
}
try { try {
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: options.bucket, Bucket: options.bucket,

View File

@ -0,0 +1,297 @@
/**
* Public Booking Confirmation Page
*
* Allows carriers to accept booking requests via email link
* Route: /booking/confirm/:token
*/
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingConfirmPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
useEffect(() => {
if (!token) {
setError('Token de confirmation invalide');
setIsLoading(false);
return;
}
// Auto-accept the booking
handleAccept();
}, [token]);
const handleAccept = async () => {
setIsAccepting(true);
setError(null);
try {
const result = await acceptCsvBooking(token);
setBooking(result);
} catch (err) {
console.error('Acceptance error:', err);
if (err instanceof Error) {
setError(err.message);
} else {
setError('Une erreur est survenue lors de l\'acceptation');
}
} finally {
setIsLoading(false);
setIsAccepting(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Confirmation en cours...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Erreur de confirmation
</h1>
<p className="text-gray-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800">
<strong>Raisons possibles :</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>Le lien a expiré</li>
<li>La demande a déjà é acceptée ou refusée</li>
<li>Le token de confirmation est invalide</li>
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
</p>
</div>
</div>
);
}
if (!booking) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-green-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full">
{/* Success Icon with Animation */}
<div className="text-center mb-8">
<div className="relative inline-block">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-scale-in">
<svg
className="w-10 h-10 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
{/* Animated rings */}
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
Demande acceptée !
</h1>
<p className="text-lg text-gray-600 mb-2">
Merci d'avoir accepté cette demande de transport.
</p>
<p className="text-gray-500">
Le client a é notifié par email.
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif de la réservation
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">ID Réservation</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Trajet</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Volume</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Poids</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Palettes</span>
<span className="font-semibold text-gray-900">{booking.palletCount}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Type de conteneur</span>
<span className="font-semibold text-gray-900">{booking.containerType}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Temps de transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
<div className="flex justify-between py-3">
<span className="text-gray-600 text-lg">Prix</span>
<div className="text-right">
<div className="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
: `${booking.priceEUR.toLocaleString()}`
}
</div>
<div className="text-sm text-gray-500">
{booking.primaryCurrency === 'USD'
? `(€${booking.priceEUR.toLocaleString()})`
: `($${booking.priceUSD.toLocaleString()})`
}
</div>
</div>
</div>
</div>
{booking.notes && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">Notes :</p>
<p className="text-gray-800">{booking.notes}</p>
</div>
)}
</div>
{/* Next Steps */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Prochaines étapes
</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Le client va finaliser les détails du conteneur</li>
<li>Vous recevrez un email avec les documents nécessaires</li>
<li>Le paiement sera traité selon vos conditions habituelles</li>
</ul>
</div>
{/* Documents Section */}
{booking.documents && booking.documents.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-3">Documents fournis</h3>
<div className="space-y-2">
{booking.documents.map((doc, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200">
<div className="flex items-center">
<svg className="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p>
<p className="text-xs text-gray-500">{doc.type}</p>
</div>
</div>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Télécharger
</a>
</div>
))}
</div>
</div>
)}
{/* Contact Info */}
<div className="text-center text-sm text-gray-500">
<p>Pour toute question, contactez-nous à</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com
</a>
</div>
</div>
<style jsx>{`
@keyframes scale-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scale-in 0.5s ease-out;
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,362 @@
/**
* Public Booking Rejection Page
*
* Allows carriers to reject booking requests via email link
* Route: /booking/reject/:token
*/
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingRejectPage() {
const params = useParams();
const token = params.token as string;
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
const [isRejecting, setIsRejecting] = useState(false);
const [hasRejected, setHasRejected] = useState(false);
const [reason, setReason] = useState('');
const [showReasonField, setShowReasonField] = useState(false);
useEffect(() => {
if (!token) {
setError('Token de refus invalide');
setIsLoading(false);
return;
}
// Just validate the token exists, don't auto-reject
setIsLoading(false);
}, [token]);
const handleReject = async () => {
if (!token) return;
setIsRejecting(true);
setError(null);
try {
const result = await rejectCsvBooking(token, reason || undefined);
setBooking(result);
setHasRejected(true);
} catch (err) {
console.error('Rejection error:', err);
if (err instanceof Error) {
setError(err.message);
} else {
setError('Une erreur est survenue lors du refus');
}
} finally {
setIsRejecting(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-gray-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Erreur de refus
</h1>
<p className="text-gray-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800">
<strong>Raisons possibles :</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>Le lien a expiré</li>
<li>La demande a déjà é acceptée ou refusée</li>
<li>Le token est invalide</li>
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
</p>
</div>
</div>
);
}
// After successful rejection
if (hasRejected && booking) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full">
{/* Rejection Icon with Animation */}
<div className="text-center mb-8">
<div className="relative inline-block">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-scale-in">
<svg
className="w-10 h-10 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
Demande refusée
</h1>
<p className="text-lg text-gray-600 mb-2">
Vous avez refusé cette demande de transport.
</p>
<p className="text-gray-500">
Le client a é notifié par email.
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif de la demande refusée
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">ID Réservation</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Trajet</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Volume</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Poids</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Prix proposé</span>
<span className="font-semibold text-gray-900">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
: `${booking.priceEUR.toLocaleString()}`
}
</span>
</div>
</div>
{reason && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">Raison du refus :</p>
<p className="text-gray-800 bg-white p-3 rounded border border-gray-200">
{reason}
</p>
</div>
)}
</div>
{/* Info Message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Information
</h3>
<p className="text-sm text-blue-800">
Le client pourra soumettre une nouvelle demande avec des conditions différentes si nécessaire.
</p>
</div>
{/* Contact Info */}
<div className="text-center text-sm text-gray-500">
<p>Pour toute question, contactez-nous à</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com
</a>
</div>
</div>
<style jsx>{`
@keyframes scale-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scale-in 0.5s ease-out;
}
`}</style>
</div>
);
}
// Initial rejection form
return (
<div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full">
{/* Warning Icon */}
<div className="text-center mb-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Refuser cette demande
</h1>
<p className="text-gray-600">
Vous êtes sur le point de refuser cette demande de transport.
</p>
</div>
{/* Optional Reason Field */}
<div className="mb-6">
{!showReasonField ? (
<button
onClick={() => setShowReasonField(true)}
className="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-gray-700">Ajouter une raison (optionnel)</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
) : (
<div>
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
Raison du refus (optionnel)
</label>
<textarea
id="reason"
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Prix trop élevé, délais trop courts, itinéraire non disponible..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
maxLength={500}
/>
<div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500">
Cette information sera communiquée au client
</p>
<span className="text-xs text-gray-400">
{reason.length}/500
</span>
</div>
</div>
)}
</div>
{/* Warning Message */}
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<p className="text-sm text-orange-800">
<strong>Attention :</strong> Cette action est irréversible. Le client sera immédiatement notifié par email de votre refus.
</p>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleReject}
disabled={isRejecting}
className="w-full px-6 py-3 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
{isRejecting ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refus en cours...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Confirmer le refus
</>
)}
</button>
<a
href="mailto:support@xpeditis.com"
className="block w-full px-6 py-3 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 font-semibold rounded-lg transition-colors text-center"
>
Contacter le support
</a>
</div>
{/* Help Text */}
<p className="mt-6 text-xs text-center text-gray-500">
Si vous avez des questions avant de refuser, contactez-nous par email.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,660 @@
/**
* CSV Booking Creation Page
*
* Multi-step form for creating a CSV-based booking request
*/
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import type { CsvRateSearchResult } from '@/types/rates';
import { createCsvBooking } from '@/lib/api/bookings';
interface BookingForm {
// Rate data (pre-filled from search results)
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: 'USD' | 'EUR';
transitDays: number;
containerType: string;
// Documents to upload
documents: File[];
// Optional notes
notes?: string;
}
const DOCUMENT_TYPES = [
{ value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' },
{ value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' },
{ value: 'COMMERCIAL_INVOICE', label: 'Commercial Invoice (Facture commerciale)' },
{ value: 'CERTIFICATE_OF_ORIGIN', label: 'Certificate of Origin (Certificat d\'origine)' },
{ value: 'OTHER', label: 'Autre document' },
];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png'];
function NewBookingPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<BookingForm>({
carrierName: '',
carrierEmail: '',
origin: '',
destination: '',
volumeCBM: 0,
weightKG: 0,
palletCount: 0,
priceUSD: 0,
priceEUR: 0,
primaryCurrency: 'EUR',
transitDays: 0,
containerType: '',
documents: [],
notes: '',
});
// Load rate data from URL params
useEffect(() => {
const rateDataParam = searchParams.get('rateData');
if (rateDataParam) {
try {
const rateData: CsvRateSearchResult = JSON.parse(decodeURIComponent(rateDataParam));
setFormData(prev => ({
...prev,
carrierName: rateData.companyName,
carrierEmail: rateData.companyEmail,
origin: rateData.origin,
destination: rateData.destination,
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
weightKG: parseFloat(searchParams.get('weightKG') || '0'),
palletCount: parseInt(searchParams.get('palletCount') || '0'),
priceUSD: rateData.priceUSD,
priceEUR: rateData.priceEUR,
primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR',
transitDays: rateData.transitDays,
containerType: rateData.containerType,
}));
} catch (err) {
console.error('Failed to parse rate data:', err);
setError('Données de tarif invalides');
}
} else {
// No rate data - redirect back to search
router.push('/dashboard/search-advanced');
}
}, [searchParams, router]);
const handleFileChange = (files: FileList | null) => {
if (!files) return;
const newFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach(file => {
// Check file size
if (file.size > MAX_FILE_SIZE) {
errors.push(`${file.name}: Fichier trop volumineux (max 5MB)`);
return;
}
// Check file type
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!ACCEPTED_FILE_TYPES.includes(fileExtension)) {
errors.push(`${file.name}: Type de fichier non accepté`);
return;
}
newFiles.push(file);
});
if (errors.length > 0) {
setError(errors.join('\n'));
} else {
setError(null);
}
setFormData(prev => ({
...prev,
documents: [...prev.documents, ...newFiles],
}));
};
const removeDocument = (index: number) => {
setFormData(prev => ({
...prev,
documents: prev.documents.filter((_, i) => i !== index),
}));
};
const handleSubmit = async () => {
setIsSubmitting(true);
setError(null);
try {
// Create FormData for multipart upload
const formDataToSend = new FormData();
// Append all booking data
formDataToSend.append('carrierName', formData.carrierName);
formDataToSend.append('carrierEmail', formData.carrierEmail);
formDataToSend.append('origin', formData.origin);
formDataToSend.append('destination', formData.destination);
formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
formDataToSend.append('weightKG', formData.weightKG.toString());
formDataToSend.append('palletCount', formData.palletCount.toString());
formDataToSend.append('priceUSD', formData.priceUSD.toString());
formDataToSend.append('priceEUR', formData.priceEUR.toString());
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
formDataToSend.append('transitDays', formData.transitDays.toString());
formDataToSend.append('containerType', formData.containerType);
if (formData.notes) {
formDataToSend.append('notes', formData.notes);
}
// Append documents
formData.documents.forEach((file) => {
formDataToSend.append('documents', file);
});
// Send to API using client function
const result = await createCsvBooking(formDataToSend);
// Redirect to success page
router.push(`/dashboard/bookings?success=true&id=${result.id}`);
} catch (err) {
console.error('Booking creation error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
} finally {
setIsSubmitting(false);
}
};
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
const canProceedToStep3 = formData.documents.length >= 1;
const canSubmit = canProceedToStep3;
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency,
}).format(price);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
Retour aux résultats
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Nouvelle demande de réservation</h1>
<p className="text-gray-600">
Envoyez une demande de réservation directement au transporteur
</p>
</div>
</div>
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{[1, 2, 3].map(step => (
<div key={step} className="flex items-center flex-1">
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep >= step
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{step}
</div>
<div className="ml-3">
<p
className={`text-sm font-medium ${
currentStep >= step ? 'text-blue-600' : 'text-gray-500'
}`}
>
{step === 1 && 'Détails'}
{step === 2 && 'Documents'}
{step === 3 && 'Révision'}
</p>
</div>
</div>
{step < 3 && (
<div
className={`flex-1 h-1 mx-4 ${
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
}`}
/>
)}
</div>
))}
</div>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
<div className="flex items-start">
<span className="text-2xl mr-3"></span>
<div>
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
<p className="text-red-700 whitespace-pre-line">{error}</p>
</div>
</div>
</div>
)}
{/* Step Content */}
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Step 1: Transport Details (Read-only) */}
{currentStep === 1 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Détails du transport
</h2>
<div className="space-y-6">
{/* Carrier Info */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Transporteur
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Nom</p>
<p className="font-semibold text-gray-900">{formData.carrierName}</p>
</div>
<div>
<p className="text-sm text-gray-600">Email</p>
<p className="font-semibold text-gray-900">{formData.carrierEmail}</p>
</div>
</div>
</div>
{/* Route Info */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Trajet
</h3>
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Origine</p>
<p className="text-xl font-bold text-blue-600">{formData.origin}</p>
</div>
<div className="flex-1 mx-8">
<div className="border-t-2 border-dashed border-gray-300 relative">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
<span className="text-sm text-gray-600">{formData.transitDays} jours</span>
</div>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Destination</p>
<p className="text-xl font-bold text-blue-600">{formData.destination}</p>
</div>
</div>
</div>
{/* Shipment Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Volume</p>
<p className="text-lg font-bold text-gray-900">{formData.volumeCBM} CBM</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Poids</p>
<p className="text-lg font-bold text-gray-900">{formData.weightKG} kg</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Palettes</p>
<p className="text-lg font-bold text-gray-900">{formData.palletCount || 'N/A'}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Type</p>
<p className="text-lg font-bold text-gray-900">{formData.containerType}</p>
</div>
</div>
{/* Pricing */}
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Prix estimé
</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Prix en EUR</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')}
</p>
</div>
{formData.priceUSD > 0 && (
<div className="text-right">
<p className="text-sm text-gray-600">Prix en USD</p>
<p className="text-xl font-semibold text-gray-700">
{formatPrice(formData.priceUSD, 'USD')}
</p>
</div>
)}
</div>
</div>
</div>
<div className="mt-8 flex justify-end">
<button
onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
>
Continuer
</button>
</div>
</div>
)}
{/* Step 2: Document Upload */}
{currentStep === 2 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Documents requis
</h2>
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<span className="text-2xl mr-3">📋</span>
<div>
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
<p className="text-sm text-yellow-800">
Veuillez télécharger au moins <strong>1 document</strong> pour continuer.
Formats acceptés: PDF, DOC, DOCX, JPG, PNG (max 5MB par fichier)
</p>
</div>
</div>
</div>
{/* File Upload */}
<div className="space-y-4 mb-8">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors">
<input
type="file"
id="file-upload"
multiple
accept={ACCEPTED_FILE_TYPES.join(',')}
onChange={(e) => handleFileChange(e.target.files)}
className="hidden"
/>
<label
htmlFor="file-upload"
className="cursor-pointer flex flex-col items-center"
>
<svg
className="w-16 h-16 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-lg font-semibold text-gray-700 mb-2">
Cliquez pour sélectionner des fichiers
</p>
<p className="text-sm text-gray-500">
ou glissez-déposez vos documents ici
</p>
</label>
</div>
{/* Document Type Suggestions */}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-semibold text-gray-700 mb-2">
Documents recommandés :
</p>
<ul className="text-sm text-gray-600 space-y-1">
{DOCUMENT_TYPES.map(type => (
<li key={type.value}> {type.label}</li>
))}
</ul>
</div>
</div>
{/* Uploaded Files List */}
{formData.documents.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Documents sélectionnés ({formData.documents.length})
</h3>
<div className="space-y-2">
{formData.documents.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 rounded-lg p-4 border border-gray-200"
>
<div className="flex items-center space-x-3">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div>
<p className="font-medium text-gray-900">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={() => removeDocument(index)}
className="text-red-600 hover:text-red-800 font-medium text-sm"
>
Supprimer
</button>
</div>
))}
</div>
</div>
)}
{/* Optional Notes */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes ou instructions spéciales (optionnel)
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={4}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
/>
</div>
<div className="flex justify-between">
<button
onClick={() => setCurrentStep(1)}
className="px-8 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
>
Retour
</button>
<button
onClick={() => setCurrentStep(3)}
disabled={!canProceedToStep3}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
>
Continuer
</button>
</div>
</div>
)}
{/* Step 3: Review & Submit */}
{currentStep === 3 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Révision et envoi
</h2>
<div className="space-y-6 mb-8">
{/* Summary */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif
</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span>
<span className="font-semibold text-gray-900">{formData.carrierName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Trajet :</span>
<span className="font-semibold text-gray-900">
{formData.origin} {formData.destination}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Volume :</span>
<span className="font-semibold text-gray-900">{formData.volumeCBM} CBM</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Poids :</span>
<span className="font-semibold text-gray-900">{formData.weightKG} kg</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Documents :</span>
<span className="font-semibold text-gray-900">
{formData.documents.length} fichier(s)
</span>
</div>
<div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')}
</span>
</div>
</div>
</div>
{/* What happens next */}
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
📧 Que se passe-t-il ensuite ?
</h3>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start">
<span className="mr-2">1.</span>
<span>
Votre demande sera <strong>envoyée par email</strong> au transporteur ({formData.carrierEmail})
</span>
</li>
<li className="flex items-start">
<span className="mr-2">2.</span>
<span>
Le transporteur recevra tous vos documents et détails de transport
</span>
</li>
<li className="flex items-start">
<span className="mr-2">3.</span>
<span>
Il pourra <strong>accepter ou refuser</strong> la demande directement depuis son email
</span>
</li>
<li className="flex items-start">
<span className="mr-2">4.</span>
<span>
Vous recevrez une <strong>notification</strong> dès que le transporteur répond (sous 7 jours maximum)
</span>
</li>
</ul>
</div>
{/* Terms */}
<div className="bg-gray-50 rounded-lg p-4">
<label className="flex items-start cursor-pointer">
<input
type="checkbox"
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
required
/>
<span className="ml-3 text-sm text-gray-700">
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}
<a href="#" className="text-blue-600 hover:underline">
conditions générales
</a>
</span>
</label>
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => setCurrentStep(2)}
disabled={isSubmitting}
className="px-8 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold disabled:opacity-50"
>
Retour
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
>
{isSubmitting ? (
<span className="flex items-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Envoi en cours...
</span>
) : (
'✓ Envoyer la demande'
)}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default function NewBookingPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Chargement...</div>}>
<NewBookingPageContent />
</Suspense>
);
}

View File

@ -1,77 +1,140 @@
/** /**
* Bookings List Page * Bookings List Page
* *
* Display all bookings with filters and search * Display all bookings (standard + CSV) with filters and search
*/ */
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '@/lib/api'; import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link'; import Link from 'next/link';
type BookingType = 'all' | 'standard' | 'csv';
export default function BookingsListPage() { export default function BookingsListPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [bookingType, setBookingType] = useState<BookingType>('all');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({ // Fetch standard bookings
const { data: standardData, isLoading: standardLoading } = useQuery({
queryKey: ['bookings', page, statusFilter, searchTerm], queryKey: ['bookings', page, statusFilter, searchTerm],
queryFn: () => queryFn: () =>
bookingsApi.list({ listBookings({
page, page,
limit: 10, limit: 10,
status: statusFilter || undefined, status: statusFilter || undefined,
search: searchTerm || undefined,
}), }),
enabled: bookingType === 'all' || bookingType === 'standard',
}); });
// Fetch CSV bookings
const { data: csvData, isLoading: csvLoading } = useQuery({
queryKey: ['csv-bookings', page, statusFilter],
queryFn: () =>
listCsvBookings({
page,
limit: 10,
status: statusFilter as 'PENDING' | 'ACCEPTED' | 'REJECTED' | undefined,
}),
enabled: bookingType === 'all' || bookingType === 'csv',
});
const isLoading = standardLoading || csvLoading;
// Combine bookings based on filter
const getCombinedBookings = () => {
if (bookingType === 'standard') {
return (standardData?.data || []).map(b => ({ ...b, type: 'standard' as const }));
}
if (bookingType === 'csv') {
return (csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }));
}
// For 'all', combine both
const standard = (standardData?.data || []).map(b => ({ ...b, type: 'standard' as const }));
const csv = (csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }));
return [...standard, ...csv].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
};
const allBookings = getCombinedBookings();
const statusOptions = [ const statusOptions = [
{ value: '', label: 'All Statuses' }, { value: '', label: 'Tous les statuts' },
{ value: 'draft', label: 'Draft' }, // Standard booking statuses
{ value: 'pending', label: 'Pending' }, { value: 'draft', label: 'Brouillon' },
{ value: 'confirmed', label: 'Confirmed' }, { value: 'pending', label: 'En attente' },
{ value: 'in_transit', label: 'In Transit' }, { value: 'confirmed', label: 'Confirmé' },
{ value: 'delivered', label: 'Delivered' }, { value: 'in_transit', label: 'En transit' },
{ value: 'cancelled', label: 'Cancelled' }, { value: 'delivered', label: 'Livré' },
{ value: 'cancelled', label: 'Annulé' },
// CSV booking statuses
{ value: 'PENDING', label: 'En attente (CSV)' },
{ value: 'ACCEPTED', label: 'Accepté (CSV)' },
{ value: 'REJECTED', label: 'Refusé (CSV)' },
]; ];
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
// Standard statuses
draft: 'bg-gray-100 text-gray-800', draft: 'bg-gray-100 text-gray-800',
pending: 'bg-yellow-100 text-yellow-800', pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800', confirmed: 'bg-green-100 text-green-800',
in_transit: 'bg-blue-100 text-blue-800', in_transit: 'bg-blue-100 text-blue-800',
delivered: 'bg-purple-100 text-purple-800', delivered: 'bg-purple-100 text-purple-800',
cancelled: 'bg-red-100 text-red-800', cancelled: 'bg-red-100 text-red-800',
// CSV statuses
PENDING: 'bg-yellow-100 text-yellow-800',
ACCEPTED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}; };
return colors[status] || 'bg-gray-100 text-gray-800'; return colors[status] || 'bg-gray-100 text-gray-800';
}; };
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
// Standard statuses
draft: 'Brouillon',
pending: 'En attente',
confirmed: 'Confirmé',
in_transit: 'En transit',
delivered: 'Livré',
cancelled: 'Annulé',
// CSV statuses
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[status] || status;
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Bookings</h1> <h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
<p className="text-sm text-gray-500 mt-1">Manage and track your shipments</p> <p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
</div> </div>
<Link <Link
href="/dashboard/bookings/new" href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
> >
<span className="mr-2"></span> <span className="mr-2"></span>
New Booking Nouvelle Réservation
</Link> </Link>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="bg-white rounded-lg shadow p-4"> <div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="search" className="sr-only"> <label htmlFor="search" className="sr-only">
Search Rechercher
</label> </label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@ -95,13 +158,28 @@ export default function BookingsListPage() {
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm" className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Search by booking number or description..." placeholder="Rechercher par numéro de réservation..."
/> />
</div> </div>
</div> </div>
<div>
<label htmlFor="bookingType" className="sr-only">
Type de réservation
</label>
<select
id="bookingType"
value={bookingType}
onChange={e => setBookingType(e.target.value as BookingType)}
className="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
>
<option value="all">Toutes les réservations</option>
<option value="standard">Réservations standard</option>
<option value="csv">Réservations CSV</option>
</select>
</div>
<div> <div>
<label htmlFor="status" className="sr-only"> <label htmlFor="status" className="sr-only">
Status Statut
</label> </label>
<select <select
id="status" id="status"
@ -124,48 +202,73 @@ export default function BookingsListPage() {
{isLoading ? ( {isLoading ? (
<div className="px-6 py-12 text-center text-gray-500"> <div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
Loading bookings... Chargement des réservations...
</div> </div>
) : data?.data && data.data.length > 0 ? ( ) : allBookings && allBookings.length > 0 ? (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Booking Number Palettes/Colis
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cargo Poids
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status Route
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions N° Devis
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{data.data.map(booking => ( {allBookings.map((booking: any) => (
<tr key={booking.id} className="hover:bg-gray-50"> <tr key={`${booking.type}-${booking.id}`} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<Link <div className="text-sm font-medium text-gray-900">
href={`/dashboard/bookings/${booking.id}`} {booking.type === 'csv'
className="text-sm font-medium text-blue-600 hover:text-blue-700" ? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
> : `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
{booking.bookingNumber} </div>
</Link> <div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.type === 'csv'
? `${booking.weightKG} kg`
: booking.totalWeight
? `${booking.totalWeight} kg`
: 'N/A'}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv'
? `${booking.volumeCBM} CBM`
: booking.totalVolume
? `${booking.totalVolume} CBM`
: ''}
</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate"> <div className="text-sm text-gray-900">
{booking.cargoDescription} {booking.type === 'csv'
? `${booking.origin}${booking.destination}`
: booking.route || 'N/A'}
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{booking.containers?.length || 0} container(s) {booking.type === 'csv'
? `${booking.carrierName}`
: booking.carrier || ''}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
@ -174,18 +277,30 @@ export default function BookingsListPage() {
booking.status booking.status
)}`} )}`}
> >
{booking.status} {getStatusLabel(booking.status)}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleDateString()} {booking.createdAt
? new Date(booking.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: 'N/A'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link <Link
href={`/dashboard/bookings/${booking.id}`} href={
className="text-blue-600 hover:text-blue-900 mr-4" booking.type === 'csv'
? `/dashboard/csv-bookings/${booking.id}`
: `/dashboard/bookings/${booking.id}`
}
className="text-blue-600 hover:text-blue-900"
> >
View {booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</Link> </Link>
</td> </td>
</tr> </tr>
@ -195,7 +310,7 @@ export default function BookingsListPage() {
</div> </div>
{/* Pagination */} {/* Pagination */}
{data.total > 10 && ( {((standardData?.total || 0) + (csvData?.total || 0)) > 10 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden"> <div className="flex-1 flex justify-between sm:hidden">
<button <button
@ -203,22 +318,28 @@ export default function BookingsListPage() {
disabled={page === 1} disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed"
> >
Previous Précédent
</button> </button>
<button <button
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
disabled={page * 10 >= data.total} disabled={page * 10 >= ((standardData?.total || 0) + (csvData?.total || 0))}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed"
> >
Next Suivant
</button> </button>
</div> </div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
Showing <span className="font-medium">{(page - 1) * 10 + 1}</span> to{' '} Affichage de <span className="font-medium">{(page - 1) * 10 + 1}</span> à{' '}
<span className="font-medium">{Math.min(page * 10, data.total)}</span> of{' '} <span className="font-medium">
<span className="font-medium">{data.total}</span> results {Math.min(page * 10, (standardData?.total || 0) + (csvData?.total || 0))}
</span>{' '}
sur{' '}
<span className="font-medium">
{(standardData?.total || 0) + (csvData?.total || 0)}
</span>{' '}
résultats
</p> </p>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
@ -227,14 +348,14 @@ export default function BookingsListPage() {
disabled={page === 1} disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed"
> >
Previous Précédent
</button> </button>
<button <button
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
disabled={page * 10 >= data.total} disabled={page * 10 >= ((standardData?.total || 0) + (csvData?.total || 0))}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed"
> >
Next Suivant
</button> </button>
</div> </div>
</div> </div>
@ -256,19 +377,19 @@ export default function BookingsListPage() {
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/> />
</svg> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No bookings found</h3> <h3 className="mt-2 text-sm font-medium text-gray-900">Aucune réservation trouvée</h3>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter {searchTerm || statusFilter
? 'Try adjusting your filters' ? 'Essayez d\'ajuster vos filtres'
: 'Get started by creating your first booking'} : 'Commencez par créer votre première réservation'}
</p> </p>
<div className="mt-6"> <div className="mt-6">
<Link <Link
href="/dashboard/bookings/new" href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
> >
<span className="mr-2"></span> <span className="mr-2"></span>
New Booking Nouvelle Réservation
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -286,7 +286,13 @@ export default function SearchResultsPage() {
</div> </div>
</div> </div>
<button className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}> <button
onClick={() => {
const rateData = encodeURIComponent(JSON.stringify(card.option));
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`);
}}
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
>
Sélectionner cette option Sélectionner cette option
</button> </button>
</div> </div>
@ -347,7 +353,13 @@ export default function SearchResultsPage() {
<span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span> <span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
{result.hasSurcharges && <span className="text-orange-600"> Surcharges applicables</span>} {result.hasSurcharges && <span className="text-orange-600"> Surcharges applicables</span>}
</div> </div>
<button className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"> <button
onClick={() => {
const rateData = encodeURIComponent(JSON.stringify(result));
router.push(`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`);
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Sélectionner Sélectionner
</button> </button>
</div> </div>

View File

@ -0,0 +1,124 @@
/**
* useNotifications Hook
*
* Custom hook for managing notifications with automatic polling
* Polls the API every 30 seconds for new notifications
*/
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
interface Notification {
id: string;
type: string;
priority: 'critical' | 'high' | 'medium' | 'low';
title: string;
message: string;
read: boolean;
readAt?: string;
actionUrl?: string;
createdAt: string;
metadata?: Record<string, any>;
}
interface UseNotificationsOptions {
/**
* Whether to fetch only unread notifications
* @default true
*/
unreadOnly?: boolean;
/**
* Maximum number of notifications to fetch
* @default 10
*/
limit?: number;
/**
* Polling interval in milliseconds
* @default 30000 (30 seconds)
*/
refetchInterval?: number;
}
export function useNotifications(options: UseNotificationsOptions = {}) {
const {
unreadOnly = true,
limit = 10,
refetchInterval = 30000,
} = options;
const queryClient = useQueryClient();
// Fetch notifications with automatic polling
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['notifications', { read: !unreadOnly, limit }],
queryFn: () => listNotifications({ read: !unreadOnly, limit }),
refetchInterval, // Poll every 30 seconds by default
refetchOnWindowFocus: true, // Refetch when window regains focus
});
const notifications = (data?.notifications || []) as Notification[];
const unreadCount = notifications.filter((n: Notification) => !n.read).length;
// Mark single notification as read
const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: () => {
// Invalidate all notification queries to refetch
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
// Mark all notifications as read
const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead,
onSuccess: () => {
// Invalidate all notification queries to refetch
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
/**
* Mark a single notification as read
* @param id - Notification ID
*/
const markAsRead = async (id: string) => {
return markAsReadMutation.mutateAsync(id);
};
/**
* Mark all notifications as read
*/
const markAllAsRead = async () => {
return markAllAsReadMutation.mutateAsync();
};
/**
* Manually refetch notifications
*/
const refresh = () => {
return refetch();
};
return {
/** Array of notifications */
notifications,
/** Count of unread notifications */
unreadCount,
/** Mark a single notification as read */
markAsRead,
/** Mark all notifications as read */
markAllAsRead,
/** Manually refresh notifications */
refresh,
/** Loading state */
isLoading,
/** Error state */
error,
/** Whether marking as read is in progress */
isMarkingAsRead: markAsReadMutation.isPending,
/** Whether marking all as read is in progress */
isMarkingAllAsRead: markAllAsReadMutation.isPending,
};
}

View File

@ -4,7 +4,7 @@
* Endpoints for managing container bookings * Endpoints for managing container bookings
*/ */
import { get, post, patch } from './client'; import { get, post, patch, upload } from './client';
import type { import type {
CreateBookingRequest, CreateBookingRequest,
BookingResponse, BookingResponse,
@ -15,6 +15,54 @@ import type {
SuccessResponse, SuccessResponse,
} from '@/types/api'; } from '@/types/api';
/**
* CSV Booking types
*/
export interface CsvBookingResponse {
id: string;
bookingId: string;
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
status: 'PENDING' | 'ACCEPTED' | 'REJECTED';
documents: Array<{
type: string;
fileName: string;
url: string;
}>;
notes?: string;
confirmationToken: string;
emailSentAt?: string;
acceptedAt?: string;
rejectedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface CsvBookingListResponse {
items: CsvBookingResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CsvBookingStatsResponse {
total: number;
pending: number;
accepted: number;
rejected: number;
}
/** /**
* Create a new booking * Create a new booking
* POST /api/v1/bookings * POST /api/v1/bookings
@ -132,3 +180,92 @@ export async function updateBookingStatus(
): Promise<SuccessResponse> { ): Promise<SuccessResponse> {
return patch<SuccessResponse>(`/api/v1/bookings/${id}/status`, data); return patch<SuccessResponse>(`/api/v1/bookings/${id}/status`, data);
} }
// ============================================================================
// CSV BOOKINGS API
// ============================================================================
/**
* Create a new CSV booking with document uploads
* POST /api/v1/csv-bookings
*
* Uses multipart/form-data for file uploads
*/
export async function createCsvBooking(formData: FormData): Promise<CsvBookingResponse> {
return upload<CsvBookingResponse>('/api/v1/csv-bookings', formData);
}
/**
* Get CSV booking by ID
* GET /api/v1/csv-bookings/:id
*/
export async function getCsvBooking(id: string): Promise<CsvBookingResponse> {
return get<CsvBookingResponse>(`/api/v1/csv-bookings/${id}`);
}
/**
* List CSV bookings with pagination and filters
* GET /api/v1/csv-bookings?page=1&limit=20&status=PENDING
*/
export async function listCsvBookings(params?: {
page?: number;
limit?: number;
status?: 'PENDING' | 'ACCEPTED' | 'REJECTED';
startDate?: string;
endDate?: string;
}): Promise<CsvBookingListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.status) queryParams.append('status', params.status);
if (params?.startDate) queryParams.append('startDate', params.startDate);
if (params?.endDate) queryParams.append('endDate', params.endDate);
const queryString = queryParams.toString();
return get<CsvBookingListResponse>(
`/api/v1/csv-bookings${queryString ? `?${queryString}` : ''}`
);
}
/**
* Get CSV booking statistics for current user
* GET /api/v1/csv-bookings/stats
*/
export async function getCsvBookingStats(): Promise<CsvBookingStatsResponse> {
return get<CsvBookingStatsResponse>('/api/v1/csv-bookings/stats');
}
/**
* Cancel a pending CSV booking
* PATCH /api/v1/csv-bookings/:id/cancel
*/
export async function cancelCsvBooking(id: string): Promise<SuccessResponse> {
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
}
/**
* Accept a CSV booking (public endpoint, no auth required)
* POST /api/v1/csv-bookings/:token/accept
*/
export async function acceptCsvBooking(token: string): Promise<CsvBookingResponse> {
return post<CsvBookingResponse>(
`/api/v1/csv-bookings/${token}/accept`,
{},
false // includeAuth = false
);
}
/**
* Reject a CSV booking with reason (public endpoint, no auth required)
* POST /api/v1/csv-bookings/:token/reject
*/
export async function rejectCsvBooking(
token: string,
reason?: string
): Promise<CsvBookingResponse> {
return post<CsvBookingResponse>(
`/api/v1/csv-bookings/${token}/reject`,
{ reason },
false // includeAuth = false
);
}

View File

@ -40,6 +40,17 @@ export {
advancedSearchBookings, advancedSearchBookings,
exportBookings, exportBookings,
updateBookingStatus, updateBookingStatus,
// CSV Bookings
createCsvBooking,
getCsvBooking,
listCsvBookings,
getCsvBookingStats,
cancelCsvBooking,
acceptCsvBooking,
rejectCsvBooking,
type CsvBookingResponse,
type CsvBookingListResponse,
type CsvBookingStatsResponse,
} from './bookings'; } from './bookings';
// Users (6 endpoints) // Users (6 endpoints)

View File

@ -0,0 +1,80 @@
/**
* Rate Search Types
*
* TypeScript types for rate search functionality
*/
/**
* CSV Rate Search Request
*/
export interface CsvRateSearchRequest {
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount?: number;
containerType?: string;
hasDangerousGoods?: boolean;
requiresSpecialHandling?: boolean;
requiresTailgate?: boolean;
requiresStraps?: boolean;
requiresThermalCover?: boolean;
hasRegulatedProducts?: boolean;
requiresAppointment?: boolean;
}
/**
* Surcharge Details
*/
export interface Surcharge {
code: string;
description: string;
amount: number;
type: string;
}
/**
* Price Breakdown
*/
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: Surcharge[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
/**
* CSV Rate Search Result
*/
export interface CsvRateSearchResult {
companyName: string;
companyEmail: string;
origin: string;
destination: string;
containerType: string;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
priceBreakdown: PriceBreakdown;
hasSurcharges: boolean;
surchargeDetails: string | null;
transitDays: number;
validUntil: string;
source: string;
matchScore: number;
}
/**
* CSV Rate Search Response
*/
export interface CsvRateSearchResponse {
results: CsvRateSearchResult[];
totalResults: number;
searchedFiles: string[];
searchedAt: Date;
appliedFilters: Record<string, any>;
}

37
docker-compose.test.yml Normal file
View File

@ -0,0 +1,37 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: xpeditis-test-db
environment:
POSTGRES_DB: xpeditis_test
POSTGRES_USER: xpeditis_test
POSTGRES_PASSWORD: xpeditis_test_password
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- '5432:5432'
volumes:
- xpeditis_test_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xpeditis_test"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: xpeditis-test-redis
ports:
- '6379:6379'
volumes:
- xpeditis_test_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
xpeditis_test_db:
xpeditis_test_redis:

View File

@ -35,11 +35,32 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
minio:
image: minio/minio:latest
container_name: xpeditis-minio
restart: unless-stopped
ports:
- '9000:9000' # API port
- '9001:9001' # Console port
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 30s
timeout: 20s
retries: 3
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
redis_data: redis_data:
driver: local driver: local
minio_data:
driver: local
networks: networks:
default: default:

View File

@ -0,0 +1,539 @@
# Guide de Déploiement Portainer - Xpeditis
Ce guide explique comment déployer l'application Xpeditis sur un serveur Docker Swarm avec Portainer et Traefik.
## Prérequis
- Docker Swarm initialisé sur votre serveur
- Traefik configuré et déployé avec le réseau `traefik_network`
- Portainer installé et accessible
- Noms de domaine configurés avec DNS pointant vers votre serveur :
- `app.xpeditis.com` - Frontend
- `api.xpeditis.com` - Backend API
- `s3.xpeditis.com` - MinIO API
- `minio.xpeditis.com` - MinIO Console
## Configuration DNS Requise
Configurez les enregistrements DNS suivants (type A) pour pointer vers l'IP de votre serveur :
```
app.xpeditis.com → IP_DU_SERVEUR
www.xpeditis.com → IP_DU_SERVEUR
api.xpeditis.com → IP_DU_SERVEUR
s3.xpeditis.com → IP_DU_SERVEUR
minio.xpeditis.com → IP_DU_SERVEUR
```
## Étape 1 : Préparer les Images Docker
### 1.1 Construire l'image Backend
```bash
cd /chemin/vers/xpeditis2.0
# Construire l'image backend
docker build -t xpeditis/backend:latest -f apps/backend/Dockerfile .
# Tag et push vers votre registre (optionnel)
docker tag xpeditis/backend:latest registry.xpeditis.com/xpeditis/backend:latest
docker push registry.xpeditis.com/xpeditis/backend:latest
```
### 1.2 Construire l'image Frontend
```bash
# Construire l'image frontend
docker build -t xpeditis/frontend:latest -f apps/frontend/Dockerfile .
# Tag et push vers votre registre (optionnel)
docker tag xpeditis/frontend:latest registry.xpeditis.com/xpeditis/frontend:latest
docker push registry.xpeditis.com/xpeditis/frontend:latest
```
### 1.3 Sauvegarder les Images (Alternative sans registre)
Si vous n'avez pas de registre Docker privé :
```bash
# Sauvegarder les images
docker save xpeditis/backend:latest | gzip > xpeditis-backend.tar.gz
docker save xpeditis/frontend:latest | gzip > xpeditis-frontend.tar.gz
# Transférer vers le serveur
scp xpeditis-backend.tar.gz user@server:/tmp/
scp xpeditis-frontend.tar.gz user@server:/tmp/
# Sur le serveur, charger les images
ssh user@server
docker load < /tmp/xpeditis-backend.tar.gz
docker load < /tmp/xpeditis-frontend.tar.gz
```
## Étape 2 : Vérifier Traefik
Assurez-vous que Traefik est correctement configuré avec :
- Network `traefik_network` externe
- Entrypoints `web` (port 80) et `websecure` (port 443)
- Certificat resolver `letsencrypt` configuré
Exemple de vérification :
```bash
# Vérifier le réseau Traefik
docker network inspect traefik_network
# Vérifier que Traefik fonctionne
docker service ls | grep traefik
```
## Étape 3 : Configurer les Variables d'Environnement
Avant de déployer, **CHANGEZ TOUS LES MOTS DE PASSE** dans le fichier `portainer-stack.yml` :
### Variables à modifier :
```yaml
# Database
POSTGRES_PASSWORD: xpeditis_prod_password_CHANGE_ME → Votre_Mot_De_Passe_Fort_DB
# Redis
REDIS_PASSWORD: xpeditis_redis_password_CHANGE_ME → Votre_Mot_De_Passe_Fort_Redis
# MinIO
MINIO_ROOT_USER: minioadmin_CHANGE_ME → Votre_Utilisateur_MinIO
MINIO_ROOT_PASSWORD: minioadmin_password_CHANGE_ME → Votre_Mot_De_Passe_Fort_MinIO
# JWT
JWT_SECRET: your-super-secret-jwt-key-CHANGE_ME-min-32-characters → Votre_Secret_JWT_32_Caracteres_Min
# Email (selon votre fournisseur)
EMAIL_HOST: smtp.example.com → smtp.votre-fournisseur.com
EMAIL_PORT: 587
EMAIL_USER: noreply@xpeditis.com → Votre_Email
EMAIL_PASSWORD: email_password_CHANGE_ME → Votre_Mot_De_Passe_Email
```
### Générer des mots de passe forts :
```bash
# Générer un mot de passe aléatoire de 32 caractères
openssl rand -base64 32
# Générer un secret JWT de 64 caractères
openssl rand -base64 64 | tr -d '\n'
```
## Étape 4 : Déployer avec Portainer
### 4.1 Accéder à Portainer
1. Ouvrez votre navigateur et accédez à Portainer (ex: `https://portainer.votre-domaine.com`)
2. Connectez-vous avec vos identifiants
3. Sélectionnez votre environnement Docker Swarm
### 4.2 Créer la Stack
1. Dans le menu latéral, cliquez sur **"Stacks"**
2. Cliquez sur **"+ Add stack"**
3. Donnez un nom à la stack : `xpeditis`
4. Choisissez **"Web editor"**
5. Copiez le contenu du fichier `portainer-stack.yml` (avec vos modifications)
6. Collez le contenu dans l'éditeur Portainer
7. Cliquez sur **"Deploy the stack"**
### 4.3 Vérifier le Déploiement
1. Attendez que tous les services soient déployés (statut vert)
2. Vérifiez les logs de chaque service :
- Cliquez sur **"Stacks"** → **"xpeditis"**
- Sélectionnez un service et consultez ses logs
## Étape 5 : Initialiser la Base de Données
### 5.1 Attendre que la DB soit prête
```bash
# Vérifier que PostgreSQL est prêt
docker service logs xpeditis_xpeditis-db --tail 50
# Vous devriez voir : "database system is ready to accept connections"
```
### 5.2 Exécuter les Migrations
```bash
# Trouver le conteneur backend
BACKEND_CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-backend" --format "{{.ID}}" | head -n 1)
# Exécuter les migrations
docker exec -it $BACKEND_CONTAINER npm run migration:run
# Vérifier que les migrations sont appliquées
docker exec -it $BACKEND_CONTAINER npm run migration:show
```
### 5.3 Créer un Bucket MinIO
```bash
# Accéder au conteneur MinIO
MINIO_CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-minio" --format "{{.ID}}" | head -n 1)
# Créer le bucket
docker exec -it $MINIO_CONTAINER mc mb local/xpeditis-documents
# Définir la politique publique en lecture pour les documents
docker exec -it $MINIO_CONTAINER mc anonymous set download local/xpeditis-documents
```
Ou via la console MinIO :
1. Accédez à `https://minio.xpeditis.com`
2. Connectez-vous avec vos identifiants MinIO
3. Créez un bucket nommé `xpeditis-documents`
## Étape 6 : Créer un Utilisateur Admin
### 6.1 Via l'API (avec curl)
```bash
# Créer une organisation
curl -X POST https://api.xpeditis.com/api/v1/organizations \
-H "Content-Type: application/json" \
-d '{
"name": "Xpeditis Admin",
"type": "FREIGHT_FORWARDER",
"address": {
"street": "123 Rue Exemple",
"city": "Paris",
"postalCode": "75001",
"country": "FR"
}
}'
# Récupérer l'ID de l'organisation dans la réponse (ex: org-id-123)
# Créer un utilisateur admin
curl -X POST https://api.xpeditis.com/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "admin@xpeditis.com",
"password": "VotreMotDePasseAdmin123!",
"firstName": "Admin",
"lastName": "Xpeditis",
"organizationId": "org-id-123"
}'
```
### 6.2 Via la Base de Données (Direct SQL)
```bash
# Accéder à PostgreSQL
POSTGRES_CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-db" --format "{{.ID}}" | head -n 1)
docker exec -it $POSTGRES_CONTAINER psql -U xpeditis -d xpeditis_prod
# Dans psql, exécuter :
INSERT INTO organizations (id, name, type, address_street, address_city, address_postal_code, address_country, is_active)
VALUES (
gen_random_uuid(),
'Xpeditis Admin',
'FREIGHT_FORWARDER',
'123 Rue Exemple',
'Paris',
'75001',
'FR',
true
);
-- Récupérer l'ID de l'organisation
SELECT id, name FROM organizations;
-- Créer un utilisateur (remplacez ORG_ID_ICI par l'UUID réel)
INSERT INTO users (id, email, password, first_name, last_name, role, organization_id, is_active)
VALUES (
gen_random_uuid(),
'admin@xpeditis.com',
'$argon2id$v=19$m=65536,t=3,p=4$VOTRE_HASH_ARGON2',
'Admin',
'Xpeditis',
'ADMIN',
'ORG_ID_ICI',
true
);
\q
```
## Étape 7 : Tester l'Application
### 7.1 Vérifier les Services
```bash
# Vérifier que tous les services sont en cours d'exécution
docker service ls | grep xpeditis
# Vérifier les endpoints
curl -I https://api.xpeditis.com/health
curl -I https://app.xpeditis.com
curl -I https://minio.xpeditis.com
```
### 7.2 Tester l'Application Web
1. Ouvrez votre navigateur et accédez à `https://app.xpeditis.com`
2. Connectez-vous avec les identifiants admin créés
3. Testez les fonctionnalités principales :
- Recherche de tarifs
- Création de réservation CSV
- Upload de documents
### 7.3 Vérifier les Certificats SSL
```bash
# Vérifier le certificat SSL
curl -vI https://api.xpeditis.com 2>&1 | grep -i "SSL certificate"
curl -vI https://app.xpeditis.com 2>&1 | grep -i "SSL certificate"
```
## Étape 8 : Monitoring et Logs
### 8.1 Voir les Logs dans Portainer
1. **Stacks****xpeditis** → Sélectionnez un service
2. Cliquez sur **"Logs"**
3. Ajustez le nombre de lignes (ex: 500 dernières lignes)
### 8.2 Logs en Ligne de Commande
```bash
# Logs du backend
docker service logs xpeditis_xpeditis-backend -f --tail 100
# Logs du frontend
docker service logs xpeditis_xpeditis-frontend -f --tail 100
# Logs de la base de données
docker service logs xpeditis_xpeditis-db -f --tail 100
# Logs de Redis
docker service logs xpeditis_xpeditis-redis -f --tail 100
# Logs de MinIO
docker service logs xpeditis_xpeditis-minio -f --tail 100
```
### 8.3 Vérifier les Ressources
```bash
# Statistiques des conteneurs
docker stats
# État des services
docker service ls
# Détails d'un service
docker service inspect xpeditis_xpeditis-backend --pretty
```
## Étape 9 : Scaling (Optionnel)
### 9.1 Scaler le Backend
```bash
# Augmenter le nombre de répliques backend à 4
docker service scale xpeditis_xpeditis-backend=4
# Vérifier
docker service ps xpeditis_xpeditis-backend
```
### 9.2 Scaler le Frontend
```bash
# Augmenter le nombre de répliques frontend à 3
docker service scale xpeditis_xpeditis-frontend=3
```
### 9.3 Via Portainer
1. **Stacks****xpeditis** → Sélectionnez un service
2. Cliquez sur **"Scale"**
3. Ajustez le nombre de répliques
4. Cliquez sur **"Apply"**
## Étape 10 : Sauvegarde
### 10.1 Sauvegarde PostgreSQL
```bash
# Créer un script de sauvegarde
cat > /opt/backups/backup-xpeditis-db.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="/opt/backups/xpeditis"
DATE=$(date +%Y%m%d_%H%M%S)
CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-db" --format "{{.ID}}" | head -n 1)
mkdir -p $BACKUP_DIR
docker exec $CONTAINER pg_dump -U xpeditis xpeditis_prod | gzip > $BACKUP_DIR/xpeditis_db_$DATE.sql.gz
# Garder seulement les 7 dernières sauvegardes
find $BACKUP_DIR -name "xpeditis_db_*.sql.gz" -mtime +7 -delete
echo "Backup completed: xpeditis_db_$DATE.sql.gz"
EOF
chmod +x /opt/backups/backup-xpeditis-db.sh
# Ajouter à crontab (sauvegarde quotidienne à 2h du matin)
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/backups/backup-xpeditis-db.sh") | crontab -
```
### 10.2 Sauvegarde MinIO
```bash
# Créer un script de sauvegarde
cat > /opt/backups/backup-xpeditis-minio.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="/opt/backups/xpeditis"
DATE=$(date +%Y%m%d_%H%M%S)
CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-minio" --format "{{.ID}}" | head -n 1)
mkdir -p $BACKUP_DIR
docker exec $CONTAINER tar czf - /data | cat > $BACKUP_DIR/xpeditis_minio_$DATE.tar.gz
# Garder seulement les 7 dernières sauvegardes
find $BACKUP_DIR -name "xpeditis_minio_*.tar.gz" -mtime +7 -delete
echo "Backup completed: xpeditis_minio_$DATE.tar.gz"
EOF
chmod +x /opt/backups/backup-xpeditis-minio.sh
# Ajouter à crontab (sauvegarde quotidienne à 3h du matin)
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/backups/backup-xpeditis-minio.sh") | crontab -
```
## Mise à Jour de l'Application
### 1. Construire les Nouvelles Images
```bash
# Sur votre machine locale
cd /chemin/vers/xpeditis2.0
# Mettre à jour le code (git pull, etc.)
git pull origin main
# Construire les nouvelles images avec un nouveau tag
docker build -t xpeditis/backend:v1.1.0 -f apps/backend/Dockerfile .
docker build -t xpeditis/frontend:v1.1.0 -f apps/frontend/Dockerfile .
# Tag comme latest
docker tag xpeditis/backend:v1.1.0 xpeditis/backend:latest
docker tag xpeditis/frontend:v1.1.0 xpeditis/frontend:latest
# Push vers le registre ou sauvegarder et transférer
```
### 2. Mettre à Jour la Stack dans Portainer
1. **Stacks****xpeditis****"Editor"**
2. Modifiez les tags d'images si nécessaire
3. Cliquez sur **"Update the stack"**
4. Cochez **"Re-pull image and redeploy"**
5. Cliquez sur **"Update"**
Docker Swarm effectuera un rolling update sans downtime.
## Dépannage
### Le service ne démarre pas
```bash
# Vérifier les logs d'erreur
docker service logs xpeditis_xpeditis-backend --tail 100
# Vérifier les tâches échouées
docker service ps xpeditis_xpeditis-backend --no-trunc
# Inspecter le service
docker service inspect xpeditis_xpeditis-backend --pretty
```
### Certificat SSL non généré
```bash
# Vérifier les logs Traefik
docker service logs traefik --tail 200
# Vérifier que les DNS pointent bien vers le serveur
dig app.xpeditis.com
dig api.xpeditis.com
# Vérifier que le port 80 est accessible (Let's Encrypt challenge)
curl http://app.xpeditis.com
```
### Base de données ne se connecte pas
```bash
# Vérifier que PostgreSQL est prêt
docker service logs xpeditis_xpeditis-db --tail 50
# Tester la connexion depuis le backend
BACKEND_CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-backend" --format "{{.ID}}" | head -n 1)
docker exec -it $BACKEND_CONTAINER nc -zv xpeditis-db 5432
```
### MinIO ne fonctionne pas
```bash
# Vérifier les logs MinIO
docker service logs xpeditis_xpeditis-minio --tail 50
# Vérifier que le bucket existe
MINIO_CONTAINER=$(docker ps --filter "name=xpeditis_xpeditis-minio" --format "{{.ID}}" | head -n 1)
docker exec -it $MINIO_CONTAINER mc ls local/
```
## URLs de l'Application
Une fois déployée, l'application sera accessible via :
- **Frontend** : https://app.xpeditis.com
- **API** : https://api.xpeditis.com
- **API Docs (Swagger)** : https://api.xpeditis.com/api/docs
- **MinIO Console** : https://minio.xpeditis.com
- **MinIO API** : https://s3.xpeditis.com
## Sécurité
### Recommandations
1. **Changez tous les mots de passe** par défaut
2. **Utilisez des secrets Docker** pour les données sensibles
3. **Configurez un firewall** (UFW) pour limiter les ports ouverts
4. **Activez le monitoring** (Prometheus + Grafana)
5. **Configurez des alertes** pour les services en erreur
6. **Mettez en place des sauvegardes automatiques**
7. **Testez régulièrement la restauration** des sauvegardes
### Ports à Ouvrir
```bash
# Firewall UFW
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP (Traefik)
sudo ufw allow 443/tcp # HTTPS (Traefik)
sudo ufw enable
```
## Support
Pour plus d'informations, consultez :
- [Documentation Xpeditis](../README.md)
- [Architecture](../ARCHITECTURE.md)
- [Deployment Guide](../DEPLOYMENT.md)

307
docker/portainer-stack.yml Normal file
View File

@ -0,0 +1,307 @@
version: '3.8'
services:
# PostgreSQL Database
xpeditis-db:
image: postgres:15-alpine
restart: unless-stopped
volumes:
- xpeditis_db_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: xpeditis_prod
POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_prod_password_CHANGE_ME
PGDATA: /var/lib/postgresql/data/pgdata
networks:
- xpeditis_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xpeditis"]
interval: 10s
timeout: 5s
retries: 5
deploy:
placement:
constraints:
- node.role == manager
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
# Redis Cache
xpeditis-redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass xpeditis_redis_password_CHANGE_ME --appendonly yes
volumes:
- xpeditis_redis_data:/data
networks:
- xpeditis_internal
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 3s
retries: 5
deploy:
placement:
constraints:
- node.role == manager
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
# MinIO S3 Storage
xpeditis-minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
volumes:
- xpeditis_minio_data:/data
environment:
MINIO_ROOT_USER: minioadmin_CHANGE_ME
MINIO_ROOT_PASSWORD: minioadmin_password_CHANGE_ME
networks:
- xpeditis_internal
- traefik_network
labels:
# MinIO API
- "traefik.enable=true"
- "traefik.http.routers.xpeditis-minio-api.rule=Host(`s3.xpeditis.com`)"
- "traefik.http.routers.xpeditis-minio-api.entrypoints=websecure"
- "traefik.http.routers.xpeditis-minio-api.tls=true"
- "traefik.http.routers.xpeditis-minio-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.xpeditis-minio-api.service=xpeditis-minio-api"
- "traefik.http.services.xpeditis-minio-api.loadbalancer.server.port=9000"
# MinIO Console
- "traefik.http.routers.xpeditis-minio-console.rule=Host(`minio.xpeditis.com`)"
- "traefik.http.routers.xpeditis-minio-console.entrypoints=websecure"
- "traefik.http.routers.xpeditis-minio-console.tls=true"
- "traefik.http.routers.xpeditis-minio-console.tls.certresolver=letsencrypt"
- "traefik.http.routers.xpeditis-minio-console.service=xpeditis-minio-console"
- "traefik.http.services.xpeditis-minio-console.loadbalancer.server.port=9001"
- "traefik.docker.network=traefik_network"
# HTTP to HTTPS redirect
- "traefik.http.routers.xpeditis-minio-http.rule=Host(`s3.xpeditis.com`) || Host(`minio.xpeditis.com`)"
- "traefik.http.routers.xpeditis-minio-http.entrypoints=web"
- "traefik.http.routers.xpeditis-minio-http.middlewares=xpeditis-redirect"
- "traefik.http.middlewares.xpeditis-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.xpeditis-redirect.redirectscheme.permanent=true"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
deploy:
placement:
constraints:
- node.role == manager
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
# Backend API (NestJS)
xpeditis-backend:
image: xpeditis/backend:latest
restart: unless-stopped
environment:
# Node Environment
NODE_ENV: production
PORT: 4000
# Database
DATABASE_HOST: xpeditis-db
DATABASE_PORT: 5432
DATABASE_USER: xpeditis
DATABASE_PASSWORD: xpeditis_prod_password_CHANGE_ME
DATABASE_NAME: xpeditis_prod
DATABASE_SSL: "false"
DATABASE_SYNC: "false"
DATABASE_LOGGING: "false"
# Redis
REDIS_HOST: xpeditis-redis
REDIS_PORT: 6379
REDIS_PASSWORD: xpeditis_redis_password_CHANGE_ME
REDIS_TTL: 900
# JWT
JWT_SECRET: your-super-secret-jwt-key-CHANGE_ME-min-32-characters
JWT_ACCESS_EXPIRATION: 15m
JWT_REFRESH_EXPIRATION: 7d
# S3/MinIO
AWS_S3_ENDPOINT: http://xpeditis-minio:9000
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin_CHANGE_ME
AWS_SECRET_ACCESS_KEY: minioadmin_password_CHANGE_ME
AWS_S3_BUCKET: xpeditis-documents
AWS_S3_FORCE_PATH_STYLE: "true"
# CORS
CORS_ORIGIN: https://app.xpeditis.com,https://www.xpeditis.com
# Rate Limiting
RATE_LIMIT_TTL: 60
RATE_LIMIT_MAX: 100
# Email (Placeholder - configure based on your email provider)
EMAIL_HOST: smtp.example.com
EMAIL_PORT: 587
EMAIL_USER: noreply@xpeditis.com
EMAIL_PASSWORD: email_password_CHANGE_ME
EMAIL_FROM: "Xpeditis <noreply@xpeditis.com>"
# Sentry (Optional - for error tracking)
SENTRY_DSN: ""
SENTRY_ENVIRONMENT: production
SENTRY_TRACES_SAMPLE_RATE: 0.1
# App URLs
FRONTEND_URL: https://app.xpeditis.com
API_URL: https://api.xpeditis.com
networks:
- xpeditis_internal
- traefik_network
labels:
- "traefik.enable=true"
# API Routes
- "traefik.http.routers.xpeditis-api.rule=Host(`api.xpeditis.com`)"
- "traefik.http.routers.xpeditis-api.entrypoints=websecure"
- "traefik.http.routers.xpeditis-api.tls=true"
- "traefik.http.routers.xpeditis-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.xpeditis-api.priority=100"
- "traefik.http.services.xpeditis-api.loadbalancer.server.port=4000"
- "traefik.http.routers.xpeditis-api.middlewares=xpeditis-api-headers,xpeditis-api-ratelimit"
- "traefik.docker.network=traefik_network"
# Middleware Headers
- "traefik.http.middlewares.xpeditis-api-headers.headers.customRequestHeaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.xpeditis-api-headers.headers.customRequestHeaders.X-Forwarded-For="
- "traefik.http.middlewares.xpeditis-api-headers.headers.customRequestHeaders.X-Real-IP="
- "traefik.http.middlewares.xpeditis-api-headers.headers.accessControlAllowOriginList=https://app.xpeditis.com,https://www.xpeditis.com"
- "traefik.http.middlewares.xpeditis-api-headers.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS"
- "traefik.http.middlewares.xpeditis-api-headers.headers.accessControlAllowHeaders=*"
- "traefik.http.middlewares.xpeditis-api-headers.headers.accessControlMaxAge=3600"
# Rate Limiting
- "traefik.http.middlewares.xpeditis-api-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.xpeditis-api-ratelimit.ratelimit.burst=200"
- "traefik.http.middlewares.xpeditis-api-ratelimit.ratelimit.period=1m"
# HTTP to HTTPS redirect
- "traefik.http.routers.xpeditis-api-http.rule=Host(`api.xpeditis.com`)"
- "traefik.http.routers.xpeditis-api-http.entrypoints=web"
- "traefik.http.routers.xpeditis-api-http.priority=100"
- "traefik.http.routers.xpeditis-api-http.middlewares=xpeditis-redirect"
- "traefik.http.routers.xpeditis-api-http.service=xpeditis-api"
depends_on:
- xpeditis-db
- xpeditis-redis
- xpeditis-minio
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
replicas: 2
placement:
constraints:
- node.role == manager
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
# Frontend (Next.js)
xpeditis-frontend:
image: xpeditis/frontend:latest
restart: unless-stopped
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: https://api.xpeditis.com
NEXT_PUBLIC_WS_URL: wss://api.xpeditis.com
networks:
- traefik_network
labels:
- "traefik.enable=true"
# Frontend Routes
- "traefik.http.routers.xpeditis-app.rule=Host(`app.xpeditis.com`) || Host(`www.xpeditis.com`)"
- "traefik.http.routers.xpeditis-app.entrypoints=websecure"
- "traefik.http.routers.xpeditis-app.tls=true"
- "traefik.http.routers.xpeditis-app.tls.certresolver=letsencrypt"
- "traefik.http.routers.xpeditis-app.priority=50"
- "traefik.http.services.xpeditis-app.loadbalancer.server.port=3000"
- "traefik.http.routers.xpeditis-app.middlewares=xpeditis-app-headers"
- "traefik.docker.network=traefik_network"
# Middleware Headers
- "traefik.http.middlewares.xpeditis-app-headers.headers.customRequestHeaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.xpeditis-app-headers.headers.customRequestHeaders.X-Forwarded-For="
- "traefik.http.middlewares.xpeditis-app-headers.headers.customRequestHeaders.X-Real-IP="
# Security Headers
- "traefik.http.middlewares.xpeditis-app-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.xpeditis-app-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.xpeditis-app-headers.headers.stsPreload=true"
- "traefik.http.middlewares.xpeditis-app-headers.headers.forceSTSHeader=true"
- "traefik.http.middlewares.xpeditis-app-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.xpeditis-app-headers.headers.browserXssFilter=true"
# HTTP to HTTPS redirect
- "traefik.http.routers.xpeditis-app-http.rule=Host(`app.xpeditis.com`) || Host(`www.xpeditis.com`)"
- "traefik.http.routers.xpeditis-app-http.entrypoints=web"
- "traefik.http.routers.xpeditis-app-http.priority=50"
- "traefik.http.routers.xpeditis-app-http.middlewares=xpeditis-redirect"
- "traefik.http.routers.xpeditis-app-http.service=xpeditis-app"
depends_on:
- xpeditis-backend
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
replicas: 2
placement:
constraints:
- node.role == manager
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
volumes:
xpeditis_db_data:
driver: local
xpeditis_redis_data:
driver: local
xpeditis_minio_data:
driver: local
networks:
traefik_network:
external: true
xpeditis_internal:
driver: overlay
internal: true

View File

@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Create MinIO bucket for document storage
*
* Usage: node scripts/create-minio-bucket.js
*/
const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
const bucketName = 'xpeditis-documents';
const s3Client = new S3Client({
endpoint: 'http://localhost:9000',
region: 'us-east-1',
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
},
forcePathStyle: true,
});
async function createBucket() {
try {
// Check if bucket already exists
try {
await s3Client.send(new HeadBucketCommand({ Bucket: bucketName }));
console.log(`✅ Bucket '${bucketName}' already exists`);
return;
} catch (error) {
if (error.name !== 'NotFound') {
throw error;
}
}
// Create bucket
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
console.log(`✅ Bucket '${bucketName}' created successfully`);
} catch (error) {
console.error(`❌ Failed to create bucket:`, error.message);
process.exit(1);
}
}
createBucket();