fix booking validate

This commit is contained in:
David 2025-12-15 15:03:59 +01:00
parent faf1207300
commit 49b02face6
20 changed files with 407 additions and 3013 deletions

290
CLAUDE.md
View File

@ -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<BookingProps, 'bookingNumber' | 'status'>): 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

View File

@ -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,

View File

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

View File

@ -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<CarrierDashboardStats> {
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<any> {
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<void> {
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',
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('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 {}

View File

@ -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<CarrierProfileRepository>;
let userRepository: any;
let organizationRepository: any;
let jwtService: jest.Mocked<JwtService>;
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>(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);
});
});
});

View File

@ -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<CarrierProfileRepository>;
let carrierActivityRepository: jest.Mocked<CarrierActivityRepository>;
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>(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);
});
});
});

View File

@ -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<CsvBookingOrmEntity>,
) {}
/**
* Get carrier dashboard statistics
*/
async getCarrierStats(carrierId: string): Promise<CarrierDashboardStats> {
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<any> {
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<void> {
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<void> {
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}`);
}
}

View File

@ -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<string | null>(null);
const [bookingId, setBookingId] = useState<string | null>(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...
</h1>
<p className="text-gray-600">
Nous acceptons votre réservation et créons votre compte.
Nous traitons votre acceptation.
</p>
</div>
</div>
@ -110,10 +109,10 @@ export default function CarrierAcceptPage() {
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => router.push('/carrier/login')}
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour à la connexion
Retour à l'accueil
</button>
</div>
</div>
@ -123,34 +122,29 @@ export default function CarrierAcceptPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Réservation Acceptée !
Merci !
</h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 font-medium">
La réservation a é acceptée avec succès
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
<p className="text-green-800 font-medium text-lg mb-2">
Votre acceptation a bien é prise en compte
</p>
<p className="text-green-700 text-sm">
Nous vous remercions d'avoir accepté cette demande de transport.
</p>
</div>
{isNewAccount && (
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800 font-medium mb-2">
🎉 Compte transporteur créé !
</p>
<p className="text-sm text-blue-700">
Un email avec vos identifiants de connexion vous a é envoyé.
</p>
</div>
)}
<p className="text-gray-500 text-sm mb-4">
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
</p>
<button
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors flex items-center justify-center"
onClick={() => router.push('/')}
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors"
>
<Truck className="w-5 h-5 mr-2" />
Voir les détails de la réservation
Retour à l'accueil
</button>
</div>
</div>

View File

@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Connexion en cours...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 text-center mb-4">Erreur</h1>
<p className="text-gray-600 text-center">{error}</p>
</div>
</div>
);
}
const isAccepted = action === 'accepted';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-2xl w-full">
{/* Success Icon */}
{isAccepted ? (
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
) : (
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
)}
{/* Title */}
<h1 className="text-3xl font-bold text-gray-900 text-center mb-4">
{isAccepted ? '✅ Demande acceptée avec succès' : '❌ Demande refusée'}
</h1>
{/* New Account Message */}
{isNewAccount && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-900 font-semibold mb-2">🎉 Bienvenue sur Xpeditis !</p>
<p className="text-blue-800 text-sm">
Un compte transporteur a é créé automatiquement pour vous. Vous recevrez un email
avec vos identifiants de connexion.
</p>
</div>
)}
{/* Confirmation Message */}
<div className="mb-6">
<p className="text-gray-700 text-center mb-4">
{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.'}
</p>
</div>
{/* Redirection Notice */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<p className="text-gray-800 text-center">
<Loader2 className="w-4 h-4 animate-spin inline mr-2" />
Redirection vers votre tableau de bord dans quelques secondes...
</p>
</div>
{/* Next Steps */}
<div className="border-t pt-6">
<h2 className="text-lg font-semibold text-gray-900 mb-3">📋 Prochaines étapes</h2>
{isAccepted ? (
<ul className="space-y-2 text-gray-700">
<li className="flex items-start">
<span className="mr-2">1.</span>
<span>Le client va vous contacter directement par email</span>
</li>
<li className="flex items-start">
<span className="mr-2">2.</span>
<span>Envoyez-lui le numéro de réservation (booking number)</span>
</li>
<li className="flex items-start">
<span className="mr-2">3.</span>
<span>Organisez l'enlèvement de la marchandise</span>
</li>
<li className="flex items-start">
<span className="mr-2">4.</span>
<span>Suivez l'expédition depuis votre tableau de bord</span>
</li>
</ul>
) : (
<ul className="space-y-2 text-gray-700">
<li className="flex items-start">
<span className="mr-2">1.</span>
<span>Le client sera notifié de votre refus</span>
</li>
<li className="flex items-start">
<span className="mr-2">2.</span>
<span>Il pourra rechercher une alternative</span>
</li>
</ul>
)}
</div>
{/* Manual Link */}
<div className="mt-6 text-center">
<button
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
className="text-blue-600 hover:text-blue-800 font-medium"
>
Accéder maintenant au tableau de bord
</button>
</div>
</div>
</div>
);
}

View File

@ -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<BookingDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-green-100 text-green-800">
<CheckCircle className="w-4 h-4 mr-2" />
Accepté
</span>
);
case 'REJECTED':
return (
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-red-100 text-red-800">
<XCircle className="w-4 h-4 mr-2" />
Refusé
</span>
);
case 'PENDING':
return (
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-yellow-100 text-yellow-800">
<Clock className="w-4 h-4 mr-2" />
En attente
</span>
);
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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des détails...</p>
</div>
</div>
);
}
if (error || !booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 text-center mb-4">Erreur</h1>
<p className="text-gray-600 text-center mb-6">{error || 'Réservation introuvable'}</p>
<button
onClick={() => router.push('/carrier/dashboard')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour au tableau de bord
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<button
onClick={() => router.push('/carrier/dashboard')}
className="flex items-center text-blue-600 hover:text-blue-800 font-medium mb-4"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Retour au tableau de bord
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Détails de la Réservation
</h1>
<p className="text-gray-600">Référence: {booking.bookingId || booking.id}</p>
</div>
{getStatusBadge(booking.status)}
</div>
</div>
</div>
{/* Route Information */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
<MapPin className="w-6 h-6 mr-2 text-blue-600" />
Itinéraire
</h2>
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Origine</p>
<p className="text-2xl font-bold text-blue-600">{booking.origin}</p>
</div>
<div className="flex-1 mx-8">
<div className="border-t-2 border-dashed border-gray-300 relative">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
<Truck className="w-6 h-6 text-gray-400" />
</div>
</div>
<p className="text-center text-sm text-gray-600 mt-2">
{booking.transitDays} jours de transit
</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Destination</p>
<p className="text-2xl font-bold text-blue-600">{booking.destination}</p>
</div>
</div>
</div>
{/* Shipment Details */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
<Package className="w-6 h-6 mr-2 text-blue-600" />
Détails de la Marchandise
</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center mb-2">
<Box className="w-5 h-5 text-gray-600 mr-2" />
<p className="text-sm text-gray-600">Volume</p>
</div>
<p className="text-2xl font-bold text-gray-900">{booking.volumeCBM} CBM</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center mb-2">
<Weight className="w-5 h-5 text-gray-600 mr-2" />
<p className="text-sm text-gray-600">Poids</p>
</div>
<p className="text-2xl font-bold text-gray-900">{booking.weightKG} kg</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center mb-2">
<Package className="w-5 h-5 text-gray-600 mr-2" />
<p className="text-sm text-gray-600">Palettes</p>
</div>
<p className="text-2xl font-bold text-gray-900">{booking.palletCount || 'N/A'}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center mb-2">
<Truck className="w-5 h-5 text-gray-600 mr-2" />
<p className="text-sm text-gray-600">Type</p>
</div>
<p className="text-2xl font-bold text-gray-900">{booking.containerType}</p>
</div>
</div>
</div>
{/* Pricing */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
<DollarSign className="w-6 h-6 mr-2 text-blue-600" />
Prix
</h2>
<div className="flex items-center justify-between bg-green-50 rounded-lg p-6">
<div>
<p className="text-sm text-gray-600 mb-1">Prix total</p>
<p className="text-4xl font-bold text-green-600">
{formatPrice(
booking.primaryCurrency === 'USD' ? booking.priceUSD : booking.priceEUR,
booking.primaryCurrency
)}
</p>
</div>
{booking.primaryCurrency === 'USD' && booking.priceEUR > 0 && (
<div className="text-right">
<p className="text-sm text-gray-600 mb-1">Équivalent EUR</p>
<p className="text-2xl font-semibold text-gray-700">
{formatPrice(booking.priceEUR, 'EUR')}
</p>
</div>
)}
{booking.primaryCurrency === 'EUR' && booking.priceUSD > 0 && (
<div className="text-right">
<p className="text-sm text-gray-600 mb-1">Équivalent USD</p>
<p className="text-2xl font-semibold text-gray-700">
{formatPrice(booking.priceUSD, 'USD')}
</p>
</div>
)}
</div>
</div>
{/* Documents */}
{booking.documents && booking.documents.length > 0 && (
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
<FileText className="w-6 h-6 mr-2 text-blue-600" />
Documents ({booking.documents.length})
</h2>
<div className="space-y-3">
{booking.documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center">
<FileText className="w-5 h-5 text-blue-600 mr-3" />
<div>
<p className="font-medium text-gray-900">{doc.fileName}</p>
<p className="text-sm text-gray-600">{doc.type}</p>
</div>
</div>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Download className="w-4 h-4 mr-2" />
Télécharger
</a>
</div>
))}
</div>
</div>
)}
{/* Notes */}
{booking.notes && (
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">📝 Notes</h2>
<p className="text-gray-700 whitespace-pre-wrap">{booking.notes}</p>
</div>
)}
{/* Rejection Reason */}
{booking.status === 'REJECTED' && booking.rejectionReason && (
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6 mb-6">
<h2 className="text-xl font-bold text-red-900 mb-4"> Raison du refus</h2>
<p className="text-red-800">{booking.rejectionReason}</p>
</div>
)}
{/* Timeline */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
<Calendar className="w-6 h-6 mr-2 text-blue-600" />
Chronologie
</h2>
<div className="space-y-4">
<div className="flex items-start">
<div className="w-2 h-2 bg-blue-600 rounded-full mt-2 mr-4"></div>
<div>
<p className="font-semibold text-gray-900">Demande reçue</p>
<p className="text-sm text-gray-600">{formatDate(booking.requestedAt)}</p>
</div>
</div>
{booking.respondedAt && (
<div className="flex items-start">
<div className={`w-2 h-2 rounded-full mt-2 mr-4 ${
booking.status === 'ACCEPTED' ? 'bg-green-600' : 'bg-red-600'
}`}></div>
<div>
<p className="font-semibold text-gray-900">
{booking.status === 'ACCEPTED' ? 'Acceptée' : 'Refusée'}
</p>
<p className="text-sm text-gray-600">{formatDate(booking.respondedAt)}</p>
</div>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="mt-6 flex gap-4">
<button
onClick={() => router.push('/carrier/dashboard')}
className="flex-1 px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
>
Retour au tableau de bord
</button>
<button
onClick={() => window.print()}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold"
>
Imprimer les détails
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
{/* Mobile Sidebar Toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 bg-white border-b z-20 p-4">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="text-gray-600 hover:text-gray-900"
>
{isSidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 h-full w-64 bg-white border-r z-30 transform transition-transform lg:transform-none ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* Logo */}
<div className="p-6 border-b">
<div className="flex items-center space-x-3">
<Ship className="w-8 h-8 text-blue-600" />
<div>
<h1 className="font-bold text-lg text-gray-900">Xpeditis</h1>
<p className="text-sm text-gray-600">Portail Transporteur</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="p-4">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-blue-50 text-blue-600 font-medium'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setIsSidebarOpen(false)}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
{/* Logout Button */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t">
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-red-600 hover:bg-red-50 w-full"
>
<LogOut className="w-5 h-5" />
<span>Déconnexion</span>
</button>
</div>
</aside>
{/* Main Content */}
<main className="lg:ml-64 pt-16 lg:pt-0">
<div className="p-6">{children}</div>
</main>
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
</div>
);
}

View File

@ -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<DashboardStats | null>(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 (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
if (!stats) {
return <div>Erreur de chargement des statistiques</div>;
}
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 (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Tableau de bord</h1>
<p className="text-gray-600 mt-1">Vue d'ensemble de votre activité</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((card) => {
const Icon = card.icon;
return (
<div key={card.title} className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between mb-4">
<Icon className={`w-8 h-8 text-${card.color}-600`} />
</div>
<h3 className="text-gray-600 text-sm font-medium">{card.title}</h3>
<p className="text-3xl font-bold text-gray-900 mt-2">{card.value}</p>
</div>
);
})}
</div>
{/* Revenue & Acceptance Rate */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<TrendingUp className="w-5 h-5 mr-2 text-green-600" />
Revenus totaux
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<DollarSign className="w-5 h-5 text-green-600 mr-2" />
<span className="text-gray-700">USD</span>
</div>
<span className="text-2xl font-bold text-gray-900">
${stats.totalRevenue.usd.toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Euro className="w-5 h-5 text-blue-600 mr-2" />
<span className="text-gray-700">EUR</span>
</div>
<span className="text-2xl font-bold text-gray-900">
{stats.totalRevenue.eur.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Acceptance Rate */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Activity className="w-5 h-5 mr-2 text-blue-600" />
Taux d'acceptation
</h2>
<div className="flex items-center justify-center h-32">
<div className="text-center">
<div className="text-5xl font-bold text-blue-600">
{stats.acceptanceRate.toFixed(1)}%
</div>
<p className="text-gray-600 mt-2">
{stats.acceptedBookings} acceptées / {stats.totalBookings} total
</p>
</div>
</div>
</div>
</div>
{/* Recent Activities */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Activité récente</h2>
{stats.recentActivities.length > 0 ? (
<div className="space-y-3">
{stats.recentActivities.map((activity) => (
<div
key={activity.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="text-gray-900 font-medium">{activity.description}</p>
<p className="text-gray-600 text-sm">
{new Date(activity.createdAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
activity.type === 'BOOKING_ACCEPTED'
? 'bg-green-100 text-green-800'
: activity.type === 'BOOKING_REJECTED'
? 'bg-red-100 text-red-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{activity.type}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-600 text-center py-8">Aucune activité récente</p>
)}
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full">
{/* Header */}
<div className="text-center mb-8">
<Ship className="w-16 h-16 text-blue-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">Portail Transporteur</h1>
<p className="text-gray-600">Connectez-vous à votre espace Xpeditis</p>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password"
type="password"
value={password}
onChange={(e) => 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="••••••••"
/>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Connexion...
</>
) : (
'Se connecter'
)}
</button>
</form>
{/* Footer Links */}
<div className="mt-6 text-center">
<a href="/carrier/forgot-password" className="text-blue-600 hover:text-blue-800 text-sm">
Mot de passe oublié ?
</a>
</div>
<div className="mt-4 text-center">
<p className="text-gray-600 text-sm">
Vous n'avez pas encore de compte ?<br />
<span className="text-blue-600 font-medium">
Un compte sera créé automatiquement lors de votre première acceptation de demande.
</span>
</p>
</div>
</div>
</div>
);
}

View File

@ -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<string | null>(null);
const [bookingId, setBookingId] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-red-600 mx-auto mb-4 animate-spin" />
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Traitement en cours...
</h1>
<p className="text-gray-600">
Nous traitons votre refus de la réservation.
Nous traitons votre refus.
</p>
</div>
</div>
);
}
if (showSuccess) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Réservation Refusée
</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-4 mb-6">
<p className="text-orange-800 font-medium">
La réservation a é refusée
</p>
{reason && (
<p className="text-sm text-orange-700 mt-2">
Raison : {reason}
</p>
)}
</div>
{isNewAccount && (
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800 font-medium mb-2">
🎉 Compte transporteur créé !
</p>
<p className="text-sm text-blue-700">
Un email avec vos identifiants de connexion vous a é envoyé.
</p>
</div>
)}
<button
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors flex items-center justify-center"
>
<Truck className="w-5 h-5 mr-2" />
Voir les détails de la réservation
</button>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
@ -151,71 +109,43 @@ export default function CarrierRejectPage() {
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => router.push('/carrier/login')}
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour à la connexion
Retour à l'accueil
</button>
</div>
</div>
);
}
// Formulaire de refus
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center mb-6">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Refuser la Réservation
</h1>
<p className="text-gray-600">
Vous êtes sur le point de refuser cette demande de réservation.
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Merci de votre réponse
</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
<p className="text-orange-800 font-medium text-lg mb-2">
Votre refus a bien é pris en compte
</p>
<p className="text-orange-700 text-sm">
Nous avons bien enregistré votre décision concernant cette demande de transport.
</p>
</div>
<div className="mb-6">
<label className="flex items-center text-sm font-medium text-gray-700 mb-2">
<MessageSquare className="w-4 h-4 mr-2" />
Raison du refus (optionnel)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Capacité insuffisante, date non disponible..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
rows={4}
maxLength={500}
/>
<p className="text-xs text-gray-500 mt-1">
{reason.length}/500 caractères
</p>
</div>
<p className="text-gray-500 text-sm mb-4">
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
</p>
<div className="space-y-3">
<button
onClick={handleReject}
disabled={loading}
className="w-full px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Confirmer le Refus
</button>
<button
onClick={() => router.back()}
disabled={loading}
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Annuler
</button>
</div>
<div className="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Le client sera notifié de votre refus{reason.trim() ? ' avec la raison fournie' : ''}.
</p>
</div>
<button
onClick={() => router.push('/')}
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors"
>
Retour à l'accueil
</button>
</div>
</div>
);

View File

@ -107,6 +107,3 @@ export const organizationsApi = {
return apiClient.get<Organization[]>('/api/v1/organizations');
},
};
// Export for use in components
export { organizationsApi };