chore: sync full codebase from cicd branch
Some checks failed
CD Preprod / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
CD Preprod / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
CD Preprod / Integration Tests (push) Blocked by required conditions
CD Preprod / Build & Push Backend (push) Blocked by required conditions
CD Preprod / Build & Push Frontend (push) Blocked by required conditions
CD Preprod / Deploy to Preprod (push) Blocked by required conditions
CD Preprod / Smoke Tests (push) Blocked by required conditions
CD Preprod / Deployment Summary (push) Blocked by required conditions
CD Preprod / Notify Success (push) Blocked by required conditions
CD Preprod / Notify Failure (push) Blocked by required conditions
CD Preprod / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
CD Preprod / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled

Aligns preprod with the complete application codebase (cicd branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
David 2026-04-04 12:56:28 +02:00
parent ab0ed187ed
commit 21e9584907
165 changed files with 18147 additions and 2560 deletions

View File

@ -33,9 +33,10 @@ npm run frontend:dev # http://localhost:3000
```bash ```bash
# Backend (from apps/backend/) # Backend (from apps/backend/)
npm test # Unit tests (Jest) npm test # Unit tests (Jest)
npm test -- booking.entity.spec.ts # Single file npm test -- booking.entity.spec.ts # Single file
npm run test:cov # With coverage 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:integration # Integration tests (needs DB/Redis, 30s timeout)
npm run test:e2e # E2E tests npm run test:e2e # E2E tests
@ -75,6 +76,7 @@ npm run migration:revert
```bash ```bash
npm run backend:build # NestJS build with tsc-alias for path resolution npm run backend:build # NestJS build with tsc-alias for path resolution
npm run frontend:build # Next.js production build (standalone output) npm run frontend:build # Next.js production build (standalone output)
npm run clean # Remove all node_modules, dist, .next directories
``` ```
## Local Infrastructure ## Local Infrastructure
@ -84,6 +86,8 @@ Docker-compose defaults (no `.env` changes needed for local dev):
- **Redis**: password `xpeditis_redis_password`, port 6379 - **Redis**: password `xpeditis_redis_password`, port 6379
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001 - **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 ## Architecture
### Hexagonal Architecture (Backend) ### Hexagonal Architecture (Backend)
@ -91,15 +95,32 @@ Docker-compose defaults (no `.env` changes needed for local dev):
``` ```
apps/backend/src/ apps/backend/src/
├── domain/ # CORE - Pure TypeScript, NO framework imports ├── domain/ # CORE - Pure TypeScript, NO framework imports
│ ├── entities/ # Booking, RateQuote, User, Carrier, Port, Container, CsvBooking, etc. │ ├── entities/ # Booking, RateQuote, Carrier, Port, Container, Notification, Webhook,
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType, Volume, LicenseStatus, SubscriptionPlan, etc. │ │ # AuditLog, User, Organization, Subscription, License, CsvBooking,
│ ├── services/ # Pure domain services (rate-search, csv-rate-price-calculator, booking, port-search, etc.) │ │ # CsvRate, InvitationToken
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType,
│ │ # Volume, DateRange, Surcharge
│ ├── services/ # Pure domain services (csv-rate-price-calculator)
│ ├── ports/ │ ├── ports/
│ │ ├── in/ # Use case interfaces with execute() method │ │ ├── in/ # Use case interfaces with execute() method
│ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository') │ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository')
│ └── exceptions/ # Domain-specific exceptions │ └── exceptions/ # Domain-specific exceptions
├── application/ # Controllers, DTOs (class-validator), Guards, Decorators, Mappers ├── 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**: **Critical dependency rules**:
@ -108,6 +129,7 @@ apps/backend/src/
- Path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` (defined in `apps/backend/tsconfig.json`) - Path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` (defined in `apps/backend/tsconfig.json`)
- Domain tests run without NestJS TestingModule - Domain tests run without NestJS TestingModule
- Backend has strict TypeScript: `strict: true`, `strictNullChecks: true` (but `strictPropertyInitialization: false`) - 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) ### 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. 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) ### Frontend (Next.js 14 App Router)
``` ```
apps/frontend/ apps/frontend/
├── app/ # App Router pages ├── app/ # App Router pages (root-level)
│ ├── dashboard/ # Protected routes (bookings, admin, settings) │ ├── dashboard/ # Protected routes (bookings, admin, settings, wiki, search)
│ └── carrier/ # Carrier portal (magic link auth) │ ├── carrier/ # Carrier portal (magic link auth — accept/reject/documents)
│ ├── booking/ # Booking confirmation/rejection flows
│ └── [auth pages] # login, register, forgot-password, verify-email
└── src/ └── 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 ├── hooks/ # useBookings, useNotifications, useCsvRateSearch, useCompanies, useFilterOptions
├── lib/ ├── lib/
│ ├── api/ # Fetch-based API client with auto token refresh (client.ts + per-module files) │ ├── 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) │ └── fonts.ts # Manrope (headings) + Montserrat (body)
├── types/ # TypeScript type definitions ├── 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/*` 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 ### 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 - Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
### Frontend API Client ### 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=<pathname>` when the cookie is absent.
### Application Decorators ### Application Decorators
- `@Public()` — skip JWT auth - `@Public()` — skip JWT auth
- `@Roles()` — role-based access control - `@Roles()` — role-based access control
- `@CurrentUser()` — inject authenticated user - `@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 ### 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). 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 - RBAC Roles: ADMIN, MANAGER, USER, VIEWER, CARRIER
- JWT: access token 15min, refresh token 7d - JWT: access token 15min, refresh token 7d
- Password hashing: Argon2 - 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 ### Carrier Portal Workflow
1. Admin creates CSV booking → assigns carrier 1. Admin creates CSV booking → assigns carrier
2. Email with magic link sent (1-hour expiry) 2. Email with magic link sent (1-hour expiry)
3. Carrier auto-login → accept/reject booking 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 ## 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) 1. **Domain Entity**`domain/entities/*.entity.ts` (pure TS, unit tests)
2. **Value Objects**`domain/value-objects/*.vo.ts` (immutable) 2. **Value Objects**`domain/value-objects/*.vo.ts` (immutable)
3. **Port Interface**`domain/ports/out/*.repository.ts` (with token constant) 3. **In Port (Use Case)**`domain/ports/in/*.use-case.ts` (interface with `execute()`)
4. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts` 4. **Out Port (Repository)**`domain/ports/out/*.repository.ts` (with token constant)
5. **Migration**`npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName` 5. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
6. **Repository Impl**`infrastructure/persistence/typeorm/repositories/` 6. **Migration**`npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName`
7. **Mapper**`infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany) 7. **Repository Impl**`infrastructure/persistence/typeorm/repositories/`
8. **DTOs**`application/dto/` (with class-validator decorators) 8. **Mapper**`infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany)
9. **Controller**`application/controllers/` (with Swagger decorators) 9. **DTOs**`application/dto/` (with class-validator decorators)
10. **Module** → Register and import in `app.module.ts` 10. **Controller**`application/controllers/` (with Swagger decorators)
11. **Module** → Register repository + use-case providers, import in `app.module.ts`
## Documentation ## 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` - Setup guide: `docs/installation/START-HERE.md`
- Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md` - Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md`
- Full docs index: `docs/README.md` - Full docs index: `docs/README.md`
- Development roadmap: `TODO.md`
- Infrastructure configs (CI/CD, Docker): `infra/`

View File

@ -37,12 +37,14 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
APP_URL=http://localhost:3000 APP_URL=http://localhost:3000
# Email (SMTP) # Email (SMTP)
SMTP_HOST=smtp.sendgrid.net SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_USER=ton-email@brevo.com
SMTP_USER=apikey SMTP_PASS=ta-cle-smtp-brevo
SMTP_PASS=your-sendgrid-api-key SMTP_SECURE=false
SMTP_FROM=noreply@xpeditis.com
# 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 S3 / Storage (or MinIO for development)
AWS_ACCESS_KEY_ID=your-aws-access-key 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_USERNAME=your-one-username
ONE_PASSWORD=your-one-password ONE_PASSWORD=your-one-password
# Swagger Documentation Access (HTTP Basic Auth)
# Leave empty to disable Swagger in production, or set both to protect with a password
SWAGGER_USERNAME=admin
SWAGGER_PASSWORD=change-this-strong-password
# Security # Security
BCRYPT_ROUNDS=12 BCRYPT_ROUNDS=12
SESSION_TIMEOUT_MS=7200000 SESSION_TIMEOUT_MS=7200000
@ -93,9 +100,9 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Stripe Price IDs (create these in Stripe Dashboard) # Stripe Price IDs (create these in Stripe Dashboard)
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly

View File

@ -20,13 +20,14 @@ import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module'; import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module'; import { AdminModule } from './application/admin/admin.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module';
import { CacheModule } from './infrastructure/cache/cache.module'; import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module'; import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards // Import global guards
import { JwtAuthGuard } from './application/guards/jwt-auth.guard'; import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard'; import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({ @Module({
@ -60,21 +61,26 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
// Stripe Configuration (optional for development) // Stripe Configuration (optional for development)
STRIPE_SECRET_KEY: Joi.string().optional(), STRIPE_SECRET_KEY: Joi.string().optional(),
STRIPE_WEBHOOK_SECRET: Joi.string().optional(), STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
}), }),
}), }),
// Logging // Logging
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => {
pinoHttp: { const isDev = configService.get('NODE_ENV') === 'development';
transport: // LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail)
configService.get('NODE_ENV') === 'development' const forceJson = configService.get('LOG_FORMAT') === 'json';
const usePretty = isDev && !forceJson;
return {
pinoHttp: {
transport: usePretty
? { ? {
target: 'pino-pretty', target: 'pino-pretty',
options: { options: {
@ -84,9 +90,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
}, },
} }
: undefined, : 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], inject: [ConfigService],
}), }),
@ -128,14 +146,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
GDPRModule, GDPRModule,
AdminModule, AdminModule,
SubscriptionsModule, SubscriptionsModule,
ApiKeysModule,
], ],
controllers: [], controllers: [],
providers: [ 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 // All routes are protected by default, use @Public() to bypass
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: JwtAuthGuard, useClass: ApiKeyOrJwtGuard,
}, },
// Global rate limiting guard // Global rate limiting guard
{ {

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// Controller // Controller
import { AdminController } from '../controllers/admin.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 { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.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 * Admin Module
* *
@ -25,7 +36,12 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
* All endpoints require ADMIN role. * All endpoints require ADMIN role.
*/ */
@Module({ @Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])], imports: [
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
ConfigModule,
CsvBookingsModule,
EmailModule,
],
controllers: [AdminController], controllers: [AdminController],
providers: [ providers: [
{ {
@ -37,6 +53,10 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
useClass: TypeOrmOrganizationRepository, useClass: TypeOrmOrganizationRepository,
}, },
TypeOrmCsvBookingRepository, TypeOrmCsvBookingRepository,
{
provide: SIRET_VERIFICATION_PORT,
useClass: PappersSiretAdapter,
},
], ],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -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<CreateApiKeyResultDto> {
return this.apiKeysService.generateApiKey(user.id, user.organizationId, dto);
}
@Get()
@ApiOperation({
summary: 'Lister les clés API',
description:
"Retourne toutes les clés API de l'organisation. Les clés complètes ne sont jamais exposées — uniquement le préfixe.",
})
@ApiResponse({ status: 200, type: [ApiKeyDto] })
async list(@CurrentUser() user: { organizationId: string }): Promise<ApiKeyDto[]> {
return this.apiKeysService.listApiKeys(user.organizationId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Révoquer une clé API',
description: 'Désactive immédiatement la clé API. Cette action est irréversible.',
})
@ApiResponse({ status: 204, description: 'Clé révoquée' })
@ApiResponse({ status: 404, description: 'Clé introuvable' })
async revoke(
@CurrentUser() user: { organizationId: string },
@Param('id', ParseUUIDPipe) keyId: string
): Promise<void> {
return this.apiKeysService.revokeApiKey(keyId, user.organizationId);
}
}

View File

@ -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 {}

View File

@ -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<CreateApiKeyResultDto> {
await this.assertApiAccessPlan(organizationId);
const rawKey = this.buildRawKey();
const keyHash = this.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, KEY_PREFIX_DISPLAY_LENGTH);
const apiKey = ApiKey.create({
id: uuidv4(),
organizationId,
userId,
name: dto.name,
keyHash,
keyPrefix,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
});
const saved = await this.apiKeyRepository.save(apiKey);
this.logger.log(`API key created: ${saved.id} for org ${organizationId}`);
return {
id: saved.id,
name: saved.name,
keyPrefix: saved.keyPrefix,
isActive: saved.isActive,
lastUsedAt: saved.lastUsedAt,
expiresAt: saved.expiresAt,
createdAt: saved.createdAt,
fullKey: rawKey,
};
}
/**
* List all API keys for an organisation. Never exposes key hashes.
*/
async listApiKeys(organizationId: string): Promise<ApiKeyDto[]> {
const keys = await this.apiKeyRepository.findByOrganizationId(organizationId);
return keys.map(k => this.toDto(k));
}
/**
* Revoke (deactivate) an API key.
*/
async revokeApiKey(keyId: string, organizationId: string): Promise<void> {
const key = await this.apiKeyRepository.findById(keyId);
if (!key || key.organizationId !== organizationId) {
throw new NotFoundException('Clé API introuvable');
}
const revoked = key.revoke();
await this.apiKeyRepository.save(revoked);
this.logger.log(`API key revoked: ${keyId} for org ${organizationId}`);
}
/**
* Validate an inbound raw API key and return the user context.
* Returns null if the key is invalid, expired, or the plan is insufficient.
* Also asynchronously updates lastUsedAt.
*/
async validateAndGetUser(rawKey: string): Promise<ApiKeyUserContext | null> {
if (!rawKey?.startsWith('xped_live_')) return null;
const keyHash = this.hashKey(rawKey);
const apiKey = await this.apiKeyRepository.findByKeyHash(keyHash);
if (!apiKey || !apiKey.isValid()) return null;
// Real-time plan check — in case the org downgraded after key creation
const subscription = await this.subscriptionRepository.findByOrganizationId(
apiKey.organizationId
);
if (!subscription || !subscription.hasFeature('api_access')) {
this.logger.warn(
`API key used but org ${apiKey.organizationId} no longer has api_access feature`
);
return null;
}
// Update lastUsedAt asynchronously — don't block the request
this.apiKeyRepository
.save(apiKey.recordUsage())
.catch(err => this.logger.warn(`Failed to update lastUsedAt for key ${apiKey.id}: ${err}`));
const user = await this.userRepository.findById(apiKey.userId);
if (!user || !user.isActive) return null;
return {
id: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
firstName: user.firstName,
lastName: user.lastName,
plan: subscription.plan.value,
planFeatures: [...subscription.plan.planFeatures],
};
}
// ── Helpers ─────────────────────────────────────────────────────────────
private async assertApiAccessPlan(organizationId: string): Promise<void> {
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
if (!subscription || !subscription.hasFeature('api_access')) {
throw new ForbiddenException(
"L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API."
);
}
}
/** Format: xped_live_<64 random hex chars> */
private buildRawKey(): string {
return `xped_live_${crypto.randomBytes(32).toString('hex')}`;
}
private hashKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
private toDto(apiKey: ApiKey): ApiKeyDto {
return {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
isActive: apiKey.isActive,
lastUsedAt: apiKey.lastUsedAt,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
};
}
}

View File

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

View File

