diff --git a/INDEX.md b/INDEX.md index 25651bf..661d530 100644 --- a/INDEX.md +++ b/INDEX.md @@ -1,348 +1,81 @@ -# 📑 Xpeditis Documentation Index - -Complete guide to all documentation files in the Xpeditis project. +# Index de documentation — Xpeditis --- -## 🚀 Getting Started (Read First) +## DĂ©marrage -Start here if you're new to the project: - -1. **[README.md](README.md)** - Project overview and quick start -2. **[QUICK-START.md](QUICK-START.md)** ⚡ - Get running in 5 minutes -3. **[INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)** - Detailed installation guide -4. **[NEXT-STEPS.md](NEXT-STEPS.md)** - What to do after setup +| Fichier | Description | +|---------|-------------| +| [README.md](README.md) | Vue d'ensemble du projet | +| [QUICK-START.md](QUICK-START.md) | DĂ©marrage en 5 minutes | +| [CLAUDE.md](CLAUDE.md) | Architecture hexagonale, conventions, rĂšgles | +| [docs/README.md](docs/README.md) | Index complet de la documentation | --- -## 📊 Project Status & Planning +## Documentation complĂšte -### Sprint 0 (Complete ✅) +Toute la documentation est organisĂ©e dans [docs/](docs/) : -- **[SPRINT-0-FINAL.md](SPRINT-0-FINAL.md)** - Complete Sprint 0 report - - All deliverables - - Architecture details - - How to use - - Success criteria - -- **[SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)** - Executive summary - - Objectives achieved - - Metrics - - Key features - - Next steps - -- **[SPRINT-0-COMPLETE.md](SPRINT-0-COMPLETE.md)** - Technical completion checklist - - Week-by-week breakdown - - Files created - - Remaining tasks - -### Project Roadmap - -- **[TODO.md](TODO.md)** 📅 - 30-week MVP development roadmap - - Sprint-by-sprint breakdown - - Detailed tasks with checkboxes - - Phase 1-4 planning - - Go-to-market strategy - -- **[PRD.md](PRD.md)** 📋 - Product Requirements Document - - Business context - - Functional specifications - - Technical requirements - - Success metrics +``` +docs/ +├── README.md # Index principal +├── getting-started/ # Installation et dĂ©marrage +│ ├── quick-start.md # Guide rapide mis Ă  jour +│ ├── installation.md # Installation dĂ©taillĂ©e +│ └── windows.md # SpĂ©cifique Windows +│ +├── architecture/ # Documentation technique +│ ├── overview.md # Vue d'ensemble systĂšme +│ ├── database.md # SchĂ©ma BDD complet (21 tables) +│ ├── backend.md # NestJS hexagonal, patterns +│ └── frontend.md # Next.js 14, App Router, i18n +│ +├── features/ # Documentation par fonctionnalitĂ© +│ ├── auth.md # Auth JWT/OAuth/API Keys + RBAC +│ ├── bookings.md # RĂ©servations standard +│ ├── csv-bookings.md # CSV bookings + portail carrier +│ ├── rate-search.md # Recherche tarifs FCL + CSV +│ ├── subscriptions.md # Stripe + abonnements +│ ├── notifications.md # WebSocket + webhooks +│ └── api-access.md # ClĂ©s API +│ +├── deployment/ # DĂ©ploiement +│ ├── portainer.md # Portainer / Docker Swarm (consolidĂ©) +│ ├── hetzner/ # Kubernetes Hetzner (15 fichiers numĂ©rotĂ©s) +│ └── STRIPE_SETUP.md # Configuration Stripe +│ +├── testing/ # Tests +├── csv-system/ # SystĂšme CSV (format, calcul prix) +├── carrier-portal/ # Portail carrier (recherche API) +├── api-access/ # Documentation accĂšs API +├── backend/ # Notes backend (cleanup, MinIO) +└── archive/ # Rapports de sprint archivĂ©s + ├── phases/ # Historique phases 1-4 + └── debug/ # Notes de debug rĂ©solues +``` --- -## đŸ—ïž Architecture & Development Guidelines +## Commandes essentielles -### Core Architecture +```bash +# DĂ©marrer +docker-compose up -d +npm run install:all +cd apps/backend && npm run migration:run && cd ../.. +npm run backend:dev # http://localhost:4000 +npm run frontend:dev # http://localhost:3000 -- **[CLAUDE.md](CLAUDE.md)** đŸ—ïž - **START HERE FOR ARCHITECTURE** - - Complete hexagonal architecture guide - - Domain/Application/Infrastructure layers - - Ports & Adapters pattern - - Naming conventions - - Testing strategy - - Common pitfalls - - Complete examples (476 lines) +# Tests +npm run backend:test +npm run frontend:test -### Component-Specific Documentation - -- **[apps/backend/README.md](apps/backend/README.md)** - Backend (NestJS + Hexagonal) - - Architecture details - - Available scripts - - API endpoints - - Testing guide - - Hexagonal architecture DOs and DON'Ts - -- **[apps/frontend/README.md](apps/frontend/README.md)** - Frontend (Next.js 14) - - Tech stack - - Project structure - - API integration - - Forms & validation - - Testing guide +# QualitĂ© +npm run format +npm run backend:lint && npm run frontend:lint +``` --- -## đŸ› ïž Technical Documentation - -### Configuration Files - -**Root Level**: -- `package.json` - Workspace configuration -- `.gitignore` - Git ignore rules -- `.prettierrc` - Code formatting -- `docker-compose.yml` - PostgreSQL + Redis -- `tsconfig.json` - TypeScript configuration (per app) - -**Backend** (`apps/backend/`): -- `package.json` - Backend dependencies -- `tsconfig.json` - TypeScript strict mode + path aliases -- `nest-cli.json` - NestJS CLI configuration -- `.eslintrc.js` - ESLint rules -- `.env.example` - Environment variables template - -**Frontend** (`apps/frontend/`): -- `package.json` - Frontend dependencies -- `tsconfig.json` - TypeScript configuration -- `next.config.js` - Next.js configuration -- `tailwind.config.ts` - Tailwind CSS theme -- `postcss.config.js` - PostCSS configuration -- `.env.example` - Environment variables template - -### CI/CD - -**GitHub Actions** (`.github/workflows/`): -- `ci.yml` - Continuous Integration - - Lint & format check - - Unit tests (backend + frontend) - - E2E tests - - Build verification - -- `security.yml` - Security Audit - - npm audit - - Dependency review - -**Templates**: -- `.github/pull_request_template.md` - PR template with hexagonal architecture checklist - ---- - -## 📚 Documentation by Use Case - -### I want to... - -**...get started quickly** -1. [QUICK-START.md](QUICK-START.md) - 5-minute setup -2. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Detailed steps -3. [NEXT-STEPS.md](NEXT-STEPS.md) - Begin development - -**...understand the architecture** -1. [CLAUDE.md](CLAUDE.md) - Complete hexagonal architecture guide -2. [apps/backend/README.md](apps/backend/README.md) - Backend specifics -3. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - See what's implemented - -**...know what to build next** -1. [TODO.md](TODO.md) - Full roadmap -2. [NEXT-STEPS.md](NEXT-STEPS.md) - Immediate next tasks -3. [PRD.md](PRD.md) - Business requirements - -**...understand the business context** -1. [PRD.md](PRD.md) - Product requirements -2. [README.md](README.md) - Project overview -3. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Executive summary - -**...fix an installation issue** -1. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Troubleshooting section -2. [QUICK-START.md](QUICK-START.md) - Common issues -3. [README.md](README.md) - Basic setup - -**...write code following best practices** -1. [CLAUDE.md](CLAUDE.md) - Architecture guidelines (READ THIS FIRST) -2. [apps/backend/README.md](apps/backend/README.md) - Backend DOs and DON'Ts -3. [TODO.md](TODO.md) - Task specifications and acceptance criteria - -**...run tests** -1. [apps/backend/README.md](apps/backend/README.md) - Testing section -2. [apps/frontend/README.md](apps/frontend/README.md) - Testing section -3. [CLAUDE.md](CLAUDE.md) - Testing strategy - -**...deploy to production** -1. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Security checklist -2. [apps/backend/.env.example](apps/backend/.env.example) - All required variables -3. `.github/workflows/ci.yml` - CI/CD pipeline - ---- - -## 📖 Documentation by Role - -### For Developers - -**Must Read**: -1. [CLAUDE.md](CLAUDE.md) - Architecture principles -2. [apps/backend/README.md](apps/backend/README.md) OR [apps/frontend/README.md](apps/frontend/README.md) -3. [TODO.md](TODO.md) - Current sprint tasks - -**Reference**: -- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Setup issues -- [PRD.md](PRD.md) - Business context - -### For Architects - -**Must Read**: -1. [CLAUDE.md](CLAUDE.md) - Complete architecture -2. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Implementation details -3. [PRD.md](PRD.md) - Technical requirements - -**Reference**: -- [TODO.md](TODO.md) - Technical roadmap -- [apps/backend/README.md](apps/backend/README.md) - Backend architecture - -### For Project Managers - -**Must Read**: -1. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Status overview -2. [TODO.md](TODO.md) - Complete roadmap -3. [PRD.md](PRD.md) - Requirements & KPIs - -**Reference**: -- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Detailed completion report -- [README.md](README.md) - Project overview - -### For DevOps - -**Must Read**: -1. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Setup guide -2. [docker-compose.yml](docker-compose.yml) - Infrastructure -3. `.github/workflows/` - CI/CD pipelines - -**Reference**: -- [apps/backend/.env.example](apps/backend/.env.example) - Environment variables -- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Security checklist - ---- - -## đŸ—‚ïž Complete File List - -### Documentation (11 files) - -| File | Purpose | Length | -|------|---------|--------| -| [README.md](README.md) | Project overview | Medium | -| [CLAUDE.md](CLAUDE.md) | Architecture guide | Long (476 lines) | -| [PRD.md](PRD.md) | Product requirements | Long (352 lines) | -| [TODO.md](TODO.md) | 30-week roadmap | Very Long (1000+ lines) | -| [QUICK-START.md](QUICK-START.md) | 5-minute setup | Short | -| [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) | Detailed setup | Medium | -| [NEXT-STEPS.md](NEXT-STEPS.md) | What's next | Medium | -| [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) | Sprint 0 report | Long | -| [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) | Executive summary | Medium | -| [SPRINT-0-COMPLETE.md](SPRINT-0-COMPLETE.md) | Technical checklist | Short | -| [INDEX.md](INDEX.md) | This file | Medium | - -### App-Specific (2 files) - -| File | Purpose | -|------|---------| -| [apps/backend/README.md](apps/backend/README.md) | Backend guide | -| [apps/frontend/README.md](apps/frontend/README.md) | Frontend guide | - -### Configuration (10+ files) - -Root, backend, and frontend configuration files (package.json, tsconfig.json, etc.) - ---- - -## 📊 Documentation Statistics - -- **Total Documentation Files**: 13 -- **Total Lines**: ~4,000+ -- **Coverage**: Setup, Architecture, Development, Testing, Deployment -- **Last Updated**: October 7, 2025 - ---- - -## 🎯 Recommended Reading Path - -### For New Team Members (Day 1) - -**Morning** (2 hours): -1. [README.md](README.md) - 10 min -2. [QUICK-START.md](QUICK-START.md) - 30 min (includes setup) -3. [CLAUDE.md](CLAUDE.md) - 60 min (comprehensive architecture) -4. [PRD.md](PRD.md) - 20 min (business context) - -**Afternoon** (2 hours): -5. [apps/backend/README.md](apps/backend/README.md) OR [apps/frontend/README.md](apps/frontend/README.md) - 30 min -6. [TODO.md](TODO.md) - Current sprint section - 30 min -7. [NEXT-STEPS.md](NEXT-STEPS.md) - 30 min -8. Start coding! 🚀 - -### For Code Review (30 minutes) - -1. [CLAUDE.md](CLAUDE.md) - Hexagonal architecture section -2. [apps/backend/README.md](apps/backend/README.md) - DOs and DON'Ts -3. [TODO.md](TODO.md) - Acceptance criteria for the feature - -### For Sprint Planning (1 hour) - -1. [TODO.md](TODO.md) - Next sprint tasks -2. [PRD.md](PRD.md) - Requirements for the module -3. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Current status - ---- - -## 🔍 Quick Reference - -### Common Questions - -**Q: How do I get started?** -A: [QUICK-START.md](QUICK-START.md) - -**Q: What is hexagonal architecture?** -A: [CLAUDE.md](CLAUDE.md) - Complete guide with examples - -**Q: What should I build next?** -A: [NEXT-STEPS.md](NEXT-STEPS.md) then [TODO.md](TODO.md) - -**Q: How do I run tests?** -A: [apps/backend/README.md](apps/backend/README.md) or [apps/frontend/README.md](apps/frontend/README.md) - -**Q: Where are the business requirements?** -A: [PRD.md](PRD.md) - -**Q: What's the project status?** -A: [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - -**Q: Installation failed, what do I do?** -A: [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Troubleshooting section - -**Q: Can I change the database/framework?** -A: Yes! That's the point of hexagonal architecture. See [CLAUDE.md](CLAUDE.md) - ---- - -## 📞 Getting Help - -If you can't find what you need: - -1. **Check this index** - Use Ctrl+F to search -2. **Read CLAUDE.md** - Covers 90% of architecture questions -3. **Check TODO.md** - Has detailed task specifications -4. **Open an issue** - If documentation is unclear or missing - ---- - -## 🎉 Happy Reading! - -All documentation is up-to-date as of Sprint 0 completion. - -**Quick Links**: -- 🚀 [Get Started](QUICK-START.md) -- đŸ—ïž [Architecture](CLAUDE.md) -- 📅 [Roadmap](TODO.md) -- 📋 [Requirements](PRD.md) - ---- - -*Xpeditis MVP - Maritime Freight Booking Platform* -*Documentation Index - October 7, 2025* +*DerniĂšre mise Ă  jour : Mai 2026* diff --git a/README.md b/README.md index c185b67..8e81a43 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,151 @@ -# Xpeditis - Maritime Freight Booking Platform +# Xpeditis — Maritime Freight Booking Platform -**Xpeditis** is a B2B SaaS platform for freight forwarders to search, compare, and book maritime freight in real-time. +Plateforme B2B SaaS permettant aux transitaires de rechercher, comparer et rĂ©server du fret maritime en temps rĂ©el. --- -## ⭐ **[START HERE](START-HERE.md)** ⭐ - -**New to the project?** Read **[START-HERE.md](START-HERE.md)** - Get running in 10 minutes! - ---- - -## 🚀 Quick Start - -### Prerequisites - -- Node.js >= 20.0.0 -- npm >= 10.0.0 -- Docker & Docker Compose -- PostgreSQL 15+ -- Redis 7+ - -### Installation +## DĂ©marrage rapide ```bash -# Install dependencies -npm install +# 1. Installer les dĂ©pendances +npm run install:all -# Start infrastructure (PostgreSQL + Redis) +# 2. DĂ©marrer l'infrastructure (PostgreSQL + Redis + MinIO) docker-compose up -d -# Setup environment variables +# 3. Configurer l'environnement cp apps/backend/.env.example apps/backend/.env -cp apps/frontend/.env.example apps/frontend/.env +cp apps/frontend/.env.example apps/frontend/.env.local -# Run database migrations -npm run backend:migrate +# 4. ExĂ©cuter les migrations +cd apps/backend && npm run migration:run && cd ../.. -# Start backend (development) -npm run backend:dev - -# Start frontend (development) -npm run frontend:dev +# 5. DĂ©marrer les serveurs +npm run backend:dev # http://localhost:4000 · Swagger: /api/docs +npm run frontend:dev # http://localhost:3000 ``` -### Access Points +--- -- **Frontend**: http://localhost:3000 -- **Backend API**: http://localhost:4000 -- **API Documentation**: http://localhost:4000/api/docs - -## 📁 Project Structure +## Structure du projet ``` xpeditis/ ├── apps/ -│ ├── backend/ # NestJS API (Hexagonal Architecture) +│ ├── backend/ # NestJS 10 — Architecture hexagonale │ │ └── src/ -│ │ ├── domain/ # Pure business logic -│ │ ├── application/ # Controllers & DTOs -│ │ └── infrastructure/ # External adapters +│ │ ├── domain/ # Logique mĂ©tier pure (TypeScript) +│ │ ├── application/ # Controllers, DTOs, Guards +│ │ └── infrastructure/ # TypeORM, Redis, S3, Email, Stripe │ └── frontend/ # Next.js 14 App Router -├── packages/ -│ ├── shared-types/ # Shared TypeScript types -│ └── domain/ # Shared domain logic -└── infra/ # Infrastructure configs +│ ├── app/[locale]/ # Routing i18n (fr, en) +│ └── src/ # Components, hooks, lib/api +├── docker-compose.yml # PostgreSQL 15 + Redis 7 + MinIO +└── docs/ # Documentation complĂšte ``` -## đŸ—ïž Architecture - -This project follows **Hexagonal Architecture** (Ports & Adapters) principles: - -- **Domain Layer**: Pure business logic, no external dependencies -- **Application Layer**: Use cases, controllers, DTOs -- **Infrastructure Layer**: Database, external APIs, cache, email, storage - -See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines. - -## đŸ› ïž Development - -### Backend - -```bash -npm run backend:dev # Start dev server -npm run backend:test # Run tests -npm run backend:test:watch # Run tests in watch mode -npm run backend:test:cov # Generate coverage report -npm run backend:lint # Lint code -npm run backend:build # Build for production -``` - -### Frontend - -```bash -npm run frontend:dev # Start dev server -npm run frontend:build # Build for production -npm run frontend:test # Run tests -npm run frontend:lint # Lint code -``` - -## 📚 Documentation - -### Getting Started -- **[QUICK-START.md](QUICK-START.md)** ⚡ - Get running in 5 minutes -- **[INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)** 📩 - Detailed installation guide -- **[NEXT-STEPS.md](NEXT-STEPS.md)** 🚀 - What to do after setup - -### Architecture & Guidelines -- **[CLAUDE.md](CLAUDE.md)** đŸ—ïž - Hexagonal architecture guidelines (complete) -- **[apps/backend/README.md](apps/backend/README.md)** - Backend documentation -- **[apps/frontend/README.md](apps/frontend/README.md)** - Frontend documentation - -### Project Planning -- **[PRD.md](PRD.md)** 📋 - Product Requirements Document -- **[TODO.md](TODO.md)** 📅 - 30-week development roadmap -- **[SPRINT-0-FINAL.md](SPRINT-0-FINAL.md)** ✅ - Sprint 0 completion report -- **[SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)** 📊 - Executive summary - -### API Documentation -- **[API Docs](http://localhost:4000/api/docs)** 📖 - OpenAPI/Swagger (when running) - -## đŸ§Ș Testing - -```bash -# Run all tests -npm run test:all - -# Run backend tests -npm run backend:test - -# Run frontend tests -npm run frontend:test - -# E2E tests (after implementation) -npm run test:e2e -``` - -## 🔒 Security - -- All passwords hashed with bcrypt (12 rounds minimum) -- JWT tokens (access: 15min, refresh: 7 days) -- HTTPS/TLS 1.2+ enforced -- OWASP Top 10 protection -- Rate limiting on all endpoints -- CSRF protection - -## 📊 Tech Stack - -### Backend -- **Framework**: NestJS 10+ -- **Language**: TypeScript 5+ -- **Database**: PostgreSQL 15+ -- **Cache**: Redis 7+ -- **ORM**: TypeORM -- **Testing**: Jest, Supertest -- **API Docs**: Swagger/OpenAPI - -### Frontend -- **Framework**: Next.js 14+ (App Router) -- **Language**: TypeScript 5+ -- **Styling**: Tailwind CSS -- **UI Components**: shadcn/ui -- **State**: React Query (TanStack Query) -- **Forms**: React Hook Form + Zod -- **Testing**: Jest, React Testing Library, Playwright - -## 🚱 Carrier Integrations - -MVP supports the following maritime carriers: - -- ✅ Maersk -- ✅ MSC -- ✅ CMA CGM -- ✅ Hapag-Lloyd -- ✅ ONE (Ocean Network Express) - -## 📈 Monitoring & Logging - -- **Logging**: Winston / Pino -- **Error Tracking**: Sentry -- **APM**: Application Performance Monitoring -- **Metrics**: Prometheus (planned) - -## 🔧 Environment Variables - -See `.env.example` files in each app for required environment variables. - -## đŸ€ Contributing - -1. Create a feature branch -2. Make your changes -3. Write tests -4. Run linting and formatting -5. Submit a pull request - -## 📝 License - -Proprietary - All rights reserved - -## đŸ‘„ Team - -Built with ❀ by the Xpeditis team - --- -For detailed implementation guidelines, see [CLAUDE.md](CLAUDE.md). +## Documentation + +| Sujet | Fichier | +|-------|---------| +| Index complet | [docs/README.md](docs/README.md) | +| Architecture hexagonale + conventions | [CLAUDE.md](CLAUDE.md) | +| Vue d'ensemble systĂšme | [docs/architecture/overview.md](docs/architecture/overview.md) | +| SchĂ©ma BDD (21 tables) | [docs/architecture/database.md](docs/architecture/database.md) | +| DĂ©marrage rapide | [docs/getting-started/quick-start.md](docs/getting-started/quick-start.md) | + +--- + +## Commandes de dĂ©veloppement + +```bash +# Backend +npm run backend:dev # Serveur avec hot-reload +npm run backend:test # Tests unitaires Jest +npm run backend:lint # ESLint +npm run backend:build # Build production + +# Frontend +npm run frontend:dev # Serveur avec hot-reload +npm run frontend:test # Tests unitaires Jest +npm run frontend:lint # ESLint +cd apps/frontend && npm run test:e2e # Playwright E2E + +# QualitĂ© +npm run format # Prettier (tous les fichiers) + +# Base de donnĂ©es +cd apps/backend +npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/NomMigration +npm run migration:run +npm run migration:revert +``` + +--- + +## Stack technique + +### Backend + +| Composant | Technologie | +|-----------|-------------| +| Framework | NestJS 10 + TypeScript 5 (strict) | +| Base de donnĂ©es | PostgreSQL 15 + TypeORM | +| Cache | Redis 7 (ioredis) | +| Auth | JWT (15min) + Refresh + OAuth2 + API Keys (Argon2) | +| Temps rĂ©el | Socket.IO | +| Email | Nodemailer + MJML | +| Paiements | Stripe | +| Stockage | S3/MinIO | +| Logging | nestjs-pino | +| Monitoring | Sentry | + +### Frontend + +| Composant | Technologie | +|-----------|-------------| +| Framework | Next.js 14 App Router + TypeScript | +| Styling | Tailwind CSS + shadcn/ui (Radix UI) | +| State serveur | TanStack Query v5 | +| Tables | TanStack Table v8 + Virtual | +| Formulaires | react-hook-form + zod | +| Temps rĂ©el | Socket.IO client | +| i18n | next-intl (fr, en) | +| Graphiques | recharts | + +--- + +## Carriers intĂ©grĂ©s + +| Carrier | Code | Statut | +|---------|------|--------| +| Maersk | MAEU | Connecteur API | +| MSC | MSCU | Connecteur API | +| CMA CGM | CMDU | Connecteur API | +| Hapag-Lloyd | HLCU | Connecteur API | +| ONE | ONEY | Connecteur API | +| SSC Consolidation | — | CSV | +| ECU Worldwide | — | CSV + API | +| TCC Logistics | — | CSV | +| NVO Consolidation | — | CSV | + +--- + +## FonctionnalitĂ©s principales + +- **Recherche tarifs** : FCL (carriers API + cache Redis 15min) + LCL CSV +- **RĂ©servation standard** : workflow 4 Ă©tapes, numĂ©ro WCM-YYYY-XXXXXX +- **RĂ©servation CSV + Portail Carrier** : magic link, accept/reject +- **Dashboard** : KPI, graphiques, table interactive virtuelle +- **Auth** : JWT, OAuth2 (Google/Microsoft), API Keys, RBAC (5 rĂŽles) +- **Abonnements** : Stripe (FREE/BRONZE/SILVER/GOLD/PLATINIUM) +- **Notifications** : WebSocket temps rĂ©el + webhooks tiers +- **GDPR** : export/suppression des donnĂ©es utilisateur +- **Blog** : gestion de contenu bilingue (fr/en) +- **Audit** : journal d'audit de toutes les actions + +--- + +*Architecture hexagonale — NestJS 10 + Next.js 14 — PostgreSQL 15 + Redis 7* diff --git a/apps/backend/.env.example b/apps/backend/.env.example index aa66cfe..612a8fa 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -35,51 +35,27 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback # Application URL APP_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:3000 # Email (SMTP) - SMTP_HOST=smtp-relay.brevo.com - SMTP_PORT=587 - SMTP_USER=ton-email@brevo.com - SMTP_PASS=ta-cle-smtp-brevo - SMTP_SECURE=false - -# SMTP_FROM devient le fallback uniquement (chaque mĂ©thode a son propre from maintenant) - SMTP_FROM=noreply@xpeditis.com +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_SECURE=false +SMTP_FROM=noreply@xpeditis.com # AWS S3 / Storage (or MinIO for development) -AWS_ACCESS_KEY_ID=your-aws-access-key -AWS_SECRET_ACCESS_KEY=your-aws-secret-key +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin AWS_REGION=us-east-1 AWS_S3_ENDPOINT=http://localhost:9000 # AWS_S3_ENDPOINT= # Leave empty for AWS S3 -# Carrier APIs -# Maersk -MAERSK_API_KEY=your-maersk-api-key -MAERSK_API_URL=https://api.maersk.com/v1 -# MSC -MSC_API_KEY=your-msc-api-key -MSC_API_URL=https://api.msc.com/v1 - -# CMA CGM -CMACGM_API_URL=https://api.cma-cgm.com/v1 -CMACGM_CLIENT_ID=your-cmacgm-client-id -CMACGM_CLIENT_SECRET=your-cmacgm-client-secret - -# Hapag-Lloyd -HAPAG_API_URL=https://api.hapag-lloyd.com/v1 -HAPAG_API_KEY=your-hapag-api-key - -# ONE (Ocean Network Express) -ONE_API_URL=https://api.one-line.com/v1 -ONE_USERNAME=your-one-username -ONE_PASSWORD=your-one-password - -# Swagger Documentation Access (HTTP Basic Auth) -# Leave empty to disable Swagger in production, or set both to protect with a password -SWAGGER_USERNAME=admin -SWAGGER_PASSWORD=change-this-strong-password +# Swagger Documentation Access (HTTP Basic Auth — only you can access /api/docs) +SWAGGER_USERNAME= +SWAGGER_PASSWORD= # Security BCRYPT_ROUNDS=12 @@ -92,17 +68,18 @@ RATE_LIMIT_MAX=100 # Monitoring SENTRY_DSN=your-sentry-dsn + # Frontend URL (for redirects) FRONTEND_URL=http://localhost:3000 # Stripe (Subscriptions & Payments) -STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key -STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= -# Stripe Price IDs (create these in Stripe Dashboard) -STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly -STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly -STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly -STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly -STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly -STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly +# Stripe Price IDs (from Stripe Dashboard) +STRIPE_SILVER_MONTHLY_PRICE_ID= +STRIPE_SILVER_YEARLY_PRICE_ID= +STRIPE_GOLD_MONTHLY_PRICE_ID= +STRIPE_GOLD_YEARLY_PRICE_ID= +STRIPE_PLATINIUM_MONTHLY_PRICE_ID= +STRIPE_PLATINIUM_YEARLY_PRICE_ID= diff --git a/apps/backend/CARRIER_ACCEPT_REJECT_FIX.md b/apps/backend/CARRIER_ACCEPT_REJECT_FIX.md deleted file mode 100644 index 857c4d0..0000000 --- a/apps/backend/CARRIER_ACCEPT_REJECT_FIX.md +++ /dev/null @@ -1,328 +0,0 @@ -# ✅ FIX: Redirection Transporteur aprĂšs Accept/Reject - -**Date**: 5 dĂ©cembre 2025 -**Statut**: ✅ **CORRIGÉ ET TESTÉ** - ---- - -## 🎯 ProblĂšme IdentifiĂ© - -**SymptĂŽme**: Quand un transporteur clique sur "Accepter" ou "Refuser" dans l'email: -- ❌ Pas de redirection vers le dashboard transporteur -- ❌ Le status du booking ne change pas -- ❌ Erreur 404 ou pas de rĂ©ponse - -**URL problĂ©matique**: -``` -http://localhost:3000/api/v1/csv-bookings/{token}/accept -``` - -**Cause Racine**: Les URLs dans l'email pointaient vers le **frontend** (port 3000) au lieu du **backend** (port 4000). - ---- - -## 🔍 Analyse du ProblĂšme - -### Ce qui se passait AVANT (❌ CassĂ©) - -1. **Email envoyĂ©** avec URL: `http://localhost:3000/api/v1/csv-bookings/{token}/accept` -2. **Transporteur clique** sur le lien -3. **Frontend** (port 3000) reçoit la requĂȘte -4. **Erreur 404** car `/api/v1/*` n'existe pas sur le frontend -5. **Aucune redirection**, aucun traitement - -### Workflow Attendu (✅ Correct) - -1. **Email envoyĂ©** avec URL: `http://localhost:4000/api/v1/csv-bookings/{token}/accept` -2. **Transporteur clique** sur le lien -3. **Backend** (port 4000) reçoit la requĂȘte -4. **Backend traite**: - - Accepte le booking - - CrĂ©e un compte transporteur si nĂ©cessaire - - GĂ©nĂšre un token d'auto-login -5. **Backend redirige** vers: `http://localhost:3000/carrier/confirmed?token={autoLoginToken}&action=accepted&bookingId={id}&new={isNew}` -6. **Frontend** affiche la page de confirmation -7. **Transporteur** est auto-connectĂ© et voit son dashboard - ---- - -## ✅ Correction AppliquĂ©e - -### Fichier 1: `email.adapter.ts` (lignes 259-264) - -**AVANT** (❌): -```typescript -const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); // Frontend! -const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/accept`; -const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/reject`; -``` - -**APRÈS** (✅): -```typescript -// Use BACKEND_URL if available, otherwise construct from PORT -// The accept/reject endpoints are on the BACKEND, not the frontend -const port = this.configService.get('PORT', '4000'); -const backendUrl = this.configService.get('BACKEND_URL', `http://localhost:${port}`); -const acceptUrl = `${backendUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/accept`; -const rejectUrl = `${backendUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/reject`; -``` - -**Changements**: -- ✅ Utilise `BACKEND_URL` ou construit Ă  partir de `PORT` -- ✅ URLs pointent maintenant vers `http://localhost:4000/api/v1/...` -- ✅ Commentaires ajoutĂ©s pour clarifier - -### Fichier 2: `app.module.ts` (lignes 39-40) - -Ajout des variables `APP_URL` et `BACKEND_URL` au schĂ©ma de validation: - -```typescript -validationSchema: Joi.object({ - // ... - APP_URL: Joi.string().uri().default('http://localhost:3000'), - BACKEND_URL: Joi.string().uri().optional(), - // ... -}), -``` - -**Pourquoi**: Pour Ă©viter que ces variables soient supprimĂ©es par la validation Joi. - ---- - -## đŸ§Ș Test du Workflow Complet - -### PrĂ©requis - -- ✅ Backend en cours d'exĂ©cution (port 4000) -- ✅ Frontend en cours d'exĂ©cution (port 3000) -- ✅ MinIO en cours d'exĂ©cution -- ✅ Email adapter initialisĂ© - -### Étape 1: CrĂ©er un Booking CSV - -1. **Se connecter** au frontend: http://localhost:3000 -2. **Aller sur** la page de recherche avancĂ©e -3. **Rechercher un tarif** et cliquer sur "RĂ©server" -4. **Remplir le formulaire**: - - Carrier email: Votre email de test (ou Mailtrap) - - Ajouter au moins 1 document -5. **Cliquer sur "Envoyer la demande"** - -### Étape 2: VĂ©rifier l'Email Reçu - -1. **Ouvrir Mailtrap**: https://mailtrap.io/inboxes -2. **Trouver l'email**: "Nouvelle demande de rĂ©servation - {origin} → {destination}" -3. **VĂ©rifier les URLs** des boutons: - - ✅ Accepter: `http://localhost:4000/api/v1/csv-bookings/{token}/accept` - - ✅ Refuser: `http://localhost:4000/api/v1/csv-bookings/{token}/reject` - -**IMPORTANT**: Les URLs doivent pointer vers **port 4000** (backend), PAS port 3000! - -### Étape 3: Tester l'Acceptation - -1. **Copier l'URL** du bouton "Accepter" depuis l'email -2. **Ouvrir dans le navigateur** (ou cliquer sur le bouton) -3. **Observer**: - - ✅ Le navigateur va d'abord vers `localhost:4000` - - ✅ Puis redirige automatiquement vers `localhost:3000/carrier/confirmed?...` - - ✅ Page de confirmation affichĂ©e - - ✅ Transporteur auto-connectĂ© - -### Étape 4: VĂ©rifier le Dashboard Transporteur - -AprĂšs la redirection: - -1. **URL attendue**: - ``` - http://localhost:3000/carrier/confirmed?token={autoLoginToken}&action=accepted&bookingId={id}&new=true - ``` - -2. **Page affichĂ©e**: - - ✅ Message de confirmation: "RĂ©servation acceptĂ©e avec succĂšs!" - - ✅ Lien vers le dashboard transporteur - - ✅ Si nouveau compte: Message avec credentials - -3. **VĂ©rifier le status**: - - Le booking doit maintenant avoir le status `ACCEPTED` - - Visible dans le dashboard utilisateur (celui qui a créé le booking) - -### Étape 5: Tester le Rejet - -RĂ©pĂ©ter avec le bouton "Refuser": - -1. **CrĂ©er un nouveau booking** (Ă©tape 1) -2. **Cliquer sur "Refuser"** dans l'email -3. **VĂ©rifier**: - - ✅ Redirection vers `/carrier/confirmed?...&action=rejected` - - ✅ Message: "RĂ©servation refusĂ©e" - - ✅ Status du booking: `REJECTED` - ---- - -## 📊 VĂ©rifications Backend - -### Logs Attendus lors de l'Acceptation - -```bash -# Monitorer les logs -tail -f /tmp/backend-restart.log | grep -i "accept\|carrier\|booking" -``` - -**Logs attendus**: -``` -[CsvBookingService] Accepting booking with token: {token} -[CarrierAuthService] Creating carrier account for email: carrier@test.com -[CarrierAuthService] Carrier account created with ID: {carrierId} -[CsvBookingService] Successfully linked booking {bookingId} to carrier {carrierId} -``` - ---- - -## 🔧 Variables d'Environnement - -### `.env` Backend - -**Variables requises**: -```bash -PORT=4000 # Port du backend -APP_URL=http://localhost:3000 # URL du frontend -BACKEND_URL=http://localhost:4000 # URL du backend (optionnel, auto-construit si absent) -``` - -**En production**: -```bash -PORT=4000 -APP_URL=https://xpeditis.com -BACKEND_URL=https://api.xpeditis.com -``` - ---- - -## 🐛 DĂ©pannage - -### ProblĂšme 1: Toujours redirigĂ© vers port 3000 - -**Cause**: Email envoyĂ© AVANT la correction - -**Solution**: -1. Backend a Ă©tĂ© redĂ©marrĂ© aprĂšs la correction ✅ -2. CrĂ©er un **NOUVEAU booking** pour recevoir un email avec les bonnes URLs -3. Les anciens bookings ont encore les anciennes URLs (port 3000) - ---- - -### ProblĂšme 2: 404 Not Found sur /accept - -**Cause**: Backend pas dĂ©marrĂ© ou route mal configurĂ©e - -**Solution**: -```bash -# VĂ©rifier que le backend tourne -curl http://localhost:4000/api/v1/health || echo "Backend not responding" - -# VĂ©rifier les logs backend -tail -50 /tmp/backend-restart.log | grep -i "csv-bookings" - -# RedĂ©marrer le backend -cd apps/backend -npm run dev -``` - ---- - -### ProblĂšme 3: Token Invalid - -**Cause**: Token expirĂ© ou booking dĂ©jĂ  acceptĂ©/refusĂ© - -**Solution**: -- Les bookings ne peuvent ĂȘtre acceptĂ©s/refusĂ©s qu'une seule fois -- Si token invalide, crĂ©er un nouveau booking -- VĂ©rifier dans la base de donnĂ©es le status du booking - ---- - -### ProblĂšme 4: Pas de redirection vers /carrier/confirmed - -**Cause**: Frontend route manquante ou token d'auto-login invalide - -**VĂ©rification**: -1. VĂ©rifier que la route `/carrier/confirmed` existe dans le frontend -2. VĂ©rifier les logs backend pour voir si le token est gĂ©nĂ©rĂ© -3. VĂ©rifier que le frontend affiche bien la page - ---- - -## 📝 Checklist de Validation - -- [x] Backend redĂ©marrĂ© avec la correction -- [x] Email adapter initialisĂ© correctement -- [x] Variables `APP_URL` et `BACKEND_URL` dans le schĂ©ma Joi -- [ ] Nouveau booking créé (APRÈS la correction) -- [ ] Email reçu avec URLs correctes (port 4000) -- [ ] Clic sur "Accepter" → Redirection vers /carrier/confirmed -- [ ] Status du booking changĂ© en `ACCEPTED` -- [ ] Dashboard transporteur accessible -- [ ] Test "Refuser" fonctionne aussi - ---- - -## 🎯 RĂ©sumĂ© des Corrections - -| Aspect | Avant (❌) | AprĂšs (✅) | -|--------|-----------|-----------| -| **Email URL Accept** | `localhost:3000/api/v1/...` | `localhost:4000/api/v1/...` | -| **Email URL Reject** | `localhost:3000/api/v1/...` | `localhost:4000/api/v1/...` | -| **Redirection** | Aucune (404) | Vers `/carrier/confirmed` | -| **Status booking** | Ne change pas | `ACCEPTED` ou `REJECTED` | -| **Dashboard transporteur** | Inaccessible | Accessible avec auto-login | - ---- - -## ✅ Workflow Complet CorrigĂ© - -``` -1. Utilisateur crĂ©e booking - └─> Backend sauvegarde booking (status: PENDING) - └─> Backend envoie email avec URLs backend (port 4000) ✅ - -2. Transporteur clique "Accepter" dans email - └─> Ouvre: http://localhost:4000/api/v1/csv-bookings/{token}/accept ✅ - └─> Backend traite la requĂȘte: - ├─> Change status → ACCEPTED ✅ - ├─> CrĂ©e compte transporteur si nĂ©cessaire ✅ - ├─> GĂ©nĂšre token auto-login ✅ - └─> Redirige vers frontend: localhost:3000/carrier/confirmed?... ✅ - -3. Frontend affiche page confirmation - └─> Message de succĂšs ✅ - └─> Auto-login du transporteur ✅ - └─> Lien vers dashboard ✅ - -4. Transporteur accĂšde Ă  son dashboard - └─> Voir la liste de ses bookings ✅ - └─> GĂ©rer ses rĂ©servations ✅ -``` - ---- - -## 🚀 Prochaines Étapes - -1. **Tester immĂ©diatement**: - - CrĂ©er un nouveau booking (important: APRÈS le redĂ©marrage) - - VĂ©rifier l'email reçu - - Tester Accept/Reject - -2. **VĂ©rifier en production**: - - Mettre Ă  jour la variable `BACKEND_URL` dans le .env production - - RedĂ©ployer le backend - - Tester le workflow complet - -3. **Documentation**: - - Mettre Ă  jour le guide utilisateur - - Documenter le workflow transporteur - ---- - -**Correction effectuĂ©e le 5 dĂ©cembre 2025 par Claude Code** ✅ - -_Le systĂšme d'acceptation/rejet transporteur est maintenant 100% fonctionnel!_ 🚱✹ diff --git a/apps/backend/CSV_BOOKING_DIAGNOSTIC.md b/apps/backend/CSV_BOOKING_DIAGNOSTIC.md deleted file mode 100644 index 995b85e..0000000 --- a/apps/backend/CSV_BOOKING_DIAGNOSTIC.md +++ /dev/null @@ -1,282 +0,0 @@ -# 🔍 Diagnostic Complet - Workflow CSV Booking - -**Date**: 5 dĂ©cembre 2025 -**ProblĂšme**: Le workflow d'envoi de demande de booking ne fonctionne pas - ---- - -## ✅ VĂ©rifications EffectuĂ©es - -### 1. Backend ✅ -- ✅ Backend en cours d'exĂ©cution (port 4000) -- ✅ Configuration SMTP corrigĂ©e (variables ajoutĂ©es au schĂ©ma Joi) -- ✅ Email adapter initialisĂ© correctement avec DNS bypass -- ✅ Module CsvBookingsModule importĂ© dans app.module.ts -- ✅ Controller CsvBookingsController bien configurĂ© -- ✅ Service CsvBookingService bien configurĂ© -- ✅ MinIO container en cours d'exĂ©cution -- ✅ Bucket 'xpeditis-documents' existe dans MinIO - -### 2. Frontend ✅ -- ✅ Page `/dashboard/booking/new` existe -- ✅ Fonction `handleSubmit` bien configurĂ©e -- ✅ FormData correctement construit avec tous les champs -- ✅ Documents ajoutĂ©s avec le nom 'documents' (pluriel) -- ✅ Appel API via `createCsvBooking()` qui utilise `upload()` -- ✅ Gestion d'erreurs prĂ©sente (affiche message si Ă©chec) - ---- - -## 🔍 Points de DĂ©faillance Possibles - -### ScĂ©nario 1: Erreur Frontend (Browser Console) -**SymptĂŽmes**: Le bouton "Envoyer la demande" ne fait rien, ou affiche un message d'erreur - -**VĂ©rification**: -1. Ouvrir les DevTools du navigateur (F12) -2. Aller dans l'onglet Console -3. Cliquer sur "Envoyer la demande" -4. Regarder les erreurs affichĂ©es - -**Erreurs Possibles**: -- `Failed to fetch` → ProblĂšme de connexion au backend -- `401 Unauthorized` → Token JWT expirĂ© -- `400 Bad Request` → DonnĂ©es invalides -- `500 Internal Server Error` → Erreur backend (voir logs) - ---- - -### ScĂ©nario 2: Erreur Backend (Logs) -**SymptĂŽmes**: La requĂȘte arrive au backend mais Ă©choue - -**VĂ©rification**: -```bash -# Voir les logs backend en temps rĂ©el -tail -f /tmp/backend-startup.log - -# Puis crĂ©er un booking via le frontend -``` - -**Erreurs Possibles**: -- **Pas de logs `=== CSV Booking Request Debug ===`** → La requĂȘte n'arrive pas au controller -- **`At least one document is required`** → Aucun fichier uploadĂ© -- **`User authentication failed`** → ProblĂšme de JWT -- **`Organization ID is required`** → User sans organizationId -- **Erreur S3/MinIO** → Upload de fichiers Ă©chouĂ© -- **Erreur Email** → Envoi email Ă©chouĂ© (ne devrait plus arriver aprĂšs le fix) - ---- - -### ScĂ©nario 3: Validation ÉchouĂ©e -**SymptĂŽmes**: Erreur 400 Bad Request - -**Causes Possibles**: -- **Port codes invalides** (origin/destination): Doivent ĂȘtre exactement 5 caractĂšres (ex: NLRTM, USNYC) -- **Email invalide** (carrierEmail): Doit ĂȘtre un email valide -- **Champs numĂ©riques** (volumeCBM, weightKG, etc.): Doivent ĂȘtre > 0 -- **Currency invalide**: Doit ĂȘtre 'USD' ou 'EUR' -- **Pas de documents**: Au moins 1 fichier requis - ---- - -### ScĂ©nario 4: CORS ou Network -**SymptĂŽmes**: Erreur CORS ou network error - -**VĂ©rification**: -1. Ouvrir DevTools → Network tab -2. CrĂ©er un booking -3. Regarder la requĂȘte POST vers `/api/v1/csv-bookings` -4. VĂ©rifier: - - Status code (200/201 = OK, 4xx/5xx = erreur) - - Response body (message d'erreur) - - Request headers (Authorization token prĂ©sent?) - -**Solutions**: -- Backend et frontend doivent tourner simultanĂ©ment -- Frontend: `http://localhost:3000` -- Backend: `http://localhost:4000` - ---- - -## đŸ§Ș Tests Ă  Effectuer - -### Test 1: VĂ©rifier que le Backend Reçoit la RequĂȘte - -1. **Ouvrir un terminal et monitorer les logs**: - ```bash - tail -f /tmp/backend-startup.log | grep -i "csv\|booking\|error" - ``` - -2. **Dans le navigateur**: - - Aller sur: http://localhost:3000/dashboard/booking/new?rateData=%7B%22companyName%22%3A%22Test%20Carrier%22%2C%22companyEmail%22%3A%22carrier%40test.com%22%2C%22origin%22%3A%22NLRTM%22%2C%22destination%22%3A%22USNYC%22%2C%22containerType%22%3A%22LCL%22%2C%22priceUSD%22%3A1000%2C%22priceEUR%22%3A900%2C%22primaryCurrency%22%3A%22USD%22%2C%22transitDays%22%3A22%7D&volumeCBM=2.88&weightKG=1500&palletCount=3 - - Ajouter au moins 1 document - - Cliquer sur "Envoyer la demande" - -3. **Dans les logs, vous devriez voir**: - ``` - === CSV Booking Request Debug === - req.user: { id: '...', organizationId: '...' } - req.body: { carrierName: 'Test Carrier', ... } - files: 1 - ================================ - Creating CSV booking for user ... - Uploaded 1 documents for booking ... - CSV booking created with ID: ... - Email sent to carrier: carrier@test.com - Notification created for user ... - ``` - -4. **Si vous NE voyez PAS ces logs** → La requĂȘte n'arrive pas au backend. VĂ©rifier: - - Frontend connectĂ© et JWT valide - - Backend en cours d'exĂ©cution - - Network tab du navigateur pour voir l'erreur exacte - ---- - -### Test 2: VĂ©rifier le Browser Console - -1. **Ouvrir DevTools** (F12) -2. **Aller dans Console** -3. **CrĂ©er un booking** -4. **Regarder les erreurs**: - - Si erreur affichĂ©e → noter le message exact - - Si aucune erreur → le problĂšme est silencieux (voir Network tab) - ---- - -### Test 3: VĂ©rifier Network Tab - -1. **Ouvrir DevTools** (F12) -2. **Aller dans Network** -3. **CrĂ©er un booking** -4. **Trouver la requĂȘte** `POST /api/v1/csv-bookings` -5. **VĂ©rifier**: - - Status: Doit ĂȘtre 200 ou 201 - - Request Payload: Tous les champs prĂ©sents? - - Response: Message d'erreur? - ---- - -## 🔧 Solutions par Erreur - -### Erreur: "At least one document is required" -**Cause**: Aucun fichier n'a Ă©tĂ© uploadĂ© - -**Solution**: -- VĂ©rifier que vous avez bien sĂ©lectionnĂ© au moins 1 fichier -- VĂ©rifier que le fichier est dans les formats acceptĂ©s (PDF, DOC, DOCX, JPG, PNG) -- VĂ©rifier que le fichier fait moins de 5MB - ---- - -### Erreur: "User authentication failed" -**Cause**: Token JWT invalide ou expirĂ© - -**Solution**: -1. Se dĂ©connecter -2. Se reconnecter -3. RĂ©essayer - ---- - -### Erreur: "Organization ID is required" -**Cause**: L'utilisateur n'a pas d'organizationId - -**Solution**: -1. VĂ©rifier dans la base de donnĂ©es que l'utilisateur a bien un `organizationId` -2. Si non, assigner une organization Ă  l'utilisateur - ---- - -### Erreur: S3/MinIO Upload Failed -**Cause**: Impossible d'uploader vers MinIO - -**Solution**: -```bash -# VĂ©rifier que MinIO tourne -docker ps | grep minio - -# Si non, le dĂ©marrer -docker-compose up -d - -# VĂ©rifier que le bucket existe -cd apps/backend -node setup-minio-bucket.js -``` - ---- - -### Erreur: Email Failed (ne devrait plus arriver) -**Cause**: Envoi email Ă©chouĂ© - -**Solution**: -- VĂ©rifier que les variables SMTP sont dans le schĂ©ma Joi (dĂ©jĂ  corrigĂ© ✅) -- Tester l'envoi d'email: `node test-smtp-simple.js` - ---- - -## 📊 Checklist de Diagnostic - -Cocher au fur et Ă  mesure: - -- [ ] Backend en cours d'exĂ©cution (port 4000) -- [ ] Frontend en cours d'exĂ©cution (port 3000) -- [ ] MinIO en cours d'exĂ©cution (port 9000) -- [ ] Bucket 'xpeditis-documents' existe -- [ ] Variables SMTP configurĂ©es -- [ ] Email adapter initialisĂ© (logs backend) -- [ ] Utilisateur connectĂ© au frontend -- [ ] Token JWT valide (pas expirĂ©) -- [ ] Browser console sans erreurs -- [ ] Network tab montre requĂȘte POST envoyĂ©e -- [ ] Logs backend montrent "CSV Booking Request Debug" -- [ ] Documents uploadĂ©s (au moins 1) -- [ ] Port codes valides (5 caractĂšres exactement) -- [ ] Email transporteur valide - ---- - -## 🚀 Commandes Utiles - -```bash -# RedĂ©marrer backend -cd apps/backend -npm run dev - -# VĂ©rifier logs backend -tail -f /tmp/backend-startup.log | grep -i "csv\|booking\|error" - -# Tester email -cd apps/backend -node test-smtp-simple.js - -# VĂ©rifier MinIO -docker ps | grep minio -node setup-minio-bucket.js - -# Voir tous les endpoints -curl http://localhost:4000/api/docs -``` - ---- - -## 📝 Prochaines Étapes - -1. **Effectuer les tests** ci-dessus dans l'ordre -2. **Noter l'erreur exacte** qui apparaĂźt (console, network, logs) -3. **Appliquer la solution** correspondante -4. **RĂ©essayer** - -Si aprĂšs tous ces tests le problĂšme persiste, partager: -- Le message d'erreur exact (browser console) -- Les logs backend au moment de l'erreur -- Le status code HTTP de la requĂȘte (network tab) - ---- - -**DerniĂšre mise Ă  jour**: 5 dĂ©cembre 2025 -**Statut**: -- ✅ Email fix appliquĂ© -- ✅ MinIO bucket vĂ©rifiĂ© -- ✅ Code analysĂ© -- ⏳ En attente de tests utilisateur diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 19371af..93a4dc9 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -59,7 +59,7 @@ COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./ COPY --from=builder --chown=nestjs:nodejs /app/src ./src # Copy startup script (includes migrations) -COPY --chown=nestjs:nodejs startup.js ./startup.js +COPY --chown=nestjs:nodejs scripts/setup/startup.js ./startup.js # Create logs and uploads directories RUN mkdir -p /app/logs && \ diff --git a/apps/backend/EMAIL_CARRIER_FIX_COMPLETE.md b/apps/backend/EMAIL_CARRIER_FIX_COMPLETE.md deleted file mode 100644 index feab54f..0000000 --- a/apps/backend/EMAIL_CARRIER_FIX_COMPLETE.md +++ /dev/null @@ -1,386 +0,0 @@ -# ✅ CORRECTION COMPLÈTE - Envoi d'Email aux Transporteurs - -**Date**: 5 dĂ©cembre 2025 -**Statut**: ✅ **CORRIGÉ** - ---- - -## 🔍 ProblĂšme IdentifiĂ© - -**SymptĂŽme**: Les emails ne sont plus envoyĂ©s aux transporteurs lors de la crĂ©ation de bookings CSV. - -**Cause Racine**: -Le fix DNS implĂ©mentĂ© dans `EMAIL_FIX_SUMMARY.md` n'Ă©tait **PAS appliquĂ©** dans le code actuel de `email.adapter.ts`. Le code utilisait la configuration standard sans contournement DNS, ce qui causait des timeouts sur certains rĂ©seaux. - -```typescript -// ❌ CODE PROBLÉMATIQUE (avant correction) -this.transporter = nodemailer.createTransport({ - host, // ← utilisait directement 'sandbox.smtp.mailtrap.io' sans contournement DNS - port, - secure, - auth: { user, pass }, -}); -``` - ---- - -## ✅ Solution ImplĂ©mentĂ©e - -### 1. **Correction de `email.adapter.ts`** (Lignes 25-63) - -**Fichier modifiĂ©**: `src/infrastructure/email/email.adapter.ts` - -```typescript -private initializeTransporter(): void { - const host = this.configService.get('SMTP_HOST', 'localhost'); - const port = this.configService.get('SMTP_PORT', 2525); - const user = this.configService.get('SMTP_USER'); - const pass = this.configService.get('SMTP_PASS'); - const secure = this.configService.get('SMTP_SECURE', false); - - // 🔧 FIX: Contournement DNS pour Mailtrap - // Utilise automatiquement l'IP directe quand 'mailtrap.io' est dĂ©tectĂ© - const useDirectIP = host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS - - this.transporter = nodemailer.createTransport({ - host: actualHost, // ← Utilise IP directe pour Mailtrap - port, - secure, - auth: { user, pass }, - tls: { - rejectUnauthorized: false, - servername: serverName, // ⚠ CRITIQUE pour TLS avec IP directe - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, - }); - - this.logger.log( - `Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` + - (useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '') - ); -} -``` - -**Changements clĂ©s**: -- ✅ DĂ©tection automatique de `mailtrap.io` dans le hostname -- ✅ Utilisation de l'IP directe `3.209.246.195` au lieu du DNS -- ✅ Configuration TLS avec `servername` pour validation du certificat -- ✅ Timeouts optimisĂ©s (10s connection, 30s socket) -- ✅ Logs dĂ©taillĂ©s pour debug - -### 2. **VĂ©rification du comportement synchrone** - -**Fichier vĂ©rifiĂ©**: `src/application/services/csv-booking.service.ts` (Lignes 111-136) - -Le code utilise **dĂ©jĂ ** le comportement synchrone correct avec `await`: - -```typescript -// ✅ CODE CORRECT (comportement synchrone) -try { - await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { - bookingId, - origin: dto.origin, - destination: dto.destination, - // ... autres donnĂ©es - 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 already saved -} -``` - -**Important**: L'email est envoyĂ© de maniĂšre **synchrone** - le bouton attend la confirmation d'envoi avant de rĂ©pondre. - ---- - -## đŸ§Ș Tests de Validation - -### Test 1: Script de Test Nodemailer - -Un script de test complet a Ă©tĂ© créé pour valider les 3 configurations : - -```bash -cd apps/backend -node test-carrier-email-fix.js -``` - -**Ce script teste**: -1. ❌ **Test 1**: Configuration standard (peut Ă©chouer avec timeout DNS) -2. ✅ **Test 2**: Configuration avec IP directe (doit rĂ©ussir) -3. ✅ **Test 3**: Email complet avec template HTML (doit rĂ©ussir) - -**RĂ©sultat attendu**: -```bash -✅ Test 2 RÉUSSI - Configuration IP directe OK - Message ID: - Response: 250 2.0.0 Ok: queued - -✅ Test 3 RÉUSSI - Email complet avec template envoyĂ© - Message ID: - Response: 250 2.0.0 Ok: queued -``` - -### Test 2: RedĂ©marrage du Backend - -**IMPORTANT**: Le backend DOIT ĂȘtre redĂ©marrĂ© pour appliquer les changements. - -```bash -# 1. Tuer tous les processus backend -lsof -ti:4000 | xargs -r kill -9 - -# 2. RedĂ©marrer proprement -cd apps/backend -npm run dev -``` - -**Logs attendus au dĂ©marrage**: -```bash -✅ Email adapter initialized with SMTP host: sandbox.smtp.mailtrap.io:2525 (secure: false) [Using direct IP: 3.209.246.195 with servername: smtp.mailtrap.io] -``` - -### Test 3: Test End-to-End avec API - -**PrĂ©requis**: -- Backend dĂ©marrĂ© -- Frontend dĂ©marrĂ© (optionnel) -- Compte Mailtrap configurĂ© - -**ScĂ©nario de test**: - -1. **CrĂ©er un booking CSV** via API ou Frontend - -```bash -# Via API (Postman/cURL) -POST http://localhost:4000/api/v1/csv-bookings -Authorization: Bearer -Content-Type: multipart/form-data - -DonnĂ©es: -- carrierName: "Test Carrier" -- carrierEmail: "carrier@test.com" -- origin: "FRPAR" -- destination: "USNYC" -- volumeCBM: 10 -- weightKG: 500 -- palletCount: 2 -- priceUSD: 1500 -- priceEUR: 1350 -- primaryCurrency: "USD" -- transitDays: 15 -- containerType: "20FT" -- notes: "Test booking" -- files: [bill_of_lading.pdf, packing_list.pdf] -``` - -2. **VĂ©rifier les logs backend**: - -```bash -# SuccĂšs attendu -✅ [CsvBookingService] Creating CSV booking for user -✅ [CsvBookingService] Uploaded 2 documents for booking -✅ [CsvBookingService] CSV booking created with ID: -✅ [EmailAdapter] Email sent to carrier@test.com: Nouvelle demande de rĂ©servation - FRPAR → USNYC -✅ [CsvBookingService] Email sent to carrier: carrier@test.com -✅ [CsvBookingService] Notification created for user -``` - -3. **VĂ©rifier Mailtrap Inbox**: - - Connexion: https://mailtrap.io/inboxes - - Rechercher: "Nouvelle demande de rĂ©servation - FRPAR → USNYC" - - VĂ©rifier: Email avec template HTML complet, boutons Accepter/Refuser - ---- - -## 📊 Comparaison Avant/AprĂšs - -| CritĂšre | ❌ Avant (CassĂ©) | ✅ AprĂšs (CorrigĂ©) | -|---------|------------------|-------------------| -| **Envoi d'emails** | 0% (timeout DNS) | 100% (IP directe) | -| **Temps de rĂ©ponse API** | ~10s (timeout) | ~2s (normal) | -| **Logs d'erreur** | `queryA ETIMEOUT` | Aucune erreur | -| **Configuration requise** | DNS fonctionnel | Fonctionne partout | -| **Messages reçus** | Aucun | Tous les emails | - ---- - -## 🔧 Configuration Environnement - -### DĂ©veloppement (`.env` actuel) - -```bash -SMTP_HOST=sandbox.smtp.mailtrap.io # ← DĂ©tectĂ© automatiquement -SMTP_PORT=2525 -SMTP_SECURE=false -SMTP_USER=2597bd31d265eb -SMTP_PASS=cd126234193c89 -SMTP_FROM=noreply@xpeditis.com -``` - -**Note**: Le code dĂ©tecte automatiquement `mailtrap.io` et utilise l'IP directe. - -### Production (Recommandations) - -#### Option 1: Mailtrap Production - -```bash -SMTP_HOST=smtp.mailtrap.io # ← Le code utilisera l'IP directe automatiquement -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_USER= -SMTP_PASS= -``` - -#### Option 2: SendGrid - -```bash -SMTP_HOST=smtp.sendgrid.net # ← Pas de contournement DNS nĂ©cessaire -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=apikey -SMTP_PASS= -``` - -#### Option 3: AWS SES - -```bash -SMTP_HOST=email-smtp.us-east-1.amazonaws.com -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER= -SMTP_PASS= -``` - ---- - -## 🐛 DĂ©pannage - -### ProblĂšme 1: "Email sent" dans les logs mais rien dans Mailtrap - -**Cause**: Credentials incorrects ou mauvaise inbox -**Solution**: -1. VĂ©rifier `SMTP_USER` et `SMTP_PASS` dans `.env` -2. RĂ©gĂ©nĂ©rer les credentials sur https://mailtrap.io -3. VĂ©rifier la bonne inbox (Development, Staging, Production) - -### ProblĂšme 2: "queryA ETIMEOUT" persiste aprĂšs correction - -**Cause**: Backend pas redĂ©marrĂ© ou code pas compilĂ© -**Solution**: -```bash -# Tuer tous les backends -lsof -ti:4000 | xargs -r kill -9 - -# Nettoyer et redĂ©marrer -cd apps/backend -rm -rf dist/ -npm run build -npm run dev -``` - -### ProblĂšme 3: "EAUTH" authentication failed - -**Cause**: Credentials Mailtrap invalides ou expirĂ©s -**Solution**: -1. Se connecter Ă  https://mailtrap.io -2. Aller dans Email Testing > Inboxes > -3. Copier les nouveaux credentials (SMTP Settings) -4. Mettre Ă  jour `.env` et redĂ©marrer - -### ProblĂšme 4: Email reçu mais template cassĂ© - -**Cause**: Template HTML mal formatĂ© ou variables manquantes -**Solution**: -1. VĂ©rifier les logs pour les donnĂ©es envoyĂ©es -2. VĂ©rifier que toutes les variables sont prĂ©sentes dans `bookingData` -3. Tester le template avec `test-carrier-email-fix.js` - ---- - -## ✅ Checklist de Validation Finale - -Avant de dĂ©clarer le problĂšme rĂ©solu, vĂ©rifier: - -- [x] `email.adapter.ts` corrigĂ© avec contournement DNS -- [x] Script de test `test-carrier-email-fix.js` créé -- [x] Configuration `.env` vĂ©rifiĂ©e (SMTP_HOST, USER, PASS) -- [ ] Backend redĂ©marrĂ© avec logs confirmant IP directe -- [ ] Test nodemailer rĂ©ussi (Test 2 et 3) -- [ ] Test end-to-end: crĂ©ation de booking CSV -- [ ] Email reçu dans Mailtrap inbox -- [ ] Template HTML complet et boutons fonctionnels -- [ ] Logs backend sans erreur `ETIMEOUT` -- [ ] Notification créée pour l'utilisateur - ---- - -## 📝 Fichiers ModifiĂ©s - -| Fichier | Lignes | Description | -|---------|--------|-------------| -| `src/infrastructure/email/email.adapter.ts` | 25-63 | ✅ Contournement DNS avec IP directe | -| `test-carrier-email-fix.js` | 1-285 | đŸ§Ș Script de test email (nouveau) | -| `EMAIL_CARRIER_FIX_COMPLETE.md` | 1-xxx | 📄 Documentation correction (ce fichier) | - -**Fichiers vĂ©rifiĂ©s** (code correct): -- ✅ `src/application/services/csv-booking.service.ts` (comportement synchrone avec `await`) -- ✅ `src/infrastructure/email/templates/email-templates.ts` (template `renderCsvBookingRequest` existe) -- ✅ `src/infrastructure/email/email.module.ts` (module correctement configurĂ©) -- ✅ `src/domain/ports/out/email.port.ts` (mĂ©thode `sendCsvBookingRequest` dĂ©finie) - ---- - -## 🎉 RĂ©sultat Final - -### ✅ ProblĂšme RÉSOLU Ă  100% - -**Ce qui fonctionne maintenant**: -1. ✅ Emails aux transporteurs envoyĂ©s sans timeout DNS -2. ✅ Template HTML complet avec boutons Accepter/Refuser -3. ✅ Logs dĂ©taillĂ©s pour debugging -4. ✅ Configuration robuste (fonctionne mĂȘme si DNS lent) -5. ✅ Compatible avec n'importe quel fournisseur SMTP -6. ✅ Notifications utilisateur créées -7. ✅ Comportement synchrone (le bouton attend l'email) - -**Performance**: -- Temps d'envoi: **< 2s** (au lieu de 10s timeout) -- Taux de succĂšs: **100%** (au lieu de 0%) -- CompatibilitĂ©: **Tous rĂ©seaux** (mĂȘme avec DNS lent) - ---- - -## 🚀 Prochaines Étapes - -1. **Tester immĂ©diatement**: - ```bash - # 1. Test nodemailer - node apps/backend/test-carrier-email-fix.js - - # 2. RedĂ©marrer backend - lsof -ti:4000 | xargs -r kill -9 - cd apps/backend && npm run dev - - # 3. CrĂ©er un booking CSV via frontend ou API - ``` - -2. **VĂ©rifier Mailtrap**: https://mailtrap.io/inboxes - -3. **Si tout fonctionne**: ✅ Fermer le ticket - -4. **Si problĂšme persiste**: - - Copier les logs complets - - ExĂ©cuter `test-carrier-email-fix.js` et copier la sortie - - Partager pour debug supplĂ©mentaire - ---- - -**PrĂȘt pour la production** 🚱✹ - -_Correction effectuĂ©e le 5 dĂ©cembre 2025 par Claude Code_ diff --git a/apps/backend/EMAIL_FIX_FINAL.md b/apps/backend/EMAIL_FIX_FINAL.md deleted file mode 100644 index 61440fd..0000000 --- a/apps/backend/EMAIL_FIX_FINAL.md +++ /dev/null @@ -1,275 +0,0 @@ -# ✅ EMAIL FIX COMPLETE - ROOT CAUSE RESOLVED - -**Date**: 5 dĂ©cembre 2025 -**Statut**: ✅ **RÉSOLU ET TESTÉ** - ---- - -## 🎯 ROOT CAUSE IDENTIFIÉE - -**ProblĂšme**: Les emails aux transporteurs ne s'envoyaient plus aprĂšs l'implĂ©mentation du Carrier Portal. - -**Cause Racine**: Les variables d'environnement SMTP n'Ă©taient **PAS dĂ©clarĂ©es** dans le schĂ©ma de validation Joi de ConfigModule (`app.module.ts`). - -### Pourquoi c'Ă©tait cassĂ©? - -NestJS ConfigModule avec un `validationSchema` Joi **supprime automatiquement** toutes les variables d'environnement qui ne sont pas explicitement dĂ©clarĂ©es dans le schĂ©ma. Le schĂ©ma original (lignes 36-50 de `app.module.ts`) ne contenait que: - -```typescript -validationSchema: Joi.object({ - NODE_ENV: Joi.string()... - PORT: Joi.number()... - DATABASE_HOST: Joi.string()... - REDIS_HOST: Joi.string()... - JWT_SECRET: Joi.string()... - // ❌ AUCUNE VARIABLE SMTP DÉCLARÉE! -}) -``` - -RĂ©sultat: -- `SMTP_HOST` → undefined -- `SMTP_PORT` → undefined -- `SMTP_USER` → undefined -- `SMTP_PASS` → undefined -- `SMTP_FROM` → undefined -- `SMTP_SECURE` → undefined - -L'email adapter tentait alors de se connecter Ă  `localhost:2525` au lieu de Mailtrap, causant des erreurs `ECONNREFUSED`. - ---- - -## ✅ SOLUTION IMPLÉMENTÉE - -### 1. Ajout des variables SMTP au schĂ©ma de validation - -**Fichier modifiĂ©**: `apps/backend/src/app.module.ts` (lignes 50-56) - -```typescript -ConfigModule.forRoot({ - isGlobal: true, - validationSchema: Joi.object({ - // ... variables existantes ... - - // ✅ NOUVEAU: SMTP Configuration - SMTP_HOST: Joi.string().required(), - SMTP_PORT: Joi.number().default(2525), - SMTP_USER: Joi.string().required(), - SMTP_PASS: Joi.string().required(), - SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'), - SMTP_SECURE: Joi.boolean().default(false), - }), -}), -``` - -**Changements**: -- ✅ Ajout de 6 variables SMTP au schĂ©ma Joi -- ✅ `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` requis -- ✅ `SMTP_PORT` avec default 2525 -- ✅ `SMTP_FROM` avec validation email -- ✅ `SMTP_SECURE` avec default false - -### 2. DNS Fix (DĂ©jĂ  prĂ©sent) - -Le DNS fix dans `email.adapter.ts` (lignes 42-45) Ă©tait dĂ©jĂ  correct depuis la correction prĂ©cĂ©dente: - -```typescript -const useDirectIP = host.includes('mailtrap.io'); -const actualHost = useDirectIP ? '3.209.246.195' : host; -const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; -``` - ---- - -## đŸ§Ș TESTS DE VALIDATION - -### Test 1: Backend Logs ✅ - -```bash -[2025-12-05 13:24:59.567] INFO: Email adapter initialized with SMTP host: sandbox.smtp.mailtrap.io:2525 (secure: false) [Using direct IP: 3.209.246.195 with servername: smtp.mailtrap.io] -``` - -**VĂ©rification**: -- ✅ Host: sandbox.smtp.mailtrap.io:2525 -- ✅ Using direct IP: 3.209.246.195 -- ✅ Servername: smtp.mailtrap.io -- ✅ Secure: false - -### Test 2: SMTP Simple Test ✅ - -```bash -$ node test-smtp-simple.js - -Configuration: - SMTP_HOST: sandbox.smtp.mailtrap.io ✅ - SMTP_PORT: 2525 ✅ - SMTP_USER: 2597bd31d265eb ✅ - SMTP_PASS: *** ✅ - -Test 1: VĂ©rification de la connexion... -✅ Connexion SMTP OK - -Test 2: Envoi d'un email... -✅ Email envoyĂ© avec succĂšs! - Message ID: - Response: 250 2.0.0 Ok: queued - -✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne! -``` - -### Test 3: Email Flow Complet ✅ - -```bash -$ node debug-email-flow.js - -📊 RÉSUMÉ DES TESTS: -Connexion SMTP: ✅ OK -Email simple: ✅ OK -Email transporteur: ✅ OK - -✅ TOUS LES TESTS ONT RÉUSSI! - Le systĂšme d'envoi d'email fonctionne correctement. -``` - ---- - -## 📊 Avant/AprĂšs - -| CritĂšre | ❌ Avant | ✅ AprĂšs | -|---------|----------|----------| -| **Variables SMTP** | undefined | ChargĂ©es correctement | -| **Connexion SMTP** | ECONNREFUSED ::1:2525 | ConnectĂ© Ă  3.209.246.195:2525 | -| **Envoi email** | 0% (Ă©chec) | 100% (succĂšs) | -| **Backend logs** | Pas d'init SMTP | "Email adapter initialized" | -| **Test scripts** | Tous Ă©chouent | Tous rĂ©ussissent | - ---- - -## 🚀 VÉRIFICATION END-TO-END - -Le backend est dĂ©jĂ  dĂ©marrĂ© et fonctionnel. Pour tester le flux complet de crĂ©ation de booking avec envoi d'email: - -### Option 1: Via l'interface web - -1. Ouvrir http://localhost:3000 -2. Se connecter -3. CrĂ©er un CSV booking avec l'email d'un transporteur -4. VĂ©rifier les logs backend: - ``` - ✅ [CsvBookingService] Email sent to carrier: carrier@example.com - ``` -5. VĂ©rifier Mailtrap: https://mailtrap.io/inboxes - -### Option 2: Via API (cURL/Postman) - -```bash -POST http://localhost:4000/api/v1/csv-bookings -Authorization: Bearer -Content-Type: multipart/form-data - -{ - "carrierName": "Test Carrier", - "carrierEmail": "carrier@test.com", - "origin": "FRPAR", - "destination": "USNYC", - "volumeCBM": 10, - "weightKG": 500, - "palletCount": 2, - "priceUSD": 1500, - "primaryCurrency": "USD", - "transitDays": 15, - "containerType": "20FT", - "files": [attachment] -} -``` - -**Logs attendus**: -``` -✅ [CsvBookingService] Creating CSV booking for user -✅ [CsvBookingService] Uploaded 2 documents for booking -✅ [CsvBookingService] CSV booking created with ID: -✅ [EmailAdapter] Email sent to carrier@test.com -✅ [CsvBookingService] Email sent to carrier: carrier@test.com -``` - ---- - -## 📝 Fichiers ModifiĂ©s - -| Fichier | Lignes | Changement | -|---------|--------|------------| -| `apps/backend/src/app.module.ts` | 50-56 | ✅ Ajout variables SMTP au schĂ©ma Joi | -| `apps/backend/src/infrastructure/email/email.adapter.ts` | 42-65 | ✅ DNS fix (dĂ©jĂ  prĂ©sent) | - ---- - -## 🎉 RÉSULTAT FINAL - -### ✅ ProblĂšme RÉSOLU Ă  100% - -**Ce qui fonctionne**: -1. ✅ Variables SMTP chargĂ©es depuis `.env` -2. ✅ Email adapter s'initialise correctement -3. ✅ Connexion SMTP avec DNS bypass (IP directe) -4. ✅ Envoi d'emails simples rĂ©ussi -5. ✅ Envoi d'emails avec template HTML rĂ©ussi -6. ✅ Backend dĂ©marre sans erreur -7. ✅ Tous les tests passent - -**Performance**: -- Temps d'envoi: **< 2s** -- Taux de succĂšs: **100%** -- CompatibilitĂ©: **Tous rĂ©seaux** - ---- - -## 🔧 Commandes Utiles - -### VĂ©rifier le backend - -```bash -# Voir les logs en temps rĂ©el -tail -f /tmp/backend-startup.log - -# VĂ©rifier que le backend tourne -lsof -i:4000 - -# RedĂ©marrer le backend -lsof -ti:4000 | xargs -r kill -9 -cd apps/backend && npm run dev -``` - -### Tester l'envoi d'emails - -```bash -# Test SMTP simple -cd apps/backend -node test-smtp-simple.js - -# Test complet avec template -node debug-email-flow.js -``` - ---- - -## ✅ Checklist de Validation - -- [x] ConfigModule validation schema updated -- [x] SMTP variables added to Joi schema -- [x] Backend redĂ©marrĂ© avec succĂšs -- [x] Backend logs show "Email adapter initialized" -- [x] Test SMTP simple rĂ©ussi -- [x] Test email flow complet rĂ©ussi -- [x] Environment variables loading correctly -- [x] DNS bypass actif (direct IP) -- [ ] Test end-to-end via crĂ©ation de booking (Ă  faire par l'utilisateur) -- [ ] Email reçu dans Mailtrap (Ă  vĂ©rifier par l'utilisateur) - ---- - -**PrĂȘt pour la production** 🚱✹ - -_Correction effectuĂ©e le 5 dĂ©cembre 2025 par Claude Code_ - -**Backend Status**: ✅ Running on port 4000 -**Email System**: ✅ Fully functional -**Next Step**: Create a CSV booking to test the complete workflow diff --git a/apps/backend/EMAIL_FIX_SUMMARY.md b/apps/backend/EMAIL_FIX_SUMMARY.md deleted file mode 100644 index 56d9be4..0000000 --- a/apps/backend/EMAIL_FIX_SUMMARY.md +++ /dev/null @@ -1,295 +0,0 @@ -# 📧 RĂ©solution ComplĂšte du ProblĂšme d'Envoi d'Emails - -## 🔍 ProblĂšme IdentifiĂ© - -**SymptĂŽme**: Les emails n'Ă©taient plus envoyĂ©s aux transporteurs lors de la crĂ©ation de rĂ©servations CSV. - -**Cause Racine**: Changement du comportement d'envoi d'email de SYNCHRONE Ă  ASYNCHRONE -- Le code original utilisait `await` pour attendre l'envoi de l'email avant de rĂ©pondre -- J'ai tentĂ© d'optimiser avec `setImmediate()` et `void` operator (fire-and-forget) -- **ERREUR**: L'utilisateur VOULAIT le comportement synchrone oĂč le bouton attend la confirmation d'envoi -- Les emails n'Ă©taient plus envoyĂ©s car le contexte d'exĂ©cution Ă©tait perdu avec les appels asynchrones - -## ✅ Solution ImplĂ©mentĂ©e - -### **Restauration du comportement SYNCHRONE** ✹ SOLUTION FINALE -**Fichiers modifiĂ©s**: -- `src/application/services/csv-booking.service.ts` (lignes 111-136) -- `src/application/services/carrier-auth.service.ts` (lignes 110-117, 287-294) -- `src/infrastructure/email/email.adapter.ts` (configuration simplifiĂ©e) - -```typescript -// Utilise automatiquement l'IP 3.209.246.195 quand 'mailtrap.io' est dĂ©tectĂ© -const useDirectIP = host.includes('mailtrap.io'); -const actualHost = useDirectIP ? '3.209.246.195' : host; -const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS - -// Configuration avec IP directe + servername pour TLS -this.transporter = nodemailer.createTransport({ - host: actualHost, - port, - secure: false, - auth: { user, pass }, - tls: { - rejectUnauthorized: false, - servername: serverName, // ⚠ CRITIQUE pour TLS - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, -}); -``` - -**RĂ©sultat**: ✅ Test rĂ©ussi - Email envoyĂ© avec succĂšs (Message ID: `576597e7-1a81-165d-2a46-d97c57d21daa`) - ---- - -### 2. **Remplacement de `setImmediate()` par `void` operator** -**Fichiers ModifiĂ©s**: -- `src/application/services/csv-booking.service.ts` (ligne 114) -- `src/application/services/carrier-auth.service.ts` (lignes 112, 290) - -**Avant** (bloquant): -```typescript -setImmediate(() => { - this.emailAdapter.sendCsvBookingRequest(...) - .then(() => { ... }) - .catch(() => { ... }); -}); -``` - -**AprĂšs** (non-bloquant mais avec contexte): -```typescript -void this.emailAdapter.sendCsvBookingRequest(...) - .then(() => { - 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); - }); -``` - -**BĂ©nĂ©fices**: -- ✅ RĂ©ponse API ~50% plus rapide (pas d'attente d'envoi) -- ✅ Logs des erreurs d'envoi prĂ©servĂ©s -- ✅ Contexte NestJS maintenu (pas de perte de dĂ©pendances) - ---- - -### 3. **Configuration `.env` Mise Ă  Jour** -**Fichier**: `.env` - -```bash -# Email (SMTP) -# Using smtp.mailtrap.io instead of sandbox.smtp.mailtrap.io to avoid DNS timeout -SMTP_HOST=smtp.mailtrap.io # ← ChangĂ© -SMTP_PORT=2525 -SMTP_SECURE=false -SMTP_USER=2597bd31d265eb -SMTP_PASS=cd126234193c89 -SMTP_FROM=noreply@xpeditis.com -``` - ---- - -### 4. **Ajout des MĂ©thodes d'Email Transporteur** -**Fichier**: `src/domain/ports/out/email.port.ts` - -Ajout de 2 nouvelles mĂ©thodes Ă  l'interface: -- `sendCarrierAccountCreated()` - Email de crĂ©ation de compte avec mot de passe temporaire -- `sendCarrierPasswordReset()` - Email de rĂ©initialisation de mot de passe - -**ImplĂ©mentation**: `src/infrastructure/email/email.adapter.ts` (lignes 269-413) -- Templates HTML en français -- Boutons d'action stylisĂ©s -- Warnings de sĂ©curitĂ© -- Instructions de connexion - ---- - -## 📋 Fichiers ModifiĂ©s (RĂ©capitulatif) - -| Fichier | Lignes | Description | -|---------|--------|-------------| -| `infrastructure/email/email.adapter.ts` | 25-63 | ✹ Contournement DNS avec IP directe | -| `infrastructure/email/email.adapter.ts` | 269-413 | MĂ©thodes emails transporteur | -| `application/services/csv-booking.service.ts` | 114-137 | `void` operator pour emails async | -| `application/services/carrier-auth.service.ts` | 112-118 | `void` operator (crĂ©ation compte) | -| `application/services/carrier-auth.service.ts` | 290-296 | `void` operator (reset password) | -| `domain/ports/out/email.port.ts` | 107-123 | Interface mĂ©thodes transporteur | -| `.env` | 42 | Changement SMTP_HOST | - ---- - -## đŸ§Ș Tests de Validation - -### Test 1: Backend RedĂ©marrĂ© avec SuccĂšs ✅ **RÉUSSI** -```bash -# Tuer tous les processus sur port 4000 -lsof -ti:4000 | xargs kill -9 - -# DĂ©marrer le backend proprement -npm run dev -``` - -**RĂ©sultat**: -``` -✅ Email adapter initialized with SMTP host: sandbox.smtp.mailtrap.io:2525 (secure: false) -✅ Nest application successfully started -✅ Connected to Redis at localhost:6379 -🚱 Xpeditis API Server Running on http://localhost:4000 -``` - -### Test 2: Test d'Envoi d'Email (À faire par l'utilisateur) -1. ✅ Backend dĂ©marrĂ© avec configuration correcte -2. CrĂ©er une rĂ©servation CSV avec transporteur via API -3. VĂ©rifier les logs pour: `Email sent to carrier: [email]` -4. VĂ©rifier Mailtrap inbox: https://mailtrap.io/inboxes - ---- - -## 🎯 Comment Tester en Production - -### Étape 1: CrĂ©er une RĂ©servation CSV -```bash -POST http://localhost:4000/api/v1/csv-bookings -Content-Type: multipart/form-data - -{ - "carrierName": "Test Carrier", - "carrierEmail": "test@example.com", - "origin": "FRPAR", - "destination": "USNYC", - "volumeCBM": 10, - "weightKG": 500, - "palletCount": 2, - "priceUSD": 1500, - "priceEUR": 1300, - "primaryCurrency": "USD", - "transitDays": 15, - "containerType": "20FT", - "notes": "Test booking" -} -``` - -### Étape 2: VĂ©rifier les Logs -Rechercher dans les logs backend: -```bash -# SuccĂšs -✅ "Email sent to carrier: test@example.com" -✅ "CSV booking request sent to test@example.com for booking " - -# Échec (ne devrait plus arriver) -❌ "Failed to send email to carrier: queryA ETIMEOUT" -``` - -### Étape 3: VĂ©rifier Mailtrap -1. Connexion: https://mailtrap.io -2. Inbox: "Xpeditis Development" -3. Email: "Nouvelle demande de rĂ©servation - FRPAR → USNYC" - ---- - -## 📊 Performance - -### Avant (ProblĂšme) -- ❌ Emails: **0% envoyĂ©s** (timeout DNS) -- ⏱ Temps rĂ©ponse API: ~500ms + timeout (10s) -- ❌ Logs: Erreurs `queryA ETIMEOUT` - -### AprĂšs (CorrigĂ©) -- ✅ Emails: **100% envoyĂ©s** (IP directe) -- ⏱ Temps rĂ©ponse API: ~200-300ms (async fire-and-forget) -- ✅ Logs: `Email sent to carrier:` -- 📧 Latence email: <2s (Mailtrap) - ---- - -## 🔧 Configuration Production - -Pour le dĂ©ploiement production, mettre Ă  jour `.env`: - -```bash -# Option 1: Utiliser smtp.mailtrap.io (IP auto) -SMTP_HOST=smtp.mailtrap.io -SMTP_PORT=2525 -SMTP_SECURE=false - -# Option 2: Autre fournisseur SMTP (ex: SendGrid) -SMTP_HOST=smtp.sendgrid.net -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=apikey -SMTP_PASS= -``` - -**Note**: Le code dĂ©tecte automatiquement `mailtrap.io` et utilise l'IP. Pour d'autres fournisseurs, le DNS standard sera utilisĂ©. - ---- - -## 🐛 DĂ©pannage - -### ProblĂšme: "Email sent" dans les logs mais rien dans Mailtrap -**Cause**: Mauvais credentials ou inbox -**Solution**: VĂ©rifier `SMTP_USER` et `SMTP_PASS` dans `.env` - -### ProblĂšme: "queryA ETIMEOUT" persiste -**Cause**: Backend pas redĂ©marrĂ© ou code pas compilĂ© -**Solution**: -```bash -# 1. Tuer tous les backends -lsof -ti:4000 | xargs kill -9 - -# 2. RedĂ©marrer proprement -cd apps/backend -npm run dev -``` - -### ProblĂšme: "EAUTH" authentication failed -**Cause**: Credentials Mailtrap invalides -**Solution**: RĂ©gĂ©nĂ©rer les credentials sur https://mailtrap.io - ---- - -## ✅ Checklist de Validation - -- [x] MĂ©thodes `sendCarrierAccountCreated` et `sendCarrierPasswordReset` implĂ©mentĂ©es -- [x] Comportement SYNCHRONE restaurĂ© avec `await` (au lieu de setImmediate/void) -- [x] Configuration SMTP simplifiĂ©e (pas de contournement DNS nĂ©cessaire) -- [x] `.env` mis Ă  jour avec `sandbox.smtp.mailtrap.io` -- [x] Backend redĂ©marrĂ© proprement -- [x] Email adapter initialisĂ© avec bonne configuration -- [x] Server Ă©coute sur port 4000 -- [x] Redis connectĂ© -- [ ] Test end-to-end avec crĂ©ation CSV booking ← **À TESTER PAR L'UTILISATEUR** -- [ ] Email reçu dans Mailtrap inbox ← **À VALIDER PAR L'UTILISATEUR** - ---- - -## 📝 Notes Techniques - -### Pourquoi l'IP Directe Fonctionne ? -Node.js utilise `dns.resolve()` qui peut timeout mĂȘme si le systĂšme DNS fonctionne. En utilisant l'IP directe, on contourne complĂštement la rĂ©solution DNS. - -### Pourquoi `servername` dans TLS ? -Quand on utilise une IP directe, TLS ne peut pas vĂ©rifier le certificat sans le `servername`. On spĂ©cifie donc `smtp.mailtrap.io` manuellement. - -### Alternative (Non ImplĂ©mentĂ©e) -Configurer Node.js pour utiliser Google DNS: -```javascript -const dns = require('dns'); -dns.setServers(['8.8.8.8', '8.8.4.4']); -``` - ---- - -## 🎉 RĂ©sultat Final - -✅ **ProblĂšme rĂ©solu Ă  100%** -- Emails aux transporteurs fonctionnent -- Performance amĂ©liorĂ©e (~50% plus rapide) -- Logs clairs et prĂ©cis -- Code robuste avec gestion d'erreurs - -**PrĂȘt pour la production** 🚀 diff --git a/apps/backend/apps.zip b/apps/backend/apps.zip deleted file mode 100644 index f00902e..0000000 Binary files a/apps/backend/apps.zip and /dev/null differ diff --git a/apps/backend/debug-email-flow.js b/apps/backend/debug-email-flow.js deleted file mode 100644 index 7d3f365..0000000 --- a/apps/backend/debug-email-flow.js +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Script de debug pour tester le flux complet d'envoi d'email - * - * Ce script teste: - * 1. Connexion SMTP - * 2. Envoi d'un email simple - * 3. Envoi avec le template complet - */ - -require('dotenv').config(); -const nodemailer = require('nodemailer'); - -console.log('\n🔍 DEBUG - Flux d\'envoi d\'email transporteur\n'); -console.log('='.repeat(60)); - -// 1. Afficher la configuration -console.log('\n📋 CONFIGURATION ACTUELLE:'); -console.log('----------------------------'); -console.log('SMTP_HOST:', process.env.SMTP_HOST); -console.log('SMTP_PORT:', process.env.SMTP_PORT); -console.log('SMTP_SECURE:', process.env.SMTP_SECURE); -console.log('SMTP_USER:', process.env.SMTP_USER); -console.log('SMTP_PASS:', process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI'); -console.log('SMTP_FROM:', process.env.SMTP_FROM); -console.log('APP_URL:', process.env.APP_URL); - -// 2. VĂ©rifier les variables requises -console.log('\n✅ VÉRIFICATION DES VARIABLES:'); -console.log('--------------------------------'); -const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS']; -const missing = requiredVars.filter(v => !process.env[v]); -if (missing.length > 0) { - console.error('❌ Variables manquantes:', missing.join(', ')); - process.exit(1); -} else { - console.log('✅ Toutes les variables requises sont prĂ©sentes'); -} - -// 3. CrĂ©er le transporter avec la mĂȘme configuration que le backend -console.log('\n🔧 CRÉATION DU TRANSPORTER:'); -console.log('----------------------------'); - -const host = process.env.SMTP_HOST; -const port = parseInt(process.env.SMTP_PORT); -const user = process.env.SMTP_USER; -const pass = process.env.SMTP_PASS; -const secure = process.env.SMTP_SECURE === 'true'; - -// MĂȘme logique que dans email.adapter.ts -const useDirectIP = host.includes('mailtrap.io'); -const actualHost = useDirectIP ? '3.209.246.195' : host; -const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; - -console.log('Configuration dĂ©tectĂ©e:'); -console.log(' Host original:', host); -console.log(' Utilise IP directe:', useDirectIP); -console.log(' Host rĂ©el:', actualHost); -console.log(' Server name (TLS):', serverName); -console.log(' Port:', port); -console.log(' Secure:', secure); - -const transporter = nodemailer.createTransport({ - host: actualHost, - port, - secure, - auth: { - user, - pass, - }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, -}); - -// 4. Tester la connexion -console.log('\n🔌 TEST DE CONNEXION SMTP:'); -console.log('---------------------------'); - -async function testConnection() { - try { - console.log('VĂ©rification de la connexion...'); - await transporter.verify(); - console.log('✅ Connexion SMTP rĂ©ussie!'); - return true; - } catch (error) { - console.error('❌ Échec de la connexion SMTP:'); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - console.error(' Command:', error.command); - if (error.stack) { - console.error(' Stack:', error.stack.substring(0, 200) + '...'); - } - return false; - } -} - -// 5. Envoyer un email de test simple -async function sendSimpleEmail() { - console.log('\n📧 TEST 1: Email simple'); - console.log('------------------------'); - - try { - const info = await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@xpeditis.com', - to: 'test@example.com', - subject: 'Test Simple - ' + new Date().toISOString(), - text: 'Ceci est un test simple', - html: '

Test Simple

Ceci est un test simple

', - }); - - console.log('✅ Email simple envoyĂ© avec succĂšs!'); - console.log(' Message ID:', info.messageId); - console.log(' Response:', info.response); - console.log(' Accepted:', info.accepted); - console.log(' Rejected:', info.rejected); - return true; - } catch (error) { - console.error('❌ Échec d\'envoi email simple:'); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - return false; - } -} - -// 6. Envoyer un email avec le template transporteur complet -async function sendCarrierEmail() { - console.log('\n📧 TEST 2: Email transporteur avec template'); - console.log('--------------------------------------------'); - - const bookingData = { - bookingId: 'TEST-' + Date.now(), - origin: 'FRPAR', - destination: 'USNYC', - volumeCBM: 15.5, - weightKG: 1200, - palletCount: 6, - priceUSD: 2500, - priceEUR: 2250, - primaryCurrency: 'USD', - transitDays: 18, - containerType: '40FT', - documents: [ - { type: 'Bill of Lading', fileName: 'bol-test.pdf' }, - { type: 'Packing List', fileName: 'packing-test.pdf' }, - { type: 'Commercial Invoice', fileName: 'invoice-test.pdf' }, - ], - }; - - const baseUrl = process.env.APP_URL || 'http://localhost:3000'; - const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`; - const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`; - - // Template HTML (version simplifiĂ©e pour le test) - const htmlTemplate = ` - - - - - - Nouvelle demande de rĂ©servation - - -
-
-

🚱 Nouvelle demande de rĂ©servation

-

Xpeditis

-
-
-

Bonjour,

-

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

- -

📋 DĂ©tails du transport

- - - - - - - - - - - - - - - - - -
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Poids${bookingData.weightKG} kg
Prix - ${bookingData.priceUSD} USD -
- -
-

📄 Documents fournis

-
    - ${bookingData.documents.map(doc => `
  • 📄 ${doc.type}: ${doc.fileName}
  • `).join('')} -
-
- -
-

Veuillez confirmer votre décision :

- -
- -
-

- ⚠ Important
- Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. -

-
-
-
-

Référence de réservation : ${bookingData.bookingId}

-

© 2025 Xpeditis. Tous droits réservés.

-

Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.

-
-
- - - `; - - try { - console.log('DonnĂ©es du booking:'); - console.log(' Booking ID:', bookingData.bookingId); - console.log(' Route:', bookingData.origin, '→', bookingData.destination); - console.log(' Prix:', bookingData.priceUSD, 'USD'); - console.log(' Accept URL:', acceptUrl); - console.log(' Reject URL:', rejectUrl); - console.log('\nEnvoi en cours...'); - - const info = await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@xpeditis.com', - to: 'carrier@test.com', - subject: `Nouvelle demande de rĂ©servation - ${bookingData.origin} → ${bookingData.destination}`, - html: htmlTemplate, - }); - - console.log('\n✅ Email transporteur envoyĂ© avec succĂšs!'); - console.log(' Message ID:', info.messageId); - console.log(' Response:', info.response); - console.log(' Accepted:', info.accepted); - console.log(' Rejected:', info.rejected); - console.log('\n📬 VĂ©rifiez votre inbox Mailtrap:'); - console.log(' URL: https://mailtrap.io/inboxes'); - console.log(' Sujet: Nouvelle demande de rĂ©servation - FRPAR → USNYC'); - return true; - } catch (error) { - console.error('\n❌ Échec d\'envoi email transporteur:'); - console.error(' Message:', error.message); - console.error(' Code:', error.code); - console.error(' ResponseCode:', error.responseCode); - console.error(' Response:', error.response); - if (error.stack) { - console.error(' Stack:', error.stack.substring(0, 300)); - } - return false; - } -} - -// ExĂ©cuter tous les tests -async function runAllTests() { - console.log('\n🚀 DÉMARRAGE DES TESTS'); - console.log('='.repeat(60)); - - // Test 1: Connexion - const connectionOk = await testConnection(); - if (!connectionOk) { - console.log('\n❌ ARRÊT: La connexion SMTP a Ă©chouĂ©'); - console.log(' VĂ©rifiez vos credentials SMTP dans .env'); - process.exit(1); - } - - // Test 2: Email simple - const simpleEmailOk = await sendSimpleEmail(); - if (!simpleEmailOk) { - console.log('\n⚠ L\'email simple a Ă©chouĂ©, mais on continue...'); - } - - // Test 3: Email transporteur - const carrierEmailOk = await sendCarrierEmail(); - - // RĂ©sumĂ© - console.log('\n' + '='.repeat(60)); - console.log('📊 RÉSUMÉ DES TESTS:'); - console.log('='.repeat(60)); - console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC'); - console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC'); - console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC'); - - if (connectionOk && simpleEmailOk && carrierEmailOk) { - console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!'); - console.log(' Le systĂšme d\'envoi d\'email fonctionne correctement.'); - console.log(' Si vous ne recevez pas les emails dans le backend,'); - console.log(' le problĂšme vient de l\'intĂ©gration NestJS.'); - } else { - console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ'); - console.log(' VĂ©rifiez les erreurs ci-dessus pour comprendre le problĂšme.'); - } - - console.log('\n' + '='.repeat(60)); -} - -// Lancer les tests -runAllTests() - .then(() => { - console.log('\n✅ Tests terminĂ©s\n'); - process.exit(0); - }) - .catch(error => { - console.error('\n❌ Erreur fatale:', error); - process.exit(1); - }); diff --git a/apps/backend/docker-compose.yaml b/apps/backend/docker-compose.yaml deleted file mode 100644 index 5ce9f67..0000000 --- a/apps/backend/docker-compose.yaml +++ /dev/null @@ -1,19 +0,0 @@ -services: - postgres: - image: postgres:latest - container_name: xpeditis-postgres - environment: - POSTGRES_USER: xpeditis - POSTGRES_PASSWORD: xpeditis_dev_password - POSTGRES_DB: xpeditis_dev - ports: - - "5432:5432" - - redis: - image: redis:7 - container_name: xpeditis-redis - command: redis-server --requirepass xpeditis_redis_password - environment: - REDIS_PASSWORD: xpeditis_redis_password - ports: - - "6379:6379" diff --git a/apps/backend/delete-test-documents.js b/apps/backend/scripts/maintenance/delete-test-documents.js similarity index 96% rename from apps/backend/delete-test-documents.js rename to apps/backend/scripts/maintenance/delete-test-documents.js index 2a043fe..4f9ed78 100644 --- a/apps/backend/delete-test-documents.js +++ b/apps/backend/scripts/maintenance/delete-test-documents.js @@ -1,106 +1,106 @@ -/** - * Script to delete test documents from MinIO - * - * Deletes only small test files (< 1000 bytes) created by upload-test-documents.js - * Preserves real uploaded documents (larger files) - */ - -const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; -const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function deleteTestDocuments() { - try { - console.log('📋 Listing all files in bucket:', BUCKET_NAME); - - // List all files - let allFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allFiles = allFiles.concat(response.Contents); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(`\n📊 Found ${allFiles.length} total files\n`); - - // Filter test files (small files < 1000 bytes) - const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD); - const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD); - - console.log(`🔍 Analysis:`); - console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`); - console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`); - - if (testFiles.length === 0) { - console.log('✅ No test files to delete'); - return; - } - - console.log(`đŸ—‘ïž Deleting ${testFiles.length} test files:\n`); - - let deletedCount = 0; - for (const file of testFiles) { - console.log(` Deleting: ${file.Key} (${file.Size} bytes)`); - - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: file.Key, - }) - ); - deletedCount++; - } catch (error) { - console.error(` ❌ Failed to delete ${file.Key}:`, error.message); - } - } - - console.log(`\n✅ Deleted ${deletedCount} test files`); - console.log(`✅ Preserved ${realFiles.length} real documents\n`); - - console.log('📂 Remaining real documents:'); - realFiles.forEach(file => { - const filename = file.Key.split('/').pop(); - const sizeMB = (file.Size / 1024 / 1024).toFixed(2); - console.log(` - ${filename} (${sizeMB} MB)`); - }); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } -} - -deleteTestDocuments() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to delete test documents from MinIO + * + * Deletes only small test files (< 1000 bytes) created by upload-test-documents.js + * Preserves real uploaded documents (larger files) + */ + +const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; +const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function deleteTestDocuments() { + try { + console.log('📋 Listing all files in bucket:', BUCKET_NAME); + + // List all files + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`\n📊 Found ${allFiles.length} total files\n`); + + // Filter test files (small files < 1000 bytes) + const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD); + const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD); + + console.log(`🔍 Analysis:`); + console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`); + console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`); + + if (testFiles.length === 0) { + console.log('✅ No test files to delete'); + return; + } + + console.log(`đŸ—‘ïž Deleting ${testFiles.length} test files:\n`); + + let deletedCount = 0; + for (const file of testFiles) { + console.log(` Deleting: ${file.Key} (${file.Size} bytes)`); + + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: file.Key, + }) + ); + deletedCount++; + } catch (error) { + console.error(` ❌ Failed to delete ${file.Key}:`, error.message); + } + } + + console.log(`\n✅ Deleted ${deletedCount} test files`); + console.log(`✅ Preserved ${realFiles.length} real documents\n`); + + console.log('📂 Remaining real documents:'); + realFiles.forEach(file => { + const filename = file.Key.split('/').pop(); + const sizeMB = (file.Size / 1024 / 1024).toFixed(2); + console.log(` - ${filename} (${sizeMB} MB)`); + }); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +deleteTestDocuments() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/fix-domain-imports.js b/apps/backend/scripts/maintenance/fix-domain-imports.js similarity index 88% rename from apps/backend/fix-domain-imports.js rename to apps/backend/scripts/maintenance/fix-domain-imports.js index 67f3ea8..756b186 100644 --- a/apps/backend/fix-domain-imports.js +++ b/apps/backend/scripts/maintenance/fix-domain-imports.js @@ -1,42 +1,42 @@ -#!/usr/bin/env node - -/** - * Script to fix TypeScript imports in domain/services - * Replace relative paths with path aliases - */ - -const fs = require('fs'); -const path = require('path'); - -function fixImportsInFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - let modified = content; - - // Replace relative imports to ../ports/ with @domain/ports/ - modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/"); - modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, "import $1@domain/ports/"); - - if (modified !== content) { - fs.writeFileSync(filePath, modified, 'utf8'); - return true; - } - return false; -} - -const servicesDir = path.join(__dirname, 'src/domain/services'); -console.log('🔧 Fixing domain/services imports...\n'); - -const files = fs.readdirSync(servicesDir); -let count = 0; - -for (const file of files) { - if (file.endsWith('.ts')) { - const filePath = path.join(servicesDir, file); - if (fixImportsInFile(filePath)) { - console.log(`✅ Fixed: ${filePath}`); - count++; - } - } -} - -console.log(`\n✅ Fixed ${count} files in domain/services`); +#!/usr/bin/env node + +/** + * Script to fix TypeScript imports in domain/services + * Replace relative paths with path aliases + */ + +const fs = require('fs'); +const path = require('path'); + +function fixImportsInFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + let modified = content; + + // Replace relative imports to ../ports/ with @domain/ports/ + modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/"); + modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, 'import $1@domain/ports/'); + + if (modified !== content) { + fs.writeFileSync(filePath, modified, 'utf8'); + return true; + } + return false; +} + +const servicesDir = path.join(__dirname, 'src/domain/services'); +console.log('🔧 Fixing domain/services imports...\n'); + +const files = fs.readdirSync(servicesDir); +let count = 0; + +for (const file of files) { + if (file.endsWith('.ts')) { + const filePath = path.join(servicesDir, file); + if (fixImportsInFile(filePath)) { + console.log(`✅ Fixed: ${filePath}`); + count++; + } + } +} + +console.log(`\n✅ Fixed ${count} files in domain/services`); diff --git a/apps/backend/fix-dummy-urls.js b/apps/backend/scripts/maintenance/fix-dummy-urls.js similarity index 88% rename from apps/backend/fix-dummy-urls.js rename to apps/backend/scripts/maintenance/fix-dummy-urls.js index 721d170..3b93fe6 100644 --- a/apps/backend/fix-dummy-urls.js +++ b/apps/backend/scripts/maintenance/fix-dummy-urls.js @@ -1,90 +1,90 @@ -/** - * Script to fix dummy storage URLs in the database - * - * This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs - */ - -const { Client } = require('pg'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -async function fixDummyUrls() { - const client = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await client.connect(); - console.log('✅ Connected to database'); - - // Get all CSV bookings with documents - const result = await client.query( - `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'` - ); - - console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`); - - let updatedCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - // Update each document URL - const updatedDocuments = documents.map((doc) => { - if (doc.filePath && doc.filePath.includes('dummy-storage')) { - // Extract filename from dummy URL - const fileName = doc.fileName || doc.filePath.split('/').pop(); - const documentId = doc.id; - - // Build proper MinIO URL - const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`; - - console.log(` Old: ${doc.filePath}`); - console.log(` New: ${newUrl}`); - - return { - ...doc, - filePath: newUrl, - }; - } - return doc; - }); - - // Update the database - await client.query( - `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, - [JSON.stringify(updatedDocuments), bookingId] - ); - - updatedCount++; - console.log(`✅ Updated booking ${bookingId}\n`); - } - - console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); - console.log(`\n⚠ Note: The actual files need to be uploaded to MinIO at the correct paths.`); - console.log(` You can upload test files or re-create the bookings with real file uploads.`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await client.end(); - console.log('\n👋 Disconnected from database'); - } -} - -fixDummyUrls() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to fix dummy storage URLs in the database + * + * This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +async function fixDummyUrls() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await client.connect(); + console.log('✅ Connected to database'); + + // Get all CSV bookings with documents + const result = await client.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`); + + let updatedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Update each document URL + const updatedDocuments = documents.map(doc => { + if (doc.filePath && doc.filePath.includes('dummy-storage')) { + // Extract filename from dummy URL + const fileName = doc.fileName || doc.filePath.split('/').pop(); + const documentId = doc.id; + + // Build proper MinIO URL + const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`; + + console.log(` Old: ${doc.filePath}`); + console.log(` New: ${newUrl}`); + + return { + ...doc, + filePath: newUrl, + }; + } + return doc; + }); + + // Update the database + await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [ + JSON.stringify(updatedDocuments), + bookingId, + ]); + + updatedCount++; + console.log(`✅ Updated booking ${bookingId}\n`); + } + + console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); + console.log(`\n⚠ Note: The actual files need to be uploaded to MinIO at the correct paths.`); + console.log(` You can upload test files or re-create the bookings with real file uploads.`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await client.end(); + console.log('\n👋 Disconnected from database'); + } +} + +fixDummyUrls() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/fix-imports.js b/apps/backend/scripts/maintenance/fix-imports.js similarity index 88% rename from apps/backend/fix-imports.js rename to apps/backend/scripts/maintenance/fix-imports.js index 719e044..413bbae 100644 --- a/apps/backend/fix-imports.js +++ b/apps/backend/scripts/maintenance/fix-imports.js @@ -1,65 +1,68 @@ -#!/usr/bin/env node - -/** - * Script to fix TypeScript imports from relative paths to path aliases - * - * Replaces: - * - from '../../domain/...' → from '@domain/...' - * - from '../../../domain/...' → from '@domain/...' - * - from '../domain/...' → from '@domain/...' - * - from '../../../../domain/...' → from '@domain/...' - */ - -const fs = require('fs'); -const path = require('path'); - -function fixImportsInFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - let modified = content; - - // Replace all variations of relative domain imports with @domain alias - modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); - modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); - modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/"); - modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/"); - - // Also fix import statements (not just from) - modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/"); - modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/"); - modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, "import $1@domain/"); - modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, "import $1@domain/"); - - if (modified !== content) { - fs.writeFileSync(filePath, modified, 'utf8'); - return true; - } - return false; -} - -function walkDir(dir) { - const files = fs.readdirSync(dir); - let count = 0; - - for (const file of files) { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); - - if (stat.isDirectory()) { - count += walkDir(filePath); - } else if (file.endsWith('.ts')) { - if (fixImportsInFile(filePath)) { - console.log(`✅ Fixed: ${filePath}`); - count++; - } - } - } - - return count; -} - -const srcDir = path.join(__dirname, 'src'); -console.log('🔧 Fixing TypeScript imports...\n'); - -const count = walkDir(srcDir); - -console.log(`\n✅ Fixed ${count} files`); +#!/usr/bin/env node + +/** + * Script to fix TypeScript imports from relative paths to path aliases + * + * Replaces: + * - from '../../domain/...' → from '@domain/...' + * - from '../../../domain/...' → from '@domain/...' + * - from '../domain/...' → from '@domain/...' + * - from '../../../../domain/...' → from '@domain/...' + */ + +const fs = require('fs'); +const path = require('path'); + +function fixImportsInFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + let modified = content; + + // Replace all variations of relative domain imports with @domain alias + modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); + modified = modified.replace(/from ['"]\.\.\/\.\.\/\.\.\/domain\//g, "from '@domain/"); + modified = modified.replace(/from ['"]\.\.\/\.\.\/domain\//g, "from '@domain/"); + modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/"); + + // Also fix import statements (not just from) + modified = modified.replace( + /import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, + 'import $1@domain/' + ); + modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, 'import $1@domain/'); + modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, 'import $1@domain/'); + modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, 'import $1@domain/'); + + if (modified !== content) { + fs.writeFileSync(filePath, modified, 'utf8'); + return true; + } + return false; +} + +function walkDir(dir) { + const files = fs.readdirSync(dir); + let count = 0; + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + count += walkDir(filePath); + } else if (file.endsWith('.ts')) { + if (fixImportsInFile(filePath)) { + console.log(`✅ Fixed: ${filePath}`); + count++; + } + } + } + + return count; +} + +const srcDir = path.join(__dirname, 'src'); +console.log('🔧 Fixing TypeScript imports...\n'); + +const count = walkDir(srcDir); + +console.log(`\n✅ Fixed ${count} files`); diff --git a/apps/backend/fix-minio-hostname.js b/apps/backend/scripts/maintenance/fix-minio-hostname.js similarity index 86% rename from apps/backend/fix-minio-hostname.js rename to apps/backend/scripts/maintenance/fix-minio-hostname.js index 1455958..9bf4501 100644 --- a/apps/backend/fix-minio-hostname.js +++ b/apps/backend/scripts/maintenance/fix-minio-hostname.js @@ -1,81 +1,81 @@ -/** - * Script to fix minio hostname in document URLs - * - * Changes http://minio:9000 to http://localhost:9000 - */ - -const { Client } = require('pg'); -require('dotenv').config(); - -async function fixMinioHostname() { - const client = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await client.connect(); - console.log('✅ Connected to database'); - - // Find bookings with minio:9000 in URLs - const result = await client.query( - `SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'` - ); - - console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`); - - let updatedCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - // Update each document URL - const updatedDocuments = documents.map((doc) => { - if (doc.filePath && doc.filePath.includes('http://minio:9000')) { - const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000'); - - console.log(` Booking: ${bookingId}`); - console.log(` Old: ${doc.filePath}`); - console.log(` New: ${newUrl}\n`); - - return { - ...doc, - filePath: newUrl, - }; - } - return doc; - }); - - // Update the database - await client.query( - `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, - [JSON.stringify(updatedDocuments), bookingId] - ); - - updatedCount++; - console.log(`✅ Updated booking ${bookingId}\n`); - } - - console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await client.end(); - console.log('\n👋 Disconnected from database'); - } -} - -fixMinioHostname() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to fix minio hostname in document URLs + * + * Changes http://minio:9000 to http://localhost:9000 + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +async function fixMinioHostname() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await client.connect(); + console.log('✅ Connected to database'); + + // Find bookings with minio:9000 in URLs + const result = await client.query( + `SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`); + + let updatedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Update each document URL + const updatedDocuments = documents.map(doc => { + if (doc.filePath && doc.filePath.includes('http://minio:9000')) { + const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000'); + + console.log(` Booking: ${bookingId}`); + console.log(` Old: ${doc.filePath}`); + console.log(` New: ${newUrl}\n`); + + return { + ...doc, + filePath: newUrl, + }; + } + return doc; + }); + + // Update the database + await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [ + JSON.stringify(updatedDocuments), + bookingId, + ]); + + updatedCount++; + console.log(`✅ Updated booking ${bookingId}\n`); + } + + console.log(`\n🎉 Successfully updated ${updatedCount} bookings`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await client.end(); + console.log('\n👋 Disconnected from database'); + } +} + +fixMinioHostname() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/restore-document-references.js b/apps/backend/scripts/maintenance/restore-document-references.js similarity index 89% rename from apps/backend/restore-document-references.js rename to apps/backend/scripts/maintenance/restore-document-references.js index 3145ac4..077a90c 100644 --- a/apps/backend/restore-document-references.js +++ b/apps/backend/scripts/maintenance/restore-document-references.js @@ -1,176 +1,182 @@ -/** - * Script to restore document references in database from MinIO files - * - * Scans MinIO for existing files and creates/updates database references - */ - -const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { Client } = require('pg'); -const { v4: uuidv4 } = require('uuid'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function restoreDocumentReferences() { - const pgClient = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await pgClient.connect(); - console.log('✅ Connected to database\n'); - - // Get all MinIO files - console.log('📋 Listing files in MinIO...'); - let allFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allFiles = allFiles.concat(response.Contents); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(` Found ${allFiles.length} files in MinIO\n`); - - // Group files by booking ID - const filesByBooking = {}; - allFiles.forEach(file => { - const parts = file.Key.split('/'); - if (parts.length >= 3 && parts[0] === 'csv-bookings') { - const bookingId = parts[1]; - const documentId = parts[2].split('-')[0]; // Extract UUID from filename - const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash) - - if (!filesByBooking[bookingId]) { - filesByBooking[bookingId] = []; - } - - filesByBooking[bookingId].push({ - key: file.Key, - documentId: documentId, - fileName: fileName, - size: file.Size, - lastModified: file.LastModified, - }); - } - }); - - console.log(`📩 Found files for ${Object.keys(filesByBooking).length} bookings\n`); - - let updatedCount = 0; - let createdDocsCount = 0; - - for (const [bookingId, files] of Object.entries(filesByBooking)) { - // Check if booking exists - const bookingResult = await pgClient.query( - 'SELECT id, documents FROM csv_bookings WHERE id = $1', - [bookingId] - ); - - if (bookingResult.rows.length === 0) { - console.log(`⚠ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`); - continue; - } - - const booking = bookingResult.rows[0]; - const existingDocs = booking.documents || []; - - console.log(`\n📩 Booking: ${bookingId.substring(0, 8)}...`); - console.log(` Existing documents in DB: ${existingDocs.length}`); - console.log(` Files in MinIO: ${files.length}`); - - // Create document references for files - const newDocuments = files.map(file => { - // Determine MIME type from file extension - const ext = file.fileName.split('.').pop().toLowerCase(); - const mimeTypeMap = { - pdf: 'application/pdf', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - txt: 'text/plain', - }; - const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; - - // Determine document type - let docType = 'OTHER'; - if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) { - docType = 'BILL_OF_LADING'; - } else if (file.fileName.toLowerCase().includes('packing-list')) { - docType = 'PACKING_LIST'; - } else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) { - docType = 'COMMERCIAL_INVOICE'; - } - - const doc = { - id: file.documentId, - type: docType, - fileName: file.fileName, - filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`, - mimeType: mimeType, - size: file.size, - uploadedAt: file.lastModified.toISOString(), - }; - - console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`); - return doc; - }); - - // Update the booking with new document references - await pgClient.query( - 'UPDATE csv_bookings SET documents = $1 WHERE id = $2', - [JSON.stringify(newDocuments), bookingId] - ); - - updatedCount++; - createdDocsCount += newDocuments.length; - } - - console.log(`\n📊 Summary:`); - console.log(` Bookings updated: ${updatedCount}`); - console.log(` Document references created: ${createdDocsCount}`); - console.log(`\n✅ Document references restored`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await pgClient.end(); - console.log('\n👋 Disconnected from database'); - } -} - -restoreDocumentReferences() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to restore document references in database from MinIO files + * + * Scans MinIO for existing files and creates/updates database references + */ + +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { Client } = require('pg'); +const { v4: uuidv4 } = require('uuid'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function restoreDocumentReferences() { + const pgClient = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await pgClient.connect(); + console.log('✅ Connected to database\n'); + + // Get all MinIO files + console.log('📋 Listing files in MinIO...'); + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(` Found ${allFiles.length} files in MinIO\n`); + + // Group files by booking ID + const filesByBooking = {}; + allFiles.forEach(file => { + const parts = file.Key.split('/'); + if (parts.length >= 3 && parts[0] === 'csv-bookings') { + const bookingId = parts[1]; + const documentId = parts[2].split('-')[0]; // Extract UUID from filename + const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash) + + if (!filesByBooking[bookingId]) { + filesByBooking[bookingId] = []; + } + + filesByBooking[bookingId].push({ + key: file.Key, + documentId: documentId, + fileName: fileName, + size: file.Size, + lastModified: file.LastModified, + }); + } + }); + + console.log(`📩 Found files for ${Object.keys(filesByBooking).length} bookings\n`); + + let updatedCount = 0; + let createdDocsCount = 0; + + for (const [bookingId, files] of Object.entries(filesByBooking)) { + // Check if booking exists + const bookingResult = await pgClient.query( + 'SELECT id, documents FROM csv_bookings WHERE id = $1', + [bookingId] + ); + + if (bookingResult.rows.length === 0) { + console.log(`⚠ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`); + continue; + } + + const booking = bookingResult.rows[0]; + const existingDocs = booking.documents || []; + + console.log(`\n📩 Booking: ${bookingId.substring(0, 8)}...`); + console.log(` Existing documents in DB: ${existingDocs.length}`); + console.log(` Files in MinIO: ${files.length}`); + + // Create document references for files + const newDocuments = files.map(file => { + // Determine MIME type from file extension + const ext = file.fileName.split('.').pop().toLowerCase(); + const mimeTypeMap = { + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + txt: 'text/plain', + }; + const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; + + // Determine document type + let docType = 'OTHER'; + if ( + file.fileName.toLowerCase().includes('bill-of-lading') || + file.fileName.toLowerCase().includes('bol') + ) { + docType = 'BILL_OF_LADING'; + } else if (file.fileName.toLowerCase().includes('packing-list')) { + docType = 'PACKING_LIST'; + } else if ( + file.fileName.toLowerCase().includes('commercial-invoice') || + file.fileName.toLowerCase().includes('invoice') + ) { + docType = 'COMMERCIAL_INVOICE'; + } + + const doc = { + id: file.documentId, + type: docType, + fileName: file.fileName, + filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`, + mimeType: mimeType, + size: file.size, + uploadedAt: file.lastModified.toISOString(), + }; + + console.log(` ✅ ${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`); + return doc; + }); + + // Update the booking with new document references + await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [ + JSON.stringify(newDocuments), + bookingId, + ]); + + updatedCount++; + createdDocsCount += newDocuments.length; + } + + console.log(`\n📊 Summary:`); + console.log(` Bookings updated: ${updatedCount}`); + console.log(` Document references created: ${createdDocsCount}`); + console.log(`\n✅ Document references restored`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +restoreDocumentReferences() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/sync-database-with-minio.js b/apps/backend/scripts/maintenance/sync-database-with-minio.js similarity index 93% rename from apps/backend/sync-database-with-minio.js rename to apps/backend/scripts/maintenance/sync-database-with-minio.js index f7b1f44..67f611a 100644 --- a/apps/backend/sync-database-with-minio.js +++ b/apps/backend/scripts/maintenance/sync-database-with-minio.js @@ -1,154 +1,154 @@ -/** - * Script to sync database with MinIO - * - * Removes document references from database for files that no longer exist in MinIO - */ - -const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); -const { Client } = require('pg'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function syncDatabase() { - const pgClient = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - await pgClient.connect(); - console.log('✅ Connected to database\n'); - - // Get all MinIO files - console.log('📋 Listing files in MinIO...'); - let allMinioFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key)); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(` Found ${allMinioFiles.length} files in MinIO\n`); - - // Create a set for faster lookup - const minioFilesSet = new Set(allMinioFiles); - - // Get all bookings with documents - const result = await pgClient.query( - `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0` - ); - - console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`); - - let updatedCount = 0; - let removedDocsCount = 0; - let emptyBookingsCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - // Filter documents to keep only those that exist in MinIO - const validDocuments = []; - const missingDocuments = []; - - for (const doc of documents) { - if (!doc.filePath) { - missingDocuments.push(doc); - continue; - } - - // Extract the S3 key from the URL - try { - const url = new URL(doc.filePath); - const pathname = url.pathname; - // Remove leading slash and bucket name - const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); - - if (minioFilesSet.has(key)) { - validDocuments.push(doc); - } else { - missingDocuments.push(doc); - } - } catch (error) { - console.error(` ⚠ Invalid URL for booking ${bookingId}: ${doc.filePath}`); - missingDocuments.push(doc); - } - } - - if (missingDocuments.length > 0) { - console.log(`\n📩 Booking: ${bookingId.substring(0, 8)}...`); - console.log(` Total documents: ${documents.length}`); - console.log(` Valid documents: ${validDocuments.length}`); - console.log(` Missing documents: ${missingDocuments.length}`); - - missingDocuments.forEach(doc => { - console.log(` ❌ ${doc.fileName || 'Unknown'}`); - }); - - // Update the database - await pgClient.query( - `UPDATE csv_bookings SET documents = $1 WHERE id = $2`, - [JSON.stringify(validDocuments), bookingId] - ); - - updatedCount++; - removedDocsCount += missingDocuments.length; - - if (validDocuments.length === 0) { - emptyBookingsCount++; - console.log(` ⚠ This booking now has NO documents`); - } - } - } - - console.log(`\n📊 Summary:`); - console.log(` Bookings updated: ${updatedCount}`); - console.log(` Documents removed from DB: ${removedDocsCount}`); - console.log(` Bookings with no documents: ${emptyBookingsCount}`); - console.log(`\n✅ Database synchronized with MinIO`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await pgClient.end(); - console.log('\n👋 Disconnected from database'); - } -} - -syncDatabase() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to sync database with MinIO + * + * Removes document references from database for files that no longer exist in MinIO + */ + +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { Client } = require('pg'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function syncDatabase() { + const pgClient = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + await pgClient.connect(); + console.log('✅ Connected to database\n'); + + // Get all MinIO files + console.log('📋 Listing files in MinIO...'); + let allMinioFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key)); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(` Found ${allMinioFiles.length} files in MinIO\n`); + + // Create a set for faster lookup + const minioFilesSet = new Set(allMinioFiles); + + // Get all bookings with documents + const result = await pgClient.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0` + ); + + console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`); + + let updatedCount = 0; + let removedDocsCount = 0; + let emptyBookingsCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + // Filter documents to keep only those that exist in MinIO + const validDocuments = []; + const missingDocuments = []; + + for (const doc of documents) { + if (!doc.filePath) { + missingDocuments.push(doc); + continue; + } + + // Extract the S3 key from the URL + try { + const url = new URL(doc.filePath); + const pathname = url.pathname; + // Remove leading slash and bucket name + const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); + + if (minioFilesSet.has(key)) { + validDocuments.push(doc); + } else { + missingDocuments.push(doc); + } + } catch (error) { + console.error(` ⚠ Invalid URL for booking ${bookingId}: ${doc.filePath}`); + missingDocuments.push(doc); + } + } + + if (missingDocuments.length > 0) { + console.log(`\n📩 Booking: ${bookingId.substring(0, 8)}...`); + console.log(` Total documents: ${documents.length}`); + console.log(` Valid documents: ${validDocuments.length}`); + console.log(` Missing documents: ${missingDocuments.length}`); + + missingDocuments.forEach(doc => { + console.log(` ❌ ${doc.fileName || 'Unknown'}`); + }); + + // Update the database + await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [ + JSON.stringify(validDocuments), + bookingId, + ]); + + updatedCount++; + removedDocsCount += missingDocuments.length; + + if (validDocuments.length === 0) { + emptyBookingsCount++; + console.log(` ⚠ This booking now has NO documents`); + } + } + } + + console.log(`\n📊 Summary:`); + console.log(` Bookings updated: ${updatedCount}`); + console.log(` Documents removed from DB: ${removedDocsCount}`); + console.log(` Bookings with no documents: ${emptyBookingsCount}`); + console.log(`\n✅ Database synchronized with MinIO`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +syncDatabase() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/generate-hash.js b/apps/backend/scripts/setup/generate-hash.js similarity index 95% rename from apps/backend/generate-hash.js rename to apps/backend/scripts/setup/generate-hash.js index cea5dde..ff2cc01 100644 --- a/apps/backend/generate-hash.js +++ b/apps/backend/scripts/setup/generate-hash.js @@ -1,14 +1,14 @@ -const argon2 = require('argon2'); - -async function generateHash() { - const hash = await argon2.hash('Password123!', { - type: argon2.argon2id, - memoryCost: 65536, // 64 MB - timeCost: 3, - parallelism: 4, - }); - console.log('Argon2id hash for "Password123!":'); - console.log(hash); -} - -generateHash().catch(console.error); +const argon2 = require('argon2'); + +async function generateHash() { + const hash = await argon2.hash('Password123!', { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + console.log('Argon2id hash for "Password123!":'); + console.log(hash); +} + +generateHash().catch(console.error); diff --git a/apps/backend/scripts/generate-ports-seed.ts b/apps/backend/scripts/setup/generate-ports-seed.ts similarity index 92% rename from apps/backend/scripts/generate-ports-seed.ts rename to apps/backend/scripts/setup/generate-ports-seed.ts index d3f770c..62f20c3 100644 --- a/apps/backend/scripts/generate-ports-seed.ts +++ b/apps/backend/scripts/setup/generate-ports-seed.ts @@ -210,10 +210,7 @@ function parseSeaPorts(filePath: string): ParsedPort[] { // Validate coordinates const [longitude, latitude] = port.coordinates; - if ( - latitude < -90 || latitude > 90 || - longitude < -180 || longitude > 180 - ) { + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { skipped++; continue; } @@ -244,13 +241,14 @@ function generateSQLInserts(ports: ParsedPort[]): string { for (let i = 0; i < ports.length; i += batchSize) { const batch = ports.slice(i, i + batchSize); - const values = batch.map(port => { - const name = port.name.replace(/'/g, "''"); - const city = port.city.replace(/'/g, "''"); - const countryName = port.countryName.replace(/'/g, "''"); - const timezone = port.timezone ? `'${port.timezone}'` : 'NULL'; + const values = batch + .map(port => { + const name = port.name.replace(/'/g, "''"); + const city = port.city.replace(/'/g, "''"); + const countryName = port.countryName.replace(/'/g, "''"); + const timezone = port.timezone ? `'${port.timezone}'` : 'NULL'; - return `( + return `( '${port.code}', '${name}', '${city}', @@ -261,7 +259,8 @@ function generateSQLInserts(ports: ParsedPort[]): string { ${timezone}, ${port.isActive} )`; - }).join(',\n '); + }) + .join(',\n '); batches.push(` // Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports) @@ -321,7 +320,9 @@ async function main() { if (!fs.existsSync(seaPortsPath)) { console.error('❌ Error: /tmp/sea-ports.json not found!'); console.log('Please download it first:'); - console.log('curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json'); + console.log( + 'curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json' + ); process.exit(1); } @@ -342,7 +343,10 @@ async function main() { const migrationContent = generateMigration(ports); // Write migration file - const migrationsDir = path.join(__dirname, '../src/infrastructure/persistence/typeorm/migrations'); + const migrationsDir = path.join( + __dirname, + '../src/infrastructure/persistence/typeorm/migrations' + ); const timestamp = Date.now(); const fileName = `${timestamp}-SeedPorts.ts`; const filePath = path.join(migrationsDir, fileName); diff --git a/apps/backend/list-minio-files.js b/apps/backend/scripts/setup/list-minio-files.js similarity index 95% rename from apps/backend/list-minio-files.js rename to apps/backend/scripts/setup/list-minio-files.js index 606ad07..8a8e8f0 100644 --- a/apps/backend/list-minio-files.js +++ b/apps/backend/scripts/setup/list-minio-files.js @@ -1,92 +1,92 @@ -/** - * Script to list all files in MinIO xpeditis-documents bucket - */ - -const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function listFiles() { - try { - console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`); - - let allFiles = []; - let continuationToken = null; - - do { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - ContinuationToken: continuationToken, - }); - - const response = await s3Client.send(command); - - if (response.Contents) { - allFiles = allFiles.concat(response.Contents); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(`Found ${allFiles.length} files total:\n`); - - // Group by booking ID - const byBooking = {}; - allFiles.forEach(file => { - const parts = file.Key.split('/'); - if (parts.length >= 3 && parts[0] === 'csv-bookings') { - const bookingId = parts[1]; - if (!byBooking[bookingId]) { - byBooking[bookingId] = []; - } - byBooking[bookingId].push({ - key: file.Key, - size: file.Size, - lastModified: file.LastModified, - }); - } else { - console.log(` Other: ${file.Key} (${file.Size} bytes)`); - } - }); - - console.log(`\nFiles grouped by booking:\n`); - Object.entries(byBooking).forEach(([bookingId, files]) => { - console.log(`📩 Booking: ${bookingId.substring(0, 8)}...`); - files.forEach(file => { - const filename = file.key.split('/').pop(); - console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`); - }); - console.log(''); - }); - - console.log(`\n📊 Summary:`); - console.log(` Total files: ${allFiles.length}`); - console.log(` Bookings with files: ${Object.keys(byBooking).length}`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } -} - -listFiles() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to list all files in MinIO xpeditis-documents bucket + */ + +const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function listFiles() { + try { + console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`); + + let allFiles = []; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + ContinuationToken: continuationToken, + }); + + const response = await s3Client.send(command); + + if (response.Contents) { + allFiles = allFiles.concat(response.Contents); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`Found ${allFiles.length} files total:\n`); + + // Group by booking ID + const byBooking = {}; + allFiles.forEach(file => { + const parts = file.Key.split('/'); + if (parts.length >= 3 && parts[0] === 'csv-bookings') { + const bookingId = parts[1]; + if (!byBooking[bookingId]) { + byBooking[bookingId] = []; + } + byBooking[bookingId].push({ + key: file.Key, + size: file.Size, + lastModified: file.LastModified, + }); + } else { + console.log(` Other: ${file.Key} (${file.Size} bytes)`); + } + }); + + console.log(`\nFiles grouped by booking:\n`); + Object.entries(byBooking).forEach(([bookingId, files]) => { + console.log(`📩 Booking: ${bookingId.substring(0, 8)}...`); + files.forEach(file => { + const filename = file.key.split('/').pop(); + console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`); + }); + console.log(''); + }); + + console.log(`\n📊 Summary:`); + console.log(` Total files: ${allFiles.length}`); + console.log(` Bookings with files: ${Object.keys(byBooking).length}`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +listFiles() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/scripts/list-stripe-prices.js b/apps/backend/scripts/setup/list-stripe-prices.js similarity index 91% rename from apps/backend/scripts/list-stripe-prices.js rename to apps/backend/scripts/setup/list-stripe-prices.js index 2756851..75c3ea7 100644 --- a/apps/backend/scripts/list-stripe-prices.js +++ b/apps/backend/scripts/setup/list-stripe-prices.js @@ -5,7 +5,10 @@ const Stripe = require('stripe'); -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'); +const stripe = new Stripe( + process.env.STRIPE_SECRET_KEY || + 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr' +); async function listPrices() { console.log('Fetching Stripe prices...\n'); @@ -46,7 +49,6 @@ async function listPrices() { console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx'); console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx'); console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx'); - } catch (error) { console.error('Error fetching prices:', error.message); } diff --git a/apps/backend/run-migrations.js b/apps/backend/scripts/setup/run-migrations.js similarity index 92% rename from apps/backend/run-migrations.js rename to apps/backend/scripts/setup/run-migrations.js index db1590a..3ffae11 100644 --- a/apps/backend/run-migrations.js +++ b/apps/backend/scripts/setup/run-migrations.js @@ -1,44 +1,44 @@ -const { DataSource } = require('typeorm'); -const path = require('path'); - -const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT, 10), - username: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], - migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], - synchronize: false, - logging: true, -}); - -console.log('🚀 Starting Xpeditis Backend Migration Script...'); -console.log('📩 Initializing DataSource...'); - -AppDataSource.initialize() - .then(async () => { - console.log('✅ DataSource initialized successfully'); - console.log('🔄 Running pending migrations...'); - - const migrations = await AppDataSource.runMigrations(); - - if (migrations.length === 0) { - console.log('✅ No pending migrations'); - } else { - console.log(`✅ Successfully ran ${migrations.length} migration(s):`); - migrations.forEach((migration) => { - console.log(` - ${migration.name}`); - }); - } - - await AppDataSource.destroy(); - console.log('✅ Database migrations completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('❌ Error during migration:'); - console.error(error); - process.exit(1); - }); +const { DataSource } = require('typeorm'); +const path = require('path'); + +const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], + migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], + synchronize: false, + logging: true, +}); + +console.log('🚀 Starting Xpeditis Backend Migration Script...'); +console.log('📩 Initializing DataSource...'); + +AppDataSource.initialize() + .then(async () => { + console.log('✅ DataSource initialized successfully'); + console.log('🔄 Running pending migrations...'); + + const migrations = await AppDataSource.runMigrations(); + + if (migrations.length === 0) { + console.log('✅ No pending migrations'); + } else { + console.log(`✅ Successfully ran ${migrations.length} migration(s):`); + migrations.forEach(migration => { + console.log(` - ${migration.name}`); + }); + } + + await AppDataSource.destroy(); + console.log('✅ Database migrations completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('❌ Error during migration:'); + console.error(error); + process.exit(1); + }); diff --git a/apps/backend/set-bucket-policy.js b/apps/backend/scripts/setup/set-bucket-policy.js similarity index 95% rename from apps/backend/set-bucket-policy.js rename to apps/backend/scripts/setup/set-bucket-policy.js index e6e9735..854a815 100644 --- a/apps/backend/set-bucket-policy.js +++ b/apps/backend/scripts/setup/set-bucket-policy.js @@ -1,79 +1,79 @@ -/** - * Script to set MinIO bucket policy for public read access - * - * This allows documents to be downloaded directly via URL without authentication - */ - -const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -async function setBucketPolicy() { - try { - // Policy to allow public read access to all objects in the bucket - const policy = { - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: '*', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], - }, - ], - }; - - console.log('📋 Setting bucket policy for:', BUCKET_NAME); - console.log('Policy:', JSON.stringify(policy, null, 2)); - - // Set the bucket policy - await s3Client.send( - new PutBucketPolicyCommand({ - Bucket: BUCKET_NAME, - Policy: JSON.stringify(policy), - }) - ); - - console.log('\n✅ Bucket policy set successfully!'); - console.log(` All objects in ${BUCKET_NAME} are now publicly readable`); - - // Verify the policy was set - console.log('\n🔍 Verifying bucket policy...'); - const getPolicy = await s3Client.send( - new GetBucketPolicyCommand({ - Bucket: BUCKET_NAME, - }) - ); - - console.log('✅ Current policy:', getPolicy.Policy); - - console.log('\n📝 Note: This allows public read access to all documents.'); - console.log(' For production, consider using signed URLs instead.'); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } -} - -setBucketPolicy() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to set MinIO bucket policy for public read access + * + * This allows documents to be downloaded directly via URL without authentication + */ + +const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +async function setBucketPolicy() { + try { + // Policy to allow public read access to all objects in the bucket + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: '*', + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`], + }, + ], + }; + + console.log('📋 Setting bucket policy for:', BUCKET_NAME); + console.log('Policy:', JSON.stringify(policy, null, 2)); + + // Set the bucket policy + await s3Client.send( + new PutBucketPolicyCommand({ + Bucket: BUCKET_NAME, + Policy: JSON.stringify(policy), + }) + ); + + console.log('\n✅ Bucket policy set successfully!'); + console.log(` All objects in ${BUCKET_NAME} are now publicly readable`); + + // Verify the policy was set + console.log('\n🔍 Verifying bucket policy...'); + const getPolicy = await s3Client.send( + new GetBucketPolicyCommand({ + Bucket: BUCKET_NAME, + }) + ); + + console.log('✅ Current policy:', getPolicy.Policy); + + console.log('\n📝 Note: This allows public read access to all documents.'); + console.log(' For production, consider using signed URLs instead.'); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +setBucketPolicy() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/setup-minio-bucket.js b/apps/backend/scripts/setup/setup-minio-bucket.js similarity index 97% rename from apps/backend/setup-minio-bucket.js rename to apps/backend/scripts/setup/setup-minio-bucket.js index 4b94faf..6ce7c24 100644 --- a/apps/backend/setup-minio-bucket.js +++ b/apps/backend/scripts/setup/setup-minio-bucket.js @@ -1,91 +1,91 @@ -#!/usr/bin/env node -/** - * Setup MinIO Bucket - * - * Creates the required bucket for document storage if it doesn't exist - */ - -const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); -require('dotenv').config(); - -const BUCKET_NAME = 'xpeditis-documents'; - -// Configure S3 client for MinIO -const s3Client = new S3Client({ - region: process.env.AWS_REGION || 'us-east-1', - endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000', - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, // Required for MinIO -}); - -async function setupBucket() { - console.log('\nđŸȘŁ MinIO Bucket Setup'); - console.log('=========================================='); - console.log(`Bucket name: ${BUCKET_NAME}`); - console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`); - console.log(''); - - try { - // Check if bucket exists - console.log('📋 Step 1: Checking if bucket exists...'); - try { - await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Bucket '${BUCKET_NAME}' already exists`); - console.log(''); - console.log('✅ Setup complete! The bucket is ready to use.'); - process.exit(0); - } catch (error) { - if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { - console.log(`â„č Bucket '${BUCKET_NAME}' does not exist`); - } else { - throw error; - } - } - - // Create bucket - console.log(''); - console.log('📋 Step 2: Creating bucket...'); - await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`); - - // Verify creation - console.log(''); - console.log('📋 Step 3: Verifying bucket...'); - await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Bucket '${BUCKET_NAME}' verified!`); - - console.log(''); - console.log('=========================================='); - console.log('✅ Setup complete! The bucket is ready to use.'); - console.log(''); - console.log('You can now:'); - console.log(' 1. Create CSV bookings via the frontend'); - console.log(' 2. Upload documents to this bucket'); - console.log(' 3. View files at: http://localhost:9001 (MinIO Console)'); - console.log(''); - - process.exit(0); - } catch (error) { - console.error(''); - console.error('❌ ERROR: Failed to setup bucket'); - console.error(''); - console.error('Error details:'); - console.error(` Name: ${error.name}`); - console.error(` Message: ${error.message}`); - if (error.$metadata) { - console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`); - } - console.error(''); - console.error('Common solutions:'); - console.error(' 1. Check if MinIO is running: docker ps | grep minio'); - console.error(' 2. Verify credentials in .env file'); - console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly'); - console.error(''); - process.exit(1); - } -} - -setupBucket(); +#!/usr/bin/env node +/** + * Setup MinIO Bucket + * + * Creates the required bucket for document storage if it doesn't exist + */ + +const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); +require('dotenv').config(); + +const BUCKET_NAME = 'xpeditis-documents'; + +// Configure S3 client for MinIO +const s3Client = new S3Client({ + region: process.env.AWS_REGION || 'us-east-1', + endpoint: process.env.AWS_S3_ENDPOINT || 'http://localhost:9000', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, // Required for MinIO +}); + +async function setupBucket() { + console.log('\nđŸȘŁ MinIO Bucket Setup'); + console.log('=========================================='); + console.log(`Bucket name: ${BUCKET_NAME}`); + console.log(`Endpoint: ${process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'}`); + console.log(''); + + try { + // Check if bucket exists + console.log('📋 Step 1: Checking if bucket exists...'); + try { + await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Bucket '${BUCKET_NAME}' already exists`); + console.log(''); + console.log('✅ Setup complete! The bucket is ready to use.'); + process.exit(0); + } catch (error) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + console.log(`â„č Bucket '${BUCKET_NAME}' does not exist`); + } else { + throw error; + } + } + + // Create bucket + console.log(''); + console.log('📋 Step 2: Creating bucket...'); + await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Bucket '${BUCKET_NAME}' created successfully!`); + + // Verify creation + console.log(''); + console.log('📋 Step 3: Verifying bucket...'); + await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Bucket '${BUCKET_NAME}' verified!`); + + console.log(''); + console.log('=========================================='); + console.log('✅ Setup complete! The bucket is ready to use.'); + console.log(''); + console.log('You can now:'); + console.log(' 1. Create CSV bookings via the frontend'); + console.log(' 2. Upload documents to this bucket'); + console.log(' 3. View files at: http://localhost:9001 (MinIO Console)'); + console.log(''); + + process.exit(0); + } catch (error) { + console.error(''); + console.error('❌ ERROR: Failed to setup bucket'); + console.error(''); + console.error('Error details:'); + console.error(` Name: ${error.name}`); + console.error(` Message: ${error.message}`); + if (error.$metadata) { + console.error(` HTTP Status: ${error.$metadata.httpStatusCode}`); + } + console.error(''); + console.error('Common solutions:'); + console.error(' 1. Check if MinIO is running: docker ps | grep minio'); + console.error(' 2. Verify credentials in .env file'); + console.error(' 3. Ensure AWS_S3_ENDPOINT is set correctly'); + console.error(''); + process.exit(1); + } +} + +setupBucket(); diff --git a/apps/backend/startup.js b/apps/backend/scripts/setup/startup.js similarity index 92% rename from apps/backend/startup.js rename to apps/backend/scripts/setup/startup.js index cb3fe47..02a7143 100644 --- a/apps/backend/startup.js +++ b/apps/backend/scripts/setup/startup.js @@ -1,102 +1,102 @@ -#!/usr/bin/env node - -const { Client } = require('pg'); -const { DataSource } = require('typeorm'); -const path = require('path'); -const { spawn } = require('child_process'); - -async function waitForPostgres(maxAttempts = 30) { - console.log('⏳ Waiting for PostgreSQL to be ready...'); - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const client = new Client({ - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT, 10), - user: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - }); - - await client.connect(); - await client.end(); - console.log('✅ PostgreSQL is ready'); - return true; - } catch (error) { - console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`); - await new Promise(resolve => setTimeout(resolve, 2000)); - } - } - - console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts'); - process.exit(1); -} - -async function runMigrations() { - console.log('🔄 Running database migrations...'); - - const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT, 10), - username: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], - migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], - synchronize: false, - logging: true, - }); - - try { - await AppDataSource.initialize(); - console.log('✅ DataSource initialized'); - - const migrations = await AppDataSource.runMigrations(); - - if (migrations.length === 0) { - console.log('✅ No pending migrations'); - } else { - console.log(`✅ Successfully ran ${migrations.length} migration(s):`); - migrations.forEach((migration) => { - console.log(` - ${migration.name}`); - }); - } - - await AppDataSource.destroy(); - console.log('✅ Database migrations completed'); - return true; - } catch (error) { - console.error('❌ Error during migration:', error); - process.exit(1); - } -} - -function startApplication() { - console.log('🚀 Starting NestJS application...'); - - const app = spawn('node', ['dist/main'], { - stdio: 'inherit', - env: process.env - }); - - app.on('exit', (code) => { - process.exit(code); - }); - - process.on('SIGTERM', () => app.kill('SIGTERM')); - process.on('SIGINT', () => app.kill('SIGINT')); -} - -async function main() { - console.log('🚀 Starting Xpeditis Backend...'); - - await waitForPostgres(); - await runMigrations(); - startApplication(); -} - -main().catch((error) => { - console.error('❌ Startup failed:', error); - process.exit(1); -}); +#!/usr/bin/env node + +const { Client } = require('pg'); +const { DataSource } = require('typeorm'); +const path = require('path'); +const { spawn } = require('child_process'); + +async function waitForPostgres(maxAttempts = 30) { + console.log('⏳ Waiting for PostgreSQL to be ready...'); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const client = new Client({ + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + }); + + await client.connect(); + await client.end(); + console.log('✅ PostgreSQL is ready'); + return true; + } catch (error) { + console.log(`⏳ Attempt ${attempt}/${maxAttempts} - PostgreSQL not ready, retrying...`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + console.error('❌ Failed to connect to PostgreSQL after', maxAttempts, 'attempts'); + process.exit(1); +} + +async function runMigrations() { + console.log('🔄 Running database migrations...'); + + const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], + migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], + synchronize: false, + logging: true, + }); + + try { + await AppDataSource.initialize(); + console.log('✅ DataSource initialized'); + + const migrations = await AppDataSource.runMigrations(); + + if (migrations.length === 0) { + console.log('✅ No pending migrations'); + } else { + console.log(`✅ Successfully ran ${migrations.length} migration(s):`); + migrations.forEach(migration => { + console.log(` - ${migration.name}`); + }); + } + + await AppDataSource.destroy(); + console.log('✅ Database migrations completed'); + return true; + } catch (error) { + console.error('❌ Error during migration:', error); + process.exit(1); + } +} + +function startApplication() { + console.log('🚀 Starting NestJS application...'); + + const app = spawn('node', ['dist/main'], { + stdio: 'inherit', + env: process.env, + }); + + app.on('exit', code => { + process.exit(code); + }); + + process.on('SIGTERM', () => app.kill('SIGTERM')); + process.on('SIGINT', () => app.kill('SIGINT')); +} + +async function main() { + console.log('🚀 Starting Xpeditis Backend...'); + + await waitForPostgres(); + await runMigrations(); + startApplication(); +} + +main().catch(error => { + console.error('❌ Startup failed:', error); + process.exit(1); +}); diff --git a/apps/backend/upload-test-documents.js b/apps/backend/scripts/setup/upload-test-documents.js similarity index 95% rename from apps/backend/upload-test-documents.js rename to apps/backend/scripts/setup/upload-test-documents.js index 69e3323..95d358a 100644 --- a/apps/backend/upload-test-documents.js +++ b/apps/backend/scripts/setup/upload-test-documents.js @@ -1,185 +1,185 @@ -/** - * Script to upload test documents to MinIO - */ - -const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3'); -const { Client: PgClient } = require('pg'); -const fs = require('fs'); -const path = require('path'); -require('dotenv').config(); - -const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; -const BUCKET_NAME = 'xpeditis-documents'; - -// Initialize MinIO client -const s3Client = new S3Client({ - region: 'us-east-1', - endpoint: MINIO_ENDPOINT, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', - }, - forcePathStyle: true, -}); - -// Create a simple PDF buffer (minimal valid PDF) -function createTestPDF(title) { - return Buffer.from( - `%PDF-1.4 -1 0 obj -<< -/Type /Catalog -/Pages 2 0 R ->> -endobj -2 0 obj -<< -/Type /Pages -/Kids [3 0 R] -/Count 1 ->> -endobj -3 0 obj -<< -/Type /Page -/Parent 2 0 R -/MediaBox [0 0 612 792] -/Contents 4 0 R -/Resources << -/Font << -/F1 << -/Type /Font -/Subtype /Type1 -/BaseFont /Helvetica ->> ->> ->> ->> -endobj -4 0 obj -<< -/Length 100 ->> -stream -BT -/F1 24 Tf -100 700 Td -(${title}) Tj -ET -endstream -endobj -xref -0 5 -0000000000 65535 f -0000000009 00000 n -0000000058 00000 n -0000000115 00000 n -0000000300 00000 n -trailer -<< -/Size 5 -/Root 1 0 R ->> -startxref -450 -%%EOF`, - 'utf-8' - ); -} - -async function uploadTestDocuments() { - const pgClient = new PgClient({ - host: process.env.DATABASE_HOST || 'localhost', - port: process.env.DATABASE_PORT || 5432, - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - }); - - try { - // Connect to database - await pgClient.connect(); - console.log('✅ Connected to database'); - - // Create bucket if it doesn't exist - try { - await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); - console.log(`✅ Created bucket: ${BUCKET_NAME}`); - } catch (error) { - if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') { - console.log(`✅ Bucket already exists: ${BUCKET_NAME}`); - } else { - console.log(`⚠ Could not create bucket (might already exist): ${error.message}`); - } - } - - // Get all CSV bookings with documents - const result = await pgClient.query( - `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` - ); - - console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`); - - let uploadedCount = 0; - - for (const row of result.rows) { - const bookingId = row.id; - const documents = row.documents; - - console.log(`\n📩 Processing booking: ${bookingId}`); - - for (const doc of documents) { - if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) { - console.log(` ⏭ Skipping document (not a MinIO URL): ${doc.fileName}`); - continue; - } - - // Extract the S3 key from the URL - const url = new URL(doc.filePath); - const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); - - // Create test PDF content - const pdfContent = createTestPDF(doc.fileName || 'Test Document'); - - try { - // Upload to MinIO - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: pdfContent, - ContentType: doc.mimeType || 'application/pdf', - }) - ); - - console.log(` ✅ Uploaded: ${doc.fileName}`); - console.log(` Path: ${key}`); - uploadedCount++; - } catch (error) { - console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message); - } - } - } - - console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`); - console.log(`\n📍 MinIO Console: http://localhost:9001`); - console.log(` Username: minioadmin`); - console.log(` Password: minioadmin`); - } catch (error) { - console.error('❌ Error:', error); - throw error; - } finally { - await pgClient.end(); - console.log('\n👋 Disconnected from database'); - } -} - -uploadTestDocuments() - .then(() => { - console.log('\n✅ Script completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Script failed:', error); - process.exit(1); - }); +/** + * Script to upload test documents to MinIO + */ + +const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3'); +const { Client: PgClient } = require('pg'); +const fs = require('fs'); +const path = require('path'); +require('dotenv').config(); + +const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000'; +const BUCKET_NAME = 'xpeditis-documents'; + +// Initialize MinIO client +const s3Client = new S3Client({ + region: 'us-east-1', + endpoint: MINIO_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', + }, + forcePathStyle: true, +}); + +// Create a simple PDF buffer (minimal valid PDF) +function createTestPDF(title) { + return Buffer.from( + `%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/MediaBox [0 0 612 792] +/Contents 4 0 R +/Resources << +/Font << +/F1 << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +endobj +4 0 obj +<< +/Length 100 +>> +stream +BT +/F1 24 Tf +100 700 Td +(${title}) Tj +ET +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000300 00000 n +trailer +<< +/Size 5 +/Root 1 0 R +>> +startxref +450 +%%EOF`, + 'utf-8' + ); +} + +async function uploadTestDocuments() { + const pgClient = new PgClient({ + host: process.env.DATABASE_HOST || 'localhost', + port: process.env.DATABASE_PORT || 5432, + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + }); + + try { + // Connect to database + await pgClient.connect(); + console.log('✅ Connected to database'); + + // Create bucket if it doesn't exist + try { + await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME })); + console.log(`✅ Created bucket: ${BUCKET_NAME}`); + } catch (error) { + if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') { + console.log(`✅ Bucket already exists: ${BUCKET_NAME}`); + } else { + console.log(`⚠ Could not create bucket (might already exist): ${error.message}`); + } + } + + // Get all CSV bookings with documents + const result = await pgClient.query( + `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL` + ); + + console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`); + + let uploadedCount = 0; + + for (const row of result.rows) { + const bookingId = row.id; + const documents = row.documents; + + console.log(`\n📩 Processing booking: ${bookingId}`); + + for (const doc of documents) { + if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) { + console.log(` ⏭ Skipping document (not a MinIO URL): ${doc.fileName}`); + continue; + } + + // Extract the S3 key from the URL + const url = new URL(doc.filePath); + const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, ''); + + // Create test PDF content + const pdfContent = createTestPDF(doc.fileName || 'Test Document'); + + try { + // Upload to MinIO + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: pdfContent, + ContentType: doc.mimeType || 'application/pdf', + }) + ); + + console.log(` ✅ Uploaded: ${doc.fileName}`); + console.log(` Path: ${key}`); + uploadedCount++; + } catch (error) { + console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message); + } + } + } + + console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`); + console.log(`\n📍 MinIO Console: http://localhost:9001`); + console.log(` Username: minioadmin`); + console.log(` Password: minioadmin`); + } catch (error) { + console.error('❌ Error:', error); + throw error; + } finally { + await pgClient.end(); + console.log('\n👋 Disconnected from database'); + } +} + +uploadTestDocuments() + .then(() => { + console.log('\n✅ Script completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\n❌ Script failed:', error); + process.exit(1); + }); diff --git a/apps/backend/create-test-booking.js b/apps/backend/scripts/test/create-test-booking.js similarity index 92% rename from apps/backend/create-test-booking.js rename to apps/backend/scripts/test/create-test-booking.js index cf0bd73..2740f62 100644 --- a/apps/backend/create-test-booking.js +++ b/apps/backend/scripts/test/create-test-booking.js @@ -1,114 +1,113 @@ -/** - * Script pour crĂ©er un booking de test avec statut PENDING - * Usage: node create-test-booking.js - */ - -const { Client } = require('pg'); -const { v4: uuidv4 } = require('uuid'); - -async function createTestBooking() { - const client = new Client({ - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432'), - database: process.env.DATABASE_NAME || 'xpeditis_dev', - user: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - }); - - try { - await client.connect(); - console.log('✅ ConnectĂ© Ă  la base de donnĂ©es'); - - const bookingId = uuidv4(); - const confirmationToken = uuidv4(); - const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com - const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63'; - - // Create dummy documents in JSONB format - const dummyDocuments = JSON.stringify([ - { - id: uuidv4(), - type: 'BILL_OF_LADING', - fileName: 'bill-of-lading.pdf', - filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf', - mimeType: 'application/pdf', - size: 102400, // 100KB - uploadedAt: new Date().toISOString(), - }, - { - id: uuidv4(), - type: 'PACKING_LIST', - fileName: 'packing-list.pdf', - filePath: 'https://dummy-storage.com/documents/packing-list.pdf', - mimeType: 'application/pdf', - size: 51200, // 50KB - uploadedAt: new Date().toISOString(), - }, - { - id: uuidv4(), - type: 'COMMERCIAL_INVOICE', - fileName: 'commercial-invoice.pdf', - filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf', - mimeType: 'application/pdf', - size: 76800, // 75KB - uploadedAt: new Date().toISOString(), - }, - ]); - - const query = ` - INSERT INTO csv_bookings ( - id, user_id, organization_id, carrier_name, carrier_email, - origin, destination, volume_cbm, weight_kg, pallet_count, - price_usd, price_eur, primary_currency, transit_days, container_type, - status, confirmation_token, requested_at, notes, documents - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19 - ) RETURNING id, confirmation_token; - `; - - const values = [ - bookingId, - userId, - organizationId, - 'Test Carrier', - 'test@carrier.com', - 'NLRTM', // Rotterdam - 'USNYC', // New York - 25.5, // volume_cbm - 3500, // weight_kg - 10, // pallet_count - 1850.50, // price_usd - 1665.45, // price_eur - 'USD', // primary_currency - 28, // transit_days - 'LCL', // container_type - 'PENDING', // status - IMPORTANT! - confirmationToken, - 'Test booking created by script', - dummyDocuments, // documents JSONB - ]; - - const result = await client.query(query, values); - - console.log('\n🎉 Booking de test créé avec succĂšs!'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(`📩 Booking ID: ${bookingId}`); - console.log(`🔑 Token: ${confirmationToken}`); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - console.log('🔗 URLs de test:'); - console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`); - console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`); - console.log('\n📧 URL API (pour curl):'); - console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`); - console.log('\n✅ Ce booking est en statut PENDING et peut ĂȘtre acceptĂ©/refusĂ©.\n'); - - } catch (error) { - console.error('❌ Erreur:', error.message); - console.error(error); - } finally { - await client.end(); - } -} - -createTestBooking(); +/** + * Script pour crĂ©er un booking de test avec statut PENDING + * Usage: node create-test-booking.js + */ + +const { Client } = require('pg'); +const { v4: uuidv4 } = require('uuid'); + +async function createTestBooking() { + const client = new Client({ + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5432'), + database: process.env.DATABASE_NAME || 'xpeditis_dev', + user: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + }); + + try { + await client.connect(); + console.log('✅ ConnectĂ© Ă  la base de donnĂ©es'); + + const bookingId = uuidv4(); + const confirmationToken = uuidv4(); + const userId = '8cf7d5b3-d94f-44aa-bb5a-080002919dd1'; // User demo@xpeditis.com + const organizationId = '199fafa9-d26f-4cf9-9206-73432baa8f63'; + + // Create dummy documents in JSONB format + const dummyDocuments = JSON.stringify([ + { + id: uuidv4(), + type: 'BILL_OF_LADING', + fileName: 'bill-of-lading.pdf', + filePath: 'https://dummy-storage.com/documents/bill-of-lading.pdf', + mimeType: 'application/pdf', + size: 102400, // 100KB + uploadedAt: new Date().toISOString(), + }, + { + id: uuidv4(), + type: 'PACKING_LIST', + fileName: 'packing-list.pdf', + filePath: 'https://dummy-storage.com/documents/packing-list.pdf', + mimeType: 'application/pdf', + size: 51200, // 50KB + uploadedAt: new Date().toISOString(), + }, + { + id: uuidv4(), + type: 'COMMERCIAL_INVOICE', + fileName: 'commercial-invoice.pdf', + filePath: 'https://dummy-storage.com/documents/commercial-invoice.pdf', + mimeType: 'application/pdf', + size: 76800, // 75KB + uploadedAt: new Date().toISOString(), + }, + ]); + + const query = ` + INSERT INTO csv_bookings ( + id, user_id, organization_id, carrier_name, carrier_email, + origin, destination, volume_cbm, weight_kg, pallet_count, + price_usd, price_eur, primary_currency, transit_days, container_type, + status, confirmation_token, requested_at, notes, documents + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19 + ) RETURNING id, confirmation_token; + `; + + const values = [ + bookingId, + userId, + organizationId, + 'Test Carrier', + 'test@carrier.com', + 'NLRTM', // Rotterdam + 'USNYC', // New York + 25.5, // volume_cbm + 3500, // weight_kg + 10, // pallet_count + 1850.5, // price_usd + 1665.45, // price_eur + 'USD', // primary_currency + 28, // transit_days + 'LCL', // container_type + 'PENDING', // status - IMPORTANT! + confirmationToken, + 'Test booking created by script', + dummyDocuments, // documents JSONB + ]; + + const result = await client.query(query, values); + + console.log('\n🎉 Booking de test créé avec succĂšs!'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`📩 Booking ID: ${bookingId}`); + console.log(`🔑 Token: ${confirmationToken}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + console.log('🔗 URLs de test:'); + console.log(` Accept: http://localhost:3000/carrier/accept/${confirmationToken}`); + console.log(` Reject: http://localhost:3000/carrier/reject/${confirmationToken}`); + console.log('\n📧 URL API (pour curl):'); + console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`); + console.log('\n✅ Ce booking est en statut PENDING et peut ĂȘtre acceptĂ©/refusĂ©.\n'); + } catch (error) { + console.error('❌ Erreur:', error.message); + console.error(error); + } finally { + await client.end(); + } +} + +createTestBooking(); diff --git a/apps/backend/diagnostic-complet.sh b/apps/backend/scripts/test/diagnostic-complet.sh similarity index 100% rename from apps/backend/diagnostic-complet.sh rename to apps/backend/scripts/test/diagnostic-complet.sh diff --git a/apps/backend/login-and-test.js b/apps/backend/scripts/test/login-and-test.js similarity index 87% rename from apps/backend/login-and-test.js rename to apps/backend/scripts/test/login-and-test.js index b94a702..c90fea7 100644 --- a/apps/backend/login-and-test.js +++ b/apps/backend/scripts/test/login-and-test.js @@ -9,18 +9,21 @@ async function loginAndTestEmail() { console.log('🔐 Connexion...'); const loginResponse = await axios.post(`${API_URL}/auth/login`, { email: 'admin@xpeditis.com', - password: 'Admin123!@#' + password: 'Admin123!@#', }); const token = loginResponse.data.accessToken; console.log('✅ ConnectĂ© avec succĂšs\n'); // 2. CrĂ©er un CSV booking pour tester l'envoi d'email - console.log('📧 CrĂ©ation d\'une CSV booking pour tester l\'envoi d\'email...'); + console.log("📧 CrĂ©ation d'une CSV booking pour tester l'envoi d'email..."); const form = new FormData(); const testFile = Buffer.from('Test document PDF content'); - form.append('documents', testFile, { filename: 'test-doc.pdf', contentType: 'application/pdf' }); + form.append('documents', testFile, { + filename: 'test-doc.pdf', + contentType: 'application/pdf', + }); form.append('carrierName', 'Test Carrier'); form.append('carrierEmail', 'testcarrier@example.com'); @@ -39,8 +42,8 @@ async function loginAndTestEmail() { const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, { headers: { ...form.getHeaders(), - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, }); console.log('✅ CSV Booking créé:', bookingResponse.data.id); @@ -50,7 +53,6 @@ async function loginAndTestEmail() { console.log('2. VĂ©rifier Mailtrap inbox: https://mailtrap.io/inboxes'); console.log('3. Email devrait ĂȘtre envoyĂ© Ă : testcarrier@example.com'); console.log('\n⏳ Attendez quelques secondes puis vĂ©rifiez les logs du backend...'); - } catch (error) { console.error('❌ ERREUR:'); if (error.response) { diff --git a/apps/backend/start-and-test.sh b/apps/backend/scripts/test/start-and-test.sh similarity index 100% rename from apps/backend/start-and-test.sh rename to apps/backend/scripts/test/start-and-test.sh diff --git a/apps/backend/test-booking-creation.sh b/apps/backend/scripts/test/test-booking-creation.sh similarity index 100% rename from apps/backend/test-booking-creation.sh rename to apps/backend/scripts/test/test-booking-creation.sh diff --git a/apps/backend/test-booking-simple.sh b/apps/backend/scripts/test/test-booking-simple.sh similarity index 100% rename from apps/backend/test-booking-simple.sh rename to apps/backend/scripts/test/test-booking-simple.sh diff --git a/apps/backend/test-booking-workflow.js b/apps/backend/scripts/test/test-booking-workflow.js similarity index 88% rename from apps/backend/test-booking-workflow.js rename to apps/backend/scripts/test/test-booking-workflow.js index c2f25d8..19889ee 100644 --- a/apps/backend/test-booking-workflow.js +++ b/apps/backend/scripts/test/test-booking-workflow.js @@ -12,7 +12,7 @@ const API_BASE = 'http://localhost:4000/api/v1'; // Test credentials - you need to use real credentials from your database const TEST_USER = { email: 'admin@xpeditis.com', // Change this to a real user email - password: 'Admin123!', // Change this to the real password + password: 'Admin123!', // Change this to the real password }; async function testWorkflow() { @@ -56,16 +56,12 @@ async function testWorkflow() { contentType: 'application/pdf', }); - const bookingResponse = await axios.post( - `${API_BASE}/csv-bookings`, - form, - { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${token}`, - }, - } - ); + const bookingResponse = await axios.post(`${API_BASE}/csv-bookings`, form, { + headers: { + ...form.getHeaders(), + Authorization: `Bearer ${token}`, + }, + }); console.log('✅ Booking created successfully!'); console.log('📩 Booking ID:', bookingResponse.data.id); @@ -80,7 +76,9 @@ async function testWorkflow() { console.error('❌ Error:', error.response?.data || error.message); if (error.response?.status === 401) { - console.error('\n⚠ Authentication failed. Please update TEST_USER credentials in the script.'); + console.error( + '\n⚠ Authentication failed. Please update TEST_USER credentials in the script.' + ); } if (error.response?.status === 400) { diff --git a/apps/backend/test-carrier-email-fix.js b/apps/backend/scripts/test/test-carrier-email-fix.js similarity index 95% rename from apps/backend/test-carrier-email-fix.js rename to apps/backend/scripts/test/test-carrier-email-fix.js index 88b7b6e..2e60159 100644 --- a/apps/backend/test-carrier-email-fix.js +++ b/apps/backend/scripts/test/test-carrier-email-fix.js @@ -1,228 +1,230 @@ -/** - * Script de test pour vĂ©rifier l'envoi d'email aux transporteurs - * - * Usage: node test-carrier-email-fix.js - */ - -const nodemailer = require('nodemailer'); - -async function testEmailConfig() { - console.log('🔍 Test de configuration email Mailtrap...\n'); - - const config = { - host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io', - port: parseInt(process.env.SMTP_PORT || '2525'), - user: process.env.SMTP_USER || '2597bd31d265eb', - pass: process.env.SMTP_PASS || 'cd126234193c89', - }; - - console.log('📧 Configuration SMTP:'); - console.log(` Host: ${config.host}`); - console.log(` Port: ${config.port}`); - console.log(` User: ${config.user}`); - console.log(` Pass: ${config.pass.substring(0, 4)}***\n`); - - // Test 1: Configuration standard (peut Ă©chouer avec timeout DNS) - console.log('Test 1: Configuration standard...'); - try { - const transporter1 = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: false, - auth: { - user: config.user, - pass: config.pass, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - }); - - await transporter1.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@xpeditis.com', - subject: 'Test Email - Configuration Standard', - html: '

Test réussi!

Configuration standard fonctionne.

', - }); - - console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n'); - } catch (error) { - console.error('❌ Test 1 ÉCHOUÉ:', error.message); - console.error(' Code:', error.code); - console.error(' Timeout?', error.message.includes('ETIMEOUT')); - console.log(''); - } - - // Test 2: Configuration avec IP directe (devrait toujours fonctionner) - console.log('Test 2: Configuration avec IP directe...'); - try { - const useDirectIP = config.host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : config.host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; - - console.log(` Utilisation IP directe: ${useDirectIP}`); - console.log(` Host rĂ©el: ${actualHost}`); - console.log(` Server name (TLS): ${serverName}`); - - const transporter2 = nodemailer.createTransport({ - host: actualHost, - port: config.port, - secure: false, - auth: { - user: config.user, - pass: config.pass, - }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, - }); - - const result = await transporter2.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@xpeditis.com', - subject: 'Test Email - Configuration IP Directe', - html: '

Test réussi!

Configuration avec IP directe fonctionne.

', - }); - - console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK'); - console.log(` Message ID: ${result.messageId}`); - console.log(` Response: ${result.response}\n`); - } catch (error) { - console.error('❌ Test 2 ÉCHOUÉ:', error.message); - console.error(' Code:', error.code); - console.log(''); - } - - // Test 3: Template HTML de booking transporteur - console.log('Test 3: Envoi avec template HTML complet...'); - try { - const useDirectIP = config.host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : config.host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; - - const transporter3 = nodemailer.createTransport({ - host: actualHost, - port: config.port, - secure: false, - auth: { - user: config.user, - pass: config.pass, - }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, - }); - - const bookingData = { - bookingId: 'TEST-' + Date.now(), - origin: 'FRPAR', - destination: 'USNYC', - volumeCBM: 10.5, - weightKG: 850, - palletCount: 4, - priceUSD: 1500, - priceEUR: 1350, - primaryCurrency: 'USD', - transitDays: 15, - containerType: '20FT', - documents: [ - { type: 'Bill of Lading', fileName: 'bol.pdf' }, - { type: 'Packing List', fileName: 'packing_list.pdf' }, - ], - acceptUrl: 'http://localhost:3000/carrier/booking/accept', - rejectUrl: 'http://localhost:3000/carrier/booking/reject', - }; - - const htmlTemplate = ` - - - - -
-
-

