From 49b02face6ddc320f17a352b44f99a274184b969 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 15 Dec 2025 15:03:59 +0100 Subject: [PATCH] fix booking validate --- CLAUDE.md | 290 +++++++++++-- apps/backend/src/app.module.ts | 2 - .../controllers/carrier-auth.controller.ts | 152 ------- .../carrier-dashboard.controller.ts | 219 ---------- .../csv-booking-actions.controller.ts | 46 +- .../controllers/csv-bookings.controller.ts | 46 +- .../src/application/csv-bookings.module.ts | 15 +- .../src/application/dto/carrier-auth.dto.ts | 110 ----- .../modules/carrier-portal.module.ts | 85 ---- .../services/carrier-auth.service.spec.ts | 346 --------------- .../carrier-dashboard.service.spec.ts | 309 ------------- .../services/carrier-dashboard.service.ts | 408 ----------------- .../app/carrier/accept/[token]/page.tsx | 80 ++-- apps/frontend/app/carrier/confirmed/page.tsx | 161 ------- .../carrier/dashboard/bookings/[id]/page.tsx | 409 ------------------ .../frontend/app/carrier/dashboard/layout.tsx | 142 ------ apps/frontend/app/carrier/dashboard/page.tsx | 214 --------- apps/frontend/app/carrier/login/page.tsx | 137 ------ .../app/carrier/reject/[token]/page.tsx | 246 ++++------- apps/frontend/lib/api/organizations.ts | 3 - 20 files changed, 407 insertions(+), 3013 deletions(-) delete mode 100644 apps/backend/src/application/controllers/carrier-auth.controller.ts delete mode 100644 apps/backend/src/application/controllers/carrier-dashboard.controller.ts delete mode 100644 apps/backend/src/application/dto/carrier-auth.dto.ts delete mode 100644 apps/backend/src/application/modules/carrier-portal.module.ts delete mode 100644 apps/backend/src/application/services/carrier-auth.service.spec.ts delete mode 100644 apps/backend/src/application/services/carrier-dashboard.service.spec.ts delete mode 100644 apps/backend/src/application/services/carrier-dashboard.service.ts delete mode 100644 apps/frontend/app/carrier/confirmed/page.tsx delete mode 100644 apps/frontend/app/carrier/dashboard/bookings/[id]/page.tsx delete mode 100644 apps/frontend/app/carrier/dashboard/layout.tsx delete mode 100644 apps/frontend/app/carrier/dashboard/page.tsx delete mode 100644 apps/frontend/app/carrier/login/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index df72cf9..283148a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,42 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Active Feature**: Carrier Portal (Branch: `feature_dashboard_transporteur`) - Dedicated portal for carriers to manage booking requests, view statistics, and download documents. +## Repository Structure + +This is a **monorepo** containing both backend and frontend applications: + +``` +/Users/david/Documents/xpeditis/dev/xpeditis2.0/ +├── apps/ +│ ├── backend/ # NestJS API (Node.js 20+, TypeScript 5+) +│ │ ├── src/ +│ │ │ ├── domain/ # Pure business logic (no framework deps) +│ │ │ ├── application/ # Controllers, DTOs, Guards +│ │ │ └── infrastructure/ # ORM, Cache, External APIs +│ │ ├── test/ # Integration & E2E tests +│ │ ├── load-tests/ # K6 load testing scripts +│ │ └── package.json # Backend dependencies +│ └── frontend/ # Next.js 14 App Router (React 18) +│ ├── app/ # Next.js App Router pages +│ ├── src/ # Components, hooks, utilities +│ ├── e2e/ # Playwright E2E tests +│ └── package.json # Frontend dependencies +├── infra/ +│ └── postgres/ # PostgreSQL init scripts +├── docker/ # Docker build & deployment configs +├── docker-compose.yml # Local development infrastructure +├── package.json # Root monorepo package.json (workspace scripts) +├── .prettierrc # Prettier configuration (shared) +├── .github/workflows/ # GitHub Actions CI/CD pipelines +└── CLAUDE.md # This file (architecture guide) +``` + +**Workspace Management**: +- Root `package.json` contains monorepo-level scripts +- Each app has its own `package.json` with specific dependencies +- Use root-level commands (`npm run backend:dev`) for convenience +- Or navigate to specific app and run commands directly + ## Development Commands ### Local Development Setup @@ -155,8 +191,17 @@ npm run migration:run # Revert last migration npm run migration:revert + +# Show migration status +npm run migration:show ``` +**Important Migration Notes**: +- Migration files use Unix timestamp format: `1733185000000-DescriptiveName.ts` +- Always test migrations in development before running in production +- Migrations run automatically via TypeORM DataSource configuration +- Never modify existing migrations that have been applied to production + ### Build & Production ```bash @@ -179,25 +224,34 @@ The backend follows strict hexagonal architecture with three isolated layers: ``` apps/backend/src/ -├── application/ # 🔌 Controllers & DTOs (depends ONLY on domain) -│ ├── auth/ # JWT authentication module -│ ├── rates/ # Rate search endpoints -│ ├── bookings/ # Booking management +├── domain/ # 🔵 CORE - Pure business logic (NO framework dependencies) +│ ├── entities/ # Business entities (Booking, RateQuote, User, CarrierProfile) +│ ├── value-objects/ # Immutable VOs (Money, Email, BookingNumber, Port) +│ ├── services/ # Domain services (pure TypeScript) +│ ├── ports/ +│ │ ├── in/ # API Ports (use cases exposed by domain) +│ │ └── out/ # SPI Ports (interfaces required by domain) +│ └── exceptions/ # Domain exceptions +│ +├── application/ # 🔌 Controllers & DTOs (depends ONLY on domain) +│ ├── auth/ # JWT authentication module +│ ├── rates/ # Rate search endpoints +│ ├── bookings/ # Booking management │ ├── csv-bookings.module.ts # CSV booking imports │ ├── modules/ │ │ └── carrier-portal.module.ts # Carrier portal feature -│ ├── controllers/ # REST endpoints +│ ├── controllers/ # REST endpoints │ │ ├── carrier-auth.controller.ts │ │ └── carrier-dashboard.controller.ts -│ ├── dto/ # Data transfer objects with validation +│ ├── dto/ # Data transfer objects with validation │ │ └── carrier-auth.dto.ts -│ ├── services/ # Application services +│ ├── services/ # Application services │ │ ├── carrier-auth.service.ts │ │ └── carrier-dashboard.service.ts -│ ├── guards/ # Auth guards, rate limiting, RBAC -│ └── mappers/ # DTO ↔ Domain entity mapping +│ ├── guards/ # Auth guards, rate limiting, RBAC +│ └── mappers/ # DTO ↔ Domain entity mapping │ -└── infrastructure/ # 🏗️ External integrations (depends ONLY on domain) +└── infrastructure/ # 🏗️ External integrations (depends ONLY on domain) ├── persistence/typeorm/ # PostgreSQL repositories │ ├── entities/ │ │ ├── carrier-profile.orm-entity.ts @@ -207,25 +261,52 @@ apps/backend/src/ │ ├── repositories/ │ │ ├── carrier-profile.repository.ts │ │ └── carrier-activity.repository.ts + │ ├── mappers/ # Domain ↔ ORM entity mappers │ └── migrations/ │ ├── 1733185000000-CreateCarrierProfiles.ts │ ├── 1733186000000-CreateCarrierActivities.ts │ ├── 1733187000000-AddCarrierToCsvBookings.ts │ └── 1733188000000-AddCarrierFlagToOrganizations.ts - ├── cache/ # Redis adapter - ├── carriers/ # Maersk, MSC, CMA CGM connectors + ├── cache/ # Redis adapter + ├── carriers/ # Maersk, MSC, CMA CGM connectors │ └── csv-loader/ # CSV-based rate connector - ├── email/ # MJML email service (carrier notifications) - ├── storage/ # S3 storage adapter - ├── websocket/ # Real-time carrier updates - └── security/ # Helmet.js, rate limiting, CORS + ├── email/ # MJML email service (carrier notifications) + ├── storage/ # S3 storage adapter + ├── websocket/ # Real-time carrier updates + └── security/ # Helmet.js, rate limiting, CORS ``` **Critical Rules**: -1. **Domain layer**: No imports of NestJS, TypeORM, Redis, or any framework (domain layer not shown - pure business logic) -2. **Dependencies flow inward**: Infrastructure → Application → Domain +1. **Domain layer**: No imports of NestJS, TypeORM, Redis, or any framework - pure TypeScript only +2. **Dependencies flow inward**: Infrastructure → Application → Domain (never the reverse) 3. **TypeScript path aliases**: Use `@domain/*`, `@application/*`, `@infrastructure/*` 4. **Testing**: Domain tests must run without NestJS TestingModule +5. **Mappers**: Use dedicated mapper classes for Domain ↔ ORM and Domain ↔ DTO conversions + +**Example - Domain Entity Structure**: +```typescript +// apps/backend/src/domain/entities/booking.entity.ts +export class Booking { + private readonly props: BookingProps; + + static create(props: Omit): Booking { + const bookingProps: BookingProps = { + ...props, + bookingNumber: BookingNumber.generate(), + status: BookingStatus.create('draft'), + }; + Booking.validate(bookingProps); + return new Booking(bookingProps); + } + + updateStatus(newStatus: BookingStatus): Booking { + if (!this.status.canTransitionTo(newStatus)) { + throw new InvalidStatusTransitionException(); + } + return new Booking({ ...this.props, status: newStatus }); + } +} +``` ### Frontend Architecture (Next.js 14 App Router) @@ -632,33 +713,69 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) - ORM Entities: `booking.orm-entity.ts`, `carrier-profile.orm-entity.ts` - Migrations: `1730000000001-CreateBookings.ts`, `1733185000000-CreateCarrierProfiles.ts` +## Key Architectural Patterns Used + +### 1. Domain-Driven Design (DDD) +- **Entities**: Mutable objects with identity (e.g., `Booking`, `User`) +- **Value Objects**: Immutable, identity-less objects (e.g., `Money`, `Email`, `BookingNumber`) +- **Aggregates**: Cluster of entities/VOs treated as a unit (e.g., `Booking` with `Container` items) +- **Domain Services**: Stateless operations that don't belong to entities +- **Domain Events**: Not yet implemented (planned for Phase 5) + +### 2. Repository Pattern +- **Interface in Domain**: `apps/backend/src/domain/ports/out/booking.repository.port.ts` +- **Implementation in Infrastructure**: `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts` +- **Mapper Pattern**: Separate mappers for Domain ↔ ORM entity conversion + +### 3. DTO Pattern +- **Request DTOs**: Validate incoming API requests with `class-validator` +- **Response DTOs**: Control API response shape +- **Mappers**: Convert between DTOs and Domain entities in application layer + +### 4. Circuit Breaker Pattern +- Used for external carrier API calls (Maersk, MSC, CMA CGM) +- Library: `opossum` +- Timeout: 5 seconds per carrier +- Location: `apps/backend/src/infrastructure/carriers/*/` + +### 5. Caching Strategy +- **Redis for rate quotes**: 15-minute TTL +- **Cache-aside pattern**: Check cache first, fetch from carriers on miss +- **Cache key format**: `rate:{origin}:{destination}:{containerType}` + ## Common Pitfalls to Avoid ❌ **DO NOT**: - Import NestJS/TypeORM in domain layer - Put business logic in controllers or repositories -- Use `any` type (strict mode enabled) +- Use `any` type (strict mode enabled in backend) - Skip writing tests (coverage targets enforced) -- Use `DATABASE_SYNC=true` in production -- Commit `.env` files -- Expose sensitive data in API responses +- Use `DATABASE_SYNC=true` in production (always use migrations) +- Commit `.env` files (use `.env.example` templates) +- Expose sensitive data in API responses (passwords, tokens, internal IDs) - Skip rate limiting on public endpoints -- Use circular imports (leverage barrel exports) +- Use circular imports (leverage barrel exports with `index.ts`) - Send emails without proper error handling - Store plain text passwords (always use Argon2) +- Modify applied migrations (create new migration instead) +- Mix domain logic with framework code ✅ **DO**: -- Follow hexagonal architecture strictly -- Write tests for all new features (domain 90%+) +- Follow hexagonal architecture strictly (Infrastructure → Application → Domain) +- Write tests for all new features (domain 90%+, application 80%+) - Use TypeScript path aliases (`@domain/*`, `@application/*`, `@infrastructure/*`) -- Validate all DTOs with `class-validator` -- Implement circuit breakers for external APIs -- Cache frequently accessed data (Redis) -- Use structured logging (Pino) -- Document APIs with Swagger decorators -- Run migrations before deployment +- Validate all DTOs with `class-validator` decorators +- Implement circuit breakers for external APIs (carrier connectors) +- Cache frequently accessed data (Redis with TTL) +- Use structured logging (Pino JSON format) +- Document APIs with Swagger decorators (`@ApiOperation`, `@ApiResponse`) +- Run migrations before deployment (`npm run migration:run`) - Test email sending in development with test accounts - Use MJML for responsive email templates +- Create dedicated mappers for Domain ↔ ORM conversions +- Use Value Objects for domain concepts (Money, Email, etc.) +- Implement proper error handling with domain exceptions +- Use immutability in domain entities (return new instances on updates) ## Documentation @@ -688,6 +805,114 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) - [docker/DOCKER_BUILD_GUIDE.md](docker/DOCKER_BUILD_GUIDE.md) - Docker build instructions - [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) - Pre-deployment checklist +## Quick Reference - Common Tasks + +### Running a Single Test File +```bash +# Backend unit test +cd apps/backend +npm test -- booking.entity.spec.ts + +# Backend integration test +npm run test:integration -- booking.repository.spec.ts + +# Backend E2E test +npm run test:e2e -- carrier-portal.e2e-spec.ts + +# Frontend test +cd apps/frontend +npm test -- BookingForm.test.tsx +``` + +### Debugging TypeScript Path Aliases +If imports like `@domain/*` don't resolve: +1. Check `apps/backend/tsconfig.json` has correct `paths` configuration +2. Verify VS Code is using workspace TypeScript version +3. Restart TypeScript server in VS Code: `Cmd+Shift+P` → "TypeScript: Restart TS Server" + +### Common Environment Issues + +**PostgreSQL connection fails**: +```bash +# Verify PostgreSQL container is running +docker ps | grep xpeditis-postgres + +# Check PostgreSQL logs +docker logs xpeditis-postgres + +# Restart PostgreSQL +docker-compose restart postgres +``` + +**Redis connection fails**: +```bash +# Verify Redis container is running +docker ps | grep xpeditis-redis + +# Test Redis connection +docker exec -it xpeditis-redis redis-cli -a xpeditis_redis_password ping +# Expected: PONG + +# Restart Redis +docker-compose restart redis +``` + +**Migrations fail**: +```bash +# Check migration status +npm run migration:show + +# If stuck, revert and try again +npm run migration:revert +npm run migration:run +``` + +### Adding a New Feature (Step-by-Step) + +1. **Create Domain Entity** (if needed): + - Location: `apps/backend/src/domain/entities/` + - Pure TypeScript, no framework imports + - Write unit tests: `*.entity.spec.ts` + +2. **Create Value Objects** (if needed): + - Location: `apps/backend/src/domain/value-objects/` + - Immutable, validated in constructor + - Write unit tests: `*.vo.spec.ts` + +3. **Define Domain Port Interface**: + - Location: `apps/backend/src/domain/ports/out/` + - Interface only, no implementation + +4. **Create ORM Entity**: + - Location: `apps/backend/src/infrastructure/persistence/typeorm/entities/` + - File naming: `*.orm-entity.ts` + - Add `@Entity()` decorator + +5. **Generate Migration**: + ```bash + npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateFeatureName + ``` + +6. **Implement Repository**: + - Location: `apps/backend/src/infrastructure/persistence/typeorm/repositories/` + - Implements domain port interface + - Write integration tests + +7. **Create DTOs**: + - Location: `apps/backend/src/application/dto/` + - Add `class-validator` decorators + +8. **Create Controller**: + - Location: `apps/backend/src/application/controllers/` + - Add Swagger decorators + - Write E2E tests + +9. **Create Application Module**: + - Location: `apps/backend/src/application/modules/` + - Register controllers, services, repositories + +10. **Import Module in App.module.ts** + ## Code Review Checklist 1. Hexagonal architecture principles followed @@ -702,3 +927,6 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) 10. ESLint passes with no warnings 11. Email templates tested in development 12. Carrier workflow tested end-to-end +13. Database migrations tested in development +14. ORM entities have corresponding domain entities +15. Mappers created for Domain ↔ ORM conversions diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 49cbc8b..d993fad 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -18,7 +18,6 @@ import { NotificationsModule } from './application/notifications/notifications.m import { WebhooksModule } from './application/webhooks/webhooks.module'; import { GDPRModule } from './application/gdpr/gdpr.module'; import { CsvBookingsModule } from './application/csv-bookings.module'; -import { CarrierPortalModule } from './application/modules/carrier-portal.module'; import { CacheModule } from './infrastructure/cache/cache.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { SecurityModule } from './infrastructure/security/security.module'; @@ -109,7 +108,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; PortsModule, BookingsModule, CsvBookingsModule, - CarrierPortalModule, OrganizationsModule, UsersModule, DashboardModule, diff --git a/apps/backend/src/application/controllers/carrier-auth.controller.ts b/apps/backend/src/application/controllers/carrier-auth.controller.ts deleted file mode 100644 index f8ab538..0000000 --- a/apps/backend/src/application/controllers/carrier-auth.controller.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Carrier Auth Controller - * - * Handles carrier authentication endpoints - */ - -import { - Controller, - Post, - Body, - HttpCode, - HttpStatus, - UseGuards, - Request, - Get, - Patch, - Logger, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { CarrierAuthService } from '../services/carrier-auth.service'; -import { Public } from '../decorators/public.decorator'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { - CarrierLoginDto, - CarrierChangePasswordDto, - CarrierPasswordResetRequestDto, - CarrierLoginResponseDto, - CarrierProfileResponseDto, -} from '../dto/carrier-auth.dto'; -import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; - -@ApiTags('Carrier Auth') -@Controller('carrier-auth') -export class CarrierAuthController { - private readonly logger = new Logger(CarrierAuthController.name); - - constructor( - private readonly carrierAuthService: CarrierAuthService, - private readonly carrierProfileRepository: CarrierProfileRepository, - ) {} - - @Public() - @Post('login') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Carrier login with email and password' }) - @ApiResponse({ - status: 200, - description: 'Login successful', - type: CarrierLoginResponseDto, - }) - @ApiResponse({ status: 401, description: 'Invalid credentials' }) - async login(@Body() dto: CarrierLoginDto): Promise { - this.logger.log(`Carrier login attempt: ${dto.email}`); - return await this.carrierAuthService.login(dto.email, dto.password); - } - - @UseGuards(JwtAuthGuard) - @Get('me') - @ApiBearerAuth() - @ApiOperation({ summary: 'Get current carrier profile' }) - @ApiResponse({ - status: 200, - description: 'Profile retrieved', - type: CarrierProfileResponseDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getProfile(@Request() req: any): Promise { - this.logger.log(`Getting profile for carrier: ${req.user.carrierId}`); - - const carrier = await this.carrierProfileRepository.findById(req.user.carrierId); - - if (!carrier) { - throw new Error('Carrier profile not found'); - } - - return { - id: carrier.id, - userId: carrier.userId, - companyName: carrier.companyName, - email: carrier.user?.email, - role: 'CARRIER', - organizationId: carrier.organizationId, - phone: carrier.phone, - website: carrier.website, - city: carrier.city, - country: carrier.country, - isVerified: carrier.isVerified, - isActive: carrier.isActive, - totalBookingsAccepted: carrier.totalBookingsAccepted, - totalBookingsRejected: carrier.totalBookingsRejected, - acceptanceRate: carrier.acceptanceRate, - totalRevenueUsd: carrier.totalRevenueUsd, - totalRevenueEur: carrier.totalRevenueEur, - preferredCurrency: carrier.preferredCurrency, - lastLoginAt: carrier.lastLoginAt, - }; - } - - @UseGuards(JwtAuthGuard) - @Patch('change-password') - @ApiBearerAuth() - @ApiOperation({ summary: 'Change carrier password' }) - @ApiResponse({ status: 200, description: 'Password changed successfully' }) - @ApiResponse({ status: 401, description: 'Invalid old password' }) - async changePassword( - @Request() req: any, - @Body() dto: CarrierChangePasswordDto - ): Promise<{ message: string }> { - this.logger.log(`Password change request for carrier: ${req.user.carrierId}`); - - await this.carrierAuthService.changePassword( - req.user.carrierId, - dto.oldPassword, - dto.newPassword - ); - - return { - message: 'Password changed successfully', - }; - } - - @Public() - @Post('request-password-reset') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Request password reset (sends temporary password)' }) - @ApiResponse({ status: 200, description: 'Password reset email sent' }) - async requestPasswordReset( - @Body() dto: CarrierPasswordResetRequestDto - ): Promise<{ message: string }> { - this.logger.log(`Password reset requested for: ${dto.email}`); - - await this.carrierAuthService.requestPasswordReset(dto.email); - - return { - message: 'If this email exists, a password reset will be sent', - }; - } - - @Public() - @Post('verify-auto-login') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Verify auto-login token from email link' }) - @ApiResponse({ status: 200, description: 'Token verified' }) - @ApiResponse({ status: 401, description: 'Invalid or expired token' }) - async verifyAutoLoginToken( - @Body() body: { token: string } - ): Promise<{ userId: string; carrierId: string }> { - this.logger.log('Verifying auto-login token'); - - return await this.carrierAuthService.verifyAutoLoginToken(body.token); - } -} diff --git a/apps/backend/src/application/controllers/carrier-dashboard.controller.ts b/apps/backend/src/application/controllers/carrier-dashboard.controller.ts deleted file mode 100644 index b6f17a9..0000000 --- a/apps/backend/src/application/controllers/carrier-dashboard.controller.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Carrier Dashboard Controller - * - * Handles carrier dashboard, bookings, and document endpoints - */ - -import { - Controller, - Get, - Post, - Param, - Query, - Body, - UseGuards, - Request, - Res, - ParseIntPipe, - DefaultValuePipe, - Logger, - HttpStatus, - HttpCode, -} from '@nestjs/common'; -import { Response } from 'express'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiParam, - ApiQuery, -} from '@nestjs/swagger'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { - CarrierDashboardService, - CarrierDashboardStats, - CarrierBookingListItem, -} from '../services/carrier-dashboard.service'; - -@ApiTags('Carrier Dashboard') -@Controller('carrier-dashboard') -@UseGuards(JwtAuthGuard) -@ApiBearerAuth() -export class CarrierDashboardController { - private readonly logger = new Logger(CarrierDashboardController.name); - - constructor(private readonly carrierDashboardService: CarrierDashboardService) {} - - @Get('stats') - @ApiOperation({ summary: 'Get carrier dashboard statistics' }) - @ApiResponse({ - status: 200, - description: 'Statistics retrieved successfully', - schema: { - type: 'object', - properties: { - totalBookings: { type: 'number' }, - pendingBookings: { type: 'number' }, - acceptedBookings: { type: 'number' }, - rejectedBookings: { type: 'number' }, - acceptanceRate: { type: 'number' }, - totalRevenue: { - type: 'object', - properties: { - usd: { type: 'number' }, - eur: { type: 'number' }, - }, - }, - recentActivities: { type: 'array' }, - }, - }, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - @ApiResponse({ status: 404, description: 'Carrier not found' }) - async getStats(@Request() req: any): Promise { - const carrierId = req.user.carrierId; - this.logger.log(`Fetching stats for carrier: ${carrierId}`); - - return await this.carrierDashboardService.getCarrierStats(carrierId); - } - - @Get('bookings') - @ApiOperation({ summary: 'Get carrier bookings list with pagination' }) - @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) - @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)' }) - @ApiQuery({ name: 'status', required: false, type: String, description: 'Filter by status (PENDING, ACCEPTED, REJECTED)' }) - @ApiResponse({ - status: 200, - description: 'Bookings retrieved successfully', - schema: { - type: 'object', - properties: { - data: { type: 'array' }, - total: { type: 'number' }, - page: { type: 'number' }, - limit: { type: 'number' }, - }, - }, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getBookings( - @Request() req: any, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, - @Query('status') status?: string - ): Promise<{ - data: CarrierBookingListItem[]; - total: number; - page: number; - limit: number; - }> { - const carrierId = req.user.carrierId; - this.logger.log(`Fetching bookings for carrier: ${carrierId} (page: ${page}, limit: ${limit}, status: ${status})`); - - return await this.carrierDashboardService.getCarrierBookings( - carrierId, - page, - limit, - status - ); - } - - @Get('bookings/:id') - @ApiOperation({ summary: 'Get booking details with documents' }) - @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) - @ApiResponse({ status: 200, description: 'Booking details retrieved' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - @ApiResponse({ status: 403, description: 'Access denied to this booking' }) - async getBookingDetails(@Request() req: any, @Param('id') bookingId: string): Promise { - const carrierId = req.user.carrierId; - this.logger.log(`Fetching booking details: ${bookingId} for carrier: ${carrierId}`); - - return await this.carrierDashboardService.getBookingDetails(carrierId, bookingId); - } - - @Get('bookings/:bookingId/documents/:documentId/download') - @ApiOperation({ summary: 'Download booking document' }) - @ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' }) - @ApiParam({ name: 'documentId', description: 'Document ID' }) - @ApiResponse({ status: 200, description: 'Document downloaded successfully' }) - @ApiResponse({ status: 403, description: 'Access denied to this document' }) - @ApiResponse({ status: 404, description: 'Document not found' }) - async downloadDocument( - @Request() req: any, - @Param('bookingId') bookingId: string, - @Param('documentId') documentId: string, - @Res() res: Response - ): Promise { - const carrierId = req.user.carrierId; - this.logger.log(`Downloading document ${documentId} from booking ${bookingId} for carrier ${carrierId}`); - - const { document } = await this.carrierDashboardService.downloadDocument( - carrierId, - bookingId, - documentId - ); - - // For now, return document metadata as JSON - // TODO: Implement actual file download from S3/MinIO - res.status(HttpStatus.OK).json({ - message: 'Document download not yet implemented', - document, - // When S3/MinIO is implemented, set headers and stream: - // res.set({ - // 'Content-Type': mimeType, - // 'Content-Disposition': `attachment; filename="${fileName}"`, - // }); - // return new StreamableFile(buffer); - }); - } - - @Post('bookings/:id/accept') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Accept a booking' }) - @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) - @ApiResponse({ status: 200, description: 'Booking accepted successfully' }) - @ApiResponse({ status: 403, description: 'Access denied or booking not in pending status' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - async acceptBooking( - @Request() req: any, - @Param('id') bookingId: string, - @Body() body: { notes?: string } - ): Promise<{ message: string }> { - const carrierId = req.user.carrierId; - this.logger.log(`Accepting booking ${bookingId} by carrier ${carrierId}`); - - await this.carrierDashboardService.acceptBooking(carrierId, bookingId, body.notes); - - return { - message: 'Booking accepted successfully', - }; - } - - @Post('bookings/:id/reject') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Reject a booking' }) - @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) - @ApiResponse({ status: 200, description: 'Booking rejected successfully' }) - @ApiResponse({ status: 403, description: 'Access denied or booking not in pending status' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - async rejectBooking( - @Request() req: any, - @Param('id') bookingId: string, - @Body() body: { reason?: string; notes?: string } - ): Promise<{ message: string }> { - const carrierId = req.user.carrierId; - this.logger.log(`Rejecting booking ${bookingId} by carrier ${carrierId}`); - - await this.carrierDashboardService.rejectBooking( - carrierId, - bookingId, - body.reason, - body.notes - ); - - return { - message: 'Booking rejected successfully', - }; - } -} diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts index e9e29a9..10c0e91 100644 --- a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -2,7 +2,6 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; -import { CarrierAuthService } from '../services/carrier-auth.service'; /** * CSV Booking Actions Controller (Public Routes) @@ -14,8 +13,7 @@ import { CarrierAuthService } from '../services/carrier-auth.service'; @Controller('csv-booking-actions') export class CsvBookingActionsController { constructor( - private readonly csvBookingService: CsvBookingService, - private readonly carrierAuthService: CarrierAuthService + private readonly csvBookingService: CsvBookingService ) {} /** @@ -33,7 +31,7 @@ export class CsvBookingActionsController { @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) @ApiResponse({ status: 200, - description: 'Booking accepted successfully. Returns auto-login token and booking details.', + description: 'Booking accepted successfully.', }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ @@ -41,28 +39,13 @@ export class CsvBookingActionsController { description: 'Booking cannot be accepted (invalid status or expired)', }) async acceptBooking(@Param('token') token: string) { - // 1. Accept the booking + // Accept the booking const booking = await this.csvBookingService.acceptBooking(token); - // 2. Create carrier account if it doesn't exist - const { carrierId, userId, isNewAccount } = - await this.carrierAuthService.createCarrierAccountIfNotExists( - booking.carrierEmail, - booking.carrierName - ); - - // 3. Link the booking to the carrier - await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); - - // 4. Generate auto-login token - const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); - - // 5. Return JSON response for frontend to handle + // Return simple success response return { success: true, - autoLoginToken, bookingId: booking.id, - isNewAccount, action: 'accepted', }; } @@ -88,7 +71,7 @@ export class CsvBookingActionsController { }) @ApiResponse({ status: 200, - description: 'Booking rejected successfully. Returns auto-login token and booking details.', + description: 'Booking rejected successfully.', }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ @@ -96,28 +79,13 @@ export class CsvBookingActionsController { description: 'Booking cannot be rejected (invalid status or expired)', }) async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) { - // 1. Reject the booking + // Reject the booking const booking = await this.csvBookingService.rejectBooking(token, reason); - // 2. Create carrier account if it doesn't exist - const { carrierId, userId, isNewAccount } = - await this.carrierAuthService.createCarrierAccountIfNotExists( - booking.carrierEmail, - booking.carrierName - ); - - // 3. Link the booking to the carrier - await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); - - // 4. Generate auto-login token - const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); - - // 5. Return JSON response for frontend to handle + // Return simple success response return { success: true, - autoLoginToken, bookingId: booking.id, - isNewAccount, action: 'rejected', reason: reason || null, }; diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index e4dd66c..231bd62 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -31,7 +31,6 @@ import { Response } from 'express'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; -import { CarrierAuthService } from '../services/carrier-auth.service'; import { CreateCsvBookingDto, CsvBookingResponseDto, @@ -49,8 +48,7 @@ import { @Controller('csv-bookings') export class CsvBookingsController { constructor( - private readonly csvBookingService: CsvBookingService, - private readonly carrierAuthService: CarrierAuthService, + private readonly csvBookingService: CsvBookingService ) {} /** @@ -174,7 +172,7 @@ export class CsvBookingsController { @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) @ApiResponse({ status: 200, - description: 'Booking accepted successfully. Returns auto-login token and booking details.', + description: 'Booking accepted successfully.', }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ @@ -182,28 +180,13 @@ export class CsvBookingsController { description: 'Booking cannot be accepted (invalid status or expired)', }) async acceptBooking(@Param('token') token: string) { - // 1. Accept the booking + // Accept the booking const booking = await this.csvBookingService.acceptBooking(token); - // 2. Create carrier account if it doesn't exist - const { carrierId, userId, isNewAccount, temporaryPassword } = - await this.carrierAuthService.createCarrierAccountIfNotExists( - booking.carrierEmail, - booking.carrierName - ); - - // 3. Link the booking to the carrier - await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); - - // 4. Generate auto-login token - const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); - - // 5. Return JSON response for frontend to handle + // Return simple success response return { success: true, - autoLoginToken, bookingId: booking.id, - isNewAccount, action: 'accepted', }; } @@ -229,7 +212,7 @@ export class CsvBookingsController { }) @ApiResponse({ status: 200, - description: 'Booking rejected successfully. Returns auto-login token and booking details.', + description: 'Booking rejected successfully.', }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ @@ -240,28 +223,13 @@ export class CsvBookingsController { @Param('token') token: string, @Query('reason') reason: string ) { - // 1. Reject the booking + // Reject the booking const booking = await this.csvBookingService.rejectBooking(token, reason); - // 2. Create carrier account if it doesn't exist - const { carrierId, userId, isNewAccount, temporaryPassword } = - await this.carrierAuthService.createCarrierAccountIfNotExists( - booking.carrierEmail, - booking.carrierName - ); - - // 3. Link the booking to the carrier - await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId); - - // 4. Generate auto-login token - const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId); - - // 5. Return JSON response for frontend to handle + // Return simple success response return { success: true, - autoLoginToken, bookingId: booking.id, - isNewAccount, action: 'rejected', reason: reason || null, }; diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts index db05f59..5d3c583 100644 --- a/apps/backend/src/application/csv-bookings.module.ts +++ b/apps/backend/src/application/csv-bookings.module.ts @@ -1,4 +1,4 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CsvBookingsController } from './controllers/csv-bookings.controller'; import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller'; @@ -8,7 +8,6 @@ import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeo import { NotificationsModule } from './notifications/notifications.module'; import { EmailModule } from '../infrastructure/email/email.module'; import { StorageModule } from '../infrastructure/storage/storage.module'; -import { CarrierPortalModule } from './modules/carrier-portal.module'; /** * CSV Bookings Module @@ -17,14 +16,18 @@ import { CarrierPortalModule } from './modules/carrier-portal.module'; */ @Module({ imports: [ - TypeOrmModule.forFeature([CsvBookingOrmEntity]), - NotificationsModule, // Import NotificationsModule to access NotificationRepository + TypeOrmModule.forFeature([ + CsvBookingOrmEntity, + ]), + NotificationsModule, EmailModule, StorageModule, - forwardRef(() => CarrierPortalModule), // Import CarrierPortalModule to access CarrierAuthService ], controllers: [CsvBookingsController, CsvBookingActionsController], - providers: [CsvBookingService, TypeOrmCsvBookingRepository], + providers: [ + CsvBookingService, + TypeOrmCsvBookingRepository, + ], exports: [CsvBookingService], }) export class CsvBookingsModule {} diff --git a/apps/backend/src/application/dto/carrier-auth.dto.ts b/apps/backend/src/application/dto/carrier-auth.dto.ts deleted file mode 100644 index 0f51167..0000000 --- a/apps/backend/src/application/dto/carrier-auth.dto.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Carrier Authentication DTOs - * - * Data transfer objects for carrier authentication endpoints - */ - -import { IsEmail, IsString, IsNotEmpty, MinLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class CarrierLoginDto { - @ApiProperty({ - description: 'Carrier email address', - example: 'carrier@example.com', - }) - @IsEmail() - @IsNotEmpty() - email: string; - - @ApiProperty({ - description: 'Carrier password', - example: 'SecurePassword123!', - }) - @IsString() - @IsNotEmpty() - @MinLength(6) - password: string; -} - -export class CarrierChangePasswordDto { - @ApiProperty({ - description: 'Current password', - example: 'OldPassword123!', - }) - @IsString() - @IsNotEmpty() - oldPassword: string; - - @ApiProperty({ - description: 'New password (minimum 12 characters)', - example: 'NewSecurePassword123!', - }) - @IsString() - @IsNotEmpty() - @MinLength(12) - newPassword: string; -} - -export class CarrierPasswordResetRequestDto { - @ApiProperty({ - description: 'Carrier email address', - example: 'carrier@example.com', - }) - @IsEmail() - @IsNotEmpty() - email: string; -} - -export class CarrierLoginResponseDto { - @ApiProperty({ - description: 'JWT access token (15min expiry)', - }) - accessToken: string; - - @ApiProperty({ - description: 'JWT refresh token (7 days expiry)', - }) - refreshToken: string; - - @ApiProperty({ - description: 'Carrier profile information', - }) - carrier: { - id: string; - companyName: string; - email: string; - }; -} - -export class CarrierProfileResponseDto { - @ApiProperty({ - description: 'Carrier profile ID', - }) - id: string; - - @ApiProperty({ - description: 'User ID', - }) - userId: string; - - @ApiProperty({ - description: 'Company name', - }) - companyName: string; - - @ApiProperty({ - description: 'Email address', - }) - email: string; - - @ApiProperty({ - description: 'Carrier role', - example: 'CARRIER', - }) - role: string; - - @ApiProperty({ - description: 'Organization ID', - }) - organizationId: string; -} diff --git a/apps/backend/src/application/modules/carrier-portal.module.ts b/apps/backend/src/application/modules/carrier-portal.module.ts deleted file mode 100644 index ccebb0f..0000000 --- a/apps/backend/src/application/modules/carrier-portal.module.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Carrier Portal Module - * - * Module for carrier (transporteur) portal functionality - * Includes authentication, dashboard, and booking management for carriers - */ - -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { JwtModule } from '@nestjs/jwt'; -import { ConfigModule, ConfigService } from '@nestjs/config'; - -// Controllers -import { CarrierAuthController } from '../controllers/carrier-auth.controller'; -import { CarrierDashboardController } from '../controllers/carrier-dashboard.controller'; - -// Services -import { CarrierAuthService } from '../services/carrier-auth.service'; -import { CarrierDashboardService } from '../services/carrier-dashboard.service'; - -// Repositories -import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; -import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository'; - -// ORM Entities -import { CarrierProfileOrmEntity } from '@infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity'; -import { CarrierActivityOrmEntity } from '@infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity'; -import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; -import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity'; -import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; - -// Infrastructure modules -import { EmailModule } from '@infrastructure/email/email.module'; - -@Module({ - imports: [ - // TypeORM entities - TypeOrmModule.forFeature([ - CarrierProfileOrmEntity, - CarrierActivityOrmEntity, - UserOrmEntity, - OrganizationOrmEntity, - CsvBookingOrmEntity, - ]), - - // JWT module for authentication - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET'), - signOptions: { - expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'), - }, - }), - inject: [ConfigService], - }), - - // Email module for sending carrier emails - EmailModule, - ], - - controllers: [ - CarrierAuthController, - CarrierDashboardController, - ], - - providers: [ - // Services - CarrierAuthService, - CarrierDashboardService, - - // Repositories - CarrierProfileRepository, - CarrierActivityRepository, - ], - - exports: [ - // Export services for use in other modules (e.g., CsvBookingsModule) - CarrierAuthService, - CarrierDashboardService, - CarrierProfileRepository, - CarrierActivityRepository, - ], -}) -export class CarrierPortalModule {} diff --git a/apps/backend/src/application/services/carrier-auth.service.spec.ts b/apps/backend/src/application/services/carrier-auth.service.spec.ts deleted file mode 100644 index 92aef6d..0000000 --- a/apps/backend/src/application/services/carrier-auth.service.spec.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * CarrierAuthService Unit Tests - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { JwtService } from '@nestjs/jwt'; -import { UnauthorizedException } from '@nestjs/common'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { CarrierAuthService } from './carrier-auth.service'; -import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; -import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; -import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity'; -import * as argon2 from 'argon2'; - -describe('CarrierAuthService', () => { - let service: CarrierAuthService; - let carrierProfileRepository: jest.Mocked; - let userRepository: any; - let organizationRepository: any; - let jwtService: jest.Mocked; - - const mockCarrierProfile = { - id: 'carrier-1', - userId: 'user-1', - organizationId: 'org-1', - companyName: 'Test Carrier', - notificationEmail: 'carrier@test.com', - isActive: true, - isVerified: true, - user: { - id: 'user-1', - email: 'carrier@test.com', - passwordHash: 'hashed-password', - firstName: 'Test', - lastName: 'Carrier', - role: 'CARRIER', - }, - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CarrierAuthService, - { - provide: CarrierProfileRepository, - useValue: { - findByEmail: jest.fn(), - findById: jest.fn(), - create: jest.fn(), - updateLastLogin: jest.fn(), - }, - }, - { - provide: getRepositoryToken(UserOrmEntity), - useValue: { - create: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: getRepositoryToken(OrganizationOrmEntity), - useValue: { - create: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: JwtService, - useValue: { - sign: jest.fn(), - verify: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(CarrierAuthService); - carrierProfileRepository = module.get(CarrierProfileRepository); - userRepository = module.get(getRepositoryToken(UserOrmEntity)); - organizationRepository = module.get(getRepositoryToken(OrganizationOrmEntity)); - jwtService = module.get(JwtService); - }); - - describe('createCarrierAccountIfNotExists', () => { - it('should return existing carrier if already exists', async () => { - carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrierProfile as any); - - const result = await service.createCarrierAccountIfNotExists( - 'carrier@test.com', - 'Test Carrier' - ); - - expect(result).toEqual({ - carrierId: 'carrier-1', - userId: 'user-1', - isNewAccount: false, - }); - expect(carrierProfileRepository.findByEmail).toHaveBeenCalledWith('carrier@test.com'); - }); - - it('should create new carrier account if not exists', async () => { - carrierProfileRepository.findByEmail.mockResolvedValue(null); - - const mockOrganization = { id: 'org-1', name: 'Test Carrier' }; - const mockUser = { id: 'user-1', email: 'carrier@test.com' }; - const mockCarrier = { - id: 'carrier-1', - userId: 'user-1', - organizationId: 'org-1', - companyName: 'Test Carrier', - companyRegistration: null, - vatNumber: null, - phone: null, - website: null, - streetAddress: null, - city: null, - postalCode: null, - country: null, - totalBookingsAccepted: 0, - totalBookingsRejected: 0, - acceptanceRate: 0, - totalRevenueUsd: 0, - totalRevenueEur: 0, - preferredCurrency: 'USD', - notificationEmail: null, - autoAcceptEnabled: false, - isVerified: false, - isActive: true, - lastLoginAt: null, - createdAt: new Date(), - updatedAt: new Date(), - user: mockUser, - organization: mockOrganization, - bookings: [], - activities: [], - }; - - organizationRepository.create.mockReturnValue(mockOrganization); - organizationRepository.save.mockResolvedValue(mockOrganization); - userRepository.create.mockReturnValue(mockUser); - userRepository.save.mockResolvedValue(mockUser); - carrierProfileRepository.create.mockResolvedValue(mockCarrier as any); - - const result = await service.createCarrierAccountIfNotExists( - 'carrier@test.com', - 'Test Carrier' - ); - - expect(result.isNewAccount).toBe(true); - expect(result.carrierId).toBe('carrier-1'); - expect(result.userId).toBe('user-1'); - expect(result.temporaryPassword).toBeDefined(); - expect(result.temporaryPassword).toHaveLength(12); - }); - }); - - describe('login', () => { - it('should login successfully with valid credentials', async () => { - const hashedPassword = await argon2.hash('password123'); - const mockCarrier = { - ...mockCarrierProfile, - user: { - ...mockCarrierProfile.user, - passwordHash: hashedPassword, - }, - }; - - carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrier as any); - jwtService.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token'); - - const result = await service.login('carrier@test.com', 'password123'); - - expect(result).toEqual({ - accessToken: 'access-token', - refreshToken: 'refresh-token', - carrier: { - id: 'carrier-1', - companyName: 'Test Carrier', - email: 'carrier@test.com', - }, - }); - expect(carrierProfileRepository.updateLastLogin).toHaveBeenCalledWith('carrier-1'); - }); - - it('should throw UnauthorizedException for non-existent carrier', async () => { - carrierProfileRepository.findByEmail.mockResolvedValue(null); - - await expect( - service.login('nonexistent@test.com', 'password123') - ).rejects.toThrow(UnauthorizedException); - }); - - it('should throw UnauthorizedException for invalid password', async () => { - const hashedPassword = await argon2.hash('correctPassword'); - const mockCarrier = { - ...mockCarrierProfile, - user: { - ...mockCarrierProfile.user, - passwordHash: hashedPassword, - }, - }; - - carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrier as any); - - await expect( - service.login('carrier@test.com', 'wrongPassword') - ).rejects.toThrow(UnauthorizedException); - }); - - it('should throw UnauthorizedException for inactive carrier', async () => { - const hashedPassword = await argon2.hash('password123'); - const mockCarrier = { - ...mockCarrierProfile, - isActive: false, - user: { - ...mockCarrierProfile.user, - passwordHash: hashedPassword, - }, - }; - - carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrier as any); - - await expect( - service.login('carrier@test.com', 'password123') - ).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('generateAutoLoginToken', () => { - it('should generate auto-login token with correct payload', async () => { - jwtService.sign.mockReturnValue('auto-login-token'); - - const token = await service.generateAutoLoginToken('user-1', 'carrier-1'); - - expect(token).toBe('auto-login-token'); - expect(jwtService.sign).toHaveBeenCalledWith( - { - sub: 'user-1', - carrierId: 'carrier-1', - type: 'carrier', - autoLogin: true, - }, - { expiresIn: '1h' } - ); - }); - }); - - describe('verifyAutoLoginToken', () => { - it('should verify valid auto-login token', async () => { - jwtService.verify.mockReturnValue({ - sub: 'user-1', - carrierId: 'carrier-1', - type: 'carrier', - autoLogin: true, - }); - - const result = await service.verifyAutoLoginToken('valid-token'); - - expect(result).toEqual({ - userId: 'user-1', - carrierId: 'carrier-1', - }); - }); - - it('should throw UnauthorizedException for invalid token type', async () => { - jwtService.verify.mockReturnValue({ - sub: 'user-1', - carrierId: 'carrier-1', - type: 'user', - autoLogin: true, - }); - - await expect( - service.verifyAutoLoginToken('invalid-token') - ).rejects.toThrow(UnauthorizedException); - }); - - it('should throw UnauthorizedException for expired token', async () => { - jwtService.verify.mockImplementation(() => { - throw new Error('Token expired'); - }); - - await expect( - service.verifyAutoLoginToken('expired-token') - ).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('changePassword', () => { - it('should change password successfully', async () => { - const oldHashedPassword = await argon2.hash('oldPassword'); - const mockCarrier = { - ...mockCarrierProfile, - user: { - ...mockCarrierProfile.user, - passwordHash: oldHashedPassword, - }, - }; - - carrierProfileRepository.findById.mockResolvedValue(mockCarrier as any); - userRepository.save.mockResolvedValue(mockCarrier.user); - - await service.changePassword('carrier-1', 'oldPassword', 'newPassword'); - - expect(userRepository.save).toHaveBeenCalled(); - }); - - it('should throw UnauthorizedException for invalid old password', async () => { - const oldHashedPassword = await argon2.hash('correctOldPassword'); - const mockCarrier = { - ...mockCarrierProfile, - user: { - ...mockCarrierProfile.user, - passwordHash: oldHashedPassword, - }, - }; - - carrierProfileRepository.findById.mockResolvedValue(mockCarrier as any); - - await expect( - service.changePassword('carrier-1', 'wrongOldPassword', 'newPassword') - ).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('requestPasswordReset', () => { - it('should generate temporary password for existing carrier', async () => { - carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrierProfile as any); - userRepository.save.mockResolvedValue(mockCarrierProfile.user); - - const result = await service.requestPasswordReset('carrier@test.com'); - - expect(result.temporaryPassword).toBeDefined(); - expect(result.temporaryPassword).toHaveLength(12); - expect(userRepository.save).toHaveBeenCalled(); - }); - - it('should throw UnauthorizedException for non-existent carrier', async () => { - carrierProfileRepository.findByEmail.mockResolvedValue(null); - - await expect( - service.requestPasswordReset('nonexistent@test.com') - ).rejects.toThrow(UnauthorizedException); - }); - }); -}); diff --git a/apps/backend/src/application/services/carrier-dashboard.service.spec.ts b/apps/backend/src/application/services/carrier-dashboard.service.spec.ts deleted file mode 100644 index 6fab485..0000000 --- a/apps/backend/src/application/services/carrier-dashboard.service.spec.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * CarrierDashboardService Unit Tests - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, ForbiddenException } from '@nestjs/common'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { CarrierDashboardService } from './carrier-dashboard.service'; -import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; -import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository'; -import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; - -describe('CarrierDashboardService', () => { - let service: CarrierDashboardService; - let carrierProfileRepository: jest.Mocked; - let carrierActivityRepository: jest.Mocked; - let csvBookingRepository: any; - - const mockCarrierProfile = { - id: 'carrier-1', - userId: 'user-1', - organizationId: 'org-1', - companyName: 'Test Carrier', - notificationEmail: 'carrier@test.com', - isActive: true, - isVerified: true, - acceptanceRate: 85.5, - totalRevenueUsd: 50000, - totalRevenueEur: 45000, - totalBookingsAccepted: 10, - totalBookingsRejected: 2, - }; - - const mockBooking = { - id: 'booking-1', - carrierId: 'carrier-1', - carrierName: 'Test Carrier', - carrierEmail: 'carrier@test.com', - origin: 'Rotterdam', - destination: 'New York', - volumeCBM: 10, - weightKG: 1000, - palletCount: 5, - priceUSD: 1500, - priceEUR: 1350, - primaryCurrency: 'USD', - transitDays: 15, - containerType: '40HC', - status: 'PENDING', - documents: [ - { - id: 'doc-1', - fileName: 'invoice.pdf', - type: 'INVOICE', - url: 'https://example.com/doc.pdf', - }, - ], - confirmationToken: 'test-token', - requestedAt: new Date(), - carrierViewedAt: null, - carrierAcceptedAt: null, - carrierRejectedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CarrierDashboardService, - { - provide: CarrierProfileRepository, - useValue: { - findById: jest.fn(), - }, - }, - { - provide: CarrierActivityRepository, - useValue: { - findByCarrierId: jest.fn(), - create: jest.fn(), - }, - }, - { - provide: getRepositoryToken(CsvBookingOrmEntity), - useValue: { - find: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), - createQueryBuilder: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(CarrierDashboardService); - carrierProfileRepository = module.get(CarrierProfileRepository); - carrierActivityRepository = module.get(CarrierActivityRepository); - csvBookingRepository = module.get(getRepositoryToken(CsvBookingOrmEntity)); - }); - - describe('getCarrierStats', () => { - it('should return carrier dashboard statistics', async () => { - carrierProfileRepository.findById.mockResolvedValue(mockCarrierProfile as any); - csvBookingRepository.find.mockResolvedValue([ - { ...mockBooking, status: 'PENDING' }, - { ...mockBooking, status: 'ACCEPTED' }, - { ...mockBooking, status: 'REJECTED' }, - ]); - carrierActivityRepository.findByCarrierId.mockResolvedValue([ - { - id: 'activity-1', - activityType: 'BOOKING_ACCEPTED', - description: 'Booking accepted', - createdAt: new Date(), - bookingId: 'booking-1', - }, - ] as any); - - const result = await service.getCarrierStats('carrier-1'); - - expect(result).toEqual({ - totalBookings: 3, - pendingBookings: 1, - acceptedBookings: 1, - rejectedBookings: 1, - acceptanceRate: 85.5, - totalRevenue: { - usd: 50000, - eur: 45000, - }, - recentActivities: [ - { - id: 'activity-1', - type: 'BOOKING_ACCEPTED', - description: 'Booking accepted', - createdAt: expect.any(Date), - bookingId: 'booking-1', - }, - ], - }); - }); - - it('should throw NotFoundException for non-existent carrier', async () => { - carrierProfileRepository.findById.mockResolvedValue(null); - - await expect(service.getCarrierStats('non-existent')).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('getCarrierBookings', () => { - it('should return paginated bookings for carrier', async () => { - carrierProfileRepository.findById.mockResolvedValue(mockCarrierProfile as any); - - const queryBuilder = { - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getCount: jest.fn().mockResolvedValue(15), - orderBy: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([mockBooking]), - }; - - csvBookingRepository.createQueryBuilder.mockReturnValue(queryBuilder); - - const result = await service.getCarrierBookings('carrier-1', 1, 10); - - expect(result).toEqual({ - data: [ - { - id: 'booking-1', - origin: 'Rotterdam', - destination: 'New York', - status: 'PENDING', - priceUsd: 1500, - priceEur: 1350, - primaryCurrency: 'USD', - requestedAt: expect.any(Date), - carrierViewedAt: null, - documentsCount: 1, - volumeCBM: 10, - weightKG: 1000, - palletCount: 5, - transitDays: 15, - containerType: '40HC', - }, - ], - total: 15, - page: 1, - limit: 10, - }); - }); - - it('should filter bookings by status', async () => { - carrierProfileRepository.findById.mockResolvedValue(mockCarrierProfile as any); - - const queryBuilder = { - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getCount: jest.fn().mockResolvedValue(5), - orderBy: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([mockBooking]), - }; - - csvBookingRepository.createQueryBuilder.mockReturnValue(queryBuilder); - - await service.getCarrierBookings('carrier-1', 1, 10, 'ACCEPTED'); - - expect(queryBuilder.andWhere).toHaveBeenCalledWith('booking.status = :status', { - status: 'ACCEPTED', - }); - }); - - it('should throw NotFoundException for non-existent carrier', async () => { - carrierProfileRepository.findById.mockResolvedValue(null); - - await expect( - service.getCarrierBookings('non-existent', 1, 10) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('getBookingDetails', () => { - it('should return booking details and mark as viewed', async () => { - const booking = { ...mockBooking, carrierViewedAt: null }; - csvBookingRepository.findOne.mockResolvedValue(booking); - csvBookingRepository.save.mockResolvedValue({ ...booking, carrierViewedAt: new Date() }); - carrierActivityRepository.create.mockResolvedValue({} as any); - - const result = await service.getBookingDetails('carrier-1', 'booking-1'); - - expect(result.id).toBe('booking-1'); - expect(result.origin).toBe('Rotterdam'); - expect(csvBookingRepository.save).toHaveBeenCalled(); - expect(carrierActivityRepository.create).toHaveBeenCalled(); - }); - - it('should not update view if already viewed', async () => { - const booking = { ...mockBooking, carrierViewedAt: new Date() }; - csvBookingRepository.findOne.mockResolvedValue(booking); - - await service.getBookingDetails('carrier-1', 'booking-1'); - - expect(csvBookingRepository.save).not.toHaveBeenCalled(); - }); - - it('should throw NotFoundException for non-existent booking', async () => { - csvBookingRepository.findOne.mockResolvedValue(null); - - await expect( - service.getBookingDetails('carrier-1', 'non-existent') - ).rejects.toThrow(NotFoundException); - }); - - it('should throw ForbiddenException for unauthorized access', async () => { - csvBookingRepository.findOne.mockResolvedValue(mockBooking); - - await expect( - service.getBookingDetails('other-carrier', 'booking-1') - ).rejects.toThrow(ForbiddenException); - }); - }); - - describe('downloadDocument', () => { - it('should allow authorized carrier to download document', async () => { - csvBookingRepository.findOne.mockResolvedValue(mockBooking); - carrierActivityRepository.create.mockResolvedValue({} as any); - - const result = await service.downloadDocument('carrier-1', 'booking-1', 'doc-1'); - - expect(result.document).toEqual({ - id: 'doc-1', - fileName: 'invoice.pdf', - type: 'INVOICE', - url: 'https://example.com/doc.pdf', - }); - expect(carrierActivityRepository.create).toHaveBeenCalled(); - }); - - it('should throw ForbiddenException for unauthorized carrier', async () => { - csvBookingRepository.findOne.mockResolvedValue(mockBooking); - - await expect( - service.downloadDocument('other-carrier', 'booking-1', 'doc-1') - ).rejects.toThrow(ForbiddenException); - }); - - it('should throw NotFoundException for non-existent booking', async () => { - csvBookingRepository.findOne.mockResolvedValue(null); - - await expect( - service.downloadDocument('carrier-1', 'booking-1', 'doc-1') - ).rejects.toThrow(ForbiddenException); - }); - - it('should throw NotFoundException for non-existent document', async () => { - csvBookingRepository.findOne.mockResolvedValue(mockBooking); - - await expect( - service.downloadDocument('carrier-1', 'booking-1', 'non-existent-doc') - ).rejects.toThrow(NotFoundException); - }); - }); -}); diff --git a/apps/backend/src/application/services/carrier-dashboard.service.ts b/apps/backend/src/application/services/carrier-dashboard.service.ts deleted file mode 100644 index 2151eb2..0000000 --- a/apps/backend/src/application/services/carrier-dashboard.service.ts +++ /dev/null @@ -1,408 +0,0 @@ -/** - * Carrier Dashboard Service - * - * Handles carrier dashboard statistics, bookings, and document management - */ - -import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository'; -import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository'; -import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; -import { CarrierActivityType } from '@infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity'; - -export interface CarrierDashboardStats { - totalBookings: number; - pendingBookings: number; - acceptedBookings: number; - rejectedBookings: number; - acceptanceRate: number; - totalRevenue: { - usd: number; - eur: number; - }; - recentActivities: any[]; -} - -export interface CarrierBookingListItem { - id: string; - origin: string; - destination: string; - status: string; - priceUsd: number; - priceEur: number; - primaryCurrency: string; - requestedAt: Date; - carrierViewedAt: Date | null; - documentsCount: number; - volumeCBM: number; - weightKG: number; - palletCount: number; - transitDays: number; - containerType: string; -} - -@Injectable() -export class CarrierDashboardService { - private readonly logger = new Logger(CarrierDashboardService.name); - - constructor( - private readonly carrierProfileRepository: CarrierProfileRepository, - private readonly carrierActivityRepository: CarrierActivityRepository, - @InjectRepository(CsvBookingOrmEntity) - private readonly csvBookingRepository: Repository, - ) {} - - /** - * Get carrier dashboard statistics - */ - async getCarrierStats(carrierId: string): Promise { - this.logger.log(`Fetching dashboard stats for carrier: ${carrierId}`); - - const carrier = await this.carrierProfileRepository.findById(carrierId); - - if (!carrier) { - throw new NotFoundException('Carrier not found'); - } - - // Get bookings for the carrier - const bookings = await this.csvBookingRepository.find({ - where: { carrierId }, - }); - - // Count bookings by status - const pendingCount = bookings.filter((b) => b.status === 'PENDING').length; - const acceptedCount = bookings.filter((b) => b.status === 'ACCEPTED').length; - const rejectedCount = bookings.filter((b) => b.status === 'REJECTED').length; - - // Get recent activities - const recentActivities = await this.carrierActivityRepository.findByCarrierId(carrierId, 10); - - const stats: CarrierDashboardStats = { - totalBookings: bookings.length, - pendingBookings: pendingCount, - acceptedBookings: acceptedCount, - rejectedBookings: rejectedCount, - acceptanceRate: carrier.acceptanceRate, - totalRevenue: { - usd: carrier.totalRevenueUsd, - eur: carrier.totalRevenueEur, - }, - recentActivities: recentActivities.map((activity) => ({ - id: activity.id, - type: activity.activityType, - description: activity.description, - createdAt: activity.createdAt, - bookingId: activity.bookingId, - })), - }; - - this.logger.log(`Dashboard stats retrieved for carrier: ${carrierId}`); - return stats; - } - - /** - * Get carrier bookings with pagination - */ - async getCarrierBookings( - carrierId: string, - page: number = 1, - limit: number = 10, - status?: string - ): Promise<{ - data: CarrierBookingListItem[]; - total: number; - page: number; - limit: number; - }> { - this.logger.log(`Fetching bookings for carrier: ${carrierId} (page: ${page}, limit: ${limit})`); - - const carrier = await this.carrierProfileRepository.findById(carrierId); - - if (!carrier) { - throw new NotFoundException('Carrier not found'); - } - - // Build query - const queryBuilder = this.csvBookingRepository - .createQueryBuilder('booking') - .where('booking.carrierId = :carrierId', { carrierId }); - - if (status) { - queryBuilder.andWhere('booking.status = :status', { status }); - } - - // Get total count - const total = await queryBuilder.getCount(); - - // Get paginated results - const bookings = await queryBuilder - .orderBy('booking.requestedAt', 'DESC') - .skip((page - 1) * limit) - .take(limit) - .getMany(); - - const data: CarrierBookingListItem[] = bookings.map((booking) => ({ - id: booking.id, - origin: booking.origin, - destination: booking.destination, - status: booking.status, - priceUsd: booking.priceUSD, - priceEur: booking.priceEUR, - primaryCurrency: booking.primaryCurrency, - requestedAt: booking.requestedAt, - carrierViewedAt: booking.carrierViewedAt, - documentsCount: booking.documents?.length || 0, - volumeCBM: booking.volumeCBM, - weightKG: booking.weightKG, - palletCount: booking.palletCount, - transitDays: booking.transitDays, - containerType: booking.containerType, - })); - - this.logger.log(`Found ${data.length} bookings for carrier: ${carrierId} (total: ${total})`); - - return { - data, - total, - page, - limit, - }; - } - - /** - * Get booking details with documents - */ - async getBookingDetails(carrierId: string, bookingId: string): Promise { - this.logger.log(`Fetching booking details: ${bookingId} for carrier: ${carrierId}`); - - const booking = await this.csvBookingRepository.findOne({ - where: { id: bookingId }, - }); - - if (!booking) { - throw new NotFoundException('Booking not found'); - } - - // Verify the booking belongs to this carrier - if (booking.carrierId !== carrierId) { - this.logger.warn(`Access denied: Carrier ${carrierId} attempted to access booking ${bookingId}`); - throw new ForbiddenException('Access denied to this booking'); - } - - // Mark as viewed if not already - if (!booking.carrierViewedAt) { - booking.carrierViewedAt = new Date(); - await this.csvBookingRepository.save(booking); - - // Log the view activity - await this.carrierActivityRepository.create({ - carrierId, - bookingId, - activityType: CarrierActivityType.BOOKING_ACCEPTED, // TODO: Add BOOKING_VIEWED type - description: `Viewed booking ${bookingId}`, - metadata: { bookingId }, - }); - - this.logger.log(`Marked booking ${bookingId} as viewed by carrier ${carrierId}`); - } - - return { - id: booking.id, - carrierName: booking.carrierName, - carrierEmail: booking.carrierEmail, - origin: booking.origin, - destination: booking.destination, - volumeCBM: booking.volumeCBM, - weightKG: booking.weightKG, - palletCount: booking.palletCount, - priceUSD: booking.priceUSD, - priceEUR: booking.priceEUR, - primaryCurrency: booking.primaryCurrency, - transitDays: booking.transitDays, - containerType: booking.containerType, - status: booking.status, - documents: booking.documents || [], - confirmationToken: booking.confirmationToken, - requestedAt: booking.requestedAt, - respondedAt: booking.respondedAt, - notes: booking.notes, - rejectionReason: booking.rejectionReason, - carrierViewedAt: booking.carrierViewedAt, - carrierAcceptedAt: booking.carrierAcceptedAt, - carrierRejectedAt: booking.carrierRejectedAt, - carrierRejectionReason: booking.carrierRejectionReason, - carrierNotes: booking.carrierNotes, - createdAt: booking.createdAt, - updatedAt: booking.updatedAt, - }; - } - - /** - * Download a document from a booking - */ - async downloadDocument( - carrierId: string, - bookingId: string, - documentId: string - ): Promise<{ document: any }> { - this.logger.log(`Downloading document ${documentId} from booking ${bookingId} for carrier ${carrierId}`); - - // Verify access - const booking = await this.csvBookingRepository.findOne({ - where: { id: bookingId }, - }); - - if (!booking || booking.carrierId !== carrierId) { - this.logger.warn(`Access denied: Carrier ${carrierId} attempted to access document from booking ${bookingId}`); - throw new ForbiddenException('Access denied to this document'); - } - - // Find the document in the booking's documents array - const document = booking.documents?.find((doc: any) => doc.id === documentId); - - if (!document) { - throw new NotFoundException(`Document not found: ${documentId}`); - } - - // Log the download activity - await this.carrierActivityRepository.create({ - carrierId, - bookingId, - activityType: CarrierActivityType.DOCUMENT_DOWNLOADED, - description: `Downloaded document ${document.fileName}`, - metadata: { - documentId, - fileName: document.fileName, - fileType: document.type, - }, - }); - - this.logger.log(`Document ${documentId} downloaded by carrier ${carrierId}`); - - // TODO: Implement actual file download from S3/MinIO - // For now, return the document metadata - return { - document, - }; - } - - /** - * Accept a booking - */ - async acceptBooking( - carrierId: string, - bookingId: string, - notes?: string - ): Promise { - this.logger.log(`Accepting booking ${bookingId} by carrier ${carrierId}`); - - const booking = await this.csvBookingRepository.findOne({ - where: { id: bookingId }, - }); - - if (!booking || booking.carrierId !== carrierId) { - throw new ForbiddenException('Access denied to this booking'); - } - - if (booking.status !== 'PENDING') { - throw new ForbiddenException('Booking is not in pending status'); - } - - // Update booking status - booking.status = 'ACCEPTED'; - booking.carrierAcceptedAt = new Date(); - booking.carrierNotes = notes || null; - booking.respondedAt = new Date(); - - await this.csvBookingRepository.save(booking); - - // Update carrier statistics - const carrier = await this.carrierProfileRepository.findById(carrierId); - if (carrier) { - const newAcceptedCount = carrier.totalBookingsAccepted + 1; - const totalBookings = newAcceptedCount + carrier.totalBookingsRejected; - const newAcceptanceRate = totalBookings > 0 ? (newAcceptedCount / totalBookings) * 100 : 0; - - // Add revenue - const newRevenueUsd = carrier.totalRevenueUsd + booking.priceUSD; - const newRevenueEur = carrier.totalRevenueEur + booking.priceEUR; - - await this.carrierProfileRepository.updateStatistics(carrierId, { - totalBookingsAccepted: newAcceptedCount, - acceptanceRate: newAcceptanceRate, - totalRevenueUsd: newRevenueUsd, - totalRevenueEur: newRevenueEur, - }); - } - - // Log activity - await this.carrierActivityRepository.create({ - carrierId, - bookingId, - activityType: CarrierActivityType.BOOKING_ACCEPTED, - description: `Accepted booking ${bookingId}`, - metadata: { bookingId, notes }, - }); - - this.logger.log(`Booking ${bookingId} accepted by carrier ${carrierId}`); - } - - /** - * Reject a booking - */ - async rejectBooking( - carrierId: string, - bookingId: string, - reason?: string, - notes?: string - ): Promise { - this.logger.log(`Rejecting booking ${bookingId} by carrier ${carrierId}`); - - const booking = await this.csvBookingRepository.findOne({ - where: { id: bookingId }, - }); - - if (!booking || booking.carrierId !== carrierId) { - throw new ForbiddenException('Access denied to this booking'); - } - - if (booking.status !== 'PENDING') { - throw new ForbiddenException('Booking is not in pending status'); - } - - // Update booking status - booking.status = 'REJECTED'; - booking.carrierRejectedAt = new Date(); - booking.carrierRejectionReason = reason || null; - booking.carrierNotes = notes || null; - booking.respondedAt = new Date(); - - await this.csvBookingRepository.save(booking); - - // Update carrier statistics - const carrier = await this.carrierProfileRepository.findById(carrierId); - if (carrier) { - const newRejectedCount = carrier.totalBookingsRejected + 1; - const totalBookings = carrier.totalBookingsAccepted + newRejectedCount; - const newAcceptanceRate = totalBookings > 0 ? (carrier.totalBookingsAccepted / totalBookings) * 100 : 0; - - await this.carrierProfileRepository.updateStatistics(carrierId, { - totalBookingsRejected: newRejectedCount, - acceptanceRate: newAcceptanceRate, - }); - } - - // Log activity - await this.carrierActivityRepository.create({ - carrierId, - bookingId, - activityType: CarrierActivityType.BOOKING_REJECTED, - description: `Rejected booking ${bookingId}`, - metadata: { bookingId, reason, notes }, - }); - - this.logger.log(`Booking ${bookingId} rejected by carrier ${carrierId}`); - } -} diff --git a/apps/frontend/app/carrier/accept/[token]/page.tsx b/apps/frontend/app/carrier/accept/[token]/page.tsx index 28c618f..e50e7e1 100644 --- a/apps/frontend/app/carrier/accept/[token]/page.tsx +++ b/apps/frontend/app/carrier/accept/[token]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { CheckCircle, Loader2, XCircle, Truck } from 'lucide-react'; +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; export default function CarrierAcceptPage() { const params = useParams(); @@ -11,8 +11,7 @@ export default function CarrierAcceptPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [bookingId, setBookingId] = useState(null); - const [isNewAccount, setIsNewAccount] = useState(false); + const [countdown, setCountdown] = useState(5); // Prevent double API calls (React 18 StrictMode issue) const hasCalledApi = useRef(false); @@ -24,6 +23,7 @@ export default function CarrierAcceptPage() { return; } hasCalledApi.current = true; + if (!token) { setError('Token manquant'); setLoading(false); @@ -47,35 +47,34 @@ export default function CarrierAcceptPage() { errorData = { message: `Erreur HTTP ${response.status}` }; } - // Messages d'erreur personnalisés let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking'; - // Log pour debug - console.error('API Error:', errorMessage); - if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) { - errorMessage = '⚠️ Ce booking a déjà été accepté.\n\nVous devez créer un NOUVEAU booking avec:\ncd apps/backend\nnode create-test-booking.js'; + errorMessage = 'Ce booking a déjà été accepté.'; } else if (errorMessage.includes('status REJECTED')) { - errorMessage = 'Ce booking a déjà été refusé. Vous ne pouvez pas l\'accepter.'; + errorMessage = 'Ce booking a déjà été refusé.'; } else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { - errorMessage = 'Booking introuvable. Le lien peut avoir expiré ou le token est invalide.'; + errorMessage = 'Booking introuvable. Le lien peut avoir expiré.'; } throw new Error(errorMessage); } - const data = await response.json(); - - // Stocker le token JWT pour l'auto-login - if (data.autoLoginToken) { - localStorage.setItem('carrier_access_token', data.autoLoginToken); - } - - setBookingId(data.bookingId); - setIsNewAccount(data.isNewAccount); setLoading(false); - // Redirection manuelle - plus de redirection automatique + // Démarrer le compte à rebours + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + router.push('/'); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); } catch (err) { console.error('Error accepting booking:', err); setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation'); @@ -95,7 +94,7 @@ export default function CarrierAcceptPage() { Traitement en cours...

