diff --git a/CLAUDE.md b/CLAUDE.md index d210ccc..02f2b3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,10 @@ npm run frontend:dev # http://localhost:3000 ```bash # Backend (from apps/backend/) -npm test # Unit tests (Jest) -npm test -- booking.entity.spec.ts # Single file -npm run test:cov # With coverage +npm test # Unit tests (Jest) +npm test -- booking.entity.spec.ts # Single file +npm test -- --testNamePattern="should create" # Filter by test name +npm run test:cov # With coverage npm run test:integration # Integration tests (needs DB/Redis, 30s timeout) npm run test:e2e # E2E tests @@ -75,6 +76,7 @@ npm run migration:revert ```bash npm run backend:build # NestJS build with tsc-alias for path resolution npm run frontend:build # Next.js production build (standalone output) +npm run clean # Remove all node_modules, dist, .next directories ``` ## Local Infrastructure @@ -84,6 +86,8 @@ Docker-compose defaults (no `.env` changes needed for local dev): - **Redis**: password `xpeditis_redis_password`, port 6379 - **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001 +Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`) — configured in `next.config.js`. + ## Architecture ### Hexagonal Architecture (Backend) @@ -91,15 +95,32 @@ Docker-compose defaults (no `.env` changes needed for local dev): ``` apps/backend/src/ ├── domain/ # CORE - Pure TypeScript, NO framework imports -│ ├── entities/ # Booking, RateQuote, User, Carrier, Port, Container, CsvBooking, etc. -│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType, Volume, LicenseStatus, SubscriptionPlan, etc. -│ ├── services/ # Pure domain services (rate-search, csv-rate-price-calculator, booking, port-search, etc.) +│ ├── entities/ # Booking, RateQuote, Carrier, Port, Container, Notification, Webhook, +│ │ # AuditLog, User, Organization, Subscription, License, CsvBooking, +│ │ # CsvRate, InvitationToken +│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType, +│ │ # Volume, DateRange, Surcharge +│ ├── services/ # Pure domain services (csv-rate-price-calculator) │ ├── ports/ │ │ ├── in/ # Use case interfaces with execute() method │ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository') │ └── exceptions/ # Domain-specific exceptions ├── application/ # Controllers, DTOs (class-validator), Guards, Decorators, Mappers -└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs, MinIO/S3, email (MJML+Nodemailer), Sentry +│ ├── [feature]/ # Feature modules: auth/, bookings/, csv-bookings, rates/, ports/, +│ │ # organizations/, users/, dashboard/, audit/, notifications/, webhooks/, +│ │ # gdpr/, admin/, subscriptions/ +│ ├── controllers/ # REST controllers (also nested under feature folders) +│ ├── services/ # Application services: audit, notification, webhook, +│ │ # booking-automation, export, fuzzy-search, brute-force-protection +│ ├── gateways/ # WebSocket gateways (notifications.gateway.ts via Socket.IO) +│ ├── guards/ # JwtAuthGuard, RolesGuard, CustomThrottlerGuard +│ ├── decorators/ # @Public(), @Roles(), @CurrentUser() +│ ├── dto/ # Request/response DTOs with class-validator +│ ├── mappers/ # Domain ↔ DTO mappers +│ └── interceptors/ # PerformanceMonitoringInterceptor +└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs, + # MinIO/S3, email (MJML+Nodemailer), Stripe, Sentry, + # Pappers (French SIRET registry), PDF generation ``` **Critical dependency rules**: @@ -108,6 +129,7 @@ apps/backend/src/ - Path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` (defined in `apps/backend/tsconfig.json`) - Domain tests run without NestJS TestingModule - Backend has strict TypeScript: `strict: true`, `strictNullChecks: true` (but `strictPropertyInitialization: false`) +- Env vars validated at startup via Joi schema in `app.module.ts` — required vars include DATABASE_*, REDIS_*, JWT_SECRET, SMTP_* ### NestJS Modules (app.module.ts) @@ -115,31 +137,36 @@ Global guards: JwtAuthGuard (all routes protected by default), CustomThrottlerGu Feature modules: Auth, Rates, Ports, Bookings, CsvBookings, Organizations, Users, Dashboard, Audit, Notifications, Webhooks, GDPR, Admin, Subscriptions. -Infrastructure modules: CacheModule, CarrierModule, SecurityModule, CsvRateModule. +Infrastructure modules: CacheModule, CarrierModule, SecurityModule, CsvRateModule, StripeModule, PdfModule, StorageModule, EmailModule. -Swagger plugin enabled in `nest-cli.json` — DTOs auto-documented. +Swagger plugin enabled in `nest-cli.json` — DTOs auto-documented. Logging via `nestjs-pino` (pino-pretty in dev). ### Frontend (Next.js 14 App Router) ``` apps/frontend/ -├── app/ # App Router pages -│ ├── dashboard/ # Protected routes (bookings, admin, settings) -│ └── carrier/ # Carrier portal (magic link auth) +├── app/ # App Router pages (root-level) +│ ├── dashboard/ # Protected routes (bookings, admin, settings, wiki, search) +│ ├── carrier/ # Carrier portal (magic link auth — accept/reject/documents) +│ ├── booking/ # Booking confirmation/rejection flows +│ └── [auth pages] # login, register, forgot-password, verify-email └── src/ - ├── components/ # React components (shadcn/ui in ui/, layout/, bookings/, admin/) + ├── app/ # Additional app pages (e.g. rates/csv-search) + ├── components/ # React components (ui/, layout/, bookings/, admin/, rate-search/, organization/) ├── hooks/ # useBookings, useNotifications, useCsvRateSearch, useCompanies, useFilterOptions ├── lib/ │ ├── api/ # Fetch-based API client with auto token refresh (client.ts + per-module files) - │ ├── context/ # Auth context + │ ├── context/ # Auth context, cookie context + │ ├── providers/ # QueryProvider (TanStack Query / React Query) │ └── fonts.ts # Manrope (headings) + Montserrat (body) ├── types/ # TypeScript type definitions - └── utils/ # Export utilities (Excel, PDF) + ├── utils/ # Export utilities (Excel, PDF) + └── legacy-pages/ # Archived page components (BookingsManagement, CarrierManagement, CarrierMonitoring) ``` Path aliases: `@/*` → `./src/*`, `@/components/*`, `@/lib/*`, `@/app/*` → `./app/*`, `@/types/*`, `@/hooks/*`, `@/utils/*` -**Note**: Frontend tsconfig has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false` (unlike backend which is strict). +**Note**: Frontend tsconfig has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false` (unlike backend which is strict). Uses TanStack Query (React Query) for server state — wrap new data fetching in hooks, not bare `fetch` calls. ### Brand Design @@ -171,13 +198,26 @@ Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR - Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods ### Frontend API Client -Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client. +Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client. + +### Route Protection (Middleware) +`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists: +- `exactPublicPaths`: exact matches (e.g. `/`) +- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.) + +All other routes redirect to `/login?redirect=` when the cookie is absent. ### Application Decorators - `@Public()` — skip JWT auth - `@Roles()` — role-based access control - `@CurrentUser()` — inject authenticated user +### API Key Authentication +A second auth mechanism alongside JWT. `ApiKey` domain entity (`domain/entities/api-key.entity.ts`) — keys are hashed with Argon2. `ApiKeyGuard` in `application/guards/` checks the `x-api-key` header. Routes can accept either JWT or API key; see `admin.controller.ts` for examples. + +### WebSocket (Real-time Notifications) +Socket.IO gateway at `application/gateways/notifications.gateway.ts`. Clients connect to `/` namespace with a JWT bearer token in the handshake auth. Server emits `notification` events. The frontend `useNotifications` hook handles subscriptions. + ### Carrier Connectors Five carrier connectors (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) extending `base-carrier.connector.ts`, each with request/response mappers. Circuit breaker via `opossum` (5s timeout). @@ -193,12 +233,14 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}: - RBAC Roles: ADMIN, MANAGER, USER, VIEWER, CARRIER - JWT: access token 15min, refresh token 7d - Password hashing: Argon2 +- OAuth providers: Google, Microsoft (configured via passport strategies) +- Organizations can be validated via Pappers API (French SIRET/company registry) at `infrastructure/external/pappers-siret.adapter.ts` ### Carrier Portal Workflow 1. Admin creates CSV booking → assigns carrier 2. Email with magic link sent (1-hour expiry) 3. Carrier auto-login → accept/reject booking -4. Activity logged in `carrier_activities` table +4. Activity logged in `carrier_activities` table (via `CarrierProfile` + `CarrierActivity` ORM entities) ## Common Pitfalls @@ -215,14 +257,15 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}: 1. **Domain Entity** → `domain/entities/*.entity.ts` (pure TS, unit tests) 2. **Value Objects** → `domain/value-objects/*.vo.ts` (immutable) -3. **Port Interface** → `domain/ports/out/*.repository.ts` (with token constant) -4. **ORM Entity** → `infrastructure/persistence/typeorm/entities/*.orm-entity.ts` -5. **Migration** → `npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName` -6. **Repository Impl** → `infrastructure/persistence/typeorm/repositories/` -7. **Mapper** → `infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany) -8. **DTOs** → `application/dto/` (with class-validator decorators) -9. **Controller** → `application/controllers/` (with Swagger decorators) -10. **Module** → Register and import in `app.module.ts` +3. **In Port (Use Case)** → `domain/ports/in/*.use-case.ts` (interface with `execute()`) +4. **Out Port (Repository)** → `domain/ports/out/*.repository.ts` (with token constant) +5. **ORM Entity** → `infrastructure/persistence/typeorm/entities/*.orm-entity.ts` +6. **Migration** → `npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName` +7. **Repository Impl** → `infrastructure/persistence/typeorm/repositories/` +8. **Mapper** → `infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany) +9. **DTOs** → `application/dto/` (with class-validator decorators) +10. **Controller** → `application/controllers/` (with Swagger decorators) +11. **Module** → Register repository + use-case providers, import in `app.module.ts` ## Documentation @@ -230,3 +273,5 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}: - Setup guide: `docs/installation/START-HERE.md` - Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md` - Full docs index: `docs/README.md` +- Development roadmap: `TODO.md` +- Infrastructure configs (CI/CD, Docker): `infra/` diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 831f9b8..aa66cfe 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -37,12 +37,14 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback APP_URL=http://localhost:3000 # Email (SMTP) -SMTP_HOST=smtp.sendgrid.net -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=apikey -SMTP_PASS=your-sendgrid-api-key -SMTP_FROM=noreply@xpeditis.com + 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 # AWS S3 / Storage (or MinIO for development) AWS_ACCESS_KEY_ID=your-aws-access-key @@ -74,6 +76,11 @@ ONE_API_URL=https://api.one-line.com/v1 ONE_USERNAME=your-one-username ONE_PASSWORD=your-one-password +# Swagger Documentation Access (HTTP Basic Auth) +# Leave empty to disable Swagger in production, or set both to protect with a password +SWAGGER_USERNAME=admin +SWAGGER_PASSWORD=change-this-strong-password + # Security BCRYPT_ROUNDS=12 SESSION_TIMEOUT_MS=7200000 @@ -93,9 +100,9 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret # Stripe Price IDs (create these in Stripe Dashboard) -STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly -STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly -STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly -STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly -STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly -STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly +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 diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index cc54c5c..7e7ada3 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -20,13 +20,14 @@ import { GDPRModule } from './application/gdpr/gdpr.module'; import { CsvBookingsModule } from './application/csv-bookings.module'; import { AdminModule } from './application/admin/admin.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; +import { ApiKeysModule } from './application/api-keys/api-keys.module'; import { CacheModule } from './infrastructure/cache/cache.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { SecurityModule } from './infrastructure/security/security.module'; import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; // Import global guards -import { JwtAuthGuard } from './application/guards/jwt-auth.guard'; +import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard'; import { CustomThrottlerGuard } from './application/guards/throttle.guard'; @Module({ @@ -60,21 +61,26 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; // Stripe Configuration (optional for development) STRIPE_SECRET_KEY: Joi.string().optional(), STRIPE_WEBHOOK_SECRET: Joi.string().optional(), - STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(), - STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(), - STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(), - STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(), - STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(), - STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(), + STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(), + STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(), + STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(), + STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), + STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), + STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), }), }), // Logging LoggerModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - pinoHttp: { - transport: - configService.get('NODE_ENV') === 'development' + useFactory: (configService: ConfigService) => { + const isDev = configService.get('NODE_ENV') === 'development'; + // LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail) + const forceJson = configService.get('LOG_FORMAT') === 'json'; + const usePretty = isDev && !forceJson; + + return { + pinoHttp: { + transport: usePretty ? { target: 'pino-pretty', options: { @@ -84,9 +90,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; }, } : undefined, - level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug', - }, - }), + level: isDev ? 'debug' : 'info', + // Redact sensitive fields from logs + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-api-key"]', + 'req.body.password', + 'req.body.currentPassword', + 'req.body.newPassword', + ], + censor: '[REDACTED]', + }, + }, + }; + }, inject: [ConfigService], }), @@ -128,14 +146,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; GDPRModule, AdminModule, SubscriptionsModule, + ApiKeysModule, ], controllers: [], providers: [ - // Global JWT authentication guard + // Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium) // All routes are protected by default, use @Public() to bypass { provide: APP_GUARD, - useClass: JwtAuthGuard, + useClass: ApiKeyOrJwtGuard, }, // Global rate limiting guard { diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts index 4fa96e8..dd92262 100644 --- a/apps/backend/src/application/admin/admin.module.ts +++ b/apps/backend/src/application/admin/admin.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; // Controller import { AdminController } from '../controllers/admin.controller'; @@ -18,6 +19,16 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +// SIRET verification +import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port'; +import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter'; + +// CSV Booking Service +import { CsvBookingsModule } from '../csv-bookings.module'; + +// Email +import { EmailModule } from '@infrastructure/email/email.module'; + /** * Admin Module * @@ -25,7 +36,12 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito * All endpoints require ADMIN role. */ @Module({ - imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])], + imports: [ + TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), + ConfigModule, + CsvBookingsModule, + EmailModule, + ], controllers: [AdminController], providers: [ { @@ -37,6 +53,10 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito useClass: TypeOrmOrganizationRepository, }, TypeOrmCsvBookingRepository, + { + provide: SIRET_VERIFICATION_PORT, + useClass: PappersSiretAdapter, + }, ], }) export class AdminModule {} diff --git a/apps/backend/src/application/api-keys/api-keys.controller.ts b/apps/backend/src/application/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..b2bb476 --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; + +import { CurrentUser } from '../decorators/current-user.decorator'; +import { RequiresFeature } from '../decorators/requires-feature.decorator'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; + +import { ApiKeysService } from './api-keys.service'; +import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto'; + +@ApiTags('API Keys') +@ApiBearerAuth() +@ApiSecurity('x-api-key') +@UseGuards(FeatureFlagGuard) +@RequiresFeature('api_access') +@Controller('api-keys') +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + @ApiOperation({ + summary: 'Générer une nouvelle clé API', + description: + "Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.", + }) + @ApiResponse({ + status: 201, + description: 'Clé créée avec succès. La clé complète est dans le champ `fullKey`.', + type: CreateApiKeyResultDto, + }) + @ApiResponse({ status: 403, description: 'Abonnement Gold ou Platinium requis' }) + async create( + @CurrentUser() user: { id: string; organizationId: string }, + @Body() dto: CreateApiKeyDto + ): Promise { + return this.apiKeysService.generateApiKey(user.id, user.organizationId, dto); + } + + @Get() + @ApiOperation({ + summary: 'Lister les clés API', + description: + "Retourne toutes les clés API de l'organisation. Les clés complètes ne sont jamais exposées — uniquement le préfixe.", + }) + @ApiResponse({ status: 200, type: [ApiKeyDto] }) + async list(@CurrentUser() user: { organizationId: string }): Promise { + return this.apiKeysService.listApiKeys(user.organizationId); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Révoquer une clé API', + description: 'Désactive immédiatement la clé API. Cette action est irréversible.', + }) + @ApiResponse({ status: 204, description: 'Clé révoquée' }) + @ApiResponse({ status: 404, description: 'Clé introuvable' }) + async revoke( + @CurrentUser() user: { organizationId: string }, + @Param('id', ParseUUIDPipe) keyId: string + ): Promise { + return this.apiKeysService.revokeApiKey(keyId, user.organizationId); + } +} diff --git a/apps/backend/src/application/api-keys/api-keys.module.ts b/apps/backend/src/application/api-keys/api-keys.module.ts new file mode 100644 index 0000000..d3a67aa --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys.module.ts @@ -0,0 +1,45 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeysService } from './api-keys.service'; + +// ORM Entities +import { ApiKeyOrmEntity } from '@infrastructure/persistence/typeorm/entities/api-key.orm-entity'; +import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; + +// Repositories +import { TypeOrmApiKeyRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository'; +import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; + +// Repository tokens +import { API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository'; +import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; + +// Subscriptions (provides SUBSCRIPTION_REPOSITORY) +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; + +// Feature flag guard needs SubscriptionRepository (injected via SubscriptionsModule) +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), + SubscriptionsModule, + ], + controllers: [ApiKeysController], + providers: [ + ApiKeysService, + FeatureFlagGuard, + { + provide: API_KEY_REPOSITORY, + useClass: TypeOrmApiKeyRepository, + }, + { + provide: USER_REPOSITORY, + useClass: TypeOrmUserRepository, + }, + ], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/apps/backend/src/application/api-keys/api-keys.service.ts b/apps/backend/src/application/api-keys/api-keys.service.ts new file mode 100644 index 0000000..eeac338 --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys.service.ts @@ -0,0 +1,200 @@ +/** + * ApiKeys Service + * + * Manages API key lifecycle: + * - Generation (GOLD/PLATINIUM subscribers only) + * - Listing (masked — prefix only) + * - Revocation + * - Validation for inbound API key authentication + */ + +import { + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as crypto from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; + +import { ApiKey } from '@domain/entities/api-key.entity'; +import { ApiKeyRepository, API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { + SubscriptionRepository, + SUBSCRIPTION_REPOSITORY, +} from '@domain/ports/out/subscription.repository'; + +import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto'; + +/** Shape of request.user populated when an API key is used. */ +export interface ApiKeyUserContext { + id: string; + email: string; + role: string; + organizationId: string; + firstName: string; + lastName: string; + plan: string; + planFeatures: string[]; +} + +const KEY_PREFIX_DISPLAY_LENGTH = 18; // "xped_live_" (10) + 8 hex chars + +@Injectable() +export class ApiKeysService { + private readonly logger = new Logger(ApiKeysService.name); + + constructor( + @Inject(API_KEY_REPOSITORY) + private readonly apiKeyRepository: ApiKeyRepository, + @Inject(USER_REPOSITORY) + private readonly userRepository: UserRepository, + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepository: SubscriptionRepository + ) {} + + /** + * Generate a new API key for the given user / organisation. + * The full raw key is returned exactly once — it is never persisted. + */ + async generateApiKey( + userId: string, + organizationId: string, + dto: CreateApiKeyDto + ): Promise { + await this.assertApiAccessPlan(organizationId); + + const rawKey = this.buildRawKey(); + const keyHash = this.hashKey(rawKey); + const keyPrefix = rawKey.substring(0, KEY_PREFIX_DISPLAY_LENGTH); + + const apiKey = ApiKey.create({ + id: uuidv4(), + organizationId, + userId, + name: dto.name, + keyHash, + keyPrefix, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + }); + + const saved = await this.apiKeyRepository.save(apiKey); + + this.logger.log(`API key created: ${saved.id} for org ${organizationId}`); + + return { + id: saved.id, + name: saved.name, + keyPrefix: saved.keyPrefix, + isActive: saved.isActive, + lastUsedAt: saved.lastUsedAt, + expiresAt: saved.expiresAt, + createdAt: saved.createdAt, + fullKey: rawKey, + }; + } + + /** + * List all API keys for an organisation. Never exposes key hashes. + */ + async listApiKeys(organizationId: string): Promise { + const keys = await this.apiKeyRepository.findByOrganizationId(organizationId); + return keys.map(k => this.toDto(k)); + } + + /** + * Revoke (deactivate) an API key. + */ + async revokeApiKey(keyId: string, organizationId: string): Promise { + const key = await this.apiKeyRepository.findById(keyId); + + if (!key || key.organizationId !== organizationId) { + throw new NotFoundException('Clé API introuvable'); + } + + const revoked = key.revoke(); + await this.apiKeyRepository.save(revoked); + + this.logger.log(`API key revoked: ${keyId} for org ${organizationId}`); + } + + /** + * Validate an inbound raw API key and return the user context. + * Returns null if the key is invalid, expired, or the plan is insufficient. + * Also asynchronously updates lastUsedAt. + */ + async validateAndGetUser(rawKey: string): Promise { + if (!rawKey?.startsWith('xped_live_')) return null; + + const keyHash = this.hashKey(rawKey); + const apiKey = await this.apiKeyRepository.findByKeyHash(keyHash); + + if (!apiKey || !apiKey.isValid()) return null; + + // Real-time plan check — in case the org downgraded after key creation + const subscription = await this.subscriptionRepository.findByOrganizationId( + apiKey.organizationId + ); + + if (!subscription || !subscription.hasFeature('api_access')) { + this.logger.warn( + `API key used but org ${apiKey.organizationId} no longer has api_access feature` + ); + return null; + } + + // Update lastUsedAt asynchronously — don't block the request + this.apiKeyRepository + .save(apiKey.recordUsage()) + .catch(err => this.logger.warn(`Failed to update lastUsedAt for key ${apiKey.id}: ${err}`)); + + const user = await this.userRepository.findById(apiKey.userId); + if (!user || !user.isActive) return null; + + return { + id: user.id, + email: user.email, + role: user.role, + organizationId: user.organizationId, + firstName: user.firstName, + lastName: user.lastName, + plan: subscription.plan.value, + planFeatures: [...subscription.plan.planFeatures], + }; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private async assertApiAccessPlan(organizationId: string): Promise { + const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); + + if (!subscription || !subscription.hasFeature('api_access')) { + throw new ForbiddenException( + "L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API." + ); + } + } + + /** Format: xped_live_<64 random hex chars> */ + private buildRawKey(): string { + return `xped_live_${crypto.randomBytes(32).toString('hex')}`; + } + + private hashKey(rawKey: string): string { + return crypto.createHash('sha256').update(rawKey).digest('hex'); + } + + private toDto(apiKey: ApiKey): ApiKeyDto { + return { + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + isActive: apiKey.isActive, + lastUsedAt: apiKey.lastUsedAt, + expiresAt: apiKey.expiresAt, + createdAt: apiKey.createdAt, + }; + } +} diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index 98af8bc..fd96f43 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -17,6 +17,7 @@ import { TypeOrmInvitationTokenRepository } from '../../infrastructure/persisten import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity'; +import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity'; import { InvitationService } from '../services/invitation.service'; import { InvitationsController } from '../controllers/invitations.controller'; import { EmailModule } from '../../infrastructure/email/email.module'; @@ -40,7 +41,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; }), // 👇 Add this to register TypeORM repositories - TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity]), + TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]), // Email module for sending invitations EmailModule, diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index cbcc17d..d5c0d18 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -5,10 +5,14 @@ import { Logger, Inject, BadRequestException, + NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; +import * as crypto from 'crypto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, IsNull } from 'typeorm'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { User, UserRole } from '@domain/entities/user.entity'; import { @@ -16,15 +20,19 @@ import { ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization } from '@domain/entities/organization.entity'; +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { v4 as uuidv4 } from 'uuid'; import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { SubscriptionService } from '../services/subscription.service'; +import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity'; export interface JwtPayload { sub: string; // user ID email: string; role: string; organizationId: string; + plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM) + planFeatures?: string[]; // plan feature flags type: 'access' | 'refresh'; } @@ -37,9 +45,13 @@ export class AuthService { private readonly userRepository: UserRepository, @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, + @Inject(EMAIL_PORT) + private readonly emailService: EmailPort, + @InjectRepository(PasswordResetTokenOrmEntity) + private readonly passwordResetTokenRepository: Repository, private readonly jwtService: JwtService, private readonly configService: ConfigService, - private readonly subscriptionService: SubscriptionService, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -203,6 +215,85 @@ export class AuthService { } } + /** + * Initiate password reset — generates token and sends email + */ + async forgotPassword(email: string): Promise { + this.logger.log(`Password reset requested for: ${email}`); + + const user = await this.userRepository.findByEmail(email); + + // Silently succeed if user not found (security: don't reveal user existence) + if (!user || !user.isActive) { + return; + } + + // Invalidate any existing unused tokens for this user + await this.passwordResetTokenRepository.update( + { userId: user.id, usedAt: IsNull() }, + { usedAt: new Date() } + ); + + // Generate a secure random token + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await this.passwordResetTokenRepository.save({ + userId: user.id, + token, + expiresAt, + usedAt: null, + }); + + await this.emailService.sendPasswordResetEmail(email, token); + + this.logger.log(`Password reset email sent to: ${email}`); + } + + /** + * Reset password using token from email + */ + async resetPassword(token: string, newPassword: string): Promise { + const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } }); + + if (!resetToken) { + throw new BadRequestException('Token de réinitialisation invalide ou expiré'); + } + + if (resetToken.usedAt) { + throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé'); + } + + if (resetToken.expiresAt < new Date()) { + throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'); + } + + const user = await this.userRepository.findById(resetToken.userId); + + if (!user || !user.isActive) { + throw new NotFoundException('Utilisateur introuvable'); + } + + const passwordHash = await argon2.hash(newPassword, { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, + }); + + // Update password (mutates in place) + user.updatePassword(passwordHash); + await this.userRepository.save(user); + + // Mark token as used + await this.passwordResetTokenRepository.update( + { id: resetToken.id }, + { usedAt: new Date() } + ); + + this.logger.log(`Password reset successfully for user: ${user.email}`); + } + /** * Validate user from JWT payload */ @@ -220,11 +311,40 @@ export class AuthService { * Generate access and refresh tokens */ private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { + // ADMIN users always get PLATINIUM plan with no expiration + let plan = 'BRONZE'; + let planFeatures: string[] = []; + + if (user.role === UserRole.ADMIN) { + plan = 'PLATINIUM'; + planFeatures = [ + 'dashboard', + 'wiki', + 'user_management', + 'csv_export', + 'api_access', + 'custom_interface', + 'dedicated_kam', + ]; + } else { + try { + const subscription = await this.subscriptionService.getOrCreateSubscription( + user.organizationId + ); + plan = subscription.plan.value; + planFeatures = [...subscription.plan.planFeatures]; + } catch (error) { + this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); + } + } + const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role, organizationId: user.organizationId, + plan, + planFeatures, type: 'access', }; @@ -233,6 +353,8 @@ export class AuthService { email: user.email, role: user.role, organizationId: user.organizationId, + plan, + planFeatures, type: 'refresh', }; @@ -302,6 +424,8 @@ export class AuthService { name: organizationData.name, type: organizationData.type, scac: organizationData.scac, + siren: organizationData.siren, + siret: organizationData.siret, address: { street: organizationData.street, city: organizationData.city, diff --git a/apps/backend/src/application/bookings/bookings.module.ts b/apps/backend/src/application/bookings/bookings.module.ts index bdc06e3..2fbd920 100644 --- a/apps/backend/src/application/bookings/bookings.module.ts +++ b/apps/backend/src/application/bookings/bookings.module.ts @@ -6,15 +6,18 @@ import { BookingsController } from '../controllers/bookings.controller'; import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; +import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository'; // Import ORM entities import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity'; import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; // Import services and domain import { BookingService } from '@domain/services/booking.service'; @@ -29,6 +32,7 @@ import { StorageModule } from '../../infrastructure/storage/storage.module'; import { AuditModule } from '../audit/audit.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; /** * Bookings Module @@ -47,6 +51,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; ContainerOrmEntity, RateQuoteOrmEntity, UserOrmEntity, + CsvBookingOrmEntity, ]), EmailModule, PdfModule, @@ -54,6 +59,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; AuditModule, NotificationsModule, WebhooksModule, + SubscriptionsModule, ], controllers: [BookingsController], providers: [ @@ -73,6 +79,10 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; provide: USER_REPOSITORY, useClass: TypeOrmUserRepository, }, + { + provide: SHIPMENT_COUNTER_PORT, + useClass: TypeOrmShipmentCounterRepository, + }, ], exports: [BOOKING_REPOSITORY], }) diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index 218aa9d..745afff 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, + Post, Patch, Delete, Param, @@ -44,6 +45,16 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org // CSV Booking imports import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; +import { CsvBookingService } from '../services/csv-booking.service'; + +// SIRET verification imports +import { + SiretVerificationPort, + SIRET_VERIFICATION_PORT, +} from '@domain/ports/out/siret-verification.port'; + +// Email imports +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; /** * Admin Controller @@ -65,7 +76,11 @@ export class AdminController { @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, - private readonly csvBookingRepository: TypeOrmCsvBookingRepository + private readonly csvBookingRepository: TypeOrmCsvBookingRepository, + private readonly csvBookingService: CsvBookingService, + @Inject(SIRET_VERIFICATION_PORT) + private readonly siretVerificationPort: SiretVerificationPort, + @Inject(EMAIL_PORT) private readonly emailPort: EmailPort ) {} // ==================== USERS ENDPOINTS ==================== @@ -329,6 +344,163 @@ export class AdminController { return OrganizationMapper.toDto(organization); } + /** + * Verify SIRET number for an organization (admin only) + * + * Calls Pappers API to verify the SIRET, then marks the organization as verified. + */ + @Post('organizations/:id/verify-siret') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Verify organization SIRET (Admin only)', + description: + 'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.', + }) + @ApiParam({ + name: 'id', + description: 'Organization ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'SIRET verification result', + schema: { + type: 'object', + properties: { + verified: { type: 'boolean' }, + companyName: { type: 'string' }, + address: { type: 'string' }, + message: { type: 'string' }, + }, + }, + }) + @ApiNotFoundResponse({ + description: 'Organization not found', + }) + async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + const siret = organization.siret; + if (!siret) { + throw new BadRequestException( + 'Organization has no SIRET number. Please set a SIRET number before verification.' + ); + } + + const result = await this.siretVerificationPort.verify(siret); + + if (!result.valid) { + this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`); + return { + verified: false, + message: `Le numero SIRET ${siret} est invalide ou introuvable.`, + }; + } + + // Mark as verified and save + organization.markSiretVerified(); + await this.organizationRepository.update(organization); + + this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`); + + return { + verified: true, + companyName: result.companyName, + address: result.address, + message: `SIRET ${siret} verifie avec succes.`, + }; + } + + /** + * Manually approve SIRET/SIREN for an organization (admin only) + * + * Marks the organization's SIRET as verified without calling the external API. + */ + @Post('organizations/:id/approve-siret') + @ApiOperation({ + summary: 'Approve SIRET/SIREN (Admin only)', + description: + 'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.', + }) + @ApiParam({ name: 'id', description: 'Organization ID (UUID)' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'SIRET approved successfully', + }) + @ApiNotFoundResponse({ description: 'Organization not found' }) + async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + if (!organization.siret && !organization.siren) { + throw new BadRequestException( + "L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation." + ); + } + + organization.markSiretVerified(); + await this.organizationRepository.update(organization); + + this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`); + + return { + approved: true, + message: 'SIRET/SIREN approuve manuellement avec succes.', + organizationId: id, + organizationName: organization.name, + }; + } + + /** + * Reject SIRET/SIREN for an organization (admin only) + * + * Resets the verification flag to false. + */ + @Post('organizations/:id/reject-siret') + @ApiOperation({ + summary: 'Reject SIRET/SIREN (Admin only)', + description: + 'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.', + }) + @ApiParam({ name: 'id', description: 'Organization ID (UUID)' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'SIRET rejected successfully', + }) + @ApiNotFoundResponse({ description: 'Organization not found' }) + async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Reset SIRET verification to false by updating the SIRET (which resets siretVerified) + // If no SIRET, just update directly + if (organization.siret) { + organization.updateSiret(organization.siret); // This resets siretVerified to false + } + await this.organizationRepository.update(organization); + + this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`); + + return { + rejected: true, + message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.", + organizationId: id, + organizationName: organization.name, + }; + } + // ==================== CSV BOOKINGS ENDPOINTS ==================== /** @@ -440,6 +612,52 @@ export class AdminController { return this.csvBookingToDto(updatedBooking); } + /** + * Resend carrier email for a booking (admin only) + * + * Manually sends the booking request email to the carrier. + * Useful when the automatic email failed (SMTP error) or for testing without Stripe. + */ + @Post('bookings/:id/resend-carrier-email') + @ApiOperation({ + summary: 'Resend carrier email (Admin only)', + description: + 'Manually resend the booking request email to the carrier. Works regardless of payment status.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ status: 200, description: 'Email sent to carrier' }) + @ApiNotFoundResponse({ description: 'Booking not found' }) + async resendCarrierEmail( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ) { + this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`); + await this.csvBookingService.resendCarrierEmail(id); + return { success: true, message: 'Email sent to carrier' }; + } + + /** + * Validate bank transfer for a booking (admin only) + * + * Transitions booking from PENDING_BANK_TRANSFER → PENDING and sends email to carrier + */ + @Post('bookings/:id/validate-transfer') + @ApiOperation({ + summary: 'Validate bank transfer (Admin only)', + description: + 'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' }) + @ApiNotFoundResponse({ description: 'Booking not found' }) + async validateBankTransfer( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ) { + this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`); + return this.csvBookingService.validateBankTransfer(id); + } + /** * Delete csv booking (admin only) */ @@ -483,6 +701,7 @@ export class AdminController { return { id: booking.id, + bookingNumber: booking.bookingNumber || null, userId: booking.userId, organizationId: booking.organizationId, carrierName: booking.carrierName, @@ -510,6 +729,50 @@ export class AdminController { }; } + // ==================== EMAIL TEST ENDPOINT ==================== + + /** + * Send a test email to verify SMTP configuration (admin only) + * + * Returns the exact SMTP error in the response instead of only logging it. + */ + @Post('test-email') + @ApiOperation({ + summary: 'Send test email (Admin only)', + description: + 'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.', + }) + @ApiResponse({ status: 200, description: 'Email sent successfully' }) + @ApiResponse({ status: 400, description: 'SMTP error — check the message field' }) + async sendTestEmail( + @Body() body: { to: string }, + @CurrentUser() user: UserPayload + ) { + if (!body?.to) { + throw new BadRequestException('Field "to" is required'); + } + + this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`); + + try { + await this.emailPort.send({ + to: body.to, + subject: '[Xpeditis] Test SMTP', + html: `

Email de test envoyé depuis le panel admin par ${user.email}.

Si vous lisez ceci, la configuration SMTP fonctionne correctement.

`, + text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`, + }); + + this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`); + return { success: true, message: `Email envoyé avec succès à ${body.to}` }; + } catch (error: any) { + this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack); + throw new BadRequestException( + `Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` + + `Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}` + ); + } + } + // ==================== DOCUMENTS ENDPOINTS ==================== /** @@ -597,4 +860,55 @@ export class AdminController { total: organization.documents.length, }; } + + /** + * Delete a document from a CSV booking (admin only) + * Bypasses ownership and status restrictions + */ + @Delete('bookings/:bookingId/documents/:documentId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Delete document from CSV booking (Admin only)', + description: 'Remove a document from a booking, bypassing ownership and status restrictions.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Document deleted successfully', + }) + async deleteDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Param('documentId', ParseUUIDPipe) documentId: string, + @CurrentUser() user: UserPayload + ): Promise<{ success: boolean; message: string }> { + this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`); + + const booking = await this.csvBookingRepository.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); + if (documentIndex === -1) { + throw new NotFoundException(`Document ${documentId} not found`); + } + + const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); + + const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } }); + if (ormBooking) { + ormBooking.documents = updatedDocuments.map(doc => ({ + id: doc.id, + type: doc.type, + fileName: doc.fileName, + filePath: doc.filePath, + mimeType: doc.mimeType, + size: doc.size, + uploadedAt: doc.uploadedAt, + })); + await this.csvBookingRepository['repository'].save(ormBooking); + } + + this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`); + return { success: true, message: 'Document deleted successfully' }; + } } diff --git a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts index fe1c5c6..f7df27c 100644 --- a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts +++ b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts @@ -489,6 +489,7 @@ export class CsvRatesAdminController { size: fileSize, uploadedAt: config.uploadedAt.toISOString(), rowCount: config.rowCount, + companyEmail: config.metadata?.companyEmail ?? null, }; }); diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index 256490c..d35f172 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -8,10 +8,21 @@ import { Get, Inject, NotFoundException, + InternalServerErrorException, + Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { AuthService } from '../auth/auth.service'; -import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto'; +import { + LoginDto, + RegisterDto, + AuthResponseDto, + RefreshTokenDto, + ForgotPasswordDto, + ResetPasswordDto, + ContactFormDto, +} from '../dto/auth-login.dto'; +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { Public } from '../decorators/public.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -32,10 +43,13 @@ import { InvitationService } from '../services/invitation.service'; @ApiTags('Authentication') @Controller('auth') export class AuthController { + private readonly logger = new Logger(AuthController.name); + constructor( private readonly authService: AuthService, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, - private readonly invitationService: InvitationService + private readonly invitationService: InvitationService, + @Inject(EMAIL_PORT) private readonly emailService: EmailPort ) {} /** @@ -209,6 +223,113 @@ export class AuthController { return { message: 'Logout successful' }; } + /** + * Contact form — forwards message to contact@xpeditis.com + */ + @Public() + @Post('contact') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Contact form', + description: 'Send a contact message to the Xpeditis team.', + }) + @ApiResponse({ status: 200, description: 'Message sent successfully' }) + async contact(@Body() dto: ContactFormDto): Promise<{ message: string }> { + const subjectLabels: Record = { + demo: 'Demande de démonstration', + pricing: 'Questions sur les tarifs', + partnership: 'Partenariat', + support: 'Support technique', + press: 'Relations presse', + careers: 'Recrutement', + other: 'Autre', + }; + + const subjectLabel = subjectLabels[dto.subject] || dto.subject; + + const html = ` +
+
+

Nouveau message de contact

+
+
+ + + + + + + + + + ${dto.company ? `` : ''} + ${dto.phone ? `` : ''} + + + + +
Nom${dto.firstName} ${dto.lastName}
Email${dto.email}
Entreprise${dto.company}
Téléphone${dto.phone}
Sujet${subjectLabel}
+
+

Message :

+

${dto.message}

+
+
+
+

Xpeditis — Formulaire de contact

+
+
+ `; + + try { + await this.emailService.send({ + to: 'contact@xpeditis.com', + replyTo: dto.email, + subject: `[Contact] ${subjectLabel} — ${dto.firstName} ${dto.lastName}`, + html, + }); + } catch (error) { + this.logger.error(`Failed to send contact email: ${error}`); + throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer."); + } + + return { message: 'Message envoyé avec succès.' }; + } + + /** + * Forgot password — sends reset email + */ + @Public() + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Forgot password', + description: 'Send a password reset email. Always returns 200 to avoid user enumeration.', + }) + @ApiResponse({ status: 200, description: 'Reset email sent (if account exists)' }) + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> { + await this.authService.forgotPassword(dto.email); + return { + message: 'Si un compte existe avec cet email, vous recevrez un lien de réinitialisation.', + }; + } + + /** + * Reset password using token from email + */ + @Public() + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Reset password', + description: 'Reset user password using the token received by email.', + }) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> { + await this.authService.resetPassword(dto.token, dto.newPassword); + return { message: 'Mot de passe réinitialisé avec succès.' }; + } + /** * Get current user profile * diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index 386c25d..921aa73 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -53,6 +53,12 @@ import { NotificationService } from '../services/notification.service'; import { NotificationsGateway } from '../gateways/notifications.gateway'; import { WebhookService } from '../services/webhook.service'; import { WebhookEvent } from '@domain/entities/webhook.entity'; +import { + ShipmentCounterPort, + SHIPMENT_COUNTER_PORT, +} from '@domain/ports/out/shipment-counter.port'; +import { SubscriptionService } from '../services/subscription.service'; +import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception'; @ApiTags('Bookings') @Controller('bookings') @@ -70,7 +76,9 @@ export class BookingsController { private readonly auditService: AuditService, private readonly notificationService: NotificationService, private readonly notificationsGateway: NotificationsGateway, - private readonly webhookService: WebhookService + private readonly webhookService: WebhookService, + @Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort, + private readonly subscriptionService: SubscriptionService ) {} @Post() @@ -105,6 +113,22 @@ export class BookingsController { ): Promise { this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); + // Check shipment limit for Bronze plan + const subscription = await this.subscriptionService.getOrCreateSubscription( + user.organizationId + ); + const maxShipments = subscription.plan.maxShipmentsPerYear; + if (maxShipments !== -1) { + const currentYear = new Date().getFullYear(); + const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + user.organizationId, + currentYear + ); + if (count >= maxShipments) { + throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments); + } + } + try { // Convert DTO to domain input, using authenticated user's data const input = { @@ -456,9 +480,16 @@ export class BookingsController { // Filter out bookings or rate quotes that are null const bookingsWithQuotes = bookingsWithQuotesRaw.filter( - (item): item is { booking: NonNullable; rateQuote: NonNullable } => - item.booking !== null && item.booking !== undefined && - item.rateQuote !== null && item.rateQuote !== undefined + ( + item + ): item is { + booking: NonNullable; + rateQuote: NonNullable; + } => + item.booking !== null && + item.booking !== undefined && + item.rateQuote !== null && + item.rateQuote !== undefined ); // Convert to DTOs diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 6ee0ad0..07a19ca 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -12,9 +12,12 @@ import { UploadedFiles, Request, BadRequestException, + ForbiddenException, ParseIntPipe, DefaultValuePipe, + Inject, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiTags, @@ -29,6 +32,16 @@ import { import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; +import { SubscriptionService } from '../services/subscription.service'; +import { + ShipmentCounterPort, + SHIPMENT_COUNTER_PORT, +} from '@domain/ports/out/shipment-counter.port'; +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; +import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception'; import { CreateCsvBookingDto, CsvBookingResponseDto, @@ -48,7 +61,15 @@ import { @ApiTags('CSV Bookings') @Controller('csv-bookings') export class CsvBookingsController { - constructor(private readonly csvBookingService: CsvBookingService) {} + constructor( + private readonly csvBookingService: CsvBookingService, + private readonly subscriptionService: SubscriptionService, + private readonly configService: ConfigService, + @Inject(SHIPMENT_COUNTER_PORT) + private readonly shipmentCounter: ShipmentCounterPort, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository + ) {} // ============================================================================ // STATIC ROUTES (must come FIRST) @@ -60,7 +81,6 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings */ @Post() - @UseGuards(JwtAuthGuard) @ApiBearerAuth() @UseInterceptors(FilesInterceptor('documents', 10)) @ApiConsumes('multipart/form-data') @@ -144,6 +164,23 @@ export class CsvBookingsController { const userId = req.user.id; const organizationId = req.user.organizationId; + // ADMIN users bypass shipment limits + if (req.user.role !== 'ADMIN') { + // Check shipment limit (Bronze plan = 12/year) + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + const maxShipments = subscription.plan.maxShipmentsPerYear; + if (maxShipments !== -1) { + const currentYear = new Date().getFullYear(); + const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + organizationId, + currentYear + ); + if (count >= maxShipments) { + throw new ShipmentLimitExceededException(organizationId, count, maxShipments); + } + } + } + // Convert string values to numbers (multipart/form-data sends everything as strings) const sanitizedDto: CreateCsvBookingDto = { ...dto, @@ -341,6 +378,126 @@ export class CsvBookingsController { }; } + /** + * Create Stripe Checkout session for commission payment + * + * POST /api/v1/csv-bookings/:id/pay + */ + @Post(':id/pay') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Pay commission for a booking', + description: + 'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Stripe checkout session created', + schema: { + type: 'object', + properties: { + sessionUrl: { type: 'string' }, + sessionId: { type: 'string' }, + commissionAmountEur: { type: 'number' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async payCommission(@Param('id') id: string, @Request() req: any) { + const userId = req.user.id; + const userEmail = req.user.email; + const organizationId = req.user.organizationId; + const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + + // ADMIN users bypass SIRET verification + if (req.user.role !== 'ADMIN') { + // SIRET verification gate: organization must have a verified SIRET before paying + const organization = await this.organizationRepository.findById(organizationId); + if (!organization || !organization.siretVerified) { + throw new ForbiddenException( + 'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un paiement. Contactez votre administrateur.' + ); + } + } + + return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl); + } + + /** + * Confirm commission payment after Stripe redirect + * + * POST /api/v1/csv-bookings/:id/confirm-payment + */ + @Post(':id/confirm-payment') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Confirm commission payment', + description: + 'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiBody({ + schema: { + type: 'object', + required: ['sessionId'], + properties: { + sessionId: { type: 'string', description: 'Stripe Checkout session ID' }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Payment confirmed, booking activated', + type: CsvBookingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async confirmPayment( + @Param('id') id: string, + @Body('sessionId') sessionId: string, + @Request() req: any + ): Promise { + if (!sessionId) { + throw new BadRequestException('sessionId is required'); + } + + const userId = req.user.id; + return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId); + } + + /** + * Declare bank transfer — user confirms they have sent the wire transfer + * + * POST /api/v1/csv-bookings/:id/declare-transfer + */ + @Post(':id/declare-transfer') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Declare bank transfer', + description: + 'User confirms they have sent the bank wire transfer. Transitions booking to PENDING_BANK_TRANSFER awaiting admin validation.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Bank transfer declared, booking awaiting admin validation', + type: CsvBookingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async declareTransfer( + @Param('id') id: string, + @Request() req: any + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.declareBankTransfer(id, userId); + } + // ============================================================================ // PARAMETERIZED ROUTES (must come LAST) // ============================================================================ diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index 1c77436..ee37702 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -22,12 +22,7 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser } from '../decorators/current-user.decorator'; import { UserPayload } from '../decorators/current-user.decorator'; import { GDPRService } from '../services/gdpr.service'; -import { - UpdateConsentDto, - ConsentResponseDto, - WithdrawConsentDto, - ConsentSuccessDto, -} from '../dto/consent.dto'; +import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto'; @ApiTags('GDPR') @Controller('gdpr') diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts index 40436d2..e596276 100644 --- a/apps/backend/src/application/controllers/invitations.controller.ts +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -2,6 +2,7 @@ import { Controller, Post, Get, + Delete, Body, UseGuards, HttpCode, @@ -71,7 +72,8 @@ export class InvitationsController { dto.lastName, dto.role as unknown as UserRole, user.organizationId, - user.id + user.id, + user.role ); return { @@ -136,6 +138,29 @@ export class InvitationsController { }; } + /** + * Cancel (delete) a pending invitation + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Cancel invitation', + description: 'Delete a pending invitation. Admin/manager only.', + }) + @ApiResponse({ status: 204, description: 'Invitation cancelled' }) + @ApiResponse({ status: 404, description: 'Invitation not found' }) + @ApiResponse({ status: 400, description: 'Invitation already used' }) + async cancelInvitation( + @Param('id') id: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`); + await this.invitationService.cancelInvitation(id, user.organizationId); + } + /** * List organization invitations */ diff --git a/apps/backend/src/application/controllers/subscriptions.controller.ts b/apps/backend/src/application/controllers/subscriptions.controller.ts index f3e933b..bc806d1 100644 --- a/apps/backend/src/application/controllers/subscriptions.controller.ts +++ b/apps/backend/src/application/controllers/subscriptions.controller.ts @@ -22,6 +22,8 @@ import { Headers, RawBodyRequest, Req, + Inject, + ForbiddenException, } from '@nestjs/common'; import { ApiTags, @@ -47,13 +49,21 @@ import { RolesGuard } from '../guards/roles.guard'; import { Roles } from '../decorators/roles.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Public } from '../decorators/public.decorator'; +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; @ApiTags('Subscriptions') @Controller('subscriptions') export class SubscriptionsController { private readonly logger = new Logger(SubscriptionsController.name); - constructor(private readonly subscriptionService: SubscriptionService) {} + constructor( + private readonly subscriptionService: SubscriptionService, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository + ) {} /** * Get subscription overview for current organization @@ -77,10 +87,10 @@ export class SubscriptionsController { description: 'Forbidden - requires admin or manager role', }) async getSubscriptionOverview( - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Getting subscription overview`); - return this.subscriptionService.getSubscriptionOverview(user.organizationId); + return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role); } /** @@ -126,7 +136,7 @@ export class SubscriptionsController { }) async canInvite(@CurrentUser() user: UserPayload): Promise { this.logger.log(`[User: ${user.email}] Checking license availability`); - return this.subscriptionService.canInviteUser(user.organizationId); + return this.subscriptionService.canInviteUser(user.organizationId, user.role); } /** @@ -139,8 +149,7 @@ export class SubscriptionsController { @ApiBearerAuth() @ApiOperation({ summary: 'Create checkout session', - description: - 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.', + description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.', }) @ApiResponse({ status: 200, @@ -157,14 +166,22 @@ export class SubscriptionsController { }) async createCheckoutSession( @Body() dto: CreateCheckoutSessionDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`); - return this.subscriptionService.createCheckoutSession( - user.organizationId, - user.id, - dto, - ); + + // ADMIN users bypass all payment restrictions + if (user.role !== 'ADMIN') { + // SIRET verification gate: organization must have a verified SIRET before purchasing + const organization = await this.organizationRepository.findById(user.organizationId); + if (!organization || !organization.siretVerified) { + throw new ForbiddenException( + 'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un achat. Contactez votre administrateur.' + ); + } + } + + return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto); } /** @@ -195,7 +212,7 @@ export class SubscriptionsController { }) async createPortalSession( @Body() dto: CreatePortalSessionDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating portal session`); return this.subscriptionService.createPortalSession(user.organizationId, dto); @@ -230,10 +247,10 @@ export class SubscriptionsController { }) async syncFromStripe( @Body() dto: SyncSubscriptionDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log( - `[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`, + `[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}` ); return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId); } @@ -247,7 +264,7 @@ export class SubscriptionsController { @ApiExcludeEndpoint() async handleWebhook( @Headers('stripe-signature') signature: string, - @Req() req: RawBodyRequest, + @Req() req: RawBodyRequest ): Promise<{ received: boolean }> { const rawBody = req.rawBody; if (!rawBody) { diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 99793db..8483b6a 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -44,8 +44,10 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; +import { RequiresFeature } from '../decorators/requires-feature.decorator'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; @@ -64,14 +66,15 @@ import { SubscriptionService } from '../services/subscription.service'; */ @ApiTags('Users') @Controller('users') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) +@RequiresFeature('user_management') @ApiBearerAuth() export class UsersController { private readonly logger = new Logger(UsersController.name); constructor( @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, - private readonly subscriptionService: SubscriptionService, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -284,7 +287,7 @@ export class UsersController { } catch (error) { this.logger.error(`Failed to reallocate license for user ${id}:`, error); throw new ForbiddenException( - 'Cannot reactivate user: no licenses available. Please upgrade your subscription.', + 'Cannot reactivate user: no licenses available. Please upgrade your subscription.' ); } } else { diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts index b9b1ef2..6330924 100644 --- a/apps/backend/src/application/csv-bookings.module.ts +++ b/apps/backend/src/application/csv-bookings.module.ts @@ -1,13 +1,24 @@ 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 @@ -16,13 +27,31 @@ import { StorageModule } from '../infrastructure/storage/storage.module'; */ @Module({ imports: [ - TypeOrmModule.forFeature([CsvBookingOrmEntity]), + TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), + ConfigModule, NotificationsModule, EmailModule, StorageModule, + SubscriptionsModule, + StripeModule, ], controllers: [CsvBookingsController, CsvBookingActionsController], - providers: [CsvBookingService, TypeOrmCsvBookingRepository], + 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.controller.ts b/apps/backend/src/application/dashboard/dashboard.controller.ts index 12d6d2f..77c1dc8 100644 --- a/apps/backend/src/application/dashboard/dashboard.controller.ts +++ b/apps/backend/src/application/dashboard/dashboard.controller.ts @@ -7,9 +7,12 @@ import { Controller, Get, UseGuards, Request } from '@nestjs/common'; import { AnalyticsService } from '../services/analytics.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; +import { RequiresFeature } from '../decorators/requires-feature.decorator'; @Controller('dashboard') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, FeatureFlagGuard) +@RequiresFeature('dashboard') export class DashboardController { constructor(private readonly analyticsService: AnalyticsService) {} diff --git a/apps/backend/src/application/dashboard/dashboard.module.ts b/apps/backend/src/application/dashboard/dashboard.module.ts index 75cfaf8..b483b11 100644 --- a/apps/backend/src/application/dashboard/dashboard.module.ts +++ b/apps/backend/src/application/dashboard/dashboard.module.ts @@ -8,11 +8,13 @@ import { AnalyticsService } from '../services/analytics.service'; import { BookingsModule } from '../bookings/bookings.module'; import { RatesModule } from '../rates/rates.module'; import { CsvBookingsModule } from '../csv-bookings.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; @Module({ - imports: [BookingsModule, RatesModule, CsvBookingsModule], + imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule], controllers: [DashboardController], - providers: [AnalyticsService], + providers: [AnalyticsService, FeatureFlagGuard], exports: [AnalyticsService], }) export class DashboardModule {} diff --git a/apps/backend/src/application/decorators/requires-feature.decorator.ts b/apps/backend/src/application/decorators/requires-feature.decorator.ts new file mode 100644 index 0000000..cdbe677 --- /dev/null +++ b/apps/backend/src/application/decorators/requires-feature.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common'; +import { PlanFeature } from '@domain/value-objects/plan-feature.vo'; + +export const REQUIRED_FEATURES_KEY = 'requiredFeatures'; + +/** + * Decorator to require specific plan features for a route. + * Works with FeatureFlagGuard to enforce access control. + * + * Usage: + * @RequiresFeature('dashboard') + * @RequiresFeature('csv_export', 'api_access') + */ +export const RequiresFeature = (...features: PlanFeature[]) => + SetMetadata(REQUIRED_FEATURES_KEY, features); diff --git a/apps/backend/src/application/dto/api-key.dto.ts b/apps/backend/src/application/dto/api-key.dto.ts new file mode 100644 index 0000000..17b9e17 --- /dev/null +++ b/apps/backend/src/application/dto/api-key.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateApiKeyDto { + @ApiProperty({ + description: 'Nom de la clé API (pour identification)', + example: 'Intégration ERP Production', + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ + description: "Date d'expiration de la clé (ISO 8601). Si absent, la clé n'expire pas.", + example: '2027-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +export class ApiKeyDto { + @ApiProperty({ description: 'Identifiant unique de la clé', example: 'uuid-here' }) + id: string; + + @ApiProperty({ description: 'Nom de la clé', example: 'Intégration ERP Production' }) + name: string; + + @ApiProperty({ + description: 'Préfixe de la clé (pour identification visuelle)', + example: 'xped_live_a1b2c3d4', + }) + keyPrefix: string; + + @ApiProperty({ description: 'La clé est-elle active', example: true }) + isActive: boolean; + + @ApiPropertyOptional({ + description: 'Dernière utilisation de la clé', + example: '2025-03-20T14:30:00.000Z', + }) + lastUsedAt: Date | null; + + @ApiPropertyOptional({ + description: "Date d'expiration", + example: '2027-01-01T00:00:00.000Z', + }) + expiresAt: Date | null; + + @ApiProperty({ description: 'Date de création', example: '2025-03-26T10:00:00.000Z' }) + createdAt: Date; +} + +export class CreateApiKeyResultDto extends ApiKeyDto { + @ApiProperty({ + description: + 'Clé API complète — affichée UNE SEULE FOIS. Conservez-la en lieu sûr, elle ne sera plus visible.', + example: 'xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + }) + fullKey: string; +} diff --git a/apps/backend/src/application/dto/auth-login.dto.ts b/apps/backend/src/application/dto/auth-login.dto.ts index 20aec51..93a292c 100644 --- a/apps/backend/src/application/dto/auth-login.dto.ts +++ b/apps/backend/src/application/dto/auth-login.dto.ts @@ -7,6 +7,7 @@ import { IsEnum, MaxLength, Matches, + IsBoolean, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -22,12 +23,81 @@ export class LoginDto { @ApiProperty({ example: 'SecurePassword123!', - description: 'Password (minimum 12 characters)', + description: 'Password', + }) + @IsString() + password: string; + + @ApiPropertyOptional({ + example: true, + description: 'Remember me for extended session', + }) + @IsBoolean() + @IsOptional() + rememberMe?: boolean; +} + +export class ContactFormDto { + @ApiProperty({ example: 'Jean', description: 'First name' }) + @IsString() + @MinLength(1) + firstName: string; + + @ApiProperty({ example: 'Dupont', description: 'Last name' }) + @IsString() + @MinLength(1) + lastName: string; + + @ApiProperty({ example: 'jean@acme.com', description: 'Sender email' }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiPropertyOptional({ example: 'Acme Logistics', description: 'Company name' }) + @IsString() + @IsOptional() + company?: string; + + @ApiPropertyOptional({ example: '+33 6 12 34 56 78', description: 'Phone number' }) + @IsString() + @IsOptional() + phone?: string; + + @ApiProperty({ example: 'demo', description: 'Subject category' }) + @IsString() + @MinLength(1) + subject: string; + + @ApiProperty({ example: 'Bonjour, je souhaite...', description: 'Message body' }) + @IsString() + @MinLength(10) + message: string; +} + +export class ForgotPasswordDto { + @ApiProperty({ + example: 'john.doe@acme.com', + description: 'Email address for password reset', + }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; +} + +export class ResetPasswordDto { + @ApiProperty({ + example: 'abc123token...', + description: 'Password reset token from email', + }) + @IsString() + token: string; + + @ApiProperty({ + example: 'NewSecurePassword123!', + description: 'New password (minimum 12 characters)', minLength: 12, }) @IsString() @MinLength(12, { message: 'Password must be at least 12 characters' }) - password: string; + newPassword: string; } /** @@ -94,6 +164,31 @@ export class RegisterOrganizationDto { @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' }) country: string; + @ApiProperty({ + example: '123456789', + description: 'French SIREN number (9 digits, required)', + minLength: 9, + maxLength: 9, + }) + @IsString() + @MinLength(9, { message: 'SIREN must be exactly 9 digits' }) + @MaxLength(9, { message: 'SIREN must be exactly 9 digits' }) + @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) + siren: string; + + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits, optional)', + minLength: 14, + maxLength: 14, + }) + @IsString() + @IsOptional() + @MinLength(14, { message: 'SIRET must be exactly 14 digits' }) + @MaxLength(14, { message: 'SIRET must be exactly 14 digits' }) + @Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' }) + siret?: string; + @ApiPropertyOptional({ example: 'MAEU', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', diff --git a/apps/backend/src/application/dto/carrier-documents.dto.ts b/apps/backend/src/application/dto/carrier-documents.dto.ts index 7bdb79a..71f038b 100644 --- a/apps/backend/src/application/dto/carrier-documents.dto.ts +++ b/apps/backend/src/application/dto/carrier-documents.dto.ts @@ -1,112 +1,118 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -/** - * DTO for verifying document access password - */ -export class VerifyDocumentAccessDto { - @ApiProperty({ description: 'Password for document access (booking number code)' }) - @IsString() - @IsNotEmpty() - password: string; -} - -/** - * Response DTO for checking document access requirements - */ -export class DocumentAccessRequirementsDto { - @ApiProperty({ description: 'Whether password is required to access documents' }) - requiresPassword: boolean; - - @ApiPropertyOptional({ description: 'Booking number (if available)' }) - bookingNumber?: string; - - @ApiProperty({ description: 'Current booking status' }) - status: string; -} - -/** - * Booking Summary DTO for Carrier Documents Page - */ -export class BookingSummaryDto { - @ApiProperty({ description: 'Booking unique ID' }) - id: string; - - @ApiPropertyOptional({ description: 'Human-readable booking number' }) - bookingNumber?: string; - - @ApiProperty({ description: 'Carrier/Company name' }) - carrierName: string; - - @ApiProperty({ description: 'Origin port code' }) - origin: string; - - @ApiProperty({ description: 'Destination port code' }) - destination: string; - - @ApiProperty({ description: 'Route description (origin -> destination)' }) - routeDescription: string; - - @ApiProperty({ description: 'Volume in CBM' }) - volumeCBM: number; - - @ApiProperty({ description: 'Weight in KG' }) - weightKG: number; - - @ApiProperty({ description: 'Number of pallets' }) - palletCount: number; - - @ApiProperty({ description: 'Price in the primary currency' }) - price: number; - - @ApiProperty({ description: 'Currency (USD or EUR)' }) - currency: string; - - @ApiProperty({ description: 'Transit time in days' }) - transitDays: number; - - @ApiProperty({ description: 'Container type' }) - containerType: string; - - @ApiProperty({ description: 'When the booking was accepted' }) - acceptedAt: Date; -} - -/** - * Document with signed download URL for carrier access - */ -export class DocumentWithUrlDto { - @ApiProperty({ description: 'Document unique ID' }) - id: string; - - @ApiProperty({ - description: 'Document type', - enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'], - }) - type: string; - - @ApiProperty({ description: 'Original file name' }) - fileName: string; - - @ApiProperty({ description: 'File MIME type' }) - mimeType: string; - - @ApiProperty({ description: 'File size in bytes' }) - size: number; - - @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) - downloadUrl: string; -} - -/** - * Carrier Documents Response DTO - * - * Response for carrier document access page - */ -export class CarrierDocumentsResponseDto { - @ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) - booking: BookingSummaryDto; - - @ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) - documents: DocumentWithUrlDto[]; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * DTO for verifying document access password + */ +export class VerifyDocumentAccessDto { + @ApiProperty({ description: 'Password for document access (booking number code)' }) + @IsString() + @IsNotEmpty() + password: string; +} + +/** + * Response DTO for checking document access requirements + */ +export class DocumentAccessRequirementsDto { + @ApiProperty({ description: 'Whether password is required to access documents' }) + requiresPassword: boolean; + + @ApiPropertyOptional({ description: 'Booking number (if available)' }) + bookingNumber?: string; + + @ApiProperty({ description: 'Current booking status' }) + status: string; +} + +/** + * Booking Summary DTO for Carrier Documents Page + */ +export class BookingSummaryDto { + @ApiProperty({ description: 'Booking unique ID' }) + id: string; + + @ApiPropertyOptional({ description: 'Human-readable booking number' }) + bookingNumber?: string; + + @ApiProperty({ description: 'Carrier/Company name' }) + carrierName: string; + + @ApiProperty({ description: 'Origin port code' }) + origin: string; + + @ApiProperty({ description: 'Destination port code' }) + destination: string; + + @ApiProperty({ description: 'Route description (origin -> destination)' }) + routeDescription: string; + + @ApiProperty({ description: 'Volume in CBM' }) + volumeCBM: number; + + @ApiProperty({ description: 'Weight in KG' }) + weightKG: number; + + @ApiProperty({ description: 'Number of pallets' }) + palletCount: number; + + @ApiProperty({ description: 'Price in the primary currency' }) + price: number; + + @ApiProperty({ description: 'Currency (USD or EUR)' }) + currency: string; + + @ApiProperty({ description: 'Transit time in days' }) + transitDays: number; + + @ApiProperty({ description: 'Container type' }) + containerType: string; + + @ApiProperty({ description: 'When the booking was accepted' }) + acceptedAt: Date; +} + +/** + * Document with signed download URL for carrier access + */ +export class DocumentWithUrlDto { + @ApiProperty({ description: 'Document unique ID' }) + id: string; + + @ApiProperty({ + description: 'Document type', + enum: [ + 'BILL_OF_LADING', + 'PACKING_LIST', + 'COMMERCIAL_INVOICE', + 'CERTIFICATE_OF_ORIGIN', + 'OTHER', + ], + }) + type: string; + + @ApiProperty({ description: 'Original file name' }) + fileName: string; + + @ApiProperty({ description: 'File MIME type' }) + mimeType: string; + + @ApiProperty({ description: 'File size in bytes' }) + size: number; + + @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) + downloadUrl: string; +} + +/** + * Carrier Documents Response DTO + * + * Response for carrier document access page + */ +export class CarrierDocumentsResponseDto { + @ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) + booking: BookingSummaryDto; + + @ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) + documents: DocumentWithUrlDto[]; +} diff --git a/apps/backend/src/application/dto/consent.dto.ts b/apps/backend/src/application/dto/consent.dto.ts index fa3b883..741e720 100644 --- a/apps/backend/src/application/dto/consent.dto.ts +++ b/apps/backend/src/application/dto/consent.dto.ts @@ -1,139 +1,139 @@ -/** - * Cookie Consent DTOs - * GDPR compliant consent management - */ - -import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -/** - * Request DTO for recording/updating cookie consent - */ -export class UpdateConsentDto { - @ApiProperty({ - example: true, - description: 'Essential cookies consent (always true, required for functionality)', - default: true, - }) - @IsBoolean() - essential: boolean; - - @ApiProperty({ - example: false, - description: 'Functional cookies consent (preferences, language, etc.)', - default: false, - }) - @IsBoolean() - functional: boolean; - - @ApiProperty({ - example: false, - description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', - default: false, - }) - @IsBoolean() - analytics: boolean; - - @ApiProperty({ - example: false, - description: 'Marketing cookies consent (ads, tracking, remarketing)', - default: false, - }) - @IsBoolean() - marketing: boolean; - - @ApiPropertyOptional({ - example: '192.168.1.1', - description: 'IP address at time of consent (for GDPR audit trail)', - }) - @IsOptional() - @IsString() - ipAddress?: string; - - @ApiPropertyOptional({ - example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - description: 'User agent at time of consent', - }) - @IsOptional() - @IsString() - userAgent?: string; -} - -/** - * Response DTO for consent status - */ -export class ConsentResponseDto { - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'User ID', - }) - userId: string; - - @ApiProperty({ - example: true, - description: 'Essential cookies consent (always true)', - }) - essential: boolean; - - @ApiProperty({ - example: false, - description: 'Functional cookies consent', - }) - functional: boolean; - - @ApiProperty({ - example: false, - description: 'Analytics cookies consent', - }) - analytics: boolean; - - @ApiProperty({ - example: false, - description: 'Marketing cookies consent', - }) - marketing: boolean; - - @ApiProperty({ - example: '2025-01-27T10:30:00.000Z', - description: 'Date when consent was recorded', - }) - consentDate: Date; - - @ApiProperty({ - example: '2025-01-27T10:30:00.000Z', - description: 'Last update timestamp', - }) - updatedAt: Date; -} - -/** - * Request DTO for withdrawing specific consent - */ -export class WithdrawConsentDto { - @ApiProperty({ - example: 'marketing', - description: 'Type of consent to withdraw', - enum: ['functional', 'analytics', 'marketing'], - }) - @IsEnum(['functional', 'analytics', 'marketing'], { - message: 'Consent type must be functional, analytics, or marketing', - }) - consentType: 'functional' | 'analytics' | 'marketing'; -} - -/** - * Success response DTO - */ -export class ConsentSuccessDto { - @ApiProperty({ - example: true, - description: 'Operation success status', - }) - success: boolean; - - @ApiProperty({ - example: 'Consent preferences saved successfully', - description: 'Response message', - }) - message: string; -} +/** + * Cookie Consent DTOs + * GDPR compliant consent management + */ + +import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Request DTO for recording/updating cookie consent + */ +export class UpdateConsentDto { + @ApiProperty({ + example: true, + description: 'Essential cookies consent (always true, required for functionality)', + default: true, + }) + @IsBoolean() + essential: boolean; + + @ApiProperty({ + example: false, + description: 'Functional cookies consent (preferences, language, etc.)', + default: false, + }) + @IsBoolean() + functional: boolean; + + @ApiProperty({ + example: false, + description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', + default: false, + }) + @IsBoolean() + analytics: boolean; + + @ApiProperty({ + example: false, + description: 'Marketing cookies consent (ads, tracking, remarketing)', + default: false, + }) + @IsBoolean() + marketing: boolean; + + @ApiPropertyOptional({ + example: '192.168.1.1', + description: 'IP address at time of consent (for GDPR audit trail)', + }) + @IsOptional() + @IsString() + ipAddress?: string; + + @ApiPropertyOptional({ + example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + description: 'User agent at time of consent', + }) + @IsOptional() + @IsString() + userAgent?: string; +} + +/** + * Response DTO for consent status + */ +export class ConsentResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'User ID', + }) + userId: string; + + @ApiProperty({ + example: true, + description: 'Essential cookies consent (always true)', + }) + essential: boolean; + + @ApiProperty({ + example: false, + description: 'Functional cookies consent', + }) + functional: boolean; + + @ApiProperty({ + example: false, + description: 'Analytics cookies consent', + }) + analytics: boolean; + + @ApiProperty({ + example: false, + description: 'Marketing cookies consent', + }) + marketing: boolean; + + @ApiProperty({ + example: '2025-01-27T10:30:00.000Z', + description: 'Date when consent was recorded', + }) + consentDate: Date; + + @ApiProperty({ + example: '2025-01-27T10:30:00.000Z', + description: 'Last update timestamp', + }) + updatedAt: Date; +} + +/** + * Request DTO for withdrawing specific consent + */ +export class WithdrawConsentDto { + @ApiProperty({ + example: 'marketing', + description: 'Type of consent to withdraw', + enum: ['functional', 'analytics', 'marketing'], + }) + @IsEnum(['functional', 'analytics', 'marketing'], { + message: 'Consent type must be functional, analytics, or marketing', + }) + consentType: 'functional' | 'analytics' | 'marketing'; +} + +/** + * Success response DTO + */ +export class ConsentSuccessDto { + @ApiProperty({ + example: true, + description: 'Operation success status', + }) + success: boolean; + + @ApiProperty({ + example: 'Consent preferences saved successfully', + description: 'Response message', + }) + message: string; +} diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index d2425f3..d32f5f8 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -294,8 +294,8 @@ export class CsvBookingResponseDto { @ApiProperty({ description: 'Booking status', - enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], - example: 'PENDING', + enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + example: 'PENDING_PAYMENT', }) status: string; @@ -353,6 +353,18 @@ export class CsvBookingResponseDto { example: 1850.5, }) price: number; + + @ApiPropertyOptional({ + description: 'Commission rate in percent', + example: 5, + }) + commissionRate?: number; + + @ApiPropertyOptional({ + description: 'Commission amount in EUR', + example: 313.27, + }) + commissionAmountEur?: number; } /** @@ -414,6 +426,12 @@ export class CsvBookingListResponseDto { * Statistics for user's or organization's bookings */ export class CsvBookingStatsDto { + @ApiProperty({ + description: 'Number of bookings awaiting payment', + example: 1, + }) + pendingPayment: number; + @ApiProperty({ description: 'Number of pending bookings', example: 5, diff --git a/apps/backend/src/application/dto/organization.dto.ts b/apps/backend/src/application/dto/organization.dto.ts index 881322c..130a53b 100644 --- a/apps/backend/src/application/dto/organization.dto.ts +++ b/apps/backend/src/application/dto/organization.dto.ts @@ -184,6 +184,19 @@ export class UpdateOrganizationDto { @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) siren?: string; + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits)', + minLength: 14, + maxLength: 14, + }) + @IsString() + @IsOptional() + @MinLength(14) + @MaxLength(14) + @Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' }) + siret?: string; + @ApiPropertyOptional({ example: 'FR123456789', description: 'EU EORI number', @@ -344,6 +357,25 @@ export class OrganizationResponseDto { }) documents: OrganizationDocumentDto[]; + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits)', + }) + siret?: string; + + @ApiProperty({ + example: false, + description: 'Whether the SIRET has been verified by an admin', + }) + siretVerified: boolean; + + @ApiPropertyOptional({ + example: 'none', + description: 'Organization status badge', + enum: ['none', 'silver', 'gold', 'platinium'], + }) + statusBadge?: string; + @ApiProperty({ example: true, description: 'Active status', diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts index a8e7f75..5302528 100644 --- a/apps/backend/src/application/dto/subscription.dto.ts +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -5,25 +5,16 @@ */ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsString, - IsEnum, - IsNotEmpty, - IsUrl, - IsOptional, - IsBoolean, - IsInt, - Min, -} from 'class-validator'; +import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator'; /** * Subscription plan types */ export enum SubscriptionPlanDto { - FREE = 'FREE', - STARTER = 'STARTER', - PRO = 'PRO', - ENTERPRISE = 'ENTERPRISE', + BRONZE = 'BRONZE', + SILVER = 'SILVER', + GOLD = 'GOLD', + PLATINIUM = 'PLATINIUM', } /** @@ -53,7 +44,7 @@ export enum BillingIntervalDto { */ export class CreateCheckoutSessionDto { @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'The subscription plan to purchase', enum: SubscriptionPlanDto, }) @@ -197,14 +188,14 @@ export class LicenseResponseDto { */ export class PlanDetailsDto { @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'Plan identifier', enum: SubscriptionPlanDto, }) plan: SubscriptionPlanDto; @ApiProperty({ - example: 'Starter', + example: 'Silver', description: 'Plan display name', }) name: string; @@ -216,20 +207,51 @@ export class PlanDetailsDto { maxLicenses: number; @ApiProperty({ - example: 49, + example: 249, description: 'Monthly price in EUR', }) monthlyPriceEur: number; @ApiProperty({ - example: 470, - description: 'Yearly price in EUR', + example: 2739, + description: 'Yearly price in EUR (11 months)', }) yearlyPriceEur: number; @ApiProperty({ - example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'], - description: 'List of features included in this plan', + example: -1, + description: 'Maximum shipments per year (-1 for unlimited)', + }) + maxShipmentsPerYear: number; + + @ApiProperty({ + example: 3, + description: 'Commission rate percentage on shipments', + }) + commissionRatePercent: number; + + @ApiProperty({ + example: 'email', + description: 'Support level: none, email, direct, dedicated_kam', + }) + supportLevel: string; + + @ApiProperty({ + example: 'silver', + description: 'Status badge: none, silver, gold, platinium', + }) + statusBadge: string; + + @ApiProperty({ + example: ['dashboard', 'wiki', 'user_management', 'csv_export'], + description: 'List of plan feature flags', + type: [String], + }) + planFeatures: string[]; + + @ApiProperty({ + example: ["Jusqu'à 5 utilisateurs", 'Expéditions illimitées', 'Import CSV'], + description: 'List of human-readable features included in this plan', type: [String], }) features: string[]; @@ -252,7 +274,7 @@ export class SubscriptionResponseDto { organizationId: string; @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'Current subscription plan', enum: SubscriptionPlanDto, }) diff --git a/apps/backend/src/application/guards/api-key-or-jwt.guard.ts b/apps/backend/src/application/guards/api-key-or-jwt.guard.ts new file mode 100644 index 0000000..e910831 --- /dev/null +++ b/apps/backend/src/application/guards/api-key-or-jwt.guard.ts @@ -0,0 +1,55 @@ +import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { ApiKeysService } from '../api-keys/api-keys.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +/** + * Combined Authentication Guard + * + * Replaces the global JwtAuthGuard to support two authentication methods: + * + * 1. **API Key** (`X-API-Key` header) + * - Validates the raw key against its stored SHA-256 hash + * - Checks the organisation subscription is GOLD or PLATINIUM in real-time + * - Sets request.user with full user/plan context + * - Available exclusively to Gold and Platinium subscribers + * + * 2. **JWT Bearer token** (`Authorization: Bearer `) + * - Delegates to the existing Passport JWT strategy (unchanged behaviour) + * - Works for all subscription tiers (frontend access) + * + * Routes decorated with @Public() bypass both methods. + * + * Priority: API Key is checked first; if absent, falls back to JWT. + */ +@Injectable() +export class ApiKeyOrJwtGuard extends JwtAuthGuard { + constructor( + reflector: Reflector, + private readonly apiKeysService: ApiKeysService + ) { + super(reflector); + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest>(); + const rawApiKey: string | undefined = request.headers['x-api-key']; + + if (rawApiKey) { + const userContext = await this.apiKeysService.validateAndGetUser(rawApiKey); + + if (!userContext) { + throw new UnauthorizedException( + "Clé API invalide, expirée ou votre abonnement ne permet plus l'accès API." + ); + } + + request.user = userContext; + return true; + } + + // No API key header — use standard JWT flow (handles @Public() too) + return super.canActivate(context) as Promise; + } +} diff --git a/apps/backend/src/application/guards/feature-flag.guard.ts b/apps/backend/src/application/guards/feature-flag.guard.ts new file mode 100644 index 0000000..d769ac4 --- /dev/null +++ b/apps/backend/src/application/guards/feature-flag.guard.ts @@ -0,0 +1,108 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PlanFeature } from '@domain/value-objects/plan-feature.vo'; +import { + SubscriptionRepository, + SUBSCRIPTION_REPOSITORY, +} from '@domain/ports/out/subscription.repository'; +import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator'; + +/** + * Feature Flag Guard + * + * Checks if the user's subscription plan includes the required features. + * First tries to read plan from JWT payload (fast path), falls back to DB lookup. + * + * Usage: + * @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) + * @RequiresFeature('dashboard') + */ +@Injectable() +export class FeatureFlagGuard implements CanActivate { + private readonly logger = new Logger(FeatureFlagGuard.name); + + constructor( + private readonly reflector: Reflector, + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepository: SubscriptionRepository + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get required features from @RequiresFeature() decorator + const requiredFeatures = this.reflector.getAllAndOverride( + REQUIRED_FEATURES_KEY, + [context.getHandler(), context.getClass()] + ); + + // If no features are required, allow access + if (!requiredFeatures || requiredFeatures.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.organizationId) { + return false; + } + + // ADMIN users have full access to all features — no plan check needed + if (user.role === 'ADMIN') { + return true; + } + + // Fast path: check plan features from JWT payload + if (user.planFeatures && Array.isArray(user.planFeatures)) { + const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature)); + + if (hasAllFeatures) { + return true; + } + + // JWT says no — but JWT might be stale after an upgrade. + // Fall through to DB check. + } + + // Slow path: DB lookup for fresh subscription data + try { + const subscription = await this.subscriptionRepository.findByOrganizationId( + user.organizationId + ); + + if (!subscription) { + // No subscription means Bronze (free) plan — no premium features + this.throwFeatureRequired(requiredFeatures); + } + + const plan = subscription!.plan; + const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature)); + + if (missingFeatures.length > 0) { + this.throwFeatureRequired(requiredFeatures); + } + + return true; + } catch (error) { + if (error instanceof ForbiddenException) { + throw error; + } + this.logger.error(`Failed to check subscription features: ${error}`); + // On DB error, deny access to premium features rather than 500 + this.throwFeatureRequired(requiredFeatures); + } + } + + private throwFeatureRequired(features: PlanFeature[]): never { + const featureNames = features.join(', '); + throw new ForbiddenException( + `Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.` + ); + } +} diff --git a/apps/backend/src/application/guards/index.ts b/apps/backend/src/application/guards/index.ts index e174be2..374d66f 100644 --- a/apps/backend/src/application/guards/index.ts +++ b/apps/backend/src/application/guards/index.ts @@ -1,2 +1,3 @@ export * from './jwt-auth.guard'; export * from './roles.guard'; +export * from './api-key-or-jwt.guard'; diff --git a/apps/backend/src/application/mappers/organization.mapper.ts b/apps/backend/src/application/mappers/organization.mapper.ts index 8405e33..8280ab1 100644 --- a/apps/backend/src/application/mappers/organization.mapper.ts +++ b/apps/backend/src/application/mappers/organization.mapper.ts @@ -31,6 +31,9 @@ export class OrganizationMapper { address: this.mapAddressToDto(organization.address), logoUrl: organization.logoUrl, documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), + siret: organization.siret, + siretVerified: organization.siretVerified, + statusBadge: organization.statusBadge, isActive: organization.isActive, createdAt: organization.createdAt, updatedAt: organization.updatedAt, diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 7ddf3da..5588347 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -16,7 +16,9 @@ import { NOTIFICATION_REPOSITORY, } from '@domain/ports/out/notification.repository'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; +import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { Notification, NotificationType, @@ -30,6 +32,7 @@ import { CsvBookingStatsDto, } from '../dto/csv-booking.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; +import { SubscriptionService } from './subscription.service'; /** * CSV Booking Document (simple class for domain) @@ -62,7 +65,12 @@ export class CsvBookingService { @Inject(EMAIL_PORT) private readonly emailAdapter: EmailPort, @Inject(STORAGE_PORT) - private readonly storageAdapter: StoragePort + private readonly storageAdapter: StoragePort, + @Inject(STRIPE_PORT) + private readonly stripeAdapter: StripePort, + private readonly subscriptionService: SubscriptionService, + @Inject(USER_REPOSITORY) + private readonly userRepository: UserRepository ) {} /** @@ -114,7 +122,18 @@ export class CsvBookingService { // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); - // Create domain entity + // Calculate commission based on organization's subscription plan + let commissionRate = 5; // default Bronze + let commissionAmountEur = 0; + try { + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + commissionRate = subscription.plan.commissionRatePercent; + } catch (error: any) { + this.logger.error(`Failed to get subscription for commission: ${error?.message}`); + } + commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100; + + // Create domain entity in PENDING_PAYMENT status (no email sent yet) const booking = new CsvBooking( bookingId, userId, @@ -131,12 +150,16 @@ export class CsvBookingService { dto.primaryCurrency, dto.transitDays, dto.containerType, - CsvBookingStatus.PENDING, + CsvBookingStatus.PENDING_PAYMENT, documents, confirmationToken, new Date(), undefined, - dto.notes + dto.notes, + undefined, + bookingNumber, + commissionRate, + commissionAmountEur ); // Save to database @@ -152,58 +175,398 @@ export class CsvBookingService { await this.csvBookingRepository['repository'].save(ormBooking); } - this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`); + this.logger.log( + `CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€` + ); - // Send email to carrier and WAIT for confirmation - // The button waits for the email to be sent before responding + // NO email sent to carrier yet - will be sent after commission payment + // NO notification yet - will be created after payment confirmation + + return this.toResponseDto(savedBooking); + } + + /** + * Create a Stripe Checkout session for commission payment + */ + async createCommissionPayment( + bookingId: string, + userId: string, + userEmail: string, + frontendUrl: string + ): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new BadRequestException( + `Booking is not awaiting payment. Current status: ${booking.status}` + ); + } + + const commissionAmountEur = booking.commissionAmountEur || 0; + if (commissionAmountEur <= 0) { + throw new BadRequestException('Commission amount is invalid'); + } + + const amountCents = Math.round(commissionAmountEur * 100); + + const result = await this.stripeAdapter.createCommissionCheckout({ + bookingId: booking.id, + amountCents, + currency: 'eur', + customerEmail: userEmail, + organizationId: booking.organizationId, + bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`, + successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`, + }); + + this.logger.log( + `Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR` + ); + + return { + sessionUrl: result.sessionUrl, + sessionId: result.sessionId, + commissionAmountEur, + }; + } + + /** + * Confirm commission payment and activate booking + * Called after Stripe redirect with session_id + */ + async confirmCommissionPayment( + bookingId: string, + sessionId: string, + userId: string + ): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + // Already confirmed - return current state + if (booking.status === CsvBookingStatus.PENDING) { + return this.toResponseDto(booking); + } + throw new BadRequestException( + `Booking is not awaiting payment. Current status: ${booking.status}` + ); + } + + // Verify payment with Stripe + const session = await this.stripeAdapter.getCheckoutSession(sessionId); + if (!session || session.status !== 'complete') { + throw new BadRequestException('Payment has not been completed'); + } + + // Verify the session is for this booking + if (session.metadata?.bookingId !== bookingId) { + throw new BadRequestException('Payment session does not match this booking'); + } + + // Transition to PENDING + booking.markPaymentCompleted(); + booking.stripePaymentIntentId = sessionId; + + // Save updated booking + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`); + + // Get ORM entity for booking number + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + + // NOW send email to carrier try { - await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { - bookingId, - bookingNumber, - documentPassword, - origin: dto.origin, - destination: dto.destination, - volumeCBM: dto.volumeCBM, - weightKG: dto.weightKG, - palletCount: dto.palletCount, - priceUSD: dto.priceUSD, - priceEUR: dto.priceEUR, - primaryCurrency: dto.primaryCurrency, - transitDays: dto.transitDays, - containerType: dto.containerType, - documents: documents.map(doc => ({ + await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { + bookingId: booking.id, + bookingNumber: bookingNumber || '', + documentPassword: documentPassword || '', + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + documents: booking.documents.map(doc => ({ type: doc.type, fileName: doc.fileName, })), - confirmationToken, - notes: dto.notes, + confirmationToken: booking.confirmationToken, + notes: booking.notes, }); - this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); + this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); - // Continue even if email fails - booking is already saved } // Create notification for user try { const notification = Notification.create({ id: uuidv4(), - userId, - organizationId, + userId: booking.userId, + organizationId: booking.organizationId, type: NotificationType.CSV_BOOKING_REQUEST_SENT, priority: NotificationPriority.MEDIUM, title: 'Booking Request Sent', - message: `Your booking request to ${dto.carrierName} for ${dto.origin} → ${dto.destination} has been sent successfully.`, - metadata: { bookingId, carrierName: dto.carrierName }, + message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`, + metadata: { bookingId: booking.id, carrierName: booking.carrierName }, }); await this.notificationRepository.save(notification); - this.logger.log(`Notification created for user ${userId}`); } catch (error: any) { this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); - // Continue even if notification fails } - return this.toResponseDto(savedBooking); + return this.toResponseDto(updatedBooking); + } + + /** + * Declare bank transfer — user confirms they have sent the wire transfer + * Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER + * Sends an email notification to all ADMIN users + */ + async declareBankTransfer(bookingId: string, userId: string): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new BadRequestException( + `Booking is not awaiting payment. Current status: ${booking.status}` + ); + } + + // Get booking number before update + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase(); + + booking.markBankTransferDeclared(); + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`); + + // Send email to all ADMIN users + try { + const allUsers = await this.userRepository.findAll(); + const adminEmails = allUsers + .filter(u => u.role === 'ADMIN' && u.isActive) + .map(u => u.email); + + if (adminEmails.length > 0) { + const commissionAmount = booking.commissionAmountEur + ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur) + : 'N/A'; + + await this.emailAdapter.send({ + to: adminEmails, + subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`, + html: ` +
+

Nouveau virement à valider

+

Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :

+ + + + + + + + + + + + + + + + + +
Numéro de booking${bookingNumber}
Transporteur${booking.carrierName}
Trajet${booking.getRouteDescription()}
Montant commission${commissionAmount}
+

Rendez-vous dans la console d'administration pour valider ce virement et activer le booking.

+ + Voir les bookings en attente + +
+ `, + }); + this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); + } + } catch (error: any) { + this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack); + } + + // In-app notification for the user + try { + const notification = Notification.create({ + id: uuidv4(), + userId: booking.userId, + organizationId: booking.organizationId, + type: NotificationType.BOOKING_UPDATED, + priority: NotificationPriority.MEDIUM, + title: 'Virement déclaré', + message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`, + metadata: { bookingId: booking.id }, + }); + await this.notificationRepository.save(notification); + } catch (error: any) { + this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); + } + + return this.toResponseDto(updatedBooking); + } + + /** + * Resend carrier email for a booking (admin action) + * Works regardless of payment status — useful for retrying failed emails or testing without Stripe. + */ + async resendCarrierEmail(bookingId: string): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + + await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { + bookingId: booking.id, + bookingNumber: bookingNumber || '', + documentPassword: documentPassword || '', + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + documents: booking.documents.map(doc => ({ + type: doc.type, + fileName: doc.fileName, + })), + confirmationToken: booking.confirmationToken, + notes: booking.notes, + }); + + this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`); + } + + /** + * Admin validates bank transfer — confirms receipt and activates booking + * Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier + */ + async validateBankTransfer(bookingId: string): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) { + throw new BadRequestException( + `Booking is not awaiting bank transfer validation. Current status: ${booking.status}` + ); + } + + booking.markBankTransferValidated(); + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`); + + // Get booking number for email + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + + // Send email to carrier + try { + await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { + bookingId: booking.id, + bookingNumber: bookingNumber || '', + documentPassword: documentPassword || '', + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + documents: booking.documents.map(doc => ({ + type: doc.type, + fileName: doc.fileName, + })), + confirmationToken: booking.confirmationToken, + notes: booking.notes, + }); + this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`); + } catch (error: any) { + this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); + } + + // In-app notification for the user + try { + const notification = Notification.create({ + id: uuidv4(), + userId: booking.userId, + organizationId: booking.organizationId, + type: NotificationType.BOOKING_CONFIRMED, + priority: NotificationPriority.HIGH, + title: 'Virement validé — Booking activé', + message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`, + metadata: { bookingId: booking.id }, + }); + await this.notificationRepository.save(notification); + } catch (error: any) { + this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); + } + + return this.toResponseDto(updatedBooking); } /** @@ -394,6 +757,21 @@ export class CsvBookingService { // Accept the booking (domain logic validates status) booking.accept(); + // Apply commission based on organization's subscription plan + try { + const subscription = await this.subscriptionService.getOrCreateSubscription( + booking.organizationId + ); + const commissionRate = subscription.plan.commissionRatePercent; + const baseAmountEur = booking.priceEUR; + booking.applyCommission(commissionRate, baseAmountEur); + this.logger.log( + `Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€` + ); + } catch (error: any) { + this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack); + } + // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} accepted`); @@ -568,6 +946,7 @@ export class CsvBookingService { const stats = await this.csvBookingRepository.countByStatusForUser(userId); return { + pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, @@ -583,6 +962,7 @@ export class CsvBookingService { const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId); return { + pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, @@ -678,9 +1058,15 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Allow adding documents to PENDING or ACCEPTED bookings - if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) { - throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled'); + // Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings + if ( + booking.status !== CsvBookingStatus.PENDING_PAYMENT && + booking.status !== CsvBookingStatus.PENDING && + booking.status !== CsvBookingStatus.ACCEPTED + ) { + throw new BadRequestException( + 'Cannot add documents to a booking that is rejected or cancelled' + ); } // Upload new documents @@ -723,7 +1109,10 @@ export class CsvBookingService { }); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { - this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack); + this.logger.error( + `Failed to send new documents notification: ${error?.message}`, + error?.stack + ); } } @@ -755,8 +1144,11 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Verify booking is still pending - if (booking.status !== CsvBookingStatus.PENDING) { + // Verify booking is still pending or awaiting payment + if ( + booking.status !== CsvBookingStatus.PENDING_PAYMENT && + booking.status !== CsvBookingStatus.PENDING + ) { throw new BadRequestException('Cannot delete documents from a booking that is not pending'); } @@ -871,7 +1263,9 @@ export class CsvBookingService { await this.csvBookingRepository['repository'].save(ormBooking); } - this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`); + this.logger.log( + `Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}` + ); return { success: true, @@ -947,6 +1341,8 @@ export class CsvBookingService { routeDescription: booking.getRouteDescription(), isExpired: booking.isExpired(), price: booking.getPriceInCurrency(primaryCurrency), + commissionRate: booking.commissionRate, + commissionAmountEur: booking.commissionAmountEur, }; } diff --git a/apps/backend/src/application/services/gdpr.service.ts b/apps/backend/src/application/services/gdpr.service.ts index b2d8541..d7784d2 100644 --- a/apps/backend/src/application/services/gdpr.service.ts +++ b/apps/backend/src/application/services/gdpr.service.ts @@ -120,10 +120,7 @@ export class GDPRService { /** * Record or update consent (GDPR Article 7 - Conditions for consent) */ - async recordConsent( - userId: string, - consentData: UpdateConsentDto - ): Promise { + async recordConsent(userId: string, consentData: UpdateConsentDto): Promise { this.logger.log(`Recording consent for user ${userId}`); // Verify user exists diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index 1dc6d6e..06ff751 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -38,7 +38,7 @@ export class InvitationService { @Inject(EMAIL_PORT) private readonly emailService: EmailPort, private readonly configService: ConfigService, - private readonly subscriptionService: SubscriptionService, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -50,7 +50,8 @@ export class InvitationService { lastName: string, role: UserRole, organizationId: string, - invitedById: string + invitedById: string, + inviterRole?: string ): Promise { this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`); @@ -69,14 +70,14 @@ export class InvitationService { } // Check if licenses are available for this organization - const canInviteResult = await this.subscriptionService.canInviteUser(organizationId); + const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole); if (!canInviteResult.canInvite) { this.logger.warn( - `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`, + `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}` ); throw new ForbiddenException( canInviteResult.message || - `License limit reached. Please upgrade your subscription to invite more users.`, + `License limit reached. Please upgrade your subscription to invite more users.` ); } @@ -219,6 +220,25 @@ export class InvitationService { } } + /** + * Cancel (delete) a pending invitation + */ + async cancelInvitation(invitationId: string, organizationId: string): Promise { + const invitations = await this.invitationRepository.findByOrganization(organizationId); + const invitation = invitations.find(inv => inv.id === invitationId); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + if (invitation.isUsed) { + throw new BadRequestException('Cannot delete an invitation that has already been used'); + } + + await this.invitationRepository.deleteById(invitationId); + this.logger.log(`Invitation ${invitationId} cancelled`); + } + /** * Cleanup expired invitations (can be called by a cron job) */ diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 90951de..255c0e3 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -4,24 +4,14 @@ * Business logic for subscription and license management. */ -import { - Injectable, - Inject, - Logger, - NotFoundException, - BadRequestException, - ForbiddenException, -} from '@nestjs/common'; +import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; import { SubscriptionRepository, SUBSCRIPTION_REPOSITORY, } from '@domain/ports/out/subscription.repository'; -import { - LicenseRepository, - LICENSE_REPOSITORY, -} from '@domain/ports/out/license.repository'; +import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository'; import { OrganizationRepository, ORGANIZATION_REPOSITORY, @@ -30,14 +20,10 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { Subscription } from '@domain/entities/subscription.entity'; import { License } from '@domain/entities/license.entity'; -import { - SubscriptionPlan, - SubscriptionPlanType, -} from '@domain/value-objects/subscription-plan.vo'; +import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo'; import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo'; import { NoLicensesAvailableException, - SubscriptionNotFoundException, LicenseAlreadyAssignedException, } from '@domain/exceptions/subscription.exceptions'; import { @@ -69,50 +55,54 @@ export class SubscriptionService { private readonly userRepository: UserRepository, @Inject(STRIPE_PORT) private readonly stripeAdapter: StripePort, - private readonly configService: ConfigService, + private readonly configService: ConfigService ) {} /** * Get subscription overview for an organization + * ADMIN users always see a PLATINIUM plan with no expiration */ async getSubscriptionOverview( organizationId: string, + userRole?: string ): Promise { const subscription = await this.getOrCreateSubscription(organizationId); - const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId( - subscription.id, - ); + const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id); // Enrich licenses with user information const enrichedLicenses = await Promise.all( - activeLicenses.map(async (license) => { + activeLicenses.map(async license => { const user = await this.userRepository.findById(license.userId); return this.mapLicenseToDto(license, user); - }), + }) ); // Count only non-ADMIN licenses for quota calculation // ADMIN users have unlimited licenses and don't count against the quota const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); - const maxLicenses = subscription.maxLicenses; - const availableLicenses = subscription.isUnlimited() + + // ADMIN users always have PLATINIUM plan with no expiration + const isAdmin = userRole === 'ADMIN'; + const effectivePlan = isAdmin ? SubscriptionPlan.platinium() : subscription.plan; + const maxLicenses = effectivePlan.maxLicenses; + const availableLicenses = effectivePlan.isUnlimited() ? -1 : Math.max(0, maxLicenses - usedLicenses); return { id: subscription.id, organizationId: subscription.organizationId, - plan: subscription.plan.value as SubscriptionPlanDto, - planDetails: this.mapPlanToDto(subscription.plan), + plan: effectivePlan.value as SubscriptionPlanDto, + planDetails: this.mapPlanToDto(effectivePlan), status: subscription.status.value as SubscriptionStatusDto, usedLicenses, maxLicenses, availableLicenses, - cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, - currentPeriodStart: subscription.currentPeriodStart || undefined, - currentPeriodEnd: subscription.currentPeriodEnd || undefined, + cancelAtPeriodEnd: false, + currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined, + currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined, createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, licenses: enrichedLicenses, @@ -123,27 +113,35 @@ export class SubscriptionService { * Get all available plans */ getAllPlans(): AllPlansResponseDto { - const plans = SubscriptionPlan.getAllPlans().map((plan) => - this.mapPlanToDto(plan), - ); + const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan)); return { plans }; } /** * Check if organization can invite more users - * Note: ADMIN users don't count against the license quota + * Note: ADMIN users don't count against the license quota and always have unlimited licenses */ - async canInviteUser(organizationId: string): Promise { + async canInviteUser(organizationId: string, userRole?: string): Promise { + // ADMIN users always have unlimited invitations + if (userRole === 'ADMIN') { + return { + canInvite: true, + availableLicenses: -1, + usedLicenses: 0, + maxLicenses: -1, + message: undefined, + }; + } + const subscription = await this.getOrCreateSubscription(organizationId); // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const maxLicenses = subscription.maxLicenses; const canInvite = - subscription.isActive() && - (subscription.isUnlimited() || usedLicenses < maxLicenses); + subscription.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses); const availableLicenses = subscription.isUnlimited() ? -1 @@ -171,7 +169,7 @@ export class SubscriptionService { async createCheckoutSession( organizationId: string, userId: string, - dto: CreateCheckoutSessionDto, + dto: CreateCheckoutSessionDto ): Promise { const organization = await this.organizationRepository.findById(organizationId); if (!organization) { @@ -184,23 +182,19 @@ export class SubscriptionService { } // Cannot checkout for FREE plan - if (dto.plan === SubscriptionPlanDto.FREE) { - throw new BadRequestException('Cannot create checkout session for FREE plan'); + if (dto.plan === SubscriptionPlanDto.BRONZE) { + throw new BadRequestException('Cannot create checkout session for Bronze plan'); } const subscription = await this.getOrCreateSubscription(organizationId); - const frontendUrl = this.configService.get( - 'FRONTEND_URL', - 'http://localhost:3000', - ); + const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); // Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID const successUrl = dto.successUrl || `${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = - dto.cancelUrl || - `${frontendUrl}/dashboard/settings/organization?canceled=true`; + dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`; const result = await this.stripeAdapter.createCheckoutSession({ organizationId, @@ -214,7 +208,7 @@ export class SubscriptionService { }); this.logger.log( - `Created checkout session for organization ${organizationId}, plan ${dto.plan}`, + `Created checkout session for organization ${organizationId}, plan ${dto.plan}` ); return { @@ -228,24 +222,18 @@ export class SubscriptionService { */ async createPortalSession( organizationId: string, - dto: CreatePortalSessionDto, + dto: CreatePortalSessionDto ): Promise { - const subscription = await this.subscriptionRepository.findByOrganizationId( - organizationId, - ); + const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription?.stripeCustomerId) { throw new BadRequestException( - 'No Stripe customer found for this organization. Please complete a checkout first.', + 'No Stripe customer found for this organization. Please complete a checkout first.' ); } - const frontendUrl = this.configService.get( - 'FRONTEND_URL', - 'http://localhost:3000', - ); - const returnUrl = - dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`; + const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); + const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`; const result = await this.stripeAdapter.createPortalSession({ customerId: subscription.stripeCustomerId, @@ -267,11 +255,9 @@ export class SubscriptionService { */ async syncFromStripe( organizationId: string, - sessionId?: string, + sessionId?: string ): Promise { - let subscription = await this.subscriptionRepository.findByOrganizationId( - organizationId, - ); + let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription) { subscription = await this.getOrCreateSubscription(organizationId); @@ -283,12 +269,14 @@ export class SubscriptionService { // If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details // This is important for upgrades where Stripe may create a new subscription if (sessionId) { - this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`); + this.logger.log( + `Retrieving checkout session ${sessionId} for organization ${organizationId}` + ); const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); if (checkoutSession) { this.logger.log( - `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`, + `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}` ); // Always use the subscription ID from the checkout session if available @@ -330,7 +318,7 @@ export class SubscriptionService { if (plan) { // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const newPlan = SubscriptionPlan.create(plan); @@ -354,13 +342,13 @@ export class SubscriptionService { // Update status updatedSubscription = updatedSubscription.updateStatus( - SubscriptionStatus.fromStripeStatus(stripeData.status), + SubscriptionStatus.fromStripeStatus(stripeData.status) ); await this.subscriptionRepository.save(updatedSubscription); this.logger.log( - `Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`, + `Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})` ); return this.getSubscriptionOverview(organizationId); @@ -418,14 +406,14 @@ export class SubscriptionService { if (!isAdmin) { // Count only non-ADMIN licenses for quota check const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); if (!subscription.canAllocateLicenses(usedLicenses)) { throw new NoLicensesAvailableException( organizationId, usedLicenses, - subscription.maxLicenses, + subscription.maxLicenses ); } } @@ -474,22 +462,18 @@ export class SubscriptionService { * Get or create a subscription for an organization */ async getOrCreateSubscription(organizationId: string): Promise { - let subscription = await this.subscriptionRepository.findByOrganizationId( - organizationId, - ); + let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription) { // Create FREE subscription for the organization subscription = Subscription.create({ id: uuidv4(), organizationId, - plan: SubscriptionPlan.free(), + plan: SubscriptionPlan.bronze(), }); subscription = await this.subscriptionRepository.save(subscription); - this.logger.log( - `Created FREE subscription for organization ${organizationId}`, - ); + this.logger.log(`Created Bronze subscription for organization ${organizationId}`); } return subscription; @@ -497,9 +481,7 @@ export class SubscriptionService { // Private helper methods - private async handleCheckoutCompleted( - session: Record, - ): Promise { + private async handleCheckoutCompleted(session: Record): Promise { const metadata = session.metadata as Record | undefined; const organizationId = metadata?.organizationId; const customerId = session.customer as string; @@ -537,27 +519,26 @@ export class SubscriptionService { }); subscription = subscription.updatePlan( SubscriptionPlan.create(plan), - await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) ); subscription = subscription.updateStatus( - SubscriptionStatus.fromStripeStatus(stripeSubscription.status), + SubscriptionStatus.fromStripeStatus(stripeSubscription.status) ); await this.subscriptionRepository.save(subscription); - this.logger.log( - `Updated subscription for organization ${organizationId} to plan ${plan}`, - ); + // Update organization status badge to match the plan + await this.updateOrganizationBadge(organizationId, subscription.statusBadge); + + this.logger.log(`Updated subscription for organization ${organizationId} to plan ${plan}`); } private async handleSubscriptionUpdated( - stripeSubscription: Record, + stripeSubscription: Record ): Promise { const subscriptionId = stripeSubscription.id as string; - let subscription = await this.subscriptionRepository.findByStripeSubscriptionId( - subscriptionId, - ); + let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId); if (!subscription) { this.logger.warn(`Subscription ${subscriptionId} not found in database`); @@ -576,7 +557,7 @@ export class SubscriptionService { if (plan) { // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const newPlan = SubscriptionPlan.create(plan); @@ -584,9 +565,7 @@ export class SubscriptionService { if (newPlan.canAccommodateUsers(usedLicenses)) { subscription = subscription.updatePlan(newPlan, usedLicenses); } else { - this.logger.warn( - `Cannot update to plan ${plan} - would exceed license limit`, - ); + this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`); } } @@ -597,22 +576,26 @@ export class SubscriptionService { cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd, }); subscription = subscription.updateStatus( - SubscriptionStatus.fromStripeStatus(stripeData.status), + SubscriptionStatus.fromStripeStatus(stripeData.status) ); await this.subscriptionRepository.save(subscription); + // Update organization status badge to match the plan + if (subscription.organizationId) { + await this.updateOrganizationBadge(subscription.organizationId, subscription.statusBadge); + } + this.logger.log(`Updated subscription ${subscriptionId}`); } private async handleSubscriptionDeleted( - stripeSubscription: Record, + stripeSubscription: Record ): Promise { const subscriptionId = stripeSubscription.id as string; - const subscription = await this.subscriptionRepository.findByStripeSubscriptionId( - subscriptionId, - ); + const subscription = + await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId); if (!subscription) { this.logger.warn(`Subscription ${subscriptionId} not found in database`); @@ -622,42 +605,41 @@ export class SubscriptionService { // Downgrade to FREE plan - count only non-ADMIN licenses const canceledSubscription = subscription .updatePlan( - SubscriptionPlan.free(), - await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), + SubscriptionPlan.bronze(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) ) .updateStatus(SubscriptionStatus.canceled()); await this.subscriptionRepository.save(canceledSubscription); - this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`); + // Reset organization badge to 'none' on cancellation + if (subscription.organizationId) { + await this.updateOrganizationBadge(subscription.organizationId, 'none'); + } + + this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`); } private async handlePaymentFailed(invoice: Record): Promise { const customerId = invoice.customer as string; - const subscription = await this.subscriptionRepository.findByStripeCustomerId( - customerId, - ); + const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId); if (!subscription) { this.logger.warn(`Subscription for customer ${customerId} not found`); return; } - const updatedSubscription = subscription.updateStatus( - SubscriptionStatus.pastDue(), - ); + const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue()); await this.subscriptionRepository.save(updatedSubscription); - this.logger.log( - `Subscription ${subscription.id} marked as past due due to payment failure`, - ); + this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`); } private mapLicenseToDto( license: License, - user: { email: string; firstName: string; lastName: string; role: string } | null, + user: { email: string; firstName: string; lastName: string; role: string } | null ): LicenseResponseDto { return { id: license.id, @@ -671,6 +653,19 @@ export class SubscriptionService { }; } + private async updateOrganizationBadge(organizationId: string, badge: string): Promise { + try { + const organization = await this.organizationRepository.findById(organizationId); + if (organization) { + organization.updateStatusBadge(badge as 'none' | 'silver' | 'gold' | 'platinium'); + await this.organizationRepository.save(organization); + this.logger.log(`Updated status badge for organization ${organizationId} to ${badge}`); + } + } catch (error: any) { + this.logger.error(`Failed to update organization badge: ${error?.message}`, error?.stack); + } + } + private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto { return { plan: plan.value as SubscriptionPlanDto, @@ -678,6 +673,11 @@ export class SubscriptionService { maxLicenses: plan.maxLicenses, monthlyPriceEur: plan.monthlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur, + maxShipmentsPerYear: plan.maxShipmentsPerYear, + commissionRatePercent: plan.commissionRatePercent, + supportLevel: plan.supportLevel, + statusBadge: plan.statusBadge, + planFeatures: [...plan.planFeatures], features: [...plan.features], }; } diff --git a/apps/backend/src/application/users/users.module.ts b/apps/backend/src/application/users/users.module.ts index a5f714c..1603268 100644 --- a/apps/backend/src/application/users/users.module.ts +++ b/apps/backend/src/application/users/users.module.ts @@ -7,14 +7,13 @@ import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; @Module({ - imports: [ - TypeOrmModule.forFeature([UserOrmEntity]), - SubscriptionsModule, - ], + imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule], controllers: [UsersController], providers: [ + FeatureFlagGuard, { provide: USER_REPOSITORY, useClass: TypeOrmUserRepository, diff --git a/apps/backend/src/domain/entities/api-key.entity.ts b/apps/backend/src/domain/entities/api-key.entity.ts new file mode 100644 index 0000000..f0a48fa --- /dev/null +++ b/apps/backend/src/domain/entities/api-key.entity.ts @@ -0,0 +1,135 @@ +/** + * ApiKey Entity + * + * Represents a programmatic API key for an organization. + * Only GOLD and PLATINIUM subscribers can create and use API keys. + * + * Security model: + * - The raw key is NEVER persisted — only its SHA-256 hash is stored. + * - The full key is returned exactly once, at creation time. + * - The keyPrefix (first 16 chars) is stored for display purposes. + */ + +export interface ApiKeyProps { + readonly id: string; + readonly organizationId: string; + readonly userId: string; + readonly name: string; + readonly keyHash: string; + readonly keyPrefix: string; + readonly isActive: boolean; + readonly lastUsedAt: Date | null; + readonly expiresAt: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export class ApiKey { + private readonly props: ApiKeyProps; + + private constructor(props: ApiKeyProps) { + this.props = props; + } + + static create(params: { + id: string; + organizationId: string; + userId: string; + name: string; + keyHash: string; + keyPrefix: string; + expiresAt?: Date | null; + }): ApiKey { + const now = new Date(); + return new ApiKey({ + id: params.id, + organizationId: params.organizationId, + userId: params.userId, + name: params.name, + keyHash: params.keyHash, + keyPrefix: params.keyPrefix, + isActive: true, + lastUsedAt: null, + expiresAt: params.expiresAt ?? null, + createdAt: now, + updatedAt: now, + }); + } + + static fromPersistence(props: ApiKeyProps): ApiKey { + return new ApiKey(props); + } + + get id(): string { + return this.props.id; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get userId(): string { + return this.props.userId; + } + + get name(): string { + return this.props.name; + } + + get keyHash(): string { + return this.props.keyHash; + } + + get keyPrefix(): string { + return this.props.keyPrefix; + } + + get isActive(): boolean { + return this.props.isActive; + } + + get lastUsedAt(): Date | null { + return this.props.lastUsedAt; + } + + get expiresAt(): Date | null { + return this.props.expiresAt; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + isExpired(): boolean { + if (!this.props.expiresAt) return false; + return this.props.expiresAt < new Date(); + } + + isValid(): boolean { + return this.props.isActive && !this.isExpired(); + } + + revoke(): ApiKey { + return new ApiKey({ + ...this.props, + isActive: false, + updatedAt: new Date(), + }); + } + + recordUsage(): ApiKey { + return new ApiKey({ + ...this.props, + lastUsedAt: new Date(), + updatedAt: new Date(), + }); + } + + toObject(): ApiKeyProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/domain/entities/booking.entity.ts b/apps/backend/src/domain/entities/booking.entity.ts index ac496c2..3512c7a 100644 --- a/apps/backend/src/domain/entities/booking.entity.ts +++ b/apps/backend/src/domain/entities/booking.entity.ts @@ -50,6 +50,8 @@ export interface BookingProps { cargoDescription: string; containers: BookingContainer[]; specialInstructions?: string; + commissionRate?: number; + commissionAmountEur?: number; createdAt: Date; updatedAt: Date; } @@ -161,6 +163,14 @@ export class Booking { return this.props.specialInstructions; } + get commissionRate(): number | undefined { + return this.props.commissionRate; + } + + get commissionAmountEur(): number | undefined { + return this.props.commissionAmountEur; + } + get createdAt(): Date { return this.props.createdAt; } @@ -270,6 +280,19 @@ export class Booking { }); } + /** + * Apply commission to the booking + */ + applyCommission(ratePercent: number, baseAmountEur: number): Booking { + const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100; + return new Booking({ + ...this.props, + commissionRate: ratePercent, + commissionAmountEur: commissionAmount, + updatedAt: new Date(), + }); + } + /** * Check if booking can be cancelled */ diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index 1361e0d..ca0b7de 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -6,6 +6,8 @@ import { PortCode } from '../value-objects/port-code.vo'; * Represents the lifecycle of a CSV-based booking request */ export enum CsvBookingStatus { + PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment + PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation PENDING = 'PENDING', // Awaiting carrier response ACCEPTED = 'ACCEPTED', // Carrier accepted the booking REJECTED = 'REJECTED', // Carrier rejected the booking @@ -80,7 +82,10 @@ export class CsvBooking { public respondedAt?: Date, public notes?: string, public rejectionReason?: string, - public readonly bookingNumber?: string + public readonly bookingNumber?: string, + public commissionRate?: number, + public commissionAmountEur?: number, + public stripePaymentIntentId?: string ) { this.validate(); } @@ -144,6 +149,61 @@ export class CsvBooking { } } + /** + * Apply commission to the booking + */ + applyCommission(ratePercent: number, baseAmountEur: number): void { + this.commissionRate = ratePercent; + this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100; + } + + /** + * Mark commission payment as completed → transition to PENDING + * + * @throws Error if booking is not in PENDING_PAYMENT status + */ + markPaymentCompleted(): void { + if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new Error( + `Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.` + ); + } + + this.status = CsvBookingStatus.PENDING; + } + + /** + * Declare bank transfer → transition to PENDING_BANK_TRANSFER + * Called when user confirms they have sent the bank transfer + * + * @throws Error if booking is not in PENDING_PAYMENT status + */ + markBankTransferDeclared(): void { + if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new Error( + `Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.` + ); + } + + this.status = CsvBookingStatus.PENDING_BANK_TRANSFER; + } + + /** + * Admin validates bank transfer → transition to PENDING + * Called by admin once bank transfer has been received and verified + * + * @throws Error if booking is not in PENDING_BANK_TRANSFER status + */ + markBankTransferValidated(): void { + if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) { + throw new Error( + `Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.` + ); + } + + this.status = CsvBookingStatus.PENDING; + } + /** * Accept the booking * @@ -202,6 +262,10 @@ export class CsvBooking { throw new Error('Cannot cancel rejected booking'); } + if (this.status === CsvBookingStatus.CANCELLED) { + throw new Error('Booking is already cancelled'); + } + this.status = CsvBookingStatus.CANCELLED; this.respondedAt = new Date(); } @@ -211,6 +275,10 @@ export class CsvBooking { * * @returns true if booking is older than 7 days and still pending */ + isPendingPayment(): boolean { + return this.status === CsvBookingStatus.PENDING_PAYMENT; + } + isExpired(): boolean { if (this.status !== CsvBookingStatus.PENDING) { return false; @@ -363,7 +431,10 @@ export class CsvBooking { respondedAt?: Date, notes?: string, rejectionReason?: string, - bookingNumber?: string + bookingNumber?: string, + commissionRate?: number, + commissionAmountEur?: number, + stripePaymentIntentId?: string ): CsvBooking { // Create instance without calling constructor validation const booking = Object.create(CsvBooking.prototype); @@ -392,6 +463,9 @@ export class CsvBooking { booking.notes = notes; booking.rejectionReason = rejectionReason; booking.bookingNumber = bookingNumber; + booking.commissionRate = commissionRate; + booking.commissionAmountEur = commissionAmountEur; + booking.stripePaymentIntentId = stripePaymentIntentId; return booking; } diff --git a/apps/backend/src/domain/entities/license.entity.ts b/apps/backend/src/domain/entities/license.entity.ts index 75da6b7..e61186b 100644 --- a/apps/backend/src/domain/entities/license.entity.ts +++ b/apps/backend/src/domain/entities/license.entity.ts @@ -5,10 +5,7 @@ * Each active user in an organization consumes one license. */ -import { - LicenseStatus, - LicenseStatusType, -} from '../value-objects/license-status.vo'; +import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo'; export interface LicenseProps { readonly id: string; @@ -29,11 +26,7 @@ export class License { /** * Create a new license for a user */ - static create(props: { - id: string; - subscriptionId: string; - userId: string; - }): License { + static create(props: { id: string; subscriptionId: string; userId: string }): License { return new License({ id: props.id, subscriptionId: props.subscriptionId, diff --git a/apps/backend/src/domain/entities/organization.entity.ts b/apps/backend/src/domain/entities/organization.entity.ts index 32baac5..4cfa76c 100644 --- a/apps/backend/src/domain/entities/organization.entity.ts +++ b/apps/backend/src/domain/entities/organization.entity.ts @@ -44,6 +44,9 @@ export interface OrganizationProps { address: OrganizationAddress; logoUrl?: string; documents: OrganizationDocument[]; + siret?: string; + siretVerified: boolean; + statusBadge: 'none' | 'silver' | 'gold' | 'platinium'; createdAt: Date; updatedAt: Date; isActive: boolean; @@ -59,9 +62,19 @@ export class Organization { /** * Factory method to create a new Organization */ - static create(props: Omit): Organization { + static create( + props: Omit & { + siretVerified?: boolean; + statusBadge?: 'none' | 'silver' | 'gold' | 'platinium'; + } + ): Organization { const now = new Date(); + // Validate SIRET if provided + if (props.siret && !Organization.isValidSiret(props.siret)) { + throw new Error('Invalid SIRET format. Must be 14 digits.'); + } + // Validate SCAC code if provided if (props.scac && !Organization.isValidSCAC(props.scac)) { throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); @@ -79,6 +92,8 @@ export class Organization { return new Organization({ ...props, + siretVerified: props.siretVerified ?? false, + statusBadge: props.statusBadge ?? 'none', createdAt: now, updatedAt: now, }); @@ -100,6 +115,10 @@ export class Organization { return scacPattern.test(scac); } + private static isValidSiret(siret: string): boolean { + return /^\d{14}$/.test(siret); + } + // Getters get id(): string { return this.props.id; @@ -153,6 +172,18 @@ export class Organization { return this.props.updatedAt; } + get siret(): string | undefined { + return this.props.siret; + } + + get siretVerified(): boolean { + return this.props.siretVerified; + } + + get statusBadge(): 'none' | 'silver' | 'gold' | 'platinium' { + return this.props.statusBadge; + } + get isActive(): boolean { return this.props.isActive; } @@ -183,6 +214,25 @@ export class Organization { this.props.updatedAt = new Date(); } + updateSiret(siret: string): void { + if (!Organization.isValidSiret(siret)) { + throw new Error('Invalid SIRET format. Must be 14 digits.'); + } + this.props.siret = siret; + this.props.siretVerified = false; + this.props.updatedAt = new Date(); + } + + markSiretVerified(): void { + this.props.siretVerified = true; + this.props.updatedAt = new Date(); + } + + updateStatusBadge(badge: 'none' | 'silver' | 'gold' | 'platinium'): void { + this.props.statusBadge = badge; + this.props.updatedAt = new Date(); + } + updateSiren(siren: string): void { this.props.siren = siren; this.props.updatedAt = new Date(); diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts index 9b554de..4e93f08 100644 --- a/apps/backend/src/domain/entities/subscription.entity.spec.ts +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -272,7 +272,7 @@ describe('Subscription Entity', () => { }); expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( - SubscriptionNotActiveException, + SubscriptionNotActiveException ); }); @@ -284,7 +284,7 @@ describe('Subscription Entity', () => { }); expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( - InvalidSubscriptionDowngradeException, + InvalidSubscriptionDowngradeException ); }); }); diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts index 572af04..3cde08c 100644 --- a/apps/backend/src/domain/entities/subscription.entity.ts +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -5,10 +5,7 @@ * Stripe integration, and billing period information. */ -import { - SubscriptionPlan, - SubscriptionPlanType, -} from '../value-objects/subscription-plan.vo'; +import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo'; import { SubscriptionStatus, SubscriptionStatusType, @@ -40,7 +37,7 @@ export class Subscription { } /** - * Create a new subscription (defaults to FREE plan) + * Create a new subscription (defaults to Bronze/free plan) */ static create(props: { id: string; @@ -53,7 +50,7 @@ export class Subscription { return new Subscription({ id: props.id, organizationId: props.organizationId, - plan: props.plan ?? SubscriptionPlan.free(), + plan: props.plan ?? SubscriptionPlan.bronze(), status: SubscriptionStatus.active(), stripeCustomerId: props.stripeCustomerId ?? null, stripeSubscriptionId: props.stripeSubscriptionId ?? null, @@ -68,10 +65,41 @@ export class Subscription { /** * Reconstitute from persistence */ + /** + * Check if a specific plan feature is available + */ + hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean { + return this.props.plan.hasFeature(feature); + } + + /** + * Get the maximum shipments per year allowed + */ + get maxShipmentsPerYear(): number { + return this.props.plan.maxShipmentsPerYear; + } + + /** + * Get the commission rate for this subscription's plan + */ + get commissionRatePercent(): number { + return this.props.plan.commissionRatePercent; + } + + /** + * Get the status badge for this subscription's plan + */ + get statusBadge(): string { + return this.props.plan.statusBadge; + } + + /** + * Reconstitute from persistence (supports legacy plan names) + */ static fromPersistence(props: { id: string; organizationId: string; - plan: SubscriptionPlanType; + plan: string; // Accepts both old and new plan names status: SubscriptionStatusType; stripeCustomerId: string | null; stripeSubscriptionId: string | null; @@ -84,7 +112,7 @@ export class Subscription { return new Subscription({ id: props.id, organizationId: props.organizationId, - plan: SubscriptionPlan.create(props.plan), + plan: SubscriptionPlan.fromString(props.plan), status: SubscriptionStatus.create(props.status), stripeCustomerId: props.stripeCustomerId, stripeSubscriptionId: props.stripeSubscriptionId, @@ -236,7 +264,7 @@ export class Subscription { this.props.plan.value, newPlan.value, currentUserCount, - newPlan.maxLicenses, + newPlan.maxLicenses ); } diff --git a/apps/backend/src/domain/exceptions/shipment-limit-exceeded.exception.ts b/apps/backend/src/domain/exceptions/shipment-limit-exceeded.exception.ts new file mode 100644 index 0000000..ee75eec --- /dev/null +++ b/apps/backend/src/domain/exceptions/shipment-limit-exceeded.exception.ts @@ -0,0 +1,17 @@ +/** + * Shipment Limit Exceeded Exception + * + * Thrown when an organization has reached its annual shipment limit (Bronze plan). + */ +export class ShipmentLimitExceededException extends Error { + constructor( + public readonly organizationId: string, + public readonly currentCount: number, + public readonly maxCount: number + ) { + super( + `L'organisation a atteint sa limite de ${maxCount} expéditions par an (${currentCount}/${maxCount}). Passez à un plan supérieur pour des expéditions illimitées.` + ); + this.name = 'ShipmentLimitExceededException'; + } +} diff --git a/apps/backend/src/domain/exceptions/subscription.exceptions.ts b/apps/backend/src/domain/exceptions/subscription.exceptions.ts index 55cdcbd..815aa78 100644 --- a/apps/backend/src/domain/exceptions/subscription.exceptions.ts +++ b/apps/backend/src/domain/exceptions/subscription.exceptions.ts @@ -6,11 +6,11 @@ export class NoLicensesAvailableException extends Error { constructor( public readonly organizationId: string, public readonly currentLicenses: number, - public readonly maxLicenses: number, + public readonly maxLicenses: number ) { super( `No licenses available for organization ${organizationId}. ` + - `Currently using ${currentLicenses}/${maxLicenses} licenses.`, + `Currently using ${currentLicenses}/${maxLicenses} licenses.` ); this.name = 'NoLicensesAvailableException'; Object.setPrototypeOf(this, NoLicensesAvailableException.prototype); @@ -46,11 +46,11 @@ export class InvalidSubscriptionDowngradeException extends Error { public readonly currentPlan: string, public readonly targetPlan: string, public readonly currentUsers: number, - public readonly targetMaxLicenses: number, + public readonly targetMaxLicenses: number ) { super( `Cannot downgrade from ${currentPlan} to ${targetPlan}. ` + - `Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`, + `Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).` ); this.name = 'InvalidSubscriptionDowngradeException'; Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype); @@ -60,11 +60,9 @@ export class InvalidSubscriptionDowngradeException extends Error { export class SubscriptionNotActiveException extends Error { constructor( public readonly subscriptionId: string, - public readonly currentStatus: string, + public readonly currentStatus: string ) { - super( - `Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`, - ); + super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`); this.name = 'SubscriptionNotActiveException'; Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype); } @@ -73,13 +71,10 @@ export class SubscriptionNotActiveException extends Error { export class InvalidSubscriptionStatusTransitionException extends Error { constructor( public readonly fromStatus: string, - public readonly toStatus: string, + public readonly toStatus: string ) { super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`); this.name = 'InvalidSubscriptionStatusTransitionException'; - Object.setPrototypeOf( - this, - InvalidSubscriptionStatusTransitionException.prototype, - ); + Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype); } } diff --git a/apps/backend/src/domain/ports/out/api-key.repository.ts b/apps/backend/src/domain/ports/out/api-key.repository.ts new file mode 100644 index 0000000..ceece42 --- /dev/null +++ b/apps/backend/src/domain/ports/out/api-key.repository.ts @@ -0,0 +1,11 @@ +import { ApiKey } from '@domain/entities/api-key.entity'; + +export const API_KEY_REPOSITORY = 'API_KEY_REPOSITORY'; + +export interface ApiKeyRepository { + save(apiKey: ApiKey): Promise; + findById(id: string): Promise; + findByKeyHash(keyHash: string): Promise; + findByOrganizationId(organizationId: string): Promise; + delete(id: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/email.port.ts b/apps/backend/src/domain/ports/out/email.port.ts index 75c1375..596293b 100644 --- a/apps/backend/src/domain/ports/out/email.port.ts +++ b/apps/backend/src/domain/ports/out/email.port.ts @@ -15,6 +15,7 @@ export interface EmailAttachment { export interface EmailOptions { to: string | string[]; + from?: string; cc?: string | string[]; bcc?: string | string[]; replyTo?: string; diff --git a/apps/backend/src/domain/ports/out/invitation-token.repository.ts b/apps/backend/src/domain/ports/out/invitation-token.repository.ts index b3fcc59..285c575 100644 --- a/apps/backend/src/domain/ports/out/invitation-token.repository.ts +++ b/apps/backend/src/domain/ports/out/invitation-token.repository.ts @@ -35,6 +35,11 @@ export interface InvitationTokenRepository { */ deleteExpired(): Promise; + /** + * Delete an invitation by id + */ + deleteById(id: string): Promise; + /** * Update an invitation token */ diff --git a/apps/backend/src/domain/ports/out/shipment-counter.port.ts b/apps/backend/src/domain/ports/out/shipment-counter.port.ts new file mode 100644 index 0000000..0aaad05 --- /dev/null +++ b/apps/backend/src/domain/ports/out/shipment-counter.port.ts @@ -0,0 +1,15 @@ +/** + * Shipment Counter Port + * + * Counts total shipments (bookings + CSV bookings) for an organization + * within a given year. Used to enforce the Bronze plan's 12 shipments/year limit. + */ + +export const SHIPMENT_COUNTER_PORT = 'SHIPMENT_COUNTER_PORT'; + +export interface ShipmentCounterPort { + /** + * Count all shipments (bookings + CSV bookings) created by an organization in a given year. + */ + countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise; +} diff --git a/apps/backend/src/domain/ports/out/siret-verification.port.ts b/apps/backend/src/domain/ports/out/siret-verification.port.ts new file mode 100644 index 0000000..6cae4ca --- /dev/null +++ b/apps/backend/src/domain/ports/out/siret-verification.port.ts @@ -0,0 +1,11 @@ +export const SIRET_VERIFICATION_PORT = 'SIRET_VERIFICATION_PORT'; + +export interface SiretVerificationResult { + valid: boolean; + companyName?: string; + address?: string; +} + +export interface SiretVerificationPort { + verify(siret: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/stripe.port.ts b/apps/backend/src/domain/ports/out/stripe.port.ts index 564dbfa..0546b6c 100644 --- a/apps/backend/src/domain/ports/out/stripe.port.ts +++ b/apps/backend/src/domain/ports/out/stripe.port.ts @@ -43,6 +43,22 @@ export interface StripeSubscriptionData { cancelAtPeriodEnd: boolean; } +export interface CreateCommissionCheckoutInput { + bookingId: string; + amountCents: number; + currency: 'eur'; + customerEmail: string; + organizationId: string; + bookingDescription: string; + successUrl: string; + cancelUrl: string; +} + +export interface CreateCommissionCheckoutOutput { + sessionId: string; + sessionUrl: string; +} + export interface StripeCheckoutSessionData { sessionId: string; customerId: string | null; @@ -62,16 +78,19 @@ export interface StripePort { /** * Create a Stripe Checkout session for subscription purchase */ - createCheckoutSession( - input: CreateCheckoutSessionInput, - ): Promise; + createCheckoutSession(input: CreateCheckoutSessionInput): Promise; + + /** + * Create a Stripe Checkout session for one-time commission payment + */ + createCommissionCheckout( + input: CreateCommissionCheckoutInput + ): Promise; /** * Create a Stripe Customer Portal session for subscription management */ - createPortalSession( - input: CreatePortalSessionInput, - ): Promise; + createPortalSession(input: CreatePortalSessionInput): Promise; /** * Retrieve subscription details from Stripe @@ -101,10 +120,7 @@ export interface StripePort { /** * Verify and parse a Stripe webhook event */ - constructWebhookEvent( - payload: string | Buffer, - signature: string, - ): Promise; + constructWebhookEvent(payload: string | Buffer, signature: string): Promise; /** * Map a Stripe price ID to a subscription plan diff --git a/apps/backend/src/domain/value-objects/plan-feature.vo.ts b/apps/backend/src/domain/value-objects/plan-feature.vo.ts new file mode 100644 index 0000000..ee6bd91 --- /dev/null +++ b/apps/backend/src/domain/value-objects/plan-feature.vo.ts @@ -0,0 +1,53 @@ +/** + * Plan Feature Value Object + * + * Defines the features available per subscription plan. + * Used by the FeatureFlagGuard to enforce access control. + */ + +export type PlanFeature = + | 'dashboard' + | 'wiki' + | 'user_management' + | 'csv_export' + | 'api_access' + | 'custom_interface' + | 'dedicated_kam'; + +export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [ + 'dashboard', + 'wiki', + 'user_management', + 'csv_export', + 'api_access', + 'custom_interface', + 'dedicated_kam', +]; + +export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; + +export const PLAN_FEATURES: Record = { + BRONZE: [], + SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'], + GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], + PLATINIUM: [ + 'dashboard', + 'wiki', + 'user_management', + 'csv_export', + 'api_access', + 'custom_interface', + 'dedicated_kam', + ], +}; + +export function planHasFeature( + plan: SubscriptionPlanTypeForFeatures, + feature: PlanFeature +): boolean { + return PLAN_FEATURES[plan].includes(feature); +} + +export function planGetFeatures(plan: SubscriptionPlanTypeForFeatures): readonly PlanFeature[] { + return PLAN_FEATURES[plan]; +} diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts index b82192a..f198956 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -2,68 +2,109 @@ * Subscription Plan Value Object * * Represents the different subscription plans available for organizations. - * Each plan has a maximum number of licenses that determine how many users - * can be active in an organization. + * Each plan has a maximum number of licenses, shipment limits, commission rates, + * feature flags, and support levels. + * + * Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom) */ -export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; + +export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; + +export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam'; +export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium'; + +/** + * Legacy plan name mapping for backward compatibility during migration. + */ +const LEGACY_PLAN_MAPPING: Record = { + FREE: 'BRONZE', + STARTER: 'SILVER', + PRO: 'GOLD', + ENTERPRISE: 'PLATINIUM', +}; interface PlanDetails { readonly name: string; readonly maxLicenses: number; // -1 means unlimited readonly monthlyPriceEur: number; readonly yearlyPriceEur: number; - readonly features: readonly string[]; + readonly maxShipmentsPerYear: number; // -1 means unlimited + readonly commissionRatePercent: number; + readonly statusBadge: StatusBadge; + readonly supportLevel: SupportLevel; + readonly planFeatures: readonly PlanFeature[]; + readonly features: readonly string[]; // Human-readable feature descriptions } const PLAN_DETAILS: Record = { - FREE: { - name: 'Free', - maxLicenses: 2, + BRONZE: { + name: 'Bronze', + maxLicenses: 1, monthlyPriceEur: 0, yearlyPriceEur: 0, - features: [ - 'Up to 2 users', - 'Basic rate search', - 'Email support', - ], + maxShipmentsPerYear: 12, + commissionRatePercent: 5, + statusBadge: 'none', + supportLevel: 'none', + planFeatures: PLAN_FEATURES.BRONZE, + features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'], }, - STARTER: { - name: 'Starter', + SILVER: { + name: 'Silver', maxLicenses: 5, - monthlyPriceEur: 49, - yearlyPriceEur: 470, // ~20% discount + monthlyPriceEur: 249, + yearlyPriceEur: 2739, // 249 * 11 months + maxShipmentsPerYear: -1, + commissionRatePercent: 3, + statusBadge: 'silver', + supportLevel: 'email', + planFeatures: PLAN_FEATURES.SILVER, features: [ - 'Up to 5 users', - 'Advanced rate search', - 'CSV imports', - 'Priority email support', + "Jusqu'à 5 utilisateurs", + 'Expéditions illimitées', + 'Tableau de bord', + 'Wiki Maritime', + 'Gestion des utilisateurs', + 'Import CSV', + 'Support par email', ], }, - PRO: { - name: 'Pro', + GOLD: { + name: 'Gold', maxLicenses: 20, - monthlyPriceEur: 149, - yearlyPriceEur: 1430, // ~20% discount + monthlyPriceEur: 899, + yearlyPriceEur: 9889, // 899 * 11 months + maxShipmentsPerYear: -1, + commissionRatePercent: 2, + statusBadge: 'gold', + supportLevel: 'direct', + planFeatures: PLAN_FEATURES.GOLD, features: [ - 'Up to 20 users', - 'All Starter features', - 'API access', - 'Custom integrations', - 'Phone support', + "Jusqu'à 20 utilisateurs", + 'Expéditions illimitées', + 'Toutes les fonctionnalités Silver', + 'Intégration API', + 'Assistance commerciale directe', ], }, - ENTERPRISE: { - name: 'Enterprise', + PLATINIUM: { + name: 'Platinium', maxLicenses: -1, // unlimited monthlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing + maxShipmentsPerYear: -1, + commissionRatePercent: 1, + statusBadge: 'platinium', + supportLevel: 'dedicated_kam', + planFeatures: PLAN_FEATURES.PLATINIUM, features: [ - 'Unlimited users', - 'All Pro features', - 'Dedicated account manager', - 'Custom SLA', - 'On-premise deployment option', + 'Utilisateurs illimités', + 'Toutes les fonctionnalités Gold', + 'Key Account Manager dédié', + 'Interface personnalisable', + 'Contrats tarifaires cadre', ], }, }; @@ -78,36 +119,68 @@ export class SubscriptionPlan { return new SubscriptionPlan(plan); } + /** + * Create from string with legacy name support. + * Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names. + */ static fromString(value: string): SubscriptionPlan { - const upperValue = value.toUpperCase() as SubscriptionPlanType; - if (!PLAN_DETAILS[upperValue]) { - throw new Error(`Invalid subscription plan: ${value}`); + const upperValue = value.toUpperCase(); + + // Check legacy mapping first + const mapped = LEGACY_PLAN_MAPPING[upperValue]; + if (mapped) { + return new SubscriptionPlan(mapped); } - return new SubscriptionPlan(upperValue); + + // Try direct match + if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) { + return new SubscriptionPlan(upperValue as SubscriptionPlanType); + } + + throw new Error(`Invalid subscription plan: ${value}`); } + // Named factories + static bronze(): SubscriptionPlan { + return new SubscriptionPlan('BRONZE'); + } + + static silver(): SubscriptionPlan { + return new SubscriptionPlan('SILVER'); + } + + static gold(): SubscriptionPlan { + return new SubscriptionPlan('GOLD'); + } + + static platinium(): SubscriptionPlan { + return new SubscriptionPlan('PLATINIUM'); + } + + // Legacy aliases static free(): SubscriptionPlan { - return new SubscriptionPlan('FREE'); + return SubscriptionPlan.bronze(); } static starter(): SubscriptionPlan { - return new SubscriptionPlan('STARTER'); + return SubscriptionPlan.silver(); } static pro(): SubscriptionPlan { - return new SubscriptionPlan('PRO'); + return SubscriptionPlan.gold(); } static enterprise(): SubscriptionPlan { - return new SubscriptionPlan('ENTERPRISE'); + return SubscriptionPlan.platinium(); } static getAllPlans(): SubscriptionPlan[] { - return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map( - (p) => new SubscriptionPlan(p as SubscriptionPlanType), + return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map( + p => new SubscriptionPlan(p) ); } + // Getters get value(): SubscriptionPlanType { return this.plan; } @@ -132,6 +205,33 @@ export class SubscriptionPlan { return PLAN_DETAILS[this.plan].features; } + get maxShipmentsPerYear(): number { + return PLAN_DETAILS[this.plan].maxShipmentsPerYear; + } + + get commissionRatePercent(): number { + return PLAN_DETAILS[this.plan].commissionRatePercent; + } + + get statusBadge(): StatusBadge { + return PLAN_DETAILS[this.plan].statusBadge; + } + + get supportLevel(): SupportLevel { + return PLAN_DETAILS[this.plan].supportLevel; + } + + get planFeatures(): readonly PlanFeature[] { + return PLAN_DETAILS[this.plan].planFeatures; + } + + /** + * Check if this plan includes a specific feature + */ + hasFeature(feature: PlanFeature): boolean { + return this.planFeatures.includes(feature); + } + /** * Returns true if this plan has unlimited licenses */ @@ -140,17 +240,31 @@ export class SubscriptionPlan { } /** - * Returns true if this is a paid plan + * Returns true if this plan has unlimited shipments */ - isPaid(): boolean { - return this.plan !== 'FREE'; + hasUnlimitedShipments(): boolean { + return this.maxShipmentsPerYear === -1; } /** - * Returns true if this is the free plan + * Returns true if this is a paid plan + */ + isPaid(): boolean { + return this.plan !== 'BRONZE'; + } + + /** + * Returns true if this is the free (Bronze) plan */ isFree(): boolean { - return this.plan === 'FREE'; + return this.plan === 'BRONZE'; + } + + /** + * Returns true if this plan has custom pricing (Platinium) + */ + isCustomPricing(): boolean { + return this.plan === 'PLATINIUM'; } /** @@ -165,12 +279,7 @@ export class SubscriptionPlan { * Check if upgrade to target plan is allowed */ canUpgradeTo(targetPlan: SubscriptionPlan): boolean { - const planOrder: SubscriptionPlanType[] = [ - 'FREE', - 'STARTER', - 'PRO', - 'ENTERPRISE', - ]; + const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); return targetIndex > currentIndex; @@ -180,12 +289,7 @@ export class SubscriptionPlan { * Check if downgrade to target plan is allowed given current user count */ canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { - const planOrder: SubscriptionPlanType[] = [ - 'FREE', - 'STARTER', - 'PRO', - 'ENTERPRISE', - ]; + const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); diff --git a/apps/backend/src/domain/value-objects/subscription-status.vo.ts b/apps/backend/src/domain/value-objects/subscription-status.vo.ts index de87862..959d8a9 100644 --- a/apps/backend/src/domain/value-objects/subscription-status.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-status.vo.ts @@ -191,9 +191,7 @@ export class SubscriptionStatus { */ transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus { if (!this.canTransitionTo(newStatus)) { - throw new Error( - `Invalid status transition from ${this.status} to ${newStatus.value}`, - ); + throw new Error(`Invalid status transition from ${this.status} to ${newStatus.value}`); } return newStatus; } diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 363c713..78501d1 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -4,69 +4,157 @@ * Implements EmailPort using nodemailer */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; +import * as https from 'https'; import { EmailPort, EmailOptions } from '@domain/ports/out/email.port'; import { EmailTemplates } from './templates/email-templates'; +// Display names included → moins susceptibles d'être marqués spam +const EMAIL_SENDERS = { + SECURITY: '"Xpeditis Sécurité" ', + BOOKINGS: '"Xpeditis Bookings" ', + TEAM: '"Équipe Xpeditis" ', + CARRIERS: '"Xpeditis Transporteurs" ', + NOREPLY: '"Xpeditis" ', +} as const; + +/** + * Génère une version plain text à partir du HTML pour améliorer la délivrabilité. + * Les emails sans version texte sont pénalisés par les filtres anti-spam. + */ +function htmlToPlainText(html: string): string { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<\/h[1-6]>/gi, '\n\n') + .replace(/]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + @Injectable() -export class EmailAdapter implements EmailPort { +export class EmailAdapter implements EmailPort, OnModuleInit { private readonly logger = new Logger(EmailAdapter.name); private transporter: nodemailer.Transporter; constructor( private readonly configService: ConfigService, private readonly emailTemplates: EmailTemplates - ) { - this.initializeTransporter(); + ) {} + + async onModuleInit(): Promise { + const host = this.configService.get('SMTP_HOST', 'localhost'); + + // 🔧 FIX: Mailtrap — IP directe hardcodée + if (host.includes('mailtrap.io')) { + this.buildTransporter('3.209.246.195', host); + return; + } + + // 🔧 FIX: DNS over HTTPS — contourne le port 53 UDP (bloqué sur certains réseaux). + // On appelle l'API DoH de Cloudflare via HTTPS (port 443) pour résoudre l'IP + // AVANT de créer le transporter, puis on passe l'IP directement à nodemailer. + if (!/^\d+\.\d+\.\d+\.\d+$/.test(host) && host !== 'localhost') { + try { + const ip = await this.resolveViaDoH(host); + this.logger.log(`[DNS-DoH] ${host} → ${ip}`); + this.buildTransporter(ip, host); + return; + } catch (err: any) { + this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`); + } + } + + this.buildTransporter(host, host); } - private initializeTransporter(): void { - const host = this.configService.get('SMTP_HOST', 'localhost'); + /** + * Résout un hostname en IP via l'API DNS over HTTPS de Cloudflare. + * Utilise HTTPS (port 443) donc fonctionne même quand le port 53 UDP est bloqué. + */ + private resolveViaDoH(hostname: string): Promise { + return new Promise((resolve, reject) => { + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`; + const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => { + let raw = ''; + res.on('data', (chunk) => (raw += chunk)); + res.on('end', () => { + try { + const json = JSON.parse(raw); + const aRecord = (json.Answer ?? []).find((r: any) => r.type === 1); + if (aRecord?.data) { + resolve(aRecord.data); + } else { + reject(new Error(`No A record returned by DoH for ${hostname}`)); + } + } catch (e) { + reject(e); + } + }); + }); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('DoH request timed out')); + }); + }); + } + + private buildTransporter(actualHost: string, serverName: string): void { const port = this.configService.get('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é - // Cela évite les timeouts DNS (queryA ETIMEOUT) sur certains réseaux - const useDirectIP = host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS - this.transporter = nodemailer.createTransport({ host: actualHost, port, secure, - auth: { - user, - pass, - }, - // Configuration TLS avec servername pour IP directe + auth: { user, pass }, tls: { rejectUnauthorized: false, - servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe + servername: serverName, }, - // Timeouts optimisés - connectionTimeout: 10000, // 10s - greetingTimeout: 10000, // 10s - socketTimeout: 30000, // 30s - dnsTimeout: 10000, // 10s - }); + connectionTimeout: 15000, + greetingTimeout: 15000, + socketTimeout: 30000, + } as any); this.logger.log( - `Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` + - (useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '') + `Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}` ); + + this.transporter.verify((error) => { + if (error) { + this.logger.error(`❌ SMTP connection FAILED: ${error.message}`); + } else { + this.logger.log(`✅ SMTP connection verified — ready to send emails`); + } + }); } async send(options: EmailOptions): Promise { try { - const from = this.configService.get('SMTP_FROM', 'noreply@xpeditis.com'); + const from = + options.from ?? + this.configService.get('SMTP_FROM', EMAIL_SENDERS.NOREPLY); - await this.transporter.sendMail({ + // Génère automatiquement la version plain text si absente (améliore le score anti-spam) + const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined); + + const info = await this.transporter.sendMail({ from, to: options.to, cc: options.cc, @@ -74,11 +162,13 @@ export class EmailAdapter implements EmailPort { replyTo: options.replyTo, subject: options.subject, html: options.html, - text: options.text, + text, attachments: options.attachments, }); - this.logger.log(`Email sent to ${options.to}: ${options.subject}`); + this.logger.log( + `✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}` + ); } catch (error) { this.logger.error(`Failed to send email to ${options.to}`, error); throw error; @@ -108,6 +198,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.BOOKINGS, subject: `Booking Confirmation - ${bookingNumber}`, html, attachments, @@ -122,6 +213,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.SECURITY, subject: 'Verify your email - Xpeditis', html, }); @@ -135,6 +227,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.SECURITY, subject: 'Reset your password - Xpeditis', html, }); @@ -148,6 +241,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.NOREPLY, subject: 'Welcome to Xpeditis', html, }); @@ -169,6 +263,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.TEAM, subject: `You've been invited to join ${organizationName} on Xpeditis`, html, }); @@ -209,6 +304,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.TEAM, subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`, html, }); @@ -273,6 +369,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, + from: EMAIL_SENDERS.BOOKINGS, subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`, html, }); @@ -349,6 +446,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.CARRIERS, subject: '🚢 Votre compte transporteur Xpeditis a été créé', html, }); @@ -424,6 +522,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.SECURITY, subject: '🔑 Réinitialisation de votre mot de passe Xpeditis', html, }); @@ -535,6 +634,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, + from: EMAIL_SENDERS.BOOKINGS, subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`, html, }); @@ -614,10 +714,13 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, + from: EMAIL_SENDERS.BOOKINGS, subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`, html, }); - this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`); + this.logger.log( + `New documents notification sent to ${carrierEmail} for booking ${data.bookingId}` + ); } } diff --git a/apps/backend/src/infrastructure/external/pappers-siret.adapter.ts b/apps/backend/src/infrastructure/external/pappers-siret.adapter.ts new file mode 100644 index 0000000..7de3ba0 --- /dev/null +++ b/apps/backend/src/infrastructure/external/pappers-siret.adapter.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + SiretVerificationPort, + SiretVerificationResult, +} from '@domain/ports/out/siret-verification.port'; + +@Injectable() +export class PappersSiretAdapter implements SiretVerificationPort { + private readonly logger = new Logger(PappersSiretAdapter.name); + private readonly apiKey: string; + private readonly baseUrl = 'https://api.pappers.fr/v2'; + + constructor(private readonly configService: ConfigService) { + this.apiKey = this.configService.get('PAPPERS_API_KEY', ''); + } + + async verify(siret: string): Promise { + if (!this.apiKey) { + this.logger.warn('PAPPERS_API_KEY not configured, skipping SIRET verification'); + return { valid: false }; + } + + try { + const url = `${this.baseUrl}/entreprise?api_token=${this.apiKey}&siret=${siret}`; + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 404) { + return { valid: false }; + } + this.logger.error(`Pappers API error: ${response.status} ${response.statusText}`); + return { valid: false }; + } + + const data = await response.json(); + + return { + valid: true, + companyName: data.nom_entreprise || data.denomination, + address: data.siege?.adresse_ligne_1 + ? `${data.siege.adresse_ligne_1}, ${data.siege.code_postal} ${data.siege.ville}` + : undefined, + }; + } catch (error: any) { + this.logger.error(`SIRET verification failed: ${error?.message}`, error?.stack); + return { valid: false }; + } + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/api-key.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/api-key.orm-entity.ts new file mode 100644 index 0000000..bca7e0a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/api-key.orm-entity.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + JoinColumn, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { OrganizationOrmEntity } from './organization.orm-entity'; +import { UserOrmEntity } from './user.orm-entity'; + +@Entity('api_keys') +@Index('idx_api_keys_organization_id', ['organizationId']) +@Index('idx_api_keys_user_id', ['userId']) +@Index('idx_api_keys_is_active', ['isActive']) +export class ApiKeyOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'organization_id', type: 'uuid' }) + organizationId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ name: 'key_hash', length: 64, unique: true }) + keyHash: string; + + @Column({ name: 'key_prefix', length: 20 }) + keyPrefix: string; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @Column({ name: 'last_used_at', type: 'timestamp', nullable: true }) + lastUsedAt: Date | null; + + @Column({ name: 'expires_at', type: 'timestamp', nullable: true }) + expiresAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization: OrganizationOrmEntity; + + @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserOrmEntity; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts index d78c7d7..f3db96e 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts @@ -92,6 +92,18 @@ export class BookingOrmEntity { @Column({ name: 'special_instructions', type: 'text', nullable: true }) specialInstructions: string | null; + @Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + commissionRate: number | null; + + @Column({ + name: 'commission_amount_eur', + type: 'decimal', + precision: 12, + scale: 2, + nullable: true, + }) + commissionAmountEur: number | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts index 2f8188c..0d40645 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts @@ -1,58 +1,58 @@ -/** - * Cookie Consent ORM Entity (Infrastructure Layer) - * - * TypeORM entity for cookie consent persistence - */ - -import { - Entity, - Column, - PrimaryColumn, - CreateDateColumn, - UpdateDateColumn, - Index, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { UserOrmEntity } from './user.orm-entity'; - -@Entity('cookie_consents') -@Index('idx_cookie_consents_user', ['userId']) -export class CookieConsentOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ name: 'user_id', type: 'uuid', unique: true }) - userId: string; - - @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'user_id' }) - user: UserOrmEntity; - - @Column({ type: 'boolean', default: true }) - essential: boolean; - - @Column({ type: 'boolean', default: false }) - functional: boolean; - - @Column({ type: 'boolean', default: false }) - analytics: boolean; - - @Column({ type: 'boolean', default: false }) - marketing: boolean; - - @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) - ipAddress: string | null; - - @Column({ name: 'user_agent', type: 'text', nullable: true }) - userAgent: string | null; - - @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) - consentDate: Date; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * Cookie Consent ORM Entity (Infrastructure Layer) + * + * TypeORM entity for cookie consent persistence + */ + +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UserOrmEntity } from './user.orm-entity'; + +@Entity('cookie_consents') +@Index('idx_cookie_consents_user', ['userId']) +export class CookieConsentOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId: string; + + @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserOrmEntity; + + @Column({ type: 'boolean', default: true }) + essential: boolean; + + @Column({ type: 'boolean', default: false }) + functional: boolean; + + @Column({ type: 'boolean', default: false }) + analytics: boolean; + + @Column({ type: 'boolean', default: false }) + marketing: boolean; + + @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string | null; + + @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) + consentDate: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index aa1e8a4..75eb591 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -75,11 +75,11 @@ export class CsvBookingOrmEntity { @Column({ name: 'status', type: 'enum', - enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], - default: 'PENDING', + enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + default: 'PENDING_PAYMENT', }) @Index() - status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; + status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; @Column({ name: 'documents', type: 'jsonb' }) documents: Array<{ @@ -141,6 +141,21 @@ export class CsvBookingOrmEntity { @Column({ name: 'carrier_notes', type: 'text', nullable: true }) carrierNotes: string | null; + @Column({ name: 'stripe_payment_intent_id', type: 'varchar', length: 255, nullable: true }) + stripePaymentIntentId: string | null; + + @Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + commissionRate: number | null; + + @Column({ + name: 'commission_amount_eur', + type: 'decimal', + precision: 12, + scale: 2, + nullable: true, + }) + commissionAmountEur: number | null; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts index afde22a..71b541d 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts @@ -5,14 +5,7 @@ * Represents user licenses linked to subscriptions. */ -import { - Entity, - Column, - PrimaryGeneratedColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; import { SubscriptionOrmEntity } from './subscription.orm-entity'; import { UserOrmEntity } from './user.orm-entity'; @@ -30,7 +23,7 @@ export class LicenseOrmEntity { @Column({ name: 'subscription_id', type: 'uuid' }) subscriptionId: string; - @ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, { + @ManyToOne(() => SubscriptionOrmEntity, subscription => subscription.licenses, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'subscription_id' }) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts index 8827fc7..9c59b49 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts @@ -56,6 +56,15 @@ export class OrganizationOrmEntity { @Column({ type: 'jsonb', default: '[]' }) documents: any[]; + @Column({ type: 'varchar', length: 14, nullable: true }) + siret: string | null; + + @Column({ name: 'siret_verified', type: 'boolean', default: false }) + siretVerified: boolean; + + @Column({ name: 'status_badge', type: 'varchar', length: 20, default: 'none' }) + statusBadge: string; + @Column({ name: 'is_carrier', type: 'boolean', default: false }) isCarrier: boolean; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts new file mode 100644 index 0000000..fd4598f --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('password_reset_tokens') +export class PasswordResetTokenOrmEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + @Index('IDX_password_reset_tokens_user_id') + userId: string; + + @Column({ unique: true, length: 255 }) + @Index('IDX_password_reset_tokens_token') + token: string; + + @Column({ name: 'expires_at', type: 'timestamp' }) + expiresAt: Date; + + @Column({ name: 'used_at', type: 'timestamp', nullable: true }) + usedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts index 941b744..58b3977 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts @@ -19,7 +19,7 @@ import { import { OrganizationOrmEntity } from './organization.orm-entity'; import { LicenseOrmEntity } from './license.orm-entity'; -export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +export type SubscriptionPlanOrmType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SubscriptionStatusOrmType = | 'ACTIVE' @@ -51,8 +51,8 @@ export class SubscriptionOrmEntity { // Plan information @Column({ type: 'enum', - enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'], - default: 'FREE', + enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'], + default: 'BRONZE', }) plan: SubscriptionPlanOrmType; @@ -103,6 +103,6 @@ export class SubscriptionOrmEntity { updatedAt: Date; // Relations - @OneToMany(() => LicenseOrmEntity, (license) => license.subscription) + @OneToMany(() => LicenseOrmEntity, license => license.subscription) licenses: LicenseOrmEntity[]; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/api-key-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/api-key-orm.mapper.ts new file mode 100644 index 0000000..dd48a69 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/api-key-orm.mapper.ts @@ -0,0 +1,40 @@ +import { ApiKey } from '@domain/entities/api-key.entity'; +import { ApiKeyOrmEntity } from '../entities/api-key.orm-entity'; + +export class ApiKeyOrmMapper { + static toDomain(orm: ApiKeyOrmEntity): ApiKey { + return ApiKey.fromPersistence({ + id: orm.id, + organizationId: orm.organizationId, + userId: orm.userId, + name: orm.name, + keyHash: orm.keyHash, + keyPrefix: orm.keyPrefix, + isActive: orm.isActive, + lastUsedAt: orm.lastUsedAt, + expiresAt: orm.expiresAt, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }); + } + + static toOrm(domain: ApiKey): ApiKeyOrmEntity { + const orm = new ApiKeyOrmEntity(); + orm.id = domain.id; + orm.organizationId = domain.organizationId; + orm.userId = domain.userId; + orm.name = domain.name; + orm.keyHash = domain.keyHash; + orm.keyPrefix = domain.keyPrefix; + orm.isActive = domain.isActive; + orm.lastUsedAt = domain.lastUsedAt; + orm.expiresAt = domain.expiresAt; + orm.createdAt = domain.createdAt; + orm.updatedAt = domain.updatedAt; + return orm; + } + + static toDomainMany(orms: ApiKeyOrmEntity[]): ApiKey[] { + return orms.map(orm => ApiKeyOrmMapper.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts index 5a36902..df15aec 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts @@ -27,6 +27,8 @@ export class BookingOrmMapper { orm.consignee = this.partyToJson(domain.consignee); orm.cargoDescription = domain.cargoDescription; orm.specialInstructions = domain.specialInstructions || null; + orm.commissionRate = domain.commissionRate ?? null; + orm.commissionAmountEur = domain.commissionAmountEur ?? null; orm.createdAt = domain.createdAt; orm.updatedAt = domain.updatedAt; @@ -52,6 +54,9 @@ export class BookingOrmMapper { cargoDescription: orm.cargoDescription, containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [], specialInstructions: orm.specialInstructions || undefined, + commissionRate: orm.commissionRate != null ? Number(orm.commissionRate) : undefined, + commissionAmountEur: + orm.commissionAmountEur != null ? Number(orm.commissionAmountEur) : undefined, createdAt: orm.createdAt, updatedAt: orm.updatedAt, }; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts index 4fee923..85217ed 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts @@ -42,7 +42,10 @@ export class CsvBookingMapper { ormEntity.respondedAt, ormEntity.notes, ormEntity.rejectionReason, - ormEntity.bookingNumber ?? undefined + ormEntity.bookingNumber ?? undefined, + ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined, + ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined, + ormEntity.stripePaymentIntentId ?? undefined ); } @@ -66,13 +69,16 @@ export class CsvBookingMapper { primaryCurrency: domain.primaryCurrency, transitDays: domain.transitDays, containerType: domain.containerType, - status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', + status: domain.status as CsvBookingOrmEntity['status'], documents: domain.documents as any, confirmationToken: domain.confirmationToken, requestedAt: domain.requestedAt, respondedAt: domain.respondedAt, notes: domain.notes, rejectionReason: domain.rejectionReason, + stripePaymentIntentId: domain.stripePaymentIntentId ?? null, + commissionRate: domain.commissionRate ?? null, + commissionAmountEur: domain.commissionAmountEur ?? null, }; } @@ -81,10 +87,13 @@ export class CsvBookingMapper { */ static toOrmUpdate(domain: CsvBooking): Partial { return { - status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', + status: domain.status as CsvBookingOrmEntity['status'], respondedAt: domain.respondedAt, notes: domain.notes, rejectionReason: domain.rejectionReason, + stripePaymentIntentId: domain.stripePaymentIntentId ?? null, + commissionRate: domain.commissionRate ?? null, + commissionAmountEur: domain.commissionAmountEur ?? null, }; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts index 9a4ceb5..b68d699 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts @@ -43,6 +43,6 @@ export class LicenseOrmMapper { * Map array of ORM entities to domain entities */ static toDomainMany(orms: LicenseOrmEntity[]): License[] { - return orms.map((orm) => this.toDomain(orm)); + return orms.map(orm => this.toDomain(orm)); } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts index 78f6660..9eb59c6 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts @@ -30,6 +30,9 @@ export class OrganizationOrmMapper { orm.addressCountry = props.address.country; orm.logoUrl = props.logoUrl || null; orm.documents = props.documents; + orm.siret = props.siret || null; + orm.siretVerified = props.siretVerified; + orm.statusBadge = props.statusBadge; orm.isActive = props.isActive; orm.createdAt = props.createdAt; orm.updatedAt = props.updatedAt; @@ -59,6 +62,9 @@ export class OrganizationOrmMapper { }, logoUrl: orm.logoUrl || undefined, documents: orm.documents || [], + siret: orm.siret || undefined, + siretVerified: orm.siretVerified ?? false, + statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none', isActive: orm.isActive, createdAt: orm.createdAt, updatedAt: orm.updatedAt, diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts index 95c65d0..1e07da1 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts @@ -53,6 +53,6 @@ export class SubscriptionOrmMapper { * Map array of ORM entities to domain entities */ static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] { - return orms.map((orm) => this.toDomain(orm)); + return orms.map(orm => this.toDomain(orm)); } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts index 561df7c..ccb0813 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts @@ -1,62 +1,62 @@ -/** - * Migration: Create Cookie Consents Table - * GDPR compliant cookie preference storage - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateCookieConsent1738100000000 implements MigrationInterface { - name = 'CreateCookieConsent1738100000000'; - - public async up(queryRunner: QueryRunner): Promise { - // Create cookie_consents table - await queryRunner.query(` - CREATE TABLE "cookie_consents" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "user_id" UUID NOT NULL, - "essential" BOOLEAN NOT NULL DEFAULT TRUE, - "functional" BOOLEAN NOT NULL DEFAULT FALSE, - "analytics" BOOLEAN NOT NULL DEFAULT FALSE, - "marketing" BOOLEAN NOT NULL DEFAULT FALSE, - "ip_address" VARCHAR(45) NULL, - "user_agent" TEXT NULL, - "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), - CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), - CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") - REFERENCES "users"("id") ON DELETE CASCADE - ) - `); - - // Create index for fast user lookups - await queryRunner.query(` - CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "cookie_consents"`); - } -} +/** + * Migration: Create Cookie Consents Table + * GDPR compliant cookie preference storage + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCookieConsent1738100000000 implements MigrationInterface { + name = 'CreateCookieConsent1738100000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create cookie_consents table + await queryRunner.query(` + CREATE TABLE "cookie_consents" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "user_id" UUID NOT NULL, + "essential" BOOLEAN NOT NULL DEFAULT TRUE, + "functional" BOOLEAN NOT NULL DEFAULT FALSE, + "analytics" BOOLEAN NOT NULL DEFAULT FALSE, + "marketing" BOOLEAN NOT NULL DEFAULT FALSE, + "ip_address" VARCHAR(45) NULL, + "user_agent" TEXT NULL, + "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), + CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), + CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + // Create index for fast user lookups + await queryRunner.query(` + CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "cookie_consents"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000001-RenamePlansToBronzeSilverGoldPlatinium.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000001-RenamePlansToBronzeSilverGoldPlatinium.ts new file mode 100644 index 0000000..c7bdb41 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000001-RenamePlansToBronzeSilverGoldPlatinium.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Rename subscription plans: + * FREE -> BRONZE, STARTER -> SILVER, PRO -> GOLD, ENTERPRISE -> PLATINIUM + * + * PostgreSQL does not support removing values from an enum type directly, + * so we create a new enum, migrate the column, and drop the old one. + */ +export class RenamePlansToBronzeSilverGoldPlatinium1740000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Create new enum type + await queryRunner.query( + `CREATE TYPE "subscription_plan_enum_new" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINIUM')` + ); + + // Step 2: Convert the column to VARCHAR temporarily so we can update values + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`); + + // Step 3: Update existing values + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'BRONZE' WHERE "plan" = 'FREE'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'SILVER' WHERE "plan" = 'STARTER'` + ); + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'GOLD' WHERE "plan" = 'PRO'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'PLATINIUM' WHERE "plan" = 'ENTERPRISE'` + ); + + // Step 4: Drop existing default (required before changing enum type) + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" DROP DEFAULT`); + + // Step 5: Set column to new enum type + await queryRunner.query( + `ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_new" USING "plan"::"subscription_plan_enum_new"` + ); + + // Step 6: Set new default + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'BRONZE'`); + + // Step 7: Drop old enum type (name may vary — TypeORM often creates it as subscriptions_plan_enum) + // We handle both possible names + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN + DROP TYPE "subscriptions_plan_enum"; + END IF; + END $$; + `); + + // Step 8: Rename new enum to standard name + await queryRunner.query( + `ALTER TYPE "subscription_plan_enum_new" RENAME TO "subscriptions_plan_enum"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse: create old enum, migrate back + await queryRunner.query( + `CREATE TYPE "subscription_plan_enum_old" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')` + ); + + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`); + + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'FREE' WHERE "plan" = 'BRONZE'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'STARTER' WHERE "plan" = 'SILVER'` + ); + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'PRO' WHERE "plan" = 'GOLD'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'ENTERPRISE' WHERE "plan" = 'PLATINIUM'` + ); + + await queryRunner.query( + `ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_old" USING "plan"::"subscription_plan_enum_old"` + ); + + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'FREE'`); + + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN + DROP TYPE "subscriptions_plan_enum"; + END IF; + END $$; + `); + + await queryRunner.query( + `ALTER TYPE "subscription_plan_enum_old" RENAME TO "subscriptions_plan_enum"` + ); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000002-AddCommissionFields.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000002-AddCommissionFields.ts new file mode 100644 index 0000000..204fb0e --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000002-AddCommissionFields.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCommissionFields1740000000002 implements MigrationInterface { + name = 'AddCommissionFields1740000000002'; + + public async up(queryRunner: QueryRunner): Promise { + // Add commission columns to csv_bookings (bookings table may not exist yet) + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ADD COLUMN IF NOT EXISTS "commission_rate" DECIMAL(5,2), + ADD COLUMN IF NOT EXISTS "commission_amount_eur" DECIMAL(12,2) + `); + + // Only alter bookings table if it exists + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN + ALTER TABLE "bookings" + ADD COLUMN "commission_rate" DECIMAL(5,2), + ADD COLUMN "commission_amount_eur" DECIMAL(12,2); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_bookings" + DROP COLUMN IF EXISTS "commission_amount_eur", + DROP COLUMN IF EXISTS "commission_rate" + `); + + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN + ALTER TABLE "bookings" + DROP COLUMN "commission_amount_eur", + DROP COLUMN "commission_rate"; + END IF; + END $$; + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddSiretAndStatusBadgeToOrganizations.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddSiretAndStatusBadgeToOrganizations.ts new file mode 100644 index 0000000..eabe38c --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddSiretAndStatusBadgeToOrganizations.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSiretAndStatusBadgeToOrganizations1740000000003 implements MigrationInterface { + name = 'AddSiretAndStatusBadgeToOrganizations1740000000003'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "organizations" + ADD COLUMN "siret" VARCHAR(14), + ADD COLUMN "siret_verified" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN "status_badge" VARCHAR(20) NOT NULL DEFAULT 'none' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "organizations" + DROP COLUMN "status_badge", + DROP COLUMN "siret_verified", + DROP COLUMN "siret" + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000004-AddPendingPaymentStatus.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000004-AddPendingPaymentStatus.ts new file mode 100644 index 0000000..04e6656 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000004-AddPendingPaymentStatus.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: Add PENDING_PAYMENT status to csv_bookings enum + stripe_payment_intent_id column + */ +export class AddPendingPaymentStatus1740000000004 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Drop the default before changing enum type + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + // Create new enum with PENDING_PAYMENT + await queryRunner.query(` + CREATE TYPE "csv_booking_status_new" AS ENUM ('PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED') + `); + + // Swap column to new enum type + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_new" + USING "status"::text::"csv_booking_status_new" + `); + + // Drop old enum and rename new + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`); + + // Set new default + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + + // Add stripe_payment_intent_id column + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ADD COLUMN IF NOT EXISTS "stripe_payment_intent_id" VARCHAR(255) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove stripe_payment_intent_id column + await queryRunner.query(` + ALTER TABLE "csv_bookings" DROP COLUMN IF EXISTS "stripe_payment_intent_id" + `); + + // Update any PENDING_PAYMENT rows to PENDING + await queryRunner.query(` + UPDATE "csv_bookings" SET "status" = 'PENDING' WHERE "status" = 'PENDING_PAYMENT' + `); + + // Drop default + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + // Recreate original enum without PENDING_PAYMENT + await queryRunner.query(` + CREATE TYPE "csv_booking_status_old" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED') + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_old" + USING "status"::text::"csv_booking_status_old" + `); + + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING' + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts new file mode 100644 index 0000000..870bf03 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: Add PENDING_BANK_TRANSFER status to csv_bookings enum + */ +export class AddPendingBankTransferStatus1740000000005 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Drop default before modifying enum + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + // Create new enum with PENDING_BANK_TRANSFER + await queryRunner.query(` + CREATE TYPE "csv_booking_status_new" AS ENUM ( + 'PENDING_PAYMENT', + 'PENDING_BANK_TRANSFER', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED' + ) + `); + + // Swap column to new enum type + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_new" + USING "status"::text::"csv_booking_status_new" + `); + + // Drop old enum and rename new + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`); + + // Restore default + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Move any PENDING_BANK_TRANSFER rows back to PENDING_PAYMENT + await queryRunner.query(` + UPDATE "csv_bookings" SET "status" = 'PENDING_PAYMENT' WHERE "status" = 'PENDING_BANK_TRANSFER' + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + await queryRunner.query(` + CREATE TYPE "csv_booking_status_old" AS ENUM ( + 'PENDING_PAYMENT', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED' + ) + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_old" + USING "status"::text::"csv_booking_status_old" + `); + + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts new file mode 100644 index 0000000..c443352 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: Create API Keys Table + * + * Stores API keys for programmatic access. + * Only GOLD and PLATINIUM subscribers can create keys (enforced at application level). + * + * Security: the raw key is NEVER stored — only its SHA-256 hex hash. + */ +export class CreateApiKeysTable1741000000001 implements MigrationInterface { + name = 'CreateApiKeysTable1741000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "api_keys" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "organization_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "key_hash" VARCHAR(64) NOT NULL, + "key_prefix" VARCHAR(20) NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT TRUE, + "last_used_at" TIMESTAMP NULL, + "expires_at" TIMESTAMP NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + + CONSTRAINT "pk_api_keys" PRIMARY KEY ("id"), + CONSTRAINT "uq_api_keys_key_hash" UNIQUE ("key_hash"), + CONSTRAINT "fk_api_keys_organization" FOREIGN KEY ("organization_id") + REFERENCES "organizations"("id") ON DELETE CASCADE, + CONSTRAINT "fk_api_keys_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")` + ); + await queryRunner.query( + `CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")` + ); + await queryRunner.query( + `CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")` + ); + await queryRunner.query( + `CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")` + ); + + await queryRunner.query( + `COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'` + ); + await queryRunner.query( + `COMMENT ON COLUMN "api_keys"."key_hash" IS 'SHA-256 hex hash of the raw API key. The raw key is never stored.'` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "api_keys" CASCADE`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741500000001-CreatePasswordResetTokens.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741500000001-CreatePasswordResetTokens.ts new file mode 100644 index 0000000..af57244 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741500000001-CreatePasswordResetTokens.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePasswordResetTokens1741500000001 implements MigrationInterface { + name = 'CreatePasswordResetTokens1741500000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "password_reset_tokens" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "token" character varying(255) NOT NULL, + "expires_at" TIMESTAMP NOT NULL, + "used_at" TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_password_reset_tokens" PRIMARY KEY ("id"), + CONSTRAINT "UQ_password_reset_tokens_token" UNIQUE ("token") + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_password_reset_tokens_token" ON "password_reset_tokens" ("token")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_password_reset_tokens_user_id" ON "password_reset_tokens" ("user_id")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "password_reset_tokens"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts new file mode 100644 index 0000000..f60e0b5 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts @@ -0,0 +1,32 @@ +/** + * Shipment Counter Repository + * + * Counts total shipments (bookings + CSV bookings) for an organization in a year. + * Used to enforce Bronze plan's 12 shipments/year limit. + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ShipmentCounterPort } from '@domain/ports/out/shipment-counter.port'; +import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity'; + +@Injectable() +export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort { + constructor( + @InjectRepository(CsvBookingOrmEntity) + private readonly csvBookingRepository: Repository + ) {} + + async countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise { + const startOfYear = new Date(year, 0, 1); + const startOfNextYear = new Date(year + 1, 0, 1); + + return this.csvBookingRepository + .createQueryBuilder('csv_booking') + .where('csv_booking.organization_id = :organizationId', { organizationId }) + .andWhere('csv_booking.created_at >= :start', { start: startOfYear }) + .andWhere('csv_booking.created_at < :end', { end: startOfNextYear }) + .getCount(); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository.ts new file mode 100644 index 0000000..72b6c84 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ApiKey } from '@domain/entities/api-key.entity'; +import { ApiKeyRepository } from '@domain/ports/out/api-key.repository'; +import { ApiKeyOrmEntity } from '../entities/api-key.orm-entity'; +import { ApiKeyOrmMapper } from '../mappers/api-key-orm.mapper'; + +@Injectable() +export class TypeOrmApiKeyRepository implements ApiKeyRepository { + constructor( + @InjectRepository(ApiKeyOrmEntity) + private readonly repo: Repository + ) {} + + async save(apiKey: ApiKey): Promise { + const orm = ApiKeyOrmMapper.toOrm(apiKey); + const saved = await this.repo.save(orm); + return ApiKeyOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repo.findOne({ where: { id } }); + return orm ? ApiKeyOrmMapper.toDomain(orm) : null; + } + + async findByKeyHash(keyHash: string): Promise { + const orm = await this.repo.findOne({ where: { keyHash } }); + return orm ? ApiKeyOrmMapper.toDomain(orm) : null; + } + + async findByOrganizationId(organizationId: string): Promise { + const orms = await this.repo.find({ + where: { organizationId }, + order: { createdAt: 'DESC' }, + }); + return ApiKeyOrmMapper.toDomainMany(orms); + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts index 696e405..298ced7 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts @@ -78,6 +78,10 @@ export class TypeOrmInvitationTokenRepository implements InvitationTokenReposito return result.affected || 0; } + async deleteById(id: string): Promise { + await this.repository.delete({ id }); + } + async update(invitationToken: InvitationToken): Promise { const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken); const updated = await this.repository.save(ormEntity); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts index 8c74cd6..9081e21 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts @@ -16,7 +16,7 @@ import { LicenseOrmMapper } from '../mappers/license-orm.mapper'; export class TypeOrmLicenseRepository implements LicenseRepository { constructor( @InjectRepository(LicenseOrmEntity) - private readonly repository: Repository, + private readonly repository: Repository ) {} async save(license: License): Promise { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts index 5469475..27ee649 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts @@ -16,7 +16,7 @@ import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper'; export class TypeOrmSubscriptionRepository implements SubscriptionRepository { constructor( @InjectRepository(SubscriptionOrmEntity) - private readonly repository: Repository, + private readonly repository: Repository ) {} async save(subscription: Subscription): Promise { @@ -35,9 +35,7 @@ export class TypeOrmSubscriptionRepository implements SubscriptionRepository { return orm ? SubscriptionOrmMapper.toDomain(orm) : null; } - async findByStripeSubscriptionId( - stripeSubscriptionId: string, - ): Promise { + async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise { const orm = await this.repository.findOne({ where: { stripeSubscriptionId } }); return orm ? SubscriptionOrmMapper.toDomain(orm) : null; } diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts index cf5386a..4cd3665 100644 --- a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -11,6 +11,8 @@ import { StripePort, CreateCheckoutSessionInput, CreateCheckoutSessionOutput, + CreateCommissionCheckoutInput, + CreateCommissionCheckoutOutput, CreatePortalSessionInput, CreatePortalSessionOutput, StripeSubscriptionData, @@ -42,50 +44,46 @@ export class StripeAdapter implements StripePort { this.planPriceMap = new Map(); // Configure plan price IDs from environment - const starterMonthly = this.configService.get('STRIPE_STARTER_MONTHLY_PRICE_ID'); - const starterYearly = this.configService.get('STRIPE_STARTER_YEARLY_PRICE_ID'); - const proMonthly = this.configService.get('STRIPE_PRO_MONTHLY_PRICE_ID'); - const proYearly = this.configService.get('STRIPE_PRO_YEARLY_PRICE_ID'); - const enterpriseMonthly = this.configService.get('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'); - const enterpriseYearly = this.configService.get('STRIPE_ENTERPRISE_YEARLY_PRICE_ID'); + const silverMonthly = this.configService.get('STRIPE_SILVER_MONTHLY_PRICE_ID'); + const silverYearly = this.configService.get('STRIPE_SILVER_YEARLY_PRICE_ID'); + const goldMonthly = this.configService.get('STRIPE_GOLD_MONTHLY_PRICE_ID'); + const goldYearly = this.configService.get('STRIPE_GOLD_YEARLY_PRICE_ID'); + const platiniumMonthly = this.configService.get('STRIPE_PLATINIUM_MONTHLY_PRICE_ID'); + const platiniumYearly = this.configService.get('STRIPE_PLATINIUM_YEARLY_PRICE_ID'); - if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER'); - if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER'); - if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO'); - if (proYearly) this.priceIdMap.set(proYearly, 'PRO'); - if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE'); - if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE'); + if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER'); + if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER'); + if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD'); + if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD'); + if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM'); + if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM'); - this.planPriceMap.set('STARTER', { - monthly: starterMonthly || '', - yearly: starterYearly || '', + this.planPriceMap.set('SILVER', { + monthly: silverMonthly || '', + yearly: silverYearly || '', }); - this.planPriceMap.set('PRO', { - monthly: proMonthly || '', - yearly: proYearly || '', + this.planPriceMap.set('GOLD', { + monthly: goldMonthly || '', + yearly: goldYearly || '', }); - this.planPriceMap.set('ENTERPRISE', { - monthly: enterpriseMonthly || '', - yearly: enterpriseYearly || '', + this.planPriceMap.set('PLATINIUM', { + monthly: platiniumMonthly || '', + yearly: platiniumYearly || '', }); } async createCheckoutSession( - input: CreateCheckoutSessionInput, + input: CreateCheckoutSessionInput ): Promise { const planPrices = this.planPriceMap.get(input.plan); if (!planPrices) { throw new Error(`No price configuration for plan: ${input.plan}`); } - const priceId = input.billingInterval === 'yearly' - ? planPrices.yearly - : planPrices.monthly; + const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly; if (!priceId) { - throw new Error( - `No ${input.billingInterval} price configured for plan: ${input.plan}`, - ); + throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`); } const sessionParams: Stripe.Checkout.SessionCreateParams = { @@ -119,7 +117,7 @@ export class StripeAdapter implements StripePort { const session = await this.stripe.checkout.sessions.create(sessionParams); this.logger.log( - `Created checkout session ${session.id} for organization ${input.organizationId}`, + `Created checkout session ${session.id} for organization ${input.organizationId}` ); return { @@ -128,9 +126,46 @@ export class StripeAdapter implements StripePort { }; } - async createPortalSession( - input: CreatePortalSessionInput, - ): Promise { + async createCommissionCheckout( + input: CreateCommissionCheckoutInput + ): Promise { + const session = await this.stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: input.currency, + unit_amount: input.amountCents, + product_data: { + name: 'Commission Xpeditis', + description: input.bookingDescription, + }, + }, + quantity: 1, + }, + ], + customer_email: input.customerEmail, + success_url: input.successUrl, + cancel_url: input.cancelUrl, + metadata: { + type: 'commission', + bookingId: input.bookingId, + organizationId: input.organizationId, + }, + }); + + this.logger.log( + `Created commission checkout session ${session.id} for booking ${input.bookingId}` + ); + + return { + sessionId: session.id, + sessionUrl: session.url || '', + }; + } + + async createPortalSession(input: CreatePortalSessionInput): Promise { const session = await this.stripe.billingPortal.sessions.create({ customer: input.customerId, return_url: input.returnUrl, @@ -211,13 +246,9 @@ export class StripeAdapter implements StripePort { async constructWebhookEvent( payload: string | Buffer, - signature: string, + signature: string ): Promise { - const event = this.stripe.webhooks.constructEvent( - payload, - signature, - this.webhookSecret, - ); + const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret); return { type: event.type, diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index ed82eae..657f47a 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -7,6 +7,7 @@ import compression from 'compression'; import { AppModule } from './app.module'; import { Logger } from 'nestjs-pino'; import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; +import type { Request, Response, NextFunction } from 'express'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -19,6 +20,7 @@ async function bootstrap() { const configService = app.get(ConfigService); const port = configService.get('PORT', 4000); const apiPrefix = configService.get('API_PREFIX', 'api/v1'); + const isProduction = configService.get('NODE_ENV') === 'production'; // Use Pino logger app.useLogger(app.get(Logger)); @@ -52,39 +54,76 @@ async function bootstrap() { }) ); - // Swagger documentation - const config = new DocumentBuilder() - .setTitle('Xpeditis API') - .setDescription( - 'Maritime Freight Booking Platform - API for searching rates and managing bookings' - ) - .setVersion('1.0') - .addBearerAuth() - .addTag('rates', 'Rate search and comparison') - .addTag('bookings', 'Booking management') - .addTag('auth', 'Authentication and authorization') - .addTag('users', 'User management') - .addTag('organizations', 'Organization management') - .build(); + // ─── Swagger documentation ──────────────────────────────────────────────── + const swaggerUser = configService.get('SWAGGER_USERNAME'); + const swaggerPass = configService.get('SWAGGER_PASSWORD'); + const swaggerEnabled = !isProduction || (Boolean(swaggerUser) && Boolean(swaggerPass)); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document, { - customSiteTitle: 'Xpeditis API Documentation', - customfavIcon: 'https://xpeditis.com/favicon.ico', - customCss: '.swagger-ui .topbar { display: none }', - }); + if (swaggerEnabled) { + // HTTP Basic Auth guard for Swagger routes when credentials are configured + if (swaggerUser && swaggerPass) { + const swaggerPaths = ['/api/docs', '/api/docs-json', '/api/docs-yaml']; + app.use(swaggerPaths, (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic realm="Xpeditis API Docs"'); + res.status(401).send('Authentication required'); + return; + } + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8'); + const colonIndex = decoded.indexOf(':'); + const user = decoded.slice(0, colonIndex); + const pass = decoded.slice(colonIndex + 1); + if (user !== swaggerUser || pass !== swaggerPass) { + res.setHeader('WWW-Authenticate', 'Basic realm="Xpeditis API Docs"'); + res.status(401).send('Invalid credentials'); + return; + } + next(); + }); + } + + const config = new DocumentBuilder() + .setTitle('Xpeditis API') + .setDescription( + 'Maritime Freight Booking Platform - API for searching rates and managing bookings' + ) + .setVersion('1.0') + .addBearerAuth() + .addApiKey({ type: 'apiKey', name: 'x-api-key', in: 'header' }, 'x-api-key') + .addTag('rates', 'Rate search and comparison') + .addTag('bookings', 'Booking management') + .addTag('auth', 'Authentication and authorization') + .addTag('users', 'User management') + .addTag('organizations', 'Organization management') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document, { + customSiteTitle: 'Xpeditis API Documentation', + customfavIcon: 'https://xpeditis.com/favicon.ico', + customCss: '.swagger-ui .topbar { display: none }', + }); + } + // ───────────────────────────────────────────────────────────────────────── await app.listen(port); + const swaggerStatus = swaggerEnabled + ? swaggerUser + ? `http://localhost:${port}/api/docs (protected)` + : `http://localhost:${port}/api/docs (open — add SWAGGER_USERNAME/PASSWORD to secure)` + : 'disabled in production'; + console.log(` - ╔═══════════════════════════════════════╗ - ║ ║ - ║ 🚢 Xpeditis API Server Running ║ - ║ ║ - ║ API: http://localhost:${port}/${apiPrefix} ║ - ║ Docs: http://localhost:${port}/api/docs ║ - ║ ║ - ╚═══════════════════════════════════════╝ + ╔═══════════════════════════════════════════════╗ + ║ ║ + ║ 🚢 Xpeditis API Server Running ║ + ║ ║ + ║ API: http://localhost:${port}/${apiPrefix} ║ + ║ Docs: ${swaggerStatus} ║ + ║ ║ + ╚═══════════════════════════════════════════════╝ `); } diff --git a/apps/frontend/app/about/page.tsx b/apps/frontend/app/about/page.tsx index caea7ca..b89b7c0 100644 --- a/apps/frontend/app/about/page.tsx +++ b/apps/frontend/app/about/page.tsx @@ -350,21 +350,30 @@ export default function AboutPage() {
- {/* Timeline line */} -
+ {/* Timeline vertical rail + animated fill */} +
+ +
{timeline.map((item, index) => (
-
-
+
+
{item.year}
@@ -372,9 +381,18 @@ export default function AboutPage() {

{item.description}

-
-
+ + {/* Animated center dot */} +
+
+
))} diff --git a/apps/frontend/app/contact/page.tsx b/apps/frontend/app/contact/page.tsx index e741ff0..440701e 100644 --- a/apps/frontend/app/contact/page.tsx +++ b/apps/frontend/app/contact/page.tsx @@ -13,8 +13,13 @@ import { Building2, CheckCircle2, Loader2, + Shield, + Zap, + BookOpen, + ArrowRight, } from 'lucide-react'; import { LandingHeader, LandingFooter } from '@/components/layout'; +import { sendContactForm } from '@/lib/api/auth'; export default function ContactPage() { const [formData, setFormData] = useState({ @@ -33,21 +38,36 @@ export default function ContactPage() { const heroRef = useRef(null); const formRef = useRef(null); const contactRef = useRef(null); + const afterSubmitRef = useRef(null); + const quickAccessRef = useRef(null); const isHeroInView = useInView(heroRef, { once: true }); const isFormInView = useInView(formRef, { once: true }); const isContactInView = useInView(contactRef, { once: true }); + const isAfterSubmitInView = useInView(afterSubmitRef, { once: true }); + const isQuickAccessInView = useInView(quickAccessRef, { once: true }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setIsSubmitting(true); - // Simulate form submission - await new Promise((resolve) => setTimeout(resolve, 1500)); - - setIsSubmitting(false); - setIsSubmitted(true); + try { + await sendContactForm({ + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + company: formData.company || undefined, + phone: formData.phone || undefined, + subject: formData.subject, + message: formData.message, + }); + setIsSubmitted(true); + } catch (err: any) { + setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer."); + } finally { + setIsSubmitting(false); + } }; const handleChange = ( @@ -65,7 +85,6 @@ export default function ContactPage() { title: 'Email', description: 'Envoyez-nous un email', value: 'contact@xpeditis.com', - link: 'mailto:contact@xpeditis.com', color: 'from-blue-500 to-cyan-500', }, { @@ -73,7 +92,6 @@ export default function ContactPage() { title: 'Téléphone', description: 'Appelez-nous', value: '+33 1 23 45 67 89', - link: 'tel:+33123456789', color: 'from-green-500 to-emerald-500', }, { @@ -81,15 +99,13 @@ export default function ContactPage() { title: 'Chat en direct', description: 'Discutez avec notre équipe', value: 'Disponible 24/7', - link: '#chat', color: 'from-purple-500 to-pink-500', }, { icon: Headphones, title: 'Support', - description: 'Centre d\'aide', - value: 'support.xpeditis.com', - link: 'https://support.xpeditis.com', + description: 'Support client', + value: 'support@xpeditis.com', color: 'from-orange-500 to-red-500', }, ]; @@ -103,22 +119,6 @@ export default function ContactPage() { email: 'paris@xpeditis.com', isHQ: true, }, - { - city: 'Rotterdam', - address: 'Wilhelminakade 123', - postalCode: '3072 AP Rotterdam, Netherlands', - phone: '+31 10 123 4567', - email: 'rotterdam@xpeditis.com', - isHQ: false, - }, - { - city: 'Hambourg', - address: 'Am Sandtorkai 50', - postalCode: '20457 Hamburg, Germany', - phone: '+49 40 123 4567', - email: 'hamburg@xpeditis.com', - isHQ: false, - }, ]; const subjects = [ @@ -219,22 +219,20 @@ export default function ContactPage() { {contactMethods.map((method, index) => { const IconComponent = method.icon; return ( -

{method.title}

{method.description}

{method.value}

-
+ ); })}
@@ -438,9 +436,9 @@ export default function ContactPage() { animate={isFormInView ? { opacity: 1, x: 0 } : {}} transition={{ duration: 0.8, delay: 0.2 }} > -

Nos bureaux

+

Notre bureau

- Retrouvez-nous dans nos bureaux à travers l'Europe ou contactez-nous par email. + Retrouvez-nous à Paris ou contactez-nous par email.

@@ -526,34 +524,154 @@ export default function ContactPage() {
- {/* Map Section */} -
+ {/* Section 1 : Ce qui se passe après l'envoi */} +
+
+ + {/* Decorative blobs */} +
+
+
+
+ +
+
+
+ +
+ + Après votre envoi + +
+

+ Que se passe-t-il après l'envoi de votre message ? +

+ +
+ {/* Notre engagement */} + +
+
+ +
+

Notre engagement

+
+

+ Dès réception de votre demande, un de nos experts logistiques analyse votre + profil et vos besoins. Vous recevrez une réponse personnalisée ou une invitation + pour une démonstration de la plateforme{' '} + + sous 48 heures ouvrées. + +

+
+ + {/* Sécurité */} + +
+
+ +
+

Sécurité

+
+

+ Vos informations sont protégées et traitées conformément à notre{' '} + + politique de confidentialité + + . Aucune donnée n'est partagée avec des tiers sans votre accord. +

+
+
+
+ +
+
+ + {/* Section 2 : Accès Rapide */} +
-

Notre présence en Europe

-

- Des bureaux stratégiquement situés pour mieux vous servir -

-
+
+ + Accès rapide + +

+ Besoin d'une réponse immédiate ? +

+
- -
-
- -

Carte interactive bientôt disponible

-
+
+ {/* Tarification instantanée */} + +
+ +
+

Tarification instantanée

+

+ N'attendez pas notre retour pour vos prix. Utilisez notre moteur{' '} + Click&Ship pour obtenir + une cotation de fret maritime en moins de 60 secondes. +

+ + Accéder au Dashboard + + +
+ + {/* Wiki Maritime */} + +
+ +
+

Aide rapide

+

+ Une question sur les Incoterms ou la documentation export ? Notre{' '} + Wiki Maritime contient déjà + les réponses aux questions les plus fréquentes. +

+ + Consulter le Wiki + + +
diff --git a/apps/frontend/app/dashboard/admin/bookings/page.tsx b/apps/frontend/app/dashboard/admin/bookings/page.tsx index 1bb2a1a..485aa40 100644 --- a/apps/frontend/app/dashboard/admin/bookings/page.tsx +++ b/apps/frontend/app/dashboard/admin/bookings/page.tsx @@ -1,413 +1,548 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { getAllBookings } from '@/lib/api/admin'; - -interface Booking { - id: string; - bookingNumber?: string; - bookingId?: string; - type?: string; - status: string; - // CSV bookings use these fields - origin?: string; - destination?: string; - carrierName?: string; - // Regular bookings use these fields - originPort?: { - code: string; - name: string; - }; - destinationPort?: { - code: string; - name: string; - }; - carrier?: string; - containerType: string; - quantity?: number; - price?: number; - primaryCurrency?: string; - totalPrice?: { - amount: number; - currency: string; - }; - createdAt?: string; - updatedAt?: string; - requestedAt?: string; - organizationId?: string; - userId?: string; -} - -export default function AdminBookingsPage() { - const [bookings, setBookings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedBooking, setSelectedBooking] = useState(null); - const [showDetailsModal, setShowDetailsModal] = useState(false); - const [filterStatus, setFilterStatus] = useState('all'); - const [searchTerm, setSearchTerm] = useState(''); - - // Helper function to get formatted quote number - const getQuoteNumber = (booking: Booking): string => { - if (booking.type === 'csv') { - return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; - } - return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`; - }; - - useEffect(() => { - fetchBookings(); - }, []); - - const fetchBookings = async () => { - try { - setLoading(true); - const response = await getAllBookings(); - setBookings(response.bookings || []); - setError(null); - } catch (err: any) { - setError(err.message || 'Failed to load bookings'); - } finally { - setLoading(false); - } - }; - - const getStatusColor = (status: string) => { - const colors: Record = { - draft: 'bg-gray-100 text-gray-800', - pending: 'bg-yellow-100 text-yellow-800', - confirmed: 'bg-blue-100 text-blue-800', - in_transit: 'bg-purple-100 text-purple-800', - delivered: 'bg-green-100 text-green-800', - cancelled: 'bg-red-100 text-red-800', - }; - return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; - }; - - const filteredBookings = bookings - .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) - .filter(booking => { - if (searchTerm === '') return true; - const searchLower = searchTerm.toLowerCase(); - const quoteNumber = getQuoteNumber(booking).toLowerCase(); - return ( - quoteNumber.includes(searchLower) || - booking.bookingNumber?.toLowerCase().includes(searchLower) || - booking.carrier?.toLowerCase().includes(searchLower) || - booking.carrierName?.toLowerCase().includes(searchLower) || - booking.origin?.toLowerCase().includes(searchLower) || - booking.destination?.toLowerCase().includes(searchLower) - ); - }); - - if (loading) { - return ( -
-
-
-

Loading bookings...

-
-
- ); - } - - return ( -
- {/* Header */} -
-
-

Booking Management

-

- View and manage all bookings across the platform -

-
-
- - {/* Filters */} -
-
-
- - setSearchTerm(e.target.value)} - className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
-
- - -
-
-
- - {/* Stats Cards */} -
-
-
Total Réservations
-
{bookings.length}
-
-
-
En Attente
-
- {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} -
-
-
-
Acceptées
-
- {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} -
-
-
-
Rejetées
-
- {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} -
-
-
- - {/* Error Message */} - {error && ( -
- {error} -
- )} - - {/* Bookings Table */} -
- - - - - - - - - - - - - - {filteredBookings.map(booking => ( - - - - - - - - - - ))} - -
- Numéro de devis - - Route - - Transporteur - - Conteneur - - Statut - - Prix - - Actions -
-
- {getQuoteNumber(booking)} -
-
- {new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()} -
-
-
- {booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`} -
-
- {booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''} -
-
- {booking.carrier || booking.carrierName || 'N/A'} - -
{booking.containerType}
-
- {booking.quantity ? `Qty: ${booking.quantity}` : ''} -
-
- - {booking.status} - - - {booking.totalPrice - ? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}` - : booking.price - ? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}` - : 'N/A' - } - - -
-
- - {/* Details Modal */} - {showDetailsModal && selectedBooking && ( -
-
-
-

Booking Details

- -
- -
-
-
- -
- {getQuoteNumber(selectedBooking)} -
-
-
- - - {selectedBooking.status} - -
-
- -
-

Route Information

-
-
- -
- {selectedBooking.originPort ? ( - <> -
{selectedBooking.originPort.code}
-
{selectedBooking.originPort.name}
- - ) : ( -
{selectedBooking.origin}
- )} -
-
-
- -
- {selectedBooking.destinationPort ? ( - <> -
{selectedBooking.destinationPort.code}
-
{selectedBooking.destinationPort.name}
- - ) : ( -
{selectedBooking.destination}
- )} -
-
-
-
- -
-

Shipping Details

-
-
- -
- {selectedBooking.carrier || selectedBooking.carrierName || 'N/A'} -
-
-
- -
{selectedBooking.containerType}
-
- {selectedBooking.quantity && ( -
- -
{selectedBooking.quantity}
-
- )} -
-
- -
-

Pricing

-
- {selectedBooking.totalPrice - ? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}` - : selectedBooking.price - ? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}` - : 'N/A' - } -
-
- -
-

Timeline

-
-
- -
- {new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()} -
-
- {selectedBooking.updatedAt && ( -
- -
{new Date(selectedBooking.updatedAt).toLocaleString()}
-
- )} -
-
-
- -
- -
-
-
- )} -
- ); -} +'use client'; + +import { useState, useEffect } from 'react'; +import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin'; + +interface Booking { + id: string; + bookingNumber?: string | null; + type?: string; + status: string; + origin?: string; + destination?: string; + carrierName?: string; + containerType: string; + volumeCBM?: number; + weightKG?: number; + palletCount?: number; + priceEUR?: number; + priceUSD?: number; + primaryCurrency?: string; + createdAt?: string; + requestedAt?: string; + updatedAt?: string; + organizationId?: string; + userId?: string; +} + +export default function AdminBookingsPage() { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterStatus, setFilterStatus] = useState('all'); + const [searchTerm, setSearchTerm] = useState(''); + const [validatingId, setValidatingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [openMenuId, setOpenMenuId] = useState(null); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + const [selectedBooking, setSelectedBooking] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + + useEffect(() => { + fetchBookings(); + }, []); + + const handleDeleteBooking = async (bookingId: string) => { + if (!window.confirm('Supprimer définitivement cette réservation ?')) return; + setDeletingId(bookingId); + try { + await deleteAdminBooking(bookingId); + setBookings(prev => prev.filter(b => b.id !== bookingId)); + } catch (err: any) { + setError(err.message || 'Erreur lors de la suppression'); + } finally { + setDeletingId(null); + } + }; + + const handleValidateTransfer = async (bookingId: string) => { + if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return; + setValidatingId(bookingId); + try { + await validateBankTransfer(bookingId); + await fetchBookings(); + } catch (err: any) { + setError(err.message || 'Erreur lors de la validation du virement'); + } finally { + setValidatingId(null); + } + }; + + const fetchBookings = async () => { + try { + setLoading(true); + const response = await getAllBookings(); + setBookings(response.bookings || []); + setError(null); + } catch (err: any) { + setError(err.message || 'Impossible de charger les réservations'); + } finally { + setLoading(false); + } + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + pending_payment: 'bg-orange-100 text-orange-800', + pending_bank_transfer: 'bg-amber-100 text-amber-900', + pending: 'bg-yellow-100 text-yellow-800', + accepted: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', + cancelled: 'bg-red-100 text-red-800', + }; + return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; + }; + + const getStatusLabel = (status: string) => { + const labels: Record = { + PENDING_PAYMENT: 'Paiement en attente', + PENDING_BANK_TRANSFER: 'Virement à valider', + PENDING: 'En attente transporteur', + ACCEPTED: 'Accepté', + REJECTED: 'Rejeté', + CANCELLED: 'Annulé', + }; + return labels[status.toUpperCase()] || status; + }; + + const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`; + + const filteredBookings = bookings + .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) + .filter(booking => { + if (searchTerm === '') return true; + const s = searchTerm.toLowerCase(); + return ( + booking.bookingNumber?.toLowerCase().includes(s) || + booking.id.toLowerCase().includes(s) || + booking.carrierName?.toLowerCase().includes(s) || + booking.origin?.toLowerCase().includes(s) || + booking.destination?.toLowerCase().includes(s) || + String(booking.palletCount || '').includes(s) || + String(booking.weightKG || '').includes(s) || + String(booking.volumeCBM || '').includes(s) || + booking.containerType?.toLowerCase().includes(s) + ); + }); + + if (loading) { + return ( +
+
+
+

Chargement des réservations...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Gestion des réservations

+

+ Toutes les réservations de la plateforme +

+
+ + {/* Stats Cards */} +
+
+
Total
+
{bookings.length}
+
+
+
Virements à valider
+
+ {bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length} +
+
+
+
En attente transporteur
+
+ {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} +
+
+
+
Acceptées
+
+ {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} +
+
+
+
Rejetées
+
+ {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} +
+
+
+ + {/* Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm" + /> +
+
+ + +
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Bookings Table */} +
+
+ + + + + + + + + + + + + + {filteredBookings.length === 0 ? ( + + + + ) : ( + filteredBookings.map(booking => ( + + {/* N° Booking */} + + + {/* Route */} + + + {/* Cargo */} + + + {/* Transporteur */} + + + {/* Statut */} + + + {/* Date */} + + + {/* Actions */} + + + )) + )} + +
+ N° Booking + + Route + + Cargo + + Transporteur + + Statut + + Date + + Actions +
+ Aucune réservation trouvée +
+ {booking.bookingNumber && ( +
{booking.bookingNumber}
+ )} +
{getShortId(booking)}
+
+
+ {booking.origin} → {booking.destination} +
+
+
+ {booking.containerType} + {booking.palletCount != null && ( + · {booking.palletCount} pal. + )} +
+
+ {booking.weightKG != null && {booking.weightKG.toLocaleString()} kg} + {booking.volumeCBM != null && {booking.volumeCBM} CBM} +
+
+ {booking.carrierName || '—'} + + + {getStatusLabel(booking.status)} + + + {new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')} + + +
+
+
+ {/* Actions Dropdown Menu */} + {openMenuId && menuPosition && ( + <> +
{ setOpenMenuId(null); setMenuPosition(null); }} + /> +
+
+ + {(() => { + const booking = bookings.find(b => b.id === openMenuId); + return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? ( + + ) : null; + })()} + +
+
+ + )} + + {/* Details Modal */} + {showDetailsModal && selectedBooking && ( +
+
+
+

Détails de la réservation

+ +
+ +
+
+
+ +
+ {selectedBooking.bookingNumber || getShortId(selectedBooking)} +
+
+
+ + + {getStatusLabel(selectedBooking.status)} + +
+
+ +
+

Route

+
+
+ +
{selectedBooking.origin || '—'}
+
+
+ +
{selectedBooking.destination || '—'}
+
+
+
+ +
+

Cargo & Transporteur

+
+
+ +
{selectedBooking.carrierName || '—'}
+
+
+ +
{selectedBooking.containerType}
+
+ {selectedBooking.palletCount != null && ( +
+ +
{selectedBooking.palletCount}
+
+ )} + {selectedBooking.weightKG != null && ( +
+ +
{selectedBooking.weightKG.toLocaleString()} kg
+
+ )} + {selectedBooking.volumeCBM != null && ( +
+ +
{selectedBooking.volumeCBM} CBM
+
+ )} +
+
+ + {(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && ( +
+

Prix

+
+ {selectedBooking.priceEUR != null && ( +
+ +
{selectedBooking.priceEUR.toLocaleString()} €
+
+ )} + {selectedBooking.priceUSD != null && ( +
+ +
{selectedBooking.priceUSD.toLocaleString()} $
+
+ )} +
+
+ )} + +
+

Dates

+
+
+ +
+ {new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')} +
+
+ {selectedBooking.updatedAt && ( +
+ +
{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}
+
+ )} +
+
+ + {selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && ( +
+ +
+ )} +
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/app/dashboard/admin/csv-rates/page.tsx b/apps/frontend/app/dashboard/admin/csv-rates/page.tsx index aeb9e42..3f385f2 100644 --- a/apps/frontend/app/dashboard/admin/csv-rates/page.tsx +++ b/apps/frontend/app/dashboard/admin/csv-rates/page.tsx @@ -81,20 +81,22 @@ export default function AdminCsvRatesPage() { {/* Configurations Table */} - -
- Configurations CSV actives - - Liste de toutes les compagnies avec fichiers CSV configurés - + +
+
+ Configurations CSV actives + + Liste de toutes les compagnies avec fichiers CSV configurés + +
+
-
{error && ( @@ -120,6 +122,7 @@ export default function AdminCsvRatesPage() { Taille Lignes Date d'upload + Email Actions @@ -142,6 +145,11 @@ export default function AdminCsvRatesPage() { {new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
+ +
+ {file.companyEmail ?? '—'} +
+
- + @@ -586,6 +611,60 @@ export default function AdminDocumentsPage() {
)}
+ {/* Actions Dropdown Menu */} + {openMenuId && menuPosition && ( + <> +
{ setOpenMenuId(null); setMenuPosition(null); }} + /> +
+
+ {(() => { + const [bookingId, documentId] = openMenuId.split('::'); + const doc = documents.find(d => d.bookingId === bookingId && d.id === documentId); + if (!doc) return null; + return ( + <> + + + + ); + })()} +
+
+ + )}
); } diff --git a/apps/frontend/app/dashboard/admin/logs/page.tsx b/apps/frontend/app/dashboard/admin/logs/page.tsx new file mode 100644 index 0000000..edf04ed --- /dev/null +++ b/apps/frontend/app/dashboard/admin/logs/page.tsx @@ -0,0 +1,548 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Download, + RefreshCw, + Filter, + Activity, + AlertTriangle, + Info, + Bug, + Server, +} from 'lucide-react'; + +const LOG_EXPORTER_URL = + process.env.NEXT_PUBLIC_LOG_EXPORTER_URL || 'http://localhost:3200'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface LogEntry { + timestamp: string; + service: string; + level: string; + context: string; + message: string; + reqId: string; + req_method: string; + req_url: string; + res_status: string; + response_time_ms: string; + error: string; +} + +interface LogsResponse { + total: number; + query: string; + range: { from: string; to: string }; + logs: LogEntry[]; +} + +interface Filters { + service: string; + level: string; + search: string; + startDate: string; + endDate: string; + limit: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const LEVEL_STYLES: Record = { + error: 'bg-red-100 text-red-700 border border-red-200', + fatal: 'bg-red-200 text-red-900 border border-red-300', + warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200', + info: 'bg-blue-100 text-blue-700 border border-blue-200', + debug: 'bg-gray-100 text-gray-600 border border-gray-200', + trace: 'bg-purple-100 text-purple-700 border border-purple-200', +}; + +const LEVEL_ROW_BG: Record = { + error: 'bg-red-50', + fatal: 'bg-red-100', + warn: 'bg-yellow-50', + info: '', + debug: '', + trace: '', +}; + +function LevelBadge({ level }: { level: string }) { + const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600'; + return ( + + {level} + + ); +} + +function StatCard({ + label, + value, + icon: Icon, + color, +}: { + label: string; + value: number | string; + icon: any; + color: string; +}) { + return ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function AdminLogsPage() { + const [logs, setLogs] = useState([]); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(false); + const [exportLoading, setExportLoading] = useState(false); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [expandedRow, setExpandedRow] = useState(null); + + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + const [filters, setFilters] = useState({ + service: 'all', + level: 'all', + search: '', + startDate: oneHourAgo.toISOString().slice(0, 16), + endDate: now.toISOString().slice(0, 16), + limit: '500', + }); + + // Load available services + useEffect(() => { + fetch(`${LOG_EXPORTER_URL}/api/logs/services`) + .then(r => r.json()) + .then(d => setServices(d.services || [])) + .catch(() => {}); + }, []); + + const buildQueryString = useCallback( + (fmt?: string) => { + const params = new URLSearchParams(); + if (filters.service !== 'all') params.set('service', filters.service); + if (filters.level !== 'all') params.set('level', filters.level); + if (filters.search) params.set('search', filters.search); + if (filters.startDate) params.set('start', new Date(filters.startDate).toISOString()); + if (filters.endDate) params.set('end', new Date(filters.endDate).toISOString()); + params.set('limit', filters.limit); + if (fmt) params.set('format', fmt); + return params.toString(); + }, + [filters], + ); + + const fetchLogs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString('json')}`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const data: LogsResponse = await res.json(); + setLogs(data.logs || []); + setTotal(data.total || 0); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [buildQueryString]); + + useEffect(() => { + fetchLogs(); + }, []); + + const handleExport = async (format: 'json' | 'csv') => { + setExportLoading(true); + try { + const res = await fetch( + `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString(format)}`, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err: any) { + setError(err.message); + } finally { + setExportLoading(false); + } + }; + + // Stats + const countByLevel = (level: string) => + logs.filter(l => l.level === level).length; + + const setFilter = (key: keyof Filters, value: string) => + setFilters(prev => ({ ...prev, [key]: value })); + + return ( +
+ {/* Header */} +
+
+

Logs système

+

+ Visualisation et export des logs applicatifs en temps réel +

+
+
+ +
+ +
+ + +
+
+
+
+ + {/* Stats */} +
+ + + + +
+ + {/* Filters */} +
+
+ +

Filtres

+
+
+ {/* Service */} +
+ + +
+ + {/* Level */} +
+ + +
+ + {/* Search */} +
+ + setFilter('search', e.target.value)} + onKeyDown={e => e.key === 'Enter' && fetchLogs()} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" + /> +
+ + {/* Start */} +
+ + setFilter('startDate', e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" + /> +
+ + {/* End */} +
+ + setFilter('endDate', e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" + /> +
+ + {/* Limit + Apply */} +
+ +
+ + +
+
+
+
+ + {/* Error */} + {error && ( +
+ + + Impossible de contacter le log-exporter : {error} +
+ + Vérifiez que le container log-exporter est démarré sur{' '} + {LOG_EXPORTER_URL} + +
+
+ )} + + {/* Table */} +
+
+
+ + + {loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`} + +
+ {!loading && logs.length > 0 && ( + + Cliquer sur une ligne pour les détails + + )} +
+ + {loading ? ( +
+
+
+ ) : logs.length === 0 && !error ? ( +
+ +

Aucun log trouvé pour ces filtres

+
+ ) : ( +
+ + + + + + + + + + + + + {logs.map((log, i) => ( + <> + setExpandedRow(expandedRow === i ? null : i)} + className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`} + > + + + + + + + + + {/* Expanded detail row */} + {expandedRow === i && ( + + + + )} + + ))} + +
+ Timestamp + + Service + + Niveau + + Contexte + + Message + + Req / Status +
+ {new Date(log.timestamp).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + + {log.service} + + + + + {log.context || '—'} + + + {log.error ? ( + {log.error} + ) : ( + log.message + )} + + + {log.req_method && ( + + {log.req_method}{' '} + {log.req_url}{' '} + {log.res_status && ( + + {log.res_status} + + )} + + )} +
+
+
+ Timestamp +

{log.timestamp}

+
+ {log.reqId && ( +
+ Request ID +

{log.reqId}

+
+ )} + {log.response_time_ms && ( +
+ Durée +

+ {log.response_time_ms} ms +

+
+ )} +
+ Message complet +
+                                {log.error
+                                  ? `[ERROR] ${log.error}\n\n${log.message}`
+                                  : log.message}
+                              
+
+
+
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/app/dashboard/admin/organizations/page.tsx b/apps/frontend/app/dashboard/admin/organizations/page.tsx index ddcabd7..485af68 100644 --- a/apps/frontend/app/dashboard/admin/organizations/page.tsx +++ b/apps/frontend/app/dashboard/admin/organizations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { getAllOrganizations } from '@/lib/api/admin'; +import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin'; import { createOrganization, updateOrganization } from '@/lib/api/organizations'; interface Organization { @@ -10,6 +10,9 @@ interface Organization { type: string; scac?: string; siren?: string; + siret?: string; + siretVerified?: boolean; + statusBadge?: string; eori?: string; contact_phone?: string; contact_email?: string; @@ -32,6 +35,7 @@ export default function AdminOrganizationsPage() { const [selectedOrg, setSelectedOrg] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); + const [verifyingId, setVerifyingId] = useState(null); // Form state const [formData, setFormData] = useState<{ @@ -39,6 +43,7 @@ export default function AdminOrganizationsPage() { type: string; scac: string; siren: string; + siret: string; eori: string; contact_phone: string; contact_email: string; @@ -55,6 +60,7 @@ export default function AdminOrganizationsPage() { type: 'FREIGHT_FORWARDER', scac: '', siren: '', + siret: '', eori: '', contact_phone: '', contact_email: '', @@ -130,6 +136,7 @@ export default function AdminOrganizationsPage() { type: 'FREIGHT_FORWARDER', scac: '', siren: '', + siret: '', eori: '', contact_phone: '', contact_email: '', @@ -144,6 +151,51 @@ export default function AdminOrganizationsPage() { }); }; + const handleVerifySiret = async (orgId: string) => { + try { + setVerifyingId(orgId); + const result = await verifySiret(orgId); + if (result.verified) { + alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`); + await fetchOrganizations(); + } else { + alert(result.message || 'SIRET invalide ou introuvable.'); + } + } catch (err: any) { + alert(err.message || 'Erreur lors de la verification du SIRET'); + } finally { + setVerifyingId(null); + } + }; + + const handleApproveSiret = async (orgId: string) => { + if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return; + try { + setVerifyingId(orgId); + const result = await approveSiret(orgId); + alert(result.message); + await fetchOrganizations(); + } catch (err: any) { + alert(err.message || 'Erreur lors de l\'approbation'); + } finally { + setVerifyingId(null); + } + }; + + const handleRejectSiret = async (orgId: string) => { + if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return; + try { + setVerifyingId(orgId); + const result = await rejectSiret(orgId); + alert(result.message); + await fetchOrganizations(); + } catch (err: any) { + alert(err.message || 'Erreur lors du refus'); + } finally { + setVerifyingId(null); + } + }; + const openEditModal = (org: Organization) => { setSelectedOrg(org); setFormData({ @@ -151,6 +203,7 @@ export default function AdminOrganizationsPage() { type: org.type, scac: org.scac || '', siren: org.siren || '', + siret: org.siret || '', eori: org.eori || '', contact_phone: org.contact_phone || '', contact_email: org.contact_email || '', @@ -229,6 +282,25 @@ export default function AdminOrganizationsPage() { SIREN: {org.siren}
)} +
+ SIRET: + {org.siret ? ( + <> + {org.siret} + {org.siretVerified ? ( + + Verifie + + ) : ( + + Non verifie + + )} + + ) : ( + Non renseigne + )} +
{org.contact_email && (
Email: {org.contact_email} @@ -239,13 +311,45 @@ export default function AdminOrganizationsPage() {
-
- +
+
+ + {org.siret && !org.siretVerified && ( + + )} +
+ {(org.siret || org.siren) && ( +
+ {!org.siretVerified ? ( + + ) : ( + + )} +
+ )}
))} @@ -309,6 +413,18 @@ export default function AdminOrganizationsPage() { />
+
+ + setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + placeholder="12345678901234" + /> +
+
(null); + const [loading, setLoading] = useState(true); + const [paying, setPaying] = useState(false); + const [declaring, setDeclaring] = useState(false); + const [error, setError] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(null); + const [copied, setCopied] = useState(null); + + useEffect(() => { + async function fetchBooking() { + try { + const data = await getCsvBooking(bookingId); + setBooking(data as any); + if (data.status !== 'PENDING_PAYMENT') { + router.replace('/dashboard/bookings'); + } + } catch (err) { + setError('Impossible de charger les détails du booking'); + } finally { + setLoading(false); + } + } + if (bookingId) fetchBooking(); + }, [bookingId, router]); + + const handlePayByCard = async () => { + setPaying(true); + setError(null); + try { + const result = await payBookingCommission(bookingId); + window.location.href = result.sessionUrl; + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors de la création du paiement'); + setPaying(false); + } + }; + + const handleDeclareTransfer = async () => { + setDeclaring(true); + setError(null); + try { + await declareBankTransfer(bookingId); + router.push('/dashboard/bookings?transfer=declared'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement'); + setDeclaring(false); + } + }; + + const copyToClipboard = (value: string, key: string) => { + navigator.clipboard.writeText(value); + setCopied(key); + setTimeout(() => setCopied(null), 2000); + }; + + const formatPrice = (price: number, currency: string) => + new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price); + + if (loading) { + return ( +
+
+ + Chargement... +
+
+ ); + } + + if (error && !booking) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + if (!booking) return null; + + const commissionAmount = booking.commissionAmountEur || 0; + const commissionRate = booking.commissionRate || 0; + const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase(); + + return ( +
+
+ {/* Back button */} + + +

Paiement de la commission

+

+ Finalisez votre booking en réglant la commission de service +

+ + {error && ( +
+ +

{error}

+
+ )} + +
+ {/* LEFT — Payment method selector */} +
+

+ Choisir le mode de paiement +

+ + {/* Card option */} + + + {/* Transfer option */} + + + {/* Card action */} + {selectedMethod === 'card' && ( +
+

+ Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité. +

+ +
+ )} + + {/* Transfer action */} + {selectedMethod === 'transfer' && ( +
+

+ Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur + “J'ai effectué le virement”. +

+ + {/* Bank details */} +
+ {[ + { label: 'Bénéficiaire', value: BANK_DETAILS.beneficiary, key: 'beneficiary' }, + { label: 'IBAN', value: BANK_DETAILS.iban, key: 'iban', mono: true }, + { label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true }, + { + label: 'Montant', + value: formatPrice(commissionAmount, 'EUR'), + key: 'amount', + bold: true, + }, + { label: 'Référence', value: reference, key: 'ref', mono: true }, + ].map(({ label, value, key, mono, bold }) => ( +
+ {label} +
+ + {value} + + {key !== 'amount' && ( + + )} +
+
+ ))} +
+ +
+ + + Mentionnez impérativement la référence {reference} dans le + libellé du virement. + +
+ + +
+ )} + + {/* Placeholder when no method selected */} + {selectedMethod === null && ( +
+ Sélectionnez un mode de paiement ci-dessus +
+ )} +
+ + {/* RIGHT — Booking summary */} +
+

+ Récapitulatif +

+ +
+ {booking.bookingNumber && ( +
+ Numéro + {booking.bookingNumber} +
+ )} +
+ Transporteur + + {booking.carrierName} + +
+
+ Trajet + + {booking.origin} → {booking.destination} + +
+
+ Volume / Poids + + {booking.volumeCBM} CBM · {booking.weightKG} kg + +
+
+ Transit + {booking.transitDays} jours +
+
+ Prix transport + + {formatPrice(booking.priceEUR, 'EUR')} + +
+
+ + {/* Commission box */} +
+

+ Commission ({commissionRate}% du prix transport) +

+

{formatPrice(commissionAmount, 'EUR')}

+

+ {formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}% +

+
+ +
+ +

+ Après validation du paiement, votre demande est envoyée au transporteur ( + {booking.carrierEmail}). Vous serez notifié de sa réponse. +

+
+
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/booking/[id]/payment-success/page.tsx b/apps/frontend/app/dashboard/booking/[id]/payment-success/page.tsx new file mode 100644 index 0000000..0865510 --- /dev/null +++ b/apps/frontend/app/dashboard/booking/[id]/payment-success/page.tsx @@ -0,0 +1,147 @@ +/** + * Payment Success Page + * + * Displayed after successful Stripe payment. Confirms the payment and activates the booking. + */ + +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter, useParams, useSearchParams } from 'next/navigation'; +import { CheckCircle, Loader2, AlertTriangle, Mail, ArrowRight } from 'lucide-react'; +import { confirmBookingPayment } from '@/lib/api/bookings'; + +export default function PaymentSuccessPage() { + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const bookingId = params.id as string; + const sessionId = searchParams.get('session_id'); + + const [status, setStatus] = useState<'confirming' | 'success' | 'error'>('confirming'); + const [error, setError] = useState(null); + const confirmedRef = useRef(false); + + useEffect(() => { + async function confirm() { + if (!sessionId || !bookingId || confirmedRef.current) return; + confirmedRef.current = true; + + try { + await confirmBookingPayment(bookingId, sessionId); + setStatus('success'); + } catch (err) { + console.error('Payment confirmation error:', err); + setError( + err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement' + ); + setStatus('error'); + } + } + + confirm(); + }, [bookingId, sessionId]); + + if (!sessionId) { + return ( +
+
+ +

Session invalide

+

Aucune session de paiement trouvee.

+ +
+
+ ); + } + + return ( +
+
+ {status === 'confirming' && ( + <> + +

Confirmation du paiement...

+

+ Veuillez patienter pendant que nous verifions votre paiement et activons votre booking. +

+ + )} + + {status === 'success' && ( + <> +
+
+ +
+
+

Paiement confirme !

+

+ Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking. +

+ +
+
+ + + Email envoye au transporteur + +
+

+ Vous recevrez une notification des que le transporteur repond (sous 7 jours max) +

+
+ + + + )} + + {status === 'error' && ( + <> + +

Erreur de confirmation

+

{error}

+

+ Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement. +

+
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/frontend/app/dashboard/booking/new/page.tsx b/apps/frontend/app/dashboard/booking/new/page.tsx index ebff420..8254afb 100644 --- a/apps/frontend/app/dashboard/booking/new/page.tsx +++ b/apps/frontend/app/dashboard/booking/new/page.tsx @@ -177,8 +177,8 @@ function NewBookingPageContent() { // Send to API using client function const result = await createCsvBooking(formDataToSend); - // Redirect to success page - router.push(`/dashboard/bookings?success=true&id=${result.id}`); + // Redirect to commission payment page + router.push(`/dashboard/booking/${result.id}/pay`); } catch (err) { console.error('Booking creation error:', err); setError(err instanceof Error ? err.message : 'Une erreur est survenue'); diff --git a/apps/frontend/app/dashboard/bookings/page.tsx b/apps/frontend/app/dashboard/bookings/page.tsx index 89d04bf..2f31216 100644 --- a/apps/frontend/app/dashboard/bookings/page.tsx +++ b/apps/frontend/app/dashboard/bookings/page.tsx @@ -6,22 +6,31 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { listBookings, listCsvBookings } from '@/lib/api'; import Link from 'next/link'; -import { Plus } from 'lucide-react'; +import { Plus, Clock } from 'lucide-react'; import ExportButton from '@/components/ExportButton'; +import { useSearchParams } from 'next/navigation'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; export default function BookingsListPage() { + const searchParams = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [searchType, setSearchType] = useState('route'); const [statusFilter, setStatusFilter] = useState(''); const [page, setPage] = useState(1); + const [showTransferBanner, setShowTransferBanner] = useState(false); const ITEMS_PER_PAGE = 20; + useEffect(() => { + if (searchParams.get('transfer') === 'declared') { + setShowTransferBanner(true); + } + }, [searchParams]); + // Fetch CSV bookings (fetch all for client-side filtering and pagination) const { data: csvData, isLoading, error: csvError } = useQuery({ queryKey: ['csv-bookings'], @@ -142,6 +151,21 @@ export default function BookingsListPage() { return (
+ {/* Bank transfer declared banner */} + {showTransferBanner && ( +
+
+ +
+

Virement déclaré

+

+ Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation. +

+
+
+ +
+ )} {/* Header */}
diff --git a/apps/frontend/app/dashboard/docs/page.tsx b/apps/frontend/app/dashboard/docs/page.tsx new file mode 100644 index 0000000..467006d --- /dev/null +++ b/apps/frontend/app/dashboard/docs/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { DocsPageContent } from '@/components/docs/DocsPageContent'; + +export default function DocsPage() { + return ; +} diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index b57a3c3..1510854 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -8,8 +8,8 @@ import { useAuth } from '@/lib/context/auth-context'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useState, useEffect } from 'react'; import NotificationDropdown from '@/components/NotificationDropdown'; import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown'; import Image from 'next/image'; @@ -22,23 +22,49 @@ import { Building2, Users, LogOut, + Lock, + Key, } from 'lucide-react'; +import { useSubscription } from '@/lib/context/subscription-context'; +import StatusBadge from '@/components/ui/StatusBadge'; +import type { PlanFeature } from '@/lib/api/subscriptions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { - const { user, logout } = useAuth(); + const { user, logout, loading, isAuthenticated } = useAuth(); + const { hasFeature, subscription } = useSubscription(); const pathname = usePathname(); + const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useState(false); - const navigation = [ - { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 }, + useEffect(() => { + if (!loading && !isAuthenticated) { + router.replace(`/login?redirect=${encodeURIComponent(pathname)}`); + } + }, [loading, isAuthenticated, router, pathname]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [ + { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' }, { name: 'Réservations', href: '/dashboard/bookings', icon: Package }, { name: 'Documents', href: '/dashboard/documents', icon: FileText }, - { name: 'Suivi', href: '/dashboard/track-trace', icon: Search }, - { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen }, + { name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' }, + { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' }, { name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 }, + { name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature }, // ADMIN and MANAGER only navigation items ...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ - { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users }, + { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature }, ] : []), ]; @@ -95,20 +121,26 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {/* Navigation */}
-

- {user?.firstName} {user?.lastName} -

+
+

+ {user?.firstName} {user?.lastName} +

+ {subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && ( + + )} +

{user?.email}

diff --git a/apps/frontend/app/dashboard/page.tsx b/apps/frontend/app/dashboard/page.tsx index 9804c26..29deaad 100644 --- a/apps/frontend/app/dashboard/page.tsx +++ b/apps/frontend/app/dashboard/page.tsx @@ -5,12 +5,14 @@ 'use client'; +import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { dashboardApi } from '@/lib/api'; import Link from 'next/link'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { useRouter } from 'next/navigation'; import { Package, PackageCheck, @@ -21,6 +23,7 @@ import { Plus, ArrowRight, } from 'lucide-react'; +import { useSubscription } from '@/lib/context/subscription-context'; import ExportButton from '@/components/ExportButton'; import { PieChart, @@ -39,6 +42,16 @@ import { } from 'recharts'; export default function DashboardPage() { + const router = useRouter(); + const { hasFeature, loading: subLoading } = useSubscription(); + + // Redirect Bronze users (no dashboard feature) to bookings + useEffect(() => { + if (!subLoading && !hasFeature('dashboard')) { + router.replace('/dashboard/bookings'); + } + }, [subLoading, hasFeature, router]); + // Fetch CSV booking KPIs const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({ queryKey: ['dashboard', 'csv-booking-kpis'], diff --git a/apps/frontend/app/dashboard/settings/api-keys/page.tsx b/apps/frontend/app/dashboard/settings/api-keys/page.tsx new file mode 100644 index 0000000..bfa95ca --- /dev/null +++ b/apps/frontend/app/dashboard/settings/api-keys/page.tsx @@ -0,0 +1,489 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys'; +import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys'; +import { useSubscription } from '@/lib/context/subscription-context'; +import { + Key, + Plus, + Trash2, + Copy, + Check, + AlertTriangle, + Clock, + X, + ShieldCheck, + Lock, +} from 'lucide-react'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(iso)); +} + +function keyStatusBadge(key: ApiKeyDto) { + if (!key.isActive) { + return ( + + Révoquée + + ); + } + if (key.expiresAt && new Date(key.expiresAt) < new Date()) { + return ( + + + Expirée + + ); + } + return ( + + Active + + ); +} + +// ─── Copy button ───────────────────────────────────────────────────────────── + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( + + ); +} + +// ─── Creation success modal ────────────────────────────────────────────────── + +function CreatedKeyModal({ + result, + onClose, +}: { + result: CreateApiKeyResultDto; + onClose: () => void; +}) { + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Clé API créée

+

{result.name}

+
+
+ +
+ + {/* Warning */} +
+ +

+ Copiez cette clé maintenant. Elle ne sera plus jamais affichée après + la fermeture de cette fenêtre. +

+
+ + {/* Key */} +
+ +
+ + {result.fullKey} + + +
+

+ Stockez-la dans vos variables d'environnement ou un gestionnaire de secrets. +

+
+ + {/* Footer */} +
+ +
+
+
+ ); +} + +// ─── Create key form modal ─────────────────────────────────────────────────── + +function CreateKeyModal({ + onSuccess, + onClose, +}: { + onSuccess: (result: CreateApiKeyResultDto) => void; + onClose: () => void; +}) { + const [name, setName] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: createApiKey, + onSuccess: result => { + queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + onSuccess(result); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate({ + name: name.trim(), + ...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}), + }); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

Nouvelle clé API

+
+ +
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="ex: Intégration ERP Production" + maxLength={100} + required + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent" + /> +

{name.length}/100 caractères

+
+ + {/* Expiry */} +
+ + setExpiresAt(e.target.value)} + min={new Date().toISOString().split('T')[0]} + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent" + /> +

+ Si vide, la clé n'expire jamais. +

+
+ + {/* Error */} + {mutation.isError && ( +
+ + Une erreur est survenue. Veuillez réessayer. +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +} + +// ─── Revoke confirm modal ──────────────────────────────────────────────────── + +function RevokeConfirmModal({ + apiKey, + onConfirm, + onClose, +}: { + apiKey: ApiKeyDto; + onConfirm: () => void; + onClose: () => void; +}) { + return ( +
+
+
+
+ +
+

+ Révoquer cette clé ? +

+

+ {apiKey.name} +

+

+ Cette action est immédiate et irréversible. Toute requête utilisant + cette clé sera refusée. +

+
+
+ + +
+
+
+ ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function ApiKeysPage() { + const { hasFeature } = useSubscription(); + const queryClient = useQueryClient(); + const hasApiAccess = hasFeature('api_access'); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [createdKey, setCreatedKey] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + + const { data: apiKeys, isLoading } = useQuery({ + queryKey: ['api-keys'], + queryFn: listApiKeys, + enabled: hasApiAccess, + }); + + const revokeMutation = useMutation({ + mutationFn: revokeApiKey, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + setRevokeTarget(null); + }, + }); + + // Plan upsell screen + if (!hasApiAccess) { + return ( +
+
+ +
+

Accès API

+

+ L'accès programmatique à l'API Xpeditis est disponible sur les plans{' '} + Gold et Platinium uniquement. +

+ + Voir les plans + +
+ ); + } + + const activeKeys = apiKeys?.filter(k => k.isActive) ?? []; + + return ( + <> + {/* Modals */} + {showCreateModal && ( + { + setShowCreateModal(false); + setCreatedKey(result); + }} + onClose={() => setShowCreateModal(false)} + /> + )} + {createdKey && ( + setCreatedKey(null)} /> + )} + {revokeTarget && ( + revokeMutation.mutate(revokeTarget.id)} + onClose={() => setRevokeTarget(null)} + /> + )} + + {/* Page header */} +
+
+

Clés API

+

+ Gérez les clés d'accès programmatique à l'API Xpeditis. +

+
+ +
+ + {/* Info banner */} +
+ +
+

Comment utiliser vos clés API

+

+ Ajoutez l'en-tête{' '} + + X-API-Key: xped_live_... + {' '} + à chaque requête HTTP.{' '} + + Voir la documentation + +

+
+
+ + {/* Keys list */} +
+ {isLoading ? ( +
+
+
+ ) : !apiKeys || apiKeys.length === 0 ? ( +
+ +

Aucune clé API pour le moment.

+ +
+ ) : ( +
+ {/* Table header */} +
+ Nom / Préfixe + Dernière utilisation + Expiration + Statut + +
+ + {apiKeys.map(key => ( +
+ {/* Name + prefix */} +
+

{key.name}

+ {key.keyPrefix}… +
+ + {/* Last used */} + {formatDate(key.lastUsedAt)} + + {/* Expiry */} + {formatDate(key.expiresAt)} + + {/* Status */} +
{keyStatusBadge(key)}
+ + {/* Actions */} + +
+ ))} +
+ )} +
+ + {/* Quota */} + {apiKeys && apiKeys.length > 0 && ( +

+ {activeKeys.length} / 20 clés actives utilisées +

+ )} + + ); +} diff --git a/apps/frontend/app/dashboard/settings/users/page.tsx b/apps/frontend/app/dashboard/settings/users/page.tsx index cb742d4..2b10987 100644 --- a/apps/frontend/app/dashboard/settings/users/page.tsx +++ b/apps/frontend/app/dashboard/settings/users/page.tsx @@ -10,11 +10,63 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api'; -import { createInvitation } from '@/lib/api/invitations'; +import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations'; import { useAuth } from '@/lib/context/auth-context'; import Link from 'next/link'; import ExportButton from '@/components/ExportButton'; +const PAGE_SIZE = 5; + +function Pagination({ + page, + total, + onPage, +}: { + page: number; + total: number; + onPage: (p: number) => void; +}) { + const totalPages = Math.ceil(total / PAGE_SIZE); + if (totalPages <= 1) return null; + + return ( +
+

+ {Math.min((page - 1) * PAGE_SIZE + 1, total)}–{Math.min(page * PAGE_SIZE, total)} sur {total} +

+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( + + ))} + +
+
+ ); +} + export default function UsersManagementPage() { const router = useRouter(); const queryClient = useQueryClient(); @@ -22,6 +74,8 @@ export default function UsersManagementPage() { const [showInviteModal, setShowInviteModal] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + const [usersPage, setUsersPage] = useState(1); + const [invitationsPage, setInvitationsPage] = useState(1); const [inviteForm, setInviteForm] = useState({ email: '', firstName: '', @@ -36,44 +90,37 @@ export default function UsersManagementPage() { queryFn: () => listUsers(), }); - // Check license availability const { data: licenseStatus } = useQuery({ queryKey: ['canInvite'], queryFn: () => canInviteUser(), }); + const { data: pendingInvitations } = useQuery({ + queryKey: ['invitations'], + queryFn: () => listInvitations(), + }); + const inviteMutation = useMutation({ - mutationFn: (data: typeof inviteForm) => { - return createInvitation({ - email: data.email, - firstName: data.firstName, - lastName: data.lastName, - role: data.role, - }); - }, + mutationFn: (data: typeof inviteForm) => createInvitation(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] }); - setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.'); + queryClient.invalidateQueries({ queryKey: ['invitations'] }); + setSuccess("Invitation envoyée avec succès ! L'utilisateur recevra un email avec un lien d'inscription."); setShowInviteModal(false); - setInviteForm({ - email: '', - firstName: '', - lastName: '', - role: 'USER', - }); + setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' }); + setInvitationsPage(1); setTimeout(() => setSuccess(''), 5000); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation'); + setError(err.response?.data?.message || "Échec de l'envoi de l'invitation"); setTimeout(() => setError(''), 5000); }, }); const changeRoleMutation = useMutation({ - mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => { - return updateUser(id, { role }); - }, + mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => + updateUser(id, { role }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); setSuccess('Rôle mis à jour avec succès'); @@ -86,13 +133,12 @@ export default function UsersManagementPage() { }); const toggleActiveMutation = useMutation({ - mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => { - return updateUser(id, { isActive: !isActive }); - }, + mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => + updateUser(id, { isActive: !isActive }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] }); - setSuccess('Statut de l\'utilisateur mis à jour avec succès'); + setSuccess("Statut de l'utilisateur mis à jour avec succès"); setTimeout(() => setSuccess(''), 3000); }, onError: (err: any) => { @@ -110,19 +156,31 @@ export default function UsersManagementPage() { setTimeout(() => setSuccess(''), 3000); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur'); + setError(err.response?.data?.message || "Échec de la suppression de l'utilisateur"); + setTimeout(() => setError(''), 5000); + }, + }); + + const cancelInvitationMutation = useMutation({ + mutationFn: (id: string) => cancelInvitation(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['invitations'] }); + queryClient.invalidateQueries({ queryKey: ['canInvite'] }); + setSuccess('Invitation annulée avec succès'); + setTimeout(() => setSuccess(''), 3000); + }, + onError: (err: any) => { + setError(err.response?.data?.message || "Échec de l'annulation de l'invitation"); setTimeout(() => setError(''), 5000); }, }); - // Restrict access to ADMIN and MANAGER only useEffect(() => { if (currentUser && currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER') { router.push('/dashboard'); } }, [currentUser, router]); - // Don't render until we've checked permissions if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) { return (
@@ -134,7 +192,6 @@ export default function UsersManagementPage() { const handleInvite = (e: React.FormEvent) => { e.preventDefault(); setError(''); - inviteMutation.mutate(inviteForm); }; @@ -143,21 +200,23 @@ export default function UsersManagementPage() { }; const handleToggleActive = (userId: string, isActive: boolean) => { - if ( - window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`) - ) { + if (window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)) { toggleActiveMutation.mutate({ id: userId, isActive }); } }; const handleDelete = (userId: string) => { - if ( - window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.') - ) { + if (window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) { deleteMutation.mutate(userId); } }; + const handleCancelInvitation = (invId: string, name: string) => { + if (window.confirm(`Annuler l'invitation envoyée à ${name} ?`)) { + cancelInvitationMutation.mutate(invId); + } + }; + const getRoleBadgeColor = (role: string) => { const colors: Record = { ADMIN: 'bg-red-100 text-red-800', @@ -168,6 +227,12 @@ export default function UsersManagementPage() { return colors[role] || 'bg-gray-100 text-gray-800'; }; + const allUsers = users?.users || []; + const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE); + + const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed); + const pagedInvitations = allPending.slice((invitationsPage - 1) * PAGE_SIZE, invitationsPage * PAGE_SIZE); + return (
{/* License Warning */} @@ -186,10 +251,7 @@ export default function UsersManagementPage() { Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.

- + Mettre à niveau l'abonnement
@@ -210,10 +272,7 @@ export default function UsersManagementPage() { {licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
- + Gérer l'abonnement
@@ -228,21 +287,13 @@ export default function UsersManagementPage() {
{ - const labels: Record = { - ADMIN: 'Administrateur', - MANAGER: 'Manager', - USER: 'Utilisateur', - VIEWER: 'Lecteur', - }; - return labels[v] || v; - }}, + { key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) }, { key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' }, { key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' }, ]} @@ -281,152 +332,116 @@ export default function UsersManagementPage() { {/* Users Table */}
+
+

Utilisateurs

+ {allUsers.length > 0 && ( +

{allUsers.length} membre{allUsers.length !== 1 ? 's' : ''}

+ )} +
{isLoading ? (
Chargement des utilisateurs...
- ) : users?.users && users.users.length > 0 ? ( -
- - - - - - - - - - - - - {users.users.map(user => ( - - - - - - - + ) : pagedUsers.length > 0 ? ( + <> +
+
- Utilisateur - - Email - - Rôle - - Statut - - Date de création - - Actions -
-
-
- {user.firstName[0]} - {user.lastName[0]} -
-
-
- {user.firstName} {user.lastName} -
-
- {user.email} -
-
-
-
-
{user.email}
-
- - - - {user.isActive ? 'Actif' : 'Inactif'} - - - {new Date(user.createdAt).toLocaleDateString()} - - -
+ + + + + + + + - ))} - -
UtilisateurEmailRôleStatutDate de créationActions
-
+ + + {pagedUsers.map(user => ( + + +
+
+ {user.firstName[0]}{user.lastName[0]} +
+
+
{user.firstName} {user.lastName}
+
{user.email}
+
+
+ + +
{user.email}
+ + + + + + + {user.isActive ? 'Actif' : 'Inactif'} + + + + {new Date(user.createdAt).toLocaleDateString('fr-FR')} + + + + + + ))} + + +
+ { setUsersPage(p); setOpenMenuId(null); }} /> + ) : (
- - + +

Aucun utilisateur

Commencez par inviter un membre de l'équipe

{licenseStatus?.canInvite ? ( - ) : ( - + + - Upgrade to Invite + Mettre à niveau )}
@@ -434,30 +449,94 @@ export default function UsersManagementPage() { )}
+ {/* Pending Invitations */} + {allPending.length > 0 && ( +
+
+

Invitations en attente

+

+ Utilisateurs invités mais n'ayant pas encore créé leur compte — {allPending.length} invitation{allPending.length !== 1 ? 's' : ''} +

+
+
+ + + + + + + + + + + + + {pagedInvitations.map(inv => { + const isExpired = new Date(inv.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
UtilisateurEmailRôleExpire leStatutActions
+
+
+ {inv.firstName[0]}{inv.lastName[0]} +
+
+
{inv.firstName} {inv.lastName}
+
+
+
{inv.email} + + {inv.role} + + + {new Date(inv.expiresAt).toLocaleDateString('fr-FR')} + + + {isExpired ? 'Expirée' : 'En attente'} + + + +
+
+ +
+ )} + {/* Actions Menu Modal */} {openMenuId && menuPosition && ( <>
{ - setOpenMenuId(null); - setMenuPosition(null); - }} + onClick={() => { setOpenMenuId(null); setMenuPosition(null); }} />
-
- +
-
-
- {currentUser?.role !== 'ADMIN' && ( -

- Seuls les administrateurs peuvent attribuer le rôle ADMIN -

- )}
-
diff --git a/apps/frontend/app/docs/api/page.tsx b/apps/frontend/app/docs/api/page.tsx new file mode 100644 index 0000000..9223dc1 --- /dev/null +++ b/apps/frontend/app/docs/api/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { DocsPageContent } from '@/components/docs/DocsPageContent'; + +export default function PublicDocsPage() { + return ; +} diff --git a/apps/frontend/app/docs/layout.tsx b/apps/frontend/app/docs/layout.tsx new file mode 100644 index 0000000..8c61ae4 --- /dev/null +++ b/apps/frontend/app/docs/layout.tsx @@ -0,0 +1,16 @@ +import { LandingHeader } from '@/components/layout/LandingHeader'; +import { LandingFooter } from '@/components/layout/LandingFooter'; + +export const metadata = { + title: 'Documentation API — Xpeditis', + description: 'Documentation de l\'API Xpeditis pour intégrer le fret maritime dans vos applications.', +}; + +export default function DocsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + +
{children}
+ + ); +} diff --git a/apps/frontend/app/forgot-password/page.tsx b/apps/frontend/app/forgot-password/page.tsx index 479e187..0830aac 100644 --- a/apps/frontend/app/forgot-password/page.tsx +++ b/apps/frontend/app/forgot-password/page.tsx @@ -1,13 +1,9 @@ -/** - * Forgot Password Page - * - * Request password reset - */ - 'use client'; import { useState } from 'react'; import Link from 'next/link'; +import Image from 'next/image'; +import { forgotPassword } from '@/lib/api/auth'; export default function ForgotPasswordPage() { const [email, setEmail] = useState(''); @@ -21,97 +17,173 @@ export default function ForgotPasswordPage() { setLoading(true); try { - // TODO: Implement forgotPassword API endpoint - await new Promise(resolve => setTimeout(resolve, 1000)); + await forgotPassword(email); setSuccess(true); } catch (err: any) { - setError(err.response?.data?.message || 'Failed to send reset email. Please try again.'); + setError(err.message || 'Une erreur est survenue. Veuillez réessayer.'); } finally { setLoading(false); } }; - if (success) { - return ( -
-
-
-

Xpeditis

-

- Check your email -

-
- -
-
- We've sent a password reset link to {email}. Please check your inbox - and follow the instructions. -
-
- -
- - Back to sign in + return ( +
+ {/* Left Side - Form */} +
+
+ {/* Logo */} +
+ + Xpeditis
+ + {success ? ( + <> +
+
+ + + +
+

Email envoyé

+

+ Si un compte est associé à {email}, vous recevrez un email avec + les instructions pour réinitialiser votre mot de passe. +

+

+ Pensez à vérifier vos spams si vous ne recevez rien d'ici quelques minutes. +

+
+ + Retour à la connexion + + + ) : ( + <> + {/* Header */} +
+

Mot de passe oublié ?

+

+ Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe. +

+
+ + {/* Error Message */} + {error && ( +
+ + + +

{error}

+
+ )} + + {/* Form */} + +
+ + setEmail(e.target.value)} + className="input w-full" + placeholder="votre.email@entreprise.com" + autoComplete="email" + disabled={loading} + /> +
+ + + + +
+ + + + + Retour à la connexion + +
+ + )} + + {/* Footer Links */} +
+
+ + Contactez-nous + + + Confidentialité + + + Conditions + +
+
- ); - } - return ( -
-
-
-

Xpeditis

-

- Reset your password -

-

- Enter your email address and we'll send you a link to reset your password. -

-
- -
- {error && ( -
-
{error}
+ {/* Right Side - Brand */} +
+
+
+
+

Sécurité avant tout

+

+ La protection de votre compte est notre priorité. Réinitialisez votre mot de passe en toute sécurité. +

+
+
+
+ + + +
+
+

Lien sécurisé

+

Le lien expire après 1 heure pour votre sécurité

+
+
+
+
+ + + +
+
+

Email de confirmation

+

Vérifiez votre boîte de réception et vos spams

+
+
- )} - -
- - setEmail(e.target.value)} - className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" - placeholder="Email address" - />
- -
- -
- -
- - Back to sign in - -
- +
+
+ + + + + +
); diff --git a/apps/frontend/app/login/page.tsx b/apps/frontend/app/login/page.tsx index 17ed57d..11dc16d 100644 --- a/apps/frontend/app/login/page.tsx +++ b/apps/frontend/app/login/page.tsx @@ -8,9 +8,10 @@ 'use client'; -import { useState } from 'react'; +import { useState, Suspense } from 'react'; import Link from 'next/link'; import Image from 'next/image'; +import { useSearchParams } from 'next/navigation'; import { useAuth } from '@/lib/context/auth-context'; interface FieldErrors { @@ -73,8 +74,10 @@ function getErrorMessage(error: any): { message: string; field?: 'email' | 'pass }; } -export default function LoginPage() { +function LoginPageContent() { const { login } = useAuth(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get('redirect') || '/dashboard'; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [rememberMe, setRememberMe] = useState(false); @@ -126,7 +129,7 @@ export default function LoginPage() { setIsLoading(true); try { - await login(email, password); + await login(email, password, redirectTo, rememberMe); // Navigation is handled by the login function in auth context } catch (err: any) { const { message, field } = getErrorMessage(err); @@ -305,9 +308,6 @@ export default function LoginPage() { {/* Footer Links */}
- - Centre d'aide - Contactez-nous @@ -462,3 +462,11 @@ export default function LoginPage() {
); } + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index 7af29bf..f2b319f 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef } from 'react'; +import { useRef, useState, useEffect } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { motion, useInView, useScroll, useTransform } from 'framer-motion'; @@ -27,24 +27,65 @@ import { import { useAuth } from '@/lib/context/auth-context'; import { LandingHeader, LandingFooter } from '@/components/layout'; +function AnimatedCounter({ + end, + suffix = '', + prefix = '', + decimals = 0, + isActive, + duration = 2, +}: { + end: number; + suffix?: string; + prefix?: string; + decimals?: number; + isActive: boolean; + duration?: number; +}) { + const [count, setCount] = useState(0); + + useEffect(() => { + if (!isActive) return; + let startTime: number | undefined; + + const animate = (timestamp: number) => { + if (!startTime) startTime = timestamp; + const progress = Math.min((timestamp - startTime) / (duration * 1000), 1); + const eased = 1 - Math.pow(1 - progress, 3); + setCount(eased * end); + if (progress < 1) requestAnimationFrame(animate); + else setCount(end); + }; + + requestAnimationFrame(animate); + }, [end, duration, isActive]); + + const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString(); + return <>{prefix}{display}{suffix}; +} + export default function LandingPage() { const { user, isAuthenticated } = useAuth(); const heroRef = useRef(null); const featuresRef = useRef(null); const statsRef = useRef(null); - const toolsRef = useRef(null); + const pricingRef = useRef(null); const testimonialsRef = useRef(null); const ctaRef = useRef(null); + const howRef = useRef(null); const isHeroInView = useInView(heroRef, { once: true }); const isFeaturesInView = useInView(featuresRef, { once: true }); - const isStatsInView = useInView(statsRef, { once: true }); - const isToolsInView = useInView(toolsRef, { once: true }); + const isStatsInView = useInView(statsRef, { once: true, amount: 0.3 }); + const isPricingInView = useInView(pricingRef, { once: true }); const isTestimonialsInView = useInView(testimonialsRef, { once: true }); const isCtaInView = useInView(ctaRef, { once: true }); + const isHowInView = useInView(howRef, { once: true, amount: 0.2 }); + + const [billingYearly, setBillingYearly] = useState(false); const { scrollYProgress } = useScroll(); const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']); @@ -98,106 +139,130 @@ export default function LandingPage() { }, ]; - const tools = [ - { - icon: LayoutDashboard, - title: 'Tableau de bord', - description: 'Vue d\'ensemble de votre activité maritime', - link: '/dashboard', - }, - { - icon: Package, - title: 'Mes Réservations', - description: 'Gérez toutes vos réservations en un seul endroit', - link: '/dashboard/bookings', - }, - { - icon: FileText, - title: 'Documents', - description: 'Accédez à tous vos documents maritimes', - link: '/dashboard/documents', - }, - { - icon: Search, - title: 'Suivi des expéditions', - description: 'Suivez vos conteneurs en temps réel', - link: '/dashboard/track-trace', - }, - { - icon: BookOpen, - title: 'Wiki Maritime', - description: 'Base de connaissances du fret maritime', - link: '/dashboard/wiki', - }, - { - icon: Users, - title: 'Mon Profil', - description: 'Gérez vos informations personnelles', - link: '/dashboard/profile', - }, - ]; const stats = [ - { value: '50+', label: 'Compagnies Maritimes', icon: Ship }, - { value: '10K+', label: 'Ports Mondiaux', icon: Anchor }, - { value: '<2s', label: 'Temps de Réponse', icon: Zap }, - { value: '99.5%', label: 'Disponibilité', icon: CheckCircle2 }, + { end: 50, prefix: '', suffix: '+', decimals: 0, label: 'Compagnies Maritimes', icon: Ship }, + { end: 10, prefix: '', suffix: 'K+', decimals: 0, label: 'Ports Mondiaux', icon: Anchor }, + { end: 2, prefix: '<', suffix: 's', decimals: 0, label: 'Temps de Réponse', icon: Zap }, + { end: 99.5, prefix: '', suffix: '%', decimals: 1, label: 'Disponibilité', icon: CheckCircle2 }, ]; const pricingPlans = [ { - name: 'Starter', - price: 'Gratuit', - period: '', - description: 'Idéal pour découvrir la plateforme', + key: 'bronze', + name: 'Bronze', + badge: null, + monthlyPrice: 0, + yearlyPrice: 0, + yearlyMonthly: 0, + description: 'Pour découvrir la plateforme', + users: '1 utilisateur', + shipments: '12 expéditions / an', + commission: '5%', + support: 'Aucun support', features: [ - { text: 'Jusqu\'à 5 bookings/mois', included: true }, - { text: 'Track & Trace illimité', included: true }, - { text: 'Wiki maritime complet', included: true }, - { text: 'Dashboard basique', included: true }, - { text: 'Support par email', included: true }, - { text: 'Gestion des documents', included: false }, - { text: 'Notifications temps réel', included: false }, + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord', included: false }, + { text: 'Wiki maritime', included: false }, + { text: 'Gestion des utilisateurs', included: false }, + { text: 'Export CSV', included: false }, { text: 'Accès API', included: false }, + { text: 'KAM dédié', included: false }, ], cta: 'Commencer gratuitement', + ctaLink: '/register', highlighted: false, + accentColor: 'from-amber-600 to-yellow-500', + textAccent: 'text-amber-700', + badgeBg: 'bg-amber-100 text-amber-800', }, { - name: 'Professional', - price: '99€', - period: '/mois', + key: 'silver', + name: 'Silver', + badge: 'Populaire', + monthlyPrice: 249, + yearlyPrice: 2739, + yearlyMonthly: 228, description: 'Pour les transitaires en croissance', + users: 'Jusqu\'à 5 utilisateurs', + shipments: 'Expéditions illimitées', + commission: '3%', + support: 'Support par email', features: [ - { text: 'Bookings illimités', included: true }, - { text: 'Track & Trace illimité', included: true }, + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord avancé', included: true }, { text: 'Wiki maritime complet', included: true }, - { text: 'Dashboard avancé + KPIs', included: true }, - { text: 'Support prioritaire', included: true }, - { text: 'Gestion des documents', included: true }, - { text: 'Notifications temps réel', included: true }, + { text: 'Gestion des utilisateurs', included: true }, + { text: 'Export CSV', included: true }, { text: 'Accès API', included: false }, + { text: 'KAM dédié', included: false }, ], - cta: 'Essai gratuit 14 jours', + cta: 'Commencer', + ctaLink: '/register', highlighted: true, + accentColor: 'from-slate-400 to-slate-500', + textAccent: 'text-slate-600', + badgeBg: 'bg-slate-100 text-slate-700', }, { - name: 'Enterprise', - price: 'Sur mesure', - period: '', - description: 'Pour les grandes entreprises', + key: 'gold', + name: 'Gold', + badge: null, + monthlyPrice: 899, + yearlyPrice: 9889, + yearlyMonthly: 824, + description: 'Pour les équipes exigeantes', + users: 'Jusqu\'à 20 utilisateurs', + shipments: 'Expéditions illimitées', + commission: '2%', + support: 'Assistance commerciale directe', features: [ - { text: 'Tout Professionnel +', included: true }, + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord avancé', included: true }, + { text: 'Wiki maritime complet', included: true }, + { text: 'Gestion des utilisateurs', included: true }, + { text: 'Export CSV', included: true }, { text: 'Accès API complet', included: true }, - { text: 'Intégrations personnalisées', included: true }, - { text: 'Responsable de compte dédié', included: true }, - { text: 'SLA garanti 99.9%', included: true }, - { text: 'Formation sur site', included: true }, - { text: 'Multi-organisations', included: true }, - { text: 'Audit & conformité', included: true }, + { text: 'KAM dédié', included: false }, ], - cta: 'Contactez-nous', + cta: 'Commencer', + ctaLink: '/register', highlighted: false, + accentColor: 'from-yellow-400 to-amber-400', + textAccent: 'text-amber-600', + badgeBg: 'bg-yellow-50 text-amber-700', + }, + { + key: 'platinium', + name: 'Platinium', + badge: 'Sur mesure', + monthlyPrice: null, + yearlyPrice: null, + yearlyMonthly: null, + description: 'Pour les grandes entreprises', + users: 'Utilisateurs illimités', + shipments: 'Expéditions illimitées', + commission: '1%', + support: 'Key Account Manager dédié', + features: [ + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord avancé', included: true }, + { text: 'Wiki maritime complet', included: true }, + { text: 'Gestion des utilisateurs', included: true }, + { text: 'Export CSV', included: true }, + { text: 'Accès API complet', included: true }, + { text: 'KAM dédié + Interface personnalisée', included: true }, + ], + cta: 'Nous contacter', + ctaLink: '/contact', + highlighted: false, + accentColor: 'from-brand-navy to-brand-turquoise', + textAccent: 'text-brand-turquoise', + badgeBg: 'bg-brand-navy/10 text-brand-navy', }, ]; @@ -252,20 +317,31 @@ export default function LandingPage() { {/* Hero Section */}
- {/* Background Image */} + {/* Background Video */} - {/* Container background image */} -
+
- {/* Tools & Calculators Section */} -
-
- -

- Outils & Calculateurs -

-

- Des outils puissants pour optimiser vos opérations maritimes -

-
- - - {tools.map((tool, index) => { - const IconComponent = tool.icon; - return ( - - -
-
- -
-
-

- {tool.title} -

-

{tool.description}

-
- -
- -
- ); - })} -
-
-
{/* Partner Logos Section */}
@@ -578,74 +620,198 @@ export default function LandingPage() {
+ {/* Header */} + + Tarifs +

- Tarifs simples et transparents + Des plans adaptés à votre activité

- Choisissez le plan adapté à vos besoins. Évoluez à tout moment. + De l'accès découverte au partenariat sur mesure — évoluez à tout moment.

+ {/* Billing Toggle */} + + + Mensuel + + + + Annuel + + {billingYearly && ( + + 1 mois offert + + )} + + + {/* Plans Grid */} {pricingPlans.map((plan, index) => ( - {plan.highlighted && ( -
- - Populaire + {/* Top gradient bar */} +
+ + {/* Popular badge */} + {plan.badge && plan.key === 'silver' && ( +
+ + {plan.badge}
)} -
-

{plan.name}

-

{plan.description}

-
- {plan.price} - {plan.period} + {plan.badge && plan.key === 'platinium' && ( +
+ + {plan.badge} +
-
    - {plan.features.map((feature, featureIndex) => ( -
  • - {feature.included ? ( - + )} + +
    + {/* Plan name */} +
    +
    +
    + {plan.name} +
    +

    + {plan.description} +

    +
    + + {/* Price */} +
    + {plan.monthlyPrice === null ? ( +
    + + Sur mesure + +

    + Tarification personnalisée +

    +
    + ) : plan.monthlyPrice === 0 ? ( +
    + + Gratuit + +

    + Pour toujours +

    +
    + ) : ( +
    +
    + + {billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€ + + + /mois + +
    + {billingYearly ? ( +

    + Facturé {plan.yearlyPrice?.toLocaleString('fr-FR')}€/an +

    ) : ( - +

    + Économisez 1 mois avec l'annuel +

    )} - +
    + )} +
    + + {/* Key stats */} +
    +
    + + {plan.users} +
    +
    + + {plan.shipments} +
    +
    + + + Commission {plan.commission} + +
    +
    + + {/* Features */} +
      + {plan.features.map((feature, featureIndex) => ( +
    • + {feature.included ? ( + + ) : ( + + )} + {feature.text}
    • ))}
    + + {/* CTA */} {plan.cta} @@ -655,25 +821,32 @@ export default function LandingPage() { ))} + {/* Bottom note */} -

    - Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise. +

    + Sans engagement · Résiliable à tout moment

    -

    - Des questions ? Contactez notre équipe commerciale +

    + Des questions ?{' '} + + Contactez notre équipe commerciale +

{/* How It Works Section */} -
-
+
+
@@ -681,8 +854,7 @@ export default function LandingPage() {
@@ -692,60 +864,95 @@ export default function LandingPage() {

-
- {[ - { - step: '01', - title: 'Recherchez', - description: "Entrez vos ports de départ et d'arrivée", - icon: Search, - }, - { - step: '02', - title: 'Comparez', - description: 'Analysez les tarifs de 50+ compagnies', - icon: BarChart3, - }, - { - step: '03', - title: 'Réservez', - description: 'Confirmez votre réservation en un clic', - icon: CheckCircle2, - }, - { - step: '04', - title: 'Suivez', - description: 'Suivez votre envoi en temps réel', - icon: Container, - }, - ].map((step, index) => { - const IconComponent = step.icon; - return ( - -
-
- {step.step} + {/* Steps grid with animated connecting line */} +
+ {/* Animated progress line — desktop only */} +
+ +
+ +
+ {[ + { + step: '01', + title: 'Recherchez', + description: "Entrez vos ports de départ et d'arrivée", + icon: Search, + }, + { + step: '02', + title: 'Comparez', + description: 'Analysez les tarifs de 50+ compagnies', + icon: BarChart3, + }, + { + step: '03', + title: 'Réservez', + description: 'Confirmez votre réservation en un clic', + icon: CheckCircle2, + }, + { + step: '04', + title: 'Suivez', + description: 'Suivez votre envoi en temps réel', + icon: Container, + }, + ].map((step, index) => { + const IconComponent = step.icon; + return ( + + {/* Clean number circle — no icon overlay */} +
+
+ {step.step} +
-
- + {/* Icon badge — separate from the number */} +
+
+ +
- {index < 3 && ( -
- )} -
-

{step.title}

-

{step.description}

- - ); - })} +

{step.title}

+

{step.description}

+ + ); + })} +
+ + {/* CTA after last step */} + + + Essayer maintenant + + +

+ Inscription gratuite · Aucune carte bancaire requise +

+
@@ -831,6 +1038,8 @@ export default function LandingPage() { {isAuthenticated && user ? ( Accéder au tableau de bord @@ -840,6 +1049,8 @@ export default function LandingPage() { <> Créer un compte gratuit @@ -847,6 +1058,8 @@ export default function LandingPage() { Se connecter diff --git a/apps/frontend/app/pricing/page.tsx b/apps/frontend/app/pricing/page.tsx new file mode 100644 index 0000000..beef733 --- /dev/null +++ b/apps/frontend/app/pricing/page.tsx @@ -0,0 +1,307 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Check, X, ArrowRight, Shield } from 'lucide-react'; + +type BillingInterval = 'monthly' | 'yearly'; + +const PLANS = [ + { + name: 'Bronze', + key: 'BRONZE' as const, + monthlyPrice: 0, + yearlyPrice: 0, + description: 'Pour démarrer et tester la plateforme', + maxUsers: 1, + maxShipments: '12/an', + commission: '5%', + support: 'Aucun', + badge: null, + features: [ + { name: 'Recherche de tarifs', included: true }, + { name: 'Réservations', included: true }, + { name: 'Tableau de bord', included: false }, + { name: 'Wiki Maritime', included: false }, + { name: 'Gestion des utilisateurs', included: false }, + { name: 'Import CSV', included: false }, + { name: 'Accès API', included: false }, + { name: 'Interface personnalisée', included: false }, + { name: 'KAM dédié', included: false }, + ], + cta: 'Commencer gratuitement', + ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800', + popular: false, + }, + { + name: 'Silver', + key: 'SILVER' as const, + monthlyPrice: 249, + yearlyPrice: 2739, + description: 'Pour les équipes en croissance', + maxUsers: 5, + maxShipments: 'Illimitées', + commission: '3%', + support: 'Email', + badge: 'silver' as const, + features: [ + { name: 'Recherche de tarifs', included: true }, + { name: 'Réservations', included: true }, + { name: 'Tableau de bord', included: true }, + { name: 'Wiki Maritime', included: true }, + { name: 'Gestion des utilisateurs', included: true }, + { name: 'Import CSV', included: true }, + { name: 'Accès API', included: false }, + { name: 'Interface personnalisée', included: false }, + { name: 'KAM dédié', included: false }, + ], + cta: 'Choisir Silver', + ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90', + popular: true, + }, + { + name: 'Gold', + key: 'GOLD' as const, + monthlyPrice: 899, + yearlyPrice: 9889, + description: 'Pour les entreprises établies', + maxUsers: 20, + maxShipments: 'Illimitées', + commission: '2%', + support: 'Direct', + badge: 'gold' as const, + features: [ + { name: 'Recherche de tarifs', included: true }, + { name: 'Réservations', included: true }, + { name: 'Tableau de bord', included: true }, + { name: 'Wiki Maritime', included: true }, + { name: 'Gestion des utilisateurs', included: true }, + { name: 'Import CSV', included: true }, + { name: 'Accès API', included: true }, + { name: 'Interface personnalisée', included: false }, + { name: 'KAM dédié', included: false }, + ], + cta: 'Choisir Gold', + ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600', + popular: false, + }, + { + name: 'Platinium', + key: 'PLATINIUM' as const, + monthlyPrice: -1, + yearlyPrice: -1, + description: 'Solutions sur mesure', + maxUsers: 'Illimité', + maxShipments: 'Illimitées', + commission: '1%', + support: 'KAM dédié', + badge: 'platinium' as const, + features: [ + { name: 'Recherche de tarifs', included: true }, + { name: 'Réservations', included: true }, + { name: 'Tableau de bord', included: true }, + { name: 'Wiki Maritime', included: true }, + { name: 'Gestion des utilisateurs', included: true }, + { name: 'Import CSV', included: true }, + { name: 'Accès API', included: true }, + { name: 'Interface personnalisée', included: true }, + { name: 'KAM dédié', included: true }, + ], + cta: 'Nous contacter', + ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700', + popular: false, + }, +]; + +function formatPrice(amount: number): string { + return new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +export default function PricingPage() { + const [billing, setBilling] = useState('monthly'); + + return ( +
+ {/* Header */} +
+
+ + Xpeditis + +
+ + Connexion + + + Inscription + +
+
+
+ + {/* Hero */} +
+

+ Des tarifs simples et transparents +

+

+ Choisissez la formule adaptée à votre activité de transport maritime. + Commencez gratuitement, évoluez selon vos besoins. +

+ + {/* Billing toggle */} +
+ + Mensuel + + + + Annuel + + {billing === 'yearly' && ( + + -1 mois offert + + )} +
+
+ + {/* Plans grid */} +
+
+ {PLANS.map((plan) => ( +
+ {plan.popular && ( +
+ + Populaire + +
+ )} + + {/* Plan name & badge */} +
+

{plan.name}

+ {plan.badge && ( + + )} +
+ +

{plan.description}

+ + {/* Price */} +
+ {plan.monthlyPrice === -1 ? ( +

Sur devis

+ ) : plan.monthlyPrice === 0 ? ( +

Gratuit

+ ) : ( + <> +

+ {billing === 'monthly' + ? formatPrice(plan.monthlyPrice) + : formatPrice(Math.round(plan.yearlyPrice / 12))} + /mois +

+ {billing === 'yearly' && ( +

+ {formatPrice(plan.yearlyPrice)}/an (11 mois) +

+ )} + + )} +
+ + {/* Quick stats */} +
+
+ Utilisateurs + {plan.maxUsers} +
+
+ Expéditions + {plan.maxShipments} +
+
+ Commission + {plan.commission} +
+
+ Support + {plan.support} +
+
+ + {/* Features */} +
+ {plan.features.map((feature) => ( +
+ {feature.included ? ( + + ) : ( + + )} + + {feature.name} + +
+ ))} +
+ + {/* CTA */} + + {plan.cta} + + +
+ ))} +
+
+ + {/* Footer */} +
+

Tous les prix sont en euros HT. Facturation annuelle = 11 mois.

+
+
+ ); +} diff --git a/apps/frontend/app/register/page.tsx b/apps/frontend/app/register/page.tsx index 3e3da18..e00eebd 100644 --- a/apps/frontend/app/register/page.tsx +++ b/apps/frontend/app/register/page.tsx @@ -1,12 +1,6 @@ -/** - * Register Page - Xpeditis - * - * Modern registration page with split-screen design - */ - 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; @@ -14,19 +8,25 @@ import { register } from '@/lib/api'; import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; import type { OrganizationType } from '@/types/api'; -export default function RegisterPage() { +function RegisterPageContent() { const router = useRouter(); const searchParams = useSearchParams(); + // Step management + const [step, setStep] = useState<1 | 2>(1); + + // Step 1 — Personal info const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - // Organization fields + // Step 2 — Organization const [organizationName, setOrganizationName] = useState(''); const [organizationType, setOrganizationType] = useState('FREIGHT_FORWARDER'); + const [siren, setSiren] = useState(''); + const [siret, setSiret] = useState(''); const [street, setStreet] = useState(''); const [city, setCity] = useState(''); const [state, setState] = useState(''); @@ -36,12 +36,11 @@ export default function RegisterPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - // Invitation-related state + // Invitation state const [invitationToken, setInvitationToken] = useState(null); const [invitation, setInvitation] = useState(null); const [isVerifyingInvitation, setIsVerifyingInvitation] = useState(false); - // Verify invitation token on mount useEffect(() => { const token = searchParams.get('token'); if (token) { @@ -50,13 +49,12 @@ export default function RegisterPage() { .then(invitationData => { setInvitation(invitationData); setInvitationToken(token); - // Pre-fill user information from invitation setEmail(invitationData.email); setFirstName(invitationData.firstName); setLastName(invitationData.lastName); }) - .catch(err => { - setError('Le lien d\'invitation est invalide ou expiré.'); + .catch(() => { + setError("Le lien d'invitation est invalide ou expiré."); }) .finally(() => { setIsVerifyingInvitation(false); @@ -64,36 +62,58 @@ export default function RegisterPage() { } }, [searchParams]); - const handleSubmit = async (e: React.FormEvent) => { + // ---- Step 1 validation ---- + const validateStep1 = (): string | null => { + if (!firstName.trim() || firstName.trim().length < 2) return 'Le prénom doit contenir au moins 2 caractères'; + if (!lastName.trim() || lastName.trim().length < 2) return 'Le nom doit contenir au moins 2 caractères'; + if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "L'adresse email n'est pas valide"; + if (password.length < 12) return 'Le mot de passe doit contenir au moins 12 caractères'; + if (password !== confirmPassword) return 'Les mots de passe ne correspondent pas'; + return null; + }; + + const handleStep1 = (e: React.FormEvent) => { e.preventDefault(); setError(''); - - // Validate passwords match - if (password !== confirmPassword) { - setError('Les mots de passe ne correspondent pas'); + const err = validateStep1(); + if (err) { + setError(err); return; } + // If invitation — submit directly (no org step) + if (invitationToken) { + handleFinalSubmit(); + } else { + setStep(2); + } + }; - // Validate password length - if (password.length < 12) { - setError('Le mot de passe doit contenir au moins 12 caractères'); + // ---- Step 2 validation ---- + const validateStep2 = (): string | null => { + if (!organizationName.trim()) return "Le nom de l'organisation est requis"; + if (!/^[0-9]{9}$/.test(siren)) return 'Le numéro SIREN est requis (9 chiffres)'; + if (siret && !/^[0-9]{14}$/.test(siret)) return 'Le numéro SIRET doit contenir 14 chiffres'; + if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { + return "Tous les champs d'adresse sont requis"; + } + return null; + }; + + const handleStep2 = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + const err = validateStep2(); + if (err) { + setError(err); return; } + handleFinalSubmit(); + }; - // Validate organization fields only if NOT using invitation - if (!invitationToken) { - if (!organizationName.trim()) { - setError('Le nom de l\'organisation est requis'); - return; - } - - if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { - setError('Tous les champs d\'adresse sont requis'); - return; - } - } - + // ---- Final submit ---- + const handleFinalSubmit = async () => { setIsLoading(true); + setError(''); try { await register({ @@ -101,13 +121,14 @@ export default function RegisterPage() { password, firstName, lastName, - // If invitation token exists, use it; otherwise provide organization data ...(invitationToken ? { invitationToken } : { organization: { name: organizationName, type: organizationType, + siren, + siret: siret || undefined, street, city, state: state || undefined, @@ -119,18 +140,92 @@ export default function RegisterPage() { router.push('/dashboard'); } catch (err: any) { setError(err.message || 'Erreur lors de la création du compte'); + // On error at step 2, stay on step 2; at invitation (step 1), stay on step 1 } finally { setIsLoading(false); } }; + // ---- Right panel content ---- + const rightPanel = ( +
+
+
+
+

+ {invitation ? 'Rejoignez votre équipe' : 'Rejoignez des milliers d\'entreprises'} +

+

+ Simplifiez votre logistique maritime et gagnez du temps sur chaque expédition. +

+
+
+
+ + + +
+
+

Essai gratuit de 30 jours

+

Testez toutes les fonctionnalités sans engagement

+
+
+
+
+ + + +
+
+

Sécurité maximale

+

Vos données sont protégées et chiffrées

+
+
+
+
+ + + +
+
+

Support 24/7

+

Notre équipe est là pour vous accompagner

+
+
+
+
+
+
2k+
+
Entreprises
+
+
+
150+
+
Pays couverts
+
+
+
24/7
+
Support
+
+
+
+
+
+ + + + + +
+
+ ); + return (
{/* Left Side - Form */}
{/* Logo */} -
+
+ {/* Progress indicator (only for self-registration, 2 steps) */} + {!invitation && ( +
+
+
+
= 1 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400' + }`}> + {step > 1 ? ( + + + + ) : '1'} +
+ = 1 ? 'text-brand-navy' : 'text-neutral-400'}`}> + Votre compte + +
+
= 2 ? 'bg-brand-navy' : 'bg-neutral-200'}`} /> +
+
= 2 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400' + }`}> + 2 +
+ = 2 ? 'text-brand-navy' : 'text-neutral-400'}`}> + Votre organisation + +
+
+
+ )} + {/* Header */} -
-

- {invitation ? 'Accepter l\'invitation' : 'Créer un compte'} -

-

- {invitation - ? `Vous avez été invité à rejoindre une organisation` - : 'Commencez votre essai gratuit dès aujourd\'hui'} -

+
+ {isVerifyingInvitation ? ( +

Vérification de l'invitation...

+ ) : invitation ? ( + <> +

Accepter l'invitation

+
+

+ Invitation valide — créez votre mot de passe pour rejoindre l'organisation. +

+
+ + ) : step === 1 ? ( + <> +

Créer un compte

+

Commencez votre essai gratuit dès aujourd'hui

+ + ) : ( + <> +

Votre organisation

+

Renseignez les informations de votre entreprise

+ + )}
- {/* Verifying Invitation Loading */} - {isVerifyingInvitation && ( -
-

Vérification de l'invitation...

-
- )} - - {/* Success Message for Invitation */} - {invitation && !error && ( -
-

- Invitation valide ! Créez votre mot de passe pour rejoindre l'organisation. -

-
- )} - {/* Error Message */} {error && ( -
+
+ + +

{error}

)} - {/* Form */} -
- {/* First Name & Last Name */} -
+ {/* ---- STEP 1: Personal info ---- */} + {(step === 1 || invitation) && !isVerifyingInvitation && ( + +
+
+ + setFirstName(e.target.value)} + className="input w-full" + placeholder="Jean" + disabled={isLoading || !!invitation} + /> +
+
+ + setLastName(e.target.value)} + className="input w-full" + placeholder="Dupont" + disabled={isLoading || !!invitation} + /> +
+
+
- + setFirstName(e.target.value)} + value={email} + onChange={e => setEmail(e.target.value)} className="input w-full" - placeholder="Jean" + placeholder="jean.dupont@entreprise.com" + autoComplete="email" disabled={isLoading || !!invitation} />
+
- + setLastName(e.target.value)} + value={password} + onChange={e => setPassword(e.target.value)} className="input w-full" - placeholder="Dupont" - disabled={isLoading || !!invitation} + placeholder="••••••••••••" + autoComplete="new-password" + disabled={isLoading} + /> +

Au moins 12 caractères

+
+ +
+ + setConfirmPassword(e.target.value)} + className="input w-full" + placeholder="••••••••••••" + autoComplete="new-password" + disabled={isLoading} />
-
- {/* Email */} -
- - setEmail(e.target.value)} - className="input w-full" - placeholder="jean.dupont@entreprise.com" - autoComplete="email" - disabled={isLoading || !!invitation} - /> -
- - {/* Password */} -
- - setPassword(e.target.value)} - className="input w-full" - placeholder="••••••••••••" - autoComplete="new-password" +
+ className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed mt-2" + > + {isLoading + ? 'Création du compte...' + : invitation + ? 'Créer mon compte' + : 'Continuer'} + - {/* Confirm Password */} -
- - setConfirmPassword(e.target.value)} - className="input w-full" - placeholder="••••••••••••" - autoComplete="new-password" - disabled={isLoading} - /> -
+

+ En créant un compte, vous acceptez nos{' '} + Conditions d'utilisation{' '} + et notre{' '} + Politique de confidentialité +

+
+ )} - {/* Organization Section - Only show if NOT using invitation */} - {!invitation && ( -
-

Informations de votre organisation

- - {/* Organization Name */} -
- + {/* ---- STEP 2: Organization info ---- */} + {step === 2 && !invitation && ( +
+
+
- {/* Organization Type */} -
- +
+ setSiren(e.target.value.replace(/\D/g, '').slice(0, 9))} + className="input w-full" + placeholder="123456789" + maxLength={9} + disabled={isLoading} + /> +

9 chiffres

+
+
+ + setSiret(e.target.value.replace(/\D/g, '').slice(0, 14))} + className="input w-full" + placeholder="12345678900014" + maxLength={14} + disabled={isLoading} + /> +

14 chiffres

+
+
+ +
+
- {/* City & Postal Code */} -
+
- +
- +
- {/* State & Country */}
- + setCountry(e.target.value)} + onChange={e => setCountry(e.target.value.toUpperCase().slice(0, 2))} className="input w-full" placeholder="FR" maxLength={2} disabled={isLoading} /> +

Code ISO 2 lettres

+ +
+ +
- )} - {/* Submit Button */} - - - {/* Terms */} -

- En créant un compte, vous acceptez nos{' '} - - Conditions d'utilisation - {' '} - et notre{' '} - - Politique de confidentialité - -

- +

+ En créant un compte, vous acceptez nos{' '} + Conditions d'utilisation{' '} + et notre{' '} + Politique de confidentialité +

+ + )} {/* Sign In Link */}

Vous avez déjà un compte ?{' '} - - Se connecter - + Se connecter

{/* Footer Links */} -
+
- - Centre d'aide - - - Contactez-nous - - - Confidentialité - - - Conditions - + Contactez-nous + Confidentialité + Conditions
- {/* Right Side - Brand Features (same as login) */} -
-
-
-
-

- Rejoignez des milliers d'entreprises -

-

- Simplifiez votre logistique maritime et gagnez du temps sur chaque expédition. -

- -
-
-
- - - -
-
-

Essai gratuit de 30 jours

-

- Testez toutes les fonctionnalités sans engagement -

-
-
- -
-
- - - -
-
-

Sécurité maximale

-

- Vos données sont protégées et chiffrées -

-
-
- -
-
- - - -
-
-

Support 24/7

-

- Notre équipe est là pour vous accompagner -

-
-
-
- -
-
-
2k+
-
Entreprises
-
-
-
150+
-
Pays couverts
-
-
-
24/7
-
Support
-
-
-
-
- -
- - - - - -
-
+ {rightPanel}
); } + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/apps/frontend/app/reset-password/page.tsx b/apps/frontend/app/reset-password/page.tsx index 0308932..0811cfb 100644 --- a/apps/frontend/app/reset-password/page.tsx +++ b/apps/frontend/app/reset-password/page.tsx @@ -1,16 +1,12 @@ -/** - * Reset Password Page - * - * Reset password with token from email - */ - 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Link from 'next/link'; +import Image from 'next/image'; +import { resetPassword } from '@/lib/api/auth'; -export default function ResetPasswordPage() { +function ResetPasswordContent() { const searchParams = useSearchParams(); const router = useRouter(); const [token, setToken] = useState(''); @@ -19,13 +15,14 @@ export default function ResetPasswordPage() { const [success, setSuccess] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [tokenError, setTokenError] = useState(false); useEffect(() => { const tokenFromUrl = searchParams.get('token'); if (tokenFromUrl) { setToken(tokenFromUrl); } else { - setError('Invalid reset link. Please request a new password reset.'); + setTokenError(true); } }, [searchParams]); @@ -33,139 +30,218 @@ export default function ResetPasswordPage() { e.preventDefault(); setError(''); - // Validate passwords match if (password !== confirmPassword) { - setError('Passwords do not match'); + setError('Les mots de passe ne correspondent pas'); return; } - // Validate password length if (password.length < 12) { - setError('Password must be at least 12 characters long'); - return; - } - - if (!token) { - setError('Invalid reset token'); + setError('Le mot de passe doit contenir au moins 12 caractères'); return; } setLoading(true); try { - // TODO: Implement resetPassword API endpoint - await new Promise(resolve => setTimeout(resolve, 1000)); + await resetPassword(token, password); setSuccess(true); - setTimeout(() => { - router.push('/login'); - }, 3000); + setTimeout(() => router.push('/login'), 3000); } catch (err: any) { - setError( - err.response?.data?.message || 'Failed to reset password. The link may have expired.' - ); + setError(err.message || 'Le lien de réinitialisation est invalide ou expiré.'); } finally { setLoading(false); } }; - if (success) { - return ( -
-
-
-

Xpeditis

-

- Password reset successful -

-
- -
-
- Your password has been reset successfully. You will be redirected to the login page in - a few seconds... -
-
- -
- - Go to login now + return ( +
+ {/* Left Side - Form */} +
+
+ {/* Logo */} +
+ + Xpeditis
+ + {tokenError ? ( + <> +
+
+ + + +
+

Lien invalide

+

+ Ce lien de réinitialisation est invalide. Veuillez faire une nouvelle demande. +

+
+ + Demander un nouveau lien + + + ) : success ? ( + <> +
+
+ + + +
+

Mot de passe réinitialisé !

+

+ Votre mot de passe a été modifié avec succès. Vous allez être redirigé vers la page de connexion... +

+
+ + Se connecter maintenant + + + ) : ( + <> + {/* Header */} +
+

Nouveau mot de passe

+

+ Choisissez un nouveau mot de passe sécurisé pour votre compte. +

+
+ + {/* Error Message */} + {error && ( +
+ + + +

{error}

+
+ )} + + {/* Form */} +
+
+ + setPassword(e.target.value)} + className="input w-full" + placeholder="••••••••••••" + autoComplete="new-password" + disabled={loading} + /> +

Au moins 12 caractères

+
+ +
+ + setConfirmPassword(e.target.value)} + className="input w-full" + placeholder="••••••••••••" + autoComplete="new-password" + disabled={loading} + /> +
+ + +
+ +
+ + + + + Retour à la connexion + +
+ + )} + + {/* Footer Links */} +
+
+ + Contactez-nous + + + Confidentialité + + + Conditions + +
+
- ); - } - return ( -
-
-
-

Xpeditis

-

- Set new password -

-

Please enter your new password.

+ {/* Right Side - Brand */} +
+
+
+
+

Votre sécurité, notre priorité

+

+ Choisissez un mot de passe fort pour protéger votre compte et vos données. +

+
+ {[ + 'Au moins 12 caractères', + 'Mélangez lettres, chiffres et symboles', + 'Évitez les mots du dictionnaire', + 'N\'utilisez pas le même mot de passe ailleurs', + ].map((tip) => ( +
+ + + +

{tip}

+
+ ))} +
+
+
+
+ + + + +
- -
- {error && ( -
-
{error}
-
- )} - -
-
- - setPassword(e.target.value)} - className="mt-1 appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - /> -

Must be at least 12 characters long

-
- -
- - setConfirmPassword(e.target.value)} - className="mt-1 appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - /> -
-
- -
- -
- -
- - Back to sign in - -
-
); } + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/frontend/middleware.ts b/apps/frontend/middleware.ts index 62a59fd..5a7fba3 100644 --- a/apps/frontend/middleware.ts +++ b/apps/frontend/middleware.ts @@ -7,37 +7,48 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -const publicPaths = [ - '/', +// Exact-match public paths (no sub-path matching) +const exactPublicPaths = ['/']; + +// Prefix-match public paths (plus their sub-paths) +const prefixPublicPaths = [ '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', + '/about', + '/careers', + '/blog', + '/press', + '/contact', + '/carrier', + '/pricing', + '/docs', ]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Check if path is public - const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); + const isPublicPath = + exactPublicPaths.includes(pathname) || + prefixPublicPaths.some(path => pathname === path || pathname.startsWith(path + '/')); - // Get token from cookies or headers + // Get token from cookie (synced by client.ts setAuthTokens) const token = request.cookies.get('accessToken')?.value; // Redirect to login if accessing protected route without token if (!isPublicPath && !token) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - // Redirect to dashboard if accessing public auth pages while logged in - if (isPublicPath && token && pathname !== '/') { - return NextResponse.redirect(new URL('/dashboard', request.url)); + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('redirect', pathname); + return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { - matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], + // Exclude Next.js internals, API routes, and all public static assets + matcher: ['/((?!_next/static|_next/image|api|assets|favicon\\.ico|manifest\\.json|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|mp4|mp3|pdf|txt|xml|csv|json)$).*)'], }; diff --git a/apps/frontend/src/components/ExportButton.tsx b/apps/frontend/src/components/ExportButton.tsx index 2f313a5..e2e7f5d 100644 --- a/apps/frontend/src/components/ExportButton.tsx +++ b/apps/frontend/src/components/ExportButton.tsx @@ -7,7 +7,8 @@ 'use client'; import { useState, useRef, useEffect } from 'react'; -import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react'; +import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react'; +import { useSubscription } from '@/lib/context/subscription-context'; interface ExportButtonProps { data: T[]; @@ -26,6 +27,8 @@ export default function ExportButton>({ columns, disabled = false, }: ExportButtonProps) { + const { hasFeature } = useSubscription(); + const canExport = hasFeature('csv_export'); const [isOpen, setIsOpen] = useState(false); const [isExporting, setIsExporting] = useState(false); const dropdownRef = useRef(null); @@ -171,9 +174,12 @@ export default function ExportButton>({ return (
{/* Dropdown Menu */} - {isOpen && !isExporting && ( + {isOpen && !isExporting && canExport && (
+
+ {/* Code */} +
+
+          {code.trim()}
+        
+
+
+ ); +} diff --git a/apps/frontend/src/components/docs/DocsPageContent.tsx b/apps/frontend/src/components/docs/DocsPageContent.tsx new file mode 100644 index 0000000..9f77475 --- /dev/null +++ b/apps/frontend/src/components/docs/DocsPageContent.tsx @@ -0,0 +1,1190 @@ +'use client'; + +import { useState, Suspense } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { + Search, + ChevronRight, + ArrowRight, + Key, + Package, + TrendingUp, + Building2, + ShieldCheck, + AlertTriangle, + List, + Menu, + X, + CheckCircle2, + Clock, + Info, + Zap, + Home, + ChevronLeft, + Circle, +} from 'lucide-react'; +import { CodeBlock } from '@/components/docs/CodeBlock'; +import { DOC_SECTIONS, ALL_NAV_ITEMS } from '@/components/docs/docsNav'; + +// ─── Design tokens ──────────────────────────────────────────────────────────── +const BRAND = { + navy: '#10183A', + turquoise: '#34CCCD', + teal: '#0e9999', +}; + +// ─── Reusable primitives ────────────────────────────────────────────────────── + +function H1({ children }: { children: React.ReactNode }) { + return

{children}

; +} +function H2({ children }: { children: React.ReactNode }) { + return ( +

+ + {children} +

+ ); +} +function H3({ children }: { children: React.ReactNode }) { + return

{children}

; +} +function P({ children }: { children: React.ReactNode }) { + return

{children}

; +} +function Divider() { + return
; +} +function InlineCode({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Callout({ type = 'info', children }: { type?: 'info' | 'warning' | 'success'; children: React.ReactNode }) { + const styles = { + info: { border: 'border-blue-200', bg: 'bg-blue-50', text: 'text-blue-800', icon: Info, dot: 'bg-blue-400' }, + warning: { border: 'border-amber-200', bg: 'bg-amber-50', text: 'text-amber-800', icon: AlertTriangle, dot: 'bg-amber-400' }, + success: { border: 'border-emerald-200', bg: 'bg-emerald-50', text: 'text-emerald-800', icon: CheckCircle2, dot: 'bg-emerald-400' }, + }; + const s = styles[type]; + const Icon = s.icon; + return ( +
+ +
{children}
+
+ ); +} + +function HttpBadge({ method }: { method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT' }) { + const styles: Record = { + GET: 'bg-emerald-100 text-emerald-700 border-emerald-200', + POST: 'bg-blue-100 text-blue-700 border-blue-200', + PATCH: 'bg-amber-100 text-amber-700 border-amber-200', + PUT: 'bg-orange-100 text-orange-700 border-orange-200', + DELETE: 'bg-red-100 text-red-700 border-red-200', + }; + return ( + + {method} + + ); +} + +function EndpointRow({ method, path, desc }: { method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; path: string; desc: string }) { + return ( +
+ + {path} + {desc} +
+ ); +} + +function ParamTable({ headers, rows }: { headers: string[]; rows: (string | React.ReactNode)[][] }) { + return ( +
+ + + + {headers.map(h => ( + + ))} + + + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+ ); +} + +function SectionHeader({ title, description, badge }: { title: string; description: string; badge?: string }) { + return ( +
+ {badge && ( + + {badge} + + )} +

{title}

+

{description}

+
+ ); +} + +function RequiredBadge({ required }: { required: boolean }) { + return required + ? requis + : optionnel; +} + +function LabeledBlock({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +// ─── Section: Home ──────────────────────────────────────────────────────────── + +function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) { + const quickLinks = [ + { id: 'quickstart', icon: Zap, label: 'Démarrage rapide', desc: 'Première requête en 5 min', color: 'text-amber-500', bg: 'bg-amber-50', border: 'border-amber-100' }, + { id: 'authentication', icon: Key, label: 'Authentification', desc: 'Créer et gérer vos clés API', color: 'text-blue-500', bg: 'bg-blue-50', border: 'border-blue-100' }, + { id: 'bookings', icon: Package, label: 'Bookings', desc: 'Réservations de fret maritime', color: 'text-violet-500', bg: 'bg-violet-50', border: 'border-violet-100' }, + { id: 'rates', icon: TrendingUp,label: 'Tarifs', desc: 'Tarifs en temps réel', color: 'text-emerald-500',bg: 'bg-emerald-50',border: 'border-emerald-100' }, + { id: 'endpoints', icon: List, label: 'Référence complète', desc: 'Tous les endpoints', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' }, + { id: 'errors', icon: AlertTriangle, label: 'Erreurs', desc: 'Codes et gestion des erreurs', color: 'text-red-400', bg: 'bg-red-50', border: 'border-red-100' }, + ]; + + return ( +
+ {/* Hero */} +
+
+ {/* Grid pattern */} +
+
+
+
+ API v1.0 · Plans Gold & Platinium +
+

+ API Xpeditis +

+

+ Intégrez le fret maritime dans vos applications. Tarifs en temps réel, + gestion de bookings et suivi d'expéditions via une API REST simple. +

+ +
+ + + + Gérer mes clés API + +
+ + {/* Base URL */} +
+ BASE URL + https://api.xpeditis.com +
+
+
+ + {/* Quick links grid */} +
+

Explorer la documentation

+
+ {quickLinks.map(item => ( + + ))} +
+
+ + + + {/* How it works */} +

Fonctionnement

+
+ {[ + { step: '01', title: 'Authentification par clé API', desc: <>Passez votre clé dans l'en-tête X-API-Key de chaque requête. }, + { step: '02', title: 'Format JSON standard', desc: 'Toutes les réponses sont en JSON avec une enveloppe data/meta cohérente.' }, + { step: '03', title: 'Rate limiting transparent', desc: 'Les quotas sont exposés dans les en-têtes X-RateLimit-* de chaque réponse.' }, + ].map(item => ( +
+ {item.step} +
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+ ); +} + +// ─── Section: Quick Start ───────────────────────────────────────────────────── + +function QuickStartSection({ onNavigate }: { onNavigate: (id: string) => void }) { + return ( +
+ + + + L'accès API est réservé aux plans Gold et Platinium. + Accédez à Paramètres → Abonnement pour upgrader. + + + {/* Steps */} + {[ + { + n: 1, + title: 'Obtenir votre clé API', + content: ( + <> +

Dans le dashboard, allez dans Paramètres → Clés API, cliquez Créer une clé. La clé complète est affichée une seule fois — copiez-la immédiatement.

+ + + ), + }, + { + n: 2, + title: 'Envoyer votre première requête', + content: ( + <> +

Passez la clé dans l'en-tête X-API-Key :

+ + + + ), + }, + { + n: 3, + title: 'Lire la réponse', + content: ( + <> +

Toutes les réponses suivent le même format :

+ + + + + + + + ), + }, + ].map(step => ( +
+
+
{step.n}
+ {step.n < 3 &&
} +
+
+

{step.title}

+ {step.content} +
+
+ ))} + + +

Étapes suivantes

+
+ {[ + { id: 'authentication', label: 'Gérer vos clés API', desc: 'Créer, lister et révoquer' }, + { id: 'bookings', label: 'Créer un booking', desc: 'Réservez du fret maritime' }, + { id: 'rates', label: 'Rechercher des tarifs', desc: 'Comparez en temps réel' }, + { id: 'errors', label: 'Gestion des erreurs', desc: 'Tous les codes expliqués' }, + ].map(item => ( + + ))} +
+
+ ); +} + +// ─── Section: Authentication ────────────────────────────────────────────────── + +function AuthenticationSection() { + return ( +
+ + + + Vos clés sont confidentielles. Ne les exposez jamais dans du code public. + En cas de compromission, révoquez immédiatement depuis le dashboard. + + +

Format

+

Toutes les clés Xpeditis commencent par xped_live_ suivi de 64 caractères hexadécimaux :

+ + + +

Utilisation

+

Passez votre clé dans l'en-tête X-API-Key de chaque requête :

+ + + +

Exemples par langage

+ + + + + +

Gestion des clés

+

Ces endpoints nécessitent un token JWT (connexion via le dashboard), pas une clé API.

+ +
+ {[ + { method: 'POST' as const, path: '/api-keys', desc: 'Créer une nouvelle clé' }, + { method: 'GET' as const, path: '/api-keys', desc: 'Lister toutes les clés' }, + { method: 'DELETE' as const, path: '/api-keys/:id', desc: 'Révoquer une clé' }, + ].map((ep, i) => )} +
+ +

Créer une clé

+ + " \\ + -H "Content-Type: application/json" \\ + -d '{"name": "Intégration ERP", "expiresAt": "2027-01-01T00:00:00.000Z"}'`} + /> + + + + + + +

Sécurité & rotation

+ + + Effectuez une rotation tous les 90 jours : créez une nouvelle clé, migrez votre système, puis révoquez l'ancienne. + +
+ ); +} + +// ─── Section: Bookings ──────────────────────────────────────────────────────── + +function BookingsSection() { + return ( +
+ + +

Lister les bookings

+
+ +
+ + status, 'string', , 'draft | confirmed | in_transit | delivered | cancelled'], + [page, 'number', , 'Numéro de page (défaut : 1)'], + [limit, 'number', , 'Résultats par page (défaut : 20, max : 100)'], + [origin, 'string', , 'Code port UN/LOCODE (ex : FRLEH)'], + [destination, 'string', , 'Code port UN/LOCODE (ex : CNSHA)'], + ]} + /> + + + + + + +

Créer un booking

+
+ +
+ + + + + + +

Statuts d'un booking

+ draft, 'Créé, non confirmé'], + [pending_confirmation, 'En attente du transporteur'], + [confirmed, 'Confirmé par le transporteur'], + [in_transit, 'Expédition en cours'], + [delivered, 'Livraison confirmée'], + [cancelled, 'Annulé'], + ]} + /> +
+ ); +} + +// ─── Section: Rates ─────────────────────────────────────────────────────────── + +function RatesSection() { + return ( +
+ + +

Rechercher des tarifs

+
+ +
+ + origin, , 'Code port origine (UN/LOCODE, ex : FRLEH)'], + [destination, , 'Code port destination (ex : CNSHA)'], + [containerType, , '20GP · 40GP · 40HC · 45HC · 20FR · 40FR'], + [departureDate, , 'Date souhaitée (YYYY-MM-DD)'], + [sortBy, , 'price_asc · price_desc · transit_time'], + ]} + /> + + + + + + + Les tarifs sont mis en cache 15 minutes. Après expiration, une nouvelle requête est envoyée aux transporteurs en temps réel. + + + +

Codes de ports (UN/LOCODE)

+
+ +
+ + + +
+ ); +} + +// ─── Section: Organizations ─────────────────────────────────────────────────── + +function OrganizationsSection() { + return ( +
+ + +

Profil de l'organisation

+
+ +
+ + + + + + +

Membres

+
+ +
+ +
+ ); +} + +// ─── Section: Endpoints ─────────────────────────────────────────────────────── + +function EndpointsSection() { + const groups: { label: string; endpoints: { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; path: string; desc: string }[] }[] = [ + { + label: 'Bookings', + endpoints: [ + { method: 'GET', path: '/bookings', desc: 'Lister les bookings' }, + { method: 'POST', path: '/bookings', desc: 'Créer un booking' }, + { method: 'GET', path: '/bookings/:id', desc: 'Détail d\'un booking' }, + { method: 'PATCH', path: '/bookings/:id/status',desc: 'Mettre à jour le statut' }, + ], + }, + { + label: 'Tarifs', + endpoints: [ + { method: 'GET', path: '/rates/search', desc: 'Rechercher des tarifs' }, + { method: 'GET', path: '/rates/:id', desc: 'Détail d\'un tarif' }, + { method: 'GET', path: '/ports', desc: 'Lister les ports' }, + ], + }, + { + label: 'Organisation', + endpoints: [ + { method: 'GET', path: '/organizations/me', desc: 'Profil de l\'organisation' }, + { method: 'GET', path: '/users', desc: 'Membres de l\'organisation' }, + ], + }, + { + label: 'Clés API (JWT requis)', + endpoints: [ + { method: 'POST', path: '/api-keys', desc: 'Créer une clé API' }, + { method: 'GET', path: '/api-keys', desc: 'Lister les clés API' }, + { method: 'DELETE', path: '/api-keys/:id', desc: 'Révoquer une clé' }, + ], + }, + ]; + + return ( +
+ + + {groups.map(group => ( +
+

{group.label}

+
+ {group.endpoints.map((ep, i) => )} +
+
+ ))} + + +

Format de réponse standard

+ + + + + + +
+ ); +} + +// ─── Section: Errors ───────────────────────────────────────────────────────── + +function ErrorsSection() { + return ( +
+ + + + + +

Format des erreurs

+ + +

Exemples

+ + + + + + + + En cas de 429, respectez le délai retryAfter (en secondes) avec un backoff exponentiel. + +
+ ); +} + +// ─── Section: Rate Limiting ─────────────────────────────────────────────────── + +function RateLimitingSection() { + return ( +
+ + +

Limites par plan

+ + + Le rate limiting est calculé par utilisateur associé à la clé API. + + + +

En-têtes de réponse

+ X-RateLimit-Limit, 'Nombre maximum de requêtes par fenêtre'], + [X-RateLimit-Remaining, 'Requêtes restantes dans la fenêtre'], + [X-RateLimit-Reset, 'Timestamp UNIX de réinitialisation'], + ]} + /> + + +

Bonnes pratiques

+
+ {[ + { icon: Clock, title: 'Backoff exponentiel', desc: 'En cas de 429, attendez 1s, 2s, 4s, 8s… avant de réessayer.' }, + { icon: CheckCircle2, title: 'Mise en cache', desc: 'Cachez les tarifs côté client — ils sont valides 15 minutes.' }, + { icon: ShieldCheck, title: 'Une clé par service', desc: 'Clés séparées par service/env pour un suivi précis et une révocation ciblée.' }, + ].map((item, i) => ( +
+
+ +
+
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+ ); +} + +// ─── Section map ────────────────────────────────────────────────────────────── + +function SectionContent({ activeSection, onNavigate }: { activeSection: string; onNavigate: (id: string) => void }) { + switch (activeSection) { + case 'home': return ; + case 'quickstart': return ; + case 'authentication': return ; + case 'bookings': return ; + case 'rates': return ; + case 'organizations':return ; + case 'endpoints': return ; + case 'errors': return ; + case 'rate-limiting':return ; + default: return ; + } +} + +// ─── Prev / Next ────────────────────────────────────────────────────────────── + +function PrevNext({ activeSection, onNavigate }: { activeSection: string; onNavigate: (id: string) => void }) { + const idx = ALL_NAV_ITEMS.findIndex(i => i.id === activeSection); + const prev = idx > 0 ? ALL_NAV_ITEMS[idx - 1] : null; + const next = idx < ALL_NAV_ITEMS.length - 1 ? ALL_NAV_ITEMS[idx + 1] : null; + + if (!prev && !next) return null; + + return ( +
+ {prev ? ( + + ) :
} + + {next ? ( + + ) :
} +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export interface DocsPageContentProps { + basePath: string; + /** 'dashboard' = fixed height layout, 'public' = scrollable with sticky sidebar */ + variant?: 'dashboard' | 'public'; +} + +function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + const [activeSection, setActiveSection] = useState(searchParams.get('section') ?? 'home'); + const [searchQuery, setSearchQuery] = useState(''); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const navigate = (id: string) => { + setActiveSection(id); + router.replace(`${basePath}?section=${id}`, { scroll: false }); + setSidebarOpen(false); + if (variant === 'public') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const filteredSections = DOC_SECTIONS.map(section => ({ + ...section, + items: section.items.filter(item => + item.label.toLowerCase().includes(searchQuery.toLowerCase()) + ), + })).filter(s => s.items.length > 0); + + const stickyTop = variant === 'dashboard' ? 'top-16' : 'top-20'; + const sidebarHeight= variant === 'dashboard' ? 'calc(100vh - 64px)' : 'calc(100vh - 80px)'; + + // Sidebar inner content + const SidebarInner = () => ( +
+ {/* Brand + search */} +
+
+
+ X +
+ Xpeditis + v1.0 +
+ + {/* API status */} +
+ + Tous les services opérationnels +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#34CCCD]/30 focus:border-[#34CCCD] placeholder-gray-400 transition-all" + /> +
+
+ + {/* Navigation */} + + + {/* Footer CTA */} +
+ +
+ +
+ Gérer mes clés API + + +
+
+ ); + + // ── Dashboard variant: fixed-height layout ─────────────────────────────── + if (variant === 'dashboard') { + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + {/* Desktop sidebar */} +
+ +
+ + {/* Mobile drawer */} +
+ +
+ + {/* Content */} +
+
+ + + {ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label ?? 'Documentation'} + +
+
+ + + +
+
+
+ ); + } + + // ── Public variant: scrollable with sticky sidebar ─────────────────────── + return ( +
+ {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + {/* Mobile drawer */} +
+ +
+ + {/* Desktop sticky sidebar */} + + + {/* Main scrollable content */} +
+ {/* Mobile top bar */} +
+ + + {ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label ?? 'Documentation'} + + {sidebarOpen && ( + + )} +
+ +
+ + + +
+
+
+ ); +} + +// ─── Breadcrumb ─────────────────────────────────────────────────────────────── + +function Breadcrumb({ activeSection, onNavigate }: { activeSection: string; onNavigate: (id: string) => void }) { + if (activeSection === 'home') return null; + const current = ALL_NAV_ITEMS.find(i => i.id === activeSection); + return ( + + ); +} + +// ─── Public export ──────────────────────────────────────────────────────────── + +export function DocsPageContent(props: DocsPageContentProps) { + const fallbackStyle = props.variant === 'dashboard' + ? { height: 'calc(100vh - 64px)' } + : { minHeight: 'calc(100vh - 80px)' }; + + return ( + +
+
+ }> + +
+ ); +} diff --git a/apps/frontend/src/components/docs/docsNav.ts b/apps/frontend/src/components/docs/docsNav.ts new file mode 100644 index 0000000..22e34aa --- /dev/null +++ b/apps/frontend/src/components/docs/docsNav.ts @@ -0,0 +1,61 @@ +import { + Home, + Zap, + Key, + Package, + TrendingUp, + Building2, + List, + AlertTriangle, + ShieldCheck, + type LucideIcon, +} from 'lucide-react'; + +export interface NavItem { + id: string; + label: string; + icon: LucideIcon; +} + +export interface NavSection { + title: string; + items: NavItem[]; +} + +export const DOC_SECTIONS: NavSection[] = [ + { + title: 'Démarrage', + items: [ + { id: 'home', label: 'Vue d\'ensemble', icon: Home }, + { id: 'quickstart', label: 'Guide de démarrage', icon: Zap }, + ], + }, + { + title: 'Authentification', + items: [ + { id: 'authentication', label: 'Clés API', icon: Key }, + ], + }, + { + title: 'Ressources API', + items: [ + { id: 'bookings', label: 'Bookings', icon: Package }, + { id: 'rates', label: 'Tarifs', icon: TrendingUp }, + { id: 'organizations', label: 'Organisations', icon: Building2 }, + ], + }, + { + title: 'Référence', + items: [ + { id: 'endpoints', label: 'Tous les endpoints', icon: List }, + { id: 'errors', label: 'Codes d\'erreur', icon: AlertTriangle }, + { id: 'rate-limiting', label: 'Rate Limiting', icon: ShieldCheck }, + ], + }, +]; + +export const ALL_NAV_ITEMS: NavItem[] = DOC_SECTIONS.flatMap(s => s.items); + +export function findNavItem(id: string): NavItem | undefined { + return ALL_NAV_ITEMS.find(item => item.id === id); +} diff --git a/apps/frontend/src/components/layout/LandingFooter.tsx b/apps/frontend/src/components/layout/LandingFooter.tsx index d334279..c4a038d 100644 --- a/apps/frontend/src/components/layout/LandingFooter.tsx +++ b/apps/frontend/src/components/layout/LandingFooter.tsx @@ -80,21 +80,11 @@ export function LandingFooter() { Contact -
  • - - Carrières - -
  • Blog
  • -
  • - - Presse - -
  • @@ -117,11 +107,6 @@ export function LandingFooter() { Politique de cookies -
  • - - Sécurité - -
  • Conformité RGPD diff --git a/apps/frontend/src/components/layout/LandingHeader.tsx b/apps/frontend/src/components/layout/LandingHeader.tsx index ba44ebf..e225a22 100644 --- a/apps/frontend/src/components/layout/LandingHeader.tsx +++ b/apps/frontend/src/components/layout/LandingHeader.tsx @@ -6,18 +6,16 @@ import Image from 'next/image'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, - Briefcase, - Newspaper, - Mail, Info, BookOpen, LayoutDashboard, + Code2, } from 'lucide-react'; import { useAuth } from '@/lib/context/auth-context'; interface LandingHeaderProps { transparentOnTop?: boolean; - activePage?: 'about' | 'contact' | 'careers' | 'blog' | 'press'; + activePage?: 'about' | 'contact' | 'careers' | 'blog' | 'press' | 'docs'; } export function LandingHeader({ transparentOnTop = false, activePage }: LandingHeaderProps) { @@ -27,12 +25,13 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH const companyMenuItems = [ { href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' }, - { href: '/contact', label: 'Contact', icon: Mail, description: 'Nous contacter' }, - { href: '/careers', label: 'Carrières', icon: Briefcase, description: 'Rejoignez-nous' }, { href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' }, - { href: '/press', label: 'Presse', icon: Newspaper, description: 'Espace presse' }, ]; + // "Entreprise" dropdown is active only for its own sub-pages (not contact) + const isCompanyMenuActive = + activePage !== undefined && ['about', 'blog'].includes(activePage); + const getUserInitials = () => { if (!user) return ''; const firstInitial = user.firstName?.charAt(0)?.toUpperCase() || ''; @@ -93,12 +92,6 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH > Fonctionnalités - - Outils -
  • + + Contact + + + + + Docs API + + {/* Affichage conditionnel: connecté vs non connecté */} {loading ? (
    diff --git a/apps/frontend/src/components/organization/LicensesTab.tsx b/apps/frontend/src/components/organization/LicensesTab.tsx index 694524c..5110397 100644 --- a/apps/frontend/src/components/organization/LicensesTab.tsx +++ b/apps/frontend/src/components/organization/LicensesTab.tsx @@ -65,7 +65,6 @@ export default function LicensesTab() {

    {subscription?.usedLicenses || 0}

    -

    Hors ADMIN (illimité)

    Licences disponibles

    diff --git a/apps/frontend/src/components/organization/SubscriptionTab.tsx b/apps/frontend/src/components/organization/SubscriptionTab.tsx index 1bcac26..10df2a8 100644 --- a/apps/frontend/src/components/organization/SubscriptionTab.tsx +++ b/apps/frontend/src/components/organization/SubscriptionTab.tsx @@ -117,7 +117,7 @@ export default function SubscriptionTab() { }); const handleUpgrade = (plan: SubscriptionPlan) => { - if (plan === 'FREE') return; + if (plan === 'BRONZE') return; setSelectedPlan(plan); checkoutMutation.mutate(plan); }; @@ -149,7 +149,7 @@ export default function SubscriptionTab() { const canUpgrade = (plan: SubscriptionPlan): boolean => { if (!subscription) return false; - const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; + const planOrder: SubscriptionPlan[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan); }; @@ -230,7 +230,7 @@ export default function SubscriptionTab() { )}
    - {subscription.plan !== 'FREE' && ( + {subscription.plan !== 'BRONZE' && (
    @@ -333,7 +333,7 @@ export default function SubscriptionTab() {

    {plan.name}

    - {plan.plan === 'ENTERPRISE' + {plan.plan === 'PLATINIUM' ? 'Sur devis' : formatPrice( billingInterval === 'yearly' @@ -341,7 +341,7 @@ export default function SubscriptionTab() { : plan.monthlyPriceEur, )} - {plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && ( + {plan.plan !== 'PLATINIUM' && plan.plan !== 'BRONZE' && ( /{billingInterval === 'yearly' ? 'an' : 'mois'} @@ -381,7 +381,7 @@ export default function SubscriptionTab() { > Plan actuel - ) : plan.plan === 'ENTERPRISE' ? ( + ) : plan.plan === 'PLATINIUM' ? ( - - {children} - - + + + {children} + + + ); diff --git a/apps/frontend/src/components/ui/FeatureGate.tsx b/apps/frontend/src/components/ui/FeatureGate.tsx new file mode 100644 index 0000000..6060457 --- /dev/null +++ b/apps/frontend/src/components/ui/FeatureGate.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { Lock } from 'lucide-react'; +import { useSubscription } from '@/lib/context/subscription-context'; +import type { PlanFeature } from '@/lib/api/subscriptions'; + +interface FeatureGateProps { + feature: PlanFeature; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +const FEATURE_MIN_PLAN: Record = { + dashboard: 'Silver', + wiki: 'Silver', + user_management: 'Silver', + csv_export: 'Silver', + api_access: 'Gold', + custom_interface: 'Platinium', + dedicated_kam: 'Platinium', +}; + +export default function FeatureGate({ feature, children, fallback }: FeatureGateProps) { + const { hasFeature, loading } = useSubscription(); + + if (loading) { + return <>{children}; + } + + if (hasFeature(feature)) { + return <>{children}; + } + + if (fallback) { + return <>{fallback}; + } + + const minPlan = FEATURE_MIN_PLAN[feature] || 'Silver'; + + return ( +
    +
    + {children} +
    +
    +
    +
    + +
    +

    + Fonctionnalité {minPlan}+ +

    +

    + Cette fonctionnalité nécessite le plan {minPlan} ou supérieur. +

    + + Voir les plans + +
    +
    +
    + ); +} diff --git a/apps/frontend/src/components/ui/StatusBadge.tsx b/apps/frontend/src/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..a5e5fa1 --- /dev/null +++ b/apps/frontend/src/components/ui/StatusBadge.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React from 'react'; +import { Shield } from 'lucide-react'; + +interface StatusBadgeProps { + badge: 'none' | 'silver' | 'gold' | 'platinium'; + size?: 'sm' | 'md'; +} + +const BADGE_CONFIG = { + none: null, + silver: { + label: 'Silver', + bg: 'bg-slate-100', + text: 'text-slate-700', + icon: 'text-slate-500', + }, + gold: { + label: 'Gold', + bg: 'bg-yellow-100', + text: 'text-yellow-800', + icon: 'text-yellow-600', + }, + platinium: { + label: 'Platinium', + bg: 'bg-purple-100', + text: 'text-purple-800', + icon: 'text-purple-600', + }, +}; + +export default function StatusBadge({ badge, size = 'sm' }: StatusBadgeProps) { + const config = BADGE_CONFIG[badge]; + if (!config) return null; + + const sizeClasses = size === 'sm' + ? 'text-xs px-2 py-0.5 gap-1' + : 'text-sm px-3 py-1 gap-1.5'; + + const iconSize = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'; + + return ( + + + {config.label} + + ); +} diff --git a/apps/frontend/src/lib/api/admin.ts b/apps/frontend/src/lib/api/admin.ts index a06d3fb..ce227a4 100644 --- a/apps/frontend/src/lib/api/admin.ts +++ b/apps/frontend/src/lib/api/admin.ts @@ -80,6 +80,39 @@ export async function getAdminOrganization(id: string): Promise(`/api/v1/admin/organizations/${id}`); } +/** + * Verify SIRET for an organization via Pappers API (admin only) + * POST /api/v1/admin/organizations/:id/verify-siret + * Requires: ADMIN role + */ +export async function verifySiret( + organizationId: string +): Promise<{ verified: boolean; companyName?: string; address?: string; message: string }> { + return post(`/api/v1/admin/organizations/${organizationId}/verify-siret`, {}); +} + +/** + * Manually approve SIRET/SIREN for an organization (admin only) + * POST /api/v1/admin/organizations/:id/approve-siret + * Requires: ADMIN role + */ +export async function approveSiret( + organizationId: string +): Promise<{ approved: boolean; message: string; organizationName: string }> { + return post(`/api/v1/admin/organizations/${organizationId}/approve-siret`, {}); +} + +/** + * Reject SIRET/SIREN for an organization (admin only) + * POST /api/v1/admin/organizations/:id/reject-siret + * Requires: ADMIN role + */ +export async function rejectSiret( + organizationId: string +): Promise<{ rejected: boolean; message: string; organizationName: string }> { + return post(`/api/v1/admin/organizations/${organizationId}/reject-siret`, {}); +} + // ==================== BOOKINGS ==================== /** @@ -101,6 +134,36 @@ export async function getAdminBooking(id: string): Promise { return get(`/api/v1/admin/bookings/${id}`); } +/** + * Validate bank transfer for a booking (admin only) + * POST /api/v1/admin/bookings/:id/validate-transfer + * Confirms receipt of wire transfer and activates the booking + * Requires: ADMIN role + */ +export async function validateBankTransfer(bookingId: string): Promise { + return post(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {}); +} + +/** + * Delete a booking (admin only) + * DELETE /api/v1/admin/bookings/:id + * Permanently deletes a booking from the database + * Requires: ADMIN role + */ +export async function deleteAdminBooking(bookingId: string): Promise { + return del(`/api/v1/admin/bookings/${bookingId}`); +} + +/** + * Delete a document from a booking (admin only) + * DELETE /api/v1/admin/bookings/:bookingId/documents/:documentId + * Bypasses ownership and status restrictions + * Requires: ADMIN role + */ +export async function deleteAdminDocument(bookingId: string, documentId: string): Promise { + return del(`/api/v1/admin/bookings/${bookingId}/documents/${documentId}`); +} + // ==================== DOCUMENTS ==================== /** diff --git a/apps/frontend/src/lib/api/admin/csv-rates.ts b/apps/frontend/src/lib/api/admin/csv-rates.ts index 86dc11e..71796ec 100644 --- a/apps/frontend/src/lib/api/admin/csv-rates.ts +++ b/apps/frontend/src/lib/api/admin/csv-rates.ts @@ -16,6 +16,7 @@ export interface CsvFileInfo { size: number; uploadedAt: string; rowCount?: number; + companyEmail?: string | null; } export interface CsvFileListResponse { diff --git a/apps/frontend/src/lib/api/api-keys.ts b/apps/frontend/src/lib/api/api-keys.ts new file mode 100644 index 0000000..0a6dd6d --- /dev/null +++ b/apps/frontend/src/lib/api/api-keys.ts @@ -0,0 +1,55 @@ +/** + * API Keys API + * + * Endpoints for managing API keys (Gold and Platinum plans only) + */ + +import { get, post, del } from './client'; + +export interface ApiKeyDto { + id: string; + name: string; + keyPrefix: string; + isActive: boolean; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; +} + +export interface CreateApiKeyResultDto extends ApiKeyDto { + /** Full key — shown only once at creation time */ + fullKey: string; +} + +export interface CreateApiKeyRequest { + name: string; + expiresAt?: string; +} + +/** + * List all API keys for the current organization + * GET /api-keys + * Requires: Gold or Platinum plan + */ +export async function listApiKeys(): Promise { + return get('/api-keys'); +} + +/** + * Create a new API key + * POST /api-keys + * Requires: Gold or Platinum plan + * Returns the full key — shown only once + */ +export async function createApiKey(data: CreateApiKeyRequest): Promise { + return post('/api-keys', data); +} + +/** + * Revoke an API key (immediate and irreversible) + * DELETE /api-keys/:id + * Requires: Gold or Platinum plan + */ +export async function revokeApiKey(id: string): Promise { + return del(`/api-keys/${id}`); +} diff --git a/apps/frontend/src/lib/api/auth.ts b/apps/frontend/src/lib/api/auth.ts index 3ffe267..0129251 100644 --- a/apps/frontend/src/lib/api/auth.ts +++ b/apps/frontend/src/lib/api/auth.ts @@ -31,11 +31,12 @@ export async function register(data: RegisterRequest): Promise { * User login * POST /api/v1/auth/login */ -export async function login(data: LoginRequest): Promise { - const response = await post('/api/v1/auth/login', data, false); +export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise { + const { rememberMe, ...loginData } = data; + const response = await post('/api/v1/auth/login', loginData, false); - // Store tokens - setAuthTokens(response.accessToken, response.refreshToken); + // Store tokens — localStorage if rememberMe, sessionStorage otherwise + setAuthTokens(response.accessToken, response.refreshToken, rememberMe ?? false); return response; } @@ -69,3 +70,35 @@ export async function logout(): Promise { export async function getCurrentUser(): Promise { return get('/api/v1/auth/me'); } + +/** + * Contact form — send message to contact@xpeditis.com + * POST /api/v1/auth/contact + */ +export async function sendContactForm(data: { + firstName: string; + lastName: string; + email: string; + company?: string; + phone?: string; + subject: string; + message: string; +}): Promise<{ message: string }> { + return post<{ message: string }>('/api/v1/auth/contact', data, false); +} + +/** + * Forgot password — request reset email + * POST /api/v1/auth/forgot-password + */ +export async function forgotPassword(email: string): Promise<{ message: string }> { + return post<{ message: string }>('/api/v1/auth/forgot-password', { email }, false); +} + +/** + * Reset password with token from email + * POST /api/v1/auth/reset-password + */ +export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { + return post<{ message: string }>('/api/v1/auth/reset-password', { token, newPassword }, false); +} diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index 7ee9e68..e8138d7 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -51,7 +51,7 @@ export interface CsvBookingResponse { primaryCurrency: string; transitDays: number; containerType: string; - status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; + status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; documents: Array<{ type: string; fileName: string; @@ -64,6 +64,14 @@ export interface CsvBookingResponse { rejectedAt?: string; createdAt: string; updatedAt: string; + commissionRate?: number; + commissionAmountEur?: number; +} + +export interface CommissionPaymentResponse { + sessionUrl: string; + sessionId: string; + commissionAmountEur: number; } export interface CsvBookingListResponse { @@ -287,3 +295,34 @@ export async function rejectCsvBooking( false // includeAuth = false ); } + +/** + * Create Stripe Checkout session for commission payment + * POST /api/v1/csv-bookings/:id/pay + */ +export async function payBookingCommission( + bookingId: string +): Promise { + return post(`/api/v1/csv-bookings/${bookingId}/pay`, {}); +} + +/** + * Confirm commission payment after Stripe redirect + * POST /api/v1/csv-bookings/:id/confirm-payment + */ +export async function confirmBookingPayment( + bookingId: string, + sessionId: string +): Promise { + return post(`/api/v1/csv-bookings/${bookingId}/confirm-payment`, { + sessionId, + }); +} + +/** + * Declare bank transfer — user confirms they have sent the wire transfer + * POST /api/v1/csv-bookings/:id/declare-transfer + */ +export async function declareBankTransfer(bookingId: string): Promise { + return post(`/api/v1/csv-bookings/${bookingId}/declare-transfer`, {}); +} diff --git a/apps/frontend/src/lib/api/client.ts b/apps/frontend/src/lib/api/client.ts index f752f6f..2fe11f0 100644 --- a/apps/frontend/src/lib/api/client.ts +++ b/apps/frontend/src/lib/api/client.ts @@ -11,38 +11,48 @@ let isRefreshing = false; let refreshSubscribers: Array<(token: string) => void> = []; /** - * Get authentication token from localStorage + * Get authentication token — checks localStorage first (remember me), then sessionStorage */ export function getAuthToken(): string | null { if (typeof window === 'undefined') return null; - return localStorage.getItem('access_token'); + return localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); } /** - * Get refresh token from localStorage + * Get refresh token — checks localStorage first (remember me), then sessionStorage */ export function getRefreshToken(): string | null { if (typeof window === 'undefined') return null; - return localStorage.getItem('refresh_token'); + return localStorage.getItem('refresh_token') || sessionStorage.getItem('refresh_token'); } /** - * Set authentication tokens + * Set authentication tokens. + * rememberMe=true → localStorage (persists across browser sessions) + * rememberMe=false → sessionStorage (cleared when browser closes) */ -export function setAuthTokens(accessToken: string, refreshToken: string): void { +export function setAuthTokens(accessToken: string, refreshToken: string, rememberMe = false): void { if (typeof window === 'undefined') return; - localStorage.setItem('access_token', accessToken); - localStorage.setItem('refresh_token', refreshToken); + const storage = rememberMe ? localStorage : sessionStorage; + storage.setItem('access_token', accessToken); + storage.setItem('refresh_token', refreshToken); + // Sync to cookie so Next.js middleware can read it for route protection + document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`; } /** - * Clear authentication tokens + * Clear authentication tokens from both storages */ export function clearAuthTokens(): void { if (typeof window === 'undefined') return; localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('user'); + sessionStorage.removeItem('access_token'); + sessionStorage.removeItem('refresh_token'); + sessionStorage.removeItem('user'); + // Expire the middleware cookie + document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; } /** @@ -91,9 +101,11 @@ async function refreshAccessToken(): Promise { const data = await response.json(); const newAccessToken = data.accessToken; - // Update access token in localStorage (keep same refresh token) + // Update access token in the same storage that holds the refresh token if (typeof window !== 'undefined') { - localStorage.setItem('access_token', newAccessToken); + const storage = localStorage.getItem('refresh_token') ? localStorage : sessionStorage; + storage.setItem('access_token', newAccessToken); + document.cookie = `accessToken=${newAccessToken}; path=/; SameSite=Lax`; } return newAccessToken; diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index f3d6e82..913d43e 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -24,8 +24,8 @@ export { ApiError, } from './client'; -// Authentication (5 endpoints) -export { register, login, refreshToken, logout, getCurrentUser } from './auth'; +// Authentication (8 endpoints) +export { register, login, refreshToken, logout, getCurrentUser, forgotPassword, resetPassword, sendContactForm } from './auth'; // Rates (4 endpoints) export { searchRates, searchCsvRates, getAvailableCompanies, getFilterOptions } from './rates'; diff --git a/apps/frontend/src/lib/api/invitations.ts b/apps/frontend/src/lib/api/invitations.ts index f20064f..ff949fa 100644 --- a/apps/frontend/src/lib/api/invitations.ts +++ b/apps/frontend/src/lib/api/invitations.ts @@ -1,4 +1,4 @@ -import { get, post } from './client'; +import { get, post, del } from './client'; /** * Invitation API Types @@ -49,3 +49,10 @@ export async function verifyInvitation(token: string): Promise { return get('/api/v1/invitations'); } + +/** + * Cancel (delete) a pending invitation + */ +export async function cancelInvitation(id: string): Promise { + return del(`/api/v1/invitations/${id}`); +} diff --git a/apps/frontend/src/lib/api/subscriptions.ts b/apps/frontend/src/lib/api/subscriptions.ts index 2e6b96b..28a06d9 100644 --- a/apps/frontend/src/lib/api/subscriptions.ts +++ b/apps/frontend/src/lib/api/subscriptions.ts @@ -9,7 +9,16 @@ import { get, post } from './client'; /** * Subscription plan types */ -export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +export type SubscriptionPlan = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; + +export type PlanFeature = + | 'dashboard' + | 'wiki' + | 'user_management' + | 'csv_export' + | 'api_access' + | 'custom_interface' + | 'dedicated_kam'; /** * Subscription status types @@ -38,6 +47,11 @@ export interface PlanDetails { maxLicenses: number; monthlyPriceEur: number; yearlyPriceEur: number; + maxShipmentsPerYear: number; + commissionRatePercent: number; + supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam'; + statusBadge: 'none' | 'silver' | 'gold' | 'platinium'; + planFeatures: PlanFeature[]; features: string[]; } @@ -190,14 +204,14 @@ export function formatPrice(amount: number, currency = 'EUR'): string { */ export function getPlanBadgeColor(plan: SubscriptionPlan): string { switch (plan) { - case 'FREE': - return 'bg-gray-100 text-gray-800'; - case 'STARTER': - return 'bg-blue-100 text-blue-800'; - case 'PRO': + case 'BRONZE': + return 'bg-orange-100 text-orange-800'; + case 'SILVER': + return 'bg-slate-100 text-slate-800'; + case 'GOLD': + return 'bg-yellow-100 text-yellow-800'; + case 'PLATINIUM': return 'bg-purple-100 text-purple-800'; - case 'ENTERPRISE': - return 'bg-amber-100 text-amber-800'; default: return 'bg-gray-100 text-gray-800'; } diff --git a/apps/frontend/src/lib/context/auth-context.tsx b/apps/frontend/src/lib/context/auth-context.tsx index 2431eb6..6849b80 100644 --- a/apps/frontend/src/lib/context/auth-context.tsx +++ b/apps/frontend/src/lib/context/auth-context.tsx @@ -20,7 +20,7 @@ import type { UserPayload } from '@/types/api'; interface AuthContextType { user: UserPayload | null; loading: boolean; - login: (email: string, password: string) => Promise; + login: (email: string, password: string, redirectTo?: string, rememberMe?: boolean) => Promise; register: (data: { email: string; password: string; @@ -106,17 +106,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => clearInterval(tokenCheckInterval); }, []); - const login = async (email: string, password: string) => { + const login = async (email: string, password: string, redirectTo = '/dashboard', rememberMe = false) => { try { - const response = await apiLogin({ email, password }); + await apiLogin({ email, password, rememberMe }); // Fetch complete user profile after login const currentUser = await getCurrentUser(); setUser(currentUser); - // Store user in localStorage + // Store user in the same storage as the tokens if (typeof window !== 'undefined') { - localStorage.setItem('user', JSON.stringify(currentUser)); + const storage = rememberMe ? localStorage : sessionStorage; + storage.setItem('user', JSON.stringify(currentUser)); } - router.push('/dashboard'); + router.push(redirectTo); } catch (error) { throw error; } diff --git a/apps/frontend/src/lib/context/subscription-context.tsx b/apps/frontend/src/lib/context/subscription-context.tsx new file mode 100644 index 0000000..16686b7 --- /dev/null +++ b/apps/frontend/src/lib/context/subscription-context.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { useAuth } from './auth-context'; +import { + getSubscriptionOverview, + type SubscriptionOverviewResponse, + type SubscriptionPlan, + type PlanFeature, +} from '../api/subscriptions'; + +interface SubscriptionContextType { + subscription: SubscriptionOverviewResponse | null; + loading: boolean; + plan: SubscriptionPlan | null; + planFeatures: PlanFeature[]; + hasFeature: (feature: PlanFeature) => boolean; + refresh: () => Promise; +} + +const SubscriptionContext = createContext(undefined); + +export function SubscriptionProvider({ children }: { children: React.ReactNode }) { + const { user, isAuthenticated } = useAuth(); + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchSubscription = async () => { + if (!isAuthenticated) { + setSubscription(null); + setLoading(false); + return; + } + try { + const data = await getSubscriptionOverview(); + setSubscription(data); + } catch (error) { + console.error('Failed to fetch subscription:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSubscription(); + }, [isAuthenticated, user?.organizationId]); + + const plan = subscription?.plan ?? null; + const planFeatures = subscription?.planDetails?.planFeatures ?? []; + + const hasFeature = (feature: PlanFeature): boolean => { + return planFeatures.includes(feature); + }; + + return ( + + {children} + + ); +} + +export function useSubscription() { + const context = useContext(SubscriptionContext); + if (context === undefined) { + throw new Error('useSubscription must be used within a SubscriptionProvider'); + } + return context; +} diff --git a/apps/frontend/src/types/api.ts b/apps/frontend/src/types/api.ts index e371092..b9ddcef 100644 --- a/apps/frontend/src/types/api.ts +++ b/apps/frontend/src/types/api.ts @@ -11,6 +11,8 @@ export interface RegisterOrganizationData { name: string; type: OrganizationType; + siren: string; + siret?: string; street: string; city: string; state?: string; @@ -24,7 +26,8 @@ export interface RegisterRequest { password: string; firstName: string; lastName: string; - organizationId?: string; // For invited users + invitationToken?: string; // For invited users (token-based) + organizationId?: string; // For invited users (ID-based) organization?: RegisterOrganizationData; // For new users } @@ -120,6 +123,7 @@ export interface CreateOrganizationRequest { export interface UpdateOrganizationRequest { name?: string; siren?: string; + siret?: string; eori?: string; contact_phone?: string; contact_email?: string; @@ -149,6 +153,9 @@ export interface OrganizationResponse { scac?: string | null; siren?: string | null; eori?: string | null; + siret?: string | null; + siretVerified?: boolean; + statusBadge?: 'none' | 'silver' | 'gold' | 'platinium'; contact_phone?: string | null; contact_email?: string | null; address: OrganizationAddress; diff --git a/apps/log-exporter/Dockerfile b/apps/log-exporter/Dockerfile new file mode 100644 index 0000000..d56679e --- /dev/null +++ b/apps/log-exporter/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY src/ ./src/ + +EXPOSE 3200 + +USER node + +CMD ["node", "src/index.js"] diff --git a/apps/log-exporter/package.json b/apps/log-exporter/package.json new file mode 100644 index 0000000..7edc547 --- /dev/null +++ b/apps/log-exporter/package.json @@ -0,0 +1,15 @@ +{ + "name": "xpeditis-log-exporter", + "version": "1.0.0", + "description": "Log export API for Xpeditis - queries Loki and exports logs as CSV/JSON", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "node-fetch": "^3.3.2", + "json2csv": "^6.0.0-alpha.2" + } +} diff --git a/apps/log-exporter/src/index.js b/apps/log-exporter/src/index.js new file mode 100644 index 0000000..ba659b7 --- /dev/null +++ b/apps/log-exporter/src/index.js @@ -0,0 +1,319 @@ +'use strict'; + +const express = require('express'); +const { Transform } = require('stream'); + +const app = express(); +const PORT = process.env.PORT || 3200; +const LOKI_URL = process.env.LOKI_URL || 'http://loki:3100'; +const API_KEY = process.env.LOG_EXPORTER_API_KEY; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Simple API key middleware (optional, enabled when LOG_EXPORTER_API_KEY is set). + */ +function authMiddleware(req, res, next) { + if (!API_KEY) return next(); + const key = req.headers['x-api-key'] || req.query.apiKey; + if (key !== API_KEY) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); +} + +/** + * Build a Loki LogQL query from request params. + * Supports: service, level, search (free text filter) + */ +function buildLogQLQuery({ service, level, search }) { + const labelFilters = []; + + if (service && service !== 'all') { + const services = service.split(',').map((s) => s.trim()).filter(Boolean); + if (services.length === 1) { + labelFilters.push(`service="${services[0]}"`); + } else { + labelFilters.push(`service=~"${services.join('|')}"`); + } + } + + if (level && level !== 'all') { + const levels = level.split(',').map((l) => l.trim()).filter(Boolean); + if (levels.length === 1) { + labelFilters.push(`level="${levels[0]}"`); + } else { + labelFilters.push(`level=~"${levels.join('|')}"`); + } + } + + const streamSelector = labelFilters.length > 0 + ? `{${labelFilters.join(', ')}}` + : `{service=~".+"}`; + + const lineFilters = search ? ` |= \`${search}\`` : ''; + + return `${streamSelector}${lineFilters}`; +} + +/** + * Query Loki's query_range endpoint and return flattened log entries. + */ +async function queryLoki({ query, start, end, limit = 5000 }) { + const params = new URLSearchParams({ + query, + start: String(start), + end: String(end), + limit: String(Math.min(limit, 5000)), + direction: 'BACKWARD', + }); + + const url = `${LOKI_URL}/loki/api/v1/query_range?${params}`; + + const response = await fetch(url, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Loki query failed (${response.status}): ${body}`); + } + + const data = await response.json(); + + if (data.status !== 'success') { + throw new Error(`Loki returned status: ${data.status}`); + } + + // Flatten streams → individual log entries + const entries = []; + for (const stream of data.data.result || []) { + const labels = stream.stream || {}; + for (const [tsNano, line] of stream.values || []) { + let parsed = {}; + try { + parsed = JSON.parse(line); + } catch { + parsed = { msg: line }; + } + + entries.push({ + timestamp: new Date(Math.floor(Number(tsNano) / 1e6)).toISOString(), + service: labels.service || labels.container || 'unknown', + level: labels.level || parsed.level || 'info', + context: labels.context || parsed.context || '', + message: parsed.msg || parsed.message || line, + reqId: parsed.reqId || '', + req_method: parsed.req?.method || '', + req_url: parsed.req?.url || '', + res_status: parsed.res?.statusCode || '', + response_time_ms: parsed.responseTime || '', + error: parsed.err?.message || '', + raw: line, + }); + } + } + + // Sort by timestamp ascending + entries.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + return entries; +} + +/** + * Convert array of objects to CSV string. + */ +function toCSV(entries) { + if (entries.length === 0) return ''; + + const headers = [ + 'timestamp', 'service', 'level', 'context', + 'message', 'reqId', 'req_method', 'req_url', + 'res_status', 'response_time_ms', 'error', + ]; + + const escape = (val) => { + if (val === null || val === undefined) return ''; + const str = String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + + const rows = [headers.join(',')]; + for (const entry of entries) { + rows.push(headers.map((h) => escape(entry[h])).join(',')); + } + return rows.join('\n'); +} + +// ─── Routes ────────────────────────────────────────────────────────────────── + +app.use(express.json()); + +// Rate limiting (basic — 60 requests/min per IP) +const requestCounts = new Map(); +setInterval(() => requestCounts.clear(), 60000); + +app.use((req, res, next) => { + const ip = req.ip; + const count = (requestCounts.get(ip) || 0) + 1; + requestCounts.set(ip, count); + if (count > 60) { + return res.status(429).json({ error: 'Too Many Requests' }); + } + next(); +}); + +// CORS for Grafana / frontend +app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + +/** + * GET /health + */ +app.get('/health', (req, res) => { + res.json({ status: 'ok', loki: LOKI_URL }); +}); + +/** + * GET /api/logs/services + * Returns the list of services currently emitting logs. + */ +app.get('/api/logs/services', authMiddleware, async (req, res) => { + try { + const response = await fetch(`${LOKI_URL}/loki/api/v1/label/service/values`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error(`Loki error: ${response.status}`); + const data = await response.json(); + res.json({ services: data.data || [] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +/** + * GET /api/logs/labels + * Returns all available label names. + */ +app.get('/api/logs/labels', authMiddleware, async (req, res) => { + try { + const response = await fetch(`${LOKI_URL}/loki/api/v1/labels`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error(`Loki error: ${response.status}`); + const data = await response.json(); + res.json({ labels: data.data || [] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +/** + * GET /api/logs/export + * + * Query params: + * - start : ISO date or Unix timestamp in ns (default: 1h ago) + * - end : ISO date or Unix timestamp in ns (default: now) + * - service : comma-separated service names (default: all) + * - level : comma-separated levels: error,warn,info,debug (default: all) + * - search : free-text search string + * - limit : max number of log lines (default: 5000, max: 5000) + * - format : "json" | "csv" (default: json) + * + * Examples: + * GET /api/logs/export?service=backend&level=error&format=csv + * GET /api/logs/export?start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&format=json + */ +app.get('/api/logs/export', authMiddleware, async (req, res) => { + try { + const now = Date.now(); + const ONE_HOUR_NS = 3600 * 1e9; + const nowNs = BigInt(now) * 1000000n; + const oneHourAgoNs = nowNs - BigInt(ONE_HOUR_NS); + + // Parse time range + const parseTime = (val, defaultNs) => { + if (!val) return defaultNs; + // Already in nanoseconds (large number) + if (/^\d{18,}$/.test(val)) return BigInt(val); + // Unix timestamp in seconds or ms + const n = Number(val); + if (!isNaN(n)) { + // seconds → ns + if (n < 1e12) return BigInt(Math.floor(n * 1e9)); + // ms → ns + if (n < 1e15) return BigInt(n) * 1000000n; + return BigInt(n); + } + // ISO date string + const ms = Date.parse(val); + if (isNaN(ms)) throw new Error(`Invalid time value: ${val}`); + return BigInt(ms) * 1000000n; + }; + + const startNs = parseTime(req.query.start, oneHourAgoNs); + const endNs = parseTime(req.query.end, nowNs); + + if (endNs <= startNs) { + return res.status(400).json({ error: '"end" must be after "start"' }); + } + + const format = (req.query.format || 'json').toLowerCase(); + if (!['json', 'csv'].includes(format)) { + return res.status(400).json({ error: 'format must be "json" or "csv"' }); + } + + const limit = Math.min(parseInt(req.query.limit, 10) || 5000, 5000); + + const query = buildLogQLQuery({ + service: req.query.service, + level: req.query.level, + search: req.query.search, + }); + + const entries = await queryLoki({ + query, + start: startNs.toString(), + end: endNs.toString(), + limit, + }); + + if (format === 'csv') { + const csv = toCSV(entries); + const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.csv`; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(csv); + } + + // JSON response + res.json({ + total: entries.length, + query, + range: { + from: new Date(Number(startNs / 1000000n)).toISOString(), + to: new Date(Number(endNs / 1000000n)).toISOString(), + }, + logs: entries, + }); + } catch (err) { + console.error('[log-exporter] Export error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +// ─── Start ──────────────────────────────────────────────────────────────────── + +app.listen(PORT, () => { + console.log(`[log-exporter] Listening on port ${PORT}`); + console.log(`[log-exporter] Loki URL: ${LOKI_URL}`); + console.log(`[log-exporter] API key protection: ${API_KEY ? 'enabled' : 'disabled'}`); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1365885..35a6a0f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -50,7 +50,10 @@ services: dockerfile: Dockerfile container_name: xpeditis-backend-dev ports: - - "4001:4000" + - "4000:4000" + labels: + logging: promtail + logging.service: backend depends_on: postgres: condition: service_healthy @@ -58,6 +61,8 @@ services: condition: service_healthy environment: NODE_ENV: development + # Force JSON logs in Docker so Promtail can parse them + LOG_FORMAT: json PORT: 4000 API_PREFIX: api/v1 @@ -89,10 +94,10 @@ services: AWS_S3_BUCKET: xpeditis-csv-rates # CORS - Allow both localhost (browser) and container network - CORS_ORIGIN: "http://localhost:3001,http://localhost:4001" + CORS_ORIGIN: "http://localhost:3000,http://localhost:4000" # Application URL - APP_URL: http://localhost:3001 + APP_URL: http://localhost:3000 # Security BCRYPT_ROUNDS: 10 @@ -102,19 +107,30 @@ services: RATE_LIMIT_TTL: 60 RATE_LIMIT_MAX: 100 + # SMTP (Brevo) + SMTP_HOST: smtp-relay.brevo.com + SMTP_PORT: 587 + SMTP_USER: 9637ef001@smtp-brevo.com + SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu + SMTP_SECURE: "false" + SMTP_FROM: noreply@xpeditis.com + frontend: build: context: ./apps/frontend dockerfile: Dockerfile args: - NEXT_PUBLIC_API_URL: http://localhost:4001 + NEXT_PUBLIC_API_URL: http://localhost:4000 container_name: xpeditis-frontend-dev ports: - - "3001:3000" + - "3000:3000" + labels: + logging: promtail + logging.service: frontend depends_on: - backend environment: - NEXT_PUBLIC_API_URL: http://localhost:4001 + NEXT_PUBLIC_API_URL: http://localhost:4000 volumes: postgres_data: diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 0000000..e32f650 --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,254 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Xpeditis — Full Dev Stack (infrastructure + app + logging) +# +# Usage: +# docker-compose -f docker-compose.full.yml up -d +# +# Exposed ports: +# - Frontend: http://localhost:3000 +# - Backend: http://localhost:4000 (Swagger: /api/docs) +# - Grafana: http://localhost:3030 (admin / xpeditis_grafana) +# - Loki: http://localhost:3100 (internal) +# - Promtail: http://localhost:9080 (internal) +# - log-exporter: http://localhost:3200 +# - MinIO: http://localhost:9001 (console) +# ───────────────────────────────────────────────────────────────────────────── + +version: '3.8' + +services: + # ─── Infrastructure ──────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + container_name: xpeditis-postgres-dev + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: xpeditis_dev + POSTGRES_USER: xpeditis + POSTGRES_PASSWORD: xpeditis_dev_password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U xpeditis"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - xpeditis-network + + redis: + image: redis:7-alpine + container_name: xpeditis-redis-dev + ports: + - "6379:6379" + command: redis-server --requirepass xpeditis_redis_password + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - xpeditis-network + + minio: + image: minio/minio:latest + container_name: xpeditis-minio-dev + ports: + - "9000:9000" + - "9001:9001" + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + networks: + - xpeditis-network + + # ─── Application ─────────────────────────────────────────────────────────── + backend: + build: + context: ./apps/backend + dockerfile: Dockerfile + container_name: xpeditis-backend-dev + ports: + - "4000:4000" + labels: + logging: promtail + logging.service: backend + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: development + LOG_FORMAT: json + PORT: 4000 + API_PREFIX: api/v1 + + # Database + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_USER: xpeditis + DATABASE_PASSWORD: xpeditis_dev_password + DATABASE_NAME: xpeditis_dev + DATABASE_SYNC: false + DATABASE_LOGGING: true + + # Redis + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: xpeditis_redis_password + REDIS_DB: 0 + + # JWT + JWT_SECRET: dev-secret-jwt-key-for-docker + JWT_ACCESS_EXPIRATION: 15m + JWT_REFRESH_EXPIRATION: 7d + + # S3/MinIO + AWS_S3_ENDPOINT: http://minio:9000 + AWS_REGION: us-east-1 + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + AWS_S3_BUCKET: xpeditis-csv-rates + + # CORS + CORS_ORIGIN: "http://localhost:3000,http://localhost:4000" + + # Application URL + APP_URL: http://localhost:3000 + + # Security + BCRYPT_ROUNDS: 10 + SESSION_TIMEOUT_MS: 7200000 + + # Rate Limiting + RATE_LIMIT_TTL: 60 + RATE_LIMIT_MAX: 100 + + # SMTP (Brevo) + SMTP_HOST: smtp-relay.brevo.com + SMTP_PORT: 587 + SMTP_USER: 9637ef001@smtp-brevo.com + SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu + SMTP_SECURE: "false" + SMTP_FROM: noreply@xpeditis.com + networks: + - xpeditis-network + + frontend: + build: + context: ./apps/frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: http://localhost:4000 + container_name: xpeditis-frontend-dev + ports: + - "3000:3000" + labels: + logging: promtail + logging.service: frontend + depends_on: + - backend + environment: + NEXT_PUBLIC_API_URL: http://localhost:4000 + networks: + - xpeditis-network + + # ─── Logging Stack ───────────────────────────────────────────────────────── + loki: + image: grafana/loki:3.0.0 + container_name: xpeditis-loki + restart: unless-stopped + ports: + - '3100:3100' + volumes: + - ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki_data:/loki + command: -config.file=/etc/loki/local-config.yaml + healthcheck: + test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1'] + interval: 15s + timeout: 5s + retries: 5 + networks: + - xpeditis-network + + promtail: + image: grafana/promtail:3.0.0 + container_name: xpeditis-promtail + restart: unless-stopped + ports: + - '9080:9080' + volumes: + - ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + command: -config.file=/etc/promtail/config.yml + depends_on: + loki: + condition: service_healthy + networks: + - xpeditis-network + + grafana: + image: grafana/grafana:11.0.0 + container_name: xpeditis-grafana + restart: unless-stopped + ports: + - '3030:3000' + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana + GF_USERS_ALLOW_SIGN_UP: 'false' + GF_AUTH_ANONYMOUS_ENABLED: 'false' + GF_SERVER_ROOT_URL: http://localhost:3030 + GF_ANALYTICS_REPORTING_ENABLED: 'false' + GF_ANALYTICS_CHECK_FOR_UPDATES: 'false' + volumes: + - ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro + - grafana_data:/var/lib/grafana + depends_on: + loki: + condition: service_healthy + healthcheck: + test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1'] + interval: 15s + timeout: 5s + retries: 5 + networks: + - xpeditis-network + + log-exporter: + build: + context: ./apps/log-exporter + dockerfile: Dockerfile + container_name: xpeditis-log-exporter + restart: unless-stopped + ports: + - '3200:3200' + environment: + PORT: 3200 + LOKI_URL: http://loki:3100 + # Optional: set LOG_EXPORTER_API_KEY to require x-api-key header + # LOG_EXPORTER_API_KEY: your-secret-key-here + depends_on: + loki: + condition: service_healthy + networks: + - xpeditis-network + +volumes: + postgres_data: + redis_data: + minio_data: + loki_data: + driver: local + grafana_data: + driver: local + +networks: + xpeditis-network: + name: xpeditis-network diff --git a/docker-compose.logging.yml b/docker-compose.logging.yml new file mode 100644 index 0000000..e014293 --- /dev/null +++ b/docker-compose.logging.yml @@ -0,0 +1,115 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Xpeditis — Centralized Logging Stack +# +# Usage (standalone): +# docker-compose -f docker-compose.yml -f docker-compose.logging.yml up -d +# +# Usage (full dev environment with logging): +# docker-compose -f docker-compose.dev.yml -f docker-compose.logging.yml up -d +# +# Exposed ports: +# - Grafana: http://localhost:3000 (admin / xpeditis_grafana) +# - Loki: http://localhost:3100 (internal use only) +# - Promtail: http://localhost:9080 (internal use only) +# - log-exporter: http://localhost:3200 (export API) +# ───────────────────────────────────────────────────────────────────────────── + +services: + # ─── Loki — Log storage & query engine ──────────────────────────────────── + loki: + image: grafana/loki:3.0.0 + container_name: xpeditis-loki + restart: unless-stopped + ports: + - '3100:3100' + volumes: + - ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki_data:/loki + command: -config.file=/etc/loki/local-config.yaml + healthcheck: + test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1'] + interval: 15s + timeout: 5s + retries: 5 + networks: + - xpeditis-network + + # ─── Promtail — Docker log collector ────────────────────────────────────── + promtail: + image: grafana/promtail:3.0.0 + container_name: xpeditis-promtail + restart: unless-stopped + ports: + - '9080:9080' + volumes: + - ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + # Note: /var/lib/docker/containers is not needed with docker_sd_configs (uses Docker API) + command: -config.file=/etc/promtail/config.yml + depends_on: + loki: + condition: service_healthy + networks: + - xpeditis-network + + # ─── Grafana — Visualization ─────────────────────────────────────────────── + grafana: + image: grafana/grafana:11.0.0 + container_name: xpeditis-grafana + restart: unless-stopped + ports: + - '3030:3000' + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana + GF_USERS_ALLOW_SIGN_UP: 'false' + GF_AUTH_ANONYMOUS_ENABLED: 'false' + GF_SERVER_ROOT_URL: http://localhost:3030 + # Disable telemetry + GF_ANALYTICS_REPORTING_ENABLED: 'false' + GF_ANALYTICS_CHECK_FOR_UPDATES: 'false' + volumes: + - ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro + - grafana_data:/var/lib/grafana + depends_on: + loki: + condition: service_healthy + healthcheck: + test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1'] + interval: 15s + timeout: 5s + retries: 5 + networks: + - xpeditis-network + + # ─── log-exporter — REST export API ─────────────────────────────────────── + log-exporter: + build: + context: ./apps/log-exporter + dockerfile: Dockerfile + container_name: xpeditis-log-exporter + restart: unless-stopped + ports: + - '3200:3200' + environment: + PORT: 3200 + LOKI_URL: http://loki:3100 + # Optional: set LOG_EXPORTER_API_KEY to require x-api-key header + # LOG_EXPORTER_API_KEY: your-secret-key-here + depends_on: + loki: + condition: service_healthy + networks: + - xpeditis-network + +volumes: + loki_data: + driver: local + grafana_data: + driver: local + +networks: + xpeditis-network: + name: xpeditis-network + # Re-uses the network created by docker-compose.yml / docker-compose.dev.yml. + # If starting this stack alone, the network is created automatically. diff --git a/docs/api-access/API_ACCESS.md b/docs/api-access/API_ACCESS.md new file mode 100644 index 0000000..f5bb715 --- /dev/null +++ b/docs/api-access/API_ACCESS.md @@ -0,0 +1,334 @@ +# Accès API Xpeditis — Documentation + +> **Disponible sur :** plans **Gold** et **Platinium** uniquement. +> Les plans Bronze et Silver n'ont accès qu'au frontend Xpeditis. + +--- + +## Table des matières + +1. [Vue d'ensemble](#vue-densemble) +2. [Authentification par clé API](#authentification-par-clé-api) +3. [Gestion des clés API](#gestion-des-clés-api) + - [Générer une clé](#générer-une-clé) + - [Lister les clés](#lister-les-clés) + - [Révoquer une clé](#révoquer-une-clé) +4. [Utiliser l'API](#utiliser-lapi) +5. [Sécurité et bonnes pratiques](#sécurité-et-bonnes-pratiques) +6. [Limites et quotas](#limites-et-quotas) +7. [Codes d'erreur](#codes-derreur) +8. [Exemples d'intégration](#exemples-dintégration) + +--- + +## Vue d'ensemble + +L'accès API permet aux abonnés **Gold** et **Platinium** d'intégrer Xpeditis directement dans leurs systèmes (ERP, TMS, scripts d'automatisation, etc.) sans passer par l'interface web. + +### Deux méthodes d'authentification coexistent + +| Méthode | En-tête HTTP | Disponible pour | +|---|---|---| +| **JWT Bearer** (frontend) | `Authorization: Bearer ` | Tous les plans | +| **Clé API** (accès programmatique) | `X-API-Key: ` | Gold + Platinium uniquement | + +Les deux méthodes donnent accès aux mêmes endpoints. La clé API ne nécessite pas de session interactive. + +--- + +## Authentification par clé API + +### Format de la clé + +``` +xped_live_<64 caractères hexadécimaux> +``` + +Exemple : +``` +xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 +``` + +### Utilisation dans une requête + +Ajoutez l'en-tête `X-API-Key` à chaque requête : + +```http +GET /api/v1/bookings HTTP/1.1 +Host: api.xpeditis.com +X-API-Key: xped_live_a1b2c3d4e5f6... +Content-Type: application/json +``` + +### Comportement du serveur + +1. Le serveur détecte l'en-tête `X-API-Key`. +2. Il calcule le hash SHA-256 de la clé et le compare à la base de données. +3. Il vérifie que la clé est active et non expirée. +4. Il vérifie **en temps réel** que l'organisation possède toujours un abonnement Gold ou Platinium. +5. Si tout est valide, la requête est authentifiée. + +> Si votre abonnement est rétrogradé sous Gold, vos clés API seront automatiquement refusées même si elles sont techniquement encore actives. + +--- + +## Gestion des clés API + +Les endpoints de gestion des clés nécessitent une **authentification JWT** (via le frontend ou un token Bearer). Ils ne sont pas accessibles avec une clé API elle-même. + +**Base URL :** `http://localhost:4000` (développement) ou `https://api.xpeditis.com` (production) + +--- + +### Générer une clé + +```http +POST /api-keys +Authorization: Bearer +Content-Type: application/json + +{ + "name": "Intégration ERP Production", + "expiresAt": "2027-01-01T00:00:00.000Z" +} +``` + +**Corps de la requête :** + +| Champ | Type | Requis | Description | +|---|---|---|---| +| `name` | string | ✅ | Nom identifiant la clé (max 100 caractères) | +| `expiresAt` | string (ISO 8601) | ❌ | Date d'expiration. Si absent, la clé n'expire pas. | + +**Réponse `201 Created` :** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Intégration ERP Production", + "keyPrefix": "xped_live_a1b2c3d4", + "isActive": true, + "lastUsedAt": null, + "expiresAt": "2027-01-01T00:00:00.000Z", + "createdAt": "2025-03-26T10:00:00.000Z", + "fullKey": "xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" +} +``` + +> ⚠️ **Le champ `fullKey` n'est retourné qu'une seule fois.** Copiez-le immédiatement et stockez-le de façon sécurisée (gestionnaire de secrets, variables d'environnement chiffrées, etc.). Il ne sera plus jamais visible. + +--- + +### Lister les clés + +```http +GET /api-keys +Authorization: Bearer +``` + +**Réponse `200 OK` :** + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Intégration ERP Production", + "keyPrefix": "xped_live_a1b2c3d4", + "isActive": true, + "lastUsedAt": "2025-03-25T14:30:00.000Z", + "expiresAt": "2027-01-01T00:00:00.000Z", + "createdAt": "2025-03-26T10:00:00.000Z" + }, + { + "id": "660e8400-e29b-41d4-a716-446655440001", + "name": "Script de reporting mensuel", + "keyPrefix": "xped_live_b2c3d4e5", + "isActive": false, + "lastUsedAt": "2025-02-01T09:00:00.000Z", + "expiresAt": null, + "createdAt": "2025-01-15T08:00:00.000Z" + } +] +``` + +> Les clés complètes ne sont jamais retournées dans cet endpoint. Seul le préfixe est visible. + +--- + +### Révoquer une clé + +```http +DELETE /api-keys/{id} +Authorization: Bearer +``` + +**Réponse `204 No Content`** en cas de succès. + +> La révocation est **immédiate et irréversible**. Toute requête utilisant cette clé sera refusée à partir de cet instant. Pour réactiver l'accès, créez une nouvelle clé. + +--- + +## Utiliser l'API + +Une fois votre clé générée, vous pouvez l'utiliser sur tous les endpoints de l'API Xpeditis. + +### Exemple : Récupérer les bookings + +```http +GET /bookings +X-API-Key: xped_live_a1b2c3d4e5f6... +``` + +### Exemple : Créer un booking + +```http +POST /bookings +X-API-Key: xped_live_a1b2c3d4e5f6... +Content-Type: application/json + +{ + "rateQuoteId": "...", + ... +} +``` + +### Référence complète + +La documentation Swagger interactive est disponible à : +``` +http://localhost:4000/api/docs +``` + +Elle liste tous les endpoints disponibles avec leurs paramètres et schémas de réponse. + +--- + +## Sécurité et bonnes pratiques + +### Stockage des clés + +- ❌ Ne stockez **jamais** une clé API en dur dans votre code source ou vos dépôts Git. +- ✅ Utilisez des **variables d'environnement** (`XPEDITIS_API_KEY=xped_live_...`). +- ✅ Utilisez un **gestionnaire de secrets** (AWS Secrets Manager, HashiCorp Vault, etc.) en production. + +### Rotation des clés + +Effectuez une rotation régulière de vos clés : +1. Créez une nouvelle clé (sans supprimer l'ancienne). +2. Mettez à jour votre système avec la nouvelle clé. +3. Vérifiez que tout fonctionne. +4. Révoquez l'ancienne clé. + +### Rotation d'urgence + +En cas de compromission suspectée : +1. Révoquez immédiatement la clé compromise via `DELETE /api-keys/{id}`. +2. Créez une nouvelle clé. +3. Auditez vos logs d'accès. + +### Permissions + +Une clé API agit au nom de l'utilisateur qui l'a créée, avec son rôle (MANAGER, USER, etc.) et les permissions de son organisation. Les clés héritent des restrictions d'accès de l'utilisateur créateur. + +--- + +## Limites et quotas + +| Limite | Valeur | +|---|---| +| Nombre de clés actives par organisation | 20 | +| Longueur du nom d'une clé | 100 caractères max | +| Rate limiting | Identique aux autres requêtes API | + +Le rate limiting est appliqué par utilisateur (basé sur l'ID utilisateur associé à la clé). + +--- + +## Codes d'erreur + +| Code HTTP | Description | Solution | +|---|---|---| +| `401 Unauthorized` | Clé invalide, expirée ou révoquée | Vérifiez la clé ou créez-en une nouvelle | +| `401 Unauthorized` | Clé bien formée mais hash inconnu | La clé a peut-être été révoquée ou n'existe pas | +| `403 Forbidden` | Abonnement insuffisant | Passez au plan Gold ou Platinium | +| `403 Forbidden` | Abonnement rétrogradé après création de la clé | Mettez à niveau votre abonnement | +| `404 Not Found` | Clé introuvable lors d'une révocation | Vérifiez l'ID de la clé | + +--- + +## Exemples d'intégration + +### cURL + +```bash +# Lister les bookings +curl -X GET https://api.xpeditis.com/bookings \ + -H "X-API-Key: xped_live_votre_cle_ici" \ + -H "Content-Type: application/json" +``` + +### Node.js / TypeScript + +```typescript +const XPEDITIS_API_KEY = process.env.XPEDITIS_API_KEY; +const BASE_URL = 'https://api.xpeditis.com'; + +async function getBookings() { + const response = await fetch(`${BASE_URL}/bookings`, { + headers: { + 'X-API-Key': XPEDITIS_API_KEY!, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Xpeditis API error: ${response.status}`); + } + + return response.json(); +} +``` + +### Python + +```python +import os +import requests + +API_KEY = os.environ['XPEDITIS_API_KEY'] +BASE_URL = 'https://api.xpeditis.com' + +def get_bookings(): + response = requests.get( + f'{BASE_URL}/bookings', + headers={ + 'X-API-Key': API_KEY, + 'Content-Type': 'application/json', + } + ) + response.raise_for_status() + return response.json() +``` + +### PHP + +```php +$apiKey = getenv('XPEDITIS_API_KEY'); +$baseUrl = 'https://api.xpeditis.com'; + +$ch = curl_init("$baseUrl/bookings"); +curl_setopt($ch, CURLOPT_HTTPHEADER, [ + "X-API-Key: $apiKey", + 'Content-Type: application/json', +]); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +$response = curl_exec($ch); +curl_close($ch); + +$data = json_decode($response, true); +``` + +--- + +*Documentation Xpeditis — Accès API v1.0* +*Disponible uniquement sur les plans Gold (899 €/mois) et Platinium (sur devis)* diff --git a/docs/deployment/AWS_COSTS_KUBERNETES.md b/docs/deployment/AWS_COSTS_KUBERNETES.md new file mode 100644 index 0000000..c30cf5e --- /dev/null +++ b/docs/deployment/AWS_COSTS_KUBERNETES.md @@ -0,0 +1,565 @@ +# Estimation des Coûts AWS — Déploiement Production Kubernetes (EKS) + +> Document de référence — Xpeditis 2.0 +> Région cible : `us-east-1` (ou `eu-west-1` pour conformité RGPD) +> Base tarifaire : AWS on-demand, mars 2026 +> MinIO remplacé par S3 + +--- + +## Table des matières + +1. [Architecture cible sur EKS](#1-architecture-cible-sur-eks) +2. [Inventaire des composants analysés](#2-inventaire-des-composants-analysés) +3. [Hypothèses par palier d'utilisateurs](#3-hypothèses-par-palier-dutilisateurs) +4. [Détail des coûts — 100 utilisateurs](#4-détail-des-coûts--100-utilisateurs) +5. [Détail des coûts — 1 000 utilisateurs](#5-détail-des-coûts--1-000-utilisateurs) +6. [Détail des coûts — 10 000 utilisateurs](#6-détail-des-coûts--10-000-utilisateurs) +7. [Tableau récapitulatif](#7-tableau-récapitulatif) +8. [Économies avec Reserved Instances](#8-économies-avec-reserved-instances) +9. [Facteurs de risque et dépassements potentiels](#9-facteurs-de-risque-et-dépassements-potentiels) +10. [Recommandations d'optimisation](#10-recommandations-doptimisation) +11. [Architecture Kubernetes recommandée](#11-architecture-kubernetes-recommandée) + +--- + +## 1. Architecture cible sur EKS + +``` + ┌─────────────────────────────────────────────┐ + │ AWS VPC (Multi-AZ) │ + │ │ + Internet ──── Route53 ──┤── CloudFront ──── ALB ──┬── NestJS Pods │ + │ │ (EKS NodeGroup) │ + │ └── Next.js Pods │ + │ │ + │ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ + │ │ RDS PG15 │ │ElastiCache│ │ S3 │ │ + │ │ Multi-AZ │ │ Redis7 │ │ Bucket │ │ + │ └──────────┘ └──────────┘ └─────────┘ │ + │ │ + │ ┌──────────┐ ┌──────────┐ │ + │ │ SES │ │Secrets │ │ + │ │ Email │ │Manager │ │ + │ └──────────┘ └──────────┘ │ + └─────────────────────────────────────────────┘ +``` + +**Services AWS utilisés :** +| Service AWS | Remplace / Rôle | +|---|---| +| **EKS** | Orchestration des containers (backend NestJS + frontend Next.js) | +| **RDS PostgreSQL 15** | Base de données principale (18 tables, 29 migrations) | +| **ElastiCache Redis 7** | Cache des rate quotes (TTL 15 min), pub/sub WebSocket | +| **S3** | Remplace MinIO — PDFs, documents carrier, exports CSV/Excel | +| **ALB** | Load balancer HTTPS + WebSocket (sticky sessions) | +| **CloudFront** | CDN pour assets frontend et PDFs publics | +| **SES** | 10 types d'emails transactionnels (confirmations, invitations, magic links) | +| **Secrets Manager** | JWT secret, DB passwords, API keys carriers (5 carriers) | +| **Route 53** | DNS | +| **CloudWatch** | Logs (nestjs-pino), métriques, alertes | +| **WAF** | Protection OWASP (Rate limiting déjà implémenté dans NestJS) | +| **NAT Gateway** | Accès internet pour les pods (appels APIs carriers) | + +--- + +## 2. Inventaire des composants analysés + +### Backend NestJS (charges identifiées) + +**Endpoints critiques en charge :** +- `POST /api/v1/rates/search` — appelle **5 carriers APIs en parallèle** (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) avec circuit breaker 5s timeout. C'est le endpoint le plus coûteux en compute. +- `GET /api/v1/rates/csv-search` — filtrage in-memory de fichiers CSV uploadés +- WebSocket `/notifications` — connexions persistantes Socket.IO, pub/sub via Redis + +**Redis (ElastiCache) :** +- Clés : `rate:{origin}:{destination}:{containerType}` — TTL 15 minutes +- Taux de cache hit estimé : 70–90% pendant les heures de bureau +- Pas de cluster mode nécessaire avant ~500 utilisateurs actifs simultanés + +**Base de données (RDS) :** +- 18 tables avec indexes composites +- Tables à forte croissance : `bookings`, `audit_logs`, `notifications`, `rate_quotes` +- `audit_logs` peut grossir très vite (~50–200K lignes/mois à 1 000 users) +- Extensions PostgreSQL : `pg_trgm` (recherche textuelle sur ports) + +**S3 (remplace MinIO) :** +- PDFs booking : 100–500 KB/document, 1 par booking + exports +- Documents carrier : jusqu'à 10 MB/fichier (PDF, images, Excel, CSV) +- Logos d'organisations +- Exports CSV/Excel des bookings + +**Emails (SES) :** +1. Vérification email à l'inscription +2. Mot de passe oublié / reset +3. Email de bienvenue +4. Invitation d'utilisateur (token 1h) +5. Confirmation de booking avec PDF en pièce jointe +6. Demande CSV booking au carrier (magic link) +7. Création compte carrier +8. Reset password carrier +9. Notification nouveaux documents +10. Alerte accès documents + +**Appels APIs externes :** +- 5 carriers APIs consultées à chaque rate search non mis en cache +- Latence estimée : 1–3 secondes par carrier +- Volume estimé : 10–30% des searches frappent les APIs (le reste vient du cache Redis) + +### Frontend Next.js (charges identifiées) + +- Mode `standalone` — container Docker autonome avec Node.js +- **SSR / SSG hybride** — pages dashboard en CSR, landing page statique +- Bundle gzippé estimé : 200–400 KB (React 19 + Shadcn/ui + TanStack Query) +- Leaflet (cartes), Recharts (graphiques), Framer Motion (animations) +- Images optimisées via `next/image` avec support S3 (`**.amazonaws.com`) + +--- + +## 3. Hypothèses par palier d'utilisateurs + +| Paramètre | 100 users | 1 000 users | 10 000 users | +|---|---|---|---| +| Utilisateurs actifs simultanés (pic) | 10–20 | 100–300 | 1 000–3 000 | +| Rate searches / jour | 200–500 | 2 000–8 000 | 20 000–80 000 | +| Bookings créés / mois | 20–50 | 300–800 | 3 000–8 000 | +| Connexions WebSocket simultanées | 10–20 | 100–300 | 1 000–3 000 | +| Emails / mois | 200–500 | 5 000–20 000 | 50 000–200 000 | +| Volume S3 total | 5–20 GB | 100–300 GB | 1–3 TB | +| Upload S3 / mois | 1–5 GB | 20–50 GB | 200–500 GB | +| Trafic sortant APIs carriers / mois | 5–20 GB | 100–300 GB | 1–3 TB | +| Lignes audit_logs cumulées | < 50K | < 1M | < 20M | + +--- + +## 4. Détail des coûts — 100 utilisateurs + +> Phase early-stage / MVP. Architecture sans Multi-AZ sur certains composants pour réduire les coûts. + +### Compute — EKS + +| Ressource | Config | Coût/mois | +|---|---|---| +| EKS Control Plane | 1 cluster | **$73** | +| Worker nodes | 2× `t3.medium` (2 vCPU, 4 GB) — backend + frontend | **$61** | +| **Total compute** | | **$134** | + +> Pods : 2 replicas NestJS (500m CPU / 512Mi chacun) + 1 replica Next.js (250m CPU / 256Mi). HPA désactivé à ce stade. + +### Base de données — RDS PostgreSQL + +| Ressource | Config | Coût/mois | +|---|---|---| +| Instance | `db.t4g.medium` (2 vCPU, 4 GB) — Single-AZ | **$60** | +| Stockage | 20 GB gp3 | **$2** | +| Backups | 7 jours retention | **$2** | +| **Total RDS** | | **$64** | + +### Cache — ElastiCache Redis + +| Ressource | Config | Coût/mois | +|---|---|---| +| Instance | `cache.t4g.micro` (1 nœud, 0.5 GB) | **$12** | + +> Suffisant pour ~10 000 clés rate quotes en cache. Pas de réplication à ce stade. + +### Stockage — S3 + +| Ressource | Volume | Coût/mois | +|---|---|---| +| Stockage standard | 10 GB | **$0.23** | +| Requêtes PUT/GET | ~50 000/mois | **$0.50** | +| Transfert sortant | 5 GB | **$0.45** | +| **Total S3** | | **~$2** | + +### Réseau + +| Ressource | Config | Coût/mois | +|---|---|---| +| ALB | 1 load balancer, ~5 LCU | **$22** | +| NAT Gateway | 1 NAT, ~20 GB data processing | **$34** | +| CloudFront | Distribution basique, ~5 GB transfert | **$5** | +| **Total réseau** | | **$61** | + +### Services managés + +| Ressource | Usage | Coût/mois | +|---|---|---| +| SES | ~500 emails (dont ~50 avec PDF joint ~250 KB) | **$1** | +| Secrets Manager | 12 secrets | **$5** | +| Route 53 | 1 hosted zone + queries | **$2** | +| CloudWatch | Logs 5 GB/mois + métriques de base | **$15** | +| **Total services** | | **$23** | + +### Total Tier 1 — 100 utilisateurs + +| Catégorie | Coût/mois | +|---|---| +| Compute (EKS) | $134 | +| RDS PostgreSQL | $64 | +| ElastiCache Redis | $12 | +| S3 | $2 | +| Réseau (ALB + NAT + CloudFront) | $61 | +| Services (SES + Secrets + R53 + CloudWatch) | $23 | +| **TOTAL** | **~$296/mois** | +| **Avec buffer 15%** | **~$340/mois** | + +--- + +## 5. Détail des coûts — 1 000 utilisateurs + +> Phase croissance. Multi-AZ activé sur RDS, Redis répliqué, autoscaling EKS. + +### Compute — EKS + +| Ressource | Config | Coût/mois | +|---|---|---| +| EKS Control Plane | 1 cluster | **$73** | +| Worker nodes | 3× `t3.xlarge` (4 vCPU, 16 GB) — autoscaling 3–6 nodes | **$362** | +| **Total compute** | | **$435** | + +> Pods : 4 replicas NestJS (1 CPU / 1 GB chacun) + 2 replicas Next.js. HPA activé (CPU > 70%). + +### Base de données — RDS PostgreSQL + +| Ressource | Config | Coût/mois | +|---|---|---| +| Instance | `db.r6g.large` (2 vCPU, 16 GB) — **Multi-AZ** | **$350** | +| Stockage | 100 GB gp3 | **$10** | +| RDS Proxy | Connection pooling (critique pour NestJS) | **$36** | +| Backups | 30 jours retention | **$20** | +| **Total RDS** | | **$416** | + +> RDS Proxy devient essentiel à partir de ~50 connexions concurrentes pour éviter les `too many connections`. + +### Cache — ElastiCache Redis + +| Ressource | Config | Coût/mois | +|---|---|---| +| Instance | `cache.r6g.large` (2 nœuds, 13 GB) — réplication activée | **$242** | + +> La réplication est nécessaire pour le pub/sub WebSocket multi-pods (Socket.IO scale-out). + +### Stockage — S3 + +| Ressource | Volume | Coût/mois | +|---|---|---| +| Stockage standard | 150 GB | **$3.45** | +| Stockage Intelligent-Tiering (archives) | 50 GB | **$2.50** | +| Requêtes PUT/GET | ~500 000/mois | **$3** | +| Transfert sortant | 50 GB | **$4.50** | +| **Total S3** | | **~$13** | + +### Réseau + +| Ressource | Config | Coût/mois | +|---|---|---| +| ALB | 1 load balancer, ~50 LCU | **$38** | +| NAT Gateway | 2 NATs (HA Multi-AZ), ~150 GB data | **$73** | +| CloudFront | ~50 GB transfert (assets + PDFs) | **$25** | +| **Total réseau** | | **$136** | + +### Services managés + +| Ressource | Usage | Coût/mois | +|---|---|---| +| SES | ~15 000 emails/mois | **$1.50** | +| SES — pièces jointes PDF | ~300 bookings × 250 KB | **$0.10** | +| Secrets Manager | 15 secrets | **$6** | +| Route 53 | 1 zone + queries | **$3** | +| CloudWatch | Logs 20 GB/mois + métriques + dashboards | **$50** | +| WAF | 1 WebACL, 5 rules, ~5M requêtes/mois | **$12** | +| **Total services** | | **$73** | + +### Total Tier 2 — 1 000 utilisateurs + +| Catégorie | Coût/mois | +|---|---| +| Compute (EKS) | $435 | +| RDS PostgreSQL (Multi-AZ + Proxy) | $416 | +| ElastiCache Redis (répliqué) | $242 | +| S3 | $13 | +| Réseau (ALB + NAT + CloudFront) | $136 | +| Services (SES + Secrets + R53 + CW + WAF) | $73 | +| **TOTAL** | **~$1 315/mois** | +| **Avec buffer 15%** | **~$1 512/mois** | + +--- + +## 6. Détail des coûts — 10 000 utilisateurs + +> Phase scale. Read replica RDS, Redis cluster mode, autoscaling agressif, WAF renforcé. + +### Compute — EKS + +| Ressource | Config | Coût/mois | +|---|---|---| +| EKS Control Plane | 1 cluster | **$73** | +| Worker nodes — backend | 4× `m6i.xlarge` (4 vCPU, 16 GB) — autoscaling 4–12 | **$560** | +| Worker nodes — frontend | 2× `m6i.large` (2 vCPU, 8 GB) — autoscaling 2–6 | **$140** | +| **Total compute** | | **$773** | + +> Pods NestJS : 8–15 replicas (1.5 CPU / 1.5 GB chacun). HPA + KEDA si adoption de SQS. +> Pods Next.js : 4–8 replicas (500m CPU / 512 Mi chacun). + +### Base de données — RDS PostgreSQL + +| Ressource | Config | Coût/mois | +|---|---|---| +| Instance primaire | `db.r6g.2xlarge` (8 vCPU, 64 GB) — **Multi-AZ** | **$1 402** | +| Read Replica | `db.r6g.xlarge` (analytics + audit queries) | **$350** | +| Stockage | 1 TB gp3 (SSD) | **$100** | +| RDS Proxy | Haute disponibilité | **$100** | +| Backups | 30 jours + point-in-time recovery | **$100** | +| **Total RDS** | | **$2 052** | + +> À 10 000 users, `audit_logs` peut dépasser 10M lignes. Envisager un archivage vers S3/Athena après 90 jours. + +### Cache — ElastiCache Redis + +| Ressource | Config | Coût/mois | +|---|---|---| +| Cluster mode | 3 shards × 2 nœuds `cache.r6g.large` (13 GB/shard) | **$1 452** | + +> Cluster mode nécessaire pour distribuer les ~100 000+ clés rate quotes et supporter le pub/sub à grande échelle. + +### Stockage — S3 + +| Ressource | Volume | Coût/mois | +|---|---|---| +| Stockage Standard | 500 GB (documents actifs < 30 jours) | **$11.50** | +| Stockage Intelligent-Tiering | 1 TB (documents anciens) | **$23** | +| Glacier Instant Retrieval | 2 TB (archives > 6 mois) | **$8** | +| Requêtes PUT/GET | ~5M/mois | **$20** | +| Transfert sortant S3 | 200 GB (via CloudFront réduit les coûts) | **$9** | +| **Total S3** | | **~$72** | + +### Réseau + +| Ressource | Config | Coût/mois | +|---|---|---| +| ALB | 2 load balancers (backend + frontend), ~200 LCU | **$110** | +| NAT Gateway | 2 NATs, ~1 TB appels carriers + Redis | **$111** | +| CloudFront | ~500 GB transfert (assets + PDFs + exports) | **$125** | +| **Total réseau** | | **$346** | + +### Services managés + +| Ressource | Usage | Coût/mois | +|---|---|---| +| SES | ~120 000 emails/mois | **$12** | +| SES — pièces jointes PDF | ~5 000 bookings × 300 KB | **$1.80** | +| Secrets Manager | 20 secrets + 2M API calls/mois | **$18** | +| Route 53 | 2 zones (prod + staging), traffic routing | **$10** | +| CloudWatch | Logs 100 GB/mois + métriques + Container Insights | **$200** | +| WAF + Shield Standard | WebACL, 10 rules, ~50M requêtes/mois, DDoS basique | **$65** | +| KMS | Encryption at rest RDS/S3, rotation clés | **$10** | +| **Total services** | | **$317** | + +### Total Tier 3 — 10 000 utilisateurs + +| Catégorie | Coût/mois | +|---|---| +| Compute (EKS) | $773 | +| RDS PostgreSQL (Multi-AZ + Replica + Proxy) | $2 052 | +| ElastiCache Redis (cluster mode) | $1 452 | +| S3 (avec tiering) | $72 | +| Réseau (ALB + NAT + CloudFront) | $346 | +| Services (SES + Secrets + R53 + CW + WAF + KMS) | $317 | +| **TOTAL** | **~$5 012/mois** | +| **Avec buffer 15%** | **~$5 764/mois** | + +--- + +## 7. Tableau récapitulatif + +| Composant | 100 users | 1 000 users | 10 000 users | +|---|---|---|---| +| EKS (Control Plane + Nodes) | $134 | $435 | $773 | +| RDS PostgreSQL | $64 | $416 | $2 052 | +| ElastiCache Redis | $12 | $242 | $1 452 | +| S3 | $2 | $13 | $72 | +| Réseau (ALB + NAT + CDN) | $61 | $136 | $346 | +| Services managés | $23 | $73 | $317 | +| **TOTAL (on-demand)** | **$296** | **$1 315** | **$5 012** | +| **TOTAL avec buffer 15%** | **~$340** | **~$1 512** | **~$5 764** | + +> **Note hors scope :** Stripe facture 2.9% + $0.30 par transaction. Pour 500 transactions/mois à $500 chacune, cela représente ~$7 400/mois de frais Stripe — à prendre en compte dans la marge business, pas dans l'infra. + +--- + +## 8. Économies avec Reserved Instances + +Engager 1 ou 3 ans sur les ressources stables (RDS, ElastiCache, nœuds EKS de base) permet des économies significatives. + +### 1 an — Savings Plans + +| Composant | Coût on-demand | Après 1 an RI (~35% réduction) | +|---|---|---| +| RDS (1 000 users) | $416 | **~$270** | +| ElastiCache (1 000 users) | $242 | **~$157** | +| EC2 workers EKS (1 000 users) | $362 | **~$235** | +| **Économie mensuelle** | | **~$358/mois** | + +### Impact par palier avec Reserved Instances 1 an + +| Palier | On-demand | Avec RI 1 an | Économie annuelle | +|---|---|---|---| +| 100 users | $296 | ~$220 | **~$912** | +| 1 000 users | $1 315 | ~$950 | **~$4 380** | +| 10 000 users | $5 012 | ~$3 600 | **~$16 944** | + +--- + +## 9. Facteurs de risque et dépassements potentiels + +### 🔴 Risque élevé — Appels APIs carriers + +Chaque `POST /rates/search` sans cache Redis déclenche **5 appels HTTP externes en parallèle**. +À 1 000 users avec 30% de cache miss → ~2 400 appels/heure vers les carriers. +**Impact NAT Gateway :** transfert data $0.045/GB. Si chaque réponse carrier fait 10 KB → ~100 GB/mois → $4.50. +Si les réponses sont plus volumineuses (50 KB+) ou si le cache miss monte → coût × 5 à × 10. + +**Mitigation :** Ajuster le TTL Redis (actuellement 15 min) à 30–60 min pour les routes peu demandées. + +### 🔴 Risque élevé — audit_logs à 10 000 users + +À 10 000 users, `audit_logs` peut atteindre **50–200M lignes/an** (toutes actions loggées). +RDS `db.r6g.2xlarge` avec 1 TB de stockage sera rapidement saturé. +**Mitigation :** Mettre en place un archivage automatique des logs > 90 jours vers S3 + Athena pour les requêtes analytiques. + +### 🟡 Risque moyen — WebSocket à grande échelle + +Socket.IO avec Redis adapter fonctionne bien jusqu'à ~2 000 connexions simultanées sur un cache.r6g.large. +Au-delà, envisager de passer à **Redis Cluster mode** ou à une gateway WebSocket dédiée. + +### 🟡 Risque moyen — PDFs avec pièces jointes emails (SES) + +Les booking confirmations envoient le PDF en **base64 dans le corps de l'email** (détecté dans le code email service). +Pour 5 000 bookings/mois à 300 KB/PDF → **1.5 GB de data SES/mois** → $0.18/mois en plus des emails. +À grande échelle, préférer un **lien S3 signé** dans l'email plutôt qu'une pièce jointe. + +### 🟡 Risque moyen — ElastiCache sous-dimensionné + +Le nombre de clés Redis peut exploser si les recherches sont très diversifiées (nombreuses combinaisons origin/destination/containerType). +Monitorer `cache.CurrItems` et `BytesUsedForCache` dans CloudWatch dès le démarrage. + +### 🟢 Risque faible — S3 + +Les coûts S3 restent maîtrisés avec les politiques de cycle de vie (Intelligent-Tiering + Glacier). +La migration MinIO → S3 est transparente (l'app utilise déjà le SDK AWS S3 v3). + +--- + +## 10. Recommandations d'optimisation + +### Priorité haute (impact fort, faible effort) + +1. **S3 Lifecycle Policies dès le J1** + - Documents > 30 jours → Intelligent-Tiering + - Documents > 180 jours → Glacier Instant Retrieval + - Économie estimée : 50–70% sur les coûts de stockage long terme + +2. **RDS Proxy activé dès 1 000 users** + - NestJS ouvre N connexions par pod × N pods → sans proxy, RDS sature + - `db.r6g.large` supporte ~150 connexions max, 10 pods × 10 connexions = limite atteinte + +3. **CloudFront pour tous les assets S3 publics** + - Élimine le transfert sortant S3 (×8 fois moins cher via CloudFront) + - Mettre en cache les PDFs de booking (signé URL → CloudFront signed URL) + +4. **Reserved Instances 1 an sur RDS + ElastiCache** + - Ces deux services représentent 50–60% de la facture à 1 000+ users + - Le ROI est atteint dès le premier mois comparé à l'on-demand + +### Priorité moyenne + +5. **Archivage audit_logs vers S3 + Athena** + - Créer un job cron mensuel qui exporte les logs > 90 jours en Parquet vers S3 + - Requêtes analytiques via Athena ($5 par TB scanné) + - Libère l'espace RDS et maintient les performances des indexes + +6. **SQS + Lambda pour la génération de PDFs et l'envoi d'emails** + - Actuellement synchrone → bloque le thread NestJS + - Découpler : POST /bookings → enfile en SQS → Lambda génère PDF + envoie email + - Réduit les besoins CPU des pods NestJS (~20% de réduction possible) + +7. **Lien S3 signé dans les emails plutôt que pièce jointe** + - Remplacer le PDF base64 dans SES par un lien CloudFront signé (1h expiry) + - Réduit la taille des emails de 60–70% et évite les filtres anti-spam + +### Priorité basse + +8. **Fargate Spot pour les pods non-critiques** + - Workers de génération PDF/export peuvent tourner sur Fargate Spot (70% moins cher) + +9. **KEDA (Kubernetes Event-Driven Autoscaling)** + - Scaler les pods NestJS selon la profondeur de la file SQS plutôt que le CPU + +--- + +## 11. Architecture Kubernetes recommandée + +### Namespaces + +``` +xpeditis-prod +├── backend # NestJS pods +├── frontend # Next.js pods +└── monitoring # Prometheus + Grafana (optionnel) +``` + +### Sizing des pods par palier + +#### Backend NestJS + +| Palier | Replicas (base) | Replicas (max HPA) | CPU request/limit | RAM request/limit | +|---|---|---|---|---| +| 100 users | 2 | 3 | 500m / 1500m | 512Mi / 1Gi | +| 1 000 users | 4 | 8 | 750m / 2000m | 768Mi / 1.5Gi | +| 10 000 users | 8 | 20 | 1000m / 3000m | 1Gi / 2Gi | + +#### Frontend Next.js (standalone) + +| Palier | Replicas (base) | Replicas (max HPA) | CPU request/limit | RAM request/limit | +|---|---|---|---|---| +| 100 users | 1 | 2 | 250m / 1000m | 256Mi / 512Mi | +| 1 000 users | 2 | 4 | 500m / 1500m | 512Mi / 1Gi | +| 10 000 users | 3 | 8 | 750m / 2000m | 512Mi / 1Gi | + +### Variables d'environnement Kubernetes (Secrets) + +À stocker dans **AWS Secrets Manager** et monter via External Secrets Operator : +- `DATABASE_URL` → RDS connection string avec RDS Proxy endpoint +- `REDIS_HOST` / `REDIS_PASSWORD` → ElastiCache primary endpoint +- `JWT_SECRET` → rotation automatique mensuelle recommandée +- `AWS_S3_BUCKET` → remplace `MINIO_ENDPOINT` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` +- `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` +- API keys × 5 carriers (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) +- `SMTP_HOST` → SES SMTP endpoint + credentials + +### Ingress (ALB Ingress Controller) + +```yaml +# Règles d'ingress recommandées +/api/* → backend service (port 4000) +/socket.io/* → backend service (sticky sessions activées) +/* → frontend service (port 3000) +``` + +> Le WebSocket Socket.IO nécessite `alb.ingress.kubernetes.io/target-group-attributes: stickiness.enabled=true` + +--- + +## Résumé exécutif + +| | 100 users | 1 000 users | 10 000 users | +|---|---|---|---| +| **Coût mensuel estimé** | **~$340** | **~$1 500** | **~$5 800** | +| **Coût annuel** | **~$4 080** | **~$18 000** | **~$69 600** | +| **Avec RI 1 an** | **~$2 640** | **~$11 400** | **~$43 200** | +| **Poste dominant** | Compute + Réseau | RDS Multi-AZ | RDS + Redis | +| **Principal risque** | Sur-dimensionnement | Connexions DB | audit_logs volume | + +> Les prix sont indicatifs en us-east-1 (on-demand, mars 2026). `eu-west-1` (Paris : `eu-west-3`) est ~5–10% plus cher. +> Ces estimations **excluent** : les frais Stripe (2.9% + $0.30/transaction), les licences SaaS tierces éventuelles, et les coûts de développement/opération. diff --git a/docs/deployment/CLOUD_COST_COMPARISON.md b/docs/deployment/CLOUD_COST_COMPARISON.md new file mode 100644 index 0000000..4e21dc6 --- /dev/null +++ b/docs/deployment/CLOUD_COST_COMPARISON.md @@ -0,0 +1,548 @@ +# Comparatif Cloud — Coûts de Production Xpeditis 2.0 +## Aide à la décision : AWS vs GCP vs Azure vs Hetzner vs OVHcloud vs DigitalOcean vs Scaleway + +> Analyse réalisée le 23 mars 2026 — prix on-demand vérifiés sur les sites officiels +> Scénarios : 100 / 1 000 / 10 000 utilisateurs sur Kubernetes +> MinIO → stockage objet S3-compatible de chaque fournisseur +> ⚠️ **Hetzner augmente ses prix de ~35% le 1er avril 2026** — prix actuels ET futurs indiqués + +--- + +## Table des matières + +1. [Ce que l'app exige comme infrastructure](#1-ce-que-lapp-exige-comme-infrastructure) +2. [Vue d'ensemble des fournisseurs](#2-vue-densemble-des-fournisseurs) +3. [Tableau comparatif — 100 utilisateurs](#3-tableau-comparatif--100-utilisateurs) +4. [Tableau comparatif — 1 000 utilisateurs](#4-tableau-comparatif--1-000-utilisateurs) +5. [Tableau comparatif — 10 000 utilisateurs](#5-tableau-comparatif--10-000-utilisateurs) +6. [Récapitulatif global](#6-récapitulatif-global) +7. [Analyse détaillée par fournisseur](#7-analyse-détaillée-par-fournisseur) +8. [Option hybride recommandée](#8-option-hybride-recommandée) +9. [Matrice de décision](#9-matrice-de-décision) +10. [Recommandation finale](#10-recommandation-finale) + +--- + +## 1. Ce que l'app exige comme infrastructure + +Avant de comparer, voici ce que Xpeditis consomme réellement (issu de l'analyse du code) : + +| Composant | Besoin réel | Impact coût | +|---|---|---| +| **Kubernetes** | 2–15 pods NestJS + 1–8 pods Next.js | Control plane + nodes | +| **PostgreSQL 15** | 18 tables, audit_logs volumineuses, pg_trgm | Instance avec au moins 4 GB RAM en prod | +| **Redis 7** | Cache rate quotes TTL 15 min + pub/sub WebSocket | Au moins 1 GB, réplication si multi-pods | +| **Stockage objet (S3)** | PDFs booking, docs carrier (max 10 MB), exports | ~10 GB/100 users → ~1 TB/10 000 users | +| **Load Balancer** | WebSocket sticky sessions obligatoires | 1 LB avec support WS | +| **Email** | 10 types d'emails, PDFs en pièce jointe | SES ou SMTP tiers | +| **Secrets** | JWT, DB passwords, 5 API keys carriers | Secrets Manager ou équivalent | +| **Appels externes** | 5 carriers APIs à chaque rate search (circuit breaker 5s) | Trafic sortant → coût NAT/egress | +| **DNS + TLS** | Route 53 ou équivalent + cert-manager | ~$1-3/mois | + +**Spécificité critique :** Le SDK AWS S3 v3 est déjà utilisé dans le code avec support d'endpoint personnalisé → **zéro modification de code** pour utiliser n'importe quel stockage S3-compatible (Hetzner Object Storage, DO Spaces, OVH, Scaleway). + +--- + +## 2. Vue d'ensemble des fournisseurs + +| Fournisseur | Kubernetes | DB Managée | RGPD EU | Egress gratuit | Self-managed DB | Difficulté ops | +|---|---|---|---|---|---|---| +| **Hetzner** 🇩🇪 | k3s free / HKE free | ❌ (tiers: Neon, Railway) | ✅ Allemagne/Finlande | Non ($0.045/GB) | ✅ DIY | ⭐⭐⭐ Élevée | +| **OVHcloud** 🇫🇷 | MKS gratuit (CP) | ✅ Partiel (limité) | ✅ France | ✅ **Oui** | ✅ DIY sur VM | ⭐⭐ Moyenne | +| **DigitalOcean** 🇺🇸 | DOKS gratuit (CP) | ✅ PG + Redis | ❌ US (AMS dispo) | Non ($0.01/GB) | Optionnel | ⭐ Faible | +| **Scaleway** 🇫🇷 | Kapsule gratuit (CP) | ✅ PostgreSQL | ✅ Paris | ✅ Inclus | Optionnel | ⭐ Faible | +| **GCP** 🌐 | GKE Autopilot/Standard | ✅ Cloud SQL | ✅ europe-west1 | Non ($0.12/GB) | Optionnel | ⭐ Faible | +| **AWS** 🌐 | EKS ($73/mois CP) | ✅ RDS + ElastiCache | ✅ eu-west-3 Paris | Non ($0.09/GB) | Optionnel | ⭐ Faible | +| **Azure** 🌐 | AKS gratuit (CP) | ✅ Flexible Server | ✅ westeurope | Non ($0.087/GB) | Optionnel | ⭐ Faible | +| **Vultr** 🇺🇸 | VKE ($10/mois CP) | ✅ Partiel | ❌ US | Non | Optionnel | ⭐ Faible | + +--- + +## 3. Tableau comparatif — 100 utilisateurs + +**Hypothèses :** 10–20 utilisateurs simultanés, ~50 bookings/mois, 500 rate searches/jour, 5 GB stockage, 500 emails/mois + +### Configuration retenue +- 2 worker nodes (backend NestJS × 2 pods + frontend Next.js × 1 pod) +- PostgreSQL 2 vCPU / 4-8 GB +- Redis 0.5-1 GB +- Stockage objet 20 GB +- 1 Load Balancer + +| Fournisseur | Compute | DB | Redis | Stockage | LB/Réseau | **Total/mois** | +|---|---|---|---|---|---|---| +| **Hetzner** *(k3s self-managed)* | 2× CX32: **€13.60** | CX22 self-host: **€3.79** | Shared: **€0** | Object Storage: **€4.99** | LB11: **€5.39** | **🥇 ~€28 (~$30)** | +| **Hetzner** *(post 1 avril)* | 2× CX32: **€18.38** | CX22: **€5.11** | Shared: **€0** | **€4.99** | LB11: **€7.49** | **~€36 (~$39)** | +| **DigitalOcean** *(DOKS)* | 2× s-2vcpu-4gb: **$48** | PG Basic 2 GB: **$30** | Redis 1 GB: **$15** | Spaces: **$5** | LB: **$12** | **~$110** | +| **OVHcloud** *(MKS)* | 2× B2-7: **€48** | B2-7 self-host: **€24** | Shared pod: **€0** | OBJ 20 GB: **€0.22** | LB: **€15** | **~€87 (~$94)** | +| **Scaleway** *(Kapsule)* | 2× DEV1-L: **€61** | DB-DEV-S self-host: **€14** | DEV1-S: **€7** | OBJ 20 GB: **€0.20** | LB: **€9.99** | **~€92 (~$100)** | +| **GCP** *(GKE Autopilot)* | Autopilot pods: **~$60** | Cloud SQL db-f1-small: **$26** | Memorystore 1 GB: **$39** | GCS 20 GB: **$0.40** | LB: **$18** | **~$143** | +| **Vultr** *(VKE)* | 2× 2vCPU/4GB: **$40** | PG managed ~$30: **$30** | Redis: **$15** | Block: **$5** | VKE CP: **$10** | **~$100** | +| **AWS** *(EKS, eu-west-3)* | 2× t3.medium: **$69** | db.t4g.medium: **$60** | cache.t4g.micro: **$12** | S3 20 GB: **$0.46** | EKS: **$73** + ALB: **$22** + NAT: **$34** | **~$270** | +| **Azure** *(AKS)* | 2× D2s_v3: **$140** | PG B1ms flex: **$12**\* | Redis C0: **$14** | Blob 20 GB: **$0.36** | AKS: **$0** + LB: **$18** | **~$185** | +| **GCP** *(GKE Standard)* | GKE CP: **$72** + 2× e2-std-2: **$97** | Cloud SQL: **$100** | Memorystore: **$39** | GCS: **$0.40** | LB: **$18** | **~$327** | + +> \* Azure B1ms est très limité (1 vCPU), insuffisant pour une production sérieuse. Compte tenu des vraies instances : ~$165+ + +**⚠️ AWS à 100 users :** Le control plane EKS ($73) + NAT Gateway ($34) représentent ~40% de la facture — cher pour si peu d'utilisateurs. + +--- + +## 4. Tableau comparatif — 1 000 utilisateurs + +**Hypothèses :** 100–300 simultanés, ~500 bookings/mois, 5 000 searches/jour, 200 GB stockage, 15 000 emails/mois, PostgreSQL Multi-AZ ou HA + +### Configuration retenue +- 3–4 worker nodes avec HPA +- PostgreSQL 4 vCPU / 8-16 GB **avec HA/réplication** +- Redis 1-2 GB répliqué (pub/sub WebSocket multi-pods) +- Stockage objet 200 GB +- 1 Load Balancer + +| Fournisseur | Compute | DB (HA) | Redis | Stockage | LB/Réseau | **Total/mois** | +|---|---|---|---|---|---|---| +| **Hetzner** *(k3s self-managed)* | 1×CX22 CP + 3×CX42: **€60.60** | CX32 PG + 100 GB vol: **€11.20** | CX22 Redis: **€3.79** | Object Storage: **€4.99** | LB21: **€16.40** | **🥇 ~€97 (~$105)** | +| **Hetzner** *(post 1 avril)* | **€81.69** | **€15.12** | **€5.11** | **€4.99** | **€22.14** | **~€129 (~$140)** | +| **OVHcloud** *(MKS + self-hosted)* | 3× B2-15: **€138** | B2-15 PG primary + B2-7 replica: **€70** | B2-7 Redis: **€24** | OBJ 200 GB: **€2.20** | LB: **€15** | **~€249 (~$270)** | +| **DigitalOcean** *(DOKS + managed)* | 3× s-4vcpu-8gb: **$144** | PG 4 GB HA: **$120** | Redis 2 GB: **$30** | Spaces 200 GB: **$10** | LB: **$12** | **~$316** | +| **Scaleway** *(Kapsule + managed)* | 3× PLAY2-MICRO: **€118** | DB-PRO2-XXS managed: **€80** | PLAY2-NANO Redis: **€20** | OBJ 200 GB: **€2** | LB: **€9.99** | **~€230 (~$249)** | +| **Vultr** *(VKE)* | 3× 4vCPU/8GB: **$120** | PG managed ~$60: **$60** | Redis: **$30** | Block 200 GB: **$20** | VKE CP: **$10** | **~$240** | +| **GCP** *(GKE Autopilot)* | Autopilot 8 pods: **~$200** | Cloud SQL n1-std-2 HA: **$250** | Memorystore 2 GB: **$78** | GCS: **$5** | LB: **$20** | **~$553** | +| **AWS** *(EKS, eu-west-3)* | EKS CP: **$73** + 3×t3.xlarge: **$414** | db.r6g.large Multi-AZ: **$496** | cache.r6g.large: **$150** | S3 200 GB: **$4.60** | ALB: **$38** + 2×NAT: **$73** | **~$1 249** | +| **Azure** *(AKS + managed)* | AKS CP: **$72** + 3×D4s_v3: **$420** | PG D2ds_v6 HA: **$327** | Redis C2 Standard: **$109** | Blob: **$4** | LB: **$25** | **~$957** | +| **GCP** *(GKE Standard)* | GKE CP: **$72** + 3×e2-std-4: **$292** | Cloud SQL n1-std-4 HA: **$460** | Memorystore 2 GB: **$78** | GCS: **$5** | LB: **$20** + NAT: **$20** | **~$947** | + +--- + +## 5. Tableau comparatif — 10 000 utilisateurs + +**Hypothèses :** 1 000–3 000 simultanés, ~5 000 bookings/mois, 50 000 searches/jour, 1-2 TB stockage, 150 000 emails/mois + +### Configuration retenue +- 6–8 worker nodes avec autoscaling +- PostgreSQL 8 vCPU / 32 GB HA + read replica +- Redis cluster/réplication (WebSocket + cache massif) +- Stockage objet 1 TB avec lifecycle policies +- 2 Load Balancers + +| Fournisseur | Compute | DB (HA + replica) | Redis | Stockage | LB/Réseau | **Total/mois** | +|---|---|---|---|---|---|---| +| **Hetzner** *(k3s self-managed)* | 3×CX22 CP + 6×CX52: **€227** | CCX23 PG+replica CX32: **€35** + 500 GB vol: **€22** | CX42 Redis: **€16.40** | Object Storage + extra: **€15** | LB31: **€29** | **🥇 ~€344 (~$373)** | +| **Hetzner** *(post 1 avril)* | **€306** | **€47 + €29** | **€21.49** | **€15** | **€39** | **~€458 (~$496)** | +| **OVHcloud** *(MKS + self-hosted)* | 5× B2-30: **€470** | B2-30 PG + B2-15 replica: **€140** | B2-15 Redis: **€46** | OBJ 1 TB: **€11** | 2× LB: **€30** | **~€697 (~$756)** | +| **DigitalOcean** *(DOKS + managed)* | 6× g-4vcpu-16gb: **$720** | PG 8 GB HA: **$240** | Redis 4 GB HA: **$60** | Spaces + CDN: **$40** | 2× LB: **$24** | **~$1 084** | +| **Scaleway** *(Kapsule + managed)* | 5× GP1-S: **€683** | DB-PRO2-S managed (8 vCPU): **€320** | PLAY2-MICRO Redis: **€39** | OBJ 1 TB: **€10** | 2× LB: **€20** | **~€1 072 (~$1 163)** | +| **Vultr** *(VKE)* | 6× 4vCPU/16GB HP: **$528** | PG managed large ~$120: **$120** | Redis cluster: **$60** | Block: **$50** | VKE CP: **$10** | **~$768** | +| **GCP** *(GKE Autopilot)* | Autopilot 20 pods: **~$600** | Cloud SQL n1-std-8 HA: **$800** | Memorystore 5 GB: **$195** | GCS + CDN: **$30** | LB: **$50** | **~$1 675** | +| **AWS** *(EKS, eu-west-3)* | EKS CP: **$73** + 6×m6i.xlarge: **$981** | db.r6g.2xlarge Multi-AZ: **$1 518** + replica: **$700** | cache.r6g.xlarge cluster: **$1 452** | S3 + CDN: **$72** | 2×ALB: **$110** + 2×NAT: **$111** | **~$5 017** | +| **Azure** *(AKS + managed)* | AKS CP: **$72** + 6×D4s_v3: **$840** | PG D4ds_v6 HA: **$654** + replica: **$327** | Redis P1: **$394** | Blob + CDN: **$50** | 2× LB: **$40** | **~$2 377** | +| **GCP** *(GKE Standard)* | GKE CP: **$72** + 6×e2-std-4: **$583** | Cloud SQL n1-std-8 HA: **$1 600** | Memorystore 5 GB: **$195** | GCS + CDN: **$30** | LB: **$60** | **~$2 540** | + +--- + +## 6. Récapitulatif global + +### Coût mensuel all-in (production viable) + +| Fournisseur | 100 users | 1 000 users | 10 000 users | RGPD EU | Ops requis | +|---|---|---|---|---|---| +| 🥇 **Hetzner (self-managed, actuel)** | **€28** | **€97** | **€344** | ✅ 🇩🇪 | ⭐⭐⭐ Élevé | +| 🥈 **Hetzner (post 1 avril)** | **€36** | **€129** | **€458** | ✅ 🇩🇪 | ⭐⭐⭐ Élevé | +| 🥉 **OVHcloud (self-hosted DB)** | **€87** | **€249** | **€697** | ✅ 🇫🇷 | ⭐⭐ Moyen | +| 4️⃣ **Vultr VKE** | **$100** | **$240** | **$768** | ❌ US | ⭐ Faible | +| 5️⃣ **DigitalOcean DOKS** | **$110** | **$316** | **$1 084** | ❌ US\* | ⭐ Faible | +| 6️⃣ **Scaleway Kapsule** | **€92** | **€230** | **€1 072** | ✅ 🇫🇷 | ⭐ Faible | +| 7️⃣ **GCP GKE Autopilot** | **$143** | **$553** | **$1 675** | ✅ Belgium | ⭐ Faible | +| 8️⃣ **Azure AKS** | **$185** | **$957** | **$2 377** | ✅ Netherlands | ⭐ Faible | +| 9️⃣ **GCP GKE Standard** | **$327** | **$947** | **$2 540** | ✅ Belgium | ⭐ Faible | +| 🔟 **AWS EKS** | **$270** | **$1 249** | **$5 017** | ✅ 🇫🇷 Paris | ⭐ Faible | + +> \* DigitalOcean propose une région Amsterdam (AMS3) pour la conformité RGPD européenne. + +### Rapport qualité/prix — Score global + +``` +Hetzner post-1er avril ████████████████████ 1x (référence) +OVHcloud ████████████░░░░░░░░ 3x vs Hetzner +Vultr ████████████░░░░░░░░ 3x +DigitalOcean ████████░░░░░░░░░░░░ 3.5x +Scaleway ████████░░░░░░░░░░░░ 3.5x +GCP Autopilot ████░░░░░░░░░░░░░░░░ 5x +Azure ███░░░░░░░░░░░░░░░░░ 6x +GCP Standard ██░░░░░░░░░░░░░░░░░░ 7x +AWS EKS █░░░░░░░░░░░░░░░░░░░ 13x (à 10 000 users) +``` + +--- + +## 7. Analyse détaillée par fournisseur + +### 🟢 Hetzner Cloud — Le moins cher de loin + +**Avantages :** +- Prix **5 à 15× inférieurs** à AWS/GCP pour des ressources équivalentes +- Serveurs ARM64 Ampere (CAX-series) : encore moins chers et performants pour des workloads Node.js +- Object Storage S3-compatible inclus dès €4.99/mois (1 TB) — **remplace MinIO directement** +- Kubernetes gratuit (HKE control plane free ou k3s self-managed) +- Data centers en Allemagne et Finlande → RGPD natif +- Trafic entrant gratuit, sortant €0.045/GB (bien moins qu'AWS) +- [hetzner-k3s](https://github.com/vitobotta/hetzner-k3s) : cluster Kubernetes en 5 minutes avec 1 commande +- **⚠️ Hausse de prix ~35% le 1er avril 2026** — même après, Hetzner reste 4–10× moins cher qu'AWS + +**Inconvénients :** +- **Pas de base de données managée native** → il faut soit self-hoster, soit utiliser un service tiers +- Pas de managed Redis natif +- Support limité (pas de support 24/7 téléphonique enterprise) +- PostgreSQL self-hosted = vous gérez les backups, les mises à jour, la HA +- Moins de services managés (pas d'équivalent IAM, Secrets Manager, WAF natifs) + +**Options pour la base de données sur Hetzner :** + +| Solution | Prix | Trade-off | +|---|---|---| +| Self-hosted PG sur CX32 | €6.80-9.19/mois | Vous gérez tout | +| **Neon.tech** (serverless PG) | $0-19/mois (free tier généreux) | Parfait pour dev, pas pour 10 000 users | +| **Railway.app** (managed PG) | $5/mois + usage | Simple, backups auto | +| **Supabase** (managed PG) | $25/mois (Pro) | PostgreSQL + Auth + Storage | +| **Ubicloud PG sur Hetzner** | ~$10-50/mois | Managed, tourne sur Hetzner | + +**Verdict Hetzner :** Idéal si vous avez les compétences ops pour gérer PostgreSQL et Redis. Le ROI est massif. Recommandé dès la phase MVP. + +--- + +### 🟡 OVHcloud — Le meilleur compromis européen + +**Avantages :** +- **Entreprise française**, RGPD natif, données en France +- **Zéro frais d'egress** — si votre app génère beaucoup de trafic sortant (carrier APIs, PDFs), c'est une économie réelle +- MKS Managed Kubernetes control plane **gratuit** +- Pricing prévisible sans surprises sur la facture +- Object Storage S3-compatible à €0.011/GB (moins cher que AWS S3) +- **Bonne alternative à AWS pour les clients qui exigent la souveraineté française** + +**Inconvénients :** +- Interfaces et UX moins polies qu'AWS/DO +- CloudDB (DB managée) est limitée en ressources et fonctionnalités +- Support parfois lent selon les retours communauté +- Moins de services managés (Redis non managé natif) +- Pas de CDN aussi performant que CloudFront + +**Verdict OVHcloud :** Excellent choix si la souveraineté des données en France est un critère client ou réglementaire. L'absence d'egress est un vrai avantage sur les gros volumes. + +--- + +### 🟡 DigitalOcean — Le plus simple à utiliser + +**Avantages :** +- Interface la plus simple et intuitive du marché +- **Managed PostgreSQL + Redis de qualité** à prix raisonnable +- DOKS (Kubernetes) avec control plane **gratuit** +- DO Spaces : S3-compatible, €5/mois inclut 250 GB + 1 TB egress — **parfait pour Xpeditis** +- Excellente documentation, nombreux tutoriels +- Support réactif +- Load Balancer $12/mois sans surprise + +**Inconvénients :** +- Siège aux USA (mais région Amsterdam disponible pour RGPD) +- Prix compute assez élevés vs Hetzner (×3-4) +- Pas de Reserved Instances / économies long terme +- Redis pricing identique à PostgreSQL (peut sembler cher pour un cache) + +**Verdict DigitalOcean :** Le meilleur choix si vous voulez **minimiser le temps d'opération** et que vous avez un budget correct. Pricing transparent, aucune surprise sur la facture (contrairement à AWS). + +--- + +### 🟡 Scaleway — L'alternative française moderne + +**Avantages :** +- **Entreprise française** (Iliad group), data centers à Paris +- RGPD natif, conformité HDS disponible (santé) +- Kapsule Kubernetes control plane **gratuit** +- Instances ARM64 disponibles (très économiques) +- Object Storage S3-compatible avec egress inclus +- API moderne, bonne DX + +**Inconvénients :** +- **Managed databases chères** (€80/mois pour 2 vCPU / 8 GB) +- Moins mature qu'AWS/GCP pour les fonctionnalités avancées +- Catalogue de services plus limité +- Moins de régions disponibles + +**Verdict Scaleway :** Intéressant si vous restez en self-hosted pour la DB ou si le contexte légal exige France. Les managed databases sont trop chères pour un early-stage. + +--- + +### 🔴 AWS EKS — Le plus puissant, le plus cher + +**Avantages :** +- Écosystème le plus complet (IAM, KMS, Secrets Manager, WAF, CloudFront, SES...) +- SLA enterprise, support 24/7, certifications (ISO 27001, SOC2, HDS France) +- RDS Multi-AZ battle-tested, ElastiCache Redis géré en perfection +- CloudFront CDN mondial +- Région Paris (eu-west-3) pour conformité RGPD française +- Idéal si vous intégrez avec d'autres services AWS (Maersk utilise AWS, par exemple) + +**Inconvénients :** +- **EKS control plane $73/mois fixe** — cher pour 100 users +- **NAT Gateway $33/mois par AZ** + $0.045/GB traitement → coût caché significatif +- **Facturation complexe** : les surprises de facture AWS sont légendaires +- RDS Multi-AZ double le coût de la DB +- ElastiCache explose à 10 000 users ($1 452/mois pour Redis cluster) +- Pas compétitif sur le pure coût compute + +**Verdict AWS :** Justifié uniquement si vous avez des clients enterprise qui l'exigent contractuellement, ou si vous êtes déjà profondément intégré dans l'écosystème AWS. Pour un SaaS maritime en croissance, le surcoût n'est pas justifiable avant 10 000+ users. + +--- + +### 🔶 GCP — Meilleur rapport qualité/prix parmi les hyperscalers + +**Avantages :** +- **GKE Autopilot** : pas de gestion des nodes, facturation à la pod (potentiellement économique pour charges variables) +- Réseau performant, BigQuery pour analytics +- Cloud SQL plus simple à configurer qu'AWS RDS +- Sustained Use Discounts automatiques (pas besoin de Reserved Instances) + +**Inconvénients :** +- Cloud SQL coûteux en HA (doublement) +- Memorystore Redis pricing identique à ElastiCache +- Egress coûteux ($0.12/GB vs $0.09 AWS) +- Interface moins intuitive qu'AWS/Azure + +**Verdict GCP :** Alternative intéressante à AWS avec Autopilot pour les charges variables. Mais Cloud SQL HA reste cher. Pas compétitif face à DO/Hetzner. + +--- + +### 🔶 Azure — Le moins intéressant pour ce projet + +**Avantages :** +- AKS control plane gratuit +- Bien intégré si déjà client Microsoft +- PostgreSQL Flexible Server correct + +**Inconvénients :** +- Worker nodes (VM) les plus chers des hyperscalers +- Azure Cache for Redis pricing élevé (P1 = $394/mois) +- Pricing complexe et souvent plus élevé qu'AWS à périmètre équivalent +- Moins de services maritimes/logistiques spécifiques + +**Verdict Azure :** Aucun avantage notable pour Xpeditis. AWS est plus mature pour ce type de projet si vous allez sur un hyperscaler. + +--- + +## 8. Option hybride recommandée + +La meilleure stratégie coût/risque pour Xpeditis est une **approche hybride** : compute sur Hetzner (ou OVH), services critiques sur des managed services spécialisés. + +``` +┌─────────────────────────────────────────────────────┐ +│ Xpeditis — Architecture Hybride │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Hetzner Cloud (compute + réseau) │ │ +│ │ │ │ +│ │ k3s cluster │ │ +│ │ ├── NestJS pods (2-15 replicas) │ │ +│ │ ├── Next.js pods (1-8 replicas) │ │ +│ │ └── Traefik Ingress + cert-manager │ │ +│ │ │ │ +│ │ Hetzner Object Storage │ │ +│ │ (S3-compatible, 0 changement de code) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Services managés externes (prix fixes) │ │ +│ │ │ │ +│ │ Neon.tech / Railway ──► PostgreSQL managé │ │ +│ │ Upstash Redis ──► Redis serverless │ │ +│ │ Brevo / Postmark ──► Email SMTP │ │ +│ │ Cloudflare ──► CDN + WAF (free) │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +### Coûts de l'architecture hybride + +| Service | Fournisseur | 100 users | 1 000 users | 10 000 users | +|---|---|---|---|---| +| Compute + réseau | Hetzner (post-avril) | €36 | €129 | €458 | +| PostgreSQL managé | [Neon.tech](https://neon.tech) Pro | $19 | $69 | $700 | +| Redis managé | [Upstash](https://upstash.com) | $0 (free) | $10 | $120 | +| Email | [Brevo](https://brevo.com) (ex-Sendinblue) | $0 (free 300/j) | $9 | $49 | +| CDN + WAF | [Cloudflare](https://cloudflare.com) | $0 (free) | $0 | $0-20 | +| DNS | Cloudflare | $0 | $0 | $0 | +| **TOTAL** | | **~€65 (~$70)** | **~€230 (~$250)** | **~€1 400 (~$1 520)** | + +> **Avantage Neon.tech :** PostgreSQL serverless, scale automatique, branchement de DB pour dev/staging, backups automatiques. Compatible avec le TypeORM de Xpeditis sans changement. + +> **Avantage Upstash Redis :** Pricing pay-per-use, pas de cluster à gérer. Parfait pour le cache rate quotes et le pub/sub WebSocket à petite/moyenne échelle. + +--- + +## 9. Matrice de décision + +Choisissez votre scenario selon vos priorités : + +### Critère 1 — Budget serré (early-stage, bootstrapped) + +``` +Budget < €100/mois → Hetzner self-managed + Neon.tech + Upstash +``` + +**Recommandé :** Architecture hybride Hetzner ci-dessus. +**Risque :** Vous gérez vous-même k3s, les mises à jour de nœuds, la surveillance. +**Mitigation :** Utiliser `hetzner-k3s` (automatise 90% des ops K8s) et des managed services tiers. + +--- + +### Critère 2 — Balance coût / sérénité (série A, 1-5 devs) + +``` +Budget €200-500/mois → DigitalOcean DOKS ou OVHcloud MKS +``` + +**DigitalOcean** si vous voulez la simplicité maximale et que les données hors-EU ne sont pas bloquantes. +**OVHcloud** si vous avez des clients qui exigent la souveraineté française des données. + +--- + +### Critère 3 — Conformité RGPD maximale + souveraineté française + +``` +Données en France requises → OVHcloud (Paris) ou Scaleway (Paris) ou AWS eu-west-3 (Paris) +``` + +- **Budget raisonnable :** OVHcloud (€249/mois à 1 000 users vs $1 249 AWS) +- **Conformité enterprise :** AWS eu-west-3 (certifications HDS, ISO 27001 France) + +--- + +### Critère 4 — Croissance rapide, clients enterprise (scale-up) + +``` +10 000+ users + SLA enterprise → AWS eu-west-3 ou GCP europe-west1 +``` + +Justifié si : +- Clients exigent des certifications spécifiques (HDS pour santé, PCI-DSS) +- Contrats avec pénalités SLA +- Intégration avec d'autres services cloud (ex: Maersk/MSC utilisent AWS) +- Équipe ops dédiée pour gérer les coûts AWS + +--- + +### Critère 5 — Vous voulez tout gérer vous-même (max économies) + +``` +VPS self-managed → Hetzner (k3s + PostgreSQL + Redis sur VM dédiées) +``` + +**Stack complète self-managed sur Hetzner :** + +| Composant | Solution | Outil | +|---|---|---| +| Kubernetes | k3s via hetzner-k3s | [github.com/vitobotta/hetzner-k3s](https://github.com/vitobotta/hetzner-k3s) | +| PostgreSQL | Docker + pg_auto_failover ou Patroni | Ou simplement 1 VM dédiée + cron backup S3 | +| Redis | Docker single node | Replication manuelle si besoin | +| Stockage | Hetzner Object Storage | SDK déjà configuré dans Xpeditis | +| TLS | cert-manager + Let's Encrypt | Déjà dans l'écosystème k3s | +| Monitoring | Grafana + Prometheus | Stack kube-prometheus-stack | +| Emails | Brevo / Postmark SMTP | Changer SMTP_HOST dans .env | +| CDN | Cloudflare (gratuit) | Proxy devant Hetzner LB | + +--- + +## 10. Recommandation finale + +### Pour Xpeditis 2.0 — Recommandation par phase + +--- + +#### Phase MVP / Lancement (0 → 100 users) +**→ Hetzner Cloud + services managés tiers** + +``` +Budget cible : €65-80/mois +``` + +| Composant | Solution | Coût | +|---|---|---| +| K8s cluster | 2× CX32 + k3s | €18.38 (post-avril) | +| LB | LB11 Hetzner | €7.49 | +| PostgreSQL | Neon.tech Pro | $19 | +| Redis | Upstash free tier | $0 | +| Stockage | Hetzner Object Storage | €4.99 | +| Email | Brevo free (300/j) | €0 | +| CDN + WAF | Cloudflare free | €0 | +| **TOTAL** | | **~€55/mois** | + +**Pourquoi :** À ce stade, le coût doit être minimum. Le risque technique d'un PostgreSQL managé par Neon est faible et bien inférieur à se gérer soi-même. + +--- + +#### Phase Croissance (100 → 1 000 users) +**→ OVHcloud MKS ou DigitalOcean DOKS** + +``` +Budget cible : €200-350/mois +``` + +**Si RGPD/souveraineté française critique :** +- OVHcloud MKS (3× B2-15) + PostgreSQL self-hosted sur B2-15 + Redis sur B2-7 +- **~€249/mois** + +**Si priorité simplicité :** +- DigitalOcean DOKS (3× s-4vcpu-8gb) + Managed PG 4 GB HA + Managed Redis +- **~$316/mois** + +**Pourquoi pas Hetzner ici ?** La gestion de PostgreSQL HA (Patroni + etcd) + Redis Sentinel devient complexe quand le trafic augmente et que vous n'avez pas d'équipe ops dédiée. + +--- + +#### Phase Scale (1 000 → 10 000 users) +**→ OVHcloud ou DigitalOcean (selon RGPD)** + +``` +Budget cible : €700-1 100/mois +``` + +| Fournisseur | Coût 10 000 users | Avantage | +|---|---|---| +| OVHcloud | ~€697 | RGPD France, 0 egress | +| DigitalOcean | ~$1 084 | Simplicité, managed DB | +| **AWS EKS** | **~$5 017** | SLA enterprise, certifications | + +**→ AWS EKS uniquement si** vous avez des contrats enterprise qui l'exigent, car le surcoût (×5 à ×7 vs OVH/DO) doit être justifié par le CA client. + +--- + +### Résumé en une phrase par option + +| Option | Pour qui | +|---|---| +| **Hetzner self-managed** | Vous avez les skills ops, le budget est critique, phase MVP | +| **OVHcloud MKS** | Clients français exigeants sur RGPD, budget moyen, bonne expertise Linux | +| **DigitalOcean DOKS** | Vous voulez vous concentrer sur le code, pas l'infra, budget correct | +| **Scaleway Kapsule** | Entreprise française, conformité HDS possible, managed DB acceptable | +| **AWS EKS eu-west-3** | Clients enterprise, SLA contractuels, équipe et budget dédiés | +| **Architecture hybride** | Le meilleur ROI à toutes les étapes — **recommandé par défaut** | + +--- + +### La recommandation optimale — Architecture hybride progressive + +``` +Aujourd'hui (MVP) Dans 6 mois (1 000 users) Dans 18 mois (10 000 users) +───────────────── ────────────────────────── ─────────────────────────── +Hetzner k3s Migrer vers OVHcloud MKS Rester OVH ou migrer AWS ++ Neon.tech PG + PostgreSQL self-hosted HA si clients enterprise ++ Upstash Redis + Redis Sentinel AWS eu-west-3 justifié ++ Hetzner Object Storage + Hetzner Object Storage au-delà de €500K ARR +≈ €65/mois ≈ €249/mois ≈ €697-5017/mois +``` + +> **Note sur les migrations :** Passer de Hetzner à OVHcloud ou DigitalOcean est simple (K8s manifests identiques, changement de variables d'environnement). Passer vers AWS requiert une refactorisation partielle (EKS ingress, IAM, RDS connection strings). Planifiez cette migration si vous atteignez des clients enterprise, pas avant. + +--- + +*Sources : hetzner.com/cloud, digitalocean.com/pricing, ovhcloud.com/fr/public-cloud/prices, scaleway.com/fr/tarifs, cloud.google.com, azure.microsoft.com, aws.amazon.com — Mars 2026* +*Tous les prix sont indicatifs on-demand. Les prix Hetzner pré-1er avril 2026 sont encore actifs au moment de la rédaction.* diff --git a/docs/deployment/hetzner/01-architecture.md b/docs/deployment/hetzner/01-architecture.md new file mode 100644 index 0000000..2aff52b --- /dev/null +++ b/docs/deployment/hetzner/01-architecture.md @@ -0,0 +1,286 @@ +# 01 — Architecture de production sur Hetzner + +--- + +## Vue d'ensemble + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ INTERNET │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Cloudflare │ + │ WAF + CDN + DNS │ + │ TLS termination │ + └───────────┬───────────┘ + │ HTTPS (443) + ┌───────────▼───────────┐ + │ Hetzner Load │ + │ Balancer (LB11) │ + │ €7.49/mois │ + └─────┬─────────┬───────┘ + │ │ + ┌──────────────▼──┐ ┌───▼──────────────┐ + │ Worker Node 1 │ │ Worker Node 2 │ + │ CX42 (8c/16G) │ │ CX42 (8c/16G) │ + │ €21.49/mois │ │ €21.49/mois │ + │ │ │ │ + │ ┌─────────────┐ │ │ ┌─────────────┐ │ + │ │ NestJS Pod │ │ │ │ NestJS Pod │ │ + │ │ (backend) │ │ │ │ (backend) │ │ + │ └─────────────┘ │ │ └─────────────┘ │ + │ ┌─────────────┐ │ │ ┌─────────────┐ │ + │ │ Next.js Pod │ │ │ │ Next.js Pod │ │ + │ │ (frontend) │ │ │ │ (frontend) │ │ + │ └─────────────┘ │ │ └─────────────┘ │ + └────────┬────────┘ └────────┬─────────┘ + │ Réseau privé Hetzner (10.0.0.0/16) + ┌────────▼────────────────────▼─────────┐ + │ Control Plane Node │ + │ CX22 (2c/4G) €5.11/mois │ + │ k3s server (etcd) │ + └────────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ +┌───────▼───────┐ ┌───────────▼──────────┐ ┌───────▼───────┐ +│ PostgreSQL │ │ Redis │ │ Hetzner │ +│ Neon.tech │ │ Upstash (serverless) │ │ Object │ +│ ou self-host │ │ ou self-hosted │ │ Storage │ +│ $19/mois │ │ $0-10/mois │ │ S3-compat. │ +│ │ │ │ │ €4.99/mois │ +└───────────────┘ └──────────────────────┘ └───────────────┘ +``` + +--- + +## Composants et rôles + +### Couche réseau + +| Composant | Rôle | Port | +|---|---|---| +| **Cloudflare** | DNS, WAF, CDN, protection DDoS, cache assets | 443 (HTTPS) | +| **Hetzner Load Balancer** | Distribution trafic entre workers, sticky sessions WebSocket | 80, 443 | +| **Réseau privé Hetzner** | Communication inter-nœuds (10.0.0.0/16), base de données | Interne | + +### Couche Kubernetes (k3s) + +| Composant | Rôle | Ressource | +|---|---|---| +| **Control Plane (CX22)** | etcd, kube-apiserver, scheduler, controller-manager | 2 vCPU / 4 GB | +| **Worker Nodes (CX42)** | Exécution des pods NestJS + Next.js | 8 vCPU / 16 GB chacun | +| **Traefik Ingress** | Routage HTTP/HTTPS, sticky sessions Socket.IO | Built-in k3s | +| **cert-manager** | TLS automatique via Let's Encrypt | In-cluster | +| **Hetzner Cloud Controller** | Provisionne LB + volumes depuis Kubernetes | In-cluster | +| **Hetzner CSI Driver** | PersistentVolumes sur Hetzner Volumes | In-cluster | + +### Couche application + +| Pod | Image | Replicas | Ports | +|---|---|---|---| +| **xpeditis-backend** | `ghcr.io//xpeditis-backend:latest` | 2–15 | 4000 | +| **xpeditis-frontend** | `ghcr.io//xpeditis-frontend:latest` | 1–8 | 3000 | + +### Couche données + +| Service | Option MVP | Option Production | Protocole | +|---|---|---|---| +| **PostgreSQL 15** | Neon.tech Pro ($19/mois) | Self-hosted sur CX32 | 5432 | +| **Redis 7** | Upstash free ($0-10/mois) | Self-hosted StatefulSet | 6379 | +| **Stockage fichiers** | Hetzner Object Storage (€4.99/mois) | Idem (scale automatique) | HTTPS/S3 API | + +--- + +## Flux réseau détaillé + +### Requête API standard (rate search) + +``` +Client Browser + │ HTTPS + ▼ +Cloudflare (cache miss → forward) + │ HTTPS, header CF-Connecting-IP + ▼ +Hetzner Load Balancer :443 + │ HTTP (TLS terminé par Cloudflare ou cert-manager) + ▼ +Traefik Ingress (api.xpeditis.com) + │ HTTP :80 interne + ▼ +NestJS Pod (port 4000) + ├── Redis (cache rate:FSN:HAM:20ft) → HIT → retour direct + └── MISS → 5× appels APIs carriers (Maersk/MSC/etc.) + └── Réponse → Store Redis TTL 15min + └── Réponse client +``` + +### Connexion WebSocket (notifications temps réel) + +``` +Client Browser + │ wss:// upgrade + ▼ +Cloudflare (WebSocket proxy activé) + │ + ▼ +Hetzner LB (sticky session cookie activé) + │ Même backend pod à chaque reconnexion + ▼ +Traefik (annotation sticky cookie) + │ + ▼ +NestJS Pod /notifications namespace (Socket.IO) + ├── Auth: JWT validation on connect + ├── Join room: user:{userId} + └── Redis pub/sub → broadcast cross-pods +``` + +### Upload de document (carrier portal) + +``` +Carrier Browser + │ + ▼ +NestJS POST /api/v1/csv-bookings/{id}/documents + │ Validation: type (PDF/XLS/IMG), taille max 10 MB + ▼ +S3StorageAdapter.upload() + │ AWS SDK v3, forcePathStyle: true + ▼ +Hetzner Object Storage + │ Endpoint: https://fsn1.your-objectstorage.com + └── Stocké: xpeditis-docs/{orgId}/{bookingId}/{filename} +``` + +--- + +## Ports et protocoles + +### Ports externes (ouverts sur Hetzner Firewall) + +| Port | Protocole | Source | Destination | Usage | +|---|---|---|---|---| +| 22 | TCP | Votre IP uniquement | Tous nœuds | SSH administration | +| 80 | TCP | 0.0.0.0/0 | LB | Redirection HTTP → HTTPS | +| 443 | TCP | 0.0.0.0/0 | LB | HTTPS + WebSocket | +| 6443 | TCP | Votre IP + workers | Control plane | Kubernetes API | + +### Ports internes (réseau privé 10.0.0.0/16 uniquement) + +| Port | Protocole | Usage | +|---|---|---| +| 5432 | TCP | PostgreSQL (si self-hosted) | +| 6379 | TCP | Redis (si self-hosted) | +| 4000 | TCP | NestJS API (pod → pod) | +| 3000 | TCP | Next.js (pod → pod) | +| 10250 | TCP | kubelet API | +| 2379-2380 | TCP | etcd (control plane) | + +--- + +## Namespaces Kubernetes + +``` +cluster +├── xpeditis-prod # Application principale +│ ├── Deployments: backend, frontend +│ ├── Services: backend-svc, frontend-svc +│ ├── ConfigMaps: backend-config, frontend-config +│ ├── Secrets: backend-secrets, frontend-secrets +│ ├── HPA: backend-hpa, frontend-hpa +│ └── Ingress: xpeditis-ingress +│ +├── cert-manager # Gestion certificats TLS +│ └── ClusterIssuer: letsencrypt-prod, letsencrypt-staging +│ +├── monitoring # Observabilité +│ ├── Prometheus +│ ├── Grafana +│ └── Loki +│ +└── kube-system # Système k3s + ├── Traefik (Ingress Controller) + ├── Hetzner Cloud Controller Manager + └── Hetzner CSI Driver +``` + +--- + +## Pourquoi k3s plutôt que k8s complet + +| Critère | k3s (choisi) | k8s complet | +|---|---|---| +| **RAM control plane** | 512 MB | 2-4 GB | +| **CPU control plane** | 1 vCPU | 2-4 vCPU → serveur plus cher | +| **Temps install** | 5 min (hetzner-k3s) | 30-60 min | +| **Maintenance** | System Upgrade Controller inclus | Manuelle | +| **Compatibilité** | 100% compatible kubectl/helm | — | +| **Traefik** | Inclus par défaut | Installation séparée | +| **Coût** | CX22 (€5.11/mois) comme control plane | Minimum CX42 (€21.49) | +| **Production** | Oui (utilisé par des milliers de startups) | Oui | + +--- + +## Stratégie de scaling + +### Horizontal Pod Autoscaler (HPA) + +``` +Métriques surveillées : +- CPU > 70% → Scale up +- CPU < 30% (5 min) → Scale down +- Mémoire > 80% → Scale up (custom metric) + +Backend : min 2 → max 15 pods +Frontend : min 1 → max 8 pods +``` + +### Cluster Autoscaler + +``` +Worker nodes : min 2 → max 8 +Déclenché par : pods en état "Pending" (pas assez de ressources) +Délai scale-down : 10 min d'utilisation < 50% +``` + +--- + +## Décisions d'architecture + +### Pourquoi Hetzner Object Storage plutôt que MinIO self-hosted + +Le code utilise déjà `AWS SDK v3` avec `forcePathStyle: true` et un endpoint configurable. Hetzner Object Storage est 100% compatible S3 → **zéro modification de code**, juste les variables d'environnement : + +```bash +# Avant (MinIO local) +AWS_S3_ENDPOINT=http://localhost:9000 + +# Après (Hetzner Object Storage) +AWS_S3_ENDPOINT=https://fsn1.your-objectstorage.com +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=eu-central-1 +AWS_S3_BUCKET=xpeditis-prod +``` + +### Pourquoi Neon.tech pour PostgreSQL (MVP) + +- PostgreSQL 15 managé, compatible TypeORM +- Extensions `uuid-ossp` et `pg_trgm` disponibles (requis par Xpeditis) +- Backups automatiques inclus +- Connection pooling built-in (via PgBouncer) +- Pas de gestion de HA à faire manuellement +- Free tier pour le dev, $19/mois pour la prod +- Migration vers self-hosted possible à tout moment + +### Pourquoi Cloudflare devant Hetzner LB + +- CDN mondial (cache des assets Next.js) +- Protection DDoS free +- WAF avec règles OWASP +- DNS avec failover automatique +- Certificats TLS optionnels (on peut laisser cert-manager gérer le TLS) +- Cache des PDFs générés → économise les appels S3 diff --git a/docs/deployment/hetzner/02-prerequisites.md b/docs/deployment/hetzner/02-prerequisites.md new file mode 100644 index 0000000..bc2fe7d --- /dev/null +++ b/docs/deployment/hetzner/02-prerequisites.md @@ -0,0 +1,233 @@ +# 02 — Prérequis + +Tout ce dont vous avez besoin avant de commencer le déploiement. + +--- + +## Comptes à créer + +### Obligatoires + +| Service | URL | Usage | Coût | +|---|---|---|---| +| **Hetzner Cloud** | https://console.hetzner.cloud | Serveurs, LB, Object Storage | Pay-as-you-go | +| **GitHub** | https://github.com | Code + GitHub Actions + GHCR (images Docker) | Gratuit | +| **Cloudflare** | https://cloudflare.com | DNS + WAF + CDN | Gratuit (plan Free) | +| **Neon.tech** | https://neon.tech | PostgreSQL managé | Free → $19/mois Pro | + +### Recommandés (peuvent être substitués) + +| Service | URL | Usage | Coût | +|---|---|---|---| +| **Upstash** | https://upstash.com | Redis serverless | Free → $10/mois | +| **Brevo** | https://brevo.com | Email SMTP (remplace SendGrid) | Gratuit jusqu'à 300/j | +| **Sentry** | https://sentry.io | Error tracking | Gratuit (5K events/mois) | + +--- + +## Outils locaux à installer + +### Outils essentiels + +```bash +# macOS (avec Homebrew) +brew install kubectl helm hcloud vitobotta/tap/hetzner-k3s + +# Vérification versions minimales requises +kubectl version --client # >= 1.28 +helm version # >= 3.12 +hcloud version # >= 1.40 +hetzner-k3s version # >= 3.0 +``` + +```bash +# Ubuntu/Debian +# kubectl +curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +chmod +x kubectl && sudo mv kubectl /usr/local/bin/ + +# helm +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + +# hcloud CLI +curl -Lo hcloud.tar.gz https://github.com/hetznercloud/cli/releases/latest/download/hcloud-linux-amd64.tar.gz +tar -xzf hcloud.tar.gz && sudo mv hcloud /usr/local/bin/ + +# hetzner-k3s +curl -Lo hetzner-k3s https://github.com/vitobotta/hetzner-k3s/releases/latest/download/hetzner-k3s-linux-amd64 +chmod +x hetzner-k3s && sudo mv hetzner-k3s /usr/local/bin/ +``` + +### Outils optionnels mais recommandés + +```bash +# kubectx + kubens — changer de contexte/namespace facilement +brew install kubectx + +# k9s — interface terminal pour Kubernetes (très utile) +brew install k9s + +# stern — logs multi-pods en temps réel +brew install stern + +# AWS CLI v2 — pour interagir avec Hetzner Object Storage +brew install awscli + +# Docker — pour build et test des images en local +brew install --cask docker +``` + +--- + +## Clés SSH + +Générez une paire de clés SSH dédiée pour Hetzner (ne réutilisez pas votre clé perso) : + +```bash +# Générer une clé ED25519 (plus sécurisée et performante que RSA) +ssh-keygen -t ed25519 -C "xpeditis-hetzner-deploy" -f ~/.ssh/xpeditis_hetzner + +# Résultat : +# ~/.ssh/xpeditis_hetzner (clé privée — ne JAMAIS partager) +# ~/.ssh/xpeditis_hetzner.pub (clé publique — à ajouter sur Hetzner) + +# Vérification +cat ~/.ssh/xpeditis_hetzner.pub +# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... xpeditis-hetzner-deploy +``` + +Ajoutez la clé publique dans `~/.ssh/config` pour faciliter la connexion : + +```bash +cat >> ~/.ssh/config << 'EOF' +Host hetzner-xpeditis-* + IdentityFile ~/.ssh/xpeditis_hetzner + User root + StrictHostKeyChecking no +EOF +``` + +--- + +## Nom de domaine + +Vous avez besoin d'un domaine avec accès à la gestion DNS. Ce guide suppose : + +| Domaine | Usage | +|---|---| +| `xpeditis.com` (ou votre domaine) | Site principal | +| `api.xpeditis.com` | API NestJS backend | +| `app.xpeditis.com` | Frontend Next.js | +| `monitoring.xpeditis.com` | Grafana (optionnel, accès restreint) | + +**Si vous n'avez pas encore de domaine :** Namecheap (~$10/an) ou OVHcloud (~€7/an). Une fois acheté, déléguez les DNS à Cloudflare (gratuit, meilleur outil DNS). + +### Déléguer le DNS à Cloudflare + +1. Créez un compte sur https://cloudflare.com +2. "Add a Site" → entrez votre domaine +3. Cloudflare scanne vos DNS existants +4. Copiez les 2 nameservers Cloudflare (ex: `carl.ns.cloudflare.com`) +5. Chez votre registrar, remplacez les nameservers par ceux de Cloudflare +6. Attendez 5-30 min pour la propagation + +--- + +## Variables d'environnement de travail + +Créez un fichier `.env.deploy` (ne pas committer) pour centraliser vos variables de déploiement : + +```bash +# Fichier : ~/.xpeditis-deploy.env +# Source ce fichier avant de travailler : source ~/.xpeditis-deploy.env + +# Hetzner +export HCLOUD_TOKEN="" +export HETZNER_SSH_KEY_PATH="$HOME/.ssh/xpeditis_hetzner" + +# Kubernetes +export KUBECONFIG="$HOME/.kube/kubeconfig-xpeditis-prod" + +# Domaine +export DOMAIN="xpeditis.com" +export API_DOMAIN="api.xpeditis.com" +export APP_DOMAIN="app.xpeditis.com" + +# Registry Docker (GitHub Container Registry) +export GHCR_REGISTRY="ghcr.io" +export GHCR_ORG="" +export BACKEND_IMAGE="$GHCR_REGISTRY/$GHCR_ORG/xpeditis-backend" +export FRONTEND_IMAGE="$GHCR_REGISTRY/$GHCR_ORG/xpeditis-frontend" + +# PostgreSQL (Neon.tech) +export DATABASE_URL="postgresql://user:pass@host/dbname?sslmode=require" + +# Redis (Upstash) +export REDIS_HOST="your-redis.upstash.io" +export REDIS_PORT="6379" +export REDIS_PASSWORD="" + +# Hetzner Object Storage +export HETZNER_S3_BUCKET="xpeditis-prod" +export HETZNER_S3_ENDPOINT="https://fsn1.your-objectstorage.com" +export HETZNER_S3_ACCESS_KEY="" +export HETZNER_S3_SECRET_KEY="" +``` + +--- + +## Checklist avant de démarrer + +``` +□ Compte Hetzner Cloud créé et vérifié (CB enregistrée) +□ Compte GitHub avec repo xpeditis2.0 (ou fork) +□ Compte Cloudflare avec domaine délégué +□ Compte Neon.tech créé +□ Compte Upstash créé (optionnel) +□ kubectl installé (>= 1.28) +□ helm installé (>= 3.12) +□ hcloud CLI installé et configuré +□ hetzner-k3s installé +□ Paire de clés SSH ED25519 générée pour Hetzner +□ Docker installé (pour build local) +□ Domaine + sous-domaines api. et app. planifiés +``` + +--- + +## Configuration hcloud CLI + +```bash +# Configurer le token Hetzner +hcloud context create xpeditis-prod +# Entrez votre token quand demandé + +# Vérifier la configuration +hcloud context list +hcloud server list # Doit retourner une liste vide (ou vos serveurs) + +# Lister les types de serveurs disponibles +hcloud server-type list + +# Lister les images disponibles +hcloud image list --type system | grep ubuntu +``` + +--- + +## Vérification finale + +```bash +# Tous ces checks doivent passer avant de continuer +echo "=== Vérification des outils ===" +kubectl version --client --short 2>/dev/null && echo "✅ kubectl OK" || echo "❌ kubectl manquant" +helm version --short 2>/dev/null && echo "✅ helm OK" || echo "❌ helm manquant" +hcloud version 2>/dev/null && echo "✅ hcloud OK" || echo "❌ hcloud manquant" +hetzner-k3s version 2>/dev/null && echo "✅ hetzner-k3s OK" || echo "❌ hetzner-k3s manquant" +docker --version 2>/dev/null && echo "✅ docker OK" || echo "❌ docker manquant" +ssh-keygen -l -f ~/.ssh/xpeditis_hetzner.pub 2>/dev/null && echo "✅ SSH key OK" || echo "❌ Clé SSH manquante" + +echo "" +echo "=== Vérification des tokens ===" +hcloud context list 2>/dev/null | grep xpeditis && echo "✅ hcloud context OK" || echo "❌ hcloud context non configuré" +``` diff --git a/docs/deployment/hetzner/03-hetzner-setup.md b/docs/deployment/hetzner/03-hetzner-setup.md new file mode 100644 index 0000000..08145ec --- /dev/null +++ b/docs/deployment/hetzner/03-hetzner-setup.md @@ -0,0 +1,290 @@ +# 03 — Setup Hetzner Cloud + +--- + +## Création du compte et du projet + +### 1. Créer le compte Hetzner + +1. Rendez-vous sur https://console.hetzner.cloud +2. Créez votre compte (email + CB requis) +3. Activez la vérification 2FA (obligatoire en production) +4. Créez un **nouveau projet** : `xpeditis-prod` + +### 2. Générer le token API + +1. Dans le projet `xpeditis-prod` → **Security** → **API Tokens** +2. **Generate API Token** + - Name: `hetzner-k3s-deploy` + - Permissions: **Read & Write** +3. Copiez le token immédiatement (affiché une seule fois) + +```bash +# Configurez hcloud avec ce token +hcloud context create xpeditis-prod +# → Entrez votre token + +# Vérification +hcloud server list +# Output: ID NAME STATUS IPV4 IPV6 DATACENTER +# (liste vide si première fois) +``` + +### 3. Ajouter la clé SSH + +```bash +# Via CLI hcloud +hcloud ssh-key create \ + --name xpeditis-deploy \ + --public-key-from-file ~/.ssh/xpeditis_hetzner.pub + +# Vérification +hcloud ssh-key list +# ID NAME FINGERPRINT +# 1234567 xpeditis-deploy xx:xx:xx:... +``` + +--- + +## Réseau privé (obligatoire pour la sécurité inter-nœuds) + +Le réseau privé permet aux nœuds de communiquer entre eux sans passer par internet. + +```bash +# Créer le réseau privé +hcloud network create \ + --name xpeditis-network \ + --ip-range 10.0.0.0/16 + +# Récupérer l'ID du réseau (nécessaire pour la config k3s) +hcloud network list +# ID NAME IP RANGE SERVERS +# 12345 xpeditis-network 10.0.0.0/16 0 servers + +export HETZNER_NETWORK_ID=12345 # Remplacer par votre ID + +# Créer un sous-réseau pour les nœuds du cluster +hcloud network add-subnet xpeditis-network \ + --type cloud \ + --network-zone eu-central \ + --ip-range 10.0.1.0/24 +``` + +--- + +## Firewall (règles de sécurité) + +Créez un firewall strict. Les workers ne doivent être accessibles que via le Load Balancer et depuis votre IP pour SSH. + +```bash +# Créer le firewall +hcloud firewall create --name xpeditis-firewall + +# Règle 1 : SSH depuis votre IP uniquement +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol tcp \ + --port 22 \ + --source-ips "$(curl -s https://api.ipify.org)/32" \ + --description "SSH depuis mon IP" + +# Règle 2 : HTTP/HTTPS depuis partout (via LB → workers) +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol tcp \ + --port 80 \ + --source-ips 0.0.0.0/0 \ + --description "HTTP public" + +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol tcp \ + --port 443 \ + --source-ips 0.0.0.0/0 \ + --description "HTTPS public" + +# Règle 3 : Kubernetes API (votre IP + réseau privé Hetzner) +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol tcp \ + --port 6443 \ + --source-ips "$(curl -s https://api.ipify.org)/32" \ + --description "kube-apiserver depuis mon IP" + +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol tcp \ + --port 6443 \ + --source-ips 10.0.0.0/16 \ + --description "kube-apiserver depuis réseau privé" + +# Règle 4 : Communication inter-nœuds (réseau privé uniquement) +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol tcp \ + --port 1-65535 \ + --source-ips 10.0.0.0/16 \ + --description "Trafic interne cluster" + +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol udp \ + --port 1-65535 \ + --source-ips 10.0.0.0/16 \ + --description "Trafic UDP interne cluster" + +# Règle 5 : ICMP (ping) pour monitoring +hcloud firewall add-rule xpeditis-firewall \ + --direction in \ + --protocol icmp \ + --source-ips 0.0.0.0/0 \ + --description "ICMP ping" + +# Vérification +hcloud firewall describe xpeditis-firewall +``` + +--- + +## Object Storage — Setup du bucket S3 + +### Créer le bucket + +1. Dans la console Hetzner → votre projet → **Object Storage** +2. Cliquez **Create Bucket** + - Location: **Falkenstein (fsn1)** (même région que vos serveurs) + - Bucket name: `xpeditis-prod` + - Visibility: **Private** (obligatoire) +3. Cliquez **Create** + +### Créer les credentials S3 + +1. Dans Object Storage → **Access Keys** +2. **Generate Access Key** + - Name: `xpeditis-backend` +3. Notez bien les deux valeurs (affichées une seule fois) : + - **Access Key** (commence par `htz...`) + - **Secret Key** (longue chaîne) + +### Vérifier avec AWS CLI + +```bash +# Configurer AWS CLI pour Hetzner Object Storage +aws configure --profile hetzner +# AWS Access Key ID: +# AWS Secret Access Key: +# Default region name: eu-central-1 +# Default output format: json + +# Tester la connexion +aws s3 ls --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com + +# Créer un dossier de test +aws s3 cp /dev/null s3://xpeditis-prod/test/.gitkeep \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com + +# Vérifier +aws s3 ls s3://xpeditis-prod/ \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com +``` + +### Structure du bucket recommandée + +``` +xpeditis-prod/ +├── documents/ # Documents carrier (PDF, XLS, images) +│ └── {orgId}/ +│ └── {bookingId}/ +│ └── {filename} +├── pdfs/ # PDFs de confirmation booking +│ └── {year}/ +│ └── {month}/ +│ └── {bookingNumber}.pdf +├── exports/ # Exports CSV/Excel des bookings +│ └── {orgId}/ +│ └── {timestamp}-bookings.xlsx +├── logos/ # Logos des organisations +│ └── {orgId}/ +│ └── logo.{ext} +└── backups/ # Backups PostgreSQL (voir doc 13) + └── {date}/ + └── xpeditis-{timestamp}.sql.gz +``` + +--- + +## Volumes Hetzner (si PostgreSQL self-hosted) + +Si vous choisissez d'héberger PostgreSQL sur Hetzner (voir doc 07), créez un volume dédié : + +```bash +# Créer un volume de 50 GB pour PostgreSQL +hcloud volume create \ + --name xpeditis-postgres-data \ + --size 50 \ + --location fsn1 \ + --format ext4 + +# L'ID sera utilisé dans la config k3s pour le PersistentVolume +hcloud volume list +# ID NAME SIZE SERVER LOCATION +# 67890 xpeditis-postgres-data 50 GB - fsn1 +``` + +--- + +## Placement Groups (haute disponibilité) + +Les placement groups garantissent que vos workers sont sur des hôtes physiques différents : + +```bash +# Créer un placement group "spread" (workers sur différents hôtes physiques) +hcloud placement-group create \ + --name xpeditis-workers \ + --type spread + +# Noter l'ID pour la config hetzner-k3s +hcloud placement-group list +# ID NAME TYPE SERVERS +# 111 xpeditis-workers spread 0 +``` + +--- + +## Récapitulatif des IDs à noter + +Après cette étape, vous devez avoir : + +```bash +# À sauvegarder dans ~/.xpeditis-deploy.env +export HCLOUD_TOKEN="" +export HCLOUD_NETWORK_ID="12345" # ID du réseau privé +export HCLOUD_SSH_KEY_NAME="xpeditis-deploy" +export HCLOUD_FIREWALL_NAME="xpeditis-firewall" +export HCLOUD_PLACEMENT_GROUP_NAME="xpeditis-workers" + +# Object Storage +export HETZNER_S3_ENDPOINT="https://fsn1.your-objectstorage.com" +export HETZNER_S3_BUCKET="xpeditis-prod" +export HETZNER_S3_ACCESS_KEY="" +export HETZNER_S3_SECRET_KEY="" +``` + +--- + +## Vérification globale + +```bash +# Tout doit être en place avant de continuer vers le doc 04/05 +echo "=== Network ===" && hcloud network list +echo "=== SSH Keys ===" && hcloud ssh-key list +echo "=== Firewalls ===" && hcloud firewall list +echo "=== Volumes ===" && hcloud volume list +echo "=== Placement Groups ===" && hcloud placement-group list +echo "=== Object Storage ===" && aws s3 ls s3://xpeditis-prod/ \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com 2>/dev/null && echo "✅ Bucket accessible" || echo "❌ Bucket inaccessible" +``` diff --git a/docs/deployment/hetzner/04-server-selection.md b/docs/deployment/hetzner/04-server-selection.md new file mode 100644 index 0000000..da3ac0a --- /dev/null +++ b/docs/deployment/hetzner/04-server-selection.md @@ -0,0 +1,183 @@ +# 04 — Choix des serveurs Hetzner + +--- + +## Types de serveurs Hetzner (post 1er avril 2026) + +### Série CX — Intel/AMD partagé (usage général) + +| Type | vCPU | RAM | SSD | Bande passante | Prix/mois | +|---|---|---|---|---|---| +| CX22 | 2 | 4 GB | 40 GB | 20 TB | **€5.11** | +| CX32 | 4 | 8 GB | 80 GB | 20 TB | **€9.19** | +| CX42 | 8 | 16 GB | 160 GB | 20 TB | **€21.49** | +| CX52 | 16 | 32 GB | 320 GB | 20 TB | **€43.49** | + +### Série CAX — ARM64 Ampere (meilleur rapport prix/perfs) + +| Type | vCPU | RAM | SSD | Prix/mois | +|---|---|---|---|---| +| CAX11 | 2 | 4 GB | 40 GB | **€3.79** | +| CAX21 | 4 | 8 GB | 80 GB | **€6.49** | +| CAX31 | 8 | 16 GB | 80 GB | **€12.49** | +| CAX41 | 16 | 32 GB | 160 GB | **€24.49** | + +> **Note ARM64 :** NestJS (Node.js) et Next.js fonctionnent parfaitement sur ARM64. Les images Docker `node:20-alpine` sont multi-arch. Les carrier APIs (Maersk, MSC...) appellent des APIs externes → architecture du serveur sans impact. **Les CAX sont 35-40% moins chères que les CX pour des perfs équivalentes.** + +### Série CCX — vCPU dédiés (pour la base de données) + +| Type | vCPU dédiés | RAM | SSD NVMe | Prix/mois | +|---|---|---|---|---| +| CCX13 | 2 | 8 GB | 80 GB | **€12.49** | +| CCX23 | 4 | 16 GB | 160 GB | **€23.99** | +| CCX33 | 8 | 32 GB | 240 GB | **€51.99** | +| CCX43 | 16 | 64 GB | 360 GB | **€103.99** | + +> Les CCX sont recommandés pour PostgreSQL self-hosted car les vCPUs dédiés évitent la contention avec d'autres clients. I/O NVMe plus rapide. + +--- + +## Recommandations par palier + +### Palier MVP — 100 utilisateurs + +**Objectif :** Démarrer avec un coût minimal, tout peut tenir sur peu de nœuds. + +``` +Control Plane : 1× CX22 (2 vCPU, 4 GB) — €5.11/mois +Workers : 2× CX32 (4 vCPU, 8 GB) — €9.19/mois × 2 = €18.38/mois +Load Balancer : LB11 — €7.49/mois +────────────────────────────────────────────────────── +Sous-total cluster : €30.98/mois ++ Neon.tech Pro : $19/mois ++ Upstash Redis : $0 (free tier) ++ Object Storage : €4.99/mois (inclut 1 TB) +────────────────────────────────────────────────────── +TOTAL : ~€55/mois +``` + +**Pods qui tiennent sur cette config :** +- 2× NestJS backend (500m CPU / 512Mi RAM chacun) +- 1× Next.js frontend (250m CPU / 256Mi RAM) +- Traefik ingress (built-in) +- cert-manager + +**Alternative ARM64 encore moins chère :** +``` +Control Plane : 1× CAX11 (2 vCPU, 4 GB) — €3.79/mois +Workers : 2× CAX21 (4 vCPU, 8 GB) — €6.49/mois × 2 = €12.98/mois +────────────────────────────────────────────────────── +Sous-total (ARM64) : €16.77/mois (vs €23.49 en CX) +``` + +> ⚠️ **Si vous utilisez CAX (ARM64)**, vérifiez que vos Dockerfiles sont buildés en `linux/arm64` ou utilisez des images `linux/amd64` avec émulation QEMU. Le plus simple : build multi-arch avec `docker buildx` (voir doc 11). + +--- + +### Palier Croissance — 1 000 utilisateurs + +**Objectif :** Performance correcte, HA partielle, autoscaling activé. + +``` +Control Plane : 1× CX22 (2 vCPU, 4 GB) — €5.11/mois +Workers : 3× CX42 (8 vCPU, 16 GB) — €21.49/mois × 3 = €64.47/mois + (autoscaling jusqu'à 6 nodes) +Load Balancer : LB21 (75 targets, 2 TB) — €22.14/mois +PostgreSQL : CCX13 dédié (4 vCPU dédiés) — €23.99/mois ← self-hosted + + 100 GB Volume — €5.70/mois +Redis : CX22 dédié — €5.11/mois +Object Storage: €4.99/mois +────────────────────────────────────────────────────── +TOTAL : ~€131/mois +``` + +**Pods sur cette config :** +- 4× NestJS backend (750m CPU / 768Mi RAM) +- 2× Next.js frontend (500m CPU / 512Mi RAM) +- HPA actif : scale jusqu'à 8 pods NestJS + +--- + +### Palier Scale — 10 000 utilisateurs + +**Objectif :** Haute disponibilité, performances sous charge. + +``` +Control Plane : 3× CX22 (cluster etcd HA) — €5.11/mois × 3 = €15.33/mois +Workers : 6× CX52 (16 vCPU, 32 GB) — €43.49/mois × 6 = €260.94/mois + (autoscaling jusqu'à 12 nodes) +Load Balancer : LB31 (150 targets, 3 TB) — €39.15/mois +PostgreSQL : CCX33 primary (8 vCPU dédié) — €51.99/mois + + CCX23 replica (4 vCPU dédié) — €23.99/mois + + 500 GB Volume NVMe — €28.50/mois +Redis : CX42 dédié — €21.49/mois +Object Storage: ~€15/mois (extra 3 TB) +────────────────────────────────────────────────────── +TOTAL : ~€457/mois +``` + +--- + +## Localisation des serveurs (datacenter) + +Hetzner a des datacenters en : +- `fsn1` — Falkenstein, Allemagne (recommandé) +- `nbg1` — Nuremberg, Allemagne +- `hel1` — Helsinki, Finlande (bon pour RGPD nordique) +- `ash` — Ashburn, USA (si clients américains prioritaires) +- `hil` — Hillsboro, USA + +**Recommandation :** `fsn1` (Falkenstein) pour les clients européens. Même région pour tous les nœuds pour minimiser la latence réseau interne. + +```bash +# Vérifier les datacenters disponibles dans une région +hcloud datacenter list +# ID NAME DESCRIPTION LOCATION +# 1 fsn1-dc3 Falkenstein 1 virtual DC 3 fsn1 +# 2 nbg1-dc3 Nuremberg 1 virtual DC 3 nbg1 +# 3 hel1-dc2 Helsinki 1 virtual DC 2 hel1 +# 4 ash-dc1 Ashburn, Virginia 1 virtual DC 1 ash +# 5 hil-dc1 Hillsboro, Oregon 1 virtual DC 1 hil +``` + +--- + +## Load Balancer — Choix du plan + +| Plan | Targets | Trafic inclus | Connexions | Prix/mois | +|---|---|---|---|---| +| **LB11** | 25 | 1 TB | 1 000 simultanées | €7.49 | +| **LB21** | 75 | 2 TB | 10 000 simultanées | €22.14 | +| **LB31** | 150 | 3 TB | 100 000 simultanées | ~€39 | + +- **100 users :** LB11 largement suffisant +- **1 000 users :** LB21 recommandé (WebSocket = connexions persistantes) +- **10 000 users :** LB31 nécessaire (10 000 connexions simultanées pour WS + HTTP) + +**Configuration WebSocket sur le LB :** +```yaml +# Dans hetzner-k3s config (géré automatiquement) +# Le LB Hetzner supporte les WebSockets nativement +# Sticky sessions : cookie_name=SERVERID +``` + +--- + +## Décision finale : ARM64 ou x86 ? + +| Critère | x86 (CX) | ARM64 (CAX) | +|---|---|---| +| Coût | Référence | **-35%** | +| Performances Node.js | Bonne | **Équivalente ou meilleure** | +| Docker images officielles | ✅ linux/amd64 | ✅ linux/arm64 (Node.js 20, Alpine, etc.) | +| Build CI/CD | Simple | Nécessite `linux/arm64` ou multi-arch | +| Dépendances natives | Toutes supportées | 99% supportées (vérifier pdfkit, argon2) | +| Maturité | Très mature | Mature (2023+) | + +**Verdict pour Xpeditis :** Les CAX sont recommandées si vous acceptez la légère complexité de build multi-arch. Sinon, CX pour la simplicité. Ce guide utilise CX par défaut. + +> **Vérification des dépendances natives pour ARM64 :** +> - `argon2` → ✅ binaires précompilés ARM64 via `@node-rs/argon2` +> - `pdfkit` → ✅ pur JavaScript, pas de binaires natifs +> - `sharp` (si utilisé pour images) → ✅ binaires ARM64 disponibles +> - `better-sqlite3` → Non utilisé (TypeORM PG) diff --git a/docs/deployment/hetzner/05-k3s-cluster.md b/docs/deployment/hetzner/05-k3s-cluster.md new file mode 100644 index 0000000..ab0efdc --- /dev/null +++ b/docs/deployment/hetzner/05-k3s-cluster.md @@ -0,0 +1,476 @@ +# 05 — Création du cluster k3s avec hetzner-k3s + +C'est le fichier central. Suivez chaque étape dans l'ordre. + +--- + +## Qu'est-ce que hetzner-k3s ? + +[hetzner-k3s](https://github.com/vitobotta/hetzner-k3s) est un outil CLI qui automatise la création d'un cluster k3s sur Hetzner Cloud. En une commande, il : + +1. Crée les serveurs (control plane + workers) +2. Configure le réseau privé +3. Installe k3s sur tous les nœuds +4. Installe le Hetzner Cloud Controller Manager (provisionne LB + volumes depuis K8s) +5. Installe le Hetzner CSI Driver (PersistentVolumes sur Hetzner Volumes) +6. Configure le Cluster Autoscaler (scale automatique des workers) +7. Installe le System Upgrade Controller (upgrades k3s automatiques) +8. Configure kubectl localement + +--- + +## Fichier de configuration du cluster + +Créez le fichier `cluster.yaml` à la racine du projet ou dans un dossier sécurisé (jamais dans le repo Git) : + +```bash +mkdir -p ~/.xpeditis +cat > ~/.xpeditis/cluster.yaml << 'EOF' +# ============================================================ +# Xpeditis Production Cluster — hetzner-k3s configuration +# ============================================================ + +# Token API Hetzner (garder secret) +hetzner_token: "" + +# Nom du cluster +cluster_name: xpeditis-prod + +# Chemin du kubeconfig qui sera généré +kubeconfig_path: "~/.kube/kubeconfig-xpeditis-prod" + +# Version k3s +# Vérifier la dernière stable sur https://github.com/k3s-io/k3s/releases +k3s_version: v1.30.4+k3s1 + +# Clés SSH +public_ssh_key_path: "~/.ssh/xpeditis_hetzner.pub" +private_ssh_key_path: "~/.ssh/xpeditis_hetzner" +use_ssh_agent: false +ssh_port: 22 + +# Réseaux autorisés pour SSH et API Kubernetes +# Remplacer par votre IP fixe pour plus de sécurité +ssh_allowed_networks: + - "/32" + +api_allowed_networks: + - "/32" + +# Réseau privé Hetzner +# Créé dans le doc 03-hetzner-setup.md +existing_network: "xpeditis-network" +private_network_subnet: 10.0.0.0/16 + +# CIDRs Kubernetes (ne pas changer sauf conflit) +cluster_cidr: 10.244.0.0/16 +service_cidr: 10.96.0.0/16 +cluster_dns: 10.96.0.10 + +# Image OS +image: ubuntu-24.04 +snapshot_os: ubuntu + +# Datacenter (même région que l'Object Storage) +location: fsn1 + +# k3s options +disable_flannel: false # Flannel CNI (par défaut dans k3s) +schedule_workloads_on_masters: false # Masters dédiés au control plane + +# Packages additionnels installés sur chaque nœud +additional_packages: + - curl + - jq + - htop + - fail2ban # Protection brute force SSH + +# Commandes post-création sur chaque nœud +post_create_commands: + - apt-get update -qq + - apt-get install -y -qq fail2ban + - systemctl enable fail2ban + - systemctl start fail2ban + - | + cat >> /etc/fail2ban/jail.local << 'FAIL2BAN' + [sshd] + enabled = true + maxretry = 3 + bantime = 3600 + FAIL2BAN + - systemctl restart fail2ban + +# Helm charts installés automatiquement +cloud_controller_manager_manifest_url: "https://github.com/hetznercloud/hcloud-cloud-controller-manager/releases/download/v1.21.0/ccm-networks.yaml" +csi_driver_manifest_url: "https://raw.githubusercontent.com/hetznercloud/csi-driver/v2.8.0/deploy/kubernetes/hcloud-csi.yml" + +# System Upgrade Controller (upgrades k3s automatiques) +system_upgrade_controller_install: true +system_upgrade_controller_manifest_url: "https://github.com/rancher/system-upgrade-controller/releases/download/v0.13.4/system-upgrade-controller.yaml" + +# Cluster Autoscaler +cluster_autoscaler_install: true +cluster_autoscaler_version: "9.36.0" +cluster_autoscaler_image: "registry.k8s.io/autoscaling/cluster-autoscaler" +cluster_autoscaler_cmdline_args: + - --scan-interval=10s + - --scale-down-delay-after-add=5m + - --scale-down-unneeded-time=5m + - --max-nodes-total=12 + +# Metrics Server (pour HPA) +metrics_server_manifest_url: "https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml" + +# kube-apiserver extra args (sécurité) +kube_api_server_args: + - "--audit-log-path=/var/log/kubernetes/audit.log" + - "--audit-log-maxage=30" + - "--audit-log-maxbackup=3" + - "--audit-log-maxsize=100" + +# kubelet extra args +kubelet_args: + - "--max-pods=110" + - "--system-reserved=cpu=200m,memory=200Mi" + - "--kube-reserved=cpu=200m,memory=200Mi" + +# ============================================================ +# CONTROL PLANE +# ============================================================ +masters: + instance_type: cx22 # 2 vCPU, 4 GB + instance_count: 1 # Passer à 3 pour HA (10 000 users) + location: fsn1 + image: ~ # Utilise l'image globale + +# ============================================================ +# WORKER NODE POOLS +# ============================================================ +worker_node_pools: + - name: app-workers + instance_type: cx32 # 4 vCPU, 8 GB (MVP) + instance_count: 2 # Min pods + location: fsn1 + image: ~ + additional_packages: ~ + post_create_commands: ~ + taints: [] + labels: + - "xpeditis.io/node-role=app" + autoscaling: + enabled: true + min_instances: 2 # Minimum pour HA + max_instances: 6 # Max pour limiter les coûts +EOF +``` + +> **Pour le palier 1 000 users**, changez `cx32` → `cx42` et `max_instances: 8` +> **Pour le palier 10 000 users**, changez `cx42` → `cx52`, `instance_count: 4`, `max_instances: 12`, et `masters.instance_count: 3` + +--- + +## Création du cluster + +```bash +# Vérifier la configuration +hetzner-k3s validate --config ~/.xpeditis/cluster.yaml + +# Créer le cluster (prend 5-10 minutes) +hetzner-k3s create --config ~/.xpeditis/cluster.yaml + +# Output attendu : +# Creating infrastructure... +# Creating network... +# Creating SSH key... +# Creating firewall... +# Creating placement group... +# Creating load balancer... +# Creating masters... +# Waiting for masters to be ready... +# Creating worker pools... +# Waiting for workers to be ready... +# Installing k3s on masters... +# Installing k3s on workers... +# Installing Hetzner CCM... +# Installing Hetzner CSI... +# Installing Cluster Autoscaler... +# Installing System Upgrade Controller... +# Installing Metrics Server... +# Configuring kubeconfig... +# ✅ Cluster xpeditis-prod created successfully! +``` + +--- + +## Configuration de kubectl + +```bash +# Définir le KUBECONFIG +export KUBECONFIG=~/.kube/kubeconfig-xpeditis-prod + +# Ajouter au .zshrc ou .bashrc pour persistance +echo 'export KUBECONFIG=~/.kube/kubeconfig-xpeditis-prod' >> ~/.zshrc + +# Vérifier la connexion au cluster +kubectl cluster-info +# Kubernetes control plane is running at https://:6443 +# CoreDNS is running at https://:6443/api/v1/... + +# Lister les nœuds +kubectl get nodes -o wide +# NAME STATUS ROLES AGE VERSION +# xpeditis-prod-cx22-master-1 Ready control-plane,master 5m v1.30.4+k3s1 +# xpeditis-prod-cx32-worker-1 Ready 4m v1.30.4+k3s1 +# xpeditis-prod-cx32-worker-2 Ready 4m v1.30.4+k3s1 + +# Vérifier tous les pods système +kubectl get pods --all-namespaces +# Tous les pods doivent être Running +``` + +--- + +## Vérification du Hetzner Cloud Controller Manager + +Le CCM permet à Kubernetes de provisionner des ressources Hetzner (LB, volumes) : + +```bash +# Vérifier que le CCM tourne +kubectl get pods -n kube-system | grep hcloud + +# Vérifier que les nœuds ont le label de région +kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}{end}' +# xpeditis-prod-cx22-master-1 fsn1 +# xpeditis-prod-cx32-worker-1 fsn1 +# xpeditis-prod-cx32-worker-2 fsn1 +``` + +--- + +## Vérification du Hetzner CSI Driver + +```bash +# Le CSI driver permet de créer des PersistentVolumes sur Hetzner +kubectl get pods -n kube-system | grep hcloud-csi + +# Vérifier les StorageClasses disponibles +kubectl get storageclass +# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE +# hcloud-volumes (default) csi.hetzner.cloud Delete WaitForFirstConsumer +``` + +--- + +## Configuration de Traefik (Ingress Controller) + +k3s installe Traefik par défaut. Nous devons le configurer pour : +1. Redirection HTTP → HTTPS +2. Support WebSocket (Socket.IO) +3. Sticky sessions pour le backend + +```bash +# Créer le fichier de configuration Traefik +cat > /tmp/traefik-config.yaml << 'EOF' +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + # Logs + logs: + general: + level: INFO + access: + enabled: true + + # Ports + ports: + web: + port: 8000 + redirectTo: + port: websecure # Force HTTPS + websecure: + port: 8443 + tls: + enabled: true + + # Sticky sessions pour WebSocket + service: + spec: + externalTrafficPolicy: Local + + # Annotations pour le Load Balancer Hetzner + service: + annotations: + load-balancer.hetzner.cloud/name: "xpeditis-lb" + load-balancer.hetzner.cloud/location: "fsn1" + load-balancer.hetzner.cloud/health-check-interval: "15s" + load-balancer.hetzner.cloud/health-check-timeout: "10s" + load-balancer.hetzner.cloud/health-check-retries: "3" + load-balancer.hetzner.cloud/use-private-ip: "true" + + # Ressources + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + + # Replicas (1 suffit pour MVP) + deployment: + replicas: 1 + + # Providers supplémentaires + providers: + kubernetesCRD: + enabled: true + allowCrossNamespace: true + kubernetesIngress: + enabled: true + publishedService: + enabled: true +EOF + +kubectl apply -f /tmp/traefik-config.yaml + +# Attendre que Traefik soit mis à jour +kubectl rollout status deployment/traefik -n kube-system --timeout=120s +``` + +--- + +## Installation de cert-manager + +cert-manager gère les certificats TLS automatiquement via Let's Encrypt : + +```bash +# Ajouter le repo Helm cert-manager +helm repo add jetstack https://charts.jetstack.io +helm repo update + +# Installer cert-manager +helm install cert-manager jetstack/cert-manager \ + --namespace cert-manager \ + --create-namespace \ + --version v1.15.3 \ + --set installCRDs=true \ + --set resources.requests.cpu=50m \ + --set resources.requests.memory=64Mi \ + --set webhook.resources.requests.cpu=50m \ + --set webhook.resources.requests.memory=32Mi + +# Attendre que cert-manager soit prêt +kubectl wait --for=condition=Ready pod \ + --selector=app.kubernetes.io/instance=cert-manager \ + -n cert-manager \ + --timeout=120s + +# Vérification +kubectl get pods -n cert-manager +# NAME READY STATUS +# cert-manager-7f9f87595d-xxx 1/1 Running +# cert-manager-cainjector-54db9f97d8-xxx 1/1 Running +# cert-manager-webhook-8698c586b7-xxx 1/1 Running +``` + +--- + +## ClusterIssuers Let's Encrypt + +```bash +# Créer les issuers (staging pour test, prod pour production) +cat > /tmp/cluster-issuers.yaml << 'EOF' +--- +# STAGING — Pour tester sans risquer le rate limit +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-staging +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: admin@xpeditis.com # ← Remplacer + privateKeySecretRef: + name: letsencrypt-staging-key + solvers: + - http01: + ingress: + class: traefik +--- +# PRODUCTION — Certificats réels (max 5 renouvellements/semaine) +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@xpeditis.com # ← Remplacer + privateKeySecretRef: + name: letsencrypt-prod-key + solvers: + - http01: + ingress: + class: traefik +EOF + +kubectl apply -f /tmp/cluster-issuers.yaml + +# Vérifier les issuers +kubectl get clusterissuers +# NAME READY AGE +# letsencrypt-staging True 30s +# letsencrypt-prod True 30s +``` + +--- + +## Récapitulatif : état du cluster après cette étape + +```bash +# Vue d'ensemble complète +kubectl get nodes +kubectl get pods --all-namespaces --field-selector=status.phase!=Running + +# Doit afficher : +# kube-system traefik-* Running +# kube-system hcloud-cloud-controller Running +# kube-system hcloud-csi-* Running +# kube-system coredns-* Running +# kube-system metrics-server-* Running +# cert-manager cert-manager-* Running + +echo "✅ Cluster prêt pour le déploiement de l'application" +``` + +--- + +## Opérations sur le cluster + +### Ajouter un nœud worker manuellement + +```bash +# Modifier le fichier cluster.yaml +# Changer instance_count de 2 → 3 dans worker_node_pools +hetzner-k3s apply --config ~/.xpeditis/cluster.yaml +``` + +### Supprimer le cluster (⚠️ irréversible) + +```bash +hetzner-k3s delete --config ~/.xpeditis/cluster.yaml +``` + +### Lister les composants Hetzner créés + +```bash +hcloud server list +hcloud load-balancer list +hcloud network list +hcloud firewall list +hcloud placement-group list +``` diff --git a/docs/deployment/hetzner/06-storage-s3.md b/docs/deployment/hetzner/06-storage-s3.md new file mode 100644 index 0000000..0228130 --- /dev/null +++ b/docs/deployment/hetzner/06-storage-s3.md @@ -0,0 +1,258 @@ +# 06 — Stockage objet S3 (Hetzner Object Storage) + +--- + +## Migration MinIO → Hetzner Object Storage + +Bonne nouvelle : **aucune modification de code nécessaire.** + +Le code Xpeditis utilise déjà le AWS SDK v3 avec `forcePathStyle: true` et un endpoint configurable dans `apps/backend/src/infrastructure/storage/s3-storage.adapter.ts` : + +```typescript +// Ce code existant fonctionne avec Hetzner Object Storage +this.s3Client = new S3Client({ + region, + endpoint, // ← Changer vers Hetzner + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: !!endpoint, // ← true pour Hetzner (path-style S3) +}); +``` + +Il suffit de **changer 4 variables d'environnement** : + +```bash +# AVANT (MinIO local) +AWS_S3_ENDPOINT=http://localhost:9000 +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin +AWS_REGION=us-east-1 + +# APRÈS (Hetzner Object Storage) +AWS_S3_ENDPOINT=https://fsn1.your-objectstorage.com +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=eu-central-1 +AWS_S3_BUCKET=xpeditis-prod +``` + +--- + +## Configuration détaillée + +### Variables d'environnement backend (.env.production) + +```bash +# S3 / Hetzner Object Storage +AWS_S3_ENDPOINT=https://fsn1.your-objectstorage.com +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=eu-central-1 +AWS_S3_BUCKET=xpeditis-prod +``` + +> **Endpoint par région :** +> - Falkenstein : `https://fsn1.your-objectstorage.com` +> - Nuremberg : `https://nbg1.your-objectstorage.com` +> - Helsinki : `https://hel1.your-objectstorage.com` +> +> Utilisez la même région que vos serveurs pour éviter des frais de transfert inter-région. + +--- + +## Tester la connexion depuis le code + +```bash +# 1. Build l'image Docker backend avec les vars de test +# 2. Ou tester directement avec AWS CLI + +# Test avec AWS CLI (profil configuré dans doc 03) +aws s3 ls s3://xpeditis-prod/ \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com + +# Uploader un fichier test +echo "test" | aws s3 cp - s3://xpeditis-prod/test/health.txt \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com + +# Générer une URL signée (1h) +aws s3 presign s3://xpeditis-prod/test/health.txt \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com \ + --expires-in 3600 + +# Nettoyage +aws s3 rm s3://xpeditis-prod/test/health.txt \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com +``` + +--- + +## Structure du bucket + +Créez les "dossiers" initiaux (S3 utilise des préfixes, pas de vrais dossiers) : + +```bash +#!/bin/bash +PROFILE="hetzner" +ENDPOINT="https://fsn1.your-objectstorage.com" +BUCKET="xpeditis-prod" + +for PREFIX in documents pdfs exports logos backups/postgres; do + aws s3api put-object \ + --bucket "$BUCKET" \ + --key "$PREFIX/" \ + --profile "$PROFILE" \ + --endpoint-url "$ENDPOINT" \ + --content-length 0 + echo "✅ Créé: $PREFIX/" +done +``` + +--- + +## Lifecycle policies (économies de stockage) + +Hetzner Object Storage supporte les lifecycle rules S3 pour archiver automatiquement les anciens fichiers. + +```bash +# Créer le fichier de lifecycle +cat > /tmp/lifecycle.json << 'EOF' +{ + "Rules": [ + { + "ID": "archive-old-pdfs", + "Status": "Enabled", + "Filter": { + "Prefix": "pdfs/" + }, + "Transitions": [ + { + "Days": 90, + "StorageClass": "GLACIER" + } + ] + }, + { + "ID": "archive-old-exports", + "Status": "Enabled", + "Filter": { + "Prefix": "exports/" + }, + "Expiration": { + "Days": 365 + } + }, + { + "ID": "cleanup-old-backups", + "Status": "Enabled", + "Filter": { + "Prefix": "backups/" + }, + "Expiration": { + "Days": 30 + } + } + ] +} +EOF + +# Appliquer le lifecycle +aws s3api put-bucket-lifecycle-configuration \ + --bucket xpeditis-prod \ + --lifecycle-configuration file:///tmp/lifecycle.json \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com + +# Vérifier +aws s3api get-bucket-lifecycle-configuration \ + --bucket xpeditis-prod \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com +``` + +--- + +## CORS (pour upload direct depuis le navigateur) + +Si vous implémentez des uploads directs depuis le browser (carrier portal) : + +```bash +cat > /tmp/cors.json << 'EOF' +{ + "CORSRules": [ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], + "AllowedOrigins": [ + "https://app.xpeditis.com", + "https://xpeditis.com" + ], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3000 + } + ] +} +EOF + +aws s3api put-bucket-cors \ + --bucket xpeditis-prod \ + --cors-configuration file:///tmp/cors.json \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com +``` + +--- + +## Intégration dans le Secret Kubernetes + +Le Secret Kubernetes `backend-secrets` contiendra les credentials S3. Voir le doc 09 pour les manifests complets, mais voici la section S3 : + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: backend-secrets + namespace: xpeditis-prod +type: Opaque +stringData: + AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com" + AWS_ACCESS_KEY_ID: "" + AWS_SECRET_ACCESS_KEY: "" + AWS_REGION: "eu-central-1" + AWS_S3_BUCKET: "xpeditis-prod" +``` + +--- + +## Monitoring du stockage + +```bash +# Voir la taille totale du bucket +aws s3 ls s3://xpeditis-prod/ \ + --recursive \ + --human-readable \ + --summarize \ + --profile hetzner \ + --endpoint-url https://fsn1.your-objectstorage.com \ + | tail -3 + +# Output : +# Total Objects: 1234 +# Total Size: 4.5 GiB +``` + +--- + +## Tarifs Hetzner Object Storage + +| Ressource | Prix | +|---|---| +| Stockage | Inclus dans le pack €4.99/mois (1 TB) | +| Trafic sortant (internet) | Inclus dans le pack (1 TB) | +| Requêtes | Incluses | +| Stockage > 1 TB | €0.0067/TB/heure (~€4.90/TB/mois) | +| Trafic > 1 TB | ~€1/TB | + +Pour Xpeditis à 1 000 users (~200 GB de fichiers), le coût est de **€4.99/mois fixe**. diff --git a/docs/deployment/hetzner/07-database-postgresql.md b/docs/deployment/hetzner/07-database-postgresql.md new file mode 100644 index 0000000..3c18be9 --- /dev/null +++ b/docs/deployment/hetzner/07-database-postgresql.md @@ -0,0 +1,337 @@ +# 07 — Base de données PostgreSQL + +Deux options selon votre palier et votre tolérance aux opérations. + +--- + +## Option A — Neon.tech (recommandé pour MVP) + +### Pourquoi Neon.tech + +- PostgreSQL 15 managé, compatible TypeORM +- Extensions `uuid-ossp` et `pg_trgm` **disponibles** (requises par Xpeditis) +- Connection pooling intégré (PgBouncer) → critique pour NestJS multi-pods +- Backups automatiques + point-in-time recovery +- Free tier pour le développement +- **$19/mois** pour le plan Pro (production) +- Pas de gestion de HA, de réplication, ni de backups à faire + +### Setup Neon.tech + +1. Créez un compte sur https://neon.tech +2. "New Project" → Nom: `xpeditis-prod` → Region: `AWS eu-central-1 (Frankfurt)` (le plus proche de Hetzner FSN1) +3. Sélectionnez **Plan Pro** ($19/mois) +4. PostgreSQL version: **15** + +### Créer la base de données + +```bash +# Dans l'interface Neon → SQL Editor, ou via CLI neon +# Installer la CLI Neon +npm install -g neonctl +neonctl auth + +# Créer les extensions requises par Xpeditis +neonctl sql --project-id << 'EOF' +-- Extensions requises par Xpeditis +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- Vérification +SELECT extname, extversion FROM pg_extension +WHERE extname IN ('uuid-ossp', 'pg_trgm'); +EOF +``` + +### Connection string + +Dans l'interface Neon → Connection Details → choisissez **Pooled connection** : + +```bash +# Connection string avec pooling (via PgBouncer) — pour la prod +postgresql://xpeditis:@ep-xxx-xxx.eu-central-1.aws.neon.tech/xpeditis?pgbouncer=true&connection_limit=1&sslmode=require + +# Connection string directe — pour les migrations TypeORM +postgresql://xpeditis:@ep-xxx-xxx.eu-central-1.aws.neon.tech/xpeditis?sslmode=require +``` + +> **Important :** TypeORM migrations doivent utiliser la **connexion directe** (sans pgbouncer). Pour le runtime NestJS, utilisez la **connexion poolée**. + +### Configuration TypeORM pour Neon + +L'app utilise des variables séparées pour l'hôte/port. Modifiez pour utiliser `DATABASE_URL` : + +Vérifiez le fichier `apps/backend/src/app.module.ts`. Si TypeORM est configuré avec des variables séparées (`DATABASE_HOST`, `DATABASE_PORT`, etc.), vous avez deux options : + +**Option 1 (recommandée) — URL complète :** + +Dans le `app.module.ts`, TypeOrmModule accepte une `url` : +```typescript +TypeOrmModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + type: 'postgres', + url: configService.get('DATABASE_URL'), // ← Utiliser si disponible + ssl: { rejectUnauthorized: false }, // ← Requis pour Neon + // ... reste de la config + }), +}) +``` + +**Option 2 — Variables séparées (configuration actuelle) :** + +Décomposez l'URL Neon en variables séparées dans le `.env` : +```bash +DATABASE_HOST=ep-xxx-xxx.eu-central-1.aws.neon.tech +DATABASE_PORT=5432 +DATABASE_USER=xpeditis +DATABASE_PASSWORD= +DATABASE_NAME=xpeditis +DATABASE_SSL=true +``` + +Et ajoutez `ssl: { rejectUnauthorized: false }` dans la config TypeORM. + +### Lancer les migrations + +```bash +# Se placer dans le répertoire backend +cd apps/backend + +# Copier l'env de prod +cp .env.example .env.production + +# Éditer .env.production avec les vraies valeurs Neon +# DATABASE_HOST=ep-xxx-xxx.eu-central-1.aws.neon.tech +# DATABASE_USER=xpeditis +# DATABASE_PASSWORD= +# DATABASE_NAME=xpeditis +# DATABASE_SSL=true + +# Lancer les migrations (connexion directe, pas poolée) +NODE_ENV=production npm run migration:run + +# Vérifier les migrations appliquées +NODE_ENV=production npm run typeorm query "SELECT version, name FROM typeorm_migrations ORDER BY id" +``` + +--- + +## Option B — PostgreSQL self-hosted sur Hetzner (1 000+ users) + +### Architecture recommandée + +``` +CX22/CCX13 ─── PostgreSQL primary (lecture + écriture) + │ +CCX13 ─── PostgreSQL replica (lecture seule + failover) + │ +Volume Hetzner ─── /var/lib/postgresql/data (persistant) +``` + +### 1. Créer le serveur PostgreSQL dédié + +```bash +# Créer un serveur CCX13 dédié pour PostgreSQL +hcloud server create \ + --name xpeditis-postgres \ + --type ccx13 \ + --image ubuntu-24.04 \ + --location fsn1 \ + --ssh-key xpeditis-deploy \ + --network xpeditis-network \ + --firewall xpeditis-firewall + +# Attacher le volume de données +hcloud volume attach xpeditis-postgres-data \ + --server xpeditis-postgres \ + --automount + +# Récupérer l'IP privée +POSTGRES_PRIVATE_IP=$(hcloud server ip xpeditis-postgres --private-ip) +echo "PostgreSQL IP privée: $POSTGRES_PRIVATE_IP" +``` + +### 2. Installer et configurer PostgreSQL + +```bash +# Se connecter au serveur +ssh -i ~/.ssh/xpeditis_hetzner root@ + +# Installer PostgreSQL 15 +apt-get update +apt-get install -y postgresql-15 postgresql-client-15 + +# Monter le volume de données +DEVICE_NAME=$(lsblk -o NAME,SERIAL | grep HC | head -1 | awk '{print $1}') +mkfs.ext4 /dev/$DEVICE_NAME +mkdir -p /mnt/postgres-data +mount /dev/$DEVICE_NAME /mnt/postgres-data +echo "/dev/$DEVICE_NAME /mnt/postgres-data ext4 defaults 0 2" >> /etc/fstab + +# Déplacer les données PostgreSQL vers le volume +systemctl stop postgresql +rsync -av /var/lib/postgresql /mnt/postgres-data/ +rm -rf /var/lib/postgresql/15/main +ln -s /mnt/postgres-data/postgresql/15/main /var/lib/postgresql/15/main +systemctl start postgresql + +# Créer la base de données et l'utilisateur +sudo -u postgres psql << 'PGSQL' +CREATE USER xpeditis WITH PASSWORD ''; +CREATE DATABASE xpeditis_prod OWNER xpeditis; +GRANT ALL PRIVILEGES ON DATABASE xpeditis_prod TO xpeditis; + +-- Connecter à la base +\c xpeditis_prod + +-- Extensions requises +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- Vérification +SELECT extname FROM pg_extension; +PGSQL +``` + +### 3. Configuration PostgreSQL pour la production + +```bash +# Éditer /etc/postgresql/15/main/postgresql.conf +cat >> /etc/postgresql/15/main/postgresql.conf << 'EOF' + +# Performance tuning (pour CCX13 : 4 vCPU dédiés, 8 GB RAM) +shared_buffers = 2GB # 25% de la RAM +effective_cache_size = 6GB # 75% de la RAM +maintenance_work_mem = 512MB +checkpoint_completion_target = 0.9 +wal_buffers = 16MB +default_statistics_target = 100 +random_page_cost = 1.1 # SSD NVMe +effective_io_concurrency = 200 # SSD +work_mem = 64MB +min_wal_size = 1GB +max_wal_size = 4GB + +# Connexions +max_connections = 100 +# Avec RDS Proxy / PgBouncer en front, 100 suffisent + +# Logging +log_destination = 'stderr' +logging_collector = on +log_directory = '/var/log/postgresql' +log_filename = 'postgresql-%Y-%m-%d.log' +log_rotation_age = 1d +log_min_duration_statement = 1000 # Log les queries > 1s +log_checkpoints = on +log_connections = on +log_disconnections = on +log_lock_waits = on + +# Réplication (pour replica future) +wal_level = replica +max_wal_senders = 3 +max_replication_slots = 3 +EOF + +# Autoriser les connexions depuis le réseau privé Hetzner +cat >> /etc/postgresql/15/main/pg_hba.conf << 'EOF' + +# Connexions depuis le réseau privé Hetzner (pods k3s) +host xpeditis_prod xpeditis 10.0.0.0/16 md5 +EOF + +# Écouter sur toutes les interfaces (nécessaire pour le réseau privé) +sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '10.0.0.0\/16,localhost'/" \ + /etc/postgresql/15/main/postgresql.conf + +systemctl restart postgresql +systemctl enable postgresql + +# Test de connexion depuis le réseau privé +psql -h $POSTGRES_PRIVATE_IP -U xpeditis -d xpeditis_prod -c "SELECT version();" +``` + +### 4. Installer PgBouncer (connection pooling) + +NestJS crée une connexion par pod. Sans pooler, 10 pods × 10 connexions = 100 connexions constantes. PgBouncer réduit ça drastiquement. + +```bash +apt-get install -y pgbouncer + +cat > /etc/pgbouncer/pgbouncer.ini << 'EOF' +[databases] +xpeditis_prod = host=localhost port=5432 dbname=xpeditis_prod + +[pgbouncer] +listen_addr = 0.0.0.0 +listen_port = 6432 +auth_type = md5 +auth_file = /etc/pgbouncer/userlist.txt +pool_mode = transaction # Mode le plus efficace pour NestJS +max_client_conn = 500 # Connexions clients max +default_pool_size = 20 # Connexions vers PostgreSQL par pool +reserve_pool_size = 5 +server_reset_query = DISCARD ALL +log_connections = 1 +log_disconnections = 1 +logfile = /var/log/pgbouncer/pgbouncer.log +pidfile = /var/run/pgbouncer/pgbouncer.pid +EOF + +# Créer le fichier d'authentification +echo '"xpeditis" "md5"' > /etc/pgbouncer/userlist.txt +# Pour générer le hash md5 : +echo -n "md5$(echo -n 'xpeditis' | md5sum | awk '{print $1}')" + +systemctl enable pgbouncer +systemctl start pgbouncer +``` + +Avec PgBouncer, les pods NestJS se connectent sur le port `6432` : +```bash +# Variables d'environnement pour PgBouncer +DATABASE_HOST= +DATABASE_PORT=6432 # PgBouncer au lieu de 5432 +``` + +### 5. Lancer les migrations TypeORM + +```bash +# Depuis votre machine locale (ou depuis un pod de migration) +cd apps/backend +DATABASE_HOST= \ +DATABASE_PORT=5432 \ # Direct PostgreSQL pour les migrations (pas PgBouncer) +DATABASE_USER=xpeditis \ +DATABASE_PASSWORD= \ +DATABASE_NAME=xpeditis_prod \ +npm run migration:run + +# Vérifier +DATABASE_HOST= \ +DATABASE_PORT=5432 \ +DATABASE_USER=xpeditis \ +DATABASE_PASSWORD= \ +DATABASE_NAME=xpeditis_prod \ +npm run typeorm query "SELECT COUNT(*) as migrations FROM typeorm_migrations" +``` + +--- + +## Comparaison des options + +| Critère | Neon.tech (Option A) | Self-hosted (Option B) | +|---|---|---| +| **Coût (1 000 users)** | $19/mois | ~€30/mois (CCX13 + volume) | +| **HA** | Automatique | Manuel (Patroni) | +| **Backups** | Automatique (7 jours PITR) | Script cron (doc 13) | +| **Extensions** | uuid-ossp + pg_trgm ✅ | Toutes | +| **Migrations** | Simple | Simple | +| **Ops requis** | Aucun | Maintenance mensuelle | +| **Scale** | Jusqu'à $69/mois (Pro) | Changement de serveur | +| **Limite connexions** | PgBouncer inclus | PgBouncer à installer | + +**Recommandation :** +- < 500 users → **Neon.tech** (aucun ops, $19/mois) +- 500–5 000 users → **Self-hosted CCX23** (plus économique à ce niveau) +- > 5 000 users → **Self-hosted CCX33 + replica** (contrôle total) diff --git a/docs/deployment/hetzner/08-redis-setup.md b/docs/deployment/hetzner/08-redis-setup.md new file mode 100644 index 0000000..2d235b8 --- /dev/null +++ b/docs/deployment/hetzner/08-redis-setup.md @@ -0,0 +1,313 @@ +# 08 — Redis Setup + +Redis est utilisé dans Xpeditis pour : +1. **Cache des rate quotes** — clés `rate:{origin}:{destination}:{containerType}`, TTL 15 min +2. **Pub/sub WebSocket** — Socket.IO multi-pods nécessite Redis pour broadcaster les notifications + +--- + +## Option A — Upstash (recommandé pour MVP) + +### Pourquoi Upstash + +- Redis serverless, pay-per-use ($0.2 per 100K commands) +- Free tier : 10 000 commandes/jour, 256 MB (suffisant pour 100 users) +- Compatible avec l'interface Redis standard (ioredis) +- Support TLS natif +- Régions EU disponibles (Frankfurt) +- **Pas de serveur à gérer** + +### Setup Upstash + +1. Créez un compte sur https://upstash.com +2. **Create Database** + - Name: `xpeditis-prod` + - Region: `EU-WEST-1 (Frankfurt)` ← le plus proche de Hetzner FSN1 + - Type: **Regional** (pas Global pour commencer) + - Eviction: **Allkeys-LRU** (expire les clés les plus anciennes si mémoire pleine) + - TLS: **Enabled** +3. Copiez les credentials affichés + +### Variables d'environnement + +```bash +# Upstash fournit une URL Redis complète +REDIS_HOST=your-redis.upstash.io +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# OU avec URL (si le code le supporte) +REDIS_URL=redis://:password@your-redis.upstash.io:6379 +``` + +### Vérification de la connexion + +```bash +# Test avec redis-cli +redis-cli -h your-redis.upstash.io -p 6379 -a --tls ping +# PONG + +# Test de set/get +redis-cli -h your-redis.upstash.io -p 6379 -a --tls \ + SET test:connection "xpeditis-ok" EX 60 +redis-cli -h your-redis.upstash.io -p 6379 -a --tls \ + GET test:connection +# "xpeditis-ok" +``` + +### Configuration dans l'app Xpeditis + +Le code NestJS utilise `ioredis`. Vérifiez que TLS est activé dans la config cache : + +Dans `apps/backend/src/infrastructure/cache/cache.module.ts`, assurez-vous que la config Redis accepte TLS : + +```typescript +// La config doit inclure TLS pour Upstash +const redisOptions = { + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT', 6379), + password: configService.get('REDIS_PASSWORD'), + db: configService.get('REDIS_DB', 0), + // TLS requis pour Upstash + tls: configService.get('NODE_ENV') === 'production' ? {} : undefined, +}; +``` + +> Si la config actuelle ne supporte pas TLS, ajoutez la variable `REDIS_TLS=true` et adaptez le cache module en conséquence. + +--- + +## Option B — Redis self-hosted dans k3s + +### Quand choisir cette option + +- 1 000+ users (le free tier Upstash devient limité) +- Besoin de Redis Cluster pour le WebSocket à grande échelle +- Contrôle total des données + +### StatefulSet Redis dans Kubernetes + +```bash +# Créer le namespace si pas encore fait +kubectl create namespace xpeditis-prod 2>/dev/null || true + +# Créer le Secret Redis +cat > /tmp/redis-secret.yaml << 'EOF' +apiVersion: v1 +kind: Secret +metadata: + name: redis-secret + namespace: xpeditis-prod +type: Opaque +stringData: + REDIS_PASSWORD: "" +EOF +kubectl apply -f /tmp/redis-secret.yaml + +# Créer la ConfigMap Redis +cat > /tmp/redis-config.yaml << 'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: + name: redis-config + namespace: xpeditis-prod +data: + redis.conf: | + # Sécurité + requirepass + protected-mode yes + + # Persistance (AOF pour durabilité) + appendonly yes + appendfsync everysec + auto-aof-rewrite-percentage 100 + auto-aof-rewrite-min-size 64mb + + # Mémoire + maxmemory 512mb + maxmemory-policy allkeys-lru + + # Réseau + bind 0.0.0.0 + tcp-backlog 511 + timeout 0 + tcp-keepalive 300 + + # Logging + loglevel notice + + # Performances + lazyfree-lazy-eviction yes + lazyfree-lazy-expire yes +EOF +kubectl apply -f /tmp/redis-config.yaml + +# Créer le StatefulSet Redis +cat > /tmp/redis-statefulset.yaml << 'EOF' +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: xpeditis-prod +spec: + serviceName: redis-headless + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + name: redis + command: + - redis-server + - /etc/redis/redis.conf + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: redis-secret + key: REDIS_PASSWORD + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumeMounts: + - name: redis-config-vol + mountPath: /etc/redis + - name: redis-data + mountPath: /data + readinessProbe: + exec: + command: + - redis-cli + - -a + - $(REDIS_PASSWORD) + - ping + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + exec: + command: + - redis-cli + - -a + - $(REDIS_PASSWORD) + - ping + initialDelaySeconds: 30 + periodSeconds: 30 + volumes: + - name: redis-config-vol + configMap: + name: redis-config + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: hcloud-volumes + resources: + requests: + storage: 5Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis-headless + namespace: xpeditis-prod +spec: + clusterIP: None + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: xpeditis-prod +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 + type: ClusterIP +EOF + +kubectl apply -f /tmp/redis-statefulset.yaml + +# Attendre que Redis soit prêt +kubectl rollout status statefulset/redis -n xpeditis-prod --timeout=120s + +# Tester Redis +kubectl exec -it redis-0 -n xpeditis-prod -- redis-cli -a ping +# PONG +``` + +### Variables d'environnement pour Redis self-hosted + +```bash +REDIS_HOST=redis.xpeditis-prod.svc.cluster.local +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +# Pas de TLS (réseau privé interne k3s) +``` + +--- + +## Vérification du cache Redis dans Xpeditis + +Après déploiement de l'application, vérifiez que le cache fonctionne : + +```bash +# Se connecter à Redis +kubectl exec -it redis-0 -n xpeditis-prod -- redis-cli -a + +# Après quelques rate searches depuis l'app : +KEYS rate:* +# 1) "rate:FRNCE:DEHAM:20ft" +# 2) "rate:FRNCE:NLRTM:20ft" +# ... + +# Vérifier un TTL (doit être < 900 = 15 min) +TTL "rate:FRNCE:DEHAM:20ft" +# (integer) 647 + +# Stats globales +INFO stats +# keyspace_hits: 1234 +# keyspace_misses: 156 +# → Taux de hit = 1234/(1234+156) = 88% ✅ +``` + +--- + +## Comparaison des options + +| Critère | Upstash (Option A) | Self-hosted (Option B) | +|---|---|---| +| **Coût 100 users** | $0 (free tier) | ~€5/mois (stockage) | +| **Coût 1 000 users** | ~$5-10/mois | ~€5-10/mois | +| **Setup** | 5 minutes | 30 minutes | +| **HA** | Automatique | Non (StatefulSet 1 replica) | +| **TLS** | Forcé | Non (cluster interne) | +| **Ops** | Aucun | Monitoring mémoire | +| **Latence** | ~5-10ms (Frankfurt) | <1ms (cluster interne) | + +**Recommandation :** +- MVP → **Upstash free tier** (zéro coût, zéro ops) +- 1 000+ users → **Self-hosted dans k3s** (latence minimale, contrôle complet) diff --git a/docs/deployment/hetzner/10-ingress-tls-cloudflare.md b/docs/deployment/hetzner/10-ingress-tls-cloudflare.md new file mode 100644 index 0000000..5184cf9 --- /dev/null +++ b/docs/deployment/hetzner/10-ingress-tls-cloudflare.md @@ -0,0 +1,240 @@ +# 10 — Ingress, TLS et Cloudflare + +--- + +## Architecture TLS + +Deux approches possibles, que vous pouvez combiner : + +``` +Option 1 — TLS Cloudflare uniquement (plus simple) + Browser → Cloudflare (TLS terminé) → HTTP vers Hetzner LB → Pods + +Option 2 — TLS de bout en bout (plus sécurisé) + Browser → Cloudflare → HTTPS vers Hetzner LB → cert-manager TLS → Pods + +Recommandation : Option 2 avec Cloudflare en "Full (strict)" mode +``` + +--- + +## Configuration Cloudflare + +### 1. Ajouter les entrées DNS + +Dans votre dashboard Cloudflare → Votre domaine → DNS → Records : + +``` +Type Name Content Proxy TTL +A api ✅ ON Auto +A app ✅ ON Auto +A @ ✅ ON Auto +``` + +Pour obtenir l'IP du Load Balancer Hetzner : +```bash +hcloud load-balancer list +# ID NAME TYPE LOCATION PUBLIC NET PRIVATE NET +# 12345 xpeditis-lb lb11 fsn1 1.2.3.4 / 2001::... 10.0.0.2 +``` + +### 2. SSL/TLS Mode + +Cloudflare → Votre domaine → SSL/TLS → Overview : +- Sélectionnez **Full (strict)** ← obligatoire si cert-manager gère les certicats côté Hetzner + +### 3. Page Rules / Transform Rules + +Cloudflare → Votre domaine → Rules → Page Rules : + +``` +Rule 1 : Force HTTPS + If URL matches: http://api.xpeditis.com/* + Then: Always Use HTTPS + +Rule 2 : Force HTTPS frontend + If URL matches: http://app.xpeditis.com/* + Then: Always Use HTTPS +``` + +### 4. WAF Rules (optionnel mais recommandé) + +Cloudflare → Security → WAF → Managed Rules : +- Activer **Cloudflare Managed Ruleset** (gratuit) +- Activer **Cloudflare OWASP Core Ruleset** (gratuit) + +Custom Rules pour Xpeditis : +``` +Rule: Block rate search abuse + If: (http.request.uri.path contains "/api/v1/rates/search") AND (rate(1m) > 60) + Then: Block + +Rule: Protect Stripe webhook + If: (http.request.uri.path eq "/api/v1/subscriptions/webhook") AND (not ip.src in {151.101.0.0/17}) + Then: Block ← Autorise uniquement les IPs Stripe +``` + +### 5. Cache Rules (pour les assets frontend) + +Cloudflare → Caching → Cache Rules : +``` +Rule: Cache Next.js static assets + If: (http.request.uri.path contains "/_next/static/") + Then: Cache Everything, TTL 1 year +``` + +--- + +## Vérification du certificat TLS (cert-manager) + +Après le déploiement de l'Ingress : + +```bash +# Vérifier l'état du certificat +kubectl get certificate -n xpeditis-prod +# NAME READY SECRET AGE +# xpeditis-tls-prod True xpeditis-tls-prod 5m ← READY=True = succès + +# Si READY=False, debugger : +kubectl describe certificate xpeditis-tls-prod -n xpeditis-prod +kubectl describe certificaterequest -n xpeditis-prod +kubectl logs -n cert-manager deployment/cert-manager | tail -50 + +# Voir les challenges ACME en cours +kubectl get challenge -n xpeditis-prod +# Si des challenges sont en attente, vérifier que le DNS Cloudflare pointe bien vers le LB +``` + +### Tester la chaîne TLS + +```bash +# Tester le certificat +curl -I https://api.xpeditis.com/api/v1/health +# HTTP/2 200 +# server: traefik +# content-type: application/json + +# Détails du certificat +openssl s_client -connect api.xpeditis.com:443 -servername api.xpeditis.com 2>/dev/null | openssl x509 -noout -dates +# notBefore=Apr 1 00:00:00 2026 GMT +# notAfter=Jun 30 00:00:00 2026 GMT ← Let's Encrypt = 90 jours, renouvellement auto à 60 jours +``` + +--- + +## Configuration WebSocket Socket.IO + +Socket.IO nécessite une configuration spécifique pour fonctionner derrière Traefik + Cloudflare. + +### Cloudflare WebSocket + +Cloudflare → Votre domaine → Network → WebSockets : +- **Activer WebSockets** (désactivé par défaut sur le plan Free) + +> Note : Sur le plan Free Cloudflare, les WebSockets sont supportés mais avec un timeout de 100s. Pour les connexions persistantes Socket.IO, configurez des reconnexions côté client. + +### Traefik Sticky Sessions + +La configuration des sticky sessions dans `k8s/07-ingress.yaml` garantit que les reconnexions WebSocket retombent sur le même pod (important pour Socket.IO avant l'implémentation Redis adapter) : + +```yaml +annotations: + traefik.ingress.kubernetes.io/service.sticky.cookie: "true" + traefik.ingress.kubernetes.io/service.sticky.cookie.name: "XPEDITIS_BACKEND" + traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true" +``` + +### Test WebSocket + +```bash +# Test avec wscat (npm install -g wscat) +wscat -c "wss://api.xpeditis.com/notifications" \ + -H "Authorization: Bearer " + +# La connexion doit s'établir et recevoir : +# {"event":"unread_count","data":{"count":0}} +# {"event":"recent_notifications","data":[...]} +``` + +--- + +## Traefik Dashboard (accès restreint) + +```bash +# Traefik a un dashboard utile pour debugger les routes +# Activer l'accès avec authentification + +# Générer un mot de passe htpasswd +htpasswd -nb admin | base64 + +# Créer un Middleware BasicAuth +cat > /tmp/traefik-auth.yaml << 'EOF' +apiVersion: v1 +kind: Secret +metadata: + name: traefik-dashboard-auth + namespace: kube-system +type: kubernetes.io/basic-auth +stringData: + username: admin + password: +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: dashboard-auth + namespace: kube-system +spec: + basicAuth: + secret: traefik-dashboard-auth +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: traefik-dashboard + namespace: kube-system + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + traefik.ingress.kubernetes.io/router.middlewares: "kube-system-dashboard-auth@kubernetescrd" +spec: + ingressClassName: traefik + tls: + - hosts: + - traefik.xpeditis.com + secretName: traefik-tls + rules: + - host: traefik.xpeditis.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: traefik + port: + number: 9000 +EOF + +kubectl apply -f /tmp/traefik-auth.yaml +``` + +--- + +## Checklist TLS + +```bash +echo "=== Test endpoints ===" +curl -sf https://api.xpeditis.com/api/v1/health | jq . +curl -sf https://app.xpeditis.com/ | head -5 + +echo "=== Certificats ===" +kubectl get certificate -n xpeditis-prod +kubectl get certificaterequest -n xpeditis-prod + +echo "=== Ingress ===" +kubectl get ingress -n xpeditis-prod + +echo "=== Test HTTPS force ===" +curl -L http://api.xpeditis.com/api/v1/health +# Doit être redirigé vers HTTPS +``` diff --git a/docs/deployment/hetzner/11-cicd-github-actions.md b/docs/deployment/hetzner/11-cicd-github-actions.md new file mode 100644 index 0000000..370d486 --- /dev/null +++ b/docs/deployment/hetzner/11-cicd-github-actions.md @@ -0,0 +1,489 @@ +# 11 — CI/CD avec GitHub Actions + +Pipeline complet : commit → build Docker → push GHCR → déploiement k3s → vérification. + +--- + +## Architecture du pipeline + +``` +Push sur main + │ + ├── Job: test + │ ├── npm run backend:lint + │ ├── npm run backend:test + │ └── npm run frontend:lint + │ + ├── Job: build (si tests OK) + │ ├── docker buildx build backend → ghcr.io//xpeditis-backend:sha + :latest + │ └── docker buildx build frontend → ghcr.io//xpeditis-frontend:sha + :latest + │ + └── Job: deploy (si build OK) + ├── kubectl set image deployment/xpeditis-backend ... + ├── kubectl set image deployment/xpeditis-frontend ... + ├── kubectl rollout status ... + └── Health check final +``` + +--- + +## Secrets GitHub à configurer + +Dans votre repo GitHub → Settings → Secrets and variables → Actions → New repository secret : + +| Secret | Valeur | Usage | +|---|---|---| +| `HETZNER_KUBECONFIG` | Contenu de `~/.kube/kubeconfig-xpeditis-prod` (base64) | Accès kubectl | +| `GHCR_TOKEN` | Personal Access Token GitHub (scope: `write:packages`) | Push images | +| `SLACK_WEBHOOK_URL` | URL webhook Slack (optionnel) | Notifications | + +```bash +# Encoder le kubeconfig en base64 pour GitHub Secrets +cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0 +# Copier le résultat dans HETZNER_KUBECONFIG + +# Créer le Personal Access Token GitHub +# https://github.com/settings/tokens/new +# Scopes : write:packages, read:packages, delete:packages +``` + +--- + +## Workflow principal — `.github/workflows/deploy.yml` + +```yaml +# .github/workflows/deploy.yml +name: Build & Deploy to Hetzner + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_BACKEND: ghcr.io/${{ github.repository_owner }}/xpeditis-backend + IMAGE_FRONTEND: ghcr.io/${{ github.repository_owner }}/xpeditis-frontend + +jobs: + # ============================================================ + # JOB 1 : Tests & Lint + # ============================================================ + test: + name: Tests & Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm run install:all + + - name: Lint backend + run: npm run backend:lint + + - name: Lint frontend + run: npm run frontend:lint + + - name: Test backend (unit) + run: npm run backend:test -- --passWithNoTests + + - name: TypeScript check frontend + run: | + cd apps/frontend + npm run type-check + + # ============================================================ + # JOB 2 : Build & Push Docker Images + # ============================================================ + build: + name: Build Docker Images + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + + outputs: + backend_tag: ${{ steps.meta-backend.outputs.version }} + frontend_tag: ${{ steps.meta-frontend.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # ── Backend ── + - name: Extract metadata (backend) + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_BACKEND }} + tags: | + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}} + + - name: Build & Push backend + uses: docker/build-push-action@v5 + with: + context: . + file: apps/backend/Dockerfile + push: true + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend + platforms: linux/amd64 # Changer en linux/amd64,linux/arm64 si vous utilisez des CAX + + # ── Frontend ── + - name: Extract metadata (frontend) + id: meta-frontend + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_FRONTEND }} + tags: | + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}} + + - name: Build & Push frontend + uses: docker/build-push-action@v5 + with: + context: . + file: apps/frontend/Dockerfile + push: true + tags: ${{ steps.meta-frontend.outputs.tags }} + labels: ${{ steps.meta-frontend.outputs.labels }} + cache-from: type=gha,scope=frontend + cache-to: type=gha,mode=max,scope=frontend + platforms: linux/amd64 + build-args: | + NEXT_PUBLIC_API_URL=https://api.xpeditis.com + NEXT_PUBLIC_APP_URL=https://app.xpeditis.com + NEXT_PUBLIC_API_PREFIX=api/v1 + + # ============================================================ + # JOB 3 : Deploy vers k3s Hetzner + # ============================================================ + deploy: + name: Deploy to Hetzner k3s + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: production + url: https://app.xpeditis.com + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure kubectl + run: | + mkdir -p ~/.kube + echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + kubectl cluster-info + + - name: Deploy Backend + run: | + BACKEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)" + + kubectl set image deployment/xpeditis-backend \ + backend=${{ env.IMAGE_BACKEND }}:${BACKEND_TAG} \ + -n xpeditis-prod + + kubectl rollout status deployment/xpeditis-backend \ + -n xpeditis-prod \ + --timeout=300s + + echo "✅ Backend deployed: ${BACKEND_TAG}" + + - name: Deploy Frontend + run: | + FRONTEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)" + + kubectl set image deployment/xpeditis-frontend \ + frontend=${{ env.IMAGE_FRONTEND }}:${FRONTEND_TAG} \ + -n xpeditis-prod + + kubectl rollout status deployment/xpeditis-frontend \ + -n xpeditis-prod \ + --timeout=300s + + echo "✅ Frontend deployed: ${FRONTEND_TAG}" + + - name: Health Check + run: | + sleep 15 # Laisser le temps au LB de propager + + # Test API backend + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + https://api.xpeditis.com/api/v1/health) + if [ "$STATUS" != "200" ]; then + echo "❌ Backend health check failed (HTTP $STATUS)" + exit 1 + fi + echo "✅ Backend healthy (HTTP $STATUS)" + + # Test frontend + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + https://app.xpeditis.com/) + if [ "$STATUS" != "200" ]; then + echo "❌ Frontend health check failed (HTTP $STATUS)" + exit 1 + fi + echo "✅ Frontend healthy (HTTP $STATUS)" + + - name: Notify Slack (success) + if: success() + run: | + if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then + curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \ + -H 'Content-type: application/json' \ + --data '{ + "text": "✅ Xpeditis déployé en production", + "attachments": [{ + "color": "good", + "fields": [ + {"title": "Commit", "value": "${{ github.sha }}", "short": true}, + {"title": "Auteur", "value": "${{ github.actor }}", "short": true}, + {"title": "Message", "value": "${{ github.event.head_commit.message }}", "short": false} + ] + }] + }' + fi + + - name: Notify Slack (failure) + if: failure() + run: | + if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then + curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \ + -H 'Content-type: application/json' \ + --data '{ + "text": "❌ Échec du déploiement Xpeditis", + "attachments": [{ + "color": "danger", + "fields": [ + {"title": "Commit", "value": "${{ github.sha }}", "short": true}, + {"title": "Job", "value": "${{ github.workflow }}", "short": true} + ] + }] + }' + fi + + - name: Rollback on failure + if: failure() + run: | + echo "⏮️ Rollback en cours..." + kubectl rollout undo deployment/xpeditis-backend -n xpeditis-prod + kubectl rollout undo deployment/xpeditis-frontend -n xpeditis-prod + kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod --timeout=120s + echo "✅ Rollback terminé" +``` + +--- + +## Workflow de staging (PR preview) — `.github/workflows/staging.yml` + +```yaml +# .github/workflows/staging.yml +name: Deploy to Staging + +on: + pull_request: + branches: + - main + +jobs: + build-staging: + name: Build Staging + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & Push (staging tag) + uses: docker/build-push-action@v5 + with: + context: . + file: apps/backend/Dockerfile + push: true + tags: ghcr.io/${{ github.repository_owner }}/xpeditis-backend:pr-${{ github.event.pull_request.number }} + build-args: NODE_ENV=staging + + - name: Comment PR + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '🐳 Image Docker staging buildée : `pr-${{ github.event.pull_request.number }}`' + }) +``` + +--- + +## Mise à jour des manifests Kubernetes + +Alternativement, vous pouvez mettre à jour les fichiers YAML dans Git et les appliquer : + +```bash +# Dans le workflow CI, mettre à jour le tag d'image dans les manifests +- name: Update image in manifests + run: | + IMAGE_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)" + + # Mettre à jour les fichiers YAML + sed -i "s|image: ghcr.io/.*/xpeditis-backend:.*|image: ${{ env.IMAGE_BACKEND }}:${IMAGE_TAG}|g" \ + k8s/03-backend-deployment.yaml + + sed -i "s|image: ghcr.io/.*/xpeditis-frontend:.*|image: ${{ env.IMAGE_FRONTEND }}:${IMAGE_TAG}|g" \ + k8s/05-frontend-deployment.yaml + + # Committer les changements (GitOps) + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add k8s/ + git commit -m "chore: update image tags to ${IMAGE_TAG} [skip ci]" + git push +``` + +--- + +## Dockerfile final — Backend + +```dockerfile +# apps/backend/Dockerfile +FROM node:20-alpine AS deps +RUN apk add --no-cache python3 make g++ +WORKDIR /app +COPY package*.json ./ +COPY apps/backend/package*.json apps/backend/ +RUN npm ci --workspace=apps/backend + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/backend/node_modules ./apps/backend/node_modules +COPY . . +RUN cd apps/backend && npm run build + +FROM node:20-alpine AS runner +RUN apk add --no-cache dumb-init +RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001 +WORKDIR /app +COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/dist ./dist +COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/node_modules ./node_modules +COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/package.json ./ +USER nestjs +EXPOSE 4000 +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget -qO- http://localhost:4000/api/v1/health || exit 1 +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/main.js"] +``` + +## Dockerfile final — Frontend + +```dockerfile +# apps/frontend/Dockerfile +FROM node:20-alpine AS deps +WORKDIR /app +COPY package*.json ./ +COPY apps/frontend/package*.json apps/frontend/ +RUN npm ci --workspace=apps/frontend + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/frontend/node_modules ./apps/frontend/node_modules +COPY . . +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_API_PREFIX=api/v1 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ + NEXT_PUBLIC_API_PREFIX=$NEXT_PUBLIC_API_PREFIX \ + NEXT_TELEMETRY_DISABLED=1 +RUN cd apps/frontend && npm run build + +FROM node:20-alpine AS runner +RUN apk add --no-cache dumb-init +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 +WORKDIR /app +COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/public ./public +USER nextjs +EXPOSE 3000 +ENV NODE_ENV=production PORT=3000 HOSTNAME="0.0.0.0" NEXT_TELEMETRY_DISABLED=1 +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "server.js"] +``` + +--- + +## Test du pipeline en local + +```bash +# Simuler le build Docker localement +docker build \ + -f apps/backend/Dockerfile \ + -t xpeditis-backend:local \ + . + +docker build \ + -f apps/frontend/Dockerfile \ + -t xpeditis-frontend:local \ + --build-arg NEXT_PUBLIC_API_URL=http://localhost:4000 \ + --build-arg NEXT_PUBLIC_APP_URL=http://localhost:3000 \ + . + +# Tester l'image backend +docker run --rm -p 4000:4000 \ + -e NODE_ENV=production \ + -e DATABASE_HOST= \ + -e DATABASE_USER=xpeditis \ + -e DATABASE_PASSWORD= \ + -e DATABASE_NAME=xpeditis \ + -e REDIS_HOST= \ + -e REDIS_PASSWORD= \ + -e JWT_SECRET=test-secret \ + -e SMTP_HOST=localhost \ + -e SMTP_PORT=25 \ + -e SMTP_USER=test \ + -e SMTP_PASS=test \ + xpeditis-backend:local + +curl http://localhost:4000/api/v1/health +``` diff --git a/docs/deployment/hetzner/12-monitoring-alerting.md b/docs/deployment/hetzner/12-monitoring-alerting.md new file mode 100644 index 0000000..42129ba --- /dev/null +++ b/docs/deployment/hetzner/12-monitoring-alerting.md @@ -0,0 +1,416 @@ +# 12 — Monitoring et alertes + +--- + +## Stack de monitoring + +``` +Prometheus ← Scrape des métriques (pods, nodes, app) +Grafana ← Dashboards visuels +Loki ← Agrégation des logs (NestJS pino) +Alertmanager ← Envoi alertes (email, Slack) +Uptime Kuma ← Monitoring externe HTTP (health checks) +``` + +--- + +## Installation du kube-prometheus-stack + +La stack la plus complète, déployée avec Helm : + +```bash +# Ajouter le repo +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update + +# Créer le namespace monitoring +kubectl create namespace monitoring + +# Installer kube-prometheus-stack +helm install prometheus prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --version 65.3.1 \ + --set grafana.adminPassword="" \ + --set grafana.persistence.enabled=true \ + --set grafana.persistence.size=2Gi \ + --set grafana.persistence.storageClassName=hcloud-volumes \ + --set prometheus.prometheusSpec.retention=7d \ + --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.storageClassName=hcloud-volumes \ + --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=10Gi \ + --set alertmanager.alertmanagerSpec.storage.volumeClaimTemplate.spec.storageClassName=hcloud-volumes \ + --set alertmanager.alertmanagerSpec.storage.volumeClaimTemplate.spec.resources.requests.storage=2Gi \ + --set prometheusOperator.resources.requests.cpu=50m \ + --set prometheusOperator.resources.requests.memory=128Mi \ + --set prometheus.prometheusSpec.resources.requests.cpu=100m \ + --set prometheus.prometheusSpec.resources.requests.memory=512Mi \ + --set grafana.resources.requests.cpu=50m \ + --set grafana.resources.requests.memory=128Mi + +# Attendre que tout soit Running +kubectl rollout status deployment/prometheus-grafana -n monitoring --timeout=300s +kubectl get pods -n monitoring +``` + +--- + +## Exposer Grafana via Ingress + +```bash +cat > /tmp/grafana-ingress.yaml << 'EOF' +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grafana + namespace: monitoring + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + traefik.ingress.kubernetes.io/router.entrypoints: "websecure" + traefik.ingress.kubernetes.io/router.tls: "true" + # Restreindre aux IPs de l'équipe + traefik.ingress.kubernetes.io/router.middlewares: "monitoring-ipwhitelist@kubernetescrd" +spec: + ingressClassName: traefik + tls: + - hosts: + - monitoring.xpeditis.com + secretName: monitoring-tls + rules: + - host: monitoring.xpeditis.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: prometheus-grafana + port: + number: 80 +--- +# IP Whitelist pour Grafana (votre équipe seulement) +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ipwhitelist + namespace: monitoring +spec: + ipWhiteList: + sourceRange: + - "/32" + - "10.0.0.0/16" # Réseau interne Hetzner +EOF + +kubectl apply -f /tmp/grafana-ingress.yaml +``` + +--- + +## Installation de Loki (agrégation des logs) + +```bash +helm repo add grafana https://grafana.github.io/helm-charts +helm repo update + +helm install loki grafana/loki-stack \ + --namespace monitoring \ + --set loki.persistence.enabled=true \ + --set loki.persistence.size=5Gi \ + --set loki.persistence.storageClassName=hcloud-volumes \ + --set promtail.enabled=true \ + --set loki.config.limits_config.retention_period=7d \ + --set grafana.enabled=false # On utilise le Grafana déjà installé + +# Ajouter Loki comme datasource dans Grafana +# Grafana → Data Sources → Add → Loki +# URL: http://loki:3100 +``` + +--- + +## Configuration des alertes + +### Alertes Xpeditis spécifiques + +```bash +cat > /tmp/xpeditis-alerts.yaml << 'EOF' +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: xpeditis-alerts + namespace: xpeditis-prod + labels: + release: prometheus +spec: + groups: + - name: xpeditis.backend + interval: 30s + rules: + + # Backend down + - alert: XpeditisBackendDown + expr: up{job="xpeditis-backend"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Backend Xpeditis indisponible" + description: "Aucun pod backend ne répond depuis 1 minute." + + # Trop peu de replicas + - alert: XpeditisBackendLowReplicas + expr: kube_deployment_status_replicas_available{deployment="xpeditis-backend",namespace="xpeditis-prod"} < 1 + for: 2m + labels: + severity: critical + annotations: + summary: "Moins d'1 replica backend disponible" + + # CPU élevé (déclenchement autoscaling probable) + - alert: XpeditisHighCPU + expr: | + sum(rate(container_cpu_usage_seconds_total{ + namespace="xpeditis-prod", + container="backend" + }[5m])) by (pod) > 0.8 + for: 5m + labels: + severity: warning + annotations: + summary: "CPU élevé sur pod {{ $labels.pod }}" + description: "Utilisation CPU > 80% depuis 5 minutes." + + # Mémoire élevée + - alert: XpeditisHighMemory + expr: | + container_memory_usage_bytes{ + namespace="xpeditis-prod", + container="backend" + } / container_spec_memory_limit_bytes{ + namespace="xpeditis-prod", + container="backend" + } > 0.85 + for: 5m + labels: + severity: warning + annotations: + summary: "Mémoire élevée sur pod {{ $labels.pod }}" + + # Taux d'erreur HTTP élevé + - alert: XpeditisHighErrorRate + expr: | + sum(rate(traefik_service_requests_total{ + service=~"xpeditis-prod-xpeditis-backend.*", + code=~"5.." + }[5m])) / + sum(rate(traefik_service_requests_total{ + service=~"xpeditis-prod-xpeditis-backend.*" + }[5m])) > 0.05 + for: 2m + labels: + severity: warning + annotations: + summary: "Taux d'erreur 5xx > 5% sur l'API backend" + + # Pods en CrashLoopBackOff + - alert: XpeditisPodCrashLooping + expr: | + increase(kube_pod_container_status_restarts_total{ + namespace="xpeditis-prod" + }[1h]) > 5 + labels: + severity: critical + annotations: + summary: "Pod {{ $labels.pod }} redémarre trop souvent" + + - name: xpeditis.database + rules: + # Pas d'alerte directe sur Neon (managed) — uniquement si self-hosted + + - name: xpeditis.redis + rules: + # Redis mémoire élevée + - alert: RedisHighMemory + expr: | + redis_memory_used_bytes / + redis_memory_max_bytes > 0.85 + for: 5m + labels: + severity: warning + annotations: + summary: "Redis utilise > 85% de sa mémoire" +EOF + +kubectl apply -f /tmp/xpeditis-alerts.yaml +``` + +### Configuration Alertmanager (Slack) + +```bash +cat > /tmp/alertmanager-config.yaml << 'EOF' +apiVersion: v1 +kind: Secret +metadata: + name: alertmanager-prometheus-kube-prometheus-alertmanager + namespace: monitoring +stringData: + alertmanager.yaml: | + global: + resolve_timeout: 5m + slack_api_url: '' + + route: + group_by: ['alertname', 'namespace'] + group_wait: 10s + group_interval: 10m + repeat_interval: 12h + receiver: 'slack-notifications' + routes: + - match: + severity: critical + receiver: 'slack-critical' + - match: + severity: warning + receiver: 'slack-notifications' + + receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#xpeditis-monitoring' + icon_url: https://avatars.githubusercontent.com/u/3380462 + title: '{{ template "slack.default.title" . }}' + text: '{{ template "slack.default.text" . }}' + send_resolved: true + + - name: 'slack-critical' + slack_configs: + - channel: '#xpeditis-alerts-critiques' + color: 'danger' + title: '🚨 ALERTE CRITIQUE : {{ .CommonAnnotations.summary }}' + text: '{{ .CommonAnnotations.description }}' + send_resolved: true +EOF + +kubectl apply -f /tmp/alertmanager-config.yaml +``` + +--- + +## Dashboards Grafana recommandés + +Importez ces dashboards depuis grafana.com (ID à entrer dans Grafana → Import) : + +| Dashboard | ID | Usage | +|---|---|---| +| Kubernetes Cluster Overview | 6417 | Vue d'ensemble cluster | +| Kubernetes Deployments | 8588 | Détail des deployments | +| Node Exporter Full | 1860 | Métriques système des nœuds | +| Loki & Promtail | 12611 | Logs agrégés | +| Traefik 2 | 4475 | Métriques ingress/requêtes | + +```bash +# Dans Grafana (https://monitoring.xpeditis.com) +# → + → Import +# → Entrer l'ID et cliquer "Load" +# → Sélectionner la datasource Prometheus +# → Import +``` + +--- + +## Uptime Kuma (monitoring externe) + +Uptime Kuma monitore vos endpoints depuis l'extérieur du cluster, indépendamment de Prometheus : + +```bash +# Déployer Uptime Kuma dans le cluster +cat > /tmp/uptime-kuma.yaml << 'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: uptime-kuma + namespace: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: uptime-kuma + template: + metadata: + labels: + app: uptime-kuma + spec: + containers: + - name: uptime-kuma + image: louislam/uptime-kuma:1 + ports: + - containerPort: 3001 + volumeMounts: + - name: data + mountPath: /app/data + resources: + requests: + cpu: 50m + memory: 128Mi + volumes: + - name: data + persistentVolumeClaim: + claimName: uptime-kuma-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: uptime-kuma-pvc + namespace: monitoring +spec: + accessModes: [ReadWriteOnce] + storageClassName: hcloud-volumes + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: uptime-kuma + namespace: monitoring +spec: + selector: + app: uptime-kuma + ports: + - port: 3001 + targetPort: 3001 +EOF + +kubectl apply -f /tmp/uptime-kuma.yaml +``` + +Monitors à configurer dans Uptime Kuma : + +| Monitor | URL | Intervalle | +|---|---|---| +| API Health | `https://api.xpeditis.com/api/v1/health` | 1 min | +| Frontend | `https://app.xpeditis.com/` | 1 min | +| API Login | `POST https://api.xpeditis.com/api/v1/auth/login` | 5 min | + +--- + +## Commandes de monitoring rapides + +```bash +# Top des pods par consommation CPU/RAM +kubectl top pods -n xpeditis-prod --sort-by=cpu + +# Événements récents du namespace +kubectl get events -n xpeditis-prod --sort-by='.lastTimestamp' | tail -20 + +# Logs backend en temps réel (tous les pods) +stern xpeditis-backend -n xpeditis-prod + +# Logs d'erreurs uniquement +kubectl logs -l app=xpeditis-backend -n xpeditis-prod --since=1h | grep -i error + +# Status des HPAs +kubectl get hpa -n xpeditis-prod + +# Métriques des nœuds +kubectl top nodes +``` diff --git a/docs/deployment/hetzner/14-security-hardening.md b/docs/deployment/hetzner/14-security-hardening.md new file mode 100644 index 0000000..12d8fee --- /dev/null +++ b/docs/deployment/hetzner/14-security-hardening.md @@ -0,0 +1,349 @@ +# 14 — Sécurité et hardening + +--- + +## Couches de sécurité + +``` +Internet + │ + ▼ Couche 1 : Cloudflare (WAF, DDoS, Bot protection) + │ + ▼ Couche 2 : Hetzner Firewall (ports, IP whitelist) + │ + ▼ Couche 3 : k3s Network Policies (isolation namespace) + │ + ▼ Couche 4 : NestJS Guards (JWT, Rate Limiting, Roles) + │ + ▼ Couche 5 : PostgreSQL (SSL, auth md5) +``` + +--- + +## Hardening des nœuds Hetzner + +Ces commandes sont exécutées automatiquement via `post_create_commands` dans `cluster.yaml`, mais voici les détails : + +```bash +# Se connecter sur chaque nœud +ssh -i ~/.ssh/xpeditis_hetzner root@ + +# 1. Mettre à jour le système +apt-get update && apt-get upgrade -y + +# 2. Désactiver le login root par mot de passe (SSH key uniquement) +sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config +sed -i 's/PermitRootLogin yes/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config +systemctl restart sshd + +# 3. Configurer fail2ban +apt-get install -y fail2ban +cat > /etc/fail2ban/jail.d/sshd.conf << 'EOF' +[sshd] +enabled = true +maxretry = 3 +bantime = 3600 +findtime = 600 +EOF +systemctl enable fail2ban && systemctl restart fail2ban + +# 4. Configurer le firewall UFW (en plus du firewall Hetzner) +apt-get install -y ufw +ufw default deny incoming +ufw default allow outgoing +ufw allow from 10.0.0.0/16 # Réseau privé Hetzner +ufw allow 22/tcp # SSH +ufw allow 80/tcp # HTTP (LB) +ufw allow 443/tcp # HTTPS (LB) +ufw allow 6443/tcp # K8s API +ufw --force enable + +# 5. Kernel hardening +cat >> /etc/sysctl.d/99-security.conf << 'EOF' +# Désactiver les paquets IP forwardés depuis des sources inconnues +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.default.rp_filter = 1 + +# Ignorer les ICMP broadcasts +net.ipv4.icmp_echo_ignore_broadcasts = 1 + +# Désactiver l'acceptation des redirections ICMP +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.all.send_redirects = 0 + +# SYN flood protection +net.ipv4.tcp_syncookies = 1 +EOF +sysctl -p /etc/sysctl.d/99-security.conf +``` + +--- + +## Network Policies Kubernetes + +Les NetworkPolicies limitent les communications entre pods : + +```bash +cat > /tmp/network-policies.yaml << 'EOF' +# Politique par défaut : bloquer tout trafic entrant dans le namespace +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-ingress + namespace: xpeditis-prod +spec: + podSelector: {} + policyTypes: + - Ingress + +# Autoriser le trafic depuis Traefik vers le backend +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-traefik-to-backend + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app: xpeditis-backend + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - port: 4000 + +# Autoriser le trafic depuis Traefik vers le frontend +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-traefik-to-frontend + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app: xpeditis-frontend + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - port: 3000 + +# Autoriser le trafic du backend vers Redis (self-hosted) +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-backend-to-redis + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app: redis + ingress: + - from: + - podSelector: + matchLabels: + app: xpeditis-backend + ports: + - port: 6379 + +# Autoriser Prometheus à scraper les métriques du backend +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-prometheus-scrape + namespace: xpeditis-prod +spec: + podSelector: + matchLabels: + app: xpeditis-backend + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + ports: + - port: 4000 +EOF + +kubectl apply -f /tmp/network-policies.yaml +``` + +--- + +## Rotation des secrets + +### Script de rotation du JWT secret + +```bash +#!/bin/bash +# scripts/rotate-jwt-secret.sh +# ⚠️ Cette opération déconnecte TOUS les utilisateurs connectés + +set -e +echo "⚠️ Rotation du JWT Secret — tous les utilisateurs seront déconnectés" +read -p "Confirmer ? (yes/no): " CONFIRM +[ "$CONFIRM" != "yes" ] && exit 1 + +# Générer un nouveau secret +NEW_SECRET=$(openssl rand -base64 48) + +# Mettre à jour le Secret Kubernetes +kubectl patch secret backend-secrets -n xpeditis-prod \ + --type='json' \ + -p="[{\"op\":\"replace\",\"path\":\"/data/JWT_SECRET\",\"value\":\"$(echo -n $NEW_SECRET | base64)\"}]" + +# Redémarrer les pods pour prendre en compte le nouveau secret +kubectl rollout restart deployment/xpeditis-backend -n xpeditis-prod + +# Attendre +kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod --timeout=120s + +echo "✅ JWT Secret roté. Tous les utilisateurs devront se reconnecter." +``` + +### Rotation des credentials Hetzner Object Storage + +```bash +# 1. Dans la console Hetzner → Object Storage → Access Keys → Generate new key +# 2. Mettre à jour le Secret Kubernetes avec les nouvelles valeurs +# 3. Redémarrer les pods +kubectl patch secret backend-secrets -n xpeditis-prod \ + --type='json' \ + -p='[ + {"op":"replace","path":"/data/AWS_ACCESS_KEY_ID","value":""}, + {"op":"replace","path":"/data/AWS_SECRET_ACCESS_KEY","value":""} + ]' +kubectl rollout restart deployment/xpeditis-backend -n xpeditis-prod +# 4. Supprimer l'ancienne clé dans la console Hetzner +``` + +--- + +## Sécurisation des accès Kubernetes + +### RBAC — Utilisateur de déploiement limité + +```bash +cat > /tmp/rbac-deploy.yaml << 'EOF' +# Utilisateur de déploiement CI/CD (accès limité au namespace xpeditis-prod) +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ci-deploy + namespace: xpeditis-prod +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: deployer + namespace: xpeditis-prod +rules: +- apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "update", "patch"] +- apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list"] +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ci-deploy-binding + namespace: xpeditis-prod +subjects: +- kind: ServiceAccount + name: ci-deploy + namespace: xpeditis-prod +roleRef: + kind: Role + name: deployer + apiGroup: rbac.authorization.k8s.io +EOF + +kubectl apply -f /tmp/rbac-deploy.yaml + +# Générer un kubeconfig limité pour le CI (alternative au kubeconfig admin) +SECRET_NAME=$(kubectl get serviceaccount ci-deploy -n xpeditis-prod \ + -o jsonpath='{.secrets[0].name}') +TOKEN=$(kubectl get secret $SECRET_NAME -n xpeditis-prod \ + -o jsonpath='{.data.token}' | base64 -d) + +# Utiliser ce token dans GitHub Secrets pour le CI (plus sécurisé que le kubeconfig admin) +echo "Token CI : $TOKEN" +``` + +--- + +## Audit des accès + +```bash +# Vérifier les dernières connexions SSH sur les nœuds +for NODE in $(hcloud server list -o columns=name --no-header); do + IP=$(hcloud server ip $NODE) + echo "=== $NODE ($IP) ===" + ssh -i ~/.ssh/xpeditis_hetzner root@$IP "last -20 | head -10" +done + +# Vérifier les événements Kubernetes suspects +kubectl get events -A --field-selector type=Warning | grep -v "Normal" + +# Vérifier les tentatives d'accès bloquées par fail2ban +ssh -i ~/.ssh/xpeditis_hetzner root@ \ + "fail2ban-client status sshd" +``` + +--- + +## Checklist de sécurité + +``` +Infrastructure +□ Token API Hetzner limité au projet (read+write minimum nécessaire) +□ Firewall Hetzner : SSH uniquement depuis votre IP +□ fail2ban actif sur tous les nœuds +□ Mises à jour OS automatiques (unattended-upgrades) + +Kubernetes +□ NetworkPolicies appliquées +□ Secrets dans Kubernetes (pas dans les ConfigMaps) +□ k8s/01-secrets.yaml dans .gitignore +□ RBAC CI/CD avec ServiceAccount limité +□ Pod Security Standards activés + +Application +□ JWT_SECRET 48+ caractères aléatoires +□ NEXTAUTH_SECRET différent du JWT_SECRET +□ Stripe en mode live (pas test) en production +□ Sentry configuré pour les erreurs +□ SMTP_FROM vérifié (SPF/DKIM dans Brevo/SendGrid) + +TLS/DNS +□ Cloudflare SSL mode "Full (strict)" +□ HSTS activé (stsPreload: true dans Traefik) +□ Certificats Let's Encrypt valides (READY=True) +□ HTTP → HTTPS redirect actif + +Backups +□ Backup PostgreSQL quotidien testé +□ Secrets Kubernetes sauvegardés chiffrés +□ Test de restauration effectué +``` diff --git a/docs/deployment/hetzner/15-operations-scaling.md b/docs/deployment/hetzner/15-operations-scaling.md new file mode 100644 index 0000000..b7338a0 --- /dev/null +++ b/docs/deployment/hetzner/15-operations-scaling.md @@ -0,0 +1,424 @@ +# 15 — Opérations, Scaling et Troubleshooting + +Référence quotidienne pour gérer le cluster en production. + +--- + +## Commandes kubectl essentielles + +### Vue d'ensemble rapide + +```bash +# État du cluster +kubectl get nodes +kubectl get pods -n xpeditis-prod +kubectl get pods -n xpeditis-prod -o wide # + infos sur les nœuds + +# Ressources consommées +kubectl top nodes +kubectl top pods -n xpeditis-prod --sort-by=cpu + +# Événements récents (erreurs, warnings) +kubectl get events -n xpeditis-prod --sort-by='.lastTimestamp' | tail -30 +kubectl get events -n xpeditis-prod -w # En temps réel + +# État des déploiements +kubectl get deployments -n xpeditis-prod +kubectl get hpa -n xpeditis-prod +kubectl get pvc -n xpeditis-prod +``` + +### Logs + +```bash +# Logs backend (tous les pods) +kubectl logs -l app=xpeditis-backend -n xpeditis-prod --since=1h + +# Logs frontend +kubectl logs -l app=xpeditis-frontend -n xpeditis-prod --since=1h + +# Logs en temps réel (un pod spécifique) +kubectl logs -f pod/xpeditis-backend-5b8d6c7f9-xxxxx -n xpeditis-prod + +# Logs multi-pods en temps réel (avec stern) +stern xpeditis-backend -n xpeditis-prod +stern xpeditis -n xpeditis-prod # Backend + frontend + +# Filtrer les erreurs +kubectl logs -l app=xpeditis-backend -n xpeditis-prod --since=1h | grep -E "ERROR|error|Error" + +# Logs des dernières 100 lignes d'un pod crashé +kubectl logs --previous pod/xpeditis-backend-xxx -n xpeditis-prod | tail -100 +``` + +### Exécution dans un pod + +```bash +# Shell interactif dans un pod backend +kubectl exec -it deployment/xpeditis-backend -n xpeditis-prod -- /bin/sh + +# Commande unique +kubectl exec deployment/xpeditis-backend -n xpeditis-prod -- \ + node -e "console.log(process.env.NODE_ENV)" + +# Vérifier la connectivité DB depuis un pod +kubectl exec deployment/xpeditis-backend -n xpeditis-prod -- \ + nc -zv 10.0.1.100 5432 +``` + +--- + +## Déploiements + +### Déploiement d'une nouvelle version + +```bash +# Via CI/CD (automatique sur push main) — voir doc 11 + +# Manuel : mettre à jour l'image +IMAGE_TAG="sha-$(git rev-parse --short HEAD)" + +kubectl set image deployment/xpeditis-backend \ + backend=ghcr.io//xpeditis-backend:${IMAGE_TAG} \ + -n xpeditis-prod + +# Suivre le déploiement +kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod --timeout=300s + +# Vérifier la version déployée +kubectl get deployment xpeditis-backend -n xpeditis-prod \ + -o jsonpath='{.spec.template.spec.containers[0].image}' +``` + +### Rollback + +```bash +# Rollback vers la version précédente +kubectl rollout undo deployment/xpeditis-backend -n xpeditis-prod + +# Rollback vers une version spécifique +kubectl rollout history deployment/xpeditis-backend -n xpeditis-prod +# REVISION CHANGE-CAUSE +# 1 Initial deployment +# 2 sha-abc1234 +# 3 sha-def5678 ← actuelle + +kubectl rollout undo deployment/xpeditis-backend \ + --to-revision=2 \ + -n xpeditis-prod + +# Vérifier +kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod +``` + +### Redémarrage forcé (sans changer l'image) + +```bash +# Utile après modification des secrets ou configmaps +kubectl rollout restart deployment/xpeditis-backend -n xpeditis-prod +kubectl rollout restart deployment/xpeditis-frontend -n xpeditis-prod + +# Ou redémarrer un pod spécifique (K8s en recrée un nouveau) +kubectl delete pod xpeditis-backend-5b8d6c7f9-xxxxx -n xpeditis-prod +``` + +--- + +## Scaling manuel + +```bash +# Scale horizontal (nombre de pods) +kubectl scale deployment xpeditis-backend \ + --replicas=5 \ + -n xpeditis-prod + +# Scale horizontal frontend +kubectl scale deployment xpeditis-frontend \ + --replicas=3 \ + -n xpeditis-prod + +# Désactiver temporairement le HPA (maintenance) +kubectl patch hpa backend-hpa -n xpeditis-prod \ + -p '{"spec":{"minReplicas":0,"maxReplicas":0}}' + +# Réactiver le HPA +kubectl patch hpa backend-hpa -n xpeditis-prod \ + -p '{"spec":{"minReplicas":2,"maxReplicas":15}}' +``` + +--- + +## Gestion des nœuds + +### Maintenance d'un nœud (drain) + +```bash +# 1. Mettre le nœud en maintenance (draine les pods, bloque les nouveaux) +kubectl cordon xpeditis-prod-cx32-worker-1 +kubectl drain xpeditis-prod-cx32-worker-1 \ + --ignore-daemonsets \ + --delete-emptydir-data \ + --grace-period=30 + +# 2. Effectuer la maintenance (mise à jour OS, etc.) +ssh -i ~/.ssh/xpeditis_hetzner root@ +apt-get update && apt-get upgrade -y +reboot + +# 3. Remettre le nœud en service +kubectl uncordon xpeditis-prod-cx32-worker-1 + +# Vérifier que les pods reviennent +kubectl get pods -n xpeditis-prod -o wide | grep worker-1 +``` + +### Ajouter un nœud worker + +```bash +# Méthode 1 : Via hetzner-k3s (recommandé) +# Modifier cluster.yaml → instance_count: 3 (ou plus) +hetzner-k3s apply --config ~/.xpeditis/cluster.yaml + +# Méthode 2 : Via Cluster Autoscaler (automatique) +# Le CA crée des nœuds quand des pods sont en état "Pending" +# Pour forcer : déployer une charge +kubectl scale deployment xpeditis-backend --replicas=20 -n xpeditis-prod +# Le CA va créer des nœuds automatiquement +# Remettre en place après test +kubectl scale deployment xpeditis-backend --replicas=2 -n xpeditis-prod +``` + +--- + +## Mise à jour de k3s + +Le System Upgrade Controller gère les upgrades automatiquement. Pour une mise à jour manuelle : + +```bash +# Vérifier la version actuelle +kubectl get nodes -o jsonpath='{.items[0].status.nodeInfo.kubeletVersion}' +# v1.30.4+k3s1 + +# Créer un Plan de mise à jour +cat > /tmp/k3s-upgrade.yaml << 'EOF' +apiVersion: upgrade.cattle.io/v1 +kind: Plan +metadata: + name: k3s-server + namespace: system-upgrade +spec: + concurrency: 1 # Un nœud à la fois + cordon: true + serviceAccountName: system-upgrade + version: v1.31.0+k3s1 # Nouvelle version + upgrade: + image: rancher/k3s-upgrade + channel: https://update.k3s.io/v1-release/channels/stable + nodeSelector: + matchExpressions: + - {key: node-role.kubernetes.io/control-plane, operator: Exists} +--- +apiVersion: upgrade.cattle.io/v1 +kind: Plan +metadata: + name: k3s-agent + namespace: system-upgrade +spec: + concurrency: 1 + cordon: true + serviceAccountName: system-upgrade + version: v1.31.0+k3s1 + prepare: + image: rancher/k3s-upgrade + args: ["prepare", "k3s-server"] + upgrade: + image: rancher/k3s-upgrade + channel: https://update.k3s.io/v1-release/channels/stable + nodeSelector: + matchExpressions: + - {key: node-role.kubernetes.io/control-plane, operator: DoesNotExist} +EOF + +kubectl apply -f /tmp/k3s-upgrade.yaml + +# Suivre la progression +kubectl get plans -n system-upgrade +kubectl get jobs -n system-upgrade +``` + +--- + +## Troubleshooting — Problèmes courants + +### Pod en CrashLoopBackOff + +```bash +# 1. Voir les logs du crash +kubectl logs pod/xpeditis-backend-xxx -n xpeditis-prod --previous + +# 2. Décrire le pod +kubectl describe pod xpeditis-backend-xxx -n xpeditis-prod +# Chercher : "Error", "OOMKilled", "Exit Code" + +# Causes fréquentes : +# - OOMKilled (Exit 137) → Augmenter limits.memory +# - Exit 1 → Erreur applicative (DB unreachable, env var manquante) +# - Exit 126 → Problème de permissions sur le fichier d'entrée + +# 3. Si env var manquante +kubectl exec deployment/xpeditis-backend -n xpeditis-prod -- env | sort | grep -E "DB|REDIS|JWT" +``` + +### Pod en Pending (pas démarré) + +```bash +# Voir pourquoi le pod ne démarre pas +kubectl describe pod xpeditis-backend-xxx -n xpeditis-prod | grep -A 20 Events + +# Causes fréquentes : +# "Insufficient cpu/memory" → Pas assez de ressources sur les nœuds → Scale up +# "0/2 nodes are available" → Vérifier les taints/tolerations +# "did not trigger scale-up" → Cluster Autoscaler peut-être désactivé + +# Vérifier le Cluster Autoscaler +kubectl logs -n kube-system deployment/cluster-autoscaler | tail -30 +``` + +### L'API backend retourne des 500 + +```bash +# 1. Vérifier les logs récents +kubectl logs -l app=xpeditis-backend -n xpeditis-prod --since=15m | grep -E "Error|error|500" + +# 2. Tester le health check directement +kubectl exec deployment/xpeditis-backend -n xpeditis-prod -- \ + wget -qO- http://localhost:4000/api/v1/health | jq . + +# 3. Tester la connexion DB +kubectl exec deployment/xpeditis-backend -n xpeditis-prod -- \ + node -e " + const { Client } = require('pg'); + const c = new Client({connectionString: process.env.DATABASE_URL || 'postgres://'+process.env.DATABASE_USER+':'+process.env.DATABASE_PASSWORD+'@'+process.env.DATABASE_HOST+':'+process.env.DATABASE_PORT+'/'+process.env.DATABASE_NAME}); + c.connect().then(() => { console.log('DB OK'); c.end(); }).catch(e => { console.error('DB Error:', e.message); }); + " + +# 4. Tester la connexion Redis +kubectl exec deployment/xpeditis-backend -n xpeditis-prod -- \ + node -e " + const Redis = require('ioredis'); + const r = new Redis({host:process.env.REDIS_HOST,port:process.env.REDIS_PORT,password:process.env.REDIS_PASSWORD}); + r.ping().then(res => { console.log('Redis OK:', res); r.quit(); }).catch(e => { console.error('Redis Error:', e.message); }); + " +``` + +### TLS ne fonctionne pas + +```bash +# Vérifier cert-manager +kubectl get certificates -n xpeditis-prod +kubectl describe certificate xpeditis-tls-prod -n xpeditis-prod + +# Voir les challenges ACME +kubectl get challenges -n xpeditis-prod + +# Si challenge bloqué : vérifier que l'IP du LB est dans le DNS Cloudflare +dig +short api.xpeditis.com +# Doit retourner l'IP du Hetzner LB + +# Forcer le renouvellement du certificat +kubectl delete certificate xpeditis-tls-prod -n xpeditis-prod +kubectl apply -f k8s/07-ingress.yaml # Le certificat sera recréé automatiquement +``` + +### WebSocket se déconnecte fréquemment + +```bash +# 1. Vérifier les sticky sessions dans Traefik +kubectl logs -l app.kubernetes.io/name=traefik -n kube-system | grep -i sticky + +# 2. Vérifier Cloudflare WebSocket est activé +# Cloudflare → Votre domaine → Network → WebSockets → ON + +# 3. Vérifier le timeout WebSocket +# Cloudflare → Rules → Configuration Rules +# Créer une règle pour api.xpeditis.com/* → WebSocket timeout : 300s + +# 4. Vérifier les logs Socket.IO +kubectl logs -l app=xpeditis-backend -n xpeditis-prod | grep -i socket +``` + +--- + +## Mise en maintenance planifiée + +```bash +#!/bin/bash +# scripts/maintenance-mode.sh + +echo "🔧 Activation du mode maintenance" + +# 1. Mettre à jour le ConfigMap pour afficher une page de maintenance +kubectl patch configmap frontend-config -n xpeditis-prod \ + --type='json' \ + -p='[{"op":"add","path":"/data/MAINTENANCE_MODE","value":"true"}]' + +# 2. Redémarrer le frontend +kubectl rollout restart deployment/xpeditis-frontend -n xpeditis-prod +kubectl rollout status deployment/xpeditis-frontend -n xpeditis-prod + +# 3. Prévenir l'équipe +echo "✅ Mode maintenance activé. L'app affiche une page de maintenance." +echo "Pour désactiver : kubectl patch configmap frontend-config -n xpeditis-prod --type='json' -p='[{\"op\":\"remove\",\"path\":\"/data/MAINTENANCE_MODE\"}]'" +``` + +--- + +## Surveillance quotidienne (5 min/jour) + +```bash +#!/bin/bash +# scripts/daily-check.sh +echo "=== Rapport Quotidien Xpeditis $(date) ===" + +echo -e "\n--- CLUSTER ---" +kubectl get nodes +kubectl top nodes + +echo -e "\n--- PODS ---" +kubectl get pods -n xpeditis-prod + +echo -e "\n--- HPA ---" +kubectl get hpa -n xpeditis-prod + +echo -e "\n--- EVENTS RÉCENTS (warnings) ---" +kubectl get events -n xpeditis-prod \ + --field-selector type=Warning \ + --sort-by='.lastTimestamp' | tail -10 + +echo -e "\n--- HEALTH CHECK ---" +curl -sf https://api.xpeditis.com/api/v1/health | jq '.status' || echo "❌ API DOWN" +curl -sf -o /dev/null -w "Frontend: %{http_code}\n" https://app.xpeditis.com/ + +echo -e "\n--- CERTIFICATS ---" +kubectl get certificate -n xpeditis-prod + +echo -e "\n--- STOCKAGE ---" +kubectl get pvc -n xpeditis-prod + +echo "=== Fin du rapport ===" +``` + +--- + +## Liens utiles + +| Ressource | URL | +|---|---| +| Dashboard Grafana | https://monitoring.xpeditis.com | +| API Swagger | https://api.xpeditis.com/api/docs | +| Hetzner Console | https://console.hetzner.cloud | +| Cloudflare Dashboard | https://dash.cloudflare.com | +| Neon Dashboard | https://console.neon.tech | +| Upstash Console | https://console.upstash.com | +| GitHub Actions | https://github.com//xpeditis2.0/actions | +| GitHub GHCR | https://github.com//xpeditis2.0/pkgs/container | diff --git a/docs/deployment/hetzner/README.md b/docs/deployment/hetzner/README.md new file mode 100644 index 0000000..4a17f93 --- /dev/null +++ b/docs/deployment/hetzner/README.md @@ -0,0 +1,111 @@ +# Xpeditis 2.0 — Déploiement Production sur Hetzner Cloud + +> Documentation complète de bout en bout : du choix des serveurs au déploiement production +> Stack : k3s (Kubernetes léger) + Hetzner Object Storage (S3) + PostgreSQL + Redis + +--- + +## Pourquoi ce guide + +Ce guide couvre le déploiement de Xpeditis sur **Hetzner Cloud** avec **k3s** (Kubernetes léger), de la création du compte Hetzner jusqu'à la surveillance en production. C'est l'option la plus économique (€65-450/mois vs $270-5000 sur AWS) tout en restant production-grade. + +**Ce que vous obtiendrez en suivant ce guide :** +- Cluster Kubernetes k3s sur Hetzner avec autoscaling +- Backend NestJS et Frontend Next.js déployés en HA +- PostgreSQL managé (Neon.tech) ou self-hosted selon le budget +- Redis (Upstash) ou self-hosted +- Hetzner Object Storage en remplacement de MinIO (zéro changement de code) +- TLS automatique via Let's Encrypt + Cloudflare +- CI/CD avec GitHub Actions +- Monitoring avec Prometheus + Grafana +- Backups automatisés vers Object Storage +- Runbooks d'opérations et de troubleshooting + +--- + +## Vue d'ensemble des fichiers + +| # | Fichier | Contenu | Temps estimé | +|---|---|---|---| +| — | **README.md** | Ce fichier — index et quickstart | — | +| 01 | [Architecture](./01-architecture.md) | Diagrammes, composants, flux réseau | 15 min lecture | +| 02 | [Prérequis](./02-prerequisites.md) | Outils, comptes, SSH, DNS | 30-60 min setup | +| 03 | [Setup Hetzner](./03-hetzner-setup.md) | Compte, API token, réseau, firewall | 20 min | +| 04 | [Choix des serveurs](./04-server-selection.md) | Sizing par palier, ARM vs x86 | 10 min lecture | +| 05 | [Cluster k3s](./05-k3s-cluster.md) | **Installation complète du cluster** | 30-45 min | +| 06 | [Stockage S3](./06-storage-s3.md) | Hetzner Object Storage, migration MinIO | 15 min | +| 07 | [Base de données](./07-database-postgresql.md) | PostgreSQL (Neon ou self-hosted) | 20-60 min | +| 08 | [Redis](./08-redis-setup.md) | Redis (Upstash ou self-hosted) | 15-30 min | +| 09 | [Manifests Kubernetes](./09-kubernetes-manifests.md) | **Tous les YAMLs complets** | 30 min | +| 10 | [Ingress + TLS](./10-ingress-tls-cloudflare.md) | Traefik, cert-manager, Cloudflare | 30 min | +| 11 | [CI/CD GitHub Actions](./11-cicd-github-actions.md) | Pipeline build + deploy complet | 30 min | +| 12 | [Monitoring](./12-monitoring-alerting.md) | Prometheus, Grafana, Loki, alertes | 45 min | +| 13 | [Backups](./13-backup-disaster-recovery.md) | Stratégie backup + runbook DR | 20 min | +| 14 | [Sécurité](./14-security-hardening.md) | Hardening, network policies, WAF | 30 min | +| 15 | [Opérations](./15-operations-scaling.md) | Scaling, upgrades, troubleshooting | Référence | + +**Temps total de déploiement (première fois) : 4-6 heures** + +--- + +## Quickstart — Du zéro à la production + +Si vous avez déjà tous les prérequis, voici le chemin minimum : + +```bash +# 1. Installer hetzner-k3s +brew install vitobotta/tap/hetzner-k3s + +# 2. Configurer (voir 03-hetzner-setup.md) +export HCLOUD_TOKEN= + +# 3. Créer le cluster (voir 05-k3s-cluster.md) +hetzner-k3s create --config cluster.yaml + +# 4. Configurer kubectl +export KUBECONFIG=~/.kube/kubeconfig-xpeditis-prod + +# 5. Créer les namespaces et secrets +kubectl apply -f k8s/namespaces.yaml +kubectl apply -f k8s/secrets.yaml # après avoir rempli les valeurs + +# 6. Déployer l'application +kubectl apply -f k8s/ + +# 7. Vérifier +kubectl get pods -n xpeditis-prod +``` + +--- + +## Coûts récapitulatifs + +| Palier | Config Hetzner | Coût/mois (post 1er avril 2026) | +|---|---|---| +| **MVP (100 users)** | 1×CX22 + 2×CX32 | **€36** (+ €19 Neon.tech + €0 Upstash free) = **~€55** | +| **Croissance (1 000 users)** | 1×CX22 + 3×CX42 | **€91** (+ DB self-hosted) = **~€110** | +| **Scale (10 000 users)** | 3×CX22 + 6×CX52 | **€340** (+ DB self-hosted HA) = **~€390** | + +--- + +## Architecture résumée + +``` +Internet → Cloudflare (WAF + CDN) → Hetzner LB → k3s Ingress (Traefik) + ├── api.xpeditis.com → NestJS pods + └── app.xpeditis.com → Next.js pods + ↓ + PostgreSQL (Neon / self-hosted) + Redis (Upstash / self-hosted) + Hetzner Object Storage (S3-compatible) +``` + +--- + +## Conventions utilisées dans ce guide + +- `` — à remplacer par votre valeur +- `xpeditis-prod` — namespace Kubernetes de production +- `fsn1` — région Hetzner par défaut (Falkenstein, Allemagne) +- Les commandes `kubectl` supposent `KUBECONFIG` déjà configuré +- Les prix sont en EUR, basés sur les tarifs Hetzner post 1er avril 2026 diff --git a/infra/logging/grafana/provisioning/dashboards/provider.yml b/infra/logging/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 0000000..bd9b492 --- /dev/null +++ b/infra/logging/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: Xpeditis Dashboards + orgId: 1 + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json b/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json new file mode 100644 index 0000000..96e624f --- /dev/null +++ b/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json @@ -0,0 +1,636 @@ +{ + "title": "Xpeditis — Logs & Monitoring", + "uid": "xpeditis-logs", + "description": "Dashboard complet — logs backend/frontend, métriques HTTP, erreurs", + "tags": ["xpeditis", "logs", "backend", "frontend"], + "timezone": "browser", + "refresh": "30s", + "schemaVersion": 38, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "editable": true, + "version": 1, + "weekStart": "", + "links": [], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0,211,255,1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + + "templating": { + "list": [ + { + "name": "service", + "label": "Service", + "type": "query", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "query": "label_values(service)", + "refresh": 2, + "sort": 1, + "includeAll": true, + "allValue": ".+", + "multi": false, + "hide": 0, + "current": {}, + "options": [] + }, + { + "name": "level", + "label": "Niveau", + "type": "custom", + "query": "All : .+, error : error, fatal : fatal, warn : warn, info : info, debug : debug", + "includeAll": false, + "multi": false, + "hide": 0, + "current": { "text": "All", "value": ".+" }, + "options": [ + { "text": "All", "value": ".+", "selected": true }, + { "text": "error", "value": "error", "selected": false }, + { "text": "fatal", "value": "fatal", "selected": false }, + { "text": "warn", "value": "warn", "selected": false }, + { "text": "info", "value": "info", "selected": false }, + { "text": "debug", "value": "debug", "selected": false } + ] + }, + { + "name": "search", + "label": "Recherche", + "type": "textbox", + "query": "", + "hide": 0, + "current": { "text": "", "value": "" }, + "options": [{ "selected": true, "text": "", "value": "" }] + } + ] + }, + + "panels": [ + + { + "id": 100, + "type": "row", + "title": "Vue d'ensemble", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } + }, + + { + "id": 1, + "title": "Total logs", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=~\"$service\"} [$__range]))", + "legendFormat": "Total", + "instant": true + } + ] + }, + + { + "id": 2, + "title": "Erreurs & Fatal", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))", + "legendFormat": "Erreurs", + "instant": true + } + ] + }, + + { + "id": 3, + "title": "Warnings", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))", + "legendFormat": "Warnings", + "instant": true + } + ] + }, + + { + "id": 4, + "title": "Info", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center" + }, + "fieldConfig": { + "defaults": { + "color": { "fixedColor": "blue", "mode": "fixed" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=~\"$service\", level=\"info\"} [$__range]))", + "legendFormat": "Info", + "instant": true + } + ] + }, + + { + "id": 5, + "title": "Requêtes HTTP 5xx", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__range]))", + "legendFormat": "5xx", + "instant": true + } + ] + }, + + { + "id": 6, + "title": "Temps réponse moyen (ms)", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "unit": "ms", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__range]))", + "legendFormat": "Avg", + "instant": true + } + ] + }, + + { + "id": 200, + "type": "row", + "title": "Volume des logs", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 } + }, + + { + "id": 7, + "title": "Volume par niveau", + "type": "timeseries", + "gridPos": { "h": 8, "w": 14, "x": 0, "y": 6 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "stacking": { "group": "A", "mode": "normal" }, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short", + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "fixedColor": "gray", "mode": "fixed" } }] } + ] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\".+\"} [$__interval]))", + "legendFormat": "{{level}}" + } + ] + }, + + { + "id": 8, + "title": "Volume par service", + "type": "timeseries", + "gridPos": { "h": 8, "w": 10, "x": 14, "y": 6 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "stacking": { "group": "A", "mode": "normal" }, + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short", + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum by (service) (count_over_time({service=~\"$service\"} [$__interval]))", + "legendFormat": "{{service}}" + } + ] + }, + + { + "id": 300, + "type": "row", + "title": "HTTP — Backend", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 } + }, + + { + "id": 9, + "title": "Taux d'erreur HTTP", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + }, + "unit": "short", + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] } + ] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__interval]))", + "legendFormat": "5xx" + }, + { + "refId": "B", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 400 < 500 [$__interval]))", + "legendFormat": "4xx" + }, + { + "refId": "C", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 200 < 300 [$__interval]))", + "legendFormat": "2xx" + } + ] + }, + + { + "id": 10, + "title": "Temps de réponse (ms)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + }, + "unit": "ms", + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", + "legendFormat": "Moy" + }, + { + "refId": "B", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "max(max_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", + "legendFormat": "Max" + } + ] + }, + + { + "id": 400, + "type": "row", + "title": "Logs — Flux en direct", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } + }, + + { + "id": 11, + "title": "Backend — Logs", + "type": "logs", + "gridPos": { "h": 14, "w": 12, "x": 0, "y": 24 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": true, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "{service=\"backend\", level=~\"$level\"} |= \"$search\"", + "legendFormat": "" + } + ] + }, + + { + "id": 12, + "title": "Frontend — Logs", + "type": "logs", + "gridPos": { "h": 14, "w": 12, "x": 12, "y": 24 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": true, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "{service=\"frontend\", level=~\"$level\"} |= \"$search\"", + "legendFormat": "" + } + ] + }, + + { + "id": 500, + "type": "row", + "title": "Tous les logs filtrés", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 } + }, + + { + "id": 13, + "title": "Flux filtré — $service / $level", + "description": "Utilisez les variables en haut pour filtrer par service, niveau ou mot-clé", + "type": "logs", + "gridPos": { "h": 14, "w": 24, "x": 0, "y": 39 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": true, + "showCommonLabels": false, + "showLabels": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "{service=~\"$service\", level=~\"$level\"} |= \"$search\"", + "legendFormat": "" + } + ] + }, + + { + "id": 600, + "type": "row", + "title": "Erreurs & Exceptions", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 } + }, + + { + "id": 14, + "title": "Erreurs — Backend", + "type": "logs", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 54 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "dedupStrategy": "signature", + "enableLogDetails": true, + "prettifyLogMessage": true, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "{service=\"backend\", level=~\"error|fatal\"}", + "legendFormat": "" + } + ] + }, + + { + "id": 15, + "title": "Erreurs — Frontend", + "type": "logs", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 54 }, + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "options": { + "dedupStrategy": "signature", + "enableLogDetails": true, + "prettifyLogMessage": true, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "expr": "{service=\"frontend\", level=~\"error|fatal\"}", + "legendFormat": "" + } + ] + } + + ] +} diff --git a/infra/logging/grafana/provisioning/datasources/loki.yml b/infra/logging/grafana/provisioning/datasources/loki.yml new file mode 100644 index 0000000..b3102e9 --- /dev/null +++ b/infra/logging/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,19 @@ +apiVersion: 1 + +datasources: + - name: Loki + uid: loki-xpeditis + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + version: 1 + editable: false + jsonData: + maxLines: 1000 + timeout: 60 + derivedFields: + - datasourceUid: '' + matcherRegex: '"reqId":"([^"]+)"' + name: RequestID + url: '' diff --git a/infra/logging/loki/loki-config.yml b/infra/logging/loki/loki-config.yml new file mode 100644 index 0000000..b08f8ff --- /dev/null +++ b/infra/logging/loki/loki-config.yml @@ -0,0 +1,62 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: warn + +# Memberlist-based ring coordination — required for single-node Loki 3.x +memberlist: + bind_port: 7946 + join_members: + - 127.0.0.1:7946 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: memberlist + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + allow_structured_metadata: true + volume_enabled: true + retention_period: 744h # 31 days + reject_old_samples: true + reject_old_samples_max_age: 168h # Accept logs up to 7 days old + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + max_entries_limit_per_query: 5000 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 + delete_request_store: filesystem + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +analytics: + reporting_enabled: false diff --git a/infra/logging/promtail/promtail-config.yml b/infra/logging/promtail/promtail-config.yml new file mode 100644 index 0000000..453e222 --- /dev/null +++ b/infra/logging/promtail/promtail-config.yml @@ -0,0 +1,70 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + log_level: warn + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + batchwait: 1s + batchsize: 1048576 + timeout: 10s + +scrape_configs: + # ─── Docker container log collection (Mac-compatible via Docker socket API) ─ + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + # Only collect containers with label: logging=promtail + # Add this label to backend and frontend in docker-compose.dev.yml + - name: label + values: ['logging=promtail'] + + relabel_configs: + # Use docker-compose service name as the "service" label + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: service + # Keep container name for context + - source_labels: ['__meta_docker_container_name'] + regex: '/?(.*)' + replacement: '${1}' + target_label: container + # Log stream (stdout / stderr) + - source_labels: ['__meta_docker_container_log_stream'] + target_label: stream + + pipeline_stages: + # Drop entries older than 15 min to avoid replaying full container log history + - drop: + older_than: 15m + drop_counter_reason: entry_too_old + + # Drop noisy health-check / ping lines + - drop: + expression: 'GET /(health|metrics|minio/health)' + + # Try to parse JSON (NestJS/pino output) + - json: + expressions: + level: level + msg: msg + context: context + reqId: reqId + + # Promote parsed fields as Loki labels + - labels: + level: + context: + + # Map pino numeric levels to strings + - template: + source: level + template: >- + {{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }} + + - labels: + level: