This commit is contained in:
David-Henri ARNAUD 2025-10-15 15:14:49 +02:00
parent 22b17ef8c3
commit 68e321a08f
11 changed files with 115 additions and 171 deletions

View File

@ -11,7 +11,11 @@
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(k6:*)", "Bash(k6:*)",
"Bash(npx playwright:*)", "Bash(npx playwright:*)",
"Bash(npx newman:*)" "Bash(npx newman:*)",
"Bash(chmod:*)",
"Bash(netstat -ano)",
"Bash(findstr \":5432\")",
"Bash(findstr \"LISTENING\")"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -69,7 +69,7 @@ temp
# Docker # Docker
Dockerfile Dockerfile
.dockerignore .dockerignore
docker-compose*.yml docker-compose.yaml
# CI/CD # CI/CD
.gitlab-ci.yml .gitlab-ci.yml

View File

@ -0,0 +1,19 @@
services:
postgres:
image: postgres:latest
container_name: xpeditis-postgres
environment:
POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password
POSTGRES_DB: xpeditis_dev
ports:
- "5432:5432"
redis:
image: redis:7
container_name: xpeditis-redis
command: redis-server --requirepass xpeditis_redis_password
environment:
REDIS_PASSWORD: xpeditis_redis_password
ports:
- "6379:6379"

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { AuthController } from '../controllers/auth.controller'; import { AuthController } from '../controllers/auth.controller';
@ -9,18 +10,8 @@ import { AuthController } from '../controllers/auth.controller';
// Import domain and infrastructure dependencies // Import domain and infrastructure dependencies
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository'; import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
/**
* Authentication Module
*
* Wires together the authentication system:
* - JWT configuration with access/refresh tokens
* - Passport JWT strategy
* - Auth service and controller
* - User repository for database access
*
* This module should be imported in AppModule.
*/
@Module({ @Module({
imports: [ imports: [
// Passport configuration // Passport configuration
@ -37,6 +28,9 @@ import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/
}, },
}), }),
}), }),
// 👇 Add this to register TypeORM repository for UserOrmEntity
TypeOrmModule.forFeature([UserOrmEntity]),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [

View File

@ -1,8 +1,8 @@
import { Injectable, UnauthorizedException, ConflictException, Logger } from '@nestjs/common'; import { Injectable, UnauthorizedException, ConflictException, Logger, Inject } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { UserRepository } from '../../domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
import { User, UserRole } from '../../domain/entities/user.entity'; import { User, UserRole } from '../../domain/entities/user.entity';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -19,7 +19,8 @@ export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
constructor( constructor(
private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository, // ✅ Correct injection
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
@ -36,14 +37,12 @@ export class AuthService {
): Promise<{ accessToken: string; refreshToken: string; user: any }> { ): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Registering new user: ${email}`); this.logger.log(`Registering new user: ${email}`);
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(email); const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) { if (existingUser) {
throw new ConflictException('User with this email already exists'); throw new ConflictException('User with this email already exists');
} }
// Hash password with Argon2
const passwordHash = await argon2.hash(password, { const passwordHash = await argon2.hash(password, {
type: argon2.argon2id, type: argon2.argon2id,
memoryCost: 65536, // 64 MB memoryCost: 65536, // 64 MB
@ -51,7 +50,6 @@ export class AuthService {
parallelism: 4, parallelism: 4,
}); });
// Create user entity
const user = User.create({ const user = User.create({
id: uuidv4(), id: uuidv4(),
organizationId, organizationId,
@ -59,13 +57,11 @@ export class AuthService {
passwordHash, passwordHash,
firstName, firstName,
lastName, lastName,
role: UserRole.USER, // Default role role: UserRole.USER,
}); });
// Save to database
const savedUser = await this.userRepository.save(user); const savedUser = await this.userRepository.save(user);
// Generate tokens
const tokens = await this.generateTokens(savedUser); const tokens = await this.generateTokens(savedUser);
this.logger.log(`User registered successfully: ${email}`); this.logger.log(`User registered successfully: ${email}`);
@ -92,7 +88,6 @@ export class AuthService {
): Promise<{ accessToken: string; refreshToken: string; user: any }> { ): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Login attempt for: ${email}`); this.logger.log(`Login attempt for: ${email}`);
// Find user by email
const user = await this.userRepository.findByEmail(email); const user = await this.userRepository.findByEmail(email);
if (!user) { if (!user) {
@ -103,14 +98,12 @@ export class AuthService {
throw new UnauthorizedException('User account is inactive'); throw new UnauthorizedException('User account is inactive');
} }
// Verify password
const isPasswordValid = await argon2.verify(user.passwordHash, password); const isPasswordValid = await argon2.verify(user.passwordHash, password);
if (!isPasswordValid) { if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
// Generate tokens
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user);
this.logger.log(`User logged in successfully: ${email}`); this.logger.log(`User logged in successfully: ${email}`);
@ -133,7 +126,6 @@ export class AuthService {
*/ */
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
try { try {
// Verify refresh token
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, { const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.configService.get('JWT_SECRET'), secret: this.configService.get('JWT_SECRET'),
}); });
@ -142,14 +134,12 @@ export class AuthService {
throw new UnauthorizedException('Invalid token type'); throw new UnauthorizedException('Invalid token type');
} }
// Get user
const user = await this.userRepository.findById(payload.sub); const user = await this.userRepository.findById(payload.sub);
if (!user || !user.isActive) { if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive'); throw new UnauthorizedException('User not found or inactive');
} }
// Generate new tokens
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user);
this.logger.log(`Access token refreshed for user: ${user.email}`); this.logger.log(`Access token refreshed for user: ${user.email}`);

View File

@ -101,17 +101,13 @@ export class UsersController {
}) })
async createUser( async createUser(
@Body() dto: CreateUserDto, @Body() dto: CreateUserDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
this.logger.log( this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`);
`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`,
);
// Authorization: Managers can only create users in their own organization // Authorization: Managers can only create users in their own organization
if (user.role === 'manager' && dto.organizationId !== user.organizationId) { if (user.role === 'manager' && dto.organizationId !== user.organizationId) {
throw new ForbiddenException( throw new ForbiddenException('You can only create users in your own organization');
'You can only create users in your own organization',
);
} }
// Check if user already exists // Check if user already exists
@ -121,8 +117,7 @@ export class UsersController {
} }
// Generate temporary password if not provided // Generate temporary password if not provided
const tempPassword = const tempPassword = dto.password || this.generateTemporaryPassword();
dto.password || this.generateTemporaryPassword();
// Hash password with Argon2id // Hash password with Argon2id
const passwordHash = await argon2.hash(tempPassword, { const passwordHash = await argon2.hash(tempPassword, {
@ -153,7 +148,7 @@ export class UsersController {
// TODO: Send invitation email with temporary password // TODO: Send invitation email with temporary password
this.logger.warn( this.logger.warn(
`TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`, `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`
); );
return UserMapper.toDto(savedUser); return UserMapper.toDto(savedUser);
@ -165,8 +160,7 @@ export class UsersController {
@Get(':id') @Get(':id')
@ApiOperation({ @ApiOperation({
summary: 'Get user by ID', summary: 'Get user by ID',
description: description: 'Retrieve user details. Users can view users in their org, admins can view any.',
'Retrieve user details. Users can view users in their org, admins can view any.',
}) })
@ApiParam({ @ApiParam({
name: 'id', name: 'id',
@ -183,7 +177,7 @@ export class UsersController {
}) })
async getUser( async getUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() currentUser: UserPayload, @CurrentUser() currentUser: UserPayload
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
this.logger.log(`[User: ${currentUser.email}] Fetching user: ${id}`); this.logger.log(`[User: ${currentUser.email}] Fetching user: ${id}`);
@ -193,10 +187,7 @@ export class UsersController {
} }
// Authorization: Can only view users in same organization (unless admin) // Authorization: Can only view users in same organization (unless admin)
if ( if (currentUser.role !== 'admin' && user.organizationId !== currentUser.organizationId) {
currentUser.role !== 'admin' &&
user.organizationId !== currentUser.organizationId
) {
throw new ForbiddenException('You can only view users in your organization'); throw new ForbiddenException('You can only view users in your organization');
} }
@ -211,8 +202,7 @@ export class UsersController {
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({ @ApiOperation({
summary: 'Update user', summary: 'Update user',
description: description: 'Update user details (name, role, status). Admin/manager only.',
'Update user details (name, role, status). Admin/manager only.',
}) })
@ApiParam({ @ApiParam({
name: 'id', name: 'id',
@ -233,7 +223,7 @@ export class UsersController {
async updateUser( async updateUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto, @Body() dto: UpdateUserDto,
@CurrentUser() currentUser: UserPayload, @CurrentUser() currentUser: UserPayload
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
this.logger.log(`[User: ${currentUser.email}] Updating user: ${id}`); this.logger.log(`[User: ${currentUser.email}] Updating user: ${id}`);
@ -243,13 +233,8 @@ export class UsersController {
} }
// Authorization: Managers can only update users in their own organization // Authorization: Managers can only update users in their own organization
if ( if (currentUser.role === 'manager' && user.organizationId !== currentUser.organizationId) {
currentUser.role === 'manager' && throw new ForbiddenException('You can only update users in your own organization');
user.organizationId !== currentUser.organizationId
) {
throw new ForbiddenException(
'You can only update users in your own organization',
);
} }
// Update fields // Update fields
@ -308,7 +293,7 @@ export class UsersController {
}) })
async deleteUser( async deleteUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@CurrentUser() currentUser: UserPayload, @CurrentUser() currentUser: UserPayload
): Promise<void> { ): Promise<void> {
this.logger.log(`[Admin: ${currentUser.email}] Deactivating user: ${id}`); this.logger.log(`[Admin: ${currentUser.email}] Deactivating user: ${id}`);
@ -360,21 +345,17 @@ export class UsersController {
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
@Query('role') role: string | undefined, @Query('role') role: string | undefined,
@CurrentUser() currentUser: UserPayload, @CurrentUser() currentUser: UserPayload
): Promise<UserListResponseDto> { ): Promise<UserListResponseDto> {
this.logger.log( this.logger.log(
`[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}`, `[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}`
); );
// Fetch users by organization // Fetch users by organization
const users = await this.userRepository.findByOrganization( const users = await this.userRepository.findByOrganization(currentUser.organizationId);
currentUser.organizationId,
);
// Filter by role if provided // Filter by role if provided
const filteredUsers = role const filteredUsers = role ? users.filter(u => u.role === role) : users;
? users.filter(u => u.role === role)
: users;
// Paginate // Paginate
const startIndex = (page - 1) * pageSize; const startIndex = (page - 1) * pageSize;
@ -418,7 +399,7 @@ export class UsersController {
}) })
async updatePassword( async updatePassword(
@Body() dto: UpdatePasswordDto, @Body() dto: UpdatePasswordDto,
@CurrentUser() currentUser: UserPayload, @CurrentUser() currentUser: UserPayload
): Promise<{ message: string }> { ): Promise<{ message: string }> {
this.logger.log(`[User: ${currentUser.email}] Updating password`); this.logger.log(`[User: ${currentUser.email}] Updating password`);
@ -428,10 +409,7 @@ export class UsersController {
} }
// Verify current password // Verify current password
const isPasswordValid = await argon2.verify( const isPasswordValid = await argon2.verify(user.passwordHash, dto.currentPassword);
user.passwordHash,
dto.currentPassword,
);
if (!isPasswordValid) { if (!isPasswordValid) {
throw new ForbiddenException('Current password is incorrect'); throw new ForbiddenException('Current password is incorrect');
@ -459,8 +437,7 @@ export class UsersController {
*/ */
private generateTemporaryPassword(): string { private generateTemporaryPassword(): string {
const length = 16; const length = 16;
const charset = const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = ''; let password = '';
const randomBytes = crypto.randomBytes(length); const randomBytes = crypto.randomBytes(length);

View File

@ -1,20 +1,16 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrganizationsController } from '../controllers/organizations.controller'; import { OrganizationsController } from '../controllers/organizations.controller';
// Import domain ports // Import domain ports
import { ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository'; import { ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
/**
* Organizations Module
*
* Handles organization management functionality:
* - Create organizations (admin only)
* - View organization details
* - Update organization (admin/manager)
* - List organizations
*/
@Module({ @Module({
imports: [
TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider
],
controllers: [OrganizationsController], controllers: [OrganizationsController],
providers: [ providers: [
{ {
@ -22,6 +18,8 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/
useClass: TypeOrmOrganizationRepository, useClass: TypeOrmOrganizationRepository,
}, },
], ],
exports: [], exports: [
ORGANIZATION_REPOSITORY, // optional, if other modules need it
],
}) })
export class OrganizationsModule {} export class OrganizationsModule {}

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RatesController } from '../controllers/rates.controller'; import { RatesController } from '../controllers/rates.controller';
import { CacheModule } from '../../infrastructure/cache/cache.module'; import { CacheModule } from '../../infrastructure/cache/cache.module';
import { CarrierModule } from '../../infrastructure/carriers/carrier.module'; import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
@ -6,18 +7,14 @@ import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
// Import domain ports // Import domain ports
import { RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository'; import { RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository';
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository'; import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
/**
* Rates Module
*
* Handles rate search functionality:
* - Rate search API endpoint
* - Integration with carrier APIs
* - Redis caching for rate quotes
* - Rate quote persistence
*/
@Module({ @Module({
imports: [CacheModule, CarrierModule], imports: [
CacheModule,
CarrierModule,
TypeOrmModule.forFeature([RateQuoteOrmEntity]), // 👈 Add this
],
controllers: [RatesController], controllers: [RatesController],
providers: [ providers: [
{ {
@ -25,6 +22,8 @@ import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typ
useClass: TypeOrmRateQuoteRepository, useClass: TypeOrmRateQuoteRepository,
}, },
], ],
exports: [], exports: [
RATE_QUOTE_REPOSITORY, // optional, if used in other modules
],
}) })
export class RatesModule {} export class RatesModule {}

View File

@ -1,22 +1,16 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from '../controllers/users.controller'; import { UsersController } from '../controllers/users.controller';
// Import domain ports // Import domain ports
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository'; import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
/**
* Users Module
*
* Handles user management functionality:
* - Create/invite users (admin/manager)
* - View user details
* - Update user (admin/manager)
* - Deactivate user (admin)
* - List users in organization
* - Update own password
*/
@Module({ @Module({
imports: [
TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line
],
controllers: [UsersController], controllers: [UsersController],
providers: [ providers: [
{ {
@ -24,6 +18,8 @@ import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,
}, },
], ],
exports: [], exports: [
USER_REPOSITORY, // optional, export if other modules need it
],
}) })
export class UsersModule {} export class UsersModule {}

View File

@ -1,15 +1,9 @@
/** import { Injectable, Inject, NotFoundException } from '@nestjs/common';
* BookingService (Domain Service) import { Booking } from '../entities/booking.entity';
*
* Business logic for booking management
*/
import { Injectable } from '@nestjs/common';
import { Booking, BookingContainer } from '../entities/booking.entity';
import { BookingNumber } from '../value-objects/booking-number.vo';
import { BookingStatus } from '../value-objects/booking-status.vo';
import { BookingRepository } from '../ports/out/booking.repository'; import { BookingRepository } from '../ports/out/booking.repository';
import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
import { BOOKING_REPOSITORY } from '../ports/out/booking.repository';
import { RATE_QUOTE_REPOSITORY } from '../ports/out/rate-quote.repository';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export interface CreateBookingInput { export interface CreateBookingInput {
@ -24,7 +18,10 @@ export interface CreateBookingInput {
@Injectable() @Injectable()
export class BookingService { export class BookingService {
constructor( constructor(
@Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository, private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository private readonly rateQuoteRepository: RateQuoteRepository
) {} ) {}
@ -35,7 +32,7 @@ export class BookingService {
// Validate rate quote exists // Validate rate quote exists
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId); const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
if (!rateQuote) { if (!rateQuote) {
throw new Error(`Rate quote ${input.rateQuoteId} not found`); throw new NotFoundException(`Rate quote ${input.rateQuoteId} not found`);
} }
// TODO: Get userId and organizationId from context // TODO: Get userId and organizationId from context
@ -51,7 +48,7 @@ export class BookingService {
shipper: input.shipper, shipper: input.shipper,
consignee: input.consignee, consignee: input.consignee,
cargoDescription: input.cargoDescription, cargoDescription: input.cargoDescription,
containers: input.containers.map((c) => ({ containers: input.containers.map(c => ({
id: uuidv4(), id: uuidv4(),
type: c.type, type: c.type,
containerNumber: c.containerNumber, containerNumber: c.containerNumber,

View File

@ -7,7 +7,7 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import CircuitBreaker from 'opossum'; import * as CircuitBreaker from 'opossum'; // ✅ Correction ici
import { import {
CarrierConnectorPort, CarrierConnectorPort,
CarrierRateSearchInput, CarrierRateSearchInput,
@ -45,28 +45,28 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
}, },
}); });
// Add request interceptor for logging // Request interceptor
this.httpClient.interceptors.request.use( this.httpClient.interceptors.request.use(
(request: any) => { request => {
this.logger.debug( this.logger.debug(
`Request: ${request.method?.toUpperCase()} ${request.url}`, `Request: ${request.method?.toUpperCase()} ${request.url}`,
request.data ? JSON.stringify(request.data).substring(0, 200) : '' request.data ? JSON.stringify(request.data).substring(0, 200) : ''
); );
return request; return request;
}, },
(error: any) => { error => {
this.logger.error(`Request error: ${error?.message || 'Unknown error'}`); this.logger.error(`Request error: ${error?.message || 'Unknown error'}`);
return Promise.reject(error); return Promise.reject(error);
} }
); );
// Add response interceptor for logging // Response interceptor
this.httpClient.interceptors.response.use( this.httpClient.interceptors.response.use(
(response: any) => { response => {
this.logger.debug(`Response: ${response.status} ${response.statusText}`); this.logger.debug(`Response: ${response.status} ${response.statusText}`);
return response; return response;
}, },
(error: any) => { error => {
if (error?.code === 'ECONNABORTED') { if (error?.code === 'ECONNABORTED') {
this.logger.warn(`Request timeout after ${config.timeout}ms`); this.logger.warn(`Request timeout after ${config.timeout}ms`);
throw new CarrierTimeoutException(config.name, config.timeout); throw new CarrierTimeoutException(config.name, config.timeout);
@ -76,7 +76,7 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
} }
); );
// Create circuit breaker // Circuit breaker
this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), { this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), {
timeout: config.timeout, timeout: config.timeout,
errorThresholdPercentage: config.circuitBreakerThreshold, errorThresholdPercentage: config.circuitBreakerThreshold,
@ -84,18 +84,15 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
name: `${config.name}-circuit-breaker`, name: `${config.name}-circuit-breaker`,
}); });
// Circuit breaker event handlers this.circuitBreaker.on('open', () =>
this.circuitBreaker.on('open', () => { this.logger.warn('Circuit breaker opened - carrier unavailable')
this.logger.warn('Circuit breaker opened - carrier unavailable'); );
}); this.circuitBreaker.on('halfOpen', () =>
this.logger.log('Circuit breaker half-open - testing carrier availability')
this.circuitBreaker.on('halfOpen', () => { );
this.logger.log('Circuit breaker half-open - testing carrier availability'); this.circuitBreaker.on('close', () =>
}); this.logger.log('Circuit breaker closed - carrier available')
);
this.circuitBreaker.on('close', () => {
this.logger.log('Circuit breaker closed - carrier available');
});
} }
getCarrierName(): string { getCarrierName(): string {
@ -106,9 +103,6 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
return this.config.code; return this.config.code;
} }
/**
* Make HTTP request with retry logic
*/
protected async makeRequest<T>( protected async makeRequest<T>(
config: AxiosRequestConfig, config: AxiosRequestConfig,
retries = this.config.maxRetries retries = this.config.maxRetries
@ -126,41 +120,27 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
} }
} }
/**
* Determine if error is retryable
*/
protected isRetryableError(error: any): boolean { protected isRetryableError(error: any): boolean {
// Retry on network errors, timeouts, and 5xx server errors if (error.code === 'ECONNABORTED') return false;
if (error.code === 'ECONNABORTED') return false; // Don't retry timeouts if (error.code === 'ENOTFOUND') return false;
if (error.code === 'ENOTFOUND') return false; // Don't retry DNS errors
if (error.response) { if (error.response) {
const status = error.response.status; const status = error.response.status;
return status >= 500 && status < 600; return status >= 500 && status < 600;
} }
return true; // Retry network errors return true;
} }
/**
* Calculate retry delay with exponential backoff
*/
protected calculateRetryDelay(attempt: number): number { protected calculateRetryDelay(attempt: number): number {
const baseDelay = 1000; // 1 second const baseDelay = 1000;
const maxDelay = 5000; // 5 seconds const maxDelay = 5000;
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
// Add jitter to prevent thundering herd return delay + Math.random() * 1000; // jitter
return delay + Math.random() * 1000;
} }
/**
* Sleep utility
*/
protected sleep(ms: number): Promise<void> { protected sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
/**
* Make request with circuit breaker protection
*/
protected async requestWithCircuitBreaker<T>( protected async requestWithCircuitBreaker<T>(
config: AxiosRequestConfig config: AxiosRequestConfig
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
@ -174,16 +154,9 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
} }
} }
/**
* Health check implementation
*/
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
await this.requestWithCircuitBreaker({ await this.requestWithCircuitBreaker({ method: 'GET', url: '/health', timeout: 5000 });
method: 'GET',
url: '/health',
timeout: 5000,
});
return true; return true;
} catch (error: any) { } catch (error: any) {
this.logger.warn(`Health check failed: ${error?.message || 'Unknown error'}`); this.logger.warn(`Health check failed: ${error?.message || 'Unknown error'}`);
@ -191,9 +164,6 @@ export abstract class BaseCarrierConnector implements CarrierConnectorPort {
} }
} }
/**
* Abstract methods to be implemented by specific carriers
*/
abstract searchRates(input: CarrierRateSearchInput): Promise<RateQuote[]>; abstract searchRates(input: CarrierRateSearchInput): Promise<RateQuote[]>;
abstract checkAvailability(input: CarrierAvailabilityInput): Promise<number>; abstract checkAvailability(input: CarrierAvailabilityInput): Promise<number>;
} }