🚱 Nouvelle demande de rĂ©servation

-

Xpeditis

-
-
-

Bonjour,

-

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

-

📋 DĂ©tails du transport

- - - - - - - - - - - - - -
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Prix - ${bookingData.priceUSD} USD -
-
-

Veuillez confirmer votre décision :

- ✓ Accepter - ✗ Refuser -
-
-

- ⚠ Important
- Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. -

-
-
-
-

Référence : ${bookingData.bookingId}

-

© 2025 Xpeditis. Tous droits réservés.

-
-
- - - `; - - const result = await transporter3.sendMail({ - from: 'noreply@xpeditis.com', - to: 'carrier@test.com', - subject: `Nouvelle demande de rĂ©servation - ${bookingData.origin} → ${bookingData.destination}`, - html: htmlTemplate, - }); - - console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyĂ©'); - console.log(` Message ID: ${result.messageId}`); - console.log(` Response: ${result.response}\n`); - } catch (error) { - console.error('❌ Test 3 ÉCHOUÉ:', error.message); - console.error(' Code:', error.code); - console.log(''); - } - - console.log('📊 RĂ©sumĂ© des tests:'); - console.log(' ✓ VĂ©rifiez Mailtrap inbox: https://mailtrap.io/inboxes'); - console.log(' ✓ Recherchez les emails de test ci-dessus'); - console.log(' ✓ Si Test 2 et 3 rĂ©ussissent, le backend doit ĂȘtre corrigĂ© avec la configuration IP directe\n'); -} - -// Run test -testEmailConfig() - .then(() => { - console.log('✅ Tests terminĂ©s avec succĂšs'); - process.exit(0); - }) - .catch((error) => { - console.error('❌ Erreur lors des tests:', error); - process.exit(1); - }); +/** + * Script de test pour vĂ©rifier l'envoi d'email aux transporteurs + * + * Usage: node test-carrier-email-fix.js + */ + +const nodemailer = require('nodemailer'); + +async function testEmailConfig() { + console.log('🔍 Test de configuration email Mailtrap...\n'); + + const config = { + host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io', + port: parseInt(process.env.SMTP_PORT || '2525'), + user: process.env.SMTP_USER || '2597bd31d265eb', + pass: process.env.SMTP_PASS || 'cd126234193c89', + }; + + console.log('📧 Configuration SMTP:'); + console.log(` Host: ${config.host}`); + console.log(` Port: ${config.port}`); + console.log(` User: ${config.user}`); + console.log(` Pass: ${config.pass.substring(0, 4)}***\n`); + + // Test 1: Configuration standard (peut Ă©chouer avec timeout DNS) + console.log('Test 1: Configuration standard...'); + try { + const transporter1 = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: false, + auth: { + user: config.user, + pass: config.pass, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + }); + + await transporter1.sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@xpeditis.com', + subject: 'Test Email - Configuration Standard', + html: '

Test réussi!

Configuration standard fonctionne.

', + }); + + console.log('✅ Test 1 RÉUSSI - Configuration standard OK\n'); + } catch (error) { + console.error('❌ Test 1 ÉCHOUÉ:', error.message); + console.error(' Code:', error.code); + console.error(' Timeout?', error.message.includes('ETIMEOUT')); + console.log(''); + } + + // Test 2: Configuration avec IP directe (devrait toujours fonctionner) + console.log('Test 2: Configuration avec IP directe...'); + try { + const useDirectIP = config.host.includes('mailtrap.io'); + const actualHost = useDirectIP ? '3.209.246.195' : config.host; + const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; + + console.log(` Utilisation IP directe: ${useDirectIP}`); + console.log(` Host rĂ©el: ${actualHost}`); + console.log(` Server name (TLS): ${serverName}`); + + const transporter2 = nodemailer.createTransport({ + host: actualHost, + port: config.port, + secure: false, + auth: { + user: config.user, + pass: config.pass, + }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, + }); + + const result = await transporter2.sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@xpeditis.com', + subject: 'Test Email - Configuration IP Directe', + html: '

Test réussi!

Configuration avec IP directe fonctionne.

', + }); + + console.log('✅ Test 2 RÉUSSI - Configuration IP directe OK'); + console.log(` Message ID: ${result.messageId}`); + console.log(` Response: ${result.response}\n`); + } catch (error) { + console.error('❌ Test 2 ÉCHOUÉ:', error.message); + console.error(' Code:', error.code); + console.log(''); + } + + // Test 3: Template HTML de booking transporteur + console.log('Test 3: Envoi avec template HTML complet...'); + try { + const useDirectIP = config.host.includes('mailtrap.io'); + const actualHost = useDirectIP ? '3.209.246.195' : config.host; + const serverName = useDirectIP ? 'smtp.mailtrap.io' : config.host; + + const transporter3 = nodemailer.createTransport({ + host: actualHost, + port: config.port, + secure: false, + auth: { + user: config.user, + pass: config.pass, + }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, + }); + + const bookingData = { + bookingId: 'TEST-' + Date.now(), + origin: 'FRPAR', + destination: 'USNYC', + volumeCBM: 10.5, + weightKG: 850, + palletCount: 4, + priceUSD: 1500, + priceEUR: 1350, + primaryCurrency: 'USD', + transitDays: 15, + containerType: '20FT', + documents: [ + { type: 'Bill of Lading', fileName: 'bol.pdf' }, + { type: 'Packing List', fileName: 'packing_list.pdf' }, + ], + acceptUrl: 'http://localhost:3000/carrier/booking/accept', + rejectUrl: 'http://localhost:3000/carrier/booking/reject', + }; + + const htmlTemplate = ` + + + + +
+
+

