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
# Backend (from apps/backend/)
npm test # Unit tests (Jest)
npm test -- booking.entity.spec.ts # Single file
npm run test:cov # With coverage
npm test # Unit tests (Jest)
npm test -- booking.entity.spec.ts # Single file
npm test -- --testNamePattern="should create" # Filter by test name
npm run test:cov # With coverage
npm run test:integration # Integration tests (needs DB/Redis, 30s timeout)
npm run test:e2e # E2E tests
@ -75,6 +76,7 @@ npm run migration:revert
```bash
npm run backend:build # NestJS build with tsc-alias for path resolution
npm run frontend:build # Next.js production build (standalone output)
npm run clean # Remove all node_modules, dist, .next directories
```
## Local Infrastructure
@ -84,6 +86,8 @@ Docker-compose defaults (no `.env` changes needed for local dev):
- **Redis**: password `xpeditis_redis_password`, port 6379
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001
Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`) — configured in `next.config.js`.
## Architecture
### Hexagonal Architecture (Backend)
@ -91,15 +95,32 @@ Docker-compose defaults (no `.env` changes needed for local dev):
```
apps/backend/src/
├── domain/ # CORE - Pure TypeScript, NO framework imports
│ ├── entities/ # Booking, RateQuote, User, Carrier, Port, Container, CsvBooking, etc.
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType, Volume, LicenseStatus, SubscriptionPlan, etc.
│ ├── services/ # Pure domain services (rate-search, csv-rate-price-calculator, booking, port-search, etc.)
│ ├── entities/ # Booking, RateQuote, Carrier, Port, Container, Notification, Webhook,
│ │ # AuditLog, User, Organization, Subscription, License, CsvBooking,
│ │ # CsvRate, InvitationToken
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType,
│ │ # Volume, DateRange, Surcharge
│ ├── services/ # Pure domain services (csv-rate-price-calculator)
│ ├── ports/
│ │ ├── in/ # Use case interfaces with execute() method
│ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository')
│ └── exceptions/ # Domain-specific exceptions
├── application/ # Controllers, DTOs (class-validator), Guards, Decorators, Mappers
└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs, MinIO/S3, email (MJML+Nodemailer), Sentry
│ ├── [feature]/ # Feature modules: auth/, bookings/, csv-bookings, rates/, ports/,
│ │ # organizations/, users/, dashboard/, audit/, notifications/, webhooks/,
│ │ # gdpr/, admin/, subscriptions/
│ ├── controllers/ # REST controllers (also nested under feature folders)
│ ├── services/ # Application services: audit, notification, webhook,
│ │ # booking-automation, export, fuzzy-search, brute-force-protection
│ ├── gateways/ # WebSocket gateways (notifications.gateway.ts via Socket.IO)
│ ├── guards/ # JwtAuthGuard, RolesGuard, CustomThrottlerGuard
│ ├── decorators/ # @Public(), @Roles(), @CurrentUser()
│ ├── dto/ # Request/response DTOs with class-validator
│ ├── mappers/ # Domain ↔ DTO mappers
│ └── interceptors/ # PerformanceMonitoringInterceptor
└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs,
# MinIO/S3, email (MJML+Nodemailer), Stripe, Sentry,
# Pappers (French SIRET registry), PDF generation
```
**Critical dependency rules**:
@ -108,6 +129,7 @@ apps/backend/src/
- Path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` (defined in `apps/backend/tsconfig.json`)
- Domain tests run without NestJS TestingModule
- Backend has strict TypeScript: `strict: true`, `strictNullChecks: true` (but `strictPropertyInitialization: false`)
- Env vars validated at startup via Joi schema in `app.module.ts` — required vars include DATABASE_*, REDIS_*, JWT_SECRET, SMTP_*
### NestJS Modules (app.module.ts)
@ -115,31 +137,36 @@ Global guards: JwtAuthGuard (all routes protected by default), CustomThrottlerGu
Feature modules: Auth, Rates, Ports, Bookings, CsvBookings, Organizations, Users, Dashboard, Audit, Notifications, Webhooks, GDPR, Admin, Subscriptions.
Infrastructure modules: CacheModule, CarrierModule, SecurityModule, CsvRateModule.
Infrastructure modules: CacheModule, CarrierModule, SecurityModule, CsvRateModule, StripeModule, PdfModule, StorageModule, EmailModule.
Swagger plugin enabled in `nest-cli.json` — DTOs auto-documented.
Swagger plugin enabled in `nest-cli.json` — DTOs auto-documented. Logging via `nestjs-pino` (pino-pretty in dev).
### Frontend (Next.js 14 App Router)
```
apps/frontend/
├── app/ # App Router pages
│ ├── dashboard/ # Protected routes (bookings, admin, settings)
│ └── carrier/ # Carrier portal (magic link auth)
├── app/ # App Router pages (root-level)
│ ├── dashboard/ # Protected routes (bookings, admin, settings, wiki, search)
│ ├── carrier/ # Carrier portal (magic link auth — accept/reject/documents)
│ ├── booking/ # Booking confirmation/rejection flows
│ └── [auth pages] # login, register, forgot-password, verify-email
└── src/
├── components/ # React components (shadcn/ui in ui/, layout/, bookings/, admin/)
├── app/ # Additional app pages (e.g. rates/csv-search)
├── components/ # React components (ui/, layout/, bookings/, admin/, rate-search/, organization/)
├── hooks/ # useBookings, useNotifications, useCsvRateSearch, useCompanies, useFilterOptions
├── lib/
│ ├── api/ # Fetch-based API client with auto token refresh (client.ts + per-module files)
│ ├── context/ # Auth context
│ ├── context/ # Auth context, cookie context
│ ├── providers/ # QueryProvider (TanStack Query / React Query)
│ └── fonts.ts # Manrope (headings) + Montserrat (body)
├── types/ # TypeScript type definitions
└── utils/ # Export utilities (Excel, PDF)
├── utils/ # Export utilities (Excel, PDF)
└── legacy-pages/ # Archived page components (BookingsManagement, CarrierManagement, CarrierMonitoring)
```
Path aliases: `@/*``./src/*`, `@/components/*`, `@/lib/*`, `@/app/*``./app/*`, `@/types/*`, `@/hooks/*`, `@/utils/*`
**Note**: Frontend tsconfig has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false` (unlike backend which is strict).
**Note**: Frontend tsconfig has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false` (unlike backend which is strict). Uses TanStack Query (React Query) for server state — wrap new data fetching in hooks, not bare `fetch` calls.
### Brand Design
@ -171,13 +198,26 @@ Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
### Frontend API Client
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
### Route Protection (Middleware)
`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists:
- `exactPublicPaths`: exact matches (e.g. `/`)
- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.)
All other routes redirect to `/login?redirect=<pathname>` when the cookie is absent.
### Application Decorators
- `@Public()` — skip JWT auth
- `@Roles()` — role-based access control
- `@CurrentUser()` — inject authenticated user
### API Key Authentication
A second auth mechanism alongside JWT. `ApiKey` domain entity (`domain/entities/api-key.entity.ts`) — keys are hashed with Argon2. `ApiKeyGuard` in `application/guards/` checks the `x-api-key` header. Routes can accept either JWT or API key; see `admin.controller.ts` for examples.
### WebSocket (Real-time Notifications)
Socket.IO gateway at `application/gateways/notifications.gateway.ts`. Clients connect to `/` namespace with a JWT bearer token in the handshake auth. Server emits `notification` events. The frontend `useNotifications` hook handles subscriptions.
### Carrier Connectors
Five carrier connectors (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) extending `base-carrier.connector.ts`, each with request/response mappers. Circuit breaker via `opossum` (5s timeout).
@ -193,12 +233,14 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}:
- RBAC Roles: ADMIN, MANAGER, USER, VIEWER, CARRIER
- JWT: access token 15min, refresh token 7d
- Password hashing: Argon2
- OAuth providers: Google, Microsoft (configured via passport strategies)
- Organizations can be validated via Pappers API (French SIRET/company registry) at `infrastructure/external/pappers-siret.adapter.ts`
### Carrier Portal Workflow
1. Admin creates CSV booking → assigns carrier
2. Email with magic link sent (1-hour expiry)
3. Carrier auto-login → accept/reject booking
4. Activity logged in `carrier_activities` table
4. Activity logged in `carrier_activities` table (via `CarrierProfile` + `CarrierActivity` ORM entities)
## Common Pitfalls
@ -215,14 +257,15 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}:
1. **Domain Entity**`domain/entities/*.entity.ts` (pure TS, unit tests)
2. **Value Objects**`domain/value-objects/*.vo.ts` (immutable)
3. **Port Interface**`domain/ports/out/*.repository.ts` (with token constant)
4. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
5. **Migration**`npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName`
6. **Repository Impl**`infrastructure/persistence/typeorm/repositories/`
7. **Mapper**`infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany)
8. **DTOs**`application/dto/` (with class-validator decorators)
9. **Controller**`application/controllers/` (with Swagger decorators)
10. **Module** → Register and import in `app.module.ts`
3. **In Port (Use Case)**`domain/ports/in/*.use-case.ts` (interface with `execute()`)
4. **Out Port (Repository)**`domain/ports/out/*.repository.ts` (with token constant)
5. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
6. **Migration**`npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName`
7. **Repository Impl**`infrastructure/persistence/typeorm/repositories/`
8. **Mapper**`infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany)
9. **DTOs**`application/dto/` (with class-validator decorators)
10. **Controller**`application/controllers/` (with Swagger decorators)
11. **Module** → Register repository + use-case providers, import in `app.module.ts`
## Documentation
@ -230,3 +273,5 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}:
- Setup guide: `docs/installation/START-HERE.md`
- Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md`
- Full docs index: `docs/README.md`
- Development roadmap: `TODO.md`
- Infrastructure configs (CI/CD, Docker): `infra/`

View File

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

View File

@ -20,13 +20,14 @@ import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({
@ -60,21 +61,26 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
// Stripe Configuration (optional for development)
STRIPE_SECRET_KEY: Joi.string().optional(),
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
}),
}),
// Logging
LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
pinoHttp: {
transport:
configService.get('NODE_ENV') === 'development'
useFactory: (configService: ConfigService) => {
const isDev = configService.get('NODE_ENV') === 'development';
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail)
const forceJson = configService.get('LOG_FORMAT') === 'json';
const usePretty = isDev && !forceJson;
return {
pinoHttp: {
transport: usePretty
? {
target: 'pino-pretty',
options: {
@ -84,9 +90,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
},
}
: undefined,
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
},
}),
level: isDev ? 'debug' : 'info',
// Redact sensitive fields from logs
redact: {
paths: [
'req.headers.authorization',
'req.headers["x-api-key"]',
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
],
censor: '[REDACTED]',
},
},
};
},
inject: [ConfigService],
}),
@ -128,14 +146,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
GDPRModule,
AdminModule,
SubscriptionsModule,
ApiKeysModule,
],
controllers: [],
providers: [
// Global JWT authentication guard
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
// All routes are protected by default, use @Public() to bypass
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
useClass: ApiKeyOrJwtGuard,
},
// Global rate limiting guard
{

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// Controller
import { AdminController } from '../controllers/admin.controller';
@ -18,6 +19,16 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
// SIRET verification
import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port';
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter';
// CSV Booking Service
import { CsvBookingsModule } from '../csv-bookings.module';
// Email
import { EmailModule } from '@infrastructure/email/email.module';
/**
* Admin Module
*
@ -25,7 +36,12 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
* All endpoints require ADMIN role.
*/
@Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])],
imports: [
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
ConfigModule,
CsvBookingsModule,
EmailModule,
],
controllers: [AdminController],
providers: [
{
@ -37,6 +53,10 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
useClass: TypeOrmOrganizationRepository,
},
TypeOrmCsvBookingRepository,
{
provide: SIRET_VERIFICATION_PORT,
useClass: PappersSiretAdapter,
},
],
})
export class AdminModule {}

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

View File

@ -5,10 +5,14 @@ import {
Logger,
Inject,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity';
import {
@ -16,15 +20,19 @@ import {
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { Organization } from '@domain/entities/organization.entity';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { v4 as uuidv4 } from 'uuid';
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
import { SubscriptionService } from '../services/subscription.service';
import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity';
export interface JwtPayload {
sub: string; // user ID
email: string;
role: string;
organizationId: string;
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
planFeatures?: string[]; // plan feature flags
type: 'access' | 'refresh';
}
@ -37,9 +45,13 @@ export class AuthService {
private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
@InjectRepository(PasswordResetTokenOrmEntity)
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService,
private readonly subscriptionService: SubscriptionService
) {}
/**
@ -203,6 +215,85 @@ export class AuthService {
}
}
/**
* Initiate password reset generates token and sends email
*/
async forgotPassword(email: string): Promise<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
*/
@ -220,11 +311,40 @@ export class AuthService {
* Generate access and refresh tokens
*/
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get PLATINIUM plan with no expiration
let plan = 'BRONZE';
let planFeatures: string[] = [];
if (user.role === UserRole.ADMIN) {
plan = 'PLATINIUM';
planFeatures = [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
];
} else {
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
plan = subscription.plan.value;
planFeatures = [...subscription.plan.planFeatures];
} catch (error) {
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
}
}
const accessPayload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
plan,
planFeatures,
type: 'access',
};
@ -233,6 +353,8 @@ export class AuthService {
email: user.email,
role: user.role,
organizationId: user.organizationId,
plan,
planFeatures,
type: 'refresh',
};
@ -302,6 +424,8 @@ export class AuthService {
name: organizationData.name,
type: organizationData.type,
scac: organizationData.scac,
siren: organizationData.siren,
siret: organizationData.siret,
address: {
street: organizationData.street,
city: organizationData.city,

View File

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

View File

@ -1,6 +1,7 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
@ -44,6 +45,16 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org
// CSV Booking imports
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBookingService } from '../services/csv-booking.service';
// SIRET verification imports
import {
SiretVerificationPort,
SIRET_VERIFICATION_PORT,
} from '@domain/ports/out/siret-verification.port';
// Email imports
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
/**
* Admin Controller
@ -65,7 +76,11 @@ export class AdminController {
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
private readonly csvBookingService: CsvBookingService,
@Inject(SIRET_VERIFICATION_PORT)
private readonly siretVerificationPort: SiretVerificationPort,
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
) {}
// ==================== USERS ENDPOINTS ====================
@ -329,6 +344,163 @@ export class AdminController {
return OrganizationMapper.toDto(organization);
}
/**
* Verify SIRET number for an organization (admin only)
*
* Calls Pappers API to verify the SIRET, then marks the organization as verified.
*/
@Post('organizations/:id/verify-siret')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Verify organization SIRET (Admin only)',
description:
'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'SIRET verification result',
schema: {
type: 'object',
properties: {
verified: { type: 'boolean' },
companyName: { type: 'string' },
address: { type: 'string' },
message: { type: 'string' },
},
},
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
const siret = organization.siret;
if (!siret) {
throw new BadRequestException(
'Organization has no SIRET number. Please set a SIRET number before verification.'
);
}
const result = await this.siretVerificationPort.verify(siret);
if (!result.valid) {
this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`);
return {
verified: false,
message: `Le numero SIRET ${siret} est invalide ou introuvable.`,
};
}
// Mark as verified and save
organization.markSiretVerified();
await this.organizationRepository.update(organization);
this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`);
return {
verified: true,
companyName: result.companyName,
address: result.address,
message: `SIRET ${siret} verifie avec succes.`,
};
}
/**
* Manually approve SIRET/SIREN for an organization (admin only)
*
* Marks the organization's SIRET as verified without calling the external API.
*/
@Post('organizations/:id/approve-siret')
@ApiOperation({
summary: 'Approve SIRET/SIREN (Admin only)',
description:
'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.',
})
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
@ApiResponse({
status: HttpStatus.OK,
description: 'SIRET approved successfully',
})
@ApiNotFoundResponse({ description: 'Organization not found' })
async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
if (!organization.siret && !organization.siren) {
throw new BadRequestException(
"L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation."
);
}
organization.markSiretVerified();
await this.organizationRepository.update(organization);
this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`);
return {
approved: true,
message: 'SIRET/SIREN approuve manuellement avec succes.',
organizationId: id,
organizationName: organization.name,
};
}
/**
* Reject SIRET/SIREN for an organization (admin only)
*
* Resets the verification flag to false.
*/
@Post('organizations/:id/reject-siret')
@ApiOperation({
summary: 'Reject SIRET/SIREN (Admin only)',
description:
'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.',
})
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
@ApiResponse({
status: HttpStatus.OK,
description: 'SIRET rejected successfully',
})
@ApiNotFoundResponse({ description: 'Organization not found' })
async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
// Reset SIRET verification to false by updating the SIRET (which resets siretVerified)
// If no SIRET, just update directly
if (organization.siret) {
organization.updateSiret(organization.siret); // This resets siretVerified to false
}
await this.organizationRepository.update(organization);
this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`);
return {
rejected: true,
message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.",
organizationId: id,
organizationName: organization.name,
};
}
// ==================== CSV BOOKINGS ENDPOINTS ====================
/**
@ -440,6 +612,52 @@ export class AdminController {
return this.csvBookingToDto(updatedBooking);
}
/**
* Resend carrier email for a booking (admin only)
*
* Manually sends the booking request email to the carrier.
* Useful when the automatic email failed (SMTP error) or for testing without Stripe.
*/
@Post('bookings/:id/resend-carrier-email')
@ApiOperation({
summary: 'Resend carrier email (Admin only)',
description:
'Manually resend the booking request email to the carrier. Works regardless of payment status.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Email sent to carrier' })
@ApiNotFoundResponse({ description: 'Booking not found' })
async resendCarrierEmail(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`);
await this.csvBookingService.resendCarrierEmail(id);
return { success: true, message: 'Email sent to carrier' };
}
/**
* Validate bank transfer for a booking (admin only)
*
* Transitions booking from PENDING_BANK_TRANSFER PENDING and sends email to carrier
*/
@Post('bookings/:id/validate-transfer')
@ApiOperation({
summary: 'Validate bank transfer (Admin only)',
description:
'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' })
@ApiNotFoundResponse({ description: 'Booking not found' })
async validateBankTransfer(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
) {
this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`);
return this.csvBookingService.validateBankTransfer(id);
}
/**
* Delete csv booking (admin only)
*/
@ -483,6 +701,7 @@ export class AdminController {
return {
id: booking.id,
bookingNumber: booking.bookingNumber || null,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,
@ -510,6 +729,50 @@ export class AdminController {
};
}
// ==================== EMAIL TEST ENDPOINT ====================
/**
* Send a test email to verify SMTP configuration (admin only)
*
* Returns the exact SMTP error in the response instead of only logging it.
*/
@Post('test-email')
@ApiOperation({
summary: 'Send test email (Admin only)',
description:
'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.',
})
@ApiResponse({ status: 200, description: 'Email sent successfully' })
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
async sendTestEmail(
@Body() body: { to: string },
@CurrentUser() user: UserPayload
) {
if (!body?.to) {
throw new BadRequestException('Field "to" is required');
}
this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`);
try {
await this.emailPort.send({
to: body.to,
subject: '[Xpeditis] Test SMTP',
html: `<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 ====================
/**
@ -597,4 +860,55 @@ export class AdminController {
total: organization.documents.length,
};
}
/**
* Delete a document from a CSV booking (admin only)
* Bypasses ownership and status restrictions
*/
@Delete('bookings/:bookingId/documents/:documentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Delete document from CSV booking (Admin only)',
description: 'Remove a document from a booking, bypassing ownership and status restrictions.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Document deleted successfully',
})
async deleteDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: UserPayload
): Promise<{ success: boolean; message: string }> {
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) {
throw new NotFoundException(`Document ${documentId} not found`);
}
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,
type: doc.type,
fileName: doc.fileName,
filePath: doc.filePath,
mimeType: doc.mimeType,
size: doc.size,
uploadedAt: doc.uploadedAt,
}));
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
return { success: true, message: 'Document deleted successfully' };
}
}

View File

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

View File