- Nous acceptons votre réservation et créons votre compte. + Nous traitons votre acceptation.

@@ -110,10 +109,10 @@ export default function CarrierAcceptPage() {

Erreur

{error}

@@ -123,34 +122,29 @@ export default function CarrierAcceptPage() { return (
- +

- Réservation Acceptée ! + Merci !

-
-

- ✅ La réservation a été acceptée avec succès +

+

+ ✅ Votre acceptation a bien été prise en compte +

+

+ Nous vous remercions d'avoir accepté cette demande de transport.

- {isNewAccount && ( -
-

- 🎉 Compte transporteur créé ! -

-

- Un email avec vos identifiants de connexion vous a été envoyé. -

-
- )} +

+ Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}... +

diff --git a/apps/frontend/app/carrier/confirmed/page.tsx b/apps/frontend/app/carrier/confirmed/page.tsx deleted file mode 100644 index 9d7186c..0000000 --- a/apps/frontend/app/carrier/confirmed/page.tsx +++ /dev/null @@ -1,161 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; -import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; - -export default function CarrierConfirmedPage() { - const searchParams = useSearchParams(); - const router = useRouter(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const token = searchParams.get('token'); - const action = searchParams.get('action'); - const bookingId = searchParams.get('bookingId'); - const isNewAccount = searchParams.get('new') === 'true'; - - useEffect(() => { - const autoLogin = async () => { - if (!token) { - setError('Token manquant'); - setLoading(false); - return; - } - - try { - // Stocker le token JWT - localStorage.setItem('carrier_access_token', token); - - // Rediriger vers le dashboard après 3 secondes - setTimeout(() => { - router.push(`/carrier/dashboard/bookings/${bookingId}`); - }, 3000); - - setLoading(false); - } catch (err) { - setError('Erreur lors de la connexion automatique'); - setLoading(false); - } - }; - - autoLogin(); - }, [token, bookingId, router]); - - if (loading) { - return ( -
-
- -

Connexion en cours...

-
-
- ); - } - - if (error) { - return ( -
-
- -

Erreur

-

{error}

-
-
- ); - } - - const isAccepted = action === 'accepted'; - - return ( -
-
- {/* Success Icon */} - {isAccepted ? ( - - ) : ( - - )} - - {/* Title */} -

- {isAccepted ? '✅ Demande acceptée avec succès' : '❌ Demande refusée'} -

- - {/* New Account Message */} - {isNewAccount && ( -
-

🎉 Bienvenue sur Xpeditis !

-

- Un compte transporteur a été créé automatiquement pour vous. Vous recevrez un email - avec vos identifiants de connexion. -

-
- )} - - {/* Confirmation Message */} -
-

- {isAccepted - ? 'Votre acceptation a été enregistrée. Le client va être notifié automatiquement par email.' - : 'Votre refus a été enregistré. Le client va être notifié automatiquement.'} -

-
- - {/* Redirection Notice */} -
-

- - Redirection vers votre tableau de bord dans quelques secondes... -

-
- - {/* Next Steps */} -
-

📋 Prochaines étapes

- {isAccepted ? ( -
    -
  • - 1. - Le client va vous contacter directement par email -
  • -
  • - 2. - Envoyez-lui le numéro de réservation (booking number) -
  • -
  • - 3. - Organisez l'enlèvement de la marchandise -
  • -
  • - 4. - Suivez l'expédition depuis votre tableau de bord -
  • -
- ) : ( -
    -
  • - 1. - Le client sera notifié de votre refus -
  • -
  • - 2. - Il pourra rechercher une alternative -
  • -
- )} -
- - {/* Manual Link */} -
- -
-
-
- ); -} diff --git a/apps/frontend/app/carrier/dashboard/bookings/[id]/page.tsx b/apps/frontend/app/carrier/dashboard/bookings/[id]/page.tsx deleted file mode 100644 index 128ae04..0000000 --- a/apps/frontend/app/carrier/dashboard/bookings/[id]/page.tsx +++ /dev/null @@ -1,409 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { - ArrowLeft, - Package, - MapPin, - Calendar, - DollarSign, - FileText, - Download, - CheckCircle, - XCircle, - Clock, - Truck, - Weight, - Box -} from 'lucide-react'; - -interface BookingDocument { - id: string; - type: string; - fileName: string; - url: string; -} - -interface BookingDetails { - id: string; - bookingId?: string; - carrierName: string; - carrierEmail: string; - origin: string; - destination: string; - volumeCBM: number; - weightKG: number; - palletCount: number; - priceUSD: number; - priceEUR: number; - primaryCurrency: string; - transitDays: number; - containerType: string; - status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; - documents: BookingDocument[]; - notes?: string; - requestedAt: string; - respondedAt?: string; - rejectionReason?: string; -} - -export default function CarrierBookingDetailPage() { - const params = useParams(); - const router = useRouter(); - const bookingId = params.id as string; - - const [booking, setBooking] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchBookingDetails = async () => { - try { - const token = localStorage.getItem('carrier_access_token'); - - if (!token) { - setError('Non autorisé - veuillez vous connecter'); - setLoading(false); - return; - } - - const response = await fetch(`http://localhost:4000/api/v1/csv-bookings/${bookingId}`, { - headers: { - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Erreur ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - setBooking(data); - } catch (err) { - console.error('Error fetching booking:', err); - setError(err instanceof Error ? err.message : 'Erreur lors du chargement'); - } finally { - setLoading(false); - } - }; - - if (bookingId) { - fetchBookingDetails(); - } - }, [bookingId]); - - const getStatusBadge = (status: string) => { - switch (status) { - case 'ACCEPTED': - return ( - - - Accepté - - ); - case 'REJECTED': - return ( - - - Refusé - - ); - case 'PENDING': - return ( - - - En attente - - ); - default: - return null; - } - }; - - const formatPrice = (price: number, currency: string) => { - return new Intl.NumberFormat('fr-FR', { - style: 'currency', - currency: currency, - }).format(price); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - }; - - if (loading) { - return ( -
-
-
-

Chargement des détails...

-
-
- ); - } - - if (error || !booking) { - return ( -
-
- -

Erreur

-

{error || 'Réservation introuvable'}

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

- Détails de la Réservation -

-

Référence: {booking.bookingId || booking.id}

-
- {getStatusBadge(booking.status)} -
-
-
- - {/* Route Information */} -
-

- - Itinéraire -

- -
-
-

Origine

-

{booking.origin}

-
- -
-
-
- -
-
-

- {booking.transitDays} jours de transit -

-
- -
-

Destination

-

{booking.destination}

-
-
-
- - {/* Shipment Details */} -
-

- - Détails de la Marchandise -

- -
-
-
- -

Volume

-
-

{booking.volumeCBM} CBM

-
- -
-
- -

Poids

-
-

{booking.weightKG} kg

-
- -
-
- -

Palettes

-
-

{booking.palletCount || 'N/A'}

-
- -
-
- -

Type

-
-

{booking.containerType}

-
-
-
- - {/* Pricing */} -
-

- - Prix -

- -
-
-

Prix total

-

- {formatPrice( - booking.primaryCurrency === 'USD' ? booking.priceUSD : booking.priceEUR, - booking.primaryCurrency - )} -

-
- - {booking.primaryCurrency === 'USD' && booking.priceEUR > 0 && ( -
-

Équivalent EUR

-

- {formatPrice(booking.priceEUR, 'EUR')} -

-
- )} - - {booking.primaryCurrency === 'EUR' && booking.priceUSD > 0 && ( -
-

Équivalent USD

-

- {formatPrice(booking.priceUSD, 'USD')} -

-
- )} -
-
- - {/* Documents */} - {booking.documents && booking.documents.length > 0 && ( -
-

- - Documents ({booking.documents.length}) -

- -
- {booking.documents.map((doc) => ( -
-
- -
-

{doc.fileName}

-

{doc.type}

-
-
- - - Télécharger - -
- ))} -
-
- )} - - {/* Notes */} - {booking.notes && ( -
-

📝 Notes

-

{booking.notes}

-
- )} - - {/* Rejection Reason */} - {booking.status === 'REJECTED' && booking.rejectionReason && ( -
-

❌ Raison du refus

-

{booking.rejectionReason}

-
- )} - - {/* Timeline */} -
-

- - Chronologie -

- -
-
-
-
-

Demande reçue

-

{formatDate(booking.requestedAt)}

-
-
- - {booking.respondedAt && ( -
-
-
-

- {booking.status === 'ACCEPTED' ? 'Acceptée' : 'Refusée'} -

-

{formatDate(booking.respondedAt)}

-
-
- )} -
-
- - {/* Actions */} -
- - -
-
-
- ); -} diff --git a/apps/frontend/app/carrier/dashboard/layout.tsx b/apps/frontend/app/carrier/dashboard/layout.tsx deleted file mode 100644 index b33bb8f..0000000 --- a/apps/frontend/app/carrier/dashboard/layout.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter, usePathname } from 'next/navigation'; -import Link from 'next/link'; -import { - Ship, - LayoutDashboard, - FileText, - BarChart3, - User, - LogOut, - Menu, - X, -} from 'lucide-react'; - -export default function CarrierDashboardLayout({ children }: { children: React.ReactNode }) { - const router = useRouter(); - const pathname = usePathname(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [carrierName, setCarrierName] = useState('Transporteur'); - - useEffect(() => { - // Vérifier l'authentification - const token = localStorage.getItem('carrier_access_token'); - if (!token) { - router.push('/carrier/login'); - } - }, [router]); - - const handleLogout = () => { - localStorage.removeItem('carrier_access_token'); - localStorage.removeItem('carrier_refresh_token'); - router.push('/carrier/login'); - }; - - const menuItems = [ - { - name: 'Tableau de bord', - href: '/carrier/dashboard', - icon: LayoutDashboard, - }, - { - name: 'Réservations', - href: '/carrier/dashboard/bookings', - icon: FileText, - }, - { - name: 'Statistiques', - href: '/carrier/dashboard/stats', - icon: BarChart3, - }, - { - name: 'Mon profil', - href: '/carrier/dashboard/profile', - icon: User, - }, - ]; - - return ( -
- {/* Mobile Sidebar Toggle */} -
- -
- - {/* Sidebar */} - - - {/* Main Content */} -
-
{children}
-
- - {/* Mobile Overlay */} - {isSidebarOpen && ( -
setIsSidebarOpen(false)} - /> - )} -
- ); -} diff --git a/apps/frontend/app/carrier/dashboard/page.tsx b/apps/frontend/app/carrier/dashboard/page.tsx deleted file mode 100644 index f48f022..0000000 --- a/apps/frontend/app/carrier/dashboard/page.tsx +++ /dev/null @@ -1,214 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { - FileText, - CheckCircle, - XCircle, - Clock, - TrendingUp, - DollarSign, - Euro, - Activity, -} from 'lucide-react'; - -interface DashboardStats { - totalBookings: number; - pendingBookings: number; - acceptedBookings: number; - rejectedBookings: number; - acceptanceRate: number; - totalRevenue: { - usd: number; - eur: number; - }; - recentActivities: any[]; -} - -export default function CarrierDashboardPage() { - const router = useRouter(); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchStats(); - }, []); - - const fetchStats = async () => { - try { - const token = localStorage.getItem('carrier_access_token'); - const response = await fetch('http://localhost:4000/api/v1/carrier-dashboard/stats', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) throw new Error('Failed to fetch stats'); - - const data = await response.json(); - setStats(data); - } catch (error) { - console.error('Error fetching stats:', error); - } finally { - setLoading(false); - } - }; - - if (loading) { - return ( -
-
-
-

Chargement...

-
-
- ); - } - - if (!stats) { - return
Erreur de chargement des statistiques
; - } - - const statCards = [ - { - title: 'Total Réservations', - value: stats.totalBookings, - icon: FileText, - color: 'blue', - }, - { - title: 'En attente', - value: stats.pendingBookings, - icon: Clock, - color: 'yellow', - }, - { - title: 'Acceptées', - value: stats.acceptedBookings, - icon: CheckCircle, - color: 'green', - }, - { - title: 'Refusées', - value: stats.rejectedBookings, - icon: XCircle, - color: 'red', - }, - ]; - - return ( -
- {/* Header */} -
-

Tableau de bord

-

Vue d'ensemble de votre activité

-
- - {/* Stats Cards */} -
- {statCards.map((card) => { - const Icon = card.icon; - return ( -
-
- -
-

{card.title}

-

{card.value}

-
- ); - })} -
- - {/* Revenue & Acceptance Rate */} -
- {/* Revenue */} -
-

- - Revenus totaux -

-
-
-
- - USD -
- - ${stats.totalRevenue.usd.toLocaleString()} - -
-
-
- - EUR -
- - €{stats.totalRevenue.eur.toLocaleString()} - -
-
-
- - {/* Acceptance Rate */} -
-

- - Taux d'acceptation -

-
-
-
- {stats.acceptanceRate.toFixed(1)}% -
-

- {stats.acceptedBookings} acceptées / {stats.totalBookings} total -

-
-
-
-
- - {/* Recent Activities */} -
-

Activité récente

- {stats.recentActivities.length > 0 ? ( -
- {stats.recentActivities.map((activity) => ( -
-
-

{activity.description}

-

- {new Date(activity.createdAt).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', - hour: '2-digit', - minute: '2-digit', - })} -

-
- - {activity.type} - -
- ))} -
- ) : ( -

Aucune activité récente

- )} -
-
- ); -} diff --git a/apps/frontend/app/carrier/login/page.tsx b/apps/frontend/app/carrier/login/page.tsx deleted file mode 100644 index 23a55a6..0000000 --- a/apps/frontend/app/carrier/login/page.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Ship, Mail, Lock, Loader2 } from 'lucide-react'; - -export default function CarrierLoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - - try { - const response = await fetch('http://localhost:4000/api/v1/carrier-auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - throw new Error('Identifiants invalides'); - } - - const data = await response.json(); - - // Stocker le token - localStorage.setItem('carrier_access_token', data.accessToken); - localStorage.setItem('carrier_refresh_token', data.refreshToken); - - // Rediriger vers le dashboard - router.push('/carrier/dashboard'); - } catch (err: any) { - setError(err.message || 'Erreur de connexion'); - } finally { - setLoading(false); - } - }; - - return ( -
-
- {/* Header */} -
- -

Portail Transporteur

-

Connectez-vous à votre espace Xpeditis

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

{error}

-
- )} - - {/* Login Form */} -
- {/* Email */} -
- -
- - setEmail(e.target.value)} - required - className="pl-10 w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="votre@email.com" - /> -
-
- - {/* Password */} -
- -
- - setPassword(e.target.value)} - required - className="pl-10 w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="••••••••" - /> -
-
- - {/* Submit Button */} - -
- - {/* Footer Links */} - - -
-

- Vous n'avez pas encore de compte ?
- - Un compte sera créé automatiquement lors de votre première acceptation de demande. - -

-
-
-
- ); -} diff --git a/apps/frontend/app/carrier/reject/[token]/page.tsx b/apps/frontend/app/carrier/reject/[token]/page.tsx index 04a7ef3..1d52686 100644 --- a/apps/frontend/app/carrier/reject/[token]/page.tsx +++ b/apps/frontend/app/carrier/reject/[token]/page.tsx @@ -2,147 +2,105 @@ import { useEffect, useState, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { XCircle, Loader2, CheckCircle, Truck, MessageSquare } from 'lucide-react'; +import { XCircle, Loader2, CheckCircle } from 'lucide-react'; export default function CarrierRejectPage() { const params = useParams(); const router = useRouter(); const token = params.token as string; - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [bookingId, setBookingId] = useState(null); - const [isNewAccount, setIsNewAccount] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - const [reason, setReason] = useState(''); + const [countdown, setCountdown] = useState(5); // Prevent double API calls (React 18 StrictMode issue) const hasCalledApi = useRef(false); - const handleReject = async () => { - // Protection contre les doubles appels - if (hasCalledApi.current) { - return; - } - hasCalledApi.current = true; - if (!token) { - setError('Token manquant'); - return; - } + useEffect(() => { + const rejectBooking = async () => { + // Protection contre les doubles appels + if (hasCalledApi.current) { + return; + } + hasCalledApi.current = true; - setLoading(true); - setError(null); - - try { - // Construire l'URL avec la raison en query param si fournie - const url = new URL(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`); - if (reason.trim()) { - url.searchParams.append('reason', reason.trim()); + if (!token) { + setError('Token manquant'); + setLoading(false); + return; } - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); + try { + // Appeler l'API backend pour refuser le booking + const response = await fetch(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); - if (!response.ok) { - const errorData = await response.json(); + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (e) { + errorData = { message: `Erreur HTTP ${response.status}` }; + } - // Messages d'erreur personnalisés - let errorMessage = errorData.message || 'Erreur lors du refus du booking'; + let errorMessage = errorData.message || 'Erreur lors du refus du booking'; - if (errorMessage.includes('status REJECTED')) { - errorMessage = 'Ce booking a déjà été refusé. Vous ne pouvez pas le refuser à nouveau.'; - } else if (errorMessage.includes('status ACCEPTED')) { - errorMessage = 'Ce booking a déjà été accepté. Vous ne pouvez plus le refuser.'; - } else if (errorMessage.includes('not found')) { - errorMessage = 'Booking introuvable. Le lien peut avoir expiré.'; + if (errorMessage.includes('status REJECTED')) { + errorMessage = 'Ce booking a déjà été refusé.'; + } else if (errorMessage.includes('status ACCEPTED')) { + errorMessage = 'Ce booking a déjà été accepté.'; + } else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { + errorMessage = 'Booking introuvable. Le lien peut avoir expiré.'; + } + + throw new Error(errorMessage); } - throw new Error(errorMessage); + setLoading(false); + + // Démarrer le compte à rebours + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + router.push('/'); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + } catch (err) { + console.error('Error rejecting booking:', err); + setError(err instanceof Error ? err.message : 'Erreur lors du refus'); + setLoading(false); } + }; - const data = await response.json(); - - // Stocker le token JWT pour l'auto-login - if (data.autoLoginToken) { - localStorage.setItem('carrier_access_token', data.autoLoginToken); - } - - setBookingId(data.bookingId); - setIsNewAccount(data.isNewAccount); - setShowSuccess(true); - setLoading(false); - - // Redirection manuelle - plus de redirection automatique - } catch (err) { - console.error('Error rejecting booking:', err); - setError(err instanceof Error ? err.message : 'Erreur lors du refus'); - setLoading(false); - } - }; + rejectBooking(); + }, [token, router]); if (loading) { return (
- +

Traitement en cours...

- Nous traitons votre refus de la réservation. + Nous traitons votre refus.

); } - if (showSuccess) { - return ( -
-
- -

- Réservation Refusée -

- -
-

- ❌ La réservation a été refusée -

- {reason && ( -

- Raison : {reason} -

- )} -
- - {isNewAccount && ( -
-

- 🎉 Compte transporteur créé ! -

-

- Un email avec vos identifiants de connexion vous a été envoyé. -

-
- )} - - -
-
- ); - } - if (error) { return (
@@ -151,71 +109,43 @@ export default function CarrierRejectPage() {

Erreur

{error}

); } - // Formulaire de refus return ( -
-
-
- -

- Refuser la Réservation -

-

- Vous êtes sur le point de refuser cette demande de réservation. +

+
+ +

+ Merci de votre réponse +

+ +
+

+ ✓ Votre refus a bien été pris en compte +

+

+ Nous avons bien enregistré votre décision concernant cette demande de transport.

-
- -