🚱 Nouvelle demande de rĂ©servation

+

Xpeditis

+
+
+

Bonjour,

+

Vous avez reçu une nouvelle demande de réservation via Xpeditis.

+

📋 DĂ©tails du transport

+ + + + + + + + + + + + + +
Route${bookingData.origin} → ${bookingData.destination}
Volume${bookingData.volumeCBM} CBM
Prix + ${bookingData.priceUSD} USD +
+
+

Veuillez confirmer votre décision :

+ ✓ Accepter + ✗ Refuser +
+
+

+ ⚠ Important
+ Cette demande expire automatiquement dans 7 jours si aucune action n'est prise. +

+
+
+
+

Référence : ${bookingData.bookingId}

+

© 2025 Xpeditis. Tous droits réservés.

+
+
+ + + `; + + const result = await transporter3.sendMail({ + from: 'noreply@xpeditis.com', + to: 'carrier@test.com', + subject: `Nouvelle demande de rĂ©servation - ${bookingData.origin} → ${bookingData.destination}`, + html: htmlTemplate, + }); + + console.log('✅ Test 3 RÉUSSI - Email complet avec template envoyĂ©'); + console.log(` Message ID: ${result.messageId}`); + console.log(` Response: ${result.response}\n`); + } catch (error) { + console.error('❌ Test 3 ÉCHOUÉ:', error.message); + console.error(' Code:', error.code); + console.log(''); + } + + console.log('📊 RĂ©sumĂ© des tests:'); + console.log(' ✓ VĂ©rifiez Mailtrap inbox: https://mailtrap.io/inboxes'); + console.log(' ✓ Recherchez les emails de test ci-dessus'); + console.log( + ' ✓ Si Test 2 et 3 rĂ©ussissent, le backend doit ĂȘtre corrigĂ© avec la configuration IP directe\n' + ); +} + +// Run test +testEmailConfig() + .then(() => { + console.log('✅ Tests terminĂ©s avec succĂšs'); + process.exit(0); + }) + .catch(error => { + console.error('❌ Erreur lors des tests:', error); + process.exit(1); + }); diff --git a/apps/backend/scripts/test/test-carrier-email.js b/apps/backend/scripts/test/test-carrier-email.js new file mode 100644 index 0000000..5b44776 --- /dev/null +++ b/apps/backend/scripts/test/test-carrier-email.js @@ -0,0 +1,32 @@ +const nodemailer = require('nodemailer'); + +const transporter = nodemailer.createTransport({ + host: 'sandbox.smtp.mailtrap.io', + port: 2525, + auth: { + user: '2597bd31d265eb', + pass: 'cd126234193c89', + }, +}); + +console.log("🔄 Tentative d'envoi d'email..."); + +transporter + .sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@example.com', + subject: 'Test Email depuis Portail Transporteur', + text: 'Email de test pour vĂ©rifier la configuration', + }) + .then(info => { + console.log('✅ Email envoyĂ©:', info.messageId); + console.log('📧 Response:', info.response); + process.exit(0); + }) + .catch(err => { + console.error('❌ Erreur:', err.message); + console.error('Code:', err.code); + console.error('Command:', err.command); + console.error('Stack:', err.stack); + process.exit(1); + }); diff --git a/apps/backend/test-csv-api.js b/apps/backend/scripts/test/test-csv-api.js similarity index 100% rename from apps/backend/test-csv-api.js rename to apps/backend/scripts/test/test-csv-api.js diff --git a/apps/backend/test-csv-api.sh b/apps/backend/scripts/test/test-csv-api.sh similarity index 100% rename from apps/backend/test-csv-api.sh rename to apps/backend/scripts/test/test-csv-api.sh diff --git a/apps/backend/test-csv-booking-api.sh b/apps/backend/scripts/test/test-csv-booking-api.sh similarity index 100% rename from apps/backend/test-csv-booking-api.sh rename to apps/backend/scripts/test/test-csv-booking-api.sh diff --git a/apps/backend/test-csv-offers-api.sh b/apps/backend/scripts/test/test-csv-offers-api.sh similarity index 100% rename from apps/backend/test-csv-offers-api.sh rename to apps/backend/scripts/test/test-csv-offers-api.sh diff --git a/apps/backend/test-email-ip.js b/apps/backend/scripts/test/test-email-ip.js similarity index 90% rename from apps/backend/test-email-ip.js rename to apps/backend/scripts/test/test-email-ip.js index 1ea3bbf..e7d7093 100644 --- a/apps/backend/test-email-ip.js +++ b/apps/backend/scripts/test/test-email-ip.js @@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config); console.log('\n1ïžâƒŁ Verifying SMTP connection...'); -transporter.verify() +transporter + .verify() .then(() => { console.log('✅ SMTP connection verified!'); console.log('\n2ïžâƒŁ Sending test email...'); @@ -40,17 +41,17 @@ transporter.verify() from: 'noreply@xpeditis.com', to: 'test@example.com', subject: 'Test Xpeditis - Envoi Direct IP', - html: '

✅ Email envoyĂ© avec succĂšs!

Ce test utilise l\'IP directe pour contourner le DNS.

', + html: "

✅ Email envoyĂ© avec succĂšs!

Ce test utilise l'IP directe pour contourner le DNS.

", }); }) - .then((info) => { + .then(info => { console.log('✅ Email sent successfully!'); console.log('📧 Message ID:', info.messageId); console.log('📬 Response:', info.response); console.log('\n🎉 SUCCESS! Email sending works with IP directly.'); process.exit(0); }) - .catch((error) => { + .catch(error => { console.error('\n❌ ERROR:', error.message); console.error('Code:', error.code); console.error('Command:', error.command); diff --git a/apps/backend/test-email-service.js b/apps/backend/scripts/test/test-email-service.js similarity index 76% rename from apps/backend/test-email-service.js rename to apps/backend/scripts/test/test-email-service.js index 973e401..94da3f0 100644 --- a/apps/backend/test-email-service.js +++ b/apps/backend/scripts/test/test-email-service.js @@ -6,7 +6,8 @@ const axios = require('axios'); const API_URL = 'http://localhost:4000/api/v1'; // Token d'authentification (admin@xpeditis.com) -const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I'; +const AUTH_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I'; async function testCsvBookingEmail() { console.log('đŸ§Ș Test envoi email via CSV booking...\n'); @@ -19,7 +20,10 @@ async function testCsvBookingEmail() { // CrĂ©er un fichier de test temporaire const testFile = Buffer.from('Test document content'); - form.append('documents', testFile, { filename: 'test-document.pdf', contentType: 'application/pdf' }); + form.append('documents', testFile, { + filename: 'test-document.pdf', + contentType: 'application/pdf', + }); // Ajouter les champs du formulaire form.append('carrierName', 'Test Carrier Email'); @@ -41,8 +45,8 @@ async function testCsvBookingEmail() { const response = await axios.post(`${API_URL}/csv-bookings`, form, { headers: { ...form.getHeaders(), - 'Authorization': `Bearer ${AUTH_TOKEN}` - } + Authorization: `Bearer ${AUTH_TOKEN}`, + }, }); console.log('✅ RĂ©ponse reçue:', response.status); @@ -51,11 +55,10 @@ async function testCsvBookingEmail() { console.log('1. Les logs du backend pour voir "Email sent to carrier:"'); console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes'); console.log('3. Email destinataire: test-carrier@example.com'); - } catch (error) { console.error('❌ Erreur:', error.response?.data || error.message); if (error.response?.status === 401) { - console.error('\n⚠ Token expirĂ©. Connectez-vous d\'abord avec:'); + console.error("\n⚠ Token expirĂ©. Connectez-vous d'abord avec:"); console.error('POST /api/v1/auth/login'); console.error('{ "email": "admin@xpeditis.com", "password": "..." }'); } diff --git a/apps/backend/test-email.js b/apps/backend/scripts/test/test-email.js similarity index 95% rename from apps/backend/test-email.js rename to apps/backend/scripts/test/test-email.js index f2a1a5e..ed32a70 100644 --- a/apps/backend/test-email.js +++ b/apps/backend/scripts/test/test-email.js @@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config); console.log('\nVerifying SMTP connection...'); -transporter.verify() +transporter + .verify() .then(() => { console.log('✅ SMTP connection verified successfully!'); console.log('\nSending test email...'); @@ -43,13 +44,13 @@ transporter.verify() html: '

Test Email

If you see this, email sending works!

', }); }) - .then((info) => { + .then(info => { console.log('✅ Email sent successfully!'); console.log('Message ID:', info.messageId); console.log('Response:', info.response); process.exit(0); }) - .catch((error) => { + .catch(error => { console.error('❌ Error:', error.message); console.error('Full error:', error); process.exit(1); diff --git a/apps/backend/test-smtp-simple.js b/apps/backend/scripts/test/test-smtp-simple.js similarity index 90% rename from apps/backend/test-smtp-simple.js rename to apps/backend/scripts/test/test-smtp-simple.js index 1b2b4d4..b80b4ce 100644 --- a/apps/backend/test-smtp-simple.js +++ b/apps/backend/scripts/test/test-smtp-simple.js @@ -1,74 +1,74 @@ -#!/usr/bin/env node - -// Test SMTP ultra-simple pour identifier le problĂšme -const nodemailer = require('nodemailer'); -require('dotenv').config(); - -console.log('🔍 Test SMTP Simple\n'); -console.log('Configuration:'); -console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI'); -console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI'); -console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI'); -console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI'); -console.log(''); - -const host = process.env.SMTP_HOST; -const port = parseInt(process.env.SMTP_PORT || '2525'); -const user = process.env.SMTP_USER; -const pass = process.env.SMTP_PASS; - -// Appliquer le mĂȘme fix DNS que dans email.adapter.ts -const useDirectIP = host && host.includes('mailtrap.io'); -const actualHost = useDirectIP ? '3.209.246.195' : host; -const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; - -console.log('Fix DNS:'); -console.log(' Utilise IP directe:', useDirectIP); -console.log(' Host rĂ©el:', actualHost); -console.log(' Server name:', serverName); -console.log(''); - -const transporter = nodemailer.createTransport({ - host: actualHost, - port, - secure: false, - auth: { user, pass }, - tls: { - rejectUnauthorized: false, - servername: serverName, - }, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 30000, - dnsTimeout: 10000, -}); - -async function test() { - try { - console.log('Test 1: VĂ©rification de la connexion...'); - await transporter.verify(); - console.log('✅ Connexion SMTP OK\n'); - - console.log('Test 2: Envoi d\'un email...'); - const info = await transporter.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@example.com', - subject: 'Test - ' + new Date().toISOString(), - html: '

Test réussi!

Ce message confirme que l\'envoi d\'email fonctionne.

', - }); - - console.log('✅ Email envoyĂ© avec succĂšs!'); - console.log(' Message ID:', info.messageId); - console.log(' Response:', info.response); - console.log(''); - console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!'); - process.exit(0); - } catch (error) { - console.error('❌ ERREUR:', error.message); - console.error(' Code:', error.code); - console.error(' Command:', error.command); - process.exit(1); - } -} - -test(); +#!/usr/bin/env node + +// Test SMTP ultra-simple pour identifier le problĂšme +const nodemailer = require('nodemailer'); +require('dotenv').config(); + +console.log('🔍 Test SMTP Simple\n'); +console.log('Configuration:'); +console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'NON DÉFINI'); +console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'NON DÉFINI'); +console.log(' SMTP_USER:', process.env.SMTP_USER || 'NON DÉFINI'); +console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'NON DÉFINI'); +console.log(''); + +const host = process.env.SMTP_HOST; +const port = parseInt(process.env.SMTP_PORT || '2525'); +const user = process.env.SMTP_USER; +const pass = process.env.SMTP_PASS; + +// Appliquer le mĂȘme fix DNS que dans email.adapter.ts +const useDirectIP = host && host.includes('mailtrap.io'); +const actualHost = useDirectIP ? '3.209.246.195' : host; +const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; + +console.log('Fix DNS:'); +console.log(' Utilise IP directe:', useDirectIP); +console.log(' Host rĂ©el:', actualHost); +console.log(' Server name:', serverName); +console.log(''); + +const transporter = nodemailer.createTransport({ + host: actualHost, + port, + secure: false, + auth: { user, pass }, + tls: { + rejectUnauthorized: false, + servername: serverName, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 30000, + dnsTimeout: 10000, +}); + +async function test() { + try { + console.log('Test 1: VĂ©rification de la connexion...'); + await transporter.verify(); + console.log('✅ Connexion SMTP OK\n'); + + console.log("Test 2: Envoi d'un email..."); + const info = await transporter.sendMail({ + from: 'noreply@xpeditis.com', + to: 'test@example.com', + subject: 'Test - ' + new Date().toISOString(), + html: "

Test réussi!

Ce message confirme que l'envoi d'email fonctionne.

", + }); + + console.log('✅ Email envoyĂ© avec succĂšs!'); + console.log(' Message ID:', info.messageId); + console.log(' Response:', info.response); + console.log(''); + console.log('✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!'); + process.exit(0); + } catch (error) { + console.error('❌ ERREUR:', error.message); + console.error(' Code:', error.code); + console.error(' Command:', error.command); + process.exit(1); + } +} + +test(); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index eece0f4..2ad8548 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -26,8 +26,9 @@ import { AuditModule } from './application/audit/audit.module'; import { NotificationsModule } from './application/notifications/notifications.module'; import { WebhooksModule } from './application/webhooks/webhooks.module'; import { GDPRModule } from './application/gdpr/gdpr.module'; -import { CsvBookingsModule } from './application/csv-bookings.module'; +import { CsvBookingsModule } from './application/csv-bookings/csv-bookings.module'; import { AdminModule } from './application/admin/admin.module'; +import { BlogModule } from './application/blog/blog.module'; import { LogsModule } from './application/logs/logs.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { ApiKeysModule } from './application/api-keys/api-keys.module'; @@ -179,6 +180,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; WebhooksModule, GDPRModule, AdminModule, + BlogModule, SubscriptionsModule, ApiKeysModule, LogsModule, diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts index dd92262..e834f3b 100644 --- a/apps/backend/src/application/admin/admin.module.ts +++ b/apps/backend/src/application/admin/admin.module.ts @@ -24,23 +24,25 @@ import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.po import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter'; // CSV Booking Service -import { CsvBookingsModule } from '../csv-bookings.module'; +import { CsvBookingsModule } from '../csv-bookings/csv-bookings.module'; // Email import { EmailModule } from '@infrastructure/email/email.module'; -/** - * Admin Module - * - * Provides admin-only endpoints for managing all data in the system. - * All endpoints require ADMIN role. - */ +// Blog +import { BlogModule } from '../blog/blog.module'; + +// Storage +import { StorageModule } from '@infrastructure/storage/storage.module'; + @Module({ imports: [ TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), ConfigModule, CsvBookingsModule, EmailModule, + BlogModule, + StorageModule, ], controllers: [AdminController], providers: [ diff --git a/apps/backend/src/application/blog/blog.module.ts b/apps/backend/src/application/blog/blog.module.ts new file mode 100644 index 0000000..7e31d7b --- /dev/null +++ b/apps/backend/src/application/blog/blog.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BlogController } from '../controllers/blog.controller'; +import { BlogService } from '../services/blog.service'; +import { BlogPostOrmEntity } from '../../infrastructure/persistence/typeorm/entities/blog-post.orm-entity'; +import { TypeOrmBlogPostRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository'; +import { BLOG_POST_REPOSITORY } from '@domain/ports/out/blog-post.repository'; +import { StorageModule } from '../../infrastructure/storage/storage.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([BlogPostOrmEntity]), StorageModule], + controllers: [BlogController], + providers: [ + BlogService, + { + provide: BLOG_POST_REPOSITORY, + useClass: TypeOrmBlogPostRepository, + }, + ], + exports: [BlogService], +}) +export class BlogModule {} diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index a4ccb99..5c8393c 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -6,6 +6,7 @@ import { Delete, Param, Body, + Query, HttpCode, HttpStatus, Logger, @@ -15,14 +16,22 @@ import { BadRequestException, ParseUUIDPipe, UseGuards, + UseInterceptors, + UploadedFile, Inject, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { v4 as uuidv4 } from 'uuid'; +import * as path from 'path'; import { ApiTags, ApiOperation, ApiResponse, ApiNotFoundResponse, ApiParam, + ApiQuery, + ApiConsumes, ApiBearerAuth, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -56,6 +65,25 @@ import { // Email imports import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; +// Blog imports +import { BlogService } from '../services/blog.service'; +import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto'; +import { BlogPost } from '@domain/entities/blog-post.entity'; +import type { BlogPostCategory } from '@domain/entities/blog-post.entity'; + +// Storage imports +import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; + +const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; +const ALLOWED_IMAGE_MIMETYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/svg+xml', +]; +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB + /** * Admin Controller * @@ -80,7 +108,9 @@ export class AdminController { private readonly csvBookingService: CsvBookingService, @Inject(SIRET_VERIFICATION_PORT) private readonly siretVerificationPort: SiretVerificationPort, - @Inject(EMAIL_PORT) private readonly emailPort: EmailPort + @Inject(EMAIL_PORT) private readonly emailPort: EmailPort, + private readonly blogService: BlogService, + @Inject(STORAGE_PORT) private readonly storage: StoragePort ) {} // ==================== USERS ENDPOINTS ==================== @@ -912,4 +942,138 @@ export class AdminController { this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`); return { success: true, message: 'Document deleted successfully' }; } + + // ==================== BLOG ENDPOINTS ==================== + + @Post('blog/images') + @UseInterceptors( + FileInterceptor('image', { + storage: memoryStorage(), + limits: { fileSize: MAX_IMAGE_SIZE }, + fileFilter: (_req, file, cb) => { + if (ALLOWED_IMAGE_MIMETYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb( + new BadRequestException('Only image files are allowed (jpg, png, webp, gif, svg)'), + false + ); + } + }, + }) + ) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload a blog image to storage (Admin only)' }) + @ApiResponse({ + status: 201, + schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } }, + }) + async uploadBlogImage( + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: UserPayload + ): Promise<{ url: string; filename: string }> { + if (!file) throw new BadRequestException('No image file provided'); + + this.logger.log(`[ADMIN: ${user.email}] Uploading blog image: ${file.originalname}`); + + const ext = path.extname(file.originalname).toLowerCase(); + const sanitizedName = path + .basename(file.originalname, ext) + .replace(/[^a-z0-9]/gi, '-') + .toLowerCase(); + const filename = `${uuidv4()}-${sanitizedName}${ext}`; + const key = `blog-images/${filename}`; + + await this.storage.upload({ + bucket: BLOG_IMAGES_BUCKET, + key, + body: file.buffer, + contentType: file.mimetype, + }); + + this.logger.log(`[ADMIN] Blog image uploaded: ${key}`); + return { url: `/api/v1/blog/images/${filename}`, filename }; + } + + @Get('blog') + @ApiOperation({ summary: 'List all blog posts (Admin only)' }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'category', required: false }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + async listBlogPosts( + @Query('status') status?: any, + @Query('category') category?: BlogPostCategory, + @Query('search') search?: string, + @Query('limit') limit = 50, + @Query('offset') offset = 0, + @CurrentUser() user?: UserPayload + ) { + this.logger.log(`[ADMIN: ${user?.email}] Listing blog posts`); + const { posts, total } = await this.blogService.listAllPosts({ + status, + category, + search, + limit: Number(limit), + offset: Number(offset), + }); + return { posts: posts.map(this.mapBlogPostToDto), total }; + } + + @Post('blog') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ summary: 'Create a blog post (Admin only)' }) + async createBlogPost(@Body() dto: CreateBlogPostDto, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Creating blog post: ${dto.slug}`); + const post = await this.blogService.createPost(dto); + return this.mapBlogPostToDto(post); + } + + @Patch('blog/:id') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ summary: 'Update a blog post (Admin only)' }) + async updateBlogPost( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateBlogPostDto, + @CurrentUser() user: UserPayload + ) { + this.logger.log(`[ADMIN: ${user.email}] Updating blog post: ${id}`); + const post = await this.blogService.updatePost(id, dto); + return this.mapBlogPostToDto(post); + } + + @Delete('blog/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a blog post (Admin only)' }) + async deleteBlogPost( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`); + await this.blogService.deletePost(id); + } + + private mapBlogPostToDto(post: BlogPost) { + return { + id: post.id, + title: post.title, + slug: post.slug, + excerpt: post.excerpt, + content: post.content, + coverImageUrl: post.coverImageUrl, + category: post.category, + tags: post.tags, + authorName: post.authorName, + status: post.status, + isFeatured: post.isFeatured, + publishedAt: post.publishedAt, + metaTitle: post.metaTitle, + metaDescription: post.metaDescription, + primaryKeyword: post.primaryKeyword, + secondaryKeywords: post.secondaryKeywords, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + }; + } } diff --git a/apps/backend/src/application/controllers/blog.controller.ts b/apps/backend/src/application/controllers/blog.controller.ts new file mode 100644 index 0000000..3a36562 --- /dev/null +++ b/apps/backend/src/application/controllers/blog.controller.ts @@ -0,0 +1,135 @@ +import { + Controller, + Get, + Param, + Query, + HttpCode, + HttpStatus, + Res, + NotFoundException, + Inject, + Logger, + StreamableFile, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { Public } from '../decorators/public.decorator'; +import { BlogService } from '../services/blog.service'; +import { BlogPost } from '@domain/entities/blog-post.entity'; +import { BlogPostResponseDto, BlogPostListResponseDto } from '../dto/blog-post.dto'; +import type { BlogPostCategory } from '@domain/entities/blog-post.entity'; +import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; + +const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; + +@ApiTags('Blog') +@Controller('blog') +@Public() +export class BlogController { + private readonly logger = new Logger(BlogController.name); + + constructor( + private readonly blogService: BlogService, + @Inject(STORAGE_PORT) private readonly storage: StoragePort + ) {} + + @Get() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'List published blog posts' }) + @ApiQuery({ + name: 'category', + required: false, + enum: ['industry', 'technology', 'guides', 'news'], + }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiResponse({ status: 200, type: BlogPostListResponseDto }) + async listPosts( + @Query('category') category?: BlogPostCategory, + @Query('search') search?: string, + @Query('limit') limit = 20, + @Query('offset') offset = 0 + ): Promise { + const { posts, total } = await this.blogService.listPublishedPosts({ + category, + search, + limit: Number(limit), + offset: Number(offset), + }); + + return { + posts: posts.map(this.mapToDto), + total, + limit: Number(limit), + offset: Number(offset), + }; + } + + @Get('images/:filename') + @ApiOperation({ summary: 'Serve a blog image from storage' }) + @ApiParam({ name: 'filename' }) + async serveImage( + @Param('filename') filename: string, + @Res({ passthrough: true }) res: Response + ): Promise { + const key = `blog-images/${filename}`; + + let buffer: Buffer; + try { + buffer = await this.storage.download({ bucket: BLOG_IMAGES_BUCKET, key }); + } catch (err: any) { + this.logger.error(`Failed to serve blog image "${key}": ${err?.message}`); + throw new NotFoundException(`Image not found: ${filename}`); + } + + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + const contentTypeMap: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + svg: 'image/svg+xml', + }; + const contentType = contentTypeMap[ext] ?? 'application/octet-stream'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + return new StreamableFile(buffer); + } + + @Get(':slug') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get a published blog post by slug' }) + @ApiParam({ name: 'slug' }) + @ApiResponse({ status: 200, type: BlogPostResponseDto }) + async getPost(@Param('slug') slug: string): Promise { + const post = await this.blogService.getPublishedPostBySlug(slug); + return this.mapToDto(post); + } + + private mapToDto(post: BlogPost): BlogPostResponseDto { + return { + id: post.id, + title: post.title, + slug: post.slug, + excerpt: post.excerpt, + content: post.content, + coverImageUrl: post.coverImageUrl, + category: post.category, + tags: post.tags, + authorName: post.authorName, + status: post.status, + isFeatured: post.isFeatured, + publishedAt: post.publishedAt, + metaTitle: post.metaTitle, + metaDescription: post.metaDescription, + primaryKeyword: post.primaryKeyword, + secondaryKeywords: post.secondaryKeywords, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + }; + } +} diff --git a/apps/backend/src/application/controllers/index.ts b/apps/backend/src/application/controllers/index.ts index 70e2402..31be0d7 100644 --- a/apps/backend/src/application/controllers/index.ts +++ b/apps/backend/src/application/controllers/index.ts @@ -1,2 +1,16 @@ export * from './rates.controller'; export * from './bookings.controller'; +export * from './auth.controller'; +export * from './users.controller'; +export * from './organizations.controller'; +export * from './ports.controller'; +export * from './notifications.controller'; +export * from './webhooks.controller'; +export * from './audit.controller'; +export * from './subscriptions.controller'; +export * from './invitations.controller'; +export * from './gdpr.controller'; +export * from './health.controller'; +export * from './blog.controller'; +export * from './csv-bookings.controller'; +export * from './csv-booking-actions.controller'; diff --git a/apps/backend/src/application/controllers/organizations.controller.ts b/apps/backend/src/application/controllers/organizations.controller.ts index 821ca37..f20eeb7 100644 --- a/apps/backend/src/application/controllers/organizations.controller.ts +++ b/apps/backend/src/application/controllers/organizations.controller.ts @@ -12,6 +12,7 @@ import { UsePipes, ValidationPipe, NotFoundException, + BadRequestException, ParseUUIDPipe, ParseIntPipe, DefaultValuePipe, @@ -41,10 +42,16 @@ import { ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization, OrganizationType } from '@domain/entities/organization.entity'; +import { + NotificationType, + NotificationPriority, +} from '@domain/entities/notification.entity'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; +import { NotificationService } from '../services/notification.service'; import { v4 as uuidv4 } from 'uuid'; /** @@ -64,7 +71,9 @@ export class OrganizationsController { private readonly logger = new Logger(OrganizationsController.name); constructor( - @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository + @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + private readonly notificationService: NotificationService ) {} /** @@ -123,6 +132,11 @@ export class OrganizationsController { name: dto.name, type: dto.type, scac: dto.scac, + siren: dto.siren, + siret: dto.siret, + eori: dto.eori, + contact_phone: dto.contact_phone, + contact_email: dto.contact_email, address: OrganizationMapper.mapDtoToAddress(dto.address), logoUrl: dto.logoUrl, documents: [], @@ -252,6 +266,10 @@ export class OrganizationsController { organization.updateSiren(dto.siren); } + if (dto.siret) { + organization.updateSiret(dto.siret); + } + if (dto.eori) { organization.updateEori(dto.eori); } @@ -288,6 +306,72 @@ export class OrganizationsController { return OrganizationMapper.toDto(updatedOrg); } + /** + * Request SIRET/SIREN approval from admins + * + * Any authenticated user can call this to notify all admins + * that their organization's SIRET/SIREN needs approval. + */ + @Post('request-siret-approval') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Request SIRET/SIREN approval', + description: 'Sends a notification to all admins requesting manual SIRET/SIREN verification.', + }) + @ApiResponse({ status: 200, description: 'Approval request sent to admins' }) + @ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' }) + async requestSiretApproval( + @CurrentUser() user: UserPayload + ): Promise<{ message: string }> { + const organization = await this.organizationRepository.findById(user.organizationId); + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + if (!organization.siren && !organization.siret) { + throw new BadRequestException( + 'Aucun SIRET ou SIREN renseignĂ© sur votre organisation. Veuillez les ajouter avant de demander la validation.' + ); + } + + if (organization.siretVerified) { + throw new BadRequestException('Votre SIRET/SIREN est dĂ©jĂ  vĂ©rifiĂ©.'); + } + + const admins = await this.userRepository.findByRole('ADMIN'); + + const identifier = organization.siret + ? `SIRET ${organization.siret}` + : `SIREN ${organization.siren}`; + + await Promise.all( + admins.map(admin => + this.notificationService.createNotification({ + userId: admin.id, + organizationId: admin.organizationId, + type: NotificationType.ORGANIZATION_UPDATE, + priority: NotificationPriority.HIGH, + title: 'Demande de validation SIRET/SIREN', + message: `L'organisation "${organization.name}" demande la validation de son ${identifier}.`, + metadata: { + organizationId: organization.id, + organizationName: organization.name, + siret: organization.siret, + siren: organization.siren, + requestedBy: user.email, + }, + actionUrl: `/dashboard/admin/organizations`, + }) + ) + ); + + this.logger.log( + `[${user.email}] SIRET/SIREN approval requested for org ${organization.name} (${organization.id})` + ); + + return { message: 'Votre demande a Ă©tĂ© envoyĂ©e aux administrateurs.' }; + } + /** * List organizations * diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts deleted file mode 100644 index 6330924..0000000 --- a/apps/backend/src/application/csv-bookings.module.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule } from '@nestjs/config'; -import { CsvBookingsController } from './controllers/csv-bookings.controller'; -import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller'; -import { CsvBookingService } from './services/csv-booking.service'; -import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; -import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; -import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository'; -import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; -import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; -import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity'; -import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; -import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; -import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity'; -import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; -import { NotificationsModule } from './notifications/notifications.module'; -import { EmailModule } from '../infrastructure/email/email.module'; -import { StorageModule } from '../infrastructure/storage/storage.module'; -import { SubscriptionsModule } from './subscriptions/subscriptions.module'; -import { StripeModule } from '../infrastructure/stripe/stripe.module'; - -/** - * CSV Bookings Module - * - * Handles CSV-based booking workflow with carrier email confirmations - */ -@Module({ - imports: [ - TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), - ConfigModule, - NotificationsModule, - EmailModule, - StorageModule, - SubscriptionsModule, - StripeModule, - ], - controllers: [CsvBookingsController, CsvBookingActionsController], - providers: [ - CsvBookingService, - TypeOrmCsvBookingRepository, - { - provide: SHIPMENT_COUNTER_PORT, - useClass: TypeOrmShipmentCounterRepository, - }, - { - provide: ORGANIZATION_REPOSITORY, - useClass: TypeOrmOrganizationRepository, - }, - { - provide: USER_REPOSITORY, - useClass: TypeOrmUserRepository, - }, - ], - exports: [CsvBookingService, TypeOrmCsvBookingRepository], -}) -export class CsvBookingsModule {} diff --git a/apps/backend/src/application/csv-bookings/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings/csv-bookings.module.ts new file mode 100644 index 0000000..6f7a20a --- /dev/null +++ b/apps/backend/src/application/csv-bookings/csv-bookings.module.ts @@ -0,0 +1,57 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { CsvBookingsController } from '../controllers/csv-bookings.controller'; +import { CsvBookingActionsController } from '../controllers/csv-booking-actions.controller'; +import { CsvBookingService } from '../services/csv-booking.service'; +import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; +import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; +import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository'; +import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; +import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; +import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { EmailModule } from '../../infrastructure/email/email.module'; +import { StorageModule } from '../../infrastructure/storage/storage.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { StripeModule } from '../../infrastructure/stripe/stripe.module'; + +/** + * CSV Bookings Module + * + * Handles CSV-based booking workflow with carrier email confirmations + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), + ConfigModule, + NotificationsModule, + EmailModule, + StorageModule, + SubscriptionsModule, + StripeModule, + ], + controllers: [CsvBookingsController, CsvBookingActionsController], + providers: [ + CsvBookingService, + TypeOrmCsvBookingRepository, + { + provide: SHIPMENT_COUNTER_PORT, + useClass: TypeOrmShipmentCounterRepository, + }, + { + provide: ORGANIZATION_REPOSITORY, + useClass: TypeOrmOrganizationRepository, + }, + { + provide: USER_REPOSITORY, + useClass: TypeOrmUserRepository, + }, + ], + exports: [CsvBookingService, TypeOrmCsvBookingRepository], +}) +export class CsvBookingsModule {} diff --git a/apps/backend/src/application/dashboard/dashboard.module.ts b/apps/backend/src/application/dashboard/dashboard.module.ts index b483b11..4b3edc9 100644 --- a/apps/backend/src/application/dashboard/dashboard.module.ts +++ b/apps/backend/src/application/dashboard/dashboard.module.ts @@ -7,7 +7,7 @@ import { DashboardController } from './dashboard.controller'; import { AnalyticsService } from '../services/analytics.service'; import { BookingsModule } from '../bookings/bookings.module'; import { RatesModule } from '../rates/rates.module'; -import { CsvBookingsModule } from '../csv-bookings.module'; +import { CsvBookingsModule } from '../csv-bookings/csv-bookings.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { FeatureFlagGuard } from '../guards/feature-flag.guard'; diff --git a/apps/backend/src/application/decorators/index.ts b/apps/backend/src/application/decorators/index.ts index 76ef1b6..5720b2b 100644 --- a/apps/backend/src/application/decorators/index.ts +++ b/apps/backend/src/application/decorators/index.ts @@ -1,3 +1,4 @@ export * from './current-user.decorator'; export * from './public.decorator'; export * from './roles.decorator'; +export * from './requires-feature.decorator'; diff --git a/apps/backend/src/application/dto/blog-post.dto.ts b/apps/backend/src/application/dto/blog-post.dto.ts new file mode 100644 index 0000000..129db6c --- /dev/null +++ b/apps/backend/src/application/dto/blog-post.dto.ts @@ -0,0 +1,213 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsArray, + IsBoolean, + IsEnum, + IsDateString, + MaxLength, + MinLength, + Matches, +} from 'class-validator'; +import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity'; + +const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news']; + +export class CreateBlogPostDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(255) + title: string; + + @ApiProperty({ description: 'URL-friendly slug, e.g. "my-article"' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'Slug must be lowercase alphanumeric with hyphens', + }) + slug: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MinLength(10) + excerpt: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + content: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + coverImageUrl?: string; + + @ApiProperty({ enum: CATEGORIES }) + @IsEnum(CATEGORIES) + category: BlogPostCategory; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(255) + authorName: string; + + @ApiPropertyOptional({ description: 'ISO date string for scheduled publication' }) + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @ApiPropertyOptional({ description: 'SEO meta title (50-60 chars recommended)' }) + @IsOptional() + @IsString() + @MaxLength(255) + metaTitle?: string; + + @ApiPropertyOptional({ description: 'SEO meta description (150-160 chars recommended)' }) + @IsOptional() + @IsString() + @MaxLength(500) + metaDescription?: string; + + @ApiPropertyOptional({ description: 'Primary SEO keyword' }) + @IsOptional() + @IsString() + @MaxLength(255) + primaryKeyword?: string; + + @ApiPropertyOptional({ type: [String], description: 'Secondary SEO keywords' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + secondaryKeywords?: string[]; +} + +export class UpdateBlogPostDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + title?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'Slug must be lowercase alphanumeric with hyphens', + }) + slug?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + excerpt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + content?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + coverImageUrl?: string; + + @ApiPropertyOptional({ enum: CATEGORIES }) + @IsOptional() + @IsEnum(CATEGORIES) + category?: BlogPostCategory; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + authorName?: string; + + @ApiPropertyOptional({ enum: BlogPostStatus }) + @IsOptional() + @IsEnum(BlogPostStatus) + status?: BlogPostStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isFeatured?: boolean; + + @ApiPropertyOptional({ description: 'ISO date string for scheduled publication' }) + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + metaTitle?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + metaDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + primaryKeyword?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + secondaryKeywords?: string[]; +} + +export class BlogPostResponseDto { + @ApiProperty() id: string; + @ApiProperty() title: string; + @ApiProperty() slug: string; + @ApiProperty() excerpt: string; + @ApiProperty() content: string; + @ApiPropertyOptional() coverImageUrl?: string; + @ApiProperty() category: string; + @ApiProperty({ type: [String] }) tags: string[]; + @ApiProperty() authorName: string; + @ApiProperty({ enum: BlogPostStatus }) status: BlogPostStatus; + @ApiProperty() isFeatured: boolean; + @ApiPropertyOptional() publishedAt?: Date; + @ApiPropertyOptional() metaTitle?: string; + @ApiPropertyOptional() metaDescription?: string; + @ApiPropertyOptional() primaryKeyword?: string; + @ApiProperty({ type: [String] }) secondaryKeywords: string[]; + @ApiProperty() createdAt: Date; + @ApiProperty() updatedAt: Date; +} + +export class BlogPostListResponseDto { + @ApiProperty({ type: [BlogPostResponseDto] }) posts: BlogPostResponseDto[]; + @ApiProperty() total: number; + @ApiProperty() limit: number; + @ApiProperty() offset: number; +} diff --git a/apps/backend/src/application/dto/organization.dto.ts b/apps/backend/src/application/dto/organization.dto.ts index 130a53b..2dfe143 100644 --- a/apps/backend/src/application/dto/organization.dto.ts +++ b/apps/backend/src/application/dto/organization.dto.ts @@ -104,16 +104,21 @@ export class CreateOrganizationDto { @ApiPropertyOptional({ example: '123456789', description: 'French SIREN number (9 digits)', - minLength: 9, - maxLength: 9, }) @IsString() @IsOptional() - @MinLength(9) - @MaxLength(9) @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) siren?: string; + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits)', + }) + @IsString() + @IsOptional() + @Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' }) + siret?: string; + @ApiPropertyOptional({ example: 'FR123456789', description: 'EU EORI number', @@ -174,26 +179,18 @@ export class UpdateOrganizationDto { @ApiPropertyOptional({ example: '123456789', description: 'French SIREN number (9 digits)', - minLength: 9, - maxLength: 9, }) @IsString() @IsOptional() - @MinLength(9) - @MaxLength(9) @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) siren?: string; @ApiPropertyOptional({ example: '12345678901234', description: 'French SIRET number (14 digits)', - minLength: 14, - maxLength: 14, }) @IsString() @IsOptional() - @MinLength(14) - @MaxLength(14) @Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' }) siret?: string; diff --git a/apps/backend/src/application/guards/index.ts b/apps/backend/src/application/guards/index.ts index 374d66f..b6998e3 100644 --- a/apps/backend/src/application/guards/index.ts +++ b/apps/backend/src/application/guards/index.ts @@ -1,3 +1,5 @@ export * from './jwt-auth.guard'; export * from './roles.guard'; export * from './api-key-or-jwt.guard'; +export * from './feature-flag.guard'; +export * from './throttle.guard'; diff --git a/apps/backend/src/application/mappers/index.ts b/apps/backend/src/application/mappers/index.ts index 930a103..7628cfb 100644 --- a/apps/backend/src/application/mappers/index.ts +++ b/apps/backend/src/application/mappers/index.ts @@ -1,3 +1,6 @@ export * from './rate-quote.mapper'; export * from './booking.mapper'; export * from './port.mapper'; +export * from './user.mapper'; +export * from './organization.mapper'; +export * from './csv-rate.mapper'; diff --git a/apps/backend/src/application/organizations/organizations.module.ts b/apps/backend/src/application/organizations/organizations.module.ts index 984bbee..8449035 100644 --- a/apps/backend/src/application/organizations/organizations.module.ts +++ b/apps/backend/src/application/organizations/organizations.module.ts @@ -1,15 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OrganizationsController } from '../controllers/organizations.controller'; +import { UsersModule } from '../users/users.module'; +import { NotificationsModule } from '../notifications/notifications.module'; -// Import domain ports import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; @Module({ imports: [ - TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider + TypeOrmModule.forFeature([OrganizationOrmEntity]), + UsersModule, + NotificationsModule, ], controllers: [OrganizationsController], providers: [ @@ -18,8 +21,6 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/ useClass: TypeOrmOrganizationRepository, }, ], - exports: [ - ORGANIZATION_REPOSITORY, // optional, if other modules need it - ], + exports: [ORGANIZATION_REPOSITORY], }) export class OrganizationsModule {} diff --git a/apps/backend/src/application/rates/rates.module.ts b/apps/backend/src/application/rates/rates.module.ts index 583fa87..af47ef8 100644 --- a/apps/backend/src/application/rates/rates.module.ts +++ b/apps/backend/src/application/rates/rates.module.ts @@ -45,12 +45,16 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit }, { provide: RateSearchService, - useFactory: (cache: any, rateQuoteRepo: any, portRepo: any, carrierRepo: any) => { - // For now, create service with empty connectors array - // TODO: Inject actual carrier connectors - return new RateSearchService([], cache, rateQuoteRepo, portRepo, carrierRepo); + useFactory: ( + connectors: any[], + cache: any, + rateQuoteRepo: any, + portRepo: any, + carrierRepo: any, + ) => { + return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo); }, - inject: [CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY], + inject: ['CarrierConnectors', CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY], }, ], exports: [RATE_QUOTE_REPOSITORY, RateSearchService], diff --git a/apps/backend/src/application/services/blog.service.ts b/apps/backend/src/application/services/blog.service.ts new file mode 100644 index 0000000..5e581d8 --- /dev/null +++ b/apps/backend/src/application/services/blog.service.ts @@ -0,0 +1,158 @@ +import { + Injectable, + Inject, + NotFoundException, + ConflictException, + Logger, + OnApplicationBootstrap, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { BlogPost, BlogPostStatus } from '@domain/entities/blog-post.entity'; +import { + BlogPostRepository, + BlogPostFilters, + BLOG_POST_REPOSITORY, +} from '@domain/ports/out/blog-post.repository'; +import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto'; +import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; + +const BLOG_IMAGES_BUCKET = 'xpeditis-blog'; + +@Injectable() +export class BlogService implements OnApplicationBootstrap { + private readonly logger = new Logger(BlogService.name); + + constructor( + @Inject(BLOG_POST_REPOSITORY) + private readonly blogPostRepository: BlogPostRepository, + @Inject(STORAGE_PORT) + private readonly storage: StoragePort + ) {} + + async onApplicationBootstrap(): Promise { + try { + await this.storage.ensureBucket(BLOG_IMAGES_BUCKET); + this.logger.log(`Blog images bucket "${BLOG_IMAGES_BUCKET}" is ready`); + } catch (err: any) { + this.logger.warn(`Could not ensure blog images bucket: ${err?.message}`); + } + } + + async createPost(dto: CreateBlogPostDto): Promise { + const slugTaken = await this.blogPostRepository.slugExists(dto.slug); + if (slugTaken) { + throw new ConflictException(`Slug "${dto.slug}" is already in use`); + } + + let post = BlogPost.create({ + id: uuidv4(), + title: dto.title, + slug: dto.slug, + excerpt: dto.excerpt, + content: dto.content, + coverImageUrl: dto.coverImageUrl, + category: dto.category, + tags: dto.tags ?? [], + authorName: dto.authorName, + metaTitle: dto.metaTitle, + metaDescription: dto.metaDescription, + primaryKeyword: dto.primaryKeyword, + secondaryKeywords: dto.secondaryKeywords ?? [], + }); + + if (dto.scheduledAt) { + const schedDate = new Date(dto.scheduledAt); + post = schedDate > new Date() ? post.schedule(schedDate) : post.publish(); + } + + return this.blogPostRepository.save(post); + } + + async updatePost(id: string, dto: UpdateBlogPostDto): Promise { + const post = await this.findOrFail(id); + + if (dto.slug && dto.slug !== post.slug) { + const slugTaken = await this.blogPostRepository.slugExists(dto.slug, id); + if (slugTaken) { + throw new ConflictException(`Slug "${dto.slug}" is already in use`); + } + } + + let updated = post.update({ + title: dto.title, + slug: dto.slug, + excerpt: dto.excerpt, + content: dto.content, + coverImageUrl: dto.coverImageUrl, + category: dto.category, + tags: dto.tags, + authorName: dto.authorName, + isFeatured: dto.isFeatured, + metaTitle: dto.metaTitle, + metaDescription: dto.metaDescription, + primaryKeyword: dto.primaryKeyword, + secondaryKeywords: dto.secondaryKeywords, + }); + + if (dto.scheduledAt) { + const schedDate = new Date(dto.scheduledAt); + updated = schedDate > new Date() ? updated.schedule(schedDate) : updated.publish(); + } else if (dto.status !== undefined && dto.status !== post.status) { + if (dto.status === BlogPostStatus.PUBLISHED) updated = updated.publish(); + else if (dto.status === BlogPostStatus.ARCHIVED) updated = updated.archive(); + else if (dto.status === BlogPostStatus.DRAFT) updated = updated.unpublish(); + else if (dto.status === BlogPostStatus.SCHEDULED && post.publishedAt) + updated = updated.schedule(post.publishedAt); + } + + return this.blogPostRepository.save(updated); + } + + async deletePost(id: string): Promise { + await this.findOrFail(id); + await this.blogPostRepository.delete(id); + } + + async getPostById(id: string): Promise { + return this.findOrFail(id); + } + + async getPublishedPostBySlug(slug: string): Promise { + const post = await this.blogPostRepository.findBySlug(slug); + if (!post || !post.isVisibleToPublic()) { + throw new NotFoundException('Article not found'); + } + return post; + } + + async listPublishedPosts( + filters: BlogPostFilters + ): Promise<{ posts: BlogPost[]; total: number }> { + const publishedFilters: BlogPostFilters = { + ...filters, + status: BlogPostStatus.PUBLISHED, + includeScheduled: true, + }; + const [posts, total] = await Promise.all([ + this.blogPostRepository.findByFilters(publishedFilters), + this.blogPostRepository.count(publishedFilters), + ]); + return { posts, total }; + } + + async listAllPosts(filters: BlogPostFilters): Promise<{ posts: BlogPost[]; total: number }> { + const [posts, total] = await Promise.all([ + this.blogPostRepository.findByFilters(filters), + this.blogPostRepository.count(filters), + ]); + return { posts, total }; + } + + private async findOrFail(id: string): Promise { + const post = await this.blogPostRepository.findById(id); + if (!post) { + throw new NotFoundException(`Blog post with id "${id}" not found`); + } + return post; + } +} diff --git a/apps/backend/src/application/services/carrier-auth.service.ts b/apps/backend/src/application/services/carrier-auth.service.ts deleted file mode 100644 index a126133..0000000 --- a/apps/backend/src/application/services/carrier-auth.service.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Carrier Auth Service - * - * Handles carrier authentication and automatic account creation - */ - -import { Injectable, Logger, UnauthorizedException, Inject } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; -import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; -import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity'; -import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; -import * as argon2 from 'argon2'; -import { randomBytes } from 'crypto'; -import { v4 as uuidv4 } from 'uuid'; - -@Injectable() -export class CarrierAuthService { - private readonly logger = new Logger(CarrierAuthService.name); - - constructor( - private readonly carrierProfileRepository: CarrierProfileRepository, - @InjectRepository(UserOrmEntity) - private readonly userRepository: Repository, - @InjectRepository(OrganizationOrmEntity) - private readonly organizationRepository: Repository, - private readonly jwtService: JwtService, - @Inject(EMAIL_PORT) - private readonly emailAdapter: EmailPort - ) {} - - /** - * Create carrier account automatically when clicking accept/reject link - */ - async createCarrierAccountIfNotExists( - carrierEmail: string, - carrierName: string - ): Promise<{ - carrierId: string; - userId: string; - isNewAccount: boolean; - temporaryPassword?: string; - }> { - this.logger.log(`Checking/creating carrier account for: ${carrierEmail}`); - - // Check if carrier already exists - const existingCarrier = await this.carrierProfileRepository.findByEmail(carrierEmail); - - if (existingCarrier) { - this.logger.log(`Carrier already exists: ${carrierEmail}`); - return { - carrierId: existingCarrier.id, - userId: existingCarrier.userId, - isNewAccount: false, - }; - } - - // Create new organization for the carrier - const organizationId = uuidv4(); // Generate UUID for organization - const organization = this.organizationRepository.create({ - id: organizationId, // Provide explicit ID since @PrimaryColumn requires it - name: carrierName, - type: 'CARRIER', - isCarrier: true, - carrierType: 'LCL', // Default - addressStreet: 'TBD', - addressCity: 'TBD', - addressPostalCode: 'TBD', - addressCountry: 'FR', // Default to France - isActive: true, - }); - - const savedOrganization = await this.organizationRepository.save(organization); - this.logger.log(`Created organization: ${savedOrganization.id}`); - - // Generate temporary password - const temporaryPassword = this.generateTemporaryPassword(); - const hashedPassword = await argon2.hash(temporaryPassword); - - // Create user account - const nameParts = carrierName.split(' '); - const user = this.userRepository.create({ - id: uuidv4(), - email: carrierEmail.toLowerCase(), - passwordHash: hashedPassword, - firstName: nameParts[0] || 'Carrier', - lastName: nameParts.slice(1).join(' ') || 'Account', - role: 'CARRIER', // New role for carriers - organizationId: savedOrganization.id, - isActive: true, - isEmailVerified: true, // Auto-verified since created via email - }); - - const savedUser = await this.userRepository.save(user); - this.logger.log(`Created user: ${savedUser.id}`); - - // Create carrier profile - const carrierProfile = await this.carrierProfileRepository.create({ - userId: savedUser.id, - organizationId: savedOrganization.id, - companyName: carrierName, - notificationEmail: carrierEmail, - preferredCurrency: 'USD', - isActive: true, - isVerified: false, // Will be verified later - }); - - this.logger.log(`Created carrier profile: ${carrierProfile.id}`); - - // Send welcome email with credentials and WAIT for confirmation - try { - await this.emailAdapter.sendCarrierAccountCreated( - carrierEmail, - carrierName, - temporaryPassword - ); - this.logger.log(`Account creation email sent to ${carrierEmail}`); - } catch (error: any) { - this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack); - // Continue even if email fails - account is already created - } - - return { - carrierId: carrierProfile.id, - userId: savedUser.id, - isNewAccount: true, - temporaryPassword, - }; - } - - /** - * Generate auto-login JWT token for carrier - */ - async generateAutoLoginToken(userId: string, carrierId: string): Promise { - this.logger.log(`Generating auto-login token for carrier: ${carrierId}`); - - const payload = { - sub: userId, - carrierId, - type: 'carrier', - autoLogin: true, - }; - - const token = this.jwtService.sign(payload, { expiresIn: '1h' }); - this.logger.log(`Auto-login token generated for carrier: ${carrierId}`); - - return token; - } - - /** - * Standard login for carriers - */ - async login( - email: string, - password: string - ): Promise<{ - accessToken: string; - refreshToken: string; - carrier: { - id: string; - companyName: string; - email: string; - }; - }> { - this.logger.log(`Carrier login attempt: ${email}`); - - const carrier = await this.carrierProfileRepository.findByEmail(email); - - if (!carrier || !carrier.user) { - this.logger.warn(`Login failed: Carrier not found for email ${email}`); - throw new UnauthorizedException('Invalid credentials'); - } - - // Verify password - const isPasswordValid = await argon2.verify(carrier.user.passwordHash, password); - - if (!isPasswordValid) { - this.logger.warn(`Login failed: Invalid password for ${email}`); - throw new UnauthorizedException('Invalid credentials'); - } - - // Check if carrier is active - if (!carrier.isActive) { - this.logger.warn(`Login failed: Carrier account is inactive ${email}`); - throw new UnauthorizedException('Account is inactive'); - } - - // Update last login - await this.carrierProfileRepository.updateLastLogin(carrier.id); - - // Generate JWT tokens - const payload = { - sub: carrier.userId, - email: carrier.user.email, - carrierId: carrier.id, - organizationId: carrier.organizationId, - role: 'CARRIER', - }; - - const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); - const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); - - this.logger.log(`Login successful for carrier: ${carrier.id}`); - - return { - accessToken, - refreshToken, - carrier: { - id: carrier.id, - companyName: carrier.companyName, - email: carrier.user.email, - }, - }; - } - - /** - * Verify auto-login token - */ - async verifyAutoLoginToken(token: string): Promise<{ - userId: string; - carrierId: string; - }> { - try { - const payload = this.jwtService.verify(token); - - if (!payload.autoLogin || payload.type !== 'carrier') { - throw new UnauthorizedException('Invalid auto-login token'); - } - - return { - userId: payload.sub, - carrierId: payload.carrierId, - }; - } catch (error: any) { - this.logger.error(`Auto-login token verification failed: ${error?.message}`); - throw new UnauthorizedException('Invalid or expired token'); - } - } - - /** - * Change carrier password - */ - async changePassword(carrierId: string, oldPassword: string, newPassword: string): Promise { - this.logger.log(`Password change request for carrier: ${carrierId}`); - - const carrier = await this.carrierProfileRepository.findById(carrierId); - - if (!carrier || !carrier.user) { - throw new UnauthorizedException('Carrier not found'); - } - - // Verify old password - const isOldPasswordValid = await argon2.verify(carrier.user.passwordHash, oldPassword); - - if (!isOldPasswordValid) { - this.logger.warn(`Password change failed: Invalid old password for carrier ${carrierId}`); - throw new UnauthorizedException('Invalid old password'); - } - - // Hash new password - const hashedNewPassword = await argon2.hash(newPassword); - - // Update password - carrier.user.passwordHash = hashedNewPassword; - await this.userRepository.save(carrier.user); - - this.logger.log(`Password changed successfully for carrier: ${carrierId}`); - } - - /** - * Request password reset (sends temporary password via email) - */ - async requestPasswordReset(email: string): Promise<{ temporaryPassword: string }> { - this.logger.log(`Password reset request for: ${email}`); - - const carrier = await this.carrierProfileRepository.findByEmail(email); - - if (!carrier || !carrier.user) { - // Don't reveal if email exists or not for security - this.logger.warn(`Password reset requested for non-existent carrier: ${email}`); - throw new UnauthorizedException('If this email exists, a password reset will be sent'); - } - - // Generate temporary password - const temporaryPassword = this.generateTemporaryPassword(); - const hashedPassword = await argon2.hash(temporaryPassword); - - // Update password - carrier.user.passwordHash = hashedPassword; - await this.userRepository.save(carrier.user); - - this.logger.log(`Temporary password generated for carrier: ${carrier.id}`); - - // Send password reset email and WAIT for confirmation - try { - await this.emailAdapter.sendCarrierPasswordReset( - email, - carrier.companyName, - temporaryPassword - ); - this.logger.log(`Password reset email sent to ${email}`); - } catch (error: any) { - this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack); - // Continue even if email fails - password is already reset - } - - return { temporaryPassword }; - } - - /** - * Generate a secure temporary password - */ - private generateTemporaryPassword(): string { - return randomBytes(16).toString('hex').slice(0, 12); - } -} diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 0de8541..8ef2668 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -989,7 +989,9 @@ export class CsvBookingService { for (const file of files) { const documentId = uuidv4(); - const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`; + // Multer decodes originalname as latin1; re-encode to utf8 to preserve accented characters + const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8'); + const fileKey = `csv-bookings/${bookingId}/${documentId}-${fileName}`; // Upload to S3 const uploadResult = await this.storageAdapter.upload({ @@ -1000,12 +1002,12 @@ export class CsvBookingService { }); // Determine document type from filename or default to OTHER - const documentType = this.inferDocumentType(file.originalname); + const documentType = this.inferDocumentType(fileName); const document = new CsvBookingDocumentImpl( documentId, documentType, - file.originalname, + fileName, uploadResult.url, file.mimetype, file.size, @@ -1064,9 +1066,10 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings + // Allow adding documents to PENDING_PAYMENT, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings if ( booking.status !== CsvBookingStatus.PENDING_PAYMENT && + booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER && booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED ) { diff --git a/apps/backend/src/domain/entities/blog-post.entity.ts b/apps/backend/src/domain/entities/blog-post.entity.ts new file mode 100644 index 0000000..74a0f56 --- /dev/null +++ b/apps/backend/src/domain/entities/blog-post.entity.ts @@ -0,0 +1,179 @@ +export enum BlogPostStatus { + DRAFT = 'draft', + SCHEDULED = 'scheduled', + PUBLISHED = 'published', + ARCHIVED = 'archived', +} + +export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news'; + +interface BlogPostProps { + id: string; + title: string; + slug: string; + excerpt: string; + content: string; + coverImageUrl?: string; + category: BlogPostCategory; + tags: string[]; + authorName: string; + status: BlogPostStatus; + isFeatured: boolean; + publishedAt?: Date; + metaTitle?: string; + metaDescription?: string; + primaryKeyword?: string; + secondaryKeywords: string[]; + createdAt: Date; + updatedAt: Date; +} + +export class BlogPost { + private constructor(private readonly props: BlogPostProps) {} + + static create( + props: Omit + ): BlogPost { + const now = new Date(); + return new BlogPost({ + ...props, + secondaryKeywords: props.secondaryKeywords ?? [], + status: BlogPostStatus.DRAFT, + isFeatured: false, + createdAt: now, + updatedAt: now, + }); + } + + static fromPersistence(props: BlogPostProps): BlogPost { + return new BlogPost(props); + } + + get id(): string { + return this.props.id; + } + get title(): string { + return this.props.title; + } + get slug(): string { + return this.props.slug; + } + get excerpt(): string { + return this.props.excerpt; + } + get content(): string { + return this.props.content; + } + get coverImageUrl(): string | undefined { + return this.props.coverImageUrl; + } + get category(): BlogPostCategory { + return this.props.category; + } + get tags(): string[] { + return this.props.tags; + } + get authorName(): string { + return this.props.authorName; + } + get status(): BlogPostStatus { + return this.props.status; + } + get isFeatured(): boolean { + return this.props.isFeatured; + } + get publishedAt(): Date | undefined { + return this.props.publishedAt; + } + get metaTitle(): string | undefined { + return this.props.metaTitle; + } + get metaDescription(): string | undefined { + return this.props.metaDescription; + } + get primaryKeyword(): string | undefined { + return this.props.primaryKeyword; + } + get secondaryKeywords(): string[] { + return this.props.secondaryKeywords; + } + get createdAt(): Date { + return this.props.createdAt; + } + get updatedAt(): Date { + return this.props.updatedAt; + } + + update( + data: Partial< + Pick< + BlogPostProps, + | 'title' + | 'slug' + | 'excerpt' + | 'content' + | 'coverImageUrl' + | 'category' + | 'tags' + | 'authorName' + | 'isFeatured' + | 'metaTitle' + | 'metaDescription' + | 'primaryKeyword' + | 'secondaryKeywords' + > + > + ): BlogPost { + return new BlogPost({ ...this.props, ...data, updatedAt: new Date() }); + } + + publish(): BlogPost { + return new BlogPost({ + ...this.props, + status: BlogPostStatus.PUBLISHED, + publishedAt: this.props.publishedAt ?? new Date(), + updatedAt: new Date(), + }); + } + + schedule(date: Date): BlogPost { + return new BlogPost({ + ...this.props, + status: BlogPostStatus.SCHEDULED, + publishedAt: date, + updatedAt: new Date(), + }); + } + + archive(): BlogPost { + return new BlogPost({ ...this.props, status: BlogPostStatus.ARCHIVED, updatedAt: new Date() }); + } + + unpublish(): BlogPost { + return new BlogPost({ + ...this.props, + status: BlogPostStatus.DRAFT, + publishedAt: undefined, + updatedAt: new Date(), + }); + } + + isPublished(): boolean { + return this.props.status === BlogPostStatus.PUBLISHED; + } + + isVisibleToPublic(): boolean { + if (this.props.status === BlogPostStatus.PUBLISHED) return true; + if ( + this.props.status === BlogPostStatus.SCHEDULED && + this.props.publishedAt && + this.props.publishedAt <= new Date() + ) + return true; + return false; + } + + toObject(): BlogPostProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/domain/ports/in/index.ts b/apps/backend/src/domain/ports/in/index.ts index f41feef..32767c6 100644 --- a/apps/backend/src/domain/ports/in/index.ts +++ b/apps/backend/src/domain/ports/in/index.ts @@ -7,3 +7,4 @@ export * from './search-rates.port'; export * from './get-ports.port'; export * from './validate-availability.port'; +export * from './search-csv-rates.port'; diff --git a/apps/backend/src/domain/ports/out/blog-post.repository.ts b/apps/backend/src/domain/ports/out/blog-post.repository.ts new file mode 100644 index 0000000..8e8e039 --- /dev/null +++ b/apps/backend/src/domain/ports/out/blog-post.repository.ts @@ -0,0 +1,24 @@ +import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity'; + +export const BLOG_POST_REPOSITORY = 'BlogPostRepository'; + +export interface BlogPostFilters { + status?: BlogPostStatus; + category?: BlogPostCategory; + search?: string; + isFeatured?: boolean; + limit?: number; + offset?: number; + /** When true, also include SCHEDULED posts whose publishedAt <= now */ + includeScheduled?: boolean; +} + +export interface BlogPostRepository { + save(post: BlogPost): Promise; + findById(id: string): Promise; + findBySlug(slug: string): Promise; + findByFilters(filters: BlogPostFilters): Promise; + count(filters: BlogPostFilters): Promise; + delete(id: string): Promise; + slugExists(slug: string, excludeId?: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/index.ts b/apps/backend/src/domain/ports/out/index.ts index 9f47d85..ebc7d2f 100644 --- a/apps/backend/src/domain/ports/out/index.ts +++ b/apps/backend/src/domain/ports/out/index.ts @@ -15,6 +15,11 @@ export * from './notification.repository'; export * from './audit-log.repository'; export * from './webhook.repository'; export * from './csv-booking.repository'; +export * from './api-key.repository'; +export * from './blog-post.repository'; +export * from './invitation-token.repository'; +export * from './subscription.repository'; +export * from './license.repository'; // Infrastructure Ports export * from './cache.port'; @@ -23,6 +28,6 @@ export * from './pdf.port'; export * from './storage.port'; export * from './carrier-connector.port'; export * from './csv-rate-loader.port'; -export * from './subscription.repository'; -export * from './license.repository'; +export * from './shipment-counter.port'; +export * from './siret-verification.port'; export * from './stripe.port'; diff --git a/apps/backend/src/domain/ports/out/storage.port.ts b/apps/backend/src/domain/ports/out/storage.port.ts index 75d13a0..e03d27e 100644 --- a/apps/backend/src/domain/ports/out/storage.port.ts +++ b/apps/backend/src/domain/ports/out/storage.port.ts @@ -66,4 +66,9 @@ export interface StoragePort { * List objects in a bucket */ list(bucket: string, prefix?: string): Promise; + + /** + * Ensure a bucket exists, creating it if it does not + */ + ensureBucket(bucket: string): Promise; } diff --git a/apps/backend/src/domain/services/index.ts b/apps/backend/src/domain/services/index.ts index 1d514e6..d2ff59c 100644 --- a/apps/backend/src/domain/services/index.ts +++ b/apps/backend/src/domain/services/index.ts @@ -8,3 +8,6 @@ export * from './rate-search.service'; export * from './port-search.service'; export * from './availability-validation.service'; export * from './booking.service'; +export * from './csv-rate-search.service'; +export * from './csv-rate-price-calculator.service'; +export * from './rate-offer-generator.service'; diff --git a/apps/backend/src/domain/value-objects/index.ts b/apps/backend/src/domain/value-objects/index.ts index 2ad876c..4a26880 100644 --- a/apps/backend/src/domain/value-objects/index.ts +++ b/apps/backend/src/domain/value-objects/index.ts @@ -15,3 +15,6 @@ export * from './subscription-plan.vo'; export * from './subscription-status.vo'; export * from './license-status.vo'; export * from './locale.vo'; +export * from './surcharge.vo'; +export * from './volume.vo'; +export * from './plan-feature.vo'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts new file mode 100644 index 0000000..36cb24d --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts @@ -0,0 +1,60 @@ +import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity('blog_posts') +@Index(['status', 'published_at']) +@Index(['slug'], { unique: true }) +export class BlogPostOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column('varchar', { length: 255 }) + title: string; + + @Column('varchar', { length: 255, unique: true }) + slug: string; + + @Column('text') + excerpt: string; + + @Column('text') + content: string; + + @Column('varchar', { length: 500, nullable: true }) + cover_image_url?: string; + + @Column('varchar', { length: 50 }) + category: string; + + @Column('jsonb', { default: [] }) + tags: string[]; + + @Column('varchar', { length: 255 }) + author_name: string; + + @Column('varchar', { length: 20, default: 'draft' }) + status: string; + + @Column('boolean', { default: false }) + is_featured: boolean; + + @Column('timestamp', { nullable: true }) + published_at?: Date; + + @Column('varchar', { length: 255, nullable: true }) + meta_title?: string; + + @Column('varchar', { length: 500, nullable: true }) + meta_description?: string; + + @Column('varchar', { length: 255, nullable: true }) + primary_keyword?: string; + + @Column('jsonb', { default: [] }) + secondary_keywords: string[]; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1746000000000-CreateBlogPostsTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1746000000000-CreateBlogPostsTable.ts new file mode 100644 index 0000000..5bc66a0 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1746000000000-CreateBlogPostsTable.ts @@ -0,0 +1,116 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateBlogPostsTable1746000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'blog_posts', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + }, + { + name: 'title', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'slug', + type: 'varchar', + length: '255', + isNullable: false, + isUnique: true, + }, + { + name: 'excerpt', + type: 'text', + isNullable: false, + }, + { + name: 'content', + type: 'text', + isNullable: false, + }, + { + name: 'cover_image_url', + type: 'varchar', + length: '500', + isNullable: true, + }, + { + name: 'category', + type: 'varchar', + length: '50', + isNullable: false, + }, + { + name: 'tags', + type: 'jsonb', + isNullable: false, + default: "'[]'", + }, + { + name: 'author_name', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'status', + type: 'varchar', + length: '20', + isNullable: false, + default: "'draft'", + }, + { + name: 'is_featured', + type: 'boolean', + isNullable: false, + default: false, + }, + { + name: 'published_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true + ); + + await queryRunner.createIndex( + 'blog_posts', + new TableIndex({ + name: 'idx_blog_posts_status_published_at', + columnNames: ['status', 'published_at'], + }) + ); + + await queryRunner.createIndex( + 'blog_posts', + new TableIndex({ + name: 'idx_blog_posts_category_status', + columnNames: ['category', 'status'], + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('blog_posts'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts new file mode 100644 index 0000000..fa11348 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddSeoFieldsToBlogPosts1747000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('blog_posts', [ + new TableColumn({ + name: 'meta_title', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'meta_description', + type: 'varchar', + length: '500', + isNullable: true, + }), + new TableColumn({ + name: 'primary_keyword', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'secondary_keywords', + type: 'jsonb', + isNullable: false, + default: "'[]'", + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns('blog_posts', [ + 'meta_title', + 'meta_description', + 'primary_keyword', + 'secondary_keywords', + ]); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts new file mode 100644 index 0000000..7cac371 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity'; +import { BlogPostFilters, BlogPostRepository } from '@domain/ports/out/blog-post.repository'; +import { BlogPostOrmEntity } from '../entities/blog-post.orm-entity'; + +@Injectable() +export class TypeOrmBlogPostRepository implements BlogPostRepository { + constructor( + @InjectRepository(BlogPostOrmEntity) + private readonly ormRepository: Repository + ) {} + + async save(post: BlogPost): Promise { + const orm = this.toOrm(post); + const saved = await this.ormRepository.save(orm); + return this.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.ormRepository.findOne({ where: { id } }); + return orm ? this.toDomain(orm) : null; + } + + async findBySlug(slug: string): Promise { + const orm = await this.ormRepository.findOne({ where: { slug } }); + return orm ? this.toDomain(orm) : null; + } + + async findByFilters(filters: BlogPostFilters): Promise { + const query = this.ormRepository.createQueryBuilder('post'); + + if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) { + query.andWhere( + "(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))" + ); + } else if (filters.status) { + query.andWhere('post.status = :status', { status: filters.status }); + } + + if (filters.category) { + query.andWhere('post.category = :category', { category: filters.category }); + } + + if (filters.isFeatured !== undefined) { + query.andWhere('post.is_featured = :isFeatured', { isFeatured: filters.isFeatured }); + } + + if (filters.search) { + query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + query.orderBy('post.published_at', 'DESC').addOrderBy('post.created_at', 'DESC'); + + if (filters.offset) query.skip(filters.offset); + if (filters.limit) query.take(filters.limit); + + const results = await query.getMany(); + return results.map(e => this.toDomain(e)); + } + + async count(filters: BlogPostFilters): Promise { + const query = this.ormRepository.createQueryBuilder('post'); + + if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) { + query.andWhere( + "(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))" + ); + } else if (filters.status) { + query.andWhere('post.status = :status', { status: filters.status }); + } + if (filters.category) { + query.andWhere('post.category = :category', { category: filters.category }); + } + if (filters.search) { + query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + return query.getCount(); + } + + async delete(id: string): Promise { + await this.ormRepository.delete(id); + } + + async slugExists(slug: string, excludeId?: string): Promise { + const query = this.ormRepository + .createQueryBuilder('post') + .where('post.slug = :slug', { slug }); + if (excludeId) { + query.andWhere('post.id != :excludeId', { excludeId }); + } + const count = await query.getCount(); + return count > 0; + } + + private toDomain(orm: BlogPostOrmEntity): BlogPost { + return BlogPost.fromPersistence({ + id: orm.id, + title: orm.title, + slug: orm.slug, + excerpt: orm.excerpt, + content: orm.content, + coverImageUrl: orm.cover_image_url, + category: orm.category as BlogPostCategory, + tags: orm.tags ?? [], + authorName: orm.author_name, + status: orm.status as BlogPostStatus, + isFeatured: orm.is_featured, + publishedAt: orm.published_at, + metaTitle: orm.meta_title, + metaDescription: orm.meta_description, + primaryKeyword: orm.primary_keyword, + secondaryKeywords: orm.secondary_keywords ?? [], + createdAt: orm.created_at, + updatedAt: orm.updated_at, + }); + } + + private toOrm(post: BlogPost): BlogPostOrmEntity { + const orm = new BlogPostOrmEntity(); + orm.id = post.id; + orm.title = post.title; + orm.slug = post.slug; + orm.excerpt = post.excerpt; + orm.content = post.content; + orm.cover_image_url = post.coverImageUrl; + orm.category = post.category; + orm.tags = post.tags; + orm.author_name = post.authorName; + orm.status = post.status; + orm.is_featured = post.isFeatured; + orm.published_at = post.publishedAt; + orm.meta_title = post.metaTitle; + orm.meta_description = post.metaDescription; + orm.primary_keyword = post.primaryKeyword; + orm.secondary_keywords = post.secondaryKeywords; + orm.created_at = post.createdAt; + orm.updated_at = post.updatedAt; + return orm; + } +} diff --git a/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts b/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts index 496cf58..a054183 100644 --- a/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts +++ b/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts @@ -12,6 +12,8 @@ import { GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, + HeadBucketCommand, + CreateBucketCommand, ListObjectsV2Command, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; @@ -70,6 +72,23 @@ export class S3StorageAdapter implements StoragePort { ); } + async ensureBucket(bucket: string): Promise { + if (!this.s3Client) return; + + try { + await this.s3Client.send(new HeadBucketCommand({ Bucket: bucket })); + } catch (err: any) { + const status = err.$metadata?.httpStatusCode; + if (status === 404 || err.name === 'NoSuchBucket' || err.name === 'NotFound') { + this.logger.log(`Bucket "${bucket}" not found — creating it automatically`); + await this.s3Client.send(new CreateBucketCommand({ Bucket: bucket })); + this.logger.log(`Bucket "${bucket}" created`); + } else { + throw err; + } + } + } + async upload(options: UploadOptions): Promise { if (!this.s3Client) { throw new Error( @@ -77,6 +96,8 @@ export class S3StorageAdapter implements StoragePort { ); } + await this.ensureBucket(options.bucket); + try { const command = new PutObjectCommand({ Bucket: options.bucket, @@ -108,6 +129,12 @@ export class S3StorageAdapter implements StoragePort { } async download(options: DownloadOptions): Promise { + if (!this.s3Client) { + throw new Error( + 'S3 Storage is not configured. Set AWS_S3_ENDPOINT or AWS credentials in .env' + ); + } + try { const command = new GetObjectCommand({ Bucket: options.bucket, diff --git a/apps/backend/test-carrier-email.js b/apps/backend/test-carrier-email.js deleted file mode 100644 index 41833f5..0000000 --- a/apps/backend/test-carrier-email.js +++ /dev/null @@ -1,29 +0,0 @@ -const nodemailer = require('nodemailer'); - -const transporter = nodemailer.createTransport({ - host: 'sandbox.smtp.mailtrap.io', - port: 2525, - auth: { - user: '2597bd31d265eb', - pass: 'cd126234193c89' - } -}); - -console.log('🔄 Tentative d\'envoi d\'email...'); - -transporter.sendMail({ - from: 'noreply@xpeditis.com', - to: 'test@example.com', - subject: 'Test Email depuis Portail Transporteur', - text: 'Email de test pour vĂ©rifier la configuration' -}).then(info => { - console.log('✅ Email envoyĂ©:', info.messageId); - console.log('📧 Response:', info.response); - process.exit(0); -}).catch(err => { - console.error('❌ Erreur:', err.message); - console.error('Code:', err.code); - console.error('Command:', err.command); - console.error('Stack:', err.stack); - process.exit(1); -}); diff --git a/apps/backend/tsconfig.build.json b/apps/backend/tsconfig.build.json index 1d7acd8..64f86c6 100644 --- a/apps/backend/tsconfig.build.json +++ b/apps/backend/tsconfig.build.json @@ -1,4 +1,4 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/frontend/app/[locale]/about/page.tsx b/apps/frontend/app/[locale]/about/page.tsx index 105bf0d..a49585e 100644 --- a/apps/frontend/app/[locale]/about/page.tsx +++ b/apps/frontend/app/[locale]/about/page.tsx @@ -90,7 +90,10 @@ export default function AboutPage() { {/* Hero Section */} -
+
@@ -155,9 +158,7 @@ export default function AboutPage() {

{t('mission.title')}

-

- {t('mission.body')} -

+

{t('mission.body')}

{t('vision.title')}

-

- {t('vision.body')} -

+

{t('vision.body')}

@@ -186,11 +185,7 @@ export default function AboutPage() { >
{STATS.map((stat, index) => ( - + -

{t('valuesTitle')}

-

- {t('valuesSubtitle')} -

+

+ {t('valuesTitle')} +

+

{t('valuesSubtitle')}

- {VALUES.map((value) => { + {VALUES.map(value => { const IconComponent = value.icon; return (
-

{t(`values.${value.key}.title`)}

+

+ {t(`values.${value.key}.title`)} +

{t(`values.${value.key}.description`)}

); @@ -259,10 +256,10 @@ export default function AboutPage() { transition={{ duration: 0.8 }} className="text-center mb-16" > -

{t('timelineTitle')}

-

- {t('timelineSubtitle')} -

+

+ {t('timelineTitle')} +

+

{t('timelineSubtitle')}

@@ -286,13 +283,19 @@ export default function AboutPage() { transition={{ duration: 0.7, ease: 'easeOut' }} className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`} > -
+
-
+
{year}
-

{t(`timeline.${year}.title`)}

+

+ {t(`timeline.${year}.title`)} +

{t(`timeline.${year}.description`)}

@@ -302,7 +305,13 @@ export default function AboutPage() { initial={{ scale: 0 }} whileInView={{ scale: 1 }} viewport={{ once: true, amount: 0.6 }} - transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }} + transition={{ + duration: 0.4, + delay: 0.15, + type: 'spring', + stiffness: 320, + damping: 18, + }} className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30" />
@@ -324,10 +333,10 @@ export default function AboutPage() { transition={{ duration: 0.8 }} className="text-center mb-16" > -

{t('teamTitle')}

-

- {t('teamSubtitle')} -

+

+ {t('teamTitle')} +

+

{t('teamSubtitle')}

- {TEAM.map((member) => ( + {TEAM.map(member => (

{member.name}

-

{t(`team.${member.key}.role`)}

+

+ {t(`team.${member.key}.role`)} +

{t(`team.${member.key}.bio`)}

@@ -376,12 +387,8 @@ export default function AboutPage() { viewport={{ once: true }} transition={{ duration: 0.8 }} > -

- {t('cta.title')} -

-

- {t('cta.body')} -

+

{t('cta.title')}

+

{t('cta.body')}

]*>/g, ''); + const words = text.trim().split(/\s+/).length; + return Math.max(1, Math.ceil(words / 200)); +} + +const CATEGORY_LABELS: Record = { + industry: 'Industrie', + technology: 'Technologie', + guides: 'Guides', + news: 'Actualités', +}; + +function LoadingSkeleton() { + return ( +
+ +
+
+
+
+
+
+
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+
+
+ +
+ ); +} + +function NotFoundView() { + return ( +
+ +
+ +

Article introuvable

+

+ Cet article n'existe pas ou n'est pas encore publié. +

+ + + + Retour au blog + + +
+ +
+ ); +} + +export default function BlogPostContent({ slug }: { slug: string }) { + const [post, setPost] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + if (!slug) return; + let cancelled = false; + + getBlogPost(slug) + .then(p => { + if (!cancelled) setPost(p); + }) + .catch(() => { + if (!cancelled) setNotFound(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [slug]); + + if (loading) return ; + if (notFound || !post) return ; + + const readingTime = estimateReadingTime(post.content); + const processedContent = post.content.replace( + /src="(\/api\/v1\/blog\/images\/[^"]+)"/g, + `src="${API_BASE_URL}$1"` + ); + + const handleShare = () => { + if (navigator.share) { + navigator.share({ title: post.title, url: window.location.href }); + } else { + navigator.clipboard.writeText(window.location.href); + } + }; + + return ( +
+ + + {/* Hero */} +
+
+
+
+
+ +
+ + + + + Retour au blog + + + +
+ + {CATEGORY_LABELS[post.category] ?? post.category} + + {post.isFeatured && ( + + À la une + + )} +
+ +

+ {post.title} +

+ +

{post.excerpt}

+ +
+
+ + {post.authorName} +
+ {post.publishedAt && ( +
+ + {formatDate(post.publishedAt)} +
+ )} +
+ + {readingTime} min de lecture +
+ {post.tags.length > 0 && ( +
+ + {post.tags.join(', ')} +
+ )} +
+
+
+ +
+ + + +
+
+ + {/* Cover Image */} + {post.coverImageUrl && ( +
+ + {post.title} + +
+ )} + + {/* Content + Sidebar */} +
+
+
+ {/* Article body */} + + + {/* Floating share button (desktop) */} +
+ +
+
+ + {/* Tags */} + {post.tags.length > 0 && ( +
+ {post.tags.map(tag => ( + + #{tag} + + ))} +
+ )} + + {/* CTA */} +
+ +

+ PrĂȘt Ă  tester Xpeditis ? +

+

+ Créez votre compte en 2 minutes et lancez votre premier chiffrage maritime instantané. + C'est gratuit et sans engagement. +

+ + Créer mon compte gratuitement + +
+ + {/* Back + Share */} +
+ + + + Retour au blog + + + +
+
+
+ + +
+ ); +} diff --git a/apps/frontend/app/[locale]/blog/[slug]/page.tsx b/apps/frontend/app/[locale]/blog/[slug]/page.tsx new file mode 100644 index 0000000..445a8a9 --- /dev/null +++ b/apps/frontend/app/[locale]/blog/[slug]/page.tsx @@ -0,0 +1,81 @@ +import type { Metadata } from 'next'; +import BlogPostContent from './BlogPostContent'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + +async function fetchPostMeta(slug: string) { + try { + const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, { + cache: 'no-store', + }); + if (!res.ok) return null; + return res.json() as Promise<{ + title: string; + excerpt: string; + metaTitle?: string; + metaDescription?: string; + primaryKeyword?: string; + secondaryKeywords?: string[]; + coverImageUrl?: string; + authorName: string; + publishedAt?: string; + updatedAt: string; + }>; + } catch { + return null; + } +} + +export async function generateMetadata({ + params, +}: { + params: { slug: string; locale: string }; +}): Promise { + const post = await fetchPostMeta(params.slug); + + if (!post) { + return { title: 'Blog — Xpeditis' }; + } + + const title = post.metaTitle || post.title; + const description = post.metaDescription || post.excerpt; + const keywords = [post.primaryKeyword, ...(post.secondaryKeywords ?? [])] + .filter(Boolean) + .join(', '); + const coverImage = post.coverImageUrl + ? post.coverImageUrl.startsWith('http') + ? post.coverImageUrl + : `${API_BASE_URL}${post.coverImageUrl}` + : undefined; + + return { + title, + description, + keywords: keywords || undefined, + openGraph: { + title, + description, + type: 'article', + publishedTime: post.publishedAt, + authors: [post.authorName], + images: coverImage ? [{ url: coverImage, alt: post.title }] : [], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: coverImage ? [coverImage] : [], + }, + alternates: { + canonical: `/blog/${params.slug}`, + }, + }; +} + +export default function BlogPostPage({ + params, +}: { + params: { slug: string; locale: string }; +}) { + return ; +} diff --git a/apps/frontend/app/[locale]/blog/page.tsx b/apps/frontend/app/[locale]/blog/page.tsx index 9c4e18a..2a2843e 100644 --- a/apps/frontend/app/[locale]/blog/page.tsx +++ b/apps/frontend/app/[locale]/blog/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { motion, useInView } from 'framer-motion'; @@ -19,9 +19,16 @@ import { type LucideIcon, } from 'lucide-react'; import { LandingHeader, LandingFooter } from '@/components/layout'; +import { getBlogPosts, type BlogPost, type BlogPostCategory } from '@/lib/api/blog'; -type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news'; -type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents'; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; +function resolveUrl(url: string | null | undefined): string | undefined { + if (!url) return undefined; + if (url.startsWith('http')) return url; + return `${API_BASE_URL}${url}`; +} + +type CategoryKey = 'all' | BlogPostCategory; const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ { key: 'all', icon: BookOpen }, @@ -31,20 +38,36 @@ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ { key: 'news', icon: Globe }, ]; -const ARTICLES: { id: number; key: ArticleKey; category: Exclude; tags: string[] }[] = [ - { id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] }, - { id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] }, - { id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] }, - { id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] }, - { id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] }, - { id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] }, - { id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] }, -]; +const containerVariants = { + hidden: { opacity: 0, y: 50 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6, staggerChildren: 0.1 }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }, +}; + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('fr-FR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} export default function BlogPage() { const t = useTranslations('marketing.blog'); const [selectedCategory, setSelectedCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [posts, setPosts] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [featuredPost, setFeaturedPost] = useState(null); const heroRef = useRef(null); const articlesRef = useRef(null); @@ -54,44 +77,50 @@ export default function BlogPage() { const isArticlesInView = useInView(articlesRef, { once: true }); const isCategoriesInView = useInView(categoriesRef, { once: true }); - const filteredArticles = ARTICLES.filter((article) => { - const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory; - const title = t(`articles.${article.key}.title` as any); - const excerpt = t(`articles.${article.key}.excerpt` as any); - const searchMatch = - searchQuery === '' || - title.toLowerCase().includes(searchQuery.toLowerCase()) || - excerpt.toLowerCase().includes(searchQuery.toLowerCase()); - return categoryMatch && searchMatch; - }); + useEffect(() => { + let cancelled = false; - const containerVariants = { - hidden: { opacity: 0, y: 50 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - staggerChildren: 0.1, - }, - }, - }; + const load = async () => { + setLoading(true); + try { + const res = await getBlogPosts({ + category: selectedCategory !== 'all' ? selectedCategory : undefined, + search: searchQuery || undefined, + limit: 50, + }); + if (!cancelled) { + const featured = res.posts.find(p => p.isFeatured) ?? res.posts[0] ?? null; + const rest = res.posts.filter(p => p !== featured); + setFeaturedPost(featured); + setPosts(rest); + setTotal(res.total); + } + } catch { + if (!cancelled) { + setPosts([]); + setFeaturedPost(null); + setTotal(0); + } + } finally { + if (!cancelled) setLoading(false); + } + }; - const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { duration: 0.5 }, - }, - }; + load(); + return () => { + cancelled = true; + }; + }, [selectedCategory, searchQuery]); return (
{/* Hero Section */} -
+
@@ -126,7 +155,6 @@ export default function BlogPage() { {t('intro')}

- {/* Search Bar */} setSearchQuery(e.target.value)} + onChange={e => setSearchQuery(e.target.value)} className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none" />
@@ -147,7 +175,6 @@ export default function BlogPage() {
- {/* Wave */}
- {CATEGORIES.map((category) => { + {CATEGORIES.map(category => { const IconComponent = category.icon; const isActive = selectedCategory === category.key; return ( @@ -190,64 +217,71 @@ export default function BlogPage() {
{/* Featured Article */} -
-
- - -
-
-
- -
- -
-
-
- - {t('featuredBadge')} - - - {t('categories.technology')} - + {!loading && featuredPost && ( +
+
+ + +
+
+ {featuredPost.coverImageUrl ? ( +
+ ) : ( +
+
+ )} -

- {t('featured.title')} -

- -

{t('featured.excerpt')}

- -
-
- - {t('featured.author')} +
+
+
+ + {t('featuredBadge')} + + + {t(`categories.${featuredPost.category}`)} +
-
- - {t('featured.date')} -
-
- - {t('featured.readTime')} -
-
-
- {t('readArticle')} - +

+ {featuredPost.title} +

+ +

{featuredPost.excerpt}

+ +
+
+ + {featuredPost.authorName} +
+ {featuredPost.publishedAt && ( +
+ + {formatDate(featuredPost.publishedAt)} +
+ )} +
+ +
+ {t('readArticle')} + +
-
- - -
-
+ + +
+
+ )} {/* Articles Grid */}
@@ -259,10 +293,26 @@ export default function BlogPage() { className="flex items-center justify-between mb-12" >

{t('allTitle')}

- {t('articlesCount', { count: filteredArticles.length })} + {t('articlesCount', { count: posts.length })} - {filteredArticles.length === 0 ? ( + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : posts.length === 0 ? (

{t('noResults.title')}

@@ -275,30 +325,36 @@ export default function BlogPage() { animate={isArticlesInView ? 'visible' : 'hidden'} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" > - {filteredArticles.map((article) => ( - - + {posts.map(post => ( + +
-
- +
+ {post.coverImageUrl ? ( + {post.title} + ) : ( + + )}
- {t(`categories.${article.category}`)} + {t(`categories.${post.category}`)}

- {t(`articles.${article.key}.title` as any)} + {post.title}

-

- {t(`articles.${article.key}.excerpt` as any)} -

+

{post.excerpt}

- {article.tags.map((tag) => ( + {post.tags.map(tag => (
- {t(`articles.${article.key}.author` as any)} + {post.authorName}
-
- {t(`articles.${article.key}.date` as any)} - + {post.publishedAt && ( +
- {t(`articles.${article.key}.readTime` as any)} - -
+ {formatDate(post.publishedAt)} +
+ )}
@@ -330,21 +385,6 @@ export default function BlogPage() { ))} )} - - {/* Load More */} - {filteredArticles.length > 0 && ( - - - - )}
@@ -357,12 +397,8 @@ export default function BlogPage() { viewport={{ once: true }} transition={{ duration: 0.8 }} > -

- {t('newsletter.title')} -

-

- {t('newsletter.body')} -

+

{t('newsletter.title')}

+

{t('newsletter.body')}

-

- {t('newsletter.disclaimer')} -

+

{t('newsletter.disclaimer')}

diff --git a/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx index 54be80d..6927dfe 100644 --- a/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx @@ -81,9 +81,7 @@ export default function BookingConfirmPage() { /> -

- {t('errorTitle')} -

+

{t('errorTitle')}

{error}

@@ -98,9 +96,7 @@ export default function BookingConfirmPage() { -

- {t('errorContact')} -

+

{t('errorContact')}

); @@ -134,22 +130,14 @@ export default function BookingConfirmPage() {
-

- {t('successTitle')} -

-

- {t('successHeadline')} -

-

- {t('successBody')} -

+

{t('successTitle')}

+

{t('successHeadline')}

+

{t('successBody')}

{/* Booking Summary */}
-

- {t('summaryTitle')} -

+

{t('summaryTitle')}

@@ -197,14 +185,12 @@ export default function BookingConfirmPage() {
{booking.primaryCurrency === 'USD' ? `$${booking.priceUSD.toLocaleString()}` - : `€${booking.priceEUR.toLocaleString()}` - } + : `€${booking.priceEUR.toLocaleString()}`}
{booking.primaryCurrency === 'USD' ? `(€${booking.priceEUR.toLocaleString()})` - : `($${booking.priceUSD.toLocaleString()})` - } + : `($${booking.priceUSD.toLocaleString()})`}
@@ -222,7 +208,12 @@ export default function BookingConfirmPage() {

- + {t('nextStepsTitle')}

@@ -239,10 +230,23 @@ export default function BookingConfirmPage() {

{t('labels.documents')}

{booking.documents.map((doc, index) => ( -
+
- - + +

{doc.fileName}

diff --git a/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx index f4b8a2a..228ebe7 100644 --- a/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx @@ -89,9 +89,7 @@ export default function BookingRejectPage() { />
-

- {t('errorTitle')} -

+

{t('errorTitle')}

{error}

@@ -106,9 +104,7 @@ export default function BookingRejectPage() {
-

- {t('errorContact')} -

+

{t('errorContact')}

); @@ -137,21 +133,13 @@ export default function BookingRejectPage() {
-

- {t('rejectedTitle')} -

-

- {t('rejectedHeadline')} -

-

- {t('rejectedBody')} -

+

{t('rejectedTitle')}

+

{t('rejectedHeadline')}

+

{t('rejectedBody')}

-

- {t('summaryTitle')} -

+

{t('summaryTitle')}

@@ -181,8 +169,7 @@ export default function BookingRejectPage() { {booking.primaryCurrency === 'USD' ? `$${booking.priceUSD.toLocaleString()}` - : `€${booking.priceEUR.toLocaleString()}` - } + : `€${booking.priceEUR.toLocaleString()}`}
@@ -200,13 +187,16 @@ export default function BookingRejectPage() {

- + {t('infoTitle')}

-

- {t('infoBody')} -

+

{t('infoBody')}

@@ -259,12 +249,8 @@ export default function BookingRejectPage() { />
-

- {t('formTitle')} -

-

- {t('formIntro')} -

+

{t('formTitle')}

+

{t('formIntro')}

@@ -275,8 +261,18 @@ export default function BookingRejectPage() { >
{t('addReason')} - - + +
@@ -289,18 +285,14 @@ export default function BookingRejectPage() { id="reason" rows={4} value={reason} - onChange={(e) => setReason(e.target.value)} + onChange={e => setReason(e.target.value)} placeholder={t('reasonPlaceholder')} 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} />
-

- {t('reasonHint')} -

- - {reason.length}/500 - +

{t('reasonHint')}

+ {reason.length}/500
)} @@ -320,16 +312,36 @@ export default function BookingRejectPage() { > {isRejecting ? ( <> - - - + + + {t('submitting')} ) : ( <> - + {t('submit')} @@ -344,9 +356,7 @@ export default function BookingRejectPage() { -

- {t('helpText')} -

+

{t('helpText')}

); diff --git a/apps/frontend/app/[locale]/careers/page.tsx b/apps/frontend/app/[locale]/careers/page.tsx index 1af1e75..5437892 100644 --- a/apps/frontend/app/[locale]/careers/page.tsx +++ b/apps/frontend/app/[locale]/careers/page.tsx @@ -64,15 +64,76 @@ type JobRecord = { }; const JOBS: JobRecord[] = [ - { id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code }, - { id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code }, - { id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart }, - { id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone }, - { id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones }, - { id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart }, + { + id: 1, + key: 'frontend', + department: 'Engineering', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '65K - 85K €', + icon: Code, + }, + { + id: 2, + key: 'backend', + department: 'Engineering', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '55K - 75K €', + icon: Code, + }, + { + id: 3, + key: 'pm', + department: 'Product', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '60K - 80K €', + icon: LineChart, + }, + { + id: 4, + key: 'ae', + department: 'Sales', + location: 'Rotterdam', + type: 'CDI', + remote: false, + salary: '50K - 70K € + variable', + icon: Megaphone, + }, + { + id: 5, + key: 'csm', + department: 'Customer Success', + location: 'Paris', + type: 'CDI', + remote: true, + salary: '45K - 60K €', + icon: Headphones, + }, + { + id: 6, + key: 'data', + department: 'Data', + location: 'Hambourg', + type: 'CDI', + remote: true, + salary: '50K - 65K €', + icon: LineChart, + }, ]; -const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data']; +const DEPARTMENT_VALUES: DepartmentValue[] = [ + 'all', + 'Engineering', + 'Product', + 'Sales', + 'Customer Success', + 'Data', +]; const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg']; const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const; @@ -92,7 +153,7 @@ export default function CareersPage() { const isJobsInView = useInView(jobsRef, { once: true }); const isCultureInView = useInView(cultureRef, { once: true }); - const filteredJobs = JOBS.filter((job) => { + const filteredJobs = JOBS.filter(job => { const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment; const locationMatch = selectedLocation === 'all' || job.location === selectedLocation; return departmentMatch && locationMatch; @@ -124,7 +185,10 @@ export default function CareersPage() { {/* Hero Section */} -
+
@@ -221,9 +285,7 @@ export default function CareersPage() {

{t('benefitsTitle')}

-

- {t('benefitsSubtitle')} -

+

{t('benefitsSubtitle')}

- {BENEFITS.map((benefit) => { + {BENEFITS.map(benefit => { const IconComponent = benefit.icon; return (
-

{t(`benefits.${benefit.key}.title`)}

+

+ {t(`benefits.${benefit.key}.title`)} +

{t(`benefits.${benefit.key}.description`)}

); @@ -254,7 +318,10 @@ export default function CareersPage() {
{/* Culture Section */} -
+
{t('cultureTitle')} -

- {t('cultureBody')} -

+

{t('cultureBody')}

    {CULTURE_ITEMS.map((itemKey, index) => ( - {[1, 2, 3, 4].map((i) => ( + {[1, 2, 3, 4].map(i => (
    {t('jobsTitle')} -

    - {t('jobsSubtitle')} -

    +

    {t('jobsSubtitle')}

    {/* Filters */} @@ -332,12 +395,14 @@ export default function CareersPage() {
    @@ -346,10 +411,10 @@ export default function CareersPage() {
    -