@ -8,10 +8,21 @@ import {
Get,
Inject,
NotFoundException,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from '../auth/auth.service';
import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
import {
LoginDto,
RegisterDto,
AuthResponseDto,
RefreshTokenDto,
ForgotPasswordDto,
ResetPasswordDto,
ContactFormDto,
} from '../dto/auth-login.dto';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { Public } from '../decorators/public.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -32,10 +43,13 @@ import { InvitationService } from '../services/invitation.service';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private readonly authService: AuthService,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly invitationService: InvitationService
private readonly invitationService: InvitationService,
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
) {}
/**
@ -209,6 +223,113 @@ export class AuthController {
return { message: 'Logout successful' };
}
/**
* Contact form forwards message to contact@xpeditis.com
*/
@Public()
@Post('contact')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Contact form',
description: 'Send a contact message to the Xpeditis team.',
})
@ApiResponse({ status: 200, description: 'Message sent successfully' })
async contact(@Body() dto: ContactFormDto): Promise<{ message: string }> {
const subjectLabels: Record<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
*

View File

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

View File

@ -12,9 +12,12 @@ import {
UploadedFiles,
Request,
BadRequestException,
ForbiddenException,
ParseIntPipe,
DefaultValuePipe,
Inject,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
@ -29,6 +32,16 @@ import {
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service';
import { SubscriptionService } from '../services/subscription.service';
import {
ShipmentCounterPort,
SHIPMENT_COUNTER_PORT,
} from '@domain/ports/out/shipment-counter.port';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
import {
CreateCsvBookingDto,
CsvBookingResponseDto,
@ -48,7 +61,15 @@ import {
@ApiTags('CSV Bookings')
@Controller('csv-bookings')
export class CsvBookingsController {
constructor(private readonly csvBookingService: CsvBookingService) {}
constructor(
private readonly csvBookingService: CsvBookingService,
private readonly subscriptionService: SubscriptionService,
private readonly configService: ConfigService,
@Inject(SHIPMENT_COUNTER_PORT)
private readonly shipmentCounter: ShipmentCounterPort,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
// ============================================================================
// STATIC ROUTES (must come FIRST)
@ -60,7 +81,6 @@ export class CsvBookingsController {
* POST /api/v1/csv-bookings
*/
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data')
@ -144,6 +164,23 @@ export class CsvBookingsController {
const userId = req.user.id;
const organizationId = req.user.organizationId;
// ADMIN users bypass shipment limits
if (req.user.role !== 'ADMIN') {
// Check shipment limit (Bronze plan = 12/year)
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
organizationId,
currentYear
);
if (count >= maxShipments) {
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
}
}
}
// Convert string values to numbers (multipart/form-data sends everything as strings)
const sanitizedDto: CreateCsvBookingDto = {
...dto,
@ -341,6 +378,126 @@ export class CsvBookingsController {
};
}
/**
* Create Stripe Checkout session for commission payment
*
* POST /api/v1/csv-bookings/:id/pay
*/
@Post(':id/pay')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Pay commission for a booking',
description:
'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Stripe checkout session created',
schema: {
type: 'object',
properties: {
sessionUrl: { type: 'string' },
sessionId: { type: 'string' },
commissionAmountEur: { type: 'number' },
},
},
})
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async payCommission(@Param('id') id: string, @Request() req: any) {
const userId = req.user.id;
const userEmail = req.user.email;
const organizationId = req.user.organizationId;
const frontendUrl = this.configService.get<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)
// ============================================================================

View File

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

View File

@ -2,6 +2,7 @@ import {
Controller,
Post,
Get,
Delete,
Body,
UseGuards,
HttpCode,
@ -71,7 +72,8 @@ export class InvitationsController {
dto.lastName,
dto.role as unknown as UserRole,
user.organizationId,
user.id
user.id,
user.role
);
return {
@ -136,6 +138,29 @@ export class InvitationsController {
};
}
/**
* Cancel (delete) a pending invitation
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Cancel invitation',
description: 'Delete a pending invitation. Admin/manager only.',
})
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
@ApiResponse({ status: 404, description: 'Invitation not found' })
@ApiResponse({ status: 400, description: 'Invitation already used' })
async cancelInvitation(
@Param('id') id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
await this.invitationService.cancelInvitation(id, user.organizationId);
}
/**
* List organization invitations
*/

View File

@ -22,6 +22,8 @@ import {
Headers,
RawBodyRequest,
Req,
Inject,
ForbiddenException,
} from '@nestjs/common';
import {
ApiTags,
@ -47,13 +49,21 @@ import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Public } from '../decorators/public.decorator';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
@ApiTags('Subscriptions')
@Controller('subscriptions')
export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name);
constructor(private readonly subscriptionService: SubscriptionService) {}
constructor(
private readonly subscriptionService: SubscriptionService,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
/**
* Get subscription overview for current organization
@ -77,10 +87,10 @@ export class SubscriptionsController {
description: 'Forbidden - requires admin or manager role',
})
async getSubscriptionOverview(
@CurrentUser() user: UserPayload,
@CurrentUser() user: UserPayload
): Promise<SubscriptionOverviewResponseDto> {
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> {
this.logger.log(`[User: ${user.email}] Checking license availability`);
return this.subscriptionService.canInviteUser(user.organizationId);
return this.subscriptionService.canInviteUser(user.organizationId, user.role);
}
/**
@ -139,8 +149,7 @@ export class SubscriptionsController {
@ApiBearerAuth()
@ApiOperation({
summary: 'Create checkout session',
description:
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
})
@ApiResponse({
status: 200,
@ -157,14 +166,22 @@ export class SubscriptionsController {
})
async createCheckoutSession(
@Body() dto: CreateCheckoutSessionDto,
@CurrentUser() user: UserPayload,
@CurrentUser() user: UserPayload
): Promise<CheckoutSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
return this.subscriptionService.createCheckoutSession(
user.organizationId,
user.id,
dto,
);
// ADMIN users bypass all payment restrictions
if (user.role !== 'ADMIN') {
// SIRET verification gate: organization must have a verified SIRET before purchasing
const organization = await this.organizationRepository.findById(user.organizationId);
if (!organization || !organization.siretVerified) {
throw new ForbiddenException(
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un achat. Contactez votre administrateur.'
);
}
}
return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto);
}
/**
@ -195,7 +212,7 @@ export class SubscriptionsController {
})
async createPortalSession(
@Body() dto: CreatePortalSessionDto,
@CurrentUser() user: UserPayload,
@CurrentUser() user: UserPayload
): Promise<PortalSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating portal session`);
return this.subscriptionService.createPortalSession(user.organizationId, dto);
@ -230,10 +247,10 @@ export class SubscriptionsController {
})
async syncFromStripe(
@Body() dto: SyncSubscriptionDto,
@CurrentUser() user: UserPayload,
@CurrentUser() user: UserPayload
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`
);
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
}
@ -247,7 +264,7 @@ export class SubscriptionsController {
@ApiExcludeEndpoint()
async handleWebhook(
@Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request>,
@Req() req: RawBodyRequest<Request>
): Promise<{ received: boolean }> {
const rawBody = req.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 { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
@ -64,14 +66,15 @@ import { SubscriptionService } from '../services/subscription.service';
*/
@ApiTags('Users')
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@RequiresFeature('user_management')
@ApiBearerAuth()
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly subscriptionService: SubscriptionService,
private readonly subscriptionService: SubscriptionService
) {}
/**
@ -284,7 +287,7 @@ export class UsersController {
} catch (error) {
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
throw new ForbiddenException(
'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
'Cannot reactivate user: no licenses available. Please upgrade your subscription.'
);
}
} else {

View File

@ -1,13 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { CsvBookingsController } from './controllers/csv-bookings.controller';
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
import { CsvBookingService } from './services/csv-booking.service';
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity';
import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { NotificationsModule } from './notifications/notifications.module';
import { EmailModule } from '../infrastructure/email/email.module';
import { StorageModule } from '../infrastructure/storage/storage.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { StripeModule } from '../infrastructure/stripe/stripe.module';
/**
* CSV Bookings Module
@ -16,13 +27,31 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
*/
@Module({
imports: [
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
ConfigModule,
NotificationsModule,
EmailModule,
StorageModule,
SubscriptionsModule,
StripeModule,
],
controllers: [CsvBookingsController, CsvBookingActionsController],
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
providers: [
CsvBookingService,
TypeOrmCsvBookingRepository,
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
})
export class CsvBookingsModule {}

View File

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

View File

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

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,
MaxLength,
Matches,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@ -22,12 +23,81 @@ export class LoginDto {
@ApiProperty({
example: 'SecurePassword123!',
description: 'Password (minimum 12 characters)',
description: 'Password',
})
@IsString()
password: string;
@ApiPropertyOptional({
example: true,
description: 'Remember me for extended session',
})
@IsBoolean()
@IsOptional()
rememberMe?: boolean;
}
export class ContactFormDto {
@ApiProperty({ example: 'Jean', description: 'First name' })
@IsString()
@MinLength(1)
firstName: string;
@ApiProperty({ example: 'Dupont', description: 'Last name' })
@IsString()
@MinLength(1)
lastName: string;
@ApiProperty({ example: 'jean@acme.com', description: 'Sender email' })
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@ApiPropertyOptional({ example: 'Acme Logistics', description: 'Company name' })
@IsString()
@IsOptional()
company?: string;
@ApiPropertyOptional({ example: '+33 6 12 34 56 78', description: 'Phone number' })
@IsString()
@IsOptional()
phone?: string;
@ApiProperty({ example: 'demo', description: 'Subject category' })
@IsString()
@MinLength(1)
subject: string;
@ApiProperty({ example: 'Bonjour, je souhaite...', description: 'Message body' })
@IsString()
@MinLength(10)
message: string;
}
export class ForgotPasswordDto {
@ApiProperty({
example: 'john.doe@acme.com',
description: 'Email address for password reset',
})
@IsEmail({}, { message: 'Invalid email format' })
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
example: 'abc123token...',
description: 'Password reset token from email',
})
@IsString()
token: string;
@ApiProperty({
example: 'NewSecurePassword123!',
description: 'New password (minimum 12 characters)',
minLength: 12,
})
@IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' })
password: string;
newPassword: string;
}
/**
@ -94,6 +164,31 @@ export class RegisterOrganizationDto {
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
country: string;
@ApiProperty({
example: '123456789',
description: 'French SIREN number (9 digits, required)',
minLength: 9,
maxLength: 9,
})
@IsString()
@MinLength(9, { message: 'SIREN must be exactly 9 digits' })
@MaxLength(9, { message: 'SIREN must be exactly 9 digits' })
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren: string;
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits, optional)',
minLength: 14,
maxLength: 14,
})
@IsString()
@IsOptional()
@MinLength(14, { message: 'SIRET must be exactly 14 digits' })
@MaxLength(14, { message: 'SIRET must be exactly 14 digits' })
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
siret?: string;
@ApiPropertyOptional({
example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',

View File

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

View File

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

View File

@ -294,8 +294,8 @@ export class CsvBookingResponseDto {
@ApiProperty({
description: 'Booking status',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING',
enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING_PAYMENT',
})
status: string;
@ -353,6 +353,18 @@ export class CsvBookingResponseDto {
example: 1850.5,
})
price: number;
@ApiPropertyOptional({
description: 'Commission rate in percent',
example: 5,
})
commissionRate?: number;
@ApiPropertyOptional({
description: 'Commission amount in EUR',
example: 313.27,
})
commissionAmountEur?: number;
}
/**
@ -414,6 +426,12 @@ export class CsvBookingListResponseDto {
* Statistics for user's or organization's bookings
*/
export class CsvBookingStatsDto {
@ApiProperty({
description: 'Number of bookings awaiting payment',
example: 1,
})
pendingPayment: number;
@ApiProperty({
description: 'Number of pending bookings',
example: 5,

View File

@ -184,6 +184,19 @@ export class UpdateOrganizationDto {
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren?: string;
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits)',
minLength: 14,
maxLength: 14,
})
@IsString()
@IsOptional()
@MinLength(14)
@MaxLength(14)
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
siret?: string;
@ApiPropertyOptional({
example: 'FR123456789',
description: 'EU EORI number',
@ -344,6 +357,25 @@ export class OrganizationResponseDto {
})
documents: OrganizationDocumentDto[];
@ApiPropertyOptional({
example: '12345678901234',
description: 'French SIRET number (14 digits)',
})
siret?: string;
@ApiProperty({
example: false,
description: 'Whether the SIRET has been verified by an admin',
})
siretVerified: boolean;
@ApiPropertyOptional({
example: 'none',
description: 'Organization status badge',
enum: ['none', 'silver', 'gold', 'platinium'],
})
statusBadge?: string;
@ApiProperty({
example: true,
description: 'Active status',

View File

@ -5,25 +5,16 @@
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsEnum,
IsNotEmpty,
IsUrl,
IsOptional,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator';
/**
* Subscription plan types
*/
export enum SubscriptionPlanDto {
FREE = 'FREE',
STARTER = 'STARTER',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
BRONZE = 'BRONZE',
SILVER = 'SILVER',
GOLD = 'GOLD',
PLATINIUM = 'PLATINIUM',
}
/**
@ -53,7 +44,7 @@ export enum BillingIntervalDto {
*/
export class CreateCheckoutSessionDto {
@ApiProperty({
example: SubscriptionPlanDto.STARTER,
example: SubscriptionPlanDto.SILVER,
description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto,
})
@ -197,14 +188,14 @@ export class LicenseResponseDto {
*/
export class PlanDetailsDto {
@ApiProperty({
example: SubscriptionPlanDto.STARTER,
example: SubscriptionPlanDto.SILVER,
description: 'Plan identifier',
enum: SubscriptionPlanDto,
})
plan: SubscriptionPlanDto;
@ApiProperty({
example: 'Starter',
example: 'Silver',
description: 'Plan display name',
})
name: string;
@ -216,20 +207,51 @@ export class PlanDetailsDto {
maxLicenses: number;
@ApiProperty({
example: 49,
example: 249,
description: 'Monthly price in EUR',
})
monthlyPriceEur: number;
@ApiProperty({
example: 470,
description: 'Yearly price in EUR',
example: 2739,
description: 'Yearly price in EUR (11 months)',
})
yearlyPriceEur: number;
@ApiProperty({
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
description: 'List of features included in this plan',
example: -1,
description: 'Maximum shipments per year (-1 for unlimited)',
})
maxShipmentsPerYear: number;
@ApiProperty({
example: 3,
description: 'Commission rate percentage on shipments',
})
commissionRatePercent: number;
@ApiProperty({
example: 'email',
description: 'Support level: none, email, direct, dedicated_kam',
})
supportLevel: string;
@ApiProperty({
example: 'silver',
description: 'Status badge: none, silver, gold, platinium',
})
statusBadge: string;
@ApiProperty({
example: ['dashboard', 'wiki', 'user_management', 'csv_export'],
description: 'List of plan feature flags',
type: [String],
})
planFeatures: string[];
@ApiProperty({
example: ["Jusqu'à 5 utilisateurs", 'Expéditions illimitées', 'Import CSV'],
description: 'List of human-readable features included in this plan',
type: [String],
})
features: string[];
@ -252,7 +274,7 @@ export class SubscriptionResponseDto {
organizationId: string;
@ApiProperty({
example: SubscriptionPlanDto.STARTER,
example: SubscriptionPlanDto.SILVER,
description: 'Current subscription plan',
enum: SubscriptionPlanDto,
})

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 './roles.guard';
export * from './api-key-or-jwt.guard';

View File

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

View File

@ -16,7 +16,9 @@ import {
NOTIFICATION_REPOSITORY,
} from '@domain/ports/out/notification.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import {
Notification,
NotificationType,
@ -30,6 +32,7 @@ import {
CsvBookingStatsDto,
} from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service';
/**
* CSV Booking Document (simple class for domain)
@ -62,7 +65,12 @@ export class CsvBookingService {
@Inject(EMAIL_PORT)
private readonly emailAdapter: EmailPort,
@Inject(STORAGE_PORT)
private readonly storageAdapter: StoragePort
private readonly storageAdapter: StoragePort,
@Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort,
private readonly subscriptionService: SubscriptionService,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository
) {}
/**
@ -114,7 +122,18 @@ export class CsvBookingService {
// Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId);
// Create domain entity
// Calculate commission based on organization's subscription plan
let commissionRate = 5; // default Bronze
let commissionAmountEur = 0;
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
commissionRate = subscription.plan.commissionRatePercent;
} catch (error: any) {
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
const booking = new CsvBooking(
bookingId,
userId,
@ -131,12 +150,16 @@ export class CsvBookingService {
dto.primaryCurrency,
dto.transitDays,
dto.containerType,
CsvBookingStatus.PENDING,
CsvBookingStatus.PENDING_PAYMENT,
documents,
confirmationToken,
new Date(),
undefined,
dto.notes
dto.notes,
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
);
// Save to database
@ -152,58 +175,398 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`);
this.logger.log(
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}`
);
// Send email to carrier and WAIT for confirmation
// The button waits for the email to be sent before responding
// NO email sent to carrier yet - will be sent after commission payment
// NO notification yet - will be created after payment confirmation
return this.toResponseDto(savedBooking);
}
/**
* Create a Stripe Checkout session for commission payment
*/
async createCommissionPayment(
bookingId: string,
userId: string,
userEmail: string,
frontendUrl: string
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
const commissionAmountEur = booking.commissionAmountEur || 0;
if (commissionAmountEur <= 0) {
throw new BadRequestException('Commission amount is invalid');
}
const amountCents = Math.round(commissionAmountEur * 100);
const result = await this.stripeAdapter.createCommissionCheckout({
bookingId: booking.id,
amountCents,
currency: 'eur',
customerEmail: userEmail,
organizationId: booking.organizationId,
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`,
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
});
this.logger.log(
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
);
return {
sessionUrl: result.sessionUrl,
sessionId: result.sessionId,
commissionAmountEur,
};
}
/**
* Confirm commission payment and activate booking
* Called after Stripe redirect with session_id
*/
async confirmCommissionPayment(
bookingId: string,
sessionId: string,
userId: string
): Promise<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 {
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId,
bookingNumber,
documentPassword,
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
transitDays: dto.transitDays,
containerType: dto.containerType,
documents: documents.map(doc => ({
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id,
bookingNumber: bookingNumber || '',
documentPassword: documentPassword || '',
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
documents: booking.documents.map(doc => ({
type: doc.type,
fileName: doc.fileName,
})),
confirmationToken,
notes: dto.notes,
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
// Continue even if email fails - booking is already saved
}
// Create notification for user
try {
const notification = Notification.create({
id: uuidv4(),
userId,
organizationId,
userId: booking.userId,
organizationId: booking.organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent',
message: `Your booking request to ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`,
metadata: { bookingId, carrierName: dto.carrierName },
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
});
await this.notificationRepository.save(notification);
this.logger.log(`Notification created for user ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
// Continue even if notification fails
}
return this.toResponseDto(savedBooking);
return this.toResponseDto(updatedBooking);
}
/**
* Declare bank transfer user confirms they have sent the wire transfer
* Transitions booking from PENDING_PAYMENT PENDING_BANK_TRANSFER
* Sends an email notification to all ADMIN users
*/
async declareBankTransfer(bookingId: string, userId: string): Promise<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)
booking.accept();
// Apply commission based on organization's subscription plan
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
booking.organizationId
);
const commissionRate = subscription.plan.commissionRatePercent;
const baseAmountEur = booking.priceEUR;
booking.applyCommission(commissionRate, baseAmountEur);
this.logger.log(
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}`
);
} catch (error: any) {
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
}
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`);
@ -568,6 +946,7 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -583,6 +962,7 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -678,9 +1058,15 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Allow adding documents to PENDING or ACCEPTED bookings
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING &&
booking.status !== CsvBookingStatus.ACCEPTED
) {
throw new BadRequestException(
'Cannot add documents to a booking that is rejected or cancelled'
);
}
// Upload new documents
@ -723,7 +1109,10 @@ export class CsvBookingService {
});
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack);
this.logger.error(
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
}
}
@ -755,8 +1144,11 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
// Verify booking is still pending
if (booking.status !== CsvBookingStatus.PENDING) {
// Verify booking is still pending or awaiting payment
if (
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING
) {
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
}
@ -871,7 +1263,9 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking);
}
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
this.logger.log(
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
);
return {
success: true,
@ -947,6 +1341,8 @@ export class CsvBookingService {
routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur,
};
}

View File

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

View File

@ -38,7 +38,7 @@ export class InvitationService {
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService,
private readonly subscriptionService: SubscriptionService
) {}
/**
@ -50,7 +50,8 @@ export class InvitationService {
lastName: string,
role: UserRole,
organizationId: string,
invitedById: string
invitedById: string,
inviterRole?: string
): Promise<InvitationToken> {
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
@ -69,14 +70,14 @@ export class InvitationService {
}
// Check if licenses are available for this organization
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
if (!canInviteResult.canInvite) {
this.logger.warn(
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
);
throw new ForbiddenException(
canInviteResult.message ||
`License limit reached. Please upgrade your subscription to invite more users.`,
`License limit reached. Please upgrade your subscription to invite more users.`
);
}
@ -219,6 +220,25 @@ export class InvitationService {
}
}
/**
* Cancel (delete) a pending invitation
*/
async cancelInvitation(invitationId: string, organizationId: string): Promise<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)
*/

View File

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

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 { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({
imports: [
TypeOrmModule.forFeature([UserOrmEntity]),
SubscriptionsModule,
],
imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule],
controllers: [UsersController],
providers: [
FeatureFlagGuard,
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,

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;
containers: BookingContainer[];
specialInstructions?: string;
commissionRate?: number;
commissionAmountEur?: number;
createdAt: Date;
updatedAt: Date;
}
@ -161,6 +163,14 @@ export class Booking {
return this.props.specialInstructions;
}
get commissionRate(): number | undefined {
return this.props.commissionRate;
}
get commissionAmountEur(): number | undefined {
return this.props.commissionAmountEur;
}
get createdAt(): Date {
return this.props.createdAt;
}
@ -270,6 +280,19 @@ export class Booking {
});
}
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): Booking {
const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100;
return new Booking({
...this.props,
commissionRate: ratePercent,
commissionAmountEur: commissionAmount,
updatedAt: new Date(),
});
}
/**
* Check if booking can be cancelled
*/

View File

@ -6,6 +6,8 @@ import { PortCode } from '../value-objects/port-code.vo';
* Represents the lifecycle of a CSV-based booking request
*/
export enum CsvBookingStatus {
PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment
PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation
PENDING = 'PENDING', // Awaiting carrier response
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
REJECTED = 'REJECTED', // Carrier rejected the booking
@ -80,7 +82,10 @@ export class CsvBooking {
public respondedAt?: Date,
public notes?: string,
public rejectionReason?: string,
public readonly bookingNumber?: string
public readonly bookingNumber?: string,
public commissionRate?: number,
public commissionAmountEur?: number,
public stripePaymentIntentId?: string
) {
this.validate();
}
@ -144,6 +149,61 @@ export class CsvBooking {
}
}
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): void {
this.commissionRate = ratePercent;
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
}
/**
* Mark commission payment as completed transition to PENDING
*
* @throws Error if booking is not in PENDING_PAYMENT status
*/
markPaymentCompleted(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING;
}
/**
* Declare bank transfer transition to PENDING_BANK_TRANSFER
* Called when user confirms they have sent the bank transfer
*
* @throws Error if booking is not in PENDING_PAYMENT status
*/
markBankTransferDeclared(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING_BANK_TRANSFER;
}
/**
* Admin validates bank transfer transition to PENDING
* Called by admin once bank transfer has been received and verified
*
* @throws Error if booking is not in PENDING_BANK_TRANSFER status
*/
markBankTransferValidated(): void {
if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
throw new Error(
`Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING;
}
/**
* Accept the booking
*
@ -202,6 +262,10 @@ export class CsvBooking {
throw new Error('Cannot cancel rejected booking');
}
if (this.status === CsvBookingStatus.CANCELLED) {
throw new Error('Booking is already cancelled');
}
this.status = CsvBookingStatus.CANCELLED;
this.respondedAt = new Date();
}
@ -211,6 +275,10 @@ export class CsvBooking {
*
* @returns true if booking is older than 7 days and still pending
*/
isPendingPayment(): boolean {
return this.status === CsvBookingStatus.PENDING_PAYMENT;
}
isExpired(): boolean {
if (this.status !== CsvBookingStatus.PENDING) {
return false;
@ -363,7 +431,10 @@ export class CsvBooking {
respondedAt?: Date,
notes?: string,
rejectionReason?: string,
bookingNumber?: string
bookingNumber?: string,
commissionRate?: number,
commissionAmountEur?: number,
stripePaymentIntentId?: string
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
@ -392,6 +463,9 @@ export class CsvBooking {
booking.notes = notes;
booking.rejectionReason = rejectionReason;
booking.bookingNumber = bookingNumber;
booking.commissionRate = commissionRate;
booking.commissionAmountEur = commissionAmountEur;
booking.stripePaymentIntentId = stripePaymentIntentId;
return booking;
}

View File

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

View File

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

View File

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

View File

@ -5,10 +5,7 @@
* Stripe integration, and billing period information.
*/
import {
SubscriptionPlan,
SubscriptionPlanType,
} from '../value-objects/subscription-plan.vo';
import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo';
import {
SubscriptionStatus,
SubscriptionStatusType,
@ -40,7 +37,7 @@ export class Subscription {
}
/**
* Create a new subscription (defaults to FREE plan)
* Create a new subscription (defaults to Bronze/free plan)
*/
static create(props: {
id: string;
@ -53,7 +50,7 @@ export class Subscription {
return new Subscription({
id: props.id,
organizationId: props.organizationId,
plan: props.plan ?? SubscriptionPlan.free(),
plan: props.plan ?? SubscriptionPlan.bronze(),
status: SubscriptionStatus.active(),
stripeCustomerId: props.stripeCustomerId ?? null,
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
@ -68,10 +65,41 @@ export class Subscription {
/**
* Reconstitute from persistence
*/
/**
* Check if a specific plan feature is available
*/
hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean {
return this.props.plan.hasFeature(feature);
}
/**
* Get the maximum shipments per year allowed
*/
get maxShipmentsPerYear(): number {
return this.props.plan.maxShipmentsPerYear;
}
/**
* Get the commission rate for this subscription's plan
*/
get commissionRatePercent(): number {
return this.props.plan.commissionRatePercent;
}
/**
* Get the status badge for this subscription's plan
*/
get statusBadge(): string {
return this.props.plan.statusBadge;
}
/**
* Reconstitute from persistence (supports legacy plan names)
*/
static fromPersistence(props: {
id: string;
organizationId: string;
plan: SubscriptionPlanType;
plan: string; // Accepts both old and new plan names
status: SubscriptionStatusType;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
@ -84,7 +112,7 @@ export class Subscription {
return new Subscription({
id: props.id,
organizationId: props.organizationId,
plan: SubscriptionPlan.create(props.plan),
plan: SubscriptionPlan.fromString(props.plan),
status: SubscriptionStatus.create(props.status),
stripeCustomerId: props.stripeCustomerId,
stripeSubscriptionId: props.stripeSubscriptionId,
@ -236,7 +264,7 @@ export class Subscription {
this.props.plan.value,
newPlan.value,
currentUserCount,
newPlan.maxLicenses,
newPlan.maxLicenses
);
}

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

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 {
to: string | string[];
from?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string;

View File

@ -35,6 +35,11 @@ export interface InvitationTokenRepository {
*/
deleteExpired(): Promise<number>;
/**
* Delete an invitation by id
*/
deleteById(id: string): Promise<void>;
/**
* 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;
}
export interface CreateCommissionCheckoutInput {
bookingId: string;
amountCents: number;
currency: 'eur';
customerEmail: string;
organizationId: string;
bookingDescription: string;
successUrl: string;
cancelUrl: string;
}
export interface CreateCommissionCheckoutOutput {
sessionId: string;
sessionUrl: string;
}
export interface StripeCheckoutSessionData {
sessionId: string;
customerId: string | null;
@ -62,16 +78,19 @@ export interface StripePort {
/**
* Create a Stripe Checkout session for subscription purchase
*/
createCheckoutSession(
input: CreateCheckoutSessionInput,
): Promise<CreateCheckoutSessionOutput>;
createCheckoutSession(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
*/
createPortalSession(
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput>;
createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput>;
/**
* Retrieve subscription details from Stripe
@ -101,10 +120,7 @@ export interface StripePort {
/**
* Verify and parse a Stripe webhook event
*/
constructWebhookEvent(
payload: string | Buffer,
signature: string,
): Promise<StripeWebhookEvent>;
constructWebhookEvent(payload: string | Buffer, signature: string): Promise<StripeWebhookEvent>;
/**
* 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
*
* Represents the different subscription plans available for organizations.
* Each plan has a maximum number of licenses that determine how many users
* can be active in an organization.
* Each plan has a maximum number of licenses, shipment limits, commission rates,
* feature flags, and support levels.
*
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
*/
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam';
export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium';
/**
* Legacy plan name mapping for backward compatibility during migration.
*/
const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
};
interface PlanDetails {
readonly name: string;
readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number;
readonly yearlyPriceEur: number;
readonly features: readonly string[];
readonly maxShipmentsPerYear: number; // -1 means unlimited
readonly commissionRatePercent: number;
readonly statusBadge: StatusBadge;
readonly supportLevel: SupportLevel;
readonly planFeatures: readonly PlanFeature[];
readonly features: readonly string[]; // Human-readable feature descriptions
}
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
FREE: {
name: 'Free',
maxLicenses: 2,
BRONZE: {
name: 'Bronze',
maxLicenses: 1,
monthlyPriceEur: 0,
yearlyPriceEur: 0,
features: [
'Up to 2 users',
'Basic rate search',
'Email support',
],
maxShipmentsPerYear: 12,
commissionRatePercent: 5,
statusBadge: 'none',
supportLevel: 'none',
planFeatures: PLAN_FEATURES.BRONZE,
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
},
STARTER: {
name: 'Starter',
SILVER: {
name: 'Silver',
maxLicenses: 5,
monthlyPriceEur: 49,
yearlyPriceEur: 470, // ~20% discount
monthlyPriceEur: 249,
yearlyPriceEur: 2739, // 249 * 11 months
maxShipmentsPerYear: -1,
commissionRatePercent: 3,
statusBadge: 'silver',
supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER,
features: [
'Up to 5 users',
'Advanced rate search',
'CSV imports',
'Priority email support',
"Jusqu'à 5 utilisateurs",
'Expéditions illimitées',
'Tableau de bord',
'Wiki Maritime',
'Gestion des utilisateurs',
'Import CSV',
'Support par email',
],
},
PRO: {
name: 'Pro',
GOLD: {
name: 'Gold',
maxLicenses: 20,
monthlyPriceEur: 149,
yearlyPriceEur: 1430, // ~20% discount
monthlyPriceEur: 899,
yearlyPriceEur: 9889, // 899 * 11 months
maxShipmentsPerYear: -1,
commissionRatePercent: 2,
statusBadge: 'gold',
supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD,
features: [
'Up to 20 users',
'All Starter features',
'API access',
'Custom integrations',
'Phone support',
"Jusqu'à 20 utilisateurs",
'Expéditions illimitées',
'Toutes les fonctionnalités Silver',
'Intégration API',
'Assistance commerciale directe',
],
},
ENTERPRISE: {
name: 'Enterprise',
PLATINIUM: {
name: 'Platinium',
maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing
maxShipmentsPerYear: -1,
commissionRatePercent: 1,
statusBadge: 'platinium',
supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM,
features: [
'Unlimited users',
'All Pro features',
'Dedicated account manager',
'Custom SLA',
'On-premise deployment option',
'Utilisateurs illimités',
'Toutes les fonctionnalités Gold',
'Key Account Manager dédié',
'Interface personnalisable',
'Contrats tarifaires cadre',
],
},
};
@ -78,36 +119,68 @@ export class SubscriptionPlan {
return new SubscriptionPlan(plan);
}
/**
* Create from string with legacy name support.
* Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names.
*/
static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase() as SubscriptionPlanType;
if (!PLAN_DETAILS[upperValue]) {
throw new Error(`Invalid subscription plan: ${value}`);
const upperValue = value.toUpperCase();
// Check legacy mapping first
const mapped = LEGACY_PLAN_MAPPING[upperValue];
if (mapped) {
return new SubscriptionPlan(mapped);
}
return new SubscriptionPlan(upperValue);
// Try direct match
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
return new SubscriptionPlan(upperValue as SubscriptionPlanType);
}
throw new Error(`Invalid subscription plan: ${value}`);
}
// Named factories
static bronze(): SubscriptionPlan {
return new SubscriptionPlan('BRONZE');
}
static silver(): SubscriptionPlan {
return new SubscriptionPlan('SILVER');
}
static gold(): SubscriptionPlan {
return new SubscriptionPlan('GOLD');
}
static platinium(): SubscriptionPlan {
return new SubscriptionPlan('PLATINIUM');
}
// Legacy aliases
static free(): SubscriptionPlan {
return new SubscriptionPlan('FREE');
return SubscriptionPlan.bronze();
}
static starter(): SubscriptionPlan {
return new SubscriptionPlan('STARTER');
return SubscriptionPlan.silver();
}
static pro(): SubscriptionPlan {
return new SubscriptionPlan('PRO');
return SubscriptionPlan.gold();
}
static enterprise(): SubscriptionPlan {
return new SubscriptionPlan('ENTERPRISE');
return SubscriptionPlan.platinium();
}
static getAllPlans(): SubscriptionPlan[] {
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
(p) => new SubscriptionPlan(p as SubscriptionPlanType),
return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map(
p => new SubscriptionPlan(p)
);
}
// Getters
get value(): SubscriptionPlanType {
return this.plan;
}
@ -132,6 +205,33 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].features;
}
get maxShipmentsPerYear(): number {
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
}
get commissionRatePercent(): number {
return PLAN_DETAILS[this.plan].commissionRatePercent;
}
get statusBadge(): StatusBadge {
return PLAN_DETAILS[this.plan].statusBadge;
}
get supportLevel(): SupportLevel {
return PLAN_DETAILS[this.plan].supportLevel;
}
get planFeatures(): readonly PlanFeature[] {
return PLAN_DETAILS[this.plan].planFeatures;
}
/**
* Check if this plan includes a specific feature
*/
hasFeature(feature: PlanFeature): boolean {
return this.planFeatures.includes(feature);
}
/**
* Returns true if this plan has unlimited licenses
*/
@ -140,17 +240,31 @@ export class SubscriptionPlan {
}
/**
* Returns true if this is a paid plan
* Returns true if this plan has unlimited shipments
*/
isPaid(): boolean {
return this.plan !== 'FREE';
hasUnlimitedShipments(): boolean {
return this.maxShipmentsPerYear === -1;
}
/**
* Returns true if this is the free plan
* Returns true if this is a paid plan
*/
isPaid(): boolean {
return this.plan !== 'BRONZE';
}
/**
* Returns true if this is the free (Bronze) plan
*/
isFree(): boolean {
return this.plan === 'FREE';
return this.plan === 'BRONZE';
}
/**
* Returns true if this plan has custom pricing (Platinium)
*/
isCustomPricing(): boolean {
return this.plan === 'PLATINIUM';
}
/**
@ -165,12 +279,7 @@ export class SubscriptionPlan {
* Check if upgrade to target plan is allowed
*/
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex;
@ -180,12 +289,7 @@ export class SubscriptionPlan {
* Check if downgrade to target plan is allowed given current user count
*/
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value);

View File

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

View File

@ -4,69 +4,157 @@
* Implements EmailPort using nodemailer
*/
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import * as https from 'https';
import { EmailPort, EmailOptions } from '@domain/ports/out/email.port';
import { EmailTemplates } from './templates/email-templates';
// Display names included → moins susceptibles d'être marqués spam
const EMAIL_SENDERS = {
SECURITY: '"Xpeditis Sécurité" <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()
export class EmailAdapter implements EmailPort {
export class EmailAdapter implements EmailPort, OnModuleInit {
private readonly logger = new Logger(EmailAdapter.name);
private transporter: nodemailer.Transporter;
constructor(
private readonly configService: ConfigService,
private readonly emailTemplates: EmailTemplates
) {
this.initializeTransporter();
) {}
async onModuleInit(): Promise<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 user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
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({
host: actualHost,
port,
secure,
auth: {
user,
pass,
},
// Configuration TLS avec servername pour IP directe
auth: { user, pass },
tls: {
rejectUnauthorized: false,
servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe
servername: serverName,
},
// Timeouts optimisés
connectionTimeout: 10000, // 10s
greetingTimeout: 10000, // 10s
socketTimeout: 30000, // 30s
dnsTimeout: 10000, // 10s
});
connectionTimeout: 15000,
greetingTimeout: 15000,
socketTimeout: 30000,
} as any);
this.logger.log(
`Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` +
(useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '')
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
);
this.transporter.verify((error) => {
if (error) {
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
} else {
this.logger.log(`✅ SMTP connection verified — ready to send emails`);
}
});
}
async send(options: EmailOptions): Promise<void> {
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,
to: options.to,
cc: options.cc,
@ -74,11 +162,13 @@ export class EmailAdapter implements EmailPort {
replyTo: options.replyTo,
subject: options.subject,
html: options.html,
text: options.text,
text,
attachments: options.attachments,
});
this.logger.log(`Email sent to ${options.to}: ${options.subject}`);
this.logger.log(
`✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}`
);
} catch (error) {
this.logger.error(`Failed to send email to ${options.to}`, error);
throw error;
@ -108,6 +198,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Booking Confirmation - ${bookingNumber}`,
html,
attachments,
@ -122,6 +213,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.SECURITY,
subject: 'Verify your email - Xpeditis',
html,
});
@ -135,6 +227,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.SECURITY,
subject: 'Reset your password - Xpeditis',
html,
});
@ -148,6 +241,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.NOREPLY,
subject: 'Welcome to Xpeditis',
html,
});
@ -169,6 +263,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.TEAM,
subject: `You've been invited to join ${organizationName} on Xpeditis`,
html,
});
@ -209,6 +304,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.TEAM,
subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`,
html,
});
@ -273,6 +369,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin}${bookingData.destination}`,
html,
});
@ -349,6 +446,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.CARRIERS,
subject: '🚢 Votre compte transporteur Xpeditis a été créé',
html,
});
@ -424,6 +522,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: email,
from: EMAIL_SENDERS.SECURITY,
subject: '🔑 Réinitialisation de votre mot de passe Xpeditis',
html,
});
@ -535,6 +634,7 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin}${data.destination}`,
html,
});
@ -614,10 +714,13 @@ export class EmailAdapter implements EmailPort {
await this.send({
to: carrierEmail,
from: EMAIL_SENDERS.BOOKINGS,
subject: `Nouveaux documents - Reservation ${data.origin}${data.destination}`,
html,
});
this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`);
this.logger.log(
`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`
);
}
}

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 })
specialInstructions: string | null;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
commissionRate: number | null;
@Column({
name: 'commission_amount_eur',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
commissionAmountEur: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

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

View File

@ -75,11 +75,11 @@ export class CsvBookingOrmEntity {
@Column({
name: 'status',
type: 'enum',
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING',
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING_PAYMENT',
})
@Index()
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' })
documents: Array<{
@ -141,6 +141,21 @@ export class CsvBookingOrmEntity {
@Column({ name: 'carrier_notes', type: 'text', nullable: true })
carrierNotes: string | null;
@Column({ name: 'stripe_payment_intent_id', type: 'varchar', length: 255, nullable: true })
stripePaymentIntentId: string | null;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
commissionRate: number | null;
@Column({
name: 'commission_amount_eur',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
commissionAmountEur: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;

View File

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

View File

@ -56,6 +56,15 @@ export class OrganizationOrmEntity {
@Column({ type: 'jsonb', default: '[]' })
documents: any[];
@Column({ type: 'varchar', length: 14, nullable: true })
siret: string | null;
@Column({ name: 'siret_verified', type: 'boolean', default: false })
siretVerified: boolean;
@Column({ name: 'status_badge', type: 'varchar', length: 20, default: 'none' })
statusBadge: string;
@Column({ name: 'is_carrier', type: 'boolean', default: false })
isCarrier: boolean;

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

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

View File

@ -42,7 +42,10 @@ export class CsvBookingMapper {
ormEntity.respondedAt,
ormEntity.notes,
ormEntity.rejectionReason,
ormEntity.bookingNumber ?? undefined
ormEntity.bookingNumber ?? undefined,
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
ormEntity.stripePaymentIntentId ?? undefined
);
}
@ -66,13 +69,16 @@ export class CsvBookingMapper {
primaryCurrency: domain.primaryCurrency,
transitDays: domain.transitDays,
containerType: domain.containerType,
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
status: domain.status as CsvBookingOrmEntity['status'],
documents: domain.documents as any,
confirmationToken: domain.confirmationToken,
requestedAt: domain.requestedAt,
respondedAt: domain.respondedAt,
notes: domain.notes,
rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
};
}
@ -81,10 +87,13 @@ export class CsvBookingMapper {
*/
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return {
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
status: domain.status as CsvBookingOrmEntity['status'],
respondedAt: domain.respondedAt,
notes: domain.notes,
rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import compression from 'compression';
import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
import type { Request, Response, NextFunction } from 'express';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
@ -19,6 +20,7 @@ async function bootstrap() {
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 4000);
const apiPrefix = configService.get<string>('API_PREFIX', 'api/v1');
const isProduction = configService.get<string>('NODE_ENV') === 'production';
// Use Pino logger
app.useLogger(app.get(Logger));
@ -52,39 +54,76 @@ async function bootstrap() {
})
);
// Swagger documentation
const config = new DocumentBuilder()
.setTitle('Xpeditis API')
.setDescription(
'Maritime Freight Booking Platform - API for searching rates and managing bookings'
)
.setVersion('1.0')
.addBearerAuth()
.addTag('rates', 'Rate search and comparison')
.addTag('bookings', 'Booking management')
.addTag('auth', 'Authentication and authorization')
.addTag('users', 'User management')
.addTag('organizations', 'Organization management')
.build();
// ─── Swagger documentation ────────────────────────────────────────────────
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');
const swaggerEnabled = !isProduction || (Boolean(swaggerUser) && Boolean(swaggerPass));
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
customSiteTitle: 'Xpeditis API Documentation',
customfavIcon: 'https://xpeditis.com/favicon.ico',
customCss: '.swagger-ui .topbar { display: none }',
});
if (swaggerEnabled) {
// HTTP Basic Auth guard for Swagger routes when credentials are configured
if (swaggerUser && swaggerPass) {
const swaggerPaths = ['/api/docs', '/api/docs-json', '/api/docs-yaml'];
app.use(swaggerPaths, (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="Xpeditis API Docs"');
res.status(401).send('Authentication required');
return;
}
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
const colonIndex = decoded.indexOf(':');
const user = decoded.slice(0, colonIndex);
const pass = decoded.slice(colonIndex + 1);
if (user !== swaggerUser || pass !== swaggerPass) {
res.setHeader('WWW-Authenticate', 'Basic realm="Xpeditis API Docs"');
res.status(401).send('Invalid credentials');
return;
}
next();
});
}
const config = new DocumentBuilder()
.setTitle('Xpeditis API')
.setDescription(
'Maritime Freight Booking Platform - API for searching rates and managing bookings'
)
.setVersion('1.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', name: 'x-api-key', in: 'header' }, 'x-api-key')
.addTag('rates', 'Rate search and comparison')
.addTag('bookings', 'Booking management')
.addTag('auth', 'Authentication and authorization')
.addTag('users', 'User management')
.addTag('organizations', 'Organization management')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
customSiteTitle: 'Xpeditis API Documentation',
customfavIcon: 'https://xpeditis.com/favicon.ico',
customCss: '.swagger-ui .topbar { display: none }',
});
}
// ─────────────────────────────────────────────────────────────────────────
await app.listen(port);
const swaggerStatus = swaggerEnabled
? swaggerUser
? `http://localhost:${port}/api/docs (protected)`
: `http://localhost:${port}/api/docs (open — add SWAGGER_USERNAME/PASSWORD to secure)`
: 'disabled in production';
console.log(`
🚢 Xpeditis API Server Running
API: http://localhost:${port}/${apiPrefix} ║
Docs: http://localhost:${port}/api/docs ║
🚢 Xpeditis API Server Running
API: http://localhost:${port}/${apiPrefix}
Docs: ${swaggerStatus}
`);
}