@ -5,10 +5,14 @@ import {
Logger, Logger,
Inject, Inject,
BadRequestException, BadRequestException,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity'; import { User, UserRole } from '@domain/entities/user.entity';
import { import {
@ -16,15 +20,19 @@ import {
ORGANIZATION_REPOSITORY, ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository'; } from '@domain/ports/out/organization.repository';
import { Organization } from '@domain/entities/organization.entity'; import { Organization } from '@domain/entities/organization.entity';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { RegisterOrganizationDto } from '../dto/auth-login.dto';
import { SubscriptionService } from '../services/subscription.service'; import { SubscriptionService } from '../services/subscription.service';
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
export interface JwtPayload { export interface JwtPayload {
sub: string; // user ID sub: string; // user ID
email: string; email: string;
role: string; role: string;
organizationId: string; organizationId: string;
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
planFeatures?: string[]; // plan feature flags
type: 'access' | 'refresh'; type: 'access' | 'refresh';
} }
@ -37,9 +45,13 @@ export class AuthService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) @Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository, private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
@InjectRepository(PasswordResetTokenOrmEntity)
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService
) {} ) {}
/** /**
@ -203,6 +215,85 @@ export class AuthService {
} }
} }
/**
* Initiate password reset generates token and sends email
*/
async forgotPassword(email: string): Promise<void> {
this.logger.log(`Password reset requested for: ${email}`);
const user = await this.userRepository.findByEmail(email);
// Silently succeed if user not found (security: don't reveal user existence)
if (!user || !user.isActive) {
return;
}
// Invalidate any existing unused tokens for this user
await this.passwordResetTokenRepository.update(
{ userId: user.id, usedAt: IsNull() },
{ usedAt: new Date() }
);
// Generate a secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await this.passwordResetTokenRepository.save({
userId: user.id,
token,
expiresAt,
usedAt: null,
});
await this.emailService.sendPasswordResetEmail(email, token);
this.logger.log(`Password reset email sent to: ${email}`);
}
/**
* Reset password using token from email
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } });
if (!resetToken) {
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
}
if (resetToken.usedAt) {
throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé');
}
if (resetToken.expiresAt < new Date()) {
throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
}
const user = await this.userRepository.findById(resetToken.userId);
if (!user || !user.isActive) {
throw new NotFoundException('Utilisateur introuvable');
}
const passwordHash = await argon2.hash(newPassword, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
// Update password (mutates in place)
user.updatePassword(passwordHash);
await this.userRepository.save(user);
// Mark token as used
await this.passwordResetTokenRepository.update(
{ id: resetToken.id },
{ usedAt: new Date() }
);
this.logger.log(`Password reset successfully for user: ${user.email}`);
}
/** /**
* Validate user from JWT payload * Validate user from JWT payload
*/ */
@ -220,11 +311,40 @@ export class AuthService {
* Generate access and refresh tokens * Generate access and refresh tokens
*/ */
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get PLATINIUM plan with no expiration
let plan = 'BRONZE';
let planFeatures: string[] = [];
if (user.role === UserRole.ADMIN) {
plan = 'PLATINIUM';
planFeatures = [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
];
} else {
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
plan = subscription.plan.value;
planFeatures = [...subscription.plan.planFeatures];
} catch (error) {
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
}
}
const accessPayload: JwtPayload = { const accessPayload: JwtPayload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
plan,
planFeatures,
type: 'access', type: 'access',
}; };
@ -233,6 +353,8 @@ export class AuthService {
email: user.email, email: user.email,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
plan,
planFeatures,
type: 'refresh', type: 'refresh',
}; };
@ -302,6 +424,8 @@ export class AuthService {
name: organizationData.name, name: organizationData.name,
type: organizationData.type, type: organizationData.type,
scac: organizationData.scac, scac: organizationData.scac,
siren: organizationData.siren,
siret: organizationData.siret,
address: { address: {
street: organizationData.street, street: organizationData.street,
city: organizationData.city, city: organizationData.city,

View File

@ -6,15 +6,18 @@ import { BookingsController } from '../controllers/bookings.controller';
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository'; import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
// Import ORM entities // Import ORM entities
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity'; import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
// Import services and domain // Import services and domain
import { BookingService } from '@domain/services/booking.service'; import { BookingService } from '@domain/services/booking.service';
@ -29,6 +32,7 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { WebhooksModule } from '../webhooks/webhooks.module'; import { WebhooksModule } from '../webhooks/webhooks.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
/** /**
* Bookings Module * Bookings Module
@ -47,6 +51,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module';
ContainerOrmEntity, ContainerOrmEntity,
RateQuoteOrmEntity, RateQuoteOrmEntity,
UserOrmEntity, UserOrmEntity,
CsvBookingOrmEntity,
]), ]),
EmailModule, EmailModule,
PdfModule, PdfModule,
@ -54,6 +59,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module';
AuditModule, AuditModule,
NotificationsModule, NotificationsModule,
WebhooksModule, WebhooksModule,
SubscriptionsModule,
], ],
controllers: [BookingsController], controllers: [BookingsController],
providers: [ providers: [
@ -73,6 +79,10 @@ import { WebhooksModule } from '../webhooks/webhooks.module';
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,
}, },
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
], ],
exports: [BOOKING_REPOSITORY], exports: [BOOKING_REPOSITORY],
}) })

View File

@ -1,6 +1,7 @@
import { import {
Controller, Controller,
Get, Get,
Post,
Patch, Patch,
Delete, Delete,
Param, Param,
@ -44,6 +45,16 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org
// CSV Booking imports // CSV Booking imports
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; 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 * Admin Controller
@ -65,7 +76,11 @@ export class AdminController {
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) @Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository, 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 ==================== // ==================== USERS ENDPOINTS ====================
@ -329,6 +344,163 @@ export class AdminController {
return OrganizationMapper.toDto(organization); 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 ==================== // ==================== CSV BOOKINGS ENDPOINTS ====================
/** /**
@ -440,6 +612,52 @@ export class AdminController {
return this.csvBookingToDto(updatedBooking); return this.csvBookingToDto(updatedBooking);
} }
/**
* Resend carrier email for a booking (admin only)
*
* Manually sends the booking request email to the carrier.
* Useful when the automatic email failed (SMTP error) or for testing without Stripe.
*/
@Post('bookings/:id/resend-carrier-email')
@ApiOperation({
summary: 'Resend carrier email (Admin only)',
description:
'Manually resend the booking request email to the carrier. Works regardless of payment status.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Email sent to carrier' })
@ApiNotFoundResponse({ description: 'Booking not found' })
async resendCarrierEmail(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`);
await this.csvBookingService.resendCarrierEmail(id);
return { success: true, message: 'Email sent to carrier' };
}
/**
* Validate bank transfer for a booking (admin only)
*
* Transitions booking from PENDING_BANK_TRANSFER PENDING and sends email to carrier
*/
@Post('bookings/:id/validate-transfer')
@ApiOperation({
summary: 'Validate bank transfer (Admin only)',
description:
'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' })
@ApiNotFoundResponse({ description: 'Booking not found' })
async validateBankTransfer(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`);
return this.csvBookingService.validateBankTransfer(id);
}
/** /**
* Delete csv booking (admin only) * Delete csv booking (admin only)
*/ */
@ -483,6 +701,7 @@ export class AdminController {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber || null,
userId: booking.userId, userId: booking.userId,
organizationId: booking.organizationId, organizationId: booking.organizationId,
carrierName: booking.carrierName, 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: `<p>Email de test envoyé depuis le panel admin par <strong>${user.email}</strong>.</p><p>Si vous lisez ceci, la configuration SMTP fonctionne correctement.</p>`,
text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`,
});
this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`);
return { success: true, message: `Email envoyé avec succès à ${body.to}` };
} catch (error: any) {
this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack);
throw new BadRequestException(
`Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` +
`Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}`
);
}
}
// ==================== DOCUMENTS ENDPOINTS ==================== // ==================== DOCUMENTS ENDPOINTS ====================
/** /**
@ -597,4 +860,55 @@ export class AdminController {
total: organization.documents.length, total: organization.documents.length,
}; };
} }
/**
* Delete a document from a CSV booking (admin only)
* Bypasses ownership and status restrictions
*/
@Delete('bookings/:bookingId/documents/:documentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Delete document from CSV booking (Admin only)',
description: 'Remove a document from a booking, bypassing ownership and status restrictions.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Document deleted successfully',
})
async deleteDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: UserPayload
): Promise<{ success: boolean; message: string }> {
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document ${documentId} not found`);
}
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
return { success: true, message: 'Document deleted successfully' };
}
} }

View File

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

View File

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

View File

@ -53,6 +53,12 @@ import { NotificationService } from '../services/notification.service';
import { NotificationsGateway } from '../gateways/notifications.gateway'; import { NotificationsGateway } from '../gateways/notifications.gateway';
import { WebhookService } from '../services/webhook.service'; import { WebhookService } from '../services/webhook.service';
import { WebhookEvent } from '@domain/entities/webhook.entity'; import { WebhookEvent } from '@domain/entities/webhook.entity';
import {
ShipmentCounterPort,
SHIPMENT_COUNTER_PORT,
} from '@domain/ports/out/shipment-counter.port';
import { SubscriptionService } from '../services/subscription.service';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
@ApiTags('Bookings') @ApiTags('Bookings')
@Controller('bookings') @Controller('bookings')
@ -70,7 +76,9 @@ export class BookingsController {
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly notificationsGateway: NotificationsGateway, private readonly notificationsGateway: NotificationsGateway,
private readonly webhookService: WebhookService private readonly webhookService: WebhookService,
@Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort,
private readonly subscriptionService: SubscriptionService
) {} ) {}
@Post() @Post()
@ -105,6 +113,22 @@ export class BookingsController {
): Promise<BookingResponseDto> { ): Promise<BookingResponseDto> {
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
// Check shipment limit for Bronze plan
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
user.organizationId,
currentYear
);
if (count >= maxShipments) {
throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments);
}
}
try { try {
// Convert DTO to domain input, using authenticated user's data // Convert DTO to domain input, using authenticated user's data
const input = { const input = {
@ -456,9 +480,16 @@ export class BookingsController {
// Filter out bookings or rate quotes that are null // Filter out bookings or rate quotes that are null
const bookingsWithQuotes = bookingsWithQuotesRaw.filter( const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
(item): item is { booking: NonNullable<typeof item.booking>; rateQuote: NonNullable<typeof item.rateQuote> } => (
item.booking !== null && item.booking !== undefined && item
item.rateQuote !== null && item.rateQuote !== undefined ): item is {
booking: NonNullable<typeof item.booking>;
rateQuote: NonNullable<typeof item.rateQuote>;
} =>
item.booking !== null &&
item.booking !== undefined &&
item.rateQuote !== null &&
item.rateQuote !== undefined
); );
// Convert to DTOs // Convert to DTOs

View File

@ -12,9 +12,12 @@ import {
UploadedFiles, UploadedFiles,
Request, Request,
BadRequestException, BadRequestException,
ForbiddenException,
ParseIntPipe, ParseIntPipe,
DefaultValuePipe, DefaultValuePipe,
Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { import {
ApiTags, ApiTags,
@ -29,6 +32,16 @@ import {
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
import { 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 { import {
CreateCsvBookingDto, CreateCsvBookingDto,
CsvBookingResponseDto, CsvBookingResponseDto,
@ -48,7 +61,15 @@ import {
@ApiTags('CSV Bookings') @ApiTags('CSV Bookings')
@Controller('csv-bookings') @Controller('csv-bookings')
export class CsvBookingsController { 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) // STATIC ROUTES (must come FIRST)
@ -60,7 +81,6 @@ export class CsvBookingsController {
* POST /api/v1/csv-bookings * POST /api/v1/csv-bookings
*/ */
@Post() @Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10)) @UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ -144,6 +164,23 @@ export class CsvBookingsController {
const userId = req.user.id; const userId = req.user.id;
const organizationId = req.user.organizationId; 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) // Convert string values to numbers (multipart/form-data sends everything as strings)
const sanitizedDto: CreateCsvBookingDto = { const sanitizedDto: CreateCsvBookingDto = {
...dto, ...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<string>('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<CsvBookingResponseDto> {
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<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.declareBankTransfer(id, userId);
}
// ============================================================================ // ============================================================================
// PARAMETERIZED ROUTES (must come LAST) // PARAMETERIZED ROUTES (must come LAST)
// ============================================================================ // ============================================================================

View File

@ -22,12 +22,7 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator'; import { CurrentUser } from '../decorators/current-user.decorator';
import { UserPayload } from '../decorators/current-user.decorator'; import { UserPayload } from '../decorators/current-user.decorator';
import { GDPRService } from '../services/gdpr.service'; import { GDPRService } from '../services/gdpr.service';
import { import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
UpdateConsentDto,
ConsentResponseDto,
WithdrawConsentDto,
ConsentSuccessDto,
} from '../dto/consent.dto';
@ApiTags('GDPR') @ApiTags('GDPR')
@Controller('gdpr') @Controller('gdpr')

View File

@ -2,6 +2,7 @@ import {
Controller, Controller,
Post, Post,
Get, Get,
Delete,
Body, Body,
UseGuards, UseGuards,
HttpCode, HttpCode,
@ -71,7 +72,8 @@ export class InvitationsController {
dto.lastName, dto.lastName,
dto.role as unknown as UserRole, dto.role as unknown as UserRole,
user.organizationId, user.organizationId,
user.id user.id,
user.role
); );
return { 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<void> {
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
await this.invitationService.cancelInvitation(id, user.organizationId);
}
/** /**
* List organization invitations * List organization invitations
*/ */

View File

@ -22,6 +22,8 @@ import {
Headers, Headers,
RawBodyRequest, RawBodyRequest,
Req, Req,
Inject,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@ -47,13 +49,21 @@ import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
@ApiTags('Subscriptions') @ApiTags('Subscriptions')
@Controller('subscriptions') @Controller('subscriptions')
export class SubscriptionsController { export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name); 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 * Get subscription overview for current organization
@ -77,10 +87,10 @@ export class SubscriptionsController {
description: 'Forbidden - requires admin or manager role', description: 'Forbidden - requires admin or manager role',
}) })
async getSubscriptionOverview( async getSubscriptionOverview(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(`[User: ${user.email}] Getting subscription overview`); 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<CanInviteResponseDto> { async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
this.logger.log(`[User: ${user.email}] Checking license availability`); 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() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: 'Create checkout session', summary: 'Create checkout session',
description: description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@ -157,14 +166,22 @@ export class SubscriptionsController {
}) })
async createCheckoutSession( async createCheckoutSession(
@Body() dto: CreateCheckoutSessionDto, @Body() dto: CreateCheckoutSessionDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<CheckoutSessionResponseDto> { ): Promise<CheckoutSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`); this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
return this.subscriptionService.createCheckoutSession(
user.organizationId, // ADMIN users bypass all payment restrictions
user.id, if (user.role !== 'ADMIN') {
dto, // 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( async createPortalSession(
@Body() dto: CreatePortalSessionDto, @Body() dto: CreatePortalSessionDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<PortalSessionResponseDto> { ): Promise<PortalSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating portal session`); this.logger.log(`[User: ${user.email}] Creating portal session`);
return this.subscriptionService.createPortalSession(user.organizationId, dto); return this.subscriptionService.createPortalSession(user.organizationId, dto);
@ -230,10 +247,10 @@ export class SubscriptionsController {
}) })
async syncFromStripe( async syncFromStripe(
@Body() dto: SyncSubscriptionDto, @Body() dto: SyncSubscriptionDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
this.logger.log( 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); return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
} }
@ -247,7 +264,7 @@ export class SubscriptionsController {
@ApiExcludeEndpoint() @ApiExcludeEndpoint()
async handleWebhook( async handleWebhook(
@Headers('stripe-signature') signature: string, @Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request>, @Req() req: RawBodyRequest<Request>
): Promise<{ received: boolean }> { ): Promise<{ received: boolean }> {
const rawBody = req.rawBody; const rawBody = req.rawBody;
if (!rawBody) { if (!rawBody) {

View File

@ -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 { User, UserRole as DomainUserRole } from '@domain/entities/user.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@ -64,14 +66,15 @@ import { SubscriptionService } from '../services/subscription.service';
*/ */
@ApiTags('Users') @ApiTags('Users')
@Controller('users') @Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@RequiresFeature('user_management')
@ApiBearerAuth() @ApiBearerAuth()
export class UsersController { export class UsersController {
private readonly logger = new Logger(UsersController.name); private readonly logger = new Logger(UsersController.name);
constructor( constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService
) {} ) {}
/** /**
@ -284,7 +287,7 @@ export class UsersController {
} catch (error) { } catch (error) {
this.logger.error(`Failed to reallocate license for user ${id}:`, error); this.logger.error(`Failed to reallocate license for user ${id}:`, error);
throw new ForbiddenException( throw new ForbiddenException(
'Cannot reactivate user: no licenses available. Please upgrade your subscription.', 'Cannot reactivate user: no licenses available. Please upgrade your subscription.'
); );
} }
} else { } else {

View File

@ -1,13 +1,24 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { CsvBookingsController } from './controllers/csv-bookings.controller'; import { CsvBookingsController } from './controllers/csv-bookings.controller';
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller'; import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
import { CsvBookingService } from './services/csv-booking.service'; import { CsvBookingService } from './services/csv-booking.service';
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; 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 { NotificationsModule } from './notifications/notifications.module';
import { EmailModule } from '../infrastructure/email/email.module'; import { EmailModule } from '../infrastructure/email/email.module';
import { StorageModule } from '../infrastructure/storage/storage.module'; import { StorageModule } from '../infrastructure/storage/storage.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { StripeModule } from '../infrastructure/stripe/stripe.module';
/** /**
* CSV Bookings Module * CSV Bookings Module
@ -16,13 +27,31 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
*/ */
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([CsvBookingOrmEntity]), TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
ConfigModule,
NotificationsModule, NotificationsModule,
EmailModule, EmailModule,
StorageModule, StorageModule,
SubscriptionsModule,
StripeModule,
], ],
controllers: [CsvBookingsController, CsvBookingActionsController], 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], exports: [CsvBookingService, TypeOrmCsvBookingRepository],
}) })
export class CsvBookingsModule {} export class CsvBookingsModule {}

View File

@ -7,9 +7,12 @@
import { Controller, Get, UseGuards, Request } from '@nestjs/common'; import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AnalyticsService } from '../services/analytics.service'; import { AnalyticsService } from '../services/analytics.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
@Controller('dashboard') @Controller('dashboard')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, FeatureFlagGuard)
@RequiresFeature('dashboard')
export class DashboardController { export class DashboardController {
constructor(private readonly analyticsService: AnalyticsService) {} constructor(private readonly analyticsService: AnalyticsService) {}

View File

@ -8,11 +8,13 @@ import { AnalyticsService } from '../services/analytics.service';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { RatesModule } from '../rates/rates.module'; import { RatesModule } from '../rates/rates.module';
import { CsvBookingsModule } from '../csv-bookings.module'; import { CsvBookingsModule } from '../csv-bookings.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({ @Module({
imports: [BookingsModule, RatesModule, CsvBookingsModule], imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule],
controllers: [DashboardController], controllers: [DashboardController],
providers: [AnalyticsService], providers: [AnalyticsService, FeatureFlagGuard],
exports: [AnalyticsService], exports: [AnalyticsService],
}) })
export class DashboardModule {} export class DashboardModule {}

View File

@ -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);

View File

@ -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;
}

View File

@ -7,6 +7,7 @@ import {
IsEnum, IsEnum,
MaxLength, MaxLength,
Matches, Matches,
IsBoolean,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@ -22,12 +23,81 @@ export class LoginDto {
@ApiProperty({ @ApiProperty({
example: 'SecurePassword123!', 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, minLength: 12,
}) })
@IsString() @IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' }) @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)' }) @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
country: string; 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({ @ApiPropertyOptional({
example: 'MAEU', example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',

View File

@ -1,112 +1,118 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
/** /**
* DTO for verifying document access password * DTO for verifying document access password
*/ */
export class VerifyDocumentAccessDto { export class VerifyDocumentAccessDto {
@ApiProperty({ description: 'Password for document access (booking number code)' }) @ApiProperty({ description: 'Password for document access (booking number code)' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
} }
/** /**
* Response DTO for checking document access requirements * Response DTO for checking document access requirements
*/ */
export class DocumentAccessRequirementsDto { export class DocumentAccessRequirementsDto {
@ApiProperty({ description: 'Whether password is required to access documents' }) @ApiProperty({ description: 'Whether password is required to access documents' })
requiresPassword: boolean; requiresPassword: boolean;
@ApiPropertyOptional({ description: 'Booking number (if available)' }) @ApiPropertyOptional({ description: 'Booking number (if available)' })
bookingNumber?: string; bookingNumber?: string;
@ApiProperty({ description: 'Current booking status' }) @ApiProperty({ description: 'Current booking status' })
status: string; status: string;
} }
/** /**
* Booking Summary DTO for Carrier Documents Page * Booking Summary DTO for Carrier Documents Page
*/ */
export class BookingSummaryDto { export class BookingSummaryDto {
@ApiProperty({ description: 'Booking unique ID' }) @ApiProperty({ description: 'Booking unique ID' })
id: string; id: string;
@ApiPropertyOptional({ description: 'Human-readable booking number' }) @ApiPropertyOptional({ description: 'Human-readable booking number' })
bookingNumber?: string; bookingNumber?: string;
@ApiProperty({ description: 'Carrier/Company name' }) @ApiProperty({ description: 'Carrier/Company name' })
carrierName: string; carrierName: string;
@ApiProperty({ description: 'Origin port code' }) @ApiProperty({ description: 'Origin port code' })
origin: string; origin: string;
@ApiProperty({ description: 'Destination port code' }) @ApiProperty({ description: 'Destination port code' })
destination: string; destination: string;
@ApiProperty({ description: 'Route description (origin -> destination)' }) @ApiProperty({ description: 'Route description (origin -> destination)' })
routeDescription: string; routeDescription: string;
@ApiProperty({ description: 'Volume in CBM' }) @ApiProperty({ description: 'Volume in CBM' })
volumeCBM: number; volumeCBM: number;
@ApiProperty({ description: 'Weight in KG' }) @ApiProperty({ description: 'Weight in KG' })
weightKG: number; weightKG: number;
@ApiProperty({ description: 'Number of pallets' }) @ApiProperty({ description: 'Number of pallets' })
palletCount: number; palletCount: number;
@ApiProperty({ description: 'Price in the primary currency' }) @ApiProperty({ description: 'Price in the primary currency' })
price: number; price: number;
@ApiProperty({ description: 'Currency (USD or EUR)' }) @ApiProperty({ description: 'Currency (USD or EUR)' })
currency: string; currency: string;
@ApiProperty({ description: 'Transit time in days' }) @ApiProperty({ description: 'Transit time in days' })
transitDays: number; transitDays: number;
@ApiProperty({ description: 'Container type' }) @ApiProperty({ description: 'Container type' })
containerType: string; containerType: string;
@ApiProperty({ description: 'When the booking was accepted' }) @ApiProperty({ description: 'When the booking was accepted' })
acceptedAt: Date; acceptedAt: Date;
} }
/** /**
* Document with signed download URL for carrier access * Document with signed download URL for carrier access
*/ */
export class DocumentWithUrlDto { export class DocumentWithUrlDto {
@ApiProperty({ description: 'Document unique ID' }) @ApiProperty({ description: 'Document unique ID' })
id: string; id: string;
@ApiProperty({ @ApiProperty({
description: 'Document type', description: 'Document type',
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'], enum: [
}) 'BILL_OF_LADING',
type: string; 'PACKING_LIST',
'COMMERCIAL_INVOICE',
@ApiProperty({ description: 'Original file name' }) 'CERTIFICATE_OF_ORIGIN',
fileName: string; 'OTHER',
],
@ApiProperty({ description: 'File MIME type' }) })
mimeType: string; type: string;
@ApiProperty({ description: 'File size in bytes' }) @ApiProperty({ description: 'Original file name' })
size: number; fileName: string;
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) @ApiProperty({ description: 'File MIME type' })
downloadUrl: string; mimeType: string;
}
@ApiProperty({ description: 'File size in bytes' })
/** size: number;
* Carrier Documents Response DTO
* @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
* Response for carrier document access page downloadUrl: string;
*/ }
export class CarrierDocumentsResponseDto {
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) /**
booking: BookingSummaryDto; * Carrier Documents Response DTO
*
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) * Response for carrier document access page
documents: DocumentWithUrlDto[]; */
} export class CarrierDocumentsResponseDto {
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
booking: BookingSummaryDto;
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
documents: DocumentWithUrlDto[];
}

View File

@ -1,139 +1,139 @@
/** /**
* Cookie Consent DTOs * Cookie Consent DTOs
* GDPR compliant consent management * GDPR compliant consent management
*/ */
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator'; import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** /**
* Request DTO for recording/updating cookie consent * Request DTO for recording/updating cookie consent
*/ */
export class UpdateConsentDto { export class UpdateConsentDto {
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Essential cookies consent (always true, required for functionality)', description: 'Essential cookies consent (always true, required for functionality)',
default: true, default: true,
}) })
@IsBoolean() @IsBoolean()
essential: boolean; essential: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Functional cookies consent (preferences, language, etc.)', description: 'Functional cookies consent (preferences, language, etc.)',
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
functional: boolean; functional: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
analytics: boolean; analytics: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Marketing cookies consent (ads, tracking, remarketing)', description: 'Marketing cookies consent (ads, tracking, remarketing)',
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
marketing: boolean; marketing: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: '192.168.1.1', example: '192.168.1.1',
description: 'IP address at time of consent (for GDPR audit trail)', description: 'IP address at time of consent (for GDPR audit trail)',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
ipAddress?: string; ipAddress?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
description: 'User agent at time of consent', description: 'User agent at time of consent',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
userAgent?: string; userAgent?: string;
} }
/** /**
* Response DTO for consent status * Response DTO for consent status
*/ */
export class ConsentResponseDto { export class ConsentResponseDto {
@ApiProperty({ @ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'User ID', description: 'User ID',
}) })
userId: string; userId: string;
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Essential cookies consent (always true)', description: 'Essential cookies consent (always true)',
}) })
essential: boolean; essential: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Functional cookies consent', description: 'Functional cookies consent',
}) })
functional: boolean; functional: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Analytics cookies consent', description: 'Analytics cookies consent',
}) })
analytics: boolean; analytics: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Marketing cookies consent', description: 'Marketing cookies consent',
}) })
marketing: boolean; marketing: boolean;
@ApiProperty({ @ApiProperty({
example: '2025-01-27T10:30:00.000Z', example: '2025-01-27T10:30:00.000Z',
description: 'Date when consent was recorded', description: 'Date when consent was recorded',
}) })
consentDate: Date; consentDate: Date;
@ApiProperty({ @ApiProperty({
example: '2025-01-27T10:30:00.000Z', example: '2025-01-27T10:30:00.000Z',
description: 'Last update timestamp', description: 'Last update timestamp',
}) })
updatedAt: Date; updatedAt: Date;
} }
/** /**
* Request DTO for withdrawing specific consent * Request DTO for withdrawing specific consent
*/ */
export class WithdrawConsentDto { export class WithdrawConsentDto {
@ApiProperty({ @ApiProperty({
example: 'marketing', example: 'marketing',
description: 'Type of consent to withdraw', description: 'Type of consent to withdraw',
enum: ['functional', 'analytics', 'marketing'], enum: ['functional', 'analytics', 'marketing'],
}) })
@IsEnum(['functional', 'analytics', 'marketing'], { @IsEnum(['functional', 'analytics', 'marketing'], {
message: 'Consent type must be functional, analytics, or marketing', message: 'Consent type must be functional, analytics, or marketing',
}) })
consentType: 'functional' | 'analytics' | 'marketing'; consentType: 'functional' | 'analytics' | 'marketing';
} }
/** /**
* Success response DTO * Success response DTO
*/ */
export class ConsentSuccessDto { export class ConsentSuccessDto {
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Operation success status', description: 'Operation success status',
}) })
success: boolean; success: boolean;
@ApiProperty({ @ApiProperty({
example: 'Consent preferences saved successfully', example: 'Consent preferences saved successfully',
description: 'Response message', description: 'Response message',
}) })
message: string; message: string;
} }

View File

@ -294,8 +294,8 @@ export class CsvBookingResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Booking status', description: 'Booking status',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING', example: 'PENDING_PAYMENT',
}) })
status: string; status: string;
@ -353,6 +353,18 @@ export class CsvBookingResponseDto {
example: 1850.5, example: 1850.5,
}) })
price: number; 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 * Statistics for user's or organization's bookings
*/ */
export class CsvBookingStatsDto { export class CsvBookingStatsDto {
@ApiProperty({
description: 'Number of bookings awaiting payment',
example: 1,
})
pendingPayment: number;
@ApiProperty({ @ApiProperty({
description: 'Number of pending bookings', description: 'Number of pending bookings',
example: 5, example: 5,

View File

@ -184,6 +184,19 @@ export class UpdateOrganizationDto {
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren?: string; siren?: string;
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits)',
minLength: 14,
maxLength: 14,
})
@IsString()
@IsOptional()
@MinLength(14)
@MaxLength(14)
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
siret?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'FR123456789', example: 'FR123456789',
description: 'EU EORI number', description: 'EU EORI number',
@ -344,6 +357,25 @@ export class OrganizationResponseDto {
}) })
documents: OrganizationDocumentDto[]; 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({ @ApiProperty({
example: true, example: true,
description: 'Active status', description: 'Active status',

View File

@ -5,25 +5,16 @@
*/ */
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator';
IsString,
IsEnum,
IsNotEmpty,
IsUrl,
IsOptional,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
/** /**
* Subscription plan types * Subscription plan types
*/ */
export enum SubscriptionPlanDto { export enum SubscriptionPlanDto {
FREE = 'FREE', BRONZE = 'BRONZE',
STARTER = 'STARTER', SILVER = 'SILVER',
PRO = 'PRO', GOLD = 'GOLD',
ENTERPRISE = 'ENTERPRISE', PLATINIUM = 'PLATINIUM',
} }
/** /**
@ -53,7 +44,7 @@ export enum BillingIntervalDto {
*/ */
export class CreateCheckoutSessionDto { export class CreateCheckoutSessionDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
description: 'The subscription plan to purchase', description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
@ -197,14 +188,14 @@ export class LicenseResponseDto {
*/ */
export class PlanDetailsDto { export class PlanDetailsDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
description: 'Plan identifier', description: 'Plan identifier',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
plan: SubscriptionPlanDto; plan: SubscriptionPlanDto;
@ApiProperty({ @ApiProperty({
example: 'Starter', example: 'Silver',
description: 'Plan display name', description: 'Plan display name',
}) })
name: string; name: string;
@ -216,20 +207,51 @@ export class PlanDetailsDto {
maxLicenses: number; maxLicenses: number;
@ApiProperty({ @ApiProperty({
example: 49, example: 249,
description: 'Monthly price in EUR', description: 'Monthly price in EUR',
}) })
monthlyPriceEur: number; monthlyPriceEur: number;
@ApiProperty({ @ApiProperty({
example: 470, example: 2739,
description: 'Yearly price in EUR', description: 'Yearly price in EUR (11 months)',
}) })
yearlyPriceEur: number; yearlyPriceEur: number;
@ApiProperty({ @ApiProperty({
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'], example: -1,
description: 'List of features included in this plan', 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], type: [String],
}) })
features: string[]; features: string[];
@ -252,7 +274,7 @@ export class SubscriptionResponseDto {
organizationId: string; organizationId: string;
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.STARTER, example: SubscriptionPlanDto.SILVER,
description: 'Current subscription plan', description: 'Current subscription plan',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })

View File

@ -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 <token>`)
* - 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<boolean> {
const request = context.switchToHttp().getRequest<Record<string, any>>();
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<boolean>;
}
}

