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
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:
parent
ab0ed187ed
commit
21e9584907
91
CLAUDE.md
91
CLAUDE.md
@ -35,6 +35,7 @@ npm run frontend:dev # http://localhost:3000
|
||||
# Backend (from apps/backend/)
|
||||
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/`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => ({
|
||||
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:
|
||||
configService.get('NODE_ENV') === 'development'
|
||||
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
|
||||
{
|
||||
|
||||
@ -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 {}
|
||||
|
||||
81
apps/backend/src/application/api-keys/api-keys.controller.ts
Normal file
81
apps/backend/src/application/api-keys/api-keys.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
apps/backend/src/application/api-keys/api-keys.module.ts
Normal file
45
apps/backend/src/application/api-keys/api-keys.module.ts
Normal 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 {}
|
||||
200
apps/backend/src/application/api-keys/api-keys.service.ts
Normal file
200
apps/backend/src/application/api-keys/api-keys.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -489,6 +489,7 @@ export class CsvRatesAdminController {
|
||||
size: fileSize,
|
||||
uploadedAt: config.uploadedAt.toISOString(),
|
||||
rowCount: config.rowCount,
|
||||
companyEmail: config.metadata?.companyEmail ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,15 +166,23 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Stripe Customer Portal session
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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) {}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
63
apps/backend/src/application/dto/api-key.dto.ts
Normal file
63
apps/backend/src/application/dto/api-key.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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)',
|
||||
|
||||
@ -81,7 +81,13 @@ export class DocumentWithUrlDto {
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Document type',
|
||||
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'],
|
||||
enum: [
|
||||
'BILL_OF_LADING',
|
||||
'PACKING_LIST',
|
||||
'COMMERCIAL_INVOICE',
|
||||
'CERTIFICATE_OF_ORIGIN',
|
||||
'OTHER',
|
||||
],
|
||||
})
|
||||
type: string;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* GDPR compliant consent management
|
||||
*/
|
||||
|
||||
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
55
apps/backend/src/application/guards/api-key-or-jwt.guard.ts
Normal file
55
apps/backend/src/application/guards/api-key-or-jwt.guard.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
108
apps/backend/src/application/guards/feature-flag.guard.ts
Normal file
108
apps/backend/src/application/guards/feature-flag.guard.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
export * from './api-key-or-jwt.guard';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
135
apps/backend/src/domain/entities/api-key.entity.ts
Normal file
135
apps/backend/src/domain/entities/api-key.entity.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/backend/src/domain/ports/out/api-key.repository.ts
Normal file
11
apps/backend/src/domain/ports/out/api-key.repository.ts
Normal 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>;
|
||||
}
|
||||
@ -15,6 +15,7 @@ export interface EmailAttachment {
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string | string[];
|
||||
from?: string;
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
replyTo?: string;
|
||||
|
||||
@ -35,6 +35,11 @@ export interface InvitationTokenRepository {
|
||||
*/
|
||||
deleteExpired(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Delete an invitation by id
|
||||
*/
|
||||
deleteById(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update an invitation token
|
||||
*/
|
||||
|
||||
15
apps/backend/src/domain/ports/out/shipment-counter.port.ts
Normal file
15
apps/backend/src/domain/ports/out/shipment-counter.port.ts
Normal 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>;
|
||||
}
|
||||
11
apps/backend/src/domain/ports/out/siret-verification.port.ts
Normal file
11
apps/backend/src/domain/ports/out/siret-verification.port.ts
Normal 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>;
|
||||
}
|
||||
@ -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
|
||||
|
||||
53
apps/backend/src/domain/value-objects/plan-feature.vo.ts
Normal file
53
apps/backend/src/domain/value-objects/plan-feature.vo.ts
Normal 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];
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
return new SubscriptionPlan(upperValue);
|
||||
const upperValue = value.toUpperCase();
|
||||
|
||||
// Check legacy mapping first
|
||||
const mapped = LEGACY_PLAN_MAPPING[upperValue];
|
||||
if (mapped) {
|
||||
return new SubscriptionPlan(mapped);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailAdapter implements EmailPort {
|
||||
export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
private readonly logger = new Logger(EmailAdapter.name);
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailTemplates: EmailTemplates
|
||||
) {
|
||||
this.initializeTransporter();
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<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;
|
||||
}
|
||||
|
||||
private initializeTransporter(): void {
|
||||
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
|
||||
// 🔧 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
apps/backend/src/infrastructure/external/pappers-siret.adapter.ts
vendored
Normal file
50
apps/backend/src/infrastructure/external/pappers-siret.adapter.ts
vendored
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 $$;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,7 +54,35 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
// Swagger documentation
|
||||
// ─── Swagger documentation ────────────────────────────────────────────────
|
||||
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
|
||||
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');
|
||||
const swaggerEnabled = !isProduction || (Boolean(swaggerUser) && Boolean(swaggerPass));
|
||||
|
||||
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(
|
||||
@ -60,6 +90,7 @@ async function bootstrap() {
|
||||
)
|
||||
.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')
|
||||
@ -73,18 +104,26 @@ async function bootstrap() {
|
||||
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 ║
|
||||
║ Docs: ${swaggerStatus} ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════╝
|
||||
╚═══════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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);
|
||||
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: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-12"
|
||||
className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden p-8 lg:p-12"
|
||||
>
|
||||
<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
|
||||
{/* 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, delay: 0.2 }}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden"
|
||||
animate={isQuickAccessInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<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&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>
|
||||
|
||||
@ -1,39 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAllBookings } from '@/lib/api/admin';
|
||||
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
bookingNumber?: string;
|
||||
bookingId?: string;
|
||||
bookingNumber?: string | null;
|
||||
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;
|
||||
volumeCBM?: number;
|
||||
weightKG?: number;
|
||||
palletCount?: number;
|
||||
priceEUR?: number;
|
||||
priceUSD?: number;
|
||||
primaryCurrency?: string;
|
||||
totalPrice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
requestedAt?: string;
|
||||
updatedAt?: string;
|
||||
organizationId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
@ -42,23 +29,45 @@ 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()}`;
|
||||
};
|
||||
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);
|
||||
@ -66,7 +75,7 @@ export default function AdminBookingsPage() {
|
||||
setBookings(response.bookings || []);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load bookings');
|
||||
setError(err.message || 'Impossible de charger les réservations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -74,29 +83,45 @@ export default function AdminBookingsPage() {
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
pending_payment: 'bg-orange-100 text-orange-800',
|
||||
pending_bank_transfer: 'bg-amber-100 text-amber-900',
|
||||
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',
|
||||
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 searchLower = searchTerm.toLowerCase();
|
||||
const quoteNumber = getQuoteNumber(booking).toLowerCase();
|
||||
const s = searchTerm.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)
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
@ -105,7 +130,7 @@ export default function AdminBookingsPage() {
|
||||
<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>
|
||||
<p className="mt-4 text-gray-600">Chargement des réservations...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -114,177 +139,274 @@ export default function AdminBookingsPage() {
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
View and manage all bookings across the platform
|
||||
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-2">Search</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by booking number or carrier..."
|
||||
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"
|
||||
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-2">Status Filter</label>
|
||||
<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"
|
||||
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">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>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<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-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Numéro de devis
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° Booking
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 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">
|
||||
<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-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">
|
||||
<th className="px-4 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 className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<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.map(booking => (
|
||||
{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">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{/* 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">
|
||||
{getQuoteNumber(booking)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
|
||||
{booking.origin} → {booking.destination}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
|
||||
{/* Cargo */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`}
|
||||
{booking.containerType}
|
||||
{booking.palletCount != null && (
|
||||
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''}
|
||||
<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>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{booking.carrier || booking.carrierName || 'N/A'}
|
||||
|
||||
{/* Transporteur */}
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{booking.carrierName || '—'}
|
||||
</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}
|
||||
|
||||
{/* 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>
|
||||
<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'
|
||||
}
|
||||
|
||||
{/* Date */}
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBooking(booking);
|
||||
setShowDetailsModal(true);
|
||||
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="text-blue-600 hover:text-blue-900"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
View Details
|
||||
<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">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
|
||||
<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">Booking Details</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedBooking(null);
|
||||
}}
|
||||
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">
|
||||
@ -296,113 +418,126 @@ export default function AdminBookingsPage() {
|
||||
<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)}
|
||||
<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)}`}>
|
||||
{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 Information</h3>
|
||||
<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">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>
|
||||
<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">
|
||||
{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 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">Shipping Details</h3>
|
||||
<div className="grid grid-cols-3 gap-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">Carrier</label>
|
||||
<div className="mt-1 font-semibold">
|
||||
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
|
||||
</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">Container Type</label>
|
||||
<div className="mt-1 font-semibold">{selectedBooking.containerType}</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.quantity && (
|
||||
{selectedBooking.palletCount != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Quantity</label>
|
||||
<div className="mt-1 font-semibold">{selectedBooking.quantity}</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">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'
|
||||
}
|
||||
<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">Timeline</h3>
|
||||
<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">Created</label>
|
||||
<div className="mt-1">
|
||||
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
|
||||
<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">Last Updated</label>
|
||||
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t">
|
||||
{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"
|
||||
>
|
||||
Close
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -81,7 +81,8 @@ export default function AdminCsvRatesPage() {
|
||||
|
||||
{/* Configurations Table */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Configurations CSV actives</CardTitle>
|
||||
<CardDescription>
|
||||
@ -95,6 +96,7 @@ export default function AdminCsvRatesPage() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
548
apps/frontend/app/dashboard/admin/logs/page.tsx
Normal file
548
apps/frontend/app/dashboard/admin/logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,6 +311,7 @@ export default function AdminOrganizationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(org)}
|
||||
@ -246,6 +319,37 @@ export default function AdminOrganizationsPage() {
|
||||
>
|
||||
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
|
||||
|
||||
437
apps/frontend/app/dashboard/booking/[id]/pay/page.tsx
Normal file
437
apps/frontend/app/dashboard/booking/[id]/pay/page.tsx
Normal 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 1–3 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
|
||||
“J'ai effectué le virement”.
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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 été 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>
|
||||
|
||||
7
apps/frontend/app/dashboard/docs/page.tsx
Normal file
7
apps/frontend/app/dashboard/docs/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { DocsPageContent } from '@/components/docs/DocsPageContent';
|
||||
|
||||
export default function DocsPage() {
|
||||
return <DocsPageContent basePath="/dashboard/docs" variant="dashboard" />;
|
||||
}
|
||||
@ -8,8 +8,8 @@
|
||||
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
||||
import Image from 'next/image';
|
||||
@ -22,23 +22,49 @@ import {
|
||||
Building2,
|
||||
Users,
|
||||
LogOut,
|
||||
Lock,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { useSubscription } from '@/lib/context/subscription-context';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import type { PlanFeature } from '@/lib/api/subscriptions';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, loading, isAuthenticated } = useAuth();
|
||||
const { hasFeature, subscription } = useSubscription();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
|
||||
useEffect(() => {
|
||||
if (!loading && !isAuthenticated) {
|
||||
router.replace(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
}, [loading, isAuthenticated, router, pathname]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-4 border-brand-turquoise border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
|
||||
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
|
||||
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
|
||||
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
|
||||
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
|
||||
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
|
||||
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
|
||||
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
|
||||
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
|
||||
{ name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
|
||||
// ADMIN and MANAGER only navigation items
|
||||
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
||||
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
|
||||
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
|
||||
] : []),
|
||||
];
|
||||
|
||||
@ -95,20 +121,26 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
|
||||
{navigation.map(item => (
|
||||
{navigation.map(item => {
|
||||
const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
href={locked ? '/pricing' : item.href}
|
||||
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
||||
isActive(item.href)
|
||||
locked
|
||||
? 'text-gray-400 hover:bg-gray-50'
|
||||
: isActive(item.href)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
<span className="flex-1">{item.name}</span>
|
||||
{locked && <Lock className="w-4 h-4 text-gray-300" />}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin Panel - ADMIN role only */}
|
||||
{user?.role === 'ADMIN' && (
|
||||
@ -126,9 +158,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
{user?.lastName?.[0]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
{subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && (
|
||||
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,12 +5,14 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dashboardApi } from '@/lib/api';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
PackageCheck,
|
||||
@ -21,6 +23,7 @@ import {
|
||||
Plus,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { useSubscription } from '@/lib/context/subscription-context';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
import {
|
||||
PieChart,
|
||||
@ -39,6 +42,16 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { hasFeature, loading: subLoading } = useSubscription();
|
||||
|
||||
// Redirect Bronze users (no dashboard feature) to bookings
|
||||
useEffect(() => {
|
||||
if (!subLoading && !hasFeature('dashboard')) {
|
||||
router.replace('/dashboard/bookings');
|
||||
}
|
||||
}, [subLoading, hasFeature, router]);
|
||||
|
||||
// Fetch CSV booking KPIs
|
||||
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'csv-booking-kpis'],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user