View File

@ -350,21 +350,30 @@ export default function AboutPage() {
</motion.div>
<div className="relative">
{/* Timeline line */}
<div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-1 h-full bg-brand-turquoise/20" />
{/* Timeline vertical rail + animated fill */}
<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">
{timeline.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }}
animate={isTimelineInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.6, delay: index * 0.1 }}
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }}
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'}`}
>
<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="flex items-center space-x-3 mb-3">
<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 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
<Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
</div>
@ -372,9 +381,18 @@ export default function AboutPage() {
<p className="text-gray-600">{item.description}</p>
</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 className="hidden lg:block flex-1" />
</motion.div>
))}

View File

@ -13,8 +13,13 @@ import {
Building2,
CheckCircle2,
Loader2,
Shield,
Zap,
BookOpen,
ArrowRight,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
import { sendContactForm } from '@/lib/api/auth';
export default function ContactPage() {
const [formData, setFormData] = useState({
@ -33,21 +38,36 @@ export default function ContactPage() {
const heroRef = useRef(null);
const formRef = useRef(null);
const contactRef = useRef(null);
const afterSubmitRef = useRef(null);
const quickAccessRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true });
const isFormInView = useInView(formRef, { once: true });
const isContactInView = useInView(contactRef, { once: true });
const isAfterSubmitInView = useInView(afterSubmitRef, { once: true });
const isQuickAccessInView = useInView(quickAccessRef, { once: true });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
// Simulate form submission
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsSubmitting(false);
setIsSubmitted(true);
try {
await sendContactForm({
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
company: formData.company || undefined,
phone: formData.phone || undefined,
subject: formData.subject,
message: formData.message,
});
setIsSubmitted(true);
} catch (err: any) {
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
} finally {
setIsSubmitting(false);
}
};
const handleChange = (
@ -65,7 +85,6 @@ export default function ContactPage() {
title: 'Email',
description: 'Envoyez-nous un email',
value: 'contact@xpeditis.com',
link: 'mailto:contact@xpeditis.com',
color: 'from-blue-500 to-cyan-500',
},
{
@ -73,7 +92,6 @@ export default function ContactPage() {
title: 'Téléphone',
description: 'Appelez-nous',
value: '+33 1 23 45 67 89',
link: 'tel:+33123456789',
color: 'from-green-500 to-emerald-500',
},
{
@ -81,15 +99,13 @@ export default function ContactPage() {
title: 'Chat en direct',
description: 'Discutez avec notre équipe',
value: 'Disponible 24/7',
link: '#chat',
color: 'from-purple-500 to-pink-500',
},
{
icon: Headphones,
title: 'Support',
description: 'Centre d\'aide',
value: 'support.xpeditis.com',
link: 'https://support.xpeditis.com',
description: 'Support client',
value: 'support@xpeditis.com',
color: 'from-orange-500 to-red-500',
},
];
@ -103,22 +119,6 @@ export default function ContactPage() {
email: 'paris@xpeditis.com',
isHQ: true,
},
{
city: 'Rotterdam',
address: 'Wilhelminakade 123',
postalCode: '3072 AP Rotterdam, Netherlands',
phone: '+31 10 123 4567',
email: 'rotterdam@xpeditis.com',
isHQ: false,
},
{
city: 'Hambourg',
address: 'Am Sandtorkai 50',
postalCode: '20457 Hamburg, Germany',
phone: '+49 40 123 4567',
email: 'hamburg@xpeditis.com',
isHQ: false,
},
];
const subjects = [
@ -219,22 +219,20 @@ export default function ContactPage() {
{contactMethods.map((method, index) => {
const IconComponent = method.icon;
return (
<motion.a
<motion.div
key={index}
href={method.link}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
>
<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" />
</div>
<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-brand-turquoise font-medium">{method.value}</p>
</motion.a>
</motion.div>
);
})}
</div>
@ -438,9 +436,9 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
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">
Retrouvez-nous dans nos bureaux à travers l'Europe ou contactez-nous par email.
Retrouvez-nous à Paris ou contactez-nous par email.
</p>
<div className="space-y-6">
@ -526,34 +524,154 @@ export default function ContactPage() {
</div>
</section>
{/* Map Section */}
<section className="py-20 bg-gray-50">
{/* Section 1 : Ce qui se passe après l'envoi */}
<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">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="text-center mb-12"
animate={isQuickAccessInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.7 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre présence en Europe</h2>
<p className="text-gray-600">
Des bureaux stratégiquement situés pour mieux vous servir
</p>
</motion.div>
<div className="text-center mb-10">
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
Accès rapide
</span>
<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
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="bg-white rounded-2xl shadow-lg overflow-hidden"
>
<div className="aspect-[21/9] bg-gradient-to-br from-brand-navy/5 to-brand-turquoise/5 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-16 h-16 text-brand-turquoise mx-auto mb-4" />
<p className="text-gray-500">Carte interactive bientôt disponible</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Tarification instantanée */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.6, delay: 0.15 }}
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-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<Zap className="w-7 h-7 text-white" />
</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>
</motion.div>
</div>

View File

@ -1,413 +1,548 @@
'use client';
import { useState, useEffect } from 'react';
import { getAllBookings } from '@/lib/api/admin';
interface Booking {
id: string;
bookingNumber?: string;
bookingId?: string;
type?: string;
status: string;
// CSV bookings use these fields
origin?: string;
destination?: string;
carrierName?: string;
// Regular bookings use these fields
originPort?: {
code: string;
name: string;
};
destinationPort?: {
code: string;
name: string;
};
carrier?: string;
containerType: string;
quantity?: number;
price?: number;
primaryCurrency?: string;
totalPrice?: {
amount: number;
currency: string;
};
createdAt?: string;
updatedAt?: string;
requestedAt?: string;
organizationId?: string;
userId?: string;
}
export default function AdminBookingsPage() {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
// Helper function to get formatted quote number
const getQuoteNumber = (booking: Booking): string => {
if (booking.type === 'csv') {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
}
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
};
useEffect(() => {
fetchBookings();
}, []);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await getAllBookings();
setBookings(response.bookings || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load bookings');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-blue-100 text-blue-800',
in_transit: 'bg-purple-100 text-purple-800',
delivered: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
};
const filteredBookings = bookings
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
.filter(booking => {
if (searchTerm === '') return true;
const searchLower = searchTerm.toLowerCase();
const quoteNumber = getQuoteNumber(booking).toLowerCase();
return (
quoteNumber.includes(searchLower) ||
booking.bookingNumber?.toLowerCase().includes(searchLower) ||
booking.carrier?.toLowerCase().includes(searchLower) ||
booking.carrierName?.toLowerCase().includes(searchLower) ||
booking.origin?.toLowerCase().includes(searchLower) ||
booking.destination?.toLowerCase().includes(searchLower)
);
});
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<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>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Booking Management</h1>
<p className="mt-1 text-sm text-gray-500">
View and manage all bookings across the platform
</p>
</div>
</div>
{/* 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">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
<input
type="text"
placeholder="Search by booking number or carrier..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="draft">Draft</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="in_transit">In Transit</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Total Réservations</div>
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">En Attente</div>
<div className="text-2xl font-bold text-yellow-600">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Acceptées</div>
<div className="text-2xl font-bold text-green-600">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Rejetées</div>
<div className="text-2xl font-bold text-red-600">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Numéro de devis
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Conteneur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Prix
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredBookings.map(booking => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{getQuoteNumber(booking)}
</div>
<div className="text-xs text-gray-500">
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{booking.originPort ? `${booking.originPort.code}${booking.destinationPort?.code}` : `${booking.origin}${booking.destination}`}
</div>
<div className="text-xs text-gray-500">
{booking.originPort ? `${booking.originPort.name}${booking.destinationPort?.name}` : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{booking.carrier || booking.carrierName || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{booking.containerType}</div>
<div className="text-xs text-gray-500">
{booking.quantity ? `Qty: ${booking.quantity}` : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
{booking.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{booking.totalPrice
? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}`
: booking.price
? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}`
: 'N/A'
}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailsModal(true);
}}
className="text-blue-600 hover:text-blue-900"
>
View Details
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Details Modal */}
{showDetailsModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Booking Details</h2>
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="text-gray-400 hover:text-gray-600"
>
<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">Numéro de devis</label>
<div className="mt-1 text-lg font-semibold">
{getQuoteNumber(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)}`}>
{selectedBooking.status}
</span>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origin</label>
<div className="mt-1">
{selectedBooking.originPort ? (
<>
<div className="font-semibold">{selectedBooking.originPort.code}</div>
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div>
</>
) : (
<div className="font-semibold">{selectedBooking.origin}</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1">
{selectedBooking.destinationPort ? (
<>
<div className="font-semibold">{selectedBooking.destinationPort.code}</div>
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div>
</>
) : (
<div className="font-semibold">{selectedBooking.destination}</div>
)}
</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Carrier</label>
<div className="mt-1 font-semibold">
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Container Type</label>
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div>
</div>
{selectedBooking.quantity && (
<div>
<label className="block text-sm font-medium text-gray-500">Quantity</label>
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div>
</div>
)}
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3>
<div className="text-2xl font-bold text-blue-600">
{selectedBooking.totalPrice
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}`
: selectedBooking.price
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}`
: 'N/A'
}
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Created</label>
<div className="mt-1">
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">Last Updated</label>
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end space-x-2 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"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}
'use client';
import { useState, useEffect } from 'react';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
interface Booking {
id: string;
bookingNumber?: string | null;
type?: string;
status: string;
origin?: string;
destination?: string;
carrierName?: string;
containerType: string;
volumeCBM?: number;
weightKG?: number;
palletCount?: number;
priceEUR?: number;
priceUSD?: number;
primaryCurrency?: string;
createdAt?: string;
requestedAt?: string;
updatedAt?: string;
organizationId?: string;
userId?: string;
}
export default function AdminBookingsPage() {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const [validatingId, setValidatingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
useEffect(() => {
fetchBookings();
}, []);
const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
setDeletingId(bookingId);
try {
await deleteAdminBooking(bookingId);
setBookings(prev => prev.filter(b => b.id !== bookingId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
} finally {
setDeletingId(null);
}
};
const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
setValidatingId(bookingId);
try {
await validateBankTransfer(bookingId);
await fetchBookings();
} catch (err: any) {
setError(err.message || 'Erreur lors de la validation du virement');
} finally {
setValidatingId(null);
}
};
const fetchBookings = async () => {
try {
setLoading(true);
const response = await getAllBookings();
setBookings(response.bookings || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Impossible de charger les réservations');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
pending_payment: 'bg-orange-100 text-orange-800',
pending_bank_transfer: 'bg-amber-100 text-amber-900',
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PENDING_PAYMENT: 'Paiement en attente',
PENDING_BANK_TRANSFER: 'Virement à valider',
PENDING: 'En attente transporteur',
ACCEPTED: 'Accepté',
REJECTED: 'Rejeté',
CANCELLED: 'Annulé',
};
return labels[status.toUpperCase()] || status;
};
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
const filteredBookings = bookings
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
.filter(booking => {
if (searchTerm === '') return true;
const s = searchTerm.toLowerCase();
return (
booking.bookingNumber?.toLowerCase().includes(s) ||
booking.id.toLowerCase().includes(s) ||
booking.carrierName?.toLowerCase().includes(s) ||
booking.origin?.toLowerCase().includes(s) ||
booking.destination?.toLowerCase().includes(s) ||
String(booking.palletCount || '').includes(s) ||
String(booking.weightKG || '').includes(s) ||
String(booking.volumeCBM || '').includes(s) ||
booking.containerType?.toLowerCase().includes(s)
);
});
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<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">Chargement des réservations...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
<p className="mt-1 text-sm text-gray-500">
Toutes les réservations de la plateforme
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Total</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
</div>
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
<div className="text-xs text-amber-700 uppercase tracking-wide">Virements à valider</div>
<div className="text-2xl font-bold text-amber-700 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
<div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div>
<div className="text-2xl font-bold text-green-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div>
<div className="text-2xl font-bold text-red-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div>
</div>
</div>
{/* 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">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
<input
type="text"
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
>
<option value="all">Tous les statuts</option>
<option value="pending_bank_transfer">Virement à valider</option>
<option value="pending_payment">Paiement en attente</option>
<option value="pending">En attente transporteur</option>
<option value="accepted">Accepté</option>
<option value="rejected">Rejeté</option>
<option value="cancelled">Annulé</option>
</select>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cargo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredBookings.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
Aucune réservation trouvée
</td>
</tr>
) : (
filteredBookings.map(booking => (
<tr key={booking.id} className="hover:bg-gray-50">
{/* N° Booking */}
<td className="px-4 py-4 whitespace-nowrap">
{booking.bookingNumber && (
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div>
)}
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
</td>
{/* Route */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.origin} {booking.destination}
</div>
</td>
{/* Cargo */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{booking.containerType}
{booking.palletCount != null && (
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
)}
</div>
<div className="text-xs text-gray-500 space-x-2">
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
</div>
</td>
{/* Transporteur */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{booking.carrierName || '—'}
</td>
{/* Statut */}
<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)}`}>
{getStatusLabel(booking.status)}
</span>
</td>
{/* Date */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
</td>
{/* Actions */}
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
<button
onClick={(e) => {
if (openMenuId === booking.id) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
setOpenMenuId(booking.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<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>
</td>
</tr>
))
)}
</tbody>
</table>
</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">
<button
onClick={() => {
const booking = bookings.find(b => b.id === openMenuId);
if (booking) {
setSelectedBooking(booking);
setShowDetailsModal(true);
}
setOpenMenuId(null);
setMenuPosition(null);
}}
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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<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" />
</svg>
<span className="text-sm font-medium text-gray-700">Voir les détails</span>
</button>
{(() => {
const booking = bookings.find(b => b.id === openMenuId);
return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? (
<button
onClick={() => {
const id = openMenuId;
setOpenMenuId(null);
setMenuPosition(null);
if (id) handleValidateTransfer(id);
}}
disabled={validatingId === openMenuId}
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"
>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span className="text-sm font-medium text-green-700">Valider virement</span>
</button>
) : null;
})()}
<button
onClick={() => {
const id = openMenuId;
setOpenMenuId(null);
setMenuPosition(null);
if (id) handleDeleteBooking(id);
}}
disabled={deletingId === openMenuId}
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>
</>
)}
{/* Details Modal */}
{showDetailsModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="text-gray-400 hover:text-gray-600"
>
<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 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Configurations CSV actives</CardTitle>
<CardDescription>
Liste de toutes les compagnies avec fichiers CSV configurés
</CardDescription>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Configurations CSV actives</CardTitle>
<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>
<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>
<CardContent>
{error && (
@ -120,6 +122,7 @@ export default function AdminCsvRatesPage() {
<TableHead>Taille</TableHead>
<TableHead>Lignes</TableHead>
<TableHead>Date d'upload</TableHead>
<TableHead>Email</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
@ -142,6 +145,11 @@ export default function AdminCsvRatesPage() {
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
</div>
</TableCell>
<TableCell>
<div className="text-xs text-muted-foreground">
{file.companyEmail ?? '—'}
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"

View File

@ -1,7 +1,7 @@
'use client';
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 type { ReactNode } from 'react';
@ -54,6 +54,9 @@ export default function AdminDocumentsPage() {
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
const [currentPage, setCurrentPage] = useState(1);
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
const getQuoteNumber = (booking: Booking): string => {
@ -265,6 +268,19 @@ export default function AdminDocumentsPage() {
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) => {
try {
// 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">
Utilisateur
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Télécharger
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
@ -468,15 +484,24 @@ export default function AdminDocumentsPage() {
{doc.userName || doc.userId.substring(0, 8) + '...'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document')}
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"
onClick={(e) => {
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">
<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 className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<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>
Télécharger
</button>
</td>
</tr>
@ -586,6 +611,60 @@ export default function AdminDocumentsPage() {
</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>
);
}

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';
import { useState, useEffect } from 'react';
import { getAllOrganizations } from '@/lib/api/admin';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
interface Organization {
@ -10,6 +10,9 @@ interface Organization {
type: string;
scac?: string;
siren?: string;
siret?: string;
siretVerified?: boolean;
statusBadge?: string;
eori?: string;
contact_phone?: string;
contact_email?: string;
@ -32,6 +35,7 @@ export default function AdminOrganizationsPage() {
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [verifyingId, setVerifyingId] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState<{
@ -39,6 +43,7 @@ export default function AdminOrganizationsPage() {
type: string;
scac: string;
siren: string;
siret: string;
eori: string;
contact_phone: string;
contact_email: string;
@ -55,6 +60,7 @@ export default function AdminOrganizationsPage() {
type: 'FREIGHT_FORWARDER',
scac: '',
siren: '',
siret: '',
eori: '',
contact_phone: '',
contact_email: '',
@ -130,6 +136,7 @@ export default function AdminOrganizationsPage() {
type: 'FREIGHT_FORWARDER',
scac: '',
siren: '',
siret: '',
eori: '',
contact_phone: '',
contact_email: '',
@ -144,6 +151,51 @@ export default function AdminOrganizationsPage() {
});
};
const handleVerifySiret = async (orgId: string) => {
try {
setVerifyingId(orgId);
const result = await verifySiret(orgId);
if (result.verified) {
alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`);
await fetchOrganizations();
} else {
alert(result.message || 'SIRET invalide ou introuvable.');
}
} catch (err: any) {
alert(err.message || 'Erreur lors de la verification du SIRET');
} finally {
setVerifyingId(null);
}
};
const handleApproveSiret = async (orgId: string) => {
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
try {
setVerifyingId(orgId);
const result = await approveSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || 'Erreur lors de l\'approbation');
} finally {
setVerifyingId(null);
}
};
const handleRejectSiret = async (orgId: string) => {
if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return;
try {
setVerifyingId(orgId);
const result = await rejectSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || 'Erreur lors du refus');
} finally {
setVerifyingId(null);
}
};
const openEditModal = (org: Organization) => {
setSelectedOrg(org);
setFormData({
@ -151,6 +203,7 @@ export default function AdminOrganizationsPage() {
type: org.type,
scac: org.scac || '',
siren: org.siren || '',
siret: org.siret || '',
eori: org.eori || '',
contact_phone: org.contact_phone || '',
contact_email: org.contact_email || '',
@ -229,6 +282,25 @@ export default function AdminOrganizationsPage() {
<span className="font-medium">SIREN:</span> {org.siren}
</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 && (
<div>
<span className="font-medium">Email:</span> {org.contact_email}
@ -239,13 +311,45 @@ export default function AdminOrganizationsPage() {
</div>
</div>
<div className="flex space-x-2">
<button
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>
<div className="space-y-2">
<div className="flex space-x-2">
<button
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>
{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>
))}
@ -309,6 +413,18 @@ export default function AdminOrganizationsPage() {
/>
</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>
<label className="block text-sm font-medium text-gray-700">EORI</label>
<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
const result = await createCsvBooking(formDataToSend);
// Redirect to success page
router.push(`/dashboard/bookings?success=true&id=${result.id}`);
// Redirect to commission payment page
router.push(`/dashboard/booking/${result.id}/pay`);
} catch (err) {
console.error('Booking creation error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue');

View File

@ -6,22 +6,31 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { Plus, Clock } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() {
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false);
const ITEMS_PER_PAGE = 20;
useEffect(() => {
if (searchParams.get('transfer') === 'declared') {
setShowTransferBanner(true);
}
}, [searchParams]);
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
const { data: csvData, isLoading, error: csvError } = useQuery({
queryKey: ['csv-bookings'],
@ -142,6 +151,21 @@ export default function BookingsListPage() {
return (
<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 */}
<div className="flex items-center justify-between">
<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