View File

@ -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<boolean> {
// Get required features from @RequiresFeature() decorator
const requiredFeatures = this.reflector.getAllAndOverride<PlanFeature[]>(
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.`
);
}
}

View File

@ -1,2 +1,3 @@
export * from './jwt-auth.guard'; export * from './jwt-auth.guard';
export * from './roles.guard'; export * from './roles.guard';
export * from './api-key-or-jwt.guard';

View File

@ -31,6 +31,9 @@ export class OrganizationMapper {
address: this.mapAddressToDto(organization.address), address: this.mapAddressToDto(organization.address),
logoUrl: organization.logoUrl, logoUrl: organization.logoUrl,
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
siret: organization.siret,
siretVerified: organization.siretVerified,
statusBadge: organization.statusBadge,
isActive: organization.isActive, isActive: organization.isActive,
createdAt: organization.createdAt, createdAt: organization.createdAt,
updatedAt: organization.updatedAt, updatedAt: organization.updatedAt,

View File

@ -16,7 +16,9 @@ import {
NOTIFICATION_REPOSITORY, NOTIFICATION_REPOSITORY,
} from '@domain/ports/out/notification.repository'; } from '@domain/ports/out/notification.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; 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 { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import { import {
Notification, Notification,
NotificationType, NotificationType,
@ -30,6 +32,7 @@ import {
CsvBookingStatsDto, CsvBookingStatsDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service';
/** /**
* CSV Booking Document (simple class for domain) * CSV Booking Document (simple class for domain)
@ -62,7 +65,12 @@ export class CsvBookingService {
@Inject(EMAIL_PORT) @Inject(EMAIL_PORT)
private readonly emailAdapter: EmailPort, private readonly emailAdapter: EmailPort,
@Inject(STORAGE_PORT) @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 // Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId); 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( const booking = new CsvBooking(
bookingId, bookingId,
userId, userId,
@ -131,12 +150,16 @@ export class CsvBookingService {
dto.primaryCurrency, dto.primaryCurrency,
dto.transitDays, dto.transitDays,
dto.containerType, dto.containerType,
CsvBookingStatus.PENDING, CsvBookingStatus.PENDING_PAYMENT,
documents, documents,
confirmationToken, confirmationToken,
new Date(), new Date(),
undefined, undefined,
dto.notes dto.notes,
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
); );
// Save to database // Save to database
@ -152,58 +175,398 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking); 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 // NO email sent to carrier yet - will be sent after commission payment
// The button waits for the email to be sent before responding // 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<CsvBookingResponseDto> {
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 { try {
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId, bookingId: booking.id,
bookingNumber, bookingNumber: bookingNumber || '',
documentPassword, documentPassword: documentPassword || '',
origin: dto.origin, origin: booking.origin.getValue(),
destination: dto.destination, destination: booking.destination.getValue(),
volumeCBM: dto.volumeCBM, volumeCBM: booking.volumeCBM,
weightKG: dto.weightKG, weightKG: booking.weightKG,
palletCount: dto.palletCount, palletCount: booking.palletCount,
priceUSD: dto.priceUSD, priceUSD: booking.priceUSD,
priceEUR: dto.priceEUR, priceEUR: booking.priceEUR,
primaryCurrency: dto.primaryCurrency, primaryCurrency: booking.primaryCurrency,
transitDays: dto.transitDays, transitDays: booking.transitDays,
containerType: dto.containerType, containerType: booking.containerType,
documents: documents.map(doc => ({ documents: booking.documents.map(doc => ({
type: doc.type, type: doc.type,
fileName: doc.fileName, fileName: doc.fileName,
})), })),
confirmationToken, confirmationToken: booking.confirmationToken,
notes: dto.notes, notes: booking.notes,
}); });
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); 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 // Create notification for user
try { try {
const notification = Notification.create({ const notification = Notification.create({
id: uuidv4(), id: uuidv4(),
userId, userId: booking.userId,
organizationId, organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT, type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM, priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent', title: 'Booking Request Sent',
message: `Your booking request to ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`, message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
metadata: { bookingId, carrierName: dto.carrierName }, metadata: { bookingId: booking.id, carrierName: booking.carrierName },
}); });
await this.notificationRepository.save(notification); await this.notificationRepository.save(notification);
this.logger.log(`Notification created for user ${userId}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); 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<CsvBookingResponseDto> {
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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
<td style="padding: 8px 12px;">${bookingNumber}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
<td style="padding: 8px 12px;">${booking.carrierName}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
</tr>
</table>
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
Voir les bookings en attente
</a>
</div>
`,
});
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<void> {
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<CsvBookingResponseDto> {
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) // Accept the booking (domain logic validates status)
booking.accept(); 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 // Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking); const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`); this.logger.log(`Booking ${booking.id} accepted`);
@ -568,6 +946,7 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForUser(userId); const stats = await this.csvBookingRepository.countByStatusForUser(userId);
return { return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0, pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -583,6 +962,7 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId); const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
return { return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0, pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -678,9 +1058,15 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Allow adding documents to PENDING or ACCEPTED bookings // Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) { if (
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled'); 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 // Upload new documents
@ -723,7 +1109,10 @@ export class CsvBookingService {
}); });
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } 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`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Verify booking is still pending // Verify booking is still pending or awaiting payment
if (booking.status !== CsvBookingStatus.PENDING) { if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING
) {
throw new BadRequestException('Cannot delete documents from a booking that is not 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); 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 { return {
success: true, success: true,
@ -947,6 +1341,8 @@ export class CsvBookingService {
routeDescription: booking.getRouteDescription(), routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(), isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency), price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur,
}; };
} }

View File

@ -120,10 +120,7 @@ export class GDPRService {
/** /**
* Record or update consent (GDPR Article 7 - Conditions for consent) * Record or update consent (GDPR Article 7 - Conditions for consent)
*/ */
async recordConsent( async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
userId: string,
consentData: UpdateConsentDto
): Promise<ConsentResponseDto> {
this.logger.log(`Recording consent for user ${userId}`); this.logger.log(`Recording consent for user ${userId}`);
// Verify user exists // Verify user exists

View File

@ -38,7 +38,7 @@ export class InvitationService {
@Inject(EMAIL_PORT) @Inject(EMAIL_PORT)
private readonly emailService: EmailPort, private readonly emailService: EmailPort,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService
) {} ) {}
/** /**
@ -50,7 +50,8 @@ export class InvitationService {
lastName: string, lastName: string,
role: UserRole, role: UserRole,
organizationId: string, organizationId: string,
invitedById: string invitedById: string,
inviterRole?: string
): Promise<InvitationToken> { ): Promise<InvitationToken> {
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`); 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 // 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) { if (!canInviteResult.canInvite) {
this.logger.warn( 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( throw new ForbiddenException(
canInviteResult.message || 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<void> {
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) * Cleanup expired invitations (can be called by a cron job)
*/ */

View File

@ -4,24 +4,14 @@
* Business logic for subscription and license management. * Business logic for subscription and license management.
*/ */
import { import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
Injectable,
Inject,
Logger,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
SubscriptionRepository, SubscriptionRepository,
SUBSCRIPTION_REPOSITORY, SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository'; } from '@domain/ports/out/subscription.repository';
import { import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
LicenseRepository,
LICENSE_REPOSITORY,
} from '@domain/ports/out/license.repository';
import { import {
OrganizationRepository, OrganizationRepository,
ORGANIZATION_REPOSITORY, 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 { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import { Subscription } from '@domain/entities/subscription.entity'; import { Subscription } from '@domain/entities/subscription.entity';
import { License } from '@domain/entities/license.entity'; import { License } from '@domain/entities/license.entity';
import { import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
SubscriptionPlan,
SubscriptionPlanType,
} from '@domain/value-objects/subscription-plan.vo';
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo'; import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
import { import {
NoLicensesAvailableException, NoLicensesAvailableException,
SubscriptionNotFoundException,
LicenseAlreadyAssignedException, LicenseAlreadyAssignedException,
} from '@domain/exceptions/subscription.exceptions'; } from '@domain/exceptions/subscription.exceptions';
import { import {
@ -69,50 +55,54 @@ export class SubscriptionService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
@Inject(STRIPE_PORT) @Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort, private readonly stripeAdapter: StripePort,
private readonly configService: ConfigService, private readonly configService: ConfigService
) {} ) {}
/** /**
* Get subscription overview for an organization * Get subscription overview for an organization
* ADMIN users always see a PLATINIUM plan with no expiration
*/ */
async getSubscriptionOverview( async getSubscriptionOverview(
organizationId: string, organizationId: string,
userRole?: string
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId( const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id);
subscription.id,
);
// Enrich licenses with user information // Enrich licenses with user information
const enrichedLicenses = await Promise.all( const enrichedLicenses = await Promise.all(
activeLicenses.map(async (license) => { activeLicenses.map(async license => {
const user = await this.userRepository.findById(license.userId); const user = await this.userRepository.findById(license.userId);
return this.mapLicenseToDto(license, user); return this.mapLicenseToDto(license, user);
}), })
); );
// Count only non-ADMIN licenses for quota calculation // Count only non-ADMIN licenses for quota calculation
// ADMIN users have unlimited licenses and don't count against the quota // ADMIN users have unlimited licenses and don't count against the quota
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( 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 ? -1
: Math.max(0, maxLicenses - usedLicenses); : Math.max(0, maxLicenses - usedLicenses);
return { return {
id: subscription.id, id: subscription.id,
organizationId: subscription.organizationId, organizationId: subscription.organizationId,
plan: subscription.plan.value as SubscriptionPlanDto, plan: effectivePlan.value as SubscriptionPlanDto,
planDetails: this.mapPlanToDto(subscription.plan), planDetails: this.mapPlanToDto(effectivePlan),
status: subscription.status.value as SubscriptionStatusDto, status: subscription.status.value as SubscriptionStatusDto,
usedLicenses, usedLicenses,
maxLicenses, maxLicenses,
availableLicenses, availableLicenses,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, cancelAtPeriodEnd: false,
currentPeriodStart: subscription.currentPeriodStart || undefined, currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined,
currentPeriodEnd: subscription.currentPeriodEnd || undefined, currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined,
createdAt: subscription.createdAt, createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt, updatedAt: subscription.updatedAt,
licenses: enrichedLicenses, licenses: enrichedLicenses,
@ -123,27 +113,35 @@ export class SubscriptionService {
* Get all available plans * Get all available plans
*/ */
getAllPlans(): AllPlansResponseDto { getAllPlans(): AllPlansResponseDto {
const plans = SubscriptionPlan.getAllPlans().map((plan) => const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan));
this.mapPlanToDto(plan),
);
return { plans }; return { plans };
} }
/** /**
* Check if organization can invite more users * 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<CanInviteResponseDto> { async canInviteUser(organizationId: string, userRole?: string): Promise<CanInviteResponseDto> {
// 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); const subscription = await this.getOrCreateSubscription(organizationId);
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses // Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id, subscription.id
); );
const maxLicenses = subscription.maxLicenses; const maxLicenses = subscription.maxLicenses;
const canInvite = const canInvite =
subscription.isActive() && subscription.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses);
(subscription.isUnlimited() || usedLicenses < maxLicenses);
const availableLicenses = subscription.isUnlimited() const availableLicenses = subscription.isUnlimited()
? -1 ? -1
@ -171,7 +169,7 @@ export class SubscriptionService {
async createCheckoutSession( async createCheckoutSession(
organizationId: string, organizationId: string,
userId: string, userId: string,
dto: CreateCheckoutSessionDto, dto: CreateCheckoutSessionDto
): Promise<CheckoutSessionResponseDto> { ): Promise<CheckoutSessionResponseDto> {
const organization = await this.organizationRepository.findById(organizationId); const organization = await this.organizationRepository.findById(organizationId);
if (!organization) { if (!organization) {
@ -184,23 +182,19 @@ export class SubscriptionService {
} }
// Cannot checkout for FREE plan // Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.FREE) { if (dto.plan === SubscriptionPlanDto.BRONZE) {
throw new BadRequestException('Cannot create checkout session for FREE plan'); throw new BadRequestException('Cannot create checkout session for Bronze plan');
} }
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);
const frontendUrl = this.configService.get<string>( const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
'FRONTEND_URL',
'http://localhost:3000',
);
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID // Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
const successUrl = const successUrl =
dto.successUrl || dto.successUrl ||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`; `${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = const cancelUrl =
dto.cancelUrl || dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`;
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
const result = await this.stripeAdapter.createCheckoutSession({ const result = await this.stripeAdapter.createCheckoutSession({
organizationId, organizationId,
@ -214,7 +208,7 @@ export class SubscriptionService {
}); });
this.logger.log( this.logger.log(
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`, `Created checkout session for organization ${organizationId}, plan ${dto.plan}`
); );
return { return {
@ -228,24 +222,18 @@ export class SubscriptionService {
*/ */
async createPortalSession( async createPortalSession(
organizationId: string, organizationId: string,
dto: CreatePortalSessionDto, dto: CreatePortalSessionDto
): Promise<PortalSessionResponseDto> { ): Promise<PortalSessionResponseDto> {
const subscription = await this.subscriptionRepository.findByOrganizationId( const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
organizationId,
);
if (!subscription?.stripeCustomerId) { if (!subscription?.stripeCustomerId) {
throw new BadRequestException( 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<string>( const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
'FRONTEND_URL', const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
'http://localhost:3000',
);
const returnUrl =
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
const result = await this.stripeAdapter.createPortalSession({ const result = await this.stripeAdapter.createPortalSession({
customerId: subscription.stripeCustomerId, customerId: subscription.stripeCustomerId,
@ -267,11 +255,9 @@ export class SubscriptionService {
*/ */
async syncFromStripe( async syncFromStripe(
organizationId: string, organizationId: string,
sessionId?: string, sessionId?: string
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
let subscription = await this.subscriptionRepository.findByOrganizationId( let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
organizationId,
);
if (!subscription) { if (!subscription) {
subscription = await this.getOrCreateSubscription(organizationId); 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 // 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 // This is important for upgrades where Stripe may create a new subscription
if (sessionId) { 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); const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
if (checkoutSession) { if (checkoutSession) {
this.logger.log( 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 // Always use the subscription ID from the checkout session if available
@ -330,7 +318,7 @@ export class SubscriptionService {
if (plan) { if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses // Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id, subscription.id
); );
const newPlan = SubscriptionPlan.create(plan); const newPlan = SubscriptionPlan.create(plan);
@ -354,13 +342,13 @@ export class SubscriptionService {
// Update status // Update status
updatedSubscription = updatedSubscription.updateStatus( updatedSubscription = updatedSubscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status), SubscriptionStatus.fromStripeStatus(stripeData.status)
); );
await this.subscriptionRepository.save(updatedSubscription); await this.subscriptionRepository.save(updatedSubscription);
this.logger.log( 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); return this.getSubscriptionOverview(organizationId);
@ -418,14 +406,14 @@ export class SubscriptionService {
if (!isAdmin) { if (!isAdmin) {
// Count only non-ADMIN licenses for quota check // Count only non-ADMIN licenses for quota check
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id, subscription.id
); );
if (!subscription.canAllocateLicenses(usedLicenses)) { if (!subscription.canAllocateLicenses(usedLicenses)) {
throw new NoLicensesAvailableException( throw new NoLicensesAvailableException(
organizationId, organizationId,
usedLicenses, usedLicenses,
subscription.maxLicenses, subscription.maxLicenses
); );
} }
} }
@ -474,22 +462,18 @@ export class SubscriptionService {
* Get or create a subscription for an organization * Get or create a subscription for an organization
*/ */
async getOrCreateSubscription(organizationId: string): Promise<Subscription> { async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
let subscription = await this.subscriptionRepository.findByOrganizationId( let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
organizationId,
);
if (!subscription) { if (!subscription) {
// Create FREE subscription for the organization // Create FREE subscription for the organization
subscription = Subscription.create({ subscription = Subscription.create({
id: uuidv4(), id: uuidv4(),
organizationId, organizationId,
plan: SubscriptionPlan.free(), plan: SubscriptionPlan.bronze(),
}); });
subscription = await this.subscriptionRepository.save(subscription); subscription = await this.subscriptionRepository.save(subscription);
this.logger.log( this.logger.log(`Created Bronze subscription for organization ${organizationId}`);
`Created FREE subscription for organization ${organizationId}`,
);
} }
return subscription; return subscription;
@ -497,9 +481,7 @@ export class SubscriptionService {
// Private helper methods // Private helper methods
private async handleCheckoutCompleted( private async handleCheckoutCompleted(session: Record<string, unknown>): Promise<void> {
session: Record<string, unknown>,
): Promise<void> {
const metadata = session.metadata as Record<string, string> | undefined; const metadata = session.metadata as Record<string, string> | undefined;
const organizationId = metadata?.organizationId; const organizationId = metadata?.organizationId;
const customerId = session.customer as string; const customerId = session.customer as string;
@ -537,27 +519,26 @@ export class SubscriptionService {
}); });
subscription = subscription.updatePlan( subscription = subscription.updatePlan(
SubscriptionPlan.create(plan), SubscriptionPlan.create(plan),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
); );
subscription = subscription.updateStatus( subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeSubscription.status), SubscriptionStatus.fromStripeStatus(stripeSubscription.status)
); );
await this.subscriptionRepository.save(subscription); await this.subscriptionRepository.save(subscription);
this.logger.log( // Update organization status badge to match the plan
`Updated subscription for organization ${organizationId} to plan ${plan}`, await this.updateOrganizationBadge(organizationId, subscription.statusBadge);
);
this.logger.log(`Updated subscription for organization ${organizationId} to plan ${plan}`);
} }
private async handleSubscriptionUpdated( private async handleSubscriptionUpdated(
stripeSubscription: Record<string, unknown>, stripeSubscription: Record<string, unknown>
): Promise<void> { ): Promise<void> {
const subscriptionId = stripeSubscription.id as string; const subscriptionId = stripeSubscription.id as string;
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId( let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId);
subscriptionId,
);
if (!subscription) { if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`); this.logger.warn(`Subscription ${subscriptionId} not found in database`);
@ -576,7 +557,7 @@ export class SubscriptionService {
if (plan) { if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses // Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id, subscription.id
); );
const newPlan = SubscriptionPlan.create(plan); const newPlan = SubscriptionPlan.create(plan);
@ -584,9 +565,7 @@ export class SubscriptionService {
if (newPlan.canAccommodateUsers(usedLicenses)) { if (newPlan.canAccommodateUsers(usedLicenses)) {
subscription = subscription.updatePlan(newPlan, usedLicenses); subscription = subscription.updatePlan(newPlan, usedLicenses);
} else { } else {
this.logger.warn( this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`);
`Cannot update to plan ${plan} - would exceed license limit`,
);
} }
} }
@ -597,22 +576,26 @@ export class SubscriptionService {
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd, cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
}); });
subscription = subscription.updateStatus( subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status), SubscriptionStatus.fromStripeStatus(stripeData.status)
); );
await this.subscriptionRepository.save(subscription); 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}`); this.logger.log(`Updated subscription ${subscriptionId}`);
} }
private async handleSubscriptionDeleted( private async handleSubscriptionDeleted(
stripeSubscription: Record<string, unknown>, stripeSubscription: Record<string, unknown>
): Promise<void> { ): Promise<void> {
const subscriptionId = stripeSubscription.id as string; const subscriptionId = stripeSubscription.id as string;
const subscription = await this.subscriptionRepository.findByStripeSubscriptionId( const subscription =
subscriptionId, await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId);
);
if (!subscription) { if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`); 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 // Downgrade to FREE plan - count only non-ADMIN licenses
const canceledSubscription = subscription const canceledSubscription = subscription
.updatePlan( .updatePlan(
SubscriptionPlan.free(), SubscriptionPlan.bronze(),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
) )
.updateStatus(SubscriptionStatus.canceled()); .updateStatus(SubscriptionStatus.canceled());
await this.subscriptionRepository.save(canceledSubscription); 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<string, unknown>): Promise<void> { private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
const subscription = await this.subscriptionRepository.findByStripeCustomerId( const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId);
customerId,
);
if (!subscription) { if (!subscription) {
this.logger.warn(`Subscription for customer ${customerId} not found`); this.logger.warn(`Subscription for customer ${customerId} not found`);
return; return;
} }
const updatedSubscription = subscription.updateStatus( const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue());
SubscriptionStatus.pastDue(),
);
await this.subscriptionRepository.save(updatedSubscription); await this.subscriptionRepository.save(updatedSubscription);
this.logger.log( this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`);
`Subscription ${subscription.id} marked as past due due to payment failure`,
);
} }
private mapLicenseToDto( private mapLicenseToDto(
license: License, license: License,
user: { email: string; firstName: string; lastName: string; role: string } | null, user: { email: string; firstName: string; lastName: string; role: string } | null
): LicenseResponseDto { ): LicenseResponseDto {
return { return {
id: license.id, id: license.id,
@ -671,6 +653,19 @@ export class SubscriptionService {
}; };
} }
private async updateOrganizationBadge(organizationId: string, badge: string): Promise<void> {
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 { private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
return { return {
plan: plan.value as SubscriptionPlanDto, plan: plan.value as SubscriptionPlanDto,
@ -678,6 +673,11 @@ export class SubscriptionService {
maxLicenses: plan.maxLicenses, maxLicenses: plan.maxLicenses,
monthlyPriceEur: plan.monthlyPriceEur, monthlyPriceEur: plan.monthlyPriceEur,
yearlyPriceEur: plan.yearlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur,
maxShipmentsPerYear: plan.maxShipmentsPerYear,
commissionRatePercent: plan.commissionRatePercent,
supportLevel: plan.supportLevel,
statusBadge: plan.statusBadge,
planFeatures: [...plan.planFeatures],
features: [...plan.features], features: [...plan.features],
}; };
} }

View File

@ -7,14 +7,13 @@ import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule],
TypeOrmModule.forFeature([UserOrmEntity]),
SubscriptionsModule,
],
controllers: [UsersController], controllers: [UsersController],
providers: [ providers: [
FeatureFlagGuard,
{ {
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,

View File

@ -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 };
}
}

View File

@ -50,6 +50,8 @@ export interface BookingProps {
cargoDescription: string; cargoDescription: string;
containers: BookingContainer[]; containers: BookingContainer[];
specialInstructions?: string; specialInstructions?: string;
commissionRate?: number;
commissionAmountEur?: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -161,6 +163,14 @@ export class Booking {
return this.props.specialInstructions; return this.props.specialInstructions;
} }
get commissionRate(): number | undefined {
return this.props.commissionRate;
}
get commissionAmountEur(): number | undefined {
return this.props.commissionAmountEur;
}
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; 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 * Check if booking can be cancelled
*/ */

View File

@ -6,6 +6,8 @@ import { PortCode } from '../value-objects/port-code.vo';
* Represents the lifecycle of a CSV-based booking request * Represents the lifecycle of a CSV-based booking request
*/ */
export enum CsvBookingStatus { 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 PENDING = 'PENDING', // Awaiting carrier response
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
REJECTED = 'REJECTED', // Carrier rejected the booking REJECTED = 'REJECTED', // Carrier rejected the booking
@ -80,7 +82,10 @@ export class CsvBooking {
public respondedAt?: Date, public respondedAt?: Date,
public notes?: string, public notes?: string,
public rejectionReason?: string, public rejectionReason?: string,
public readonly bookingNumber?: string public readonly bookingNumber?: string,
public commissionRate?: number,
public commissionAmountEur?: number,
public stripePaymentIntentId?: string
) { ) {
this.validate(); 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 * Accept the booking
* *
@ -202,6 +262,10 @@ export class CsvBooking {
throw new Error('Cannot cancel rejected booking'); throw new Error('Cannot cancel rejected booking');
} }
if (this.status === CsvBookingStatus.CANCELLED) {
throw new Error('Booking is already cancelled');
}
this.status = CsvBookingStatus.CANCELLED; this.status = CsvBookingStatus.CANCELLED;
this.respondedAt = new Date(); this.respondedAt = new Date();
} }
@ -211,6 +275,10 @@ export class CsvBooking {
* *
* @returns true if booking is older than 7 days and still pending * @returns true if booking is older than 7 days and still pending
*/ */
isPendingPayment(): boolean {
return this.status === CsvBookingStatus.PENDING_PAYMENT;
}
isExpired(): boolean { isExpired(): boolean {
if (this.status !== CsvBookingStatus.PENDING) { if (this.status !== CsvBookingStatus.PENDING) {
return false; return false;
@ -363,7 +431,10 @@ export class CsvBooking {
respondedAt?: Date, respondedAt?: Date,
notes?: string, notes?: string,
rejectionReason?: string, rejectionReason?: string,
bookingNumber?: string bookingNumber?: string,
commissionRate?: number,
commissionAmountEur?: number,
stripePaymentIntentId?: string
): CsvBooking { ): CsvBooking {
// Create instance without calling constructor validation // Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype); const booking = Object.create(CsvBooking.prototype);
@ -392,6 +463,9 @@ export class CsvBooking {
booking.notes = notes; booking.notes = notes;
booking.rejectionReason = rejectionReason; booking.rejectionReason = rejectionReason;
booking.bookingNumber = bookingNumber; booking.bookingNumber = bookingNumber;
booking.commissionRate = commissionRate;
booking.commissionAmountEur = commissionAmountEur;
booking.stripePaymentIntentId = stripePaymentIntentId;
return booking; return booking;
} }

View File

@ -5,10 +5,7 @@
* Each active user in an organization consumes one license. * Each active user in an organization consumes one license.
*/ */
import { import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo';
LicenseStatus,
LicenseStatusType,
} from '../value-objects/license-status.vo';
export interface LicenseProps { export interface LicenseProps {
readonly id: string; readonly id: string;
@ -29,11 +26,7 @@ export class License {
/** /**
* Create a new license for a user * Create a new license for a user
*/ */
static create(props: { static create(props: { id: string; subscriptionId: string; userId: string }): License {
id: string;
subscriptionId: string;
userId: string;
}): License {
return new License({ return new License({
id: props.id, id: props.id,
subscriptionId: props.subscriptionId, subscriptionId: props.subscriptionId,

View File

@ -44,6 +44,9 @@ export interface OrganizationProps {
address: OrganizationAddress; address: OrganizationAddress;
logoUrl?: string; logoUrl?: string;
documents: OrganizationDocument[]; documents: OrganizationDocument[];
siret?: string;
siretVerified: boolean;
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
isActive: boolean; isActive: boolean;
@ -59,9 +62,19 @@ export class Organization {
/** /**
* Factory method to create a new Organization * Factory method to create a new Organization
*/ */
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization { static create(
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
siretVerified?: boolean;
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
}
): Organization {
const now = new Date(); 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 // Validate SCAC code if provided
if (props.scac && !Organization.isValidSCAC(props.scac)) { if (props.scac && !Organization.isValidSCAC(props.scac)) {
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
@ -79,6 +92,8 @@ export class Organization {
return new Organization({ return new Organization({
...props, ...props,
siretVerified: props.siretVerified ?? false,
statusBadge: props.statusBadge ?? 'none',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@ -100,6 +115,10 @@ export class Organization {
return scacPattern.test(scac); return scacPattern.test(scac);
} }
private static isValidSiret(siret: string): boolean {
return /^\d{14}$/.test(siret);
}
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
@ -153,6 +172,18 @@ export class Organization {
return this.props.updatedAt; 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 { get isActive(): boolean {
return this.props.isActive; return this.props.isActive;
} }
@ -183,6 +214,25 @@ export class Organization {
this.props.updatedAt = new Date(); 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 { updateSiren(siren: string): void {
this.props.siren = siren; this.props.siren = siren;
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();

View File

@ -272,7 +272,7 @@ describe('Subscription Entity', () => {
}); });
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow(
SubscriptionNotActiveException, SubscriptionNotActiveException
); );
}); });
@ -284,7 +284,7 @@ describe('Subscription Entity', () => {
}); });
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow(
InvalidSubscriptionDowngradeException, InvalidSubscriptionDowngradeException
); );
}); });
}); });

View File

@ -5,10 +5,7 @@
* Stripe integration, and billing period information. * Stripe integration, and billing period information.
*/ */
import { import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo';
SubscriptionPlan,
SubscriptionPlanType,
} from '../value-objects/subscription-plan.vo';
import { import {
SubscriptionStatus, SubscriptionStatus,
SubscriptionStatusType, 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: { static create(props: {
id: string; id: string;
@ -53,7 +50,7 @@ export class Subscription {
return new Subscription({ return new Subscription({
id: props.id, id: props.id,
organizationId: props.organizationId, organizationId: props.organizationId,
plan: props.plan ?? SubscriptionPlan.free(), plan: props.plan ?? SubscriptionPlan.bronze(),
status: SubscriptionStatus.active(), status: SubscriptionStatus.active(),
stripeCustomerId: props.stripeCustomerId ?? null, stripeCustomerId: props.stripeCustomerId ?? null,
stripeSubscriptionId: props.stripeSubscriptionId ?? null, stripeSubscriptionId: props.stripeSubscriptionId ?? null,
@ -68,10 +65,41 @@ export class Subscription {
/** /**
* Reconstitute from persistence * 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: { static fromPersistence(props: {
id: string; id: string;
organizationId: string; organizationId: string;
plan: SubscriptionPlanType; plan: string; // Accepts both old and new plan names
status: SubscriptionStatusType; status: SubscriptionStatusType;
stripeCustomerId: string | null; stripeCustomerId: string | null;
stripeSubscriptionId: string | null; stripeSubscriptionId: string | null;
@ -84,7 +112,7 @@ export class Subscription {
return new Subscription({ return new Subscription({
id: props.id, id: props.id,
organizationId: props.organizationId, organizationId: props.organizationId,
plan: SubscriptionPlan.create(props.plan), plan: SubscriptionPlan.fromString(props.plan),
status: SubscriptionStatus.create(props.status), status: SubscriptionStatus.create(props.status),
stripeCustomerId: props.stripeCustomerId, stripeCustomerId: props.stripeCustomerId,
stripeSubscriptionId: props.stripeSubscriptionId, stripeSubscriptionId: props.stripeSubscriptionId,
@ -236,7 +264,7 @@ export class Subscription {
this.props.plan.value, this.props.plan.value,
newPlan.value, newPlan.value,
currentUserCount, currentUserCount,
newPlan.maxLicenses, newPlan.maxLicenses
); );
} }

View File

@ -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';
}
}

View File

@ -6,11 +6,11 @@ export class NoLicensesAvailableException extends Error {
constructor( constructor(
public readonly organizationId: string, public readonly organizationId: string,
public readonly currentLicenses: number, public readonly currentLicenses: number,
public readonly maxLicenses: number, public readonly maxLicenses: number
) { ) {
super( super(
`No licenses available for organization ${organizationId}. ` + `No licenses available for organization ${organizationId}. ` +
`Currently using ${currentLicenses}/${maxLicenses} licenses.`, `Currently using ${currentLicenses}/${maxLicenses} licenses.`
); );
this.name = 'NoLicensesAvailableException'; this.name = 'NoLicensesAvailableException';
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype); Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
@ -46,11 +46,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
public readonly currentPlan: string, public readonly currentPlan: string,
public readonly targetPlan: string, public readonly targetPlan: string,
public readonly currentUsers: number, public readonly currentUsers: number,
public readonly targetMaxLicenses: number, public readonly targetMaxLicenses: number
) { ) {
super( super(
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` + `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'; this.name = 'InvalidSubscriptionDowngradeException';
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype); Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
@ -60,11 +60,9 @@ export class InvalidSubscriptionDowngradeException extends Error {
export class SubscriptionNotActiveException extends Error { export class SubscriptionNotActiveException extends Error {
constructor( constructor(
public readonly subscriptionId: string, public readonly subscriptionId: string,
public readonly currentStatus: string, public readonly currentStatus: string
) { ) {
super( super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`);
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
);
this.name = 'SubscriptionNotActiveException'; this.name = 'SubscriptionNotActiveException';
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype); Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
} }
@ -73,13 +71,10 @@ export class SubscriptionNotActiveException extends Error {
export class InvalidSubscriptionStatusTransitionException extends Error { export class InvalidSubscriptionStatusTransitionException extends Error {
constructor( constructor(
public readonly fromStatus: string, public readonly fromStatus: string,
public readonly toStatus: string, public readonly toStatus: string
) { ) {
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`); super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
this.name = 'InvalidSubscriptionStatusTransitionException'; this.name = 'InvalidSubscriptionStatusTransitionException';
Object.setPrototypeOf( Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype);
this,
InvalidSubscriptionStatusTransitionException.prototype,
);
} }
} }

View File

@ -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<ApiKey>;
findById(id: string): Promise<ApiKey | null>;
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
findByOrganizationId(organizationId: string): Promise<ApiKey[]>;
delete(id: string): Promise<void>;
}

View File

@ -15,6 +15,7 @@ export interface EmailAttachment {
export interface EmailOptions { export interface EmailOptions {
to: string | string[]; to: string | string[];
from?: string;
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
replyTo?: string; replyTo?: string;

View File

@ -35,6 +35,11 @@ export interface InvitationTokenRepository {
*/ */
deleteExpired(): Promise<number>; deleteExpired(): Promise<number>;
/**
* Delete an invitation by id
*/
deleteById(id: string): Promise<void>;
/** /**
* Update an invitation token * Update an invitation token
*/ */

View File

@ -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<number>;
}

View File

@ -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<SiretVerificationResult>;
}

View File

@ -43,6 +43,22 @@ export interface StripeSubscriptionData {
cancelAtPeriodEnd: boolean; 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 { export interface StripeCheckoutSessionData {
sessionId: string; sessionId: string;
customerId: string | null; customerId: string | null;
@ -62,16 +78,19 @@ export interface StripePort {
/** /**
* Create a Stripe Checkout session for subscription purchase * Create a Stripe Checkout session for subscription purchase
*/ */
createCheckoutSession( createCheckoutSession(input: CreateCheckoutSessionInput): Promise<CreateCheckoutSessionOutput>;
input: CreateCheckoutSessionInput,
): Promise<CreateCheckoutSessionOutput>; /**
* Create a Stripe Checkout session for one-time commission payment
*/
createCommissionCheckout(
input: CreateCommissionCheckoutInput
): Promise<CreateCommissionCheckoutOutput>;
/** /**
* Create a Stripe Customer Portal session for subscription management * Create a Stripe Customer Portal session for subscription management
*/ */
createPortalSession( createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput>;
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput>;
/** /**
* Retrieve subscription details from Stripe * Retrieve subscription details from Stripe
@ -101,10 +120,7 @@ export interface StripePort {
/** /**
* Verify and parse a Stripe webhook event * Verify and parse a Stripe webhook event
*/ */
constructWebhookEvent( constructWebhookEvent(payload: string | Buffer, signature: string): Promise<StripeWebhookEvent>;
payload: string | Buffer,
signature: string,
): Promise<StripeWebhookEvent>;
/** /**
* Map a Stripe price ID to a subscription plan * Map a Stripe price ID to a subscription plan

View File

@ -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<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
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];
}

View File

@ -2,68 +2,109 @@
* Subscription Plan Value Object * Subscription Plan Value Object
* *
* Represents the different subscription plans available for organizations. * Represents the different subscription plans available for organizations.
* Each plan has a maximum number of licenses that determine how many users * Each plan has a maximum number of licenses, shipment limits, commission rates,
* can be active in an organization. * 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<string, SubscriptionPlanType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
};
interface PlanDetails { interface PlanDetails {
readonly name: string; readonly name: string;
readonly maxLicenses: number; // -1 means unlimited readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number; readonly monthlyPriceEur: number;
readonly yearlyPriceEur: 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<SubscriptionPlanType, PlanDetails> = { const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
FREE: { BRONZE: {
name: 'Free', name: 'Bronze',
maxLicenses: 2, maxLicenses: 1,
monthlyPriceEur: 0, monthlyPriceEur: 0,
yearlyPriceEur: 0, yearlyPriceEur: 0,
features: [ maxShipmentsPerYear: 12,
'Up to 2 users', commissionRatePercent: 5,
'Basic rate search', statusBadge: 'none',
'Email support', supportLevel: 'none',
], planFeatures: PLAN_FEATURES.BRONZE,
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
}, },
STARTER: { SILVER: {
name: 'Starter', name: 'Silver',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 49, monthlyPriceEur: 249,
yearlyPriceEur: 470, // ~20% discount yearlyPriceEur: 2739, // 249 * 11 months
maxShipmentsPerYear: -1,
commissionRatePercent: 3,
statusBadge: 'silver',
supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER,
features: [ features: [
'Up to 5 users', "Jusqu'à 5 utilisateurs",
'Advanced rate search', 'Expéditions illimitées',
'CSV imports', 'Tableau de bord',
'Priority email support', 'Wiki Maritime',
'Gestion des utilisateurs',
'Import CSV',
'Support par email',
], ],
}, },
PRO: { GOLD: {
name: 'Pro', name: 'Gold',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 149, monthlyPriceEur: 899,
yearlyPriceEur: 1430, // ~20% discount yearlyPriceEur: 9889, // 899 * 11 months
maxShipmentsPerYear: -1,
commissionRatePercent: 2,
statusBadge: 'gold',
supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD,
features: [ features: [
'Up to 20 users', "Jusqu'à 20 utilisateurs",
'All Starter features', 'Expéditions illimitées',
'API access', 'Toutes les fonctionnalités Silver',
'Custom integrations', 'Intégration API',
'Phone support', 'Assistance commerciale directe',
], ],
}, },
ENTERPRISE: { PLATINIUM: {
name: 'Enterprise', name: 'Platinium',
maxLicenses: -1, // unlimited maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing
maxShipmentsPerYear: -1,
commissionRatePercent: 1,
statusBadge: 'platinium',
supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM,
features: [ features: [
'Unlimited users', 'Utilisateurs illimités',
'All Pro features', 'Toutes les fonctionnalités Gold',
'Dedicated account manager', 'Key Account Manager dédié',
'Custom SLA', 'Interface personnalisable',
'On-premise deployment option', 'Contrats tarifaires cadre',
], ],
}, },
}; };
@ -78,36 +119,68 @@ export class SubscriptionPlan {
return new SubscriptionPlan(plan); 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 { static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase() as SubscriptionPlanType; const upperValue = value.toUpperCase();
if (!PLAN_DETAILS[upperValue]) {
throw new Error(`Invalid subscription plan: ${value}`); // 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 { static free(): SubscriptionPlan {
return new SubscriptionPlan('FREE'); return SubscriptionPlan.bronze();
} }
static starter(): SubscriptionPlan { static starter(): SubscriptionPlan {
return new SubscriptionPlan('STARTER'); return SubscriptionPlan.silver();
} }
static pro(): SubscriptionPlan { static pro(): SubscriptionPlan {
return new SubscriptionPlan('PRO'); return SubscriptionPlan.gold();
} }
static enterprise(): SubscriptionPlan { static enterprise(): SubscriptionPlan {
return new SubscriptionPlan('ENTERPRISE'); return SubscriptionPlan.platinium();
} }
static getAllPlans(): SubscriptionPlan[] { static getAllPlans(): SubscriptionPlan[] {
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map( return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map(
(p) => new SubscriptionPlan(p as SubscriptionPlanType), p => new SubscriptionPlan(p)
); );
} }
// Getters
get value(): SubscriptionPlanType { get value(): SubscriptionPlanType {
return this.plan; return this.plan;
} }
@ -132,6 +205,33 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].features; 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 * 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 { hasUnlimitedShipments(): boolean {
return this.plan !== 'FREE'; 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 { 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 * Check if upgrade to target plan is allowed
*/ */
canUpgradeTo(targetPlan: SubscriptionPlan): boolean { canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = [ const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex; return targetIndex > currentIndex;
@ -180,12 +289,7 @@ export class SubscriptionPlan {
* Check if downgrade to target plan is allowed given current user count * Check if downgrade to target plan is allowed given current user count
*/ */
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = [ const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);

View File

@ -191,9 +191,7 @@ export class SubscriptionStatus {
*/ */
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus { transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
if (!this.canTransitionTo(newStatus)) { if (!this.canTransitionTo(newStatus)) {
throw new Error( throw new Error(`Invalid status transition from ${this.status} to ${newStatus.value}`);
`Invalid status transition from ${this.status} to ${newStatus.value}`,
);
} }
return newStatus; return newStatus;
} }

View File

@ -4,69 +4,157 @@
* Implements EmailPort using nodemailer * Implements EmailPort using nodemailer
*/ */
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import * as https from 'https';
import { EmailPort, EmailOptions } from '@domain/ports/out/email.port'; import { EmailPort, EmailOptions } from '@domain/ports/out/email.port';
import { EmailTemplates } from './templates/email-templates'; import { EmailTemplates } from './templates/email-templates';
// Display names included → moins susceptibles d'être marqués spam
const EMAIL_SENDERS = {
SECURITY: '"Xpeditis Sécurité" <security@xpeditis.com>',
BOOKINGS: '"Xpeditis Bookings" <bookings@xpeditis.com>',
TEAM: '"Équipe Xpeditis" <team@xpeditis.com>',
CARRIERS: '"Xpeditis Transporteurs" <carriers@xpeditis.com>',
NOREPLY: '"Xpeditis" <noreply@xpeditis.com>',
} 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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@Injectable() @Injectable()
export class EmailAdapter implements EmailPort { export class EmailAdapter implements EmailPort, OnModuleInit {
private readonly logger = new Logger(EmailAdapter.name); private readonly logger = new Logger(EmailAdapter.name);
private transporter: nodemailer.Transporter; private transporter: nodemailer.Transporter;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly emailTemplates: EmailTemplates private readonly emailTemplates: EmailTemplates
) { ) {}
this.initializeTransporter();
async onModuleInit(): Promise<void> {
const host = this.configService.get<string>('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<string>('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<string> {
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<number>('SMTP_PORT', 2525); const port = this.configService.get<number>('SMTP_PORT', 2525);
const user = this.configService.get<string>('SMTP_USER'); const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS'); const pass = this.configService.get<string>('SMTP_PASS');
const secure = this.configService.get<boolean>('SMTP_SECURE', false); const secure = this.configService.get<boolean>('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({ this.transporter = nodemailer.createTransport({
host: actualHost, host: actualHost,
port, port,
secure, secure,
auth: { auth: { user, pass },
user,
pass,
},
// Configuration TLS avec servername pour IP directe
tls: { tls: {
rejectUnauthorized: false, rejectUnauthorized: false,
servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe servername: serverName,
}, },
// Timeouts optimisés connectionTimeout: 15000,
connectionTimeout: 10000, // 10s greetingTimeout: 15000,
greetingTimeout: 10000, // 10s socketTimeout: 30000,
socketTimeout: 30000, // 30s } as any);
dnsTimeout: 10000, // 10s
});
this.logger.log( this.logger.log(
`Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` + `Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
(useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '')
); );
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<void> { async send(options: EmailOptions): Promise<void> {
try { try {
const from = this.configService.get<string>('SMTP_FROM', 'noreply@xpeditis.com'); const from =
options.from ??
this.configService.get<string>('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, from,
to: options.to, to: options.to,
cc: options.cc, cc: options.cc,
@ -74,11 +162,13 @@ export class EmailAdapter implements EmailPort {
replyTo: options.replyTo, replyTo: options.replyTo,
subject: options.subject, subject: options.subject,
html: options.html, html: options.html,
text: options.text, text,
attachments: options.attachments, 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) { } catch (error) {
this.logger.error(`Failed to send email to ${options.to}`, error); this.logger.error(`Failed to send email to ${options.to}`, error);
throw error; throw error;
@ -108,6 +198,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Booking Confirmation - ${bookingNumber}`, subject: `Booking Confirmation - ${bookingNumber}`,
html, html,
attachments, attachments,
@ -122,6 +213,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.SECURITY,
subject: 'Verify your email - Xpeditis', subject: 'Verify your email - Xpeditis',
html, html,
}); });
@ -135,6 +227,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.SECURITY,
subject: 'Reset your password - Xpeditis', subject: 'Reset your password - Xpeditis',
html, html,
}); });
@ -148,6 +241,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.NOREPLY,
subject: 'Welcome to Xpeditis', subject: 'Welcome to Xpeditis',
html, html,
}); });
@ -169,6 +263,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.TEAM,
subject: `You've been invited to join ${organizationName} on Xpeditis`, subject: `You've been invited to join ${organizationName} on Xpeditis`,
html, html,
}); });
@ -209,6 +304,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.TEAM,
subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`, subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`,
html, html,
}); });
@ -273,6 +369,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: carrierEmail, to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin}${bookingData.destination}`, subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin}${bookingData.destination}`,
html, html,
}); });
@ -349,6 +446,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.CARRIERS,
subject: '🚢 Votre compte transporteur Xpeditis a été créé', subject: '🚢 Votre compte transporteur Xpeditis a été créé',
html, html,
}); });
@ -424,6 +522,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: email, to: email,
from: EMAIL_SENDERS.SECURITY,
subject: '🔑 Réinitialisation de votre mot de passe Xpeditis', subject: '🔑 Réinitialisation de votre mot de passe Xpeditis',
html, html,
}); });
@ -535,6 +634,7 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: carrierEmail, to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin}${data.destination}`, subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin}${data.destination}`,
html, html,
}); });
@ -614,10 +714,13 @@ export class EmailAdapter implements EmailPort {
await this.send({ await this.send({
to: carrierEmail, to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Nouveaux documents - Reservation ${data.origin}${data.destination}`, subject: `Nouveaux documents - Reservation ${data.origin}${data.destination}`,
html, 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}`
);
} }
} }

View File

@ -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<string>('PAPPERS_API_KEY', '');
}
async verify(siret: string): Promise<SiretVerificationResult> {
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 };
}
}
}

View File

@ -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;
}

View File

@ -92,6 +92,18 @@ export class BookingOrmEntity {
@Column({ name: 'special_instructions', type: 'text', nullable: true }) @Column({ name: 'special_instructions', type: 'text', nullable: true })
specialInstructions: string | null; 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' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@ -1,58 +1,58 @@
/** /**
* Cookie Consent ORM Entity (Infrastructure Layer) * Cookie Consent ORM Entity (Infrastructure Layer)
* *
* TypeORM entity for cookie consent persistence * TypeORM entity for cookie consent persistence
*/ */
import { import {
Entity, Entity,
Column, Column,
PrimaryColumn, PrimaryColumn,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
Index, Index,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { UserOrmEntity } from './user.orm-entity'; import { UserOrmEntity } from './user.orm-entity';
@Entity('cookie_consents') @Entity('cookie_consents')
@Index('idx_cookie_consents_user', ['userId']) @Index('idx_cookie_consents_user', ['userId'])
export class CookieConsentOrmEntity { export class CookieConsentOrmEntity {
@PrimaryColumn('uuid') @PrimaryColumn('uuid')
id: string; id: string;
@Column({ name: 'user_id', type: 'uuid', unique: true }) @Column({ name: 'user_id', type: 'uuid', unique: true })
userId: string; userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'user_id' })
user: UserOrmEntity; user: UserOrmEntity;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
essential: boolean; essential: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
functional: boolean; functional: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
analytics: boolean; analytics: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
marketing: boolean; marketing: boolean;
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
ipAddress: string | null; ipAddress: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true }) @Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string | null; userAgent: string | null;
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
consentDate: Date; consentDate: Date;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date; updatedAt: Date;
} }

View File

@ -75,11 +75,11 @@ export class CsvBookingOrmEntity {
@Column({ @Column({
name: 'status', name: 'status',
type: 'enum', type: 'enum',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING', default: 'PENDING_PAYMENT',
}) })
@Index() @Index()
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' }) @Column({ name: 'documents', type: 'jsonb' })
documents: Array<{ documents: Array<{
@ -141,6 +141,21 @@ export class CsvBookingOrmEntity {
@Column({ name: 'carrier_notes', type: 'text', nullable: true }) @Column({ name: 'carrier_notes', type: 'text', nullable: true })
carrierNotes: string | null; 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' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date; createdAt: Date;

View File

@ -5,14 +5,7 @@
* Represents user licenses linked to subscriptions. * Represents user licenses linked to subscriptions.
*/ */
import { import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { SubscriptionOrmEntity } from './subscription.orm-entity'; import { SubscriptionOrmEntity } from './subscription.orm-entity';
import { UserOrmEntity } from './user.orm-entity'; import { UserOrmEntity } from './user.orm-entity';
@ -30,7 +23,7 @@ export class LicenseOrmEntity {
@Column({ name: 'subscription_id', type: 'uuid' }) @Column({ name: 'subscription_id', type: 'uuid' })
subscriptionId: string; subscriptionId: string;
@ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, { @ManyToOne(() => SubscriptionOrmEntity, subscription => subscription.licenses, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn({ name: 'subscription_id' }) @JoinColumn({ name: 'subscription_id' })

View File

@ -56,6 +56,15 @@ export class OrganizationOrmEntity {
@Column({ type: 'jsonb', default: '[]' }) @Column({ type: 'jsonb', default: '[]' })
documents: any[]; 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 }) @Column({ name: 'is_carrier', type: 'boolean', default: false })
isCarrier: boolean; isCarrier: boolean;

View File

@ -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;
}

View File

@ -19,7 +19,7 @@ import {
import { OrganizationOrmEntity } from './organization.orm-entity'; import { OrganizationOrmEntity } from './organization.orm-entity';
import { LicenseOrmEntity } from './license.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 = export type SubscriptionStatusOrmType =
| 'ACTIVE' | 'ACTIVE'
@ -51,8 +51,8 @@ export class SubscriptionOrmEntity {
// Plan information // Plan information
@Column({ @Column({
type: 'enum', type: 'enum',
enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'], enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'],
default: 'FREE', default: 'BRONZE',
}) })
plan: SubscriptionPlanOrmType; plan: SubscriptionPlanOrmType;
@ -103,6 +103,6 @@ export class SubscriptionOrmEntity {
updatedAt: Date; updatedAt: Date;
// Relations // Relations
@OneToMany(() => LicenseOrmEntity, (license) => license.subscription) @OneToMany(() => LicenseOrmEntity, license => license.subscription)
licenses: LicenseOrmEntity[]; licenses: LicenseOrmEntity[];
} }

View File

@ -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));
}
}

View File

@ -27,6 +27,8 @@ export class BookingOrmMapper {
orm.consignee = this.partyToJson(domain.consignee); orm.consignee = this.partyToJson(domain.consignee);
orm.cargoDescription = domain.cargoDescription; orm.cargoDescription = domain.cargoDescription;
orm.specialInstructions = domain.specialInstructions || null; orm.specialInstructions = domain.specialInstructions || null;
orm.commissionRate = domain.commissionRate ?? null;
orm.commissionAmountEur = domain.commissionAmountEur ?? null;
orm.createdAt = domain.createdAt; orm.createdAt = domain.createdAt;
orm.updatedAt = domain.updatedAt; orm.updatedAt = domain.updatedAt;
@ -52,6 +54,9 @@ export class BookingOrmMapper {
cargoDescription: orm.cargoDescription, cargoDescription: orm.cargoDescription,
containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [], containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [],
specialInstructions: orm.specialInstructions || undefined, specialInstructions: orm.specialInstructions || undefined,
commissionRate: orm.commissionRate != null ? Number(orm.commissionRate) : undefined,
commissionAmountEur:
orm.commissionAmountEur != null ? Number(orm.commissionAmountEur) : undefined,
createdAt: orm.createdAt, createdAt: orm.createdAt,
updatedAt: orm.updatedAt, updatedAt: orm.updatedAt,
}; };

View File

@ -42,7 +42,10 @@ export class CsvBookingMapper {
ormEntity.respondedAt, ormEntity.respondedAt,
ormEntity.notes, ormEntity.notes,
ormEntity.rejectionReason, 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, primaryCurrency: domain.primaryCurrency,
transitDays: domain.transitDays, transitDays: domain.transitDays,
containerType: domain.containerType, containerType: domain.containerType,
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', status: domain.status as CsvBookingOrmEntity['status'],
documents: domain.documents as any, documents: domain.documents as any,
confirmationToken: domain.confirmationToken, confirmationToken: domain.confirmationToken,
requestedAt: domain.requestedAt, requestedAt: domain.requestedAt,
respondedAt: domain.respondedAt, respondedAt: domain.respondedAt,
notes: domain.notes, notes: domain.notes,
rejectionReason: domain.rejectionReason, 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<CsvBookingOrmEntity> { static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return { return {
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', status: domain.status as CsvBookingOrmEntity['status'],
respondedAt: domain.respondedAt, respondedAt: domain.respondedAt,
notes: domain.notes, notes: domain.notes,
rejectionReason: domain.rejectionReason, rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
}; };
} }

View File

@ -43,6 +43,6 @@ export class LicenseOrmMapper {
* Map array of ORM entities to domain entities * Map array of ORM entities to domain entities
*/ */
static toDomainMany(orms: LicenseOrmEntity[]): License[] { static toDomainMany(orms: LicenseOrmEntity[]): License[] {
return orms.map((orm) => this.toDomain(orm)); return orms.map(orm => this.toDomain(orm));
} }
} }

View File

@ -30,6 +30,9 @@ export class OrganizationOrmMapper {
orm.addressCountry = props.address.country; orm.addressCountry = props.address.country;
orm.logoUrl = props.logoUrl || null; orm.logoUrl = props.logoUrl || null;
orm.documents = props.documents; orm.documents = props.documents;
orm.siret = props.siret || null;
orm.siretVerified = props.siretVerified;
orm.statusBadge = props.statusBadge;
orm.isActive = props.isActive; orm.isActive = props.isActive;
orm.createdAt = props.createdAt; orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt; orm.updatedAt = props.updatedAt;
@ -59,6 +62,9 @@ export class OrganizationOrmMapper {
}, },
logoUrl: orm.logoUrl || undefined, logoUrl: orm.logoUrl || undefined,
documents: orm.documents || [], documents: orm.documents || [],
siret: orm.siret || undefined,
siretVerified: orm.siretVerified ?? false,
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
isActive: orm.isActive, isActive: orm.isActive,
createdAt: orm.createdAt, createdAt: orm.createdAt,
updatedAt: orm.updatedAt, updatedAt: orm.updatedAt,

View File

@ -53,6 +53,6 @@ export class SubscriptionOrmMapper {
* Map array of ORM entities to domain entities * Map array of ORM entities to domain entities
*/ */
static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] { static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] {
return orms.map((orm) => this.toDomain(orm)); return orms.map(orm => this.toDomain(orm));
} }
} }

View File

@ -1,62 +1,62 @@
/** /**
* Migration: Create Cookie Consents Table * Migration: Create Cookie Consents Table
* GDPR compliant cookie preference storage * GDPR compliant cookie preference storage
*/ */
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCookieConsent1738100000000 implements MigrationInterface { export class CreateCookieConsent1738100000000 implements MigrationInterface {
name = 'CreateCookieConsent1738100000000'; name = 'CreateCookieConsent1738100000000';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
// Create cookie_consents table // Create cookie_consents table
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "cookie_consents" ( CREATE TABLE "cookie_consents" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL, "user_id" UUID NOT NULL,
"essential" BOOLEAN NOT NULL DEFAULT TRUE, "essential" BOOLEAN NOT NULL DEFAULT TRUE,
"functional" BOOLEAN NOT NULL DEFAULT FALSE, "functional" BOOLEAN NOT NULL DEFAULT FALSE,
"analytics" BOOLEAN NOT NULL DEFAULT FALSE, "analytics" BOOLEAN NOT NULL DEFAULT FALSE,
"marketing" BOOLEAN NOT NULL DEFAULT FALSE, "marketing" BOOLEAN NOT NULL DEFAULT FALSE,
"ip_address" VARCHAR(45) NULL, "ip_address" VARCHAR(45) NULL,
"user_agent" TEXT NULL, "user_agent" TEXT NULL,
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(), "created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id") ON DELETE CASCADE REFERENCES "users"("id") ON DELETE CASCADE
) )
`); `);
// Create index for fast user lookups // Create index for fast user lookups
await queryRunner.query(` await queryRunner.query(`
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
`); `);
// Add comments // Add comments
await queryRunner.query(` await queryRunner.query(`
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
`); `);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "cookie_consents"`); await queryRunner.query(`DROP TABLE "cookie_consents"`);
} }
} }

View File

@ -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<void> {
// 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<void> {
// 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"`
);
}
}

View File

@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCommissionFields1740000000002 implements MigrationInterface {
name = 'AddCommissionFields1740000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
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 $$;
`);
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSiretAndStatusBadgeToOrganizations1740000000003 implements MigrationInterface {
name = 'AddSiretAndStatusBadgeToOrganizations1740000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE "organizations"
DROP COLUMN "status_badge",
DROP COLUMN "siret_verified",
DROP COLUMN "siret"
`);
}
}

View File

@ -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<void> {
// 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<void> {
// 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'
`);
}
}

View File

@ -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<void> {
// 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<void> {
// 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'
`);
}
}

View File

@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "api_keys" CASCADE`);
}
}

View File

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

View File

@ -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<CsvBookingOrmEntity>
) {}
async countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number> {
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();
}
}

View File

@ -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<ApiKeyOrmEntity>
) {}
async save(apiKey: ApiKey): Promise<ApiKey> {
const orm = ApiKeyOrmMapper.toOrm(apiKey);
const saved = await this.repo.save(orm);
return ApiKeyOrmMapper.toDomain(saved);
}
async findById(id: string): Promise<ApiKey | null> {
const orm = await this.repo.findOne({ where: { id } });
return orm ? ApiKeyOrmMapper.toDomain(orm) : null;
}
async findByKeyHash(keyHash: string): Promise<ApiKey | null> {
const orm = await this.repo.findOne({ where: { keyHash } });
return orm ? ApiKeyOrmMapper.toDomain(orm) : null;
}
async findByOrganizationId(organizationId: string): Promise<ApiKey[]> {
const orms = await this.repo.find({
where: { organizationId },
order: { createdAt: 'DESC' },
});
return ApiKeyOrmMapper.toDomainMany(orms);
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
}

View File

@ -78,6 +78,10 @@ export class TypeOrmInvitationTokenRepository implements InvitationTokenReposito
return result.affected || 0; return result.affected || 0;
} }
async deleteById(id: string): Promise<void> {
await this.repository.delete({ id });
}
async update(invitationToken: InvitationToken): Promise<InvitationToken> { async update(invitationToken: InvitationToken): Promise<InvitationToken> {
const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken); const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken);
const updated = await this.repository.save(ormEntity); const updated = await this.repository.save(ormEntity);

View File

@ -16,7 +16,7 @@ import { LicenseOrmMapper } from '../mappers/license-orm.mapper';
export class TypeOrmLicenseRepository implements LicenseRepository { export class TypeOrmLicenseRepository implements LicenseRepository {
constructor( constructor(
@InjectRepository(LicenseOrmEntity) @InjectRepository(LicenseOrmEntity)
private readonly repository: Repository<LicenseOrmEntity>, private readonly repository: Repository<LicenseOrmEntity>
) {} ) {}
async save(license: License): Promise<License> { async save(license: License): Promise<License> {

View File

@ -16,7 +16,7 @@ import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper';
export class TypeOrmSubscriptionRepository implements SubscriptionRepository { export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
constructor( constructor(
@InjectRepository(SubscriptionOrmEntity) @InjectRepository(SubscriptionOrmEntity)
private readonly repository: Repository<SubscriptionOrmEntity>, private readonly repository: Repository<SubscriptionOrmEntity>
) {} ) {}
async save(subscription: Subscription): Promise<Subscription> { async save(subscription: Subscription): Promise<Subscription> {
@ -35,9 +35,7 @@ export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
return orm ? SubscriptionOrmMapper.toDomain(orm) : null; return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
} }
async findByStripeSubscriptionId( async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null> {
stripeSubscriptionId: string,
): Promise<Subscription | null> {
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } }); const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
return orm ? SubscriptionOrmMapper.toDomain(orm) : null; return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
} }

View File

@ -11,6 +11,8 @@ import {
StripePort, StripePort,
CreateCheckoutSessionInput, CreateCheckoutSessionInput,
CreateCheckoutSessionOutput, CreateCheckoutSessionOutput,
CreateCommissionCheckoutInput,
CreateCommissionCheckoutOutput,
CreatePortalSessionInput, CreatePortalSessionInput,
CreatePortalSessionOutput, CreatePortalSessionOutput,
StripeSubscriptionData, StripeSubscriptionData,
@ -42,50 +44,46 @@ export class StripeAdapter implements StripePort {
this.planPriceMap = new Map(); this.planPriceMap = new Map();
// Configure plan price IDs from environment // Configure plan price IDs from environment
const starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID'); const silverMonthly = this.configService.get<string>('STRIPE_SILVER_MONTHLY_PRICE_ID');
const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID'); const silverYearly = this.configService.get<string>('STRIPE_SILVER_YEARLY_PRICE_ID');
const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID'); const goldMonthly = this.configService.get<string>('STRIPE_GOLD_MONTHLY_PRICE_ID');
const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID'); const goldYearly = this.configService.get<string>('STRIPE_GOLD_YEARLY_PRICE_ID');
const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'); const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID');
const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID'); const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID');
if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER'); if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER');
if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER'); if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER');
if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO'); if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD');
if (proYearly) this.priceIdMap.set(proYearly, 'PRO'); if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD');
if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE'); if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM');
if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE'); if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM');
this.planPriceMap.set('STARTER', { this.planPriceMap.set('SILVER', {
monthly: starterMonthly || '', monthly: silverMonthly || '',
yearly: starterYearly || '', yearly: silverYearly || '',
}); });
this.planPriceMap.set('PRO', { this.planPriceMap.set('GOLD', {
monthly: proMonthly || '', monthly: goldMonthly || '',
yearly: proYearly || '', yearly: goldYearly || '',
}); });
this.planPriceMap.set('ENTERPRISE', { this.planPriceMap.set('PLATINIUM', {
monthly: enterpriseMonthly || '', monthly: platiniumMonthly || '',
yearly: enterpriseYearly || '', yearly: platiniumYearly || '',
}); });
} }
async createCheckoutSession( async createCheckoutSession(
input: CreateCheckoutSessionInput, input: CreateCheckoutSessionInput
): Promise<CreateCheckoutSessionOutput> { ): Promise<CreateCheckoutSessionOutput> {
const planPrices = this.planPriceMap.get(input.plan); const planPrices = this.planPriceMap.get(input.plan);
if (!planPrices) { if (!planPrices) {
throw new Error(`No price configuration for plan: ${input.plan}`); throw new Error(`No price configuration for plan: ${input.plan}`);
} }
const priceId = input.billingInterval === 'yearly' const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly;
? planPrices.yearly
: planPrices.monthly;
if (!priceId) { if (!priceId) {
throw new Error( throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`);
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
);
} }
const sessionParams: Stripe.Checkout.SessionCreateParams = { const sessionParams: Stripe.Checkout.SessionCreateParams = {
@ -119,7 +117,7 @@ export class StripeAdapter implements StripePort {
const session = await this.stripe.checkout.sessions.create(sessionParams); const session = await this.stripe.checkout.sessions.create(sessionParams);
this.logger.log( this.logger.log(
`Created checkout session ${session.id} for organization ${input.organizationId}`, `Created checkout session ${session.id} for organization ${input.organizationId}`
); );
return { return {
@ -128,9 +126,46 @@ export class StripeAdapter implements StripePort {
}; };
} }
async createPortalSession( async createCommissionCheckout(
input: CreatePortalSessionInput, input: CreateCommissionCheckoutInput
): Promise<CreatePortalSessionOutput> { ): Promise<CreateCommissionCheckoutOutput> {
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<CreatePortalSessionOutput> {
const session = await this.stripe.billingPortal.sessions.create({ const session = await this.stripe.billingPortal.sessions.create({
customer: input.customerId, customer: input.customerId,
return_url: input.returnUrl, return_url: input.returnUrl,
@ -211,13 +246,9 @@ export class StripeAdapter implements StripePort {
async constructWebhookEvent( async constructWebhookEvent(
payload: string | Buffer, payload: string | Buffer,
signature: string, signature: string
): Promise<StripeWebhookEvent> { ): Promise<StripeWebhookEvent> {
const event = this.stripe.webhooks.constructEvent( const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret);
payload,
signature,
this.webhookSecret,
);
return { return {
type: event.type, type: event.type,

View File

@ -7,6 +7,7 @@ import compression from 'compression';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
import type { Request, Response, NextFunction } from 'express';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
@ -19,6 +20,7 @@ async function bootstrap() {
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 4000); const port = configService.get<number>('PORT', 4000);
const apiPrefix = configService.get<string>('API_PREFIX', 'api/v1'); const apiPrefix = configService.get<string>('API_PREFIX', 'api/v1');
const isProduction = configService.get<string>('NODE_ENV') === 'production';
// Use Pino logger // Use Pino logger
app.useLogger(app.get(Logger)); app.useLogger(app.get(Logger));
@ -52,39 +54,76 @@ async function bootstrap() {
}) })
); );
// Swagger documentation // ─── Swagger documentation ────────────────────────────────────────────────
const config = new DocumentBuilder() const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
.setTitle('Xpeditis API') const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');
.setDescription( const swaggerEnabled = !isProduction || (Boolean(swaggerUser) && Boolean(swaggerPass));
'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();
const document = SwaggerModule.createDocument(app, config); if (swaggerEnabled) {
SwaggerModule.setup('api/docs', app, document, { // HTTP Basic Auth guard for Swagger routes when credentials are configured
customSiteTitle: 'Xpeditis API Documentation', if (swaggerUser && swaggerPass) {
customfavIcon: 'https://xpeditis.com/favicon.ico', const swaggerPaths = ['/api/docs', '/api/docs-json', '/api/docs-yaml'];
customCss: '.swagger-ui .topbar { display: none }', 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); 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(` console.log(`
🚢 Xpeditis API Server Running 🚢 Xpeditis API Server Running
API: http://localhost:${port}/${apiPrefix} ║ API: http://localhost:${port}/${apiPrefix}
Docs: http://localhost:${port}/api/docs ║ Docs: ${swaggerStatus}
`); `);
} }

View File

@ -350,21 +350,30 @@ export default function AboutPage() {
</motion.div> </motion.div>
<div className="relative"> <div className="relative">
{/* Timeline line */} {/* Timeline vertical rail + animated fill */}
<div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-1 h-full bg-brand-turquoise/20" /> <div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full bg-brand-turquoise/15 overflow-hidden">
<motion.div
initial={{ scaleY: 0 }}
animate={isTimelineInView ? { scaleY: 1 } : {}}
transition={{ duration: 2.2, delay: 0.2, ease: 'easeInOut' }}
style={{ transformOrigin: 'top' }}
className="absolute inset-0 bg-brand-turquoise/60"
/>
</div>
<div className="space-y-12"> <div className="space-y-12">
{timeline.map((item, index) => ( {timeline.map((item, index) => (
<motion.div <motion.div
key={index} key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }} initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
animate={isTimelineInView ? { opacity: 1, x: 0 } : {}} whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }} viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 0.7, ease: 'easeOut' }}
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`} className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
> >
<div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}> <div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}>
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block"> <div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
<div className="flex items-center space-x-3 mb-3"> <div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
<Calendar className="w-5 h-5 text-brand-turquoise" /> <Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{item.year}</span> <span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
</div> </div>
@ -372,9 +381,18 @@ export default function AboutPage() {
<p className="text-gray-600">{item.description}</p> <p className="text-gray-600">{item.description}</p>
</div> </div>
</div> </div>
<div className="hidden lg:flex items-center justify-center">
<div className="w-6 h-6 bg-brand-turquoise rounded-full border-4 border-white shadow-lg" /> {/* Animated center dot */}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true, amount: 0.6 }}
transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }}
className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30"
/>
</div> </div>
<div className="hidden lg:block flex-1" /> <div className="hidden lg:block flex-1" />
</motion.div> </motion.div>
))} ))}

View File

@ -13,8 +13,13 @@ import {
Building2, Building2,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
Shield,
Zap,
BookOpen,
ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { sendContactForm } from '@/lib/api/auth';
export default function ContactPage() { export default function ContactPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -33,21 +38,36 @@ export default function ContactPage() {
const heroRef = useRef(null); const heroRef = useRef(null);
const formRef = useRef(null); const formRef = useRef(null);
const contactRef = useRef(null); const contactRef = useRef(null);
const afterSubmitRef = useRef(null);
const quickAccessRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true }); const isHeroInView = useInView(heroRef, { once: true });
const isFormInView = useInView(formRef, { once: true }); const isFormInView = useInView(formRef, { once: true });
const isContactInView = useInView(contactRef, { 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setIsSubmitting(true); setIsSubmitting(true);
// Simulate form submission try {
await new Promise((resolve) => setTimeout(resolve, 1500)); await sendContactForm({
firstName: formData.firstName,
setIsSubmitting(false); lastName: formData.lastName,
setIsSubmitted(true); 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 = ( const handleChange = (
@ -65,7 +85,6 @@ export default function ContactPage() {
title: 'Email', title: 'Email',
description: 'Envoyez-nous un email', description: 'Envoyez-nous un email',
value: 'contact@xpeditis.com', value: 'contact@xpeditis.com',
link: 'mailto:contact@xpeditis.com',
color: 'from-blue-500 to-cyan-500', color: 'from-blue-500 to-cyan-500',
}, },
{ {
@ -73,7 +92,6 @@ export default function ContactPage() {
title: 'Téléphone', title: 'Téléphone',
description: 'Appelez-nous', description: 'Appelez-nous',
value: '+33 1 23 45 67 89', value: '+33 1 23 45 67 89',
link: 'tel:+33123456789',
color: 'from-green-500 to-emerald-500', color: 'from-green-500 to-emerald-500',
}, },
{ {
@ -81,15 +99,13 @@ export default function ContactPage() {
title: 'Chat en direct', title: 'Chat en direct',
description: 'Discutez avec notre équipe', description: 'Discutez avec notre équipe',
value: 'Disponible 24/7', value: 'Disponible 24/7',
link: '#chat',
color: 'from-purple-500 to-pink-500', color: 'from-purple-500 to-pink-500',
}, },
{ {
icon: Headphones, icon: Headphones,
title: 'Support', title: 'Support',
description: 'Centre d\'aide', description: 'Support client',
value: 'support.xpeditis.com', value: 'support@xpeditis.com',
link: 'https://support.xpeditis.com',
color: 'from-orange-500 to-red-500', color: 'from-orange-500 to-red-500',
}, },
]; ];
@ -103,22 +119,6 @@ export default function ContactPage() {
email: 'paris@xpeditis.com', email: 'paris@xpeditis.com',
isHQ: true, isHQ: true,
}, },
{
city: 'Rotterdam',
address: 'Wilhelminakade 123',
postalCode: '3072 AP Rotterdam, Netherlands',
phone: '+31 10 123 4567',
email: 'rotterdam@xpeditis.com',
isHQ: false,
},
{
city: 'Hambourg',
address: 'Am Sandtorkai 50',
postalCode: '20457 Hamburg, Germany',
phone: '+49 40 123 4567',
email: 'hamburg@xpeditis.com',
isHQ: false,
},
]; ];
const subjects = [ const subjects = [
@ -219,22 +219,20 @@ export default function ContactPage() {
{contactMethods.map((method, index) => { {contactMethods.map((method, index) => {
const IconComponent = method.icon; const IconComponent = method.icon;
return ( return (
<motion.a <motion.div
key={index} key={index}
href={method.link}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -5 }} className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
> >
<div <div
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`} className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4`}
> >
<IconComponent className="w-6 h-6 text-white" /> <IconComponent className="w-6 h-6 text-white" />
</div> </div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3> <h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
<p className="text-gray-500 text-sm mb-2">{method.description}</p> <p className="text-gray-500 text-sm mb-2">{method.description}</p>
<p className="text-brand-turquoise font-medium">{method.value}</p> <p className="text-brand-turquoise font-medium">{method.value}</p>
</motion.a> </motion.div>
); );
})} })}
</div> </div>
@ -438,9 +436,9 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}} animate={isFormInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
> >
<h2 className="text-3xl font-bold text-brand-navy mb-6">Nos bureaux</h2> <h2 className="text-3xl font-bold text-brand-navy mb-6">Notre bureau</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">
Retrouvez-nous dans nos bureaux à travers l'Europe ou contactez-nous par email. Retrouvez-nous à Paris ou contactez-nous par email.
</p> </p>
<div className="space-y-6"> <div className="space-y-6">
@ -526,34 +524,154 @@ export default function ContactPage() {
</div> </div>
</section> </section>
{/* Map Section */} {/* Section 1 : Ce qui se passe après l'envoi */}
<section className="py-20 bg-gray-50"> <section ref={afterSubmitRef} className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8 }}
className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden p-8 lg:p-12"
>
{/* Decorative blobs */}
<div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute -top-10 -left-10 w-64 h-64 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute -bottom-10 -right-10 w-64 h-64 bg-brand-green rounded-full blur-3xl" />
</div>
<div className="relative z-10">
<div className="flex items-center space-x-3 mb-2">
<div className="p-2 bg-brand-turquoise/20 rounded-lg">
<Mail className="w-5 h-5 text-brand-turquoise" />
</div>
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
Après votre envoi
</span>
</div>
<h2 className="text-2xl lg:text-3xl font-bold text-white mb-8">
Que se passe-t-il après l'envoi de votre message ?
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Notre engagement */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.2 }}
className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20"
>
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
</div>
<h3 className="text-lg font-bold text-white">Notre engagement</h3>
</div>
<p className="text-white/80 leading-relaxed">
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{' '}
<span className="text-brand-turquoise font-semibold">
sous 48 heures ouvrées.
</span>
</p>
</motion.div>
{/* Sécurité */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.35 }}
className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20"
>
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-brand-green" />
</div>
<h3 className="text-lg font-bold text-white">Sécurité</h3>
</div>
<p className="text-white/80 leading-relaxed">
Vos informations sont protégées et traitées conformément à notre{' '}
<a href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
politique de confidentialité
</a>
. Aucune donnée n'est partagée avec des tiers sans votre accord.
</p>
</motion.div>
</div>
</div>
</motion.div>
</div>
</section>
{/* Section 2 : Accès Rapide */}
<section ref={quickAccessRef} className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} animate={isQuickAccessInView ? { opacity: 1, y: 0 } : {}}
viewport={{ once: true }} transition={{ duration: 0.7 }}
transition={{ duration: 0.8 }}
className="text-center mb-12"
> >
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre présence en Europe</h2> <div className="text-center mb-10">
<p className="text-gray-600"> <span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
Des bureaux stratégiquement situés pour mieux vous servir Accès rapide
</p> </span>
</motion.div> <h2 className="text-2xl lg:text-3xl font-bold text-brand-navy mt-2">
Besoin d'une réponse immédiate ?
</h2>
</div>
<motion.div <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
initial={{ opacity: 0, y: 30 }} {/* Tarification instantanée */}
whileInView={{ opacity: 1, y: 0 }} <motion.div
viewport={{ once: true }} initial={{ opacity: 0, x: -30 }}
transition={{ duration: 0.8, delay: 0.2 }} animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
className="bg-white rounded-2xl shadow-lg overflow-hidden" transition={{ duration: 0.6, delay: 0.15 }}
> whileHover={{ y: -4 }}
<div className="aspect-[21/9] bg-gradient-to-br from-brand-navy/5 to-brand-turquoise/5 flex items-center justify-center"> className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 flex flex-col"
<div className="text-center"> >
<MapPin className="w-16 h-16 text-brand-turquoise mx-auto mb-4" /> <div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<p className="text-gray-500">Carte interactive bientôt disponible</p> <Zap className="w-7 h-7 text-white" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">Tarification instantanée</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
N'attendez pas notre retour pour vos prix. Utilisez notre moteur{' '}
<span className="font-semibold text-brand-navy">Click&amp;Ship</span> pour obtenir
une cotation de fret maritime en moins de 60 secondes.
</p>
<a
href="/dashboard"
className="inline-flex items-center justify-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-xl font-semibold hover:bg-brand-turquoise/90 transition-all group"
>
<span>Accéder au Dashboard</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</motion.div>
{/* Wiki Maritime */}
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.6, delay: 0.25 }}
whileHover={{ y: -4 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 flex flex-col"
>
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<BookOpen className="w-7 h-7 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">Aide rapide</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
Une question sur les Incoterms ou la documentation export ? Notre{' '}
<span className="font-semibold text-brand-navy">Wiki Maritime</span> contient déjà
les réponses aux questions les plus fréquentes.
</p>
<a
href="/dashboard/wiki"
className="inline-flex items-center justify-center space-x-2 px-6 py-3 border-2 border-brand-navy text-brand-navy rounded-xl font-semibold hover:bg-brand-navy hover:text-white transition-all group"
>
<span>Consulter le Wiki</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</motion.div>
</div> </div>
</motion.div> </motion.div>
</div> </div>

View File

@ -1,413 +1,548 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getAllBookings } from '@/lib/api/admin'; import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
interface Booking { interface Booking {
id: string; id: string;
bookingNumber?: string; bookingNumber?: string | null;
bookingId?: string; type?: string;
type?: string; status: string;
status: string; origin?: string;
// CSV bookings use these fields destination?: string;
origin?: string; carrierName?: string;
destination?: string; containerType: string;
carrierName?: string; volumeCBM?: number;
// Regular bookings use these fields weightKG?: number;
originPort?: { palletCount?: number;
code: string; priceEUR?: number;
name: string; priceUSD?: number;
}; primaryCurrency?: string;
destinationPort?: { createdAt?: string;
code: string; requestedAt?: string;
name: string; updatedAt?: string;
}; organizationId?: string;
carrier?: string; userId?: string;
containerType: string; }
quantity?: number;
price?: number; export default function AdminBookingsPage() {
primaryCurrency?: string; const [bookings, setBookings] = useState<Booking[]>([]);
totalPrice?: { const [loading, setLoading] = useState(true);
amount: number; const [error, setError] = useState<string | null>(null);
currency: string; const [filterStatus, setFilterStatus] = useState('all');
}; const [searchTerm, setSearchTerm] = useState('');
createdAt?: string; const [validatingId, setValidatingId] = useState<string | null>(null);
updatedAt?: string; const [deletingId, setDeletingId] = useState<string | null>(null);
requestedAt?: string; const [openMenuId, setOpenMenuId] = useState<string | null>(null);
organizationId?: string; const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
userId?: string; const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
} const [showDetailsModal, setShowDetailsModal] = useState(false);
export default function AdminBookingsPage() { useEffect(() => {
const [bookings, setBookings] = useState<Booking[]>([]); fetchBookings();
const [loading, setLoading] = useState(true); }, []);
const [error, setError] = useState<string | null>(null);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null); const handleDeleteBooking = async (bookingId: string) => {
const [showDetailsModal, setShowDetailsModal] = useState(false); if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
const [filterStatus, setFilterStatus] = useState('all'); setDeletingId(bookingId);
const [searchTerm, setSearchTerm] = useState(''); try {
await deleteAdminBooking(bookingId);
// Helper function to get formatted quote number setBookings(prev => prev.filter(b => b.id !== bookingId));
const getQuoteNumber = (booking: Booking): string => { } catch (err: any) {
if (booking.type === 'csv') { setError(err.message || 'Erreur lors de la suppression');
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; } finally {
} setDeletingId(null);
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`; }
}; };
useEffect(() => { const handleValidateTransfer = async (bookingId: string) => {
fetchBookings(); if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
}, []); setValidatingId(bookingId);
try {
const fetchBookings = async () => { await validateBankTransfer(bookingId);
try { await fetchBookings();
setLoading(true); } catch (err: any) {
const response = await getAllBookings(); setError(err.message || 'Erreur lors de la validation du virement');
setBookings(response.bookings || []); } finally {
setError(null); setValidatingId(null);
} catch (err: any) { }
setError(err.message || 'Failed to load bookings'); };
} finally {
setLoading(false); const fetchBookings = async () => {
} try {
}; setLoading(true);
const response = await getAllBookings();
const getStatusColor = (status: string) => { setBookings(response.bookings || []);
const colors: Record<string, string> = { setError(null);
draft: 'bg-gray-100 text-gray-800', } catch (err: any) {
pending: 'bg-yellow-100 text-yellow-800', setError(err.message || 'Impossible de charger les réservations');
confirmed: 'bg-blue-100 text-blue-800', } finally {
in_transit: 'bg-purple-100 text-purple-800', setLoading(false);
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 getStatusColor = (status: string) => {
}; const colors: Record<string, string> = {
pending_payment: 'bg-orange-100 text-orange-800',
const filteredBookings = bookings pending_bank_transfer: 'bg-amber-100 text-amber-900',
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) pending: 'bg-yellow-100 text-yellow-800',
.filter(booking => { accepted: 'bg-green-100 text-green-800',
if (searchTerm === '') return true; rejected: 'bg-red-100 text-red-800',
const searchLower = searchTerm.toLowerCase(); cancelled: 'bg-red-100 text-red-800',
const quoteNumber = getQuoteNumber(booking).toLowerCase(); };
return ( return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
quoteNumber.includes(searchLower) || };
booking.bookingNumber?.toLowerCase().includes(searchLower) ||
booking.carrier?.toLowerCase().includes(searchLower) || const getStatusLabel = (status: string) => {
booking.carrierName?.toLowerCase().includes(searchLower) || const labels: Record<string, string> = {
booking.origin?.toLowerCase().includes(searchLower) || PENDING_PAYMENT: 'Paiement en attente',
booking.destination?.toLowerCase().includes(searchLower) PENDING_BANK_TRANSFER: 'Virement à valider',
); PENDING: 'En attente transporteur',
}); ACCEPTED: 'Accepté',
REJECTED: 'Rejeté',
if (loading) { CANCELLED: 'Annulé',
return ( };
<div className="flex items-center justify-center h-96"> return labels[status.toUpperCase()] || status;
<div className="text-center"> };
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading bookings...</p> const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
</div>
</div> const filteredBookings = bookings
); .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
} .filter(booking => {
if (searchTerm === '') return true;
return ( const s = searchTerm.toLowerCase();
<div className="space-y-6"> return (
{/* Header */} booking.bookingNumber?.toLowerCase().includes(s) ||
<div className="flex items-center justify-between"> booking.id.toLowerCase().includes(s) ||
<div> booking.carrierName?.toLowerCase().includes(s) ||
<h1 className="text-2xl font-bold text-gray-900">Booking Management</h1> booking.origin?.toLowerCase().includes(s) ||
<p className="mt-1 text-sm text-gray-500"> booking.destination?.toLowerCase().includes(s) ||
View and manage all bookings across the platform String(booking.palletCount || '').includes(s) ||
</p> String(booking.weightKG || '').includes(s) ||
</div> String(booking.volumeCBM || '').includes(s) ||
</div> booking.containerType?.toLowerCase().includes(s)
);
{/* Filters */} });
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> if (loading) {
<div> return (
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label> <div className="flex items-center justify-center h-96">
<input <div className="text-center">
type="text" <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
placeholder="Search by booking number or carrier..." <p className="mt-4 text-gray-600">Chargement des réservations...</p>
value={searchTerm} </div>
onChange={e => setSearchTerm(e.target.value)} </div>
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" );
/> }
</div>
<div> return (
<label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label> <div className="space-y-6">
<select {/* Header */}
value={filterStatus} <div>
onChange={e => setFilterStatus(e.target.value)} <h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
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" <p className="mt-1 text-sm text-gray-500">
> Toutes les réservations de la plateforme
<option value="all">All Statuses</option> </p>
<option value="draft">Draft</option> </div>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option> {/* Stats Cards */}
<option value="in_transit">In Transit</option> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<option value="delivered">Delivered</option> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<option value="cancelled">Cancelled</option> <div className="text-xs text-gray-500 uppercase tracking-wide">Total</div>
</select> <div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
</div> </div>
</div> <div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
</div> <div className="text-xs text-amber-700 uppercase tracking-wide">Virements à valider</div>
<div className="text-2xl font-bold text-amber-700 mt-1">
{/* Stats Cards */} {bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> </div>
<div className="text-sm text-gray-500">Total Réservations</div> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div> <div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
</div> <div className="text-2xl font-bold text-yellow-600 mt-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
<div className="text-sm text-gray-500">En Attente</div> </div>
<div className="text-2xl font-bold text-yellow-600"> </div>
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
</div> <div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div>
</div> <div className="text-2xl font-bold text-green-600 mt-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
<div className="text-sm text-gray-500">Acceptées</div> </div>
<div className="text-2xl font-bold text-green-600"> </div>
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
</div> <div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div>
</div> <div className="text-2xl font-bold text-red-600 mt-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
<div className="text-sm text-gray-500">Rejetées</div> </div>
<div className="text-2xl font-bold text-red-600"> </div>
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} </div>
</div>
</div> {/* Filters */}
</div> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Error Message */} <div>
{error && ( <label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg"> <input
{error} type="text"
</div> placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
)} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
{/* Bookings Table */} 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"
<div className="bg-white rounded-lg shadow overflow-hidden"> />
<table className="min-w-full divide-y divide-gray-200"> </div>
<thead className="bg-gray-50"> <div>
<tr> <label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <select
Numéro de devis value={filterStatus}
</th> onChange={e => setFilterStatus(e.target.value)}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 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"
Route >
</th> <option value="all">Tous les statuts</option>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <option value="pending_bank_transfer">Virement à valider</option>
Transporteur <option value="pending_payment">Paiement en attente</option>
</th> <option value="pending">En attente transporteur</option>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <option value="accepted">Accepté</option>
Conteneur <option value="rejected">Rejeté</option>
</th> <option value="cancelled">Annulé</option>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </select>
Statut </div>
</th> </div>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </div>
Prix
</th> {/* Error Message */}
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> {error && (
Actions <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
</th> {error}
</tr> </div>
</thead> )}
<tbody className="bg-white divide-y divide-gray-200">
{filteredBookings.map(booking => ( {/* Bookings Table */}
<tr key={booking.id} className="hover:bg-gray-50"> <div className="bg-white rounded-lg shadow overflow-hidden">
<td className="px-6 py-4 whitespace-nowrap"> <div className="overflow-x-auto">
<div className="text-sm font-medium text-gray-900"> <table className="min-w-full divide-y divide-gray-200">
{getQuoteNumber(booking)} <thead className="bg-gray-50">
</div> <tr>
<div className="text-xs text-gray-500"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()} N° Booking
</div> </th>
</td> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<td className="px-6 py-4 whitespace-nowrap"> Route
<div className="text-sm text-gray-900"> </th>
{booking.originPort ? `${booking.originPort.code}${booking.destinationPort?.code}` : `${booking.origin}${booking.destination}`} <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</div> Cargo
<div className="text-xs text-gray-500"> </th>
{booking.originPort ? `${booking.originPort.name}${booking.destinationPort?.name}` : ''} <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</div> Transporteur
</td> </th>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{booking.carrier || booking.carrierName || 'N/A'} Statut
</td> </th>
<td className="px-6 py-4 whitespace-nowrap"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="text-sm text-gray-900">{booking.containerType}</div> Date
<div className="text-xs text-gray-500"> </th>
{booking.quantity ? `Qty: ${booking.quantity}` : ''} <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</div> Actions
</td> </th>
<td className="px-6 py-4 whitespace-nowrap"> </tr>
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}> </thead>
{booking.status} <tbody className="bg-white divide-y divide-gray-200">
</span> {filteredBookings.length === 0 ? (
</td> <tr>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
{booking.totalPrice Aucune réservation trouvée
? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}` </td>
: booking.price </tr>
? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}` ) : (
: 'N/A' filteredBookings.map(booking => (
} <tr key={booking.id} className="hover:bg-gray-50">
</td> {/* N° Booking */}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-4 py-4 whitespace-nowrap">
<button {booking.bookingNumber && (
onClick={() => { <div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div>
setSelectedBooking(booking); )}
setShowDetailsModal(true); <div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
}} </td>
className="text-blue-600 hover:text-blue-900"
> {/* Route */}
View Details <td className="px-4 py-4 whitespace-nowrap">
</button> <div className="text-sm font-medium text-gray-900">
</td> {booking.origin} {booking.destination}
</tr> </div>
))} </td>
</tbody>
</table> {/* Cargo */}
</div> <td className="px-4 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{/* Details Modal */} {booking.containerType}
{showDetailsModal && selectedBooking && ( {booking.palletCount != null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto"> <span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4"> )}
<div className="flex items-center justify-between mb-6"> </div>
<h2 className="text-xl font-bold">Booking Details</h2> <div className="text-xs text-gray-500 space-x-2">
<button {booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
onClick={() => { {booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
setShowDetailsModal(false); </div>
setSelectedBooking(null); </td>
}}
className="text-gray-400 hover:text-gray-600" {/* Transporteur */}
> <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {booking.carrierName || '—'}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </td>
</svg>
</button> {/* Statut */}
</div> <td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
<div className="space-y-4"> {getStatusLabel(booking.status)}
<div className="grid grid-cols-2 gap-4"> </span>
<div> </td>
<label className="block text-sm font-medium text-gray-500">Numéro de devis</label>
<div className="mt-1 text-lg font-semibold"> {/* Date */}
{getQuoteNumber(selectedBooking)} <td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
</div> {new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
</div> </td>
<div>
<label className="block text-sm font-medium text-gray-500">Statut</label> {/* Actions */}
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}> <td className="px-4 py-4 whitespace-nowrap text-right text-sm">
{selectedBooking.status} <button
</span> onClick={(e) => {
</div> if (openMenuId === booking.id) {
</div> setOpenMenuId(null);
setMenuPosition(null);
<div className="border-t pt-4"> } else {
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3> const rect = e.currentTarget.getBoundingClientRect();
<div className="grid grid-cols-2 gap-4"> setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
<div> setOpenMenuId(booking.id);
<label className="block text-sm font-medium text-gray-500">Origin</label> }
<div className="mt-1"> }}
{selectedBooking.originPort ? ( className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
<> >
<div className="font-semibold">{selectedBooking.originPort.code}</div> <svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div> <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</> </svg>
) : ( </button>
<div className="font-semibold">{selectedBooking.origin}</div> </td>
)} </tr>
</div> ))
</div> )}
<div> </tbody>
<label className="block text-sm font-medium text-gray-500">Destination</label> </table>
<div className="mt-1"> </div>
{selectedBooking.destinationPort ? ( </div>
<> {/* Actions Dropdown Menu */}
<div className="font-semibold">{selectedBooking.destinationPort.code}</div> {openMenuId && menuPosition && (
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div> <>
</> <div
) : ( className="fixed inset-0 z-[998]"
<div className="font-semibold">{selectedBooking.destination}</div> onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
)} />
</div> <div
</div> className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
</div> style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
</div> >
<div className="py-2">
<div className="border-t pt-4"> <button
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3> onClick={() => {
<div className="grid grid-cols-3 gap-4"> const booking = bookings.find(b => b.id === openMenuId);
<div> if (booking) {
<label className="block text-sm font-medium text-gray-500">Carrier</label> setSelectedBooking(booking);
<div className="mt-1 font-semibold"> setShowDetailsModal(true);
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'} }
</div> setOpenMenuId(null);
</div> setMenuPosition(null);
<div> }}
<label className="block text-sm font-medium text-gray-500">Container Type</label> className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div> >
</div> <svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{selectedBooking.quantity && ( <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<div> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<label className="block text-sm font-medium text-gray-500">Quantity</label> </svg>
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div> <span className="text-sm font-medium text-gray-700">Voir les détails</span>
</div> </button>
)} {(() => {
</div> const booking = bookings.find(b => b.id === openMenuId);
</div> return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? (
<button
<div className="border-t pt-4"> onClick={() => {
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3> const id = openMenuId;
<div className="text-2xl font-bold text-blue-600"> setOpenMenuId(null);
{selectedBooking.totalPrice setMenuPosition(null);
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}` if (id) handleValidateTransfer(id);
: selectedBooking.price }}
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}` disabled={validatingId === openMenuId}
: 'N/A' className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
} >
</div> <svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="border-t pt-4"> <span className="text-sm font-medium text-green-700">Valider virement</span>
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3> </button>
<div className="grid grid-cols-2 gap-4 text-sm"> ) : null;
<div> })()}
<label className="block text-gray-500">Created</label> <button
<div className="mt-1"> onClick={() => {
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()} const id = openMenuId;
</div> setOpenMenuId(null);
</div> setMenuPosition(null);
{selectedBooking.updatedAt && ( if (id) handleDeleteBooking(id);
<div> }}
<label className="block text-gray-500">Last Updated</label> disabled={deletingId === openMenuId}
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div> className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
</div> >
)} <svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</div> </svg>
</div> <span className="text-sm font-medium text-red-600">Supprimer</span>
</button>
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t"> </div>
<button </div>
onClick={() => { </>
setShowDetailsModal(false); )}
setSelectedBooking(null);
}} {/* Details Modal */}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50" {showDetailsModal && selectedBooking && (
> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
Close <div className="bg-white rounded-lg p-6 max-w-2xl w-full">
</button> <div className="flex items-center justify-between mb-6">
</div> <h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
</div> <button
</div> onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
)} className="text-gray-400 hover:text-gray-600"
</div> >
); <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">N° Booking</label>
<div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Statut</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
{getStatusLabel(selectedBooking.status)}
</span>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origine</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Type conteneur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
</div>
{selectedBooking.palletCount != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Palettes</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
</div>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Poids</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Volume</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
</div>
)}
</div>
</div>
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Prix</h3>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && (
<div>
<label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} </div>
</div>
)}
{selectedBooking.priceUSD != null && (
<div>
<label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
</div>
)}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Créée le</label>
<div className="mt-1 text-gray-900">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">Mise à jour</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
</div>
)}
</div>
</div>
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
<div className="border-t pt-4">
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
handleValidateTransfer(selectedBooking.id);
}}
disabled={validatingId === selectedBooking.id}
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{validatingId === selectedBooking.id ? 'Validation...' : '✓ Valider le virement'}
</button>
</div>
)}
</div>
<div className="flex justify-end mt-6 pt-4 border-t">
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; 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'; import { createOrganization, updateOrganization } from '@/lib/api/organizations';
interface Organization { interface Organization {
@ -10,6 +10,9 @@ interface Organization {
type: string; type: string;
scac?: string; scac?: string;
siren?: string; siren?: string;
siret?: string;
siretVerified?: boolean;
statusBadge?: string;
eori?: string; eori?: string;
contact_phone?: string; contact_phone?: string;
contact_email?: string; contact_email?: string;
@ -32,6 +35,7 @@ export default function AdminOrganizationsPage() {
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null); const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [verifyingId, setVerifyingId] = useState<string | null>(null);
// Form state // Form state
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
@ -39,6 +43,7 @@ export default function AdminOrganizationsPage() {
type: string; type: string;
scac: string; scac: string;
siren: string; siren: string;
siret: string;
eori: string; eori: string;
contact_phone: string; contact_phone: string;
contact_email: string; contact_email: string;
@ -55,6 +60,7 @@ export default function AdminOrganizationsPage() {
type: 'FREIGHT_FORWARDER', type: 'FREIGHT_FORWARDER',
scac: '', scac: '',
siren: '', siren: '',
siret: '',
eori: '', eori: '',
contact_phone: '', contact_phone: '',
contact_email: '', contact_email: '',
@ -130,6 +136,7 @@ export default function AdminOrganizationsPage() {
type: 'FREIGHT_FORWARDER', type: 'FREIGHT_FORWARDER',
scac: '', scac: '',
siren: '', siren: '',
siret: '',
eori: '', eori: '',
contact_phone: '', contact_phone: '',
contact_email: '', 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) => { const openEditModal = (org: Organization) => {
setSelectedOrg(org); setSelectedOrg(org);
setFormData({ setFormData({
@ -151,6 +203,7 @@ export default function AdminOrganizationsPage() {
type: org.type, type: org.type,
scac: org.scac || '', scac: org.scac || '',
siren: org.siren || '', siren: org.siren || '',
siret: org.siret || '',
eori: org.eori || '', eori: org.eori || '',
contact_phone: org.contact_phone || '', contact_phone: org.contact_phone || '',
contact_email: org.contact_email || '', contact_email: org.contact_email || '',
@ -229,6 +282,25 @@ export default function AdminOrganizationsPage() {
<span className="font-medium">SIREN:</span> {org.siren} <span className="font-medium">SIREN:</span> {org.siren}
</div> </div>
)} )}
<div className="flex items-center gap-2">
<span className="font-medium">SIRET:</span>
{org.siret ? (
<>
<span>{org.siret}</span>
{org.siretVerified ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Verifie
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Non verifie
</span>
)}
</>
) : (
<span className="text-gray-400">Non renseigne</span>
)}
</div>
{org.contact_email && ( {org.contact_email && (
<div> <div>
<span className="font-medium">Email:</span> {org.contact_email} <span className="font-medium">Email:</span> {org.contact_email}
@ -239,13 +311,45 @@ export default function AdminOrganizationsPage() {
</div> </div>
</div> </div>
<div className="flex space-x-2"> <div className="space-y-2">
<button <div className="flex space-x-2">
onClick={() => openEditModal(org)} <button
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium" onClick={() => openEditModal(org)}
> className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
Edit >
</button> Edit
</button>
{org.siret && !org.siretVerified && (
<button
onClick={() => handleVerifySiret(org.id)}
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
>
{verifyingId === org.id ? '...' : 'Verifier API'}
</button>
)}
</div>
{(org.siret || org.siren) && (
<div className="flex space-x-2">
{!org.siretVerified ? (
<button
onClick={() => handleApproveSiret(org.id)}
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
>
Approuver SIRET
</button>
) : (
<button
onClick={() => handleRejectSiret(org.id)}
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
>
Rejeter SIRET
</button>
)}
</div>
)}
</div> </div>
</div> </div>
))} ))}
@ -309,6 +413,18 @@ export default function AdminOrganizationsPage() {
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
<input
type="text"
maxLength={14}
value={formData.siret}
onChange={e => 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"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">EORI</label> <label className="block text-sm font-medium text-gray-700">EORI</label>
<input <input

View File

@ -0,0 +1,437 @@
/**
* Commission Payment Page
*
* 2-column layout:
* - Left: payment method selector + action
* - Right: booking summary
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
CreditCard,
Building2,
ArrowLeft,
Loader2,
AlertTriangle,
CheckCircle,
Copy,
Clock,
} from 'lucide-react';
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceEUR: number;
priceUSD: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
status: string;
commissionRate?: number;
commissionAmountEur?: number;
}
type PaymentMethod = 'card' | 'transfer' | null;
const BANK_DETAILS = {
beneficiary: 'XPEDITIS SAS',
iban: 'FR76 XXXX XXXX XXXX XXXX XXXX XXX',
bic: 'XXXXXXXX',
};
export default function PayCommissionPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const [booking, setBooking] = useState<BookingData | null>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [declaring, setDeclaring] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
const [copied, setCopied] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
</div>
</div>
);
}
if (error && !booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux bookings
</button>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-5xl mx-auto">
{/* Back button */}
<button
onClick={() => router.push('/dashboard/bookings')}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux bookings
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement de la commission</h1>
<p className="text-gray-500 mb-8">
Finalisez votre booking en réglant la commission de service
</p>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* LEFT — Payment method selector */}
<div className="lg:col-span-3 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
Choisir le mode de paiement
</h2>
{/* Card option */}
<button
onClick={() => setSelectedMethod('card')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'card'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'card' ? 'bg-blue-100' : 'bg-gray-100'
}`}
>
<CreditCard
className={`h-5 w-5 ${
selectedMethod === 'card' ? 'text-blue-600' : 'text-gray-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Carte bancaire</p>
<p className="text-sm text-gray-500">Paiement immédiat via Stripe</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
}`}
>
{selectedMethod === 'card' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
</div>
</button>
{/* Transfer option */}
<button
onClick={() => setSelectedMethod('transfer')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'transfer' ? 'bg-blue-100' : 'bg-gray-100'
}`}
>
<Building2
className={`h-5 w-5 ${
selectedMethod === 'transfer' ? 'text-blue-600' : 'text-gray-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Virement bancaire</p>
<p className="text-sm text-gray-500">Validation sous 13 jours ouvrables</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
}`}
>
{selectedMethod === 'transfer' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
</div>
</button>
{/* Card action */}
{selectedMethod === 'card' && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<p className="text-sm text-gray-600 mb-4">
Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité.
</p>
<button
onClick={handlePayByCard}
disabled={paying}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{paying ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Redirection vers Stripe...</span>
</>
) : (
<>
<CreditCard className="h-5 w-5" />
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span>
</>
)}
</button>
</div>
)}
{/* Transfer action */}
{selectedMethod === 'transfer' && (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<p className="text-sm text-gray-600">
Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur
&ldquo;J&apos;ai effectué le virement&rdquo;.
</p>
{/* Bank details */}
<div className="bg-gray-50 rounded-lg divide-y divide-gray-200 text-sm">
{[
{ 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 }) => (
<div key={key} className="flex items-center justify-between px-4 py-3">
<span className="text-gray-500">{label}</span>
<div className="flex items-center space-x-2">
<span
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-gray-900' : 'text-gray-800'}`}
>
{value}
</span>
{key !== 'amount' && (
<button
onClick={() => copyToClipboard(value, key)}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Copier"
>
{copied === key ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-start space-x-2 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
<Clock className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>
Mentionnez impérativement la référence <strong>{reference}</strong> dans le
libellé du virement.
</span>
</div>
<button
onClick={handleDeclareTransfer}
disabled={declaring}
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{declaring ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Enregistrement...</span>
</>
) : (
<>
<CheckCircle className="h-5 w-5" />
<span>J&apos;ai effectué le virement</span>
</>
)}
</button>
</div>
)}
{/* Placeholder when no method selected */}
{selectedMethod === null && (
<div className="bg-white rounded-xl border-2 border-dashed border-gray-200 p-6 text-center text-gray-400 text-sm">
Sélectionnez un mode de paiement ci-dessus
</div>
)}
</div>
{/* RIGHT — Booking summary */}
<div className="lg:col-span-2 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
Récapitulatif
</h2>
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
{booking.bookingNumber && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Numéro</span>
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transporteur</span>
<span className="font-semibold text-gray-900 text-right max-w-[55%]">
{booking.carrierName}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Trajet</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Volume / Poids</span>
<span className="font-semibold text-gray-900">
{booking.volumeCBM} CBM · {booking.weightKG} kg
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
<div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Prix transport</span>
<span className="font-bold text-gray-900">
{formatPrice(booking.priceEUR, 'EUR')}
</span>
</div>
</div>
{/* Commission box */}
<div className="bg-blue-600 rounded-xl p-5 text-white">
<p className="text-sm text-blue-100 mb-1">
Commission ({commissionRate}% du prix transport)
</p>
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
<p className="text-xs text-blue-200 mt-1">
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}%
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-500">
Après validation du paiement, votre demande est envoyée au transporteur (
{booking.carrierEmail}). Vous serez notifié de sa réponse.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Session invalide</h2>
<p className="text-gray-600 mb-4">Aucune session de paiement trouvee.</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux bookings
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
{status === 'confirming' && (
<>
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
<p className="text-gray-600">
Veuillez patienter pendant que nous verifions votre paiement et activons votre booking.
</p>
</>
)}
{status === 'success' && (
<>
<div className="mb-6">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
<p className="text-gray-600 mb-6">
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center space-x-2 text-blue-700">
<Mail className="h-5 w-5" />
<span className="text-sm font-medium">
Email envoye au transporteur
</span>
</div>
<p className="text-xs text-blue-600 mt-1">
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
</p>
</div>
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold flex items-center justify-center"
>
Voir mes bookings
<ArrowRight className="h-4 w-4 ml-2" />
</button>
</>
)}
{status === 'error' && (
<>
<AlertTriangle className="h-16 w-16 text-red-500 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
<p className="text-gray-600 mb-2">{error}</p>
<p className="text-sm text-gray-500 mb-6">
Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement.
</p>
<div className="space-y-3">
<button
onClick={() => {
confirmedRef.current = false;
setStatus('confirming');
setError(null);
confirmBookingPayment(bookingId, sessionId!)
.then(() => setStatus('success'))
.catch(err => {
setError(err instanceof Error ? err.message : 'Erreur');
setStatus('error');
});
}}
className="w-full px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Reessayer
</button>
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Retour aux bookings
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -177,8 +177,8 @@ function NewBookingPageContent() {
// Send to API using client function // Send to API using client function
const result = await createCsvBooking(formDataToSend); const result = await createCsvBooking(formDataToSend);
// Redirect to success page // Redirect to commission payment page
router.push(`/dashboard/bookings?success=true&id=${result.id}`); router.push(`/dashboard/booking/${result.id}/pay`);
} catch (err) { } catch (err) {
console.error('Booking creation error:', err); console.error('Booking creation error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue'); setError(err instanceof Error ? err.message : 'Une erreur est survenue');

View File

@ -6,22 +6,31 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api'; import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link'; import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus, Clock } from 'lucide-react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() { export default function BookingsListPage() {
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route'); const [searchType, setSearchType] = useState<SearchType>('route');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false);
const ITEMS_PER_PAGE = 20; 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) // Fetch CSV bookings (fetch all for client-side filtering and pagination)
const { data: csvData, isLoading, error: csvError } = useQuery({ const { data: csvData, isLoading, error: csvError } = useQuery({
queryKey: ['csv-bookings'], queryKey: ['csv-bookings'],
@ -142,6 +151,21 @@ export default function BookingsListPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Bank transfer declared banner */}
{showTransferBanner && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
<div className="flex items-start space-x-3">
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">Virement déclaré</p>
<p className="text-sm text-amber-700 mt-0.5">
Votre virement a é enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
</p>
</div>
</div>
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button>
</div>
)}
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View File

@ -0,0 +1,7 @@
'use client';
import { DocsPageContent } from '@/components/docs/DocsPageContent';
export default function DocsPage() {
return <DocsPageContent basePath="/dashboard/docs" variant="dashboard" />;
}

Some files were not shown because too many files have changed in this diff Show More