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(k6:*)",
"Bash(npx playwright:*)",
"Bash(npx newman:*)"
"Bash(npx newman:*)",
"Bash(chmod:*)",
"Bash(netstat -ano)",
"Bash(findstr \":5432\")",
"Bash(findstr \"LISTENING\")"
],
"deny": [],
"ask": []

View File

@ -69,7 +69,7 @@ temp
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
docker-compose.yaml
# CI/CD
.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 { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from '../controllers/auth.controller';
@ -9,18 +10,8 @@ import { AuthController } from '../controllers/auth.controller';
// Import domain and infrastructure dependencies
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
/**
* 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({
imports: [
// 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],
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 { ConfigService } from '@nestjs/config';
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 { v4 as uuidv4 } from 'uuid';
@ -19,7 +19,8 @@ export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly userRepository: UserRepository,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository, // ✅ Correct injection
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@ -36,14 +37,12 @@ export class AuthService {
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Registering new user: ${email}`);
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Hash password with Argon2
const passwordHash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
@ -51,7 +50,6 @@ export class AuthService {
parallelism: 4,
});
// Create user entity
const user = User.create({
id: uuidv4(),
organizationId,
@ -59,13 +57,11 @@ export class AuthService {
passwordHash,
firstName,
lastName,
role: UserRole.USER, // Default role
role: UserRole.USER,
});
// Save to database
const savedUser = await this.userRepository.save(user);
// Generate tokens
const tokens = await this.generateTokens(savedUser);
this.logger.log(`User registered successfully: ${email}`);
@ -92,7 +88,6 @@ export class AuthService {
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Login attempt for: ${email}`);
// Find user by email
const user = await this.userRepository.findByEmail(email);
if (!user) {
@ -103,14 +98,12 @@ export class AuthService {
throw new UnauthorizedException('User account is inactive');
}
// Verify password
const isPasswordValid = await argon2.verify(user.passwordHash, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Generate tokens
const tokens = await this.generateTokens(user);
this.logger.log(`User logged in successfully: ${email}`);
@ -133,7 +126,6 @@ export class AuthService {
*/
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
try {
// Verify refresh token
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.configService.get('JWT_SECRET'),
});
@ -142,14 +134,12 @@ export class AuthService {
throw new UnauthorizedException('Invalid token type');
}
// Get user
const user = await this.userRepository.findById(payload.sub);
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
// Generate new tokens
const tokens = await this.generateTokens(user);
this.logger.log(`Access token refreshed for user: ${user.email}`);

View File

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

View File

@ -1,20 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrganizationsController } from '../controllers/organizations.controller';
// Import domain ports
import { ORGANIZATION_REPOSITORY } from '../../domain/ports/out/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({
imports: [
TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider
],
controllers: [OrganizationsController],
providers: [
{
@ -22,6 +18,8 @@ import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/
useClass: TypeOrmOrganizationRepository,
},
],
exports: [],
exports: [
ORGANIZATION_REPOSITORY, // optional, if other modules need it
],
})
export class OrganizationsModule {}

View File

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RatesController } from '../controllers/rates.controller';
import { CacheModule } from '../../infrastructure/cache/cache.module';
import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
@ -6,18 +7,14 @@ import { CarrierModule } from '../../infrastructure/carriers/carrier.module';
// Import domain ports
import { RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/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({
imports: [CacheModule, CarrierModule],
imports: [
CacheModule,
CarrierModule,
TypeOrmModule.forFeature([RateQuoteOrmEntity]), // 👈 Add this
],
controllers: [RatesController],
providers: [
{
@ -25,6 +22,8 @@ import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typ
useClass: TypeOrmRateQuoteRepository,
},
],
exports: [],
exports: [
RATE_QUOTE_REPOSITORY, // optional, if used in other modules
],
})
export class RatesModule {}

View File

@ -1,22 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from '../controllers/users.controller';
// Import domain ports
import { USER_REPOSITORY } from '../../domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
/**
* 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({
imports: [
TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line
],
controllers: [UsersController],
providers: [
{
@ -24,6 +18,8 @@ import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/
useClass: TypeOrmUserRepository,
},
],
exports: [],
exports: [
USER_REPOSITORY, // optional, export if other modules need it
],
})
export class UsersModule {}

View File

@ -1,15 +1,9 @@
/**
* BookingService (Domain Service)
*
* 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 { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Booking } from '../entities/booking.entity';
import { BookingRepository } from '../ports/out/booking.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';
export interface CreateBookingInput {
@ -24,7 +18,10 @@ export interface CreateBookingInput {
@Injectable()
export class BookingService {
constructor(
@Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository
) {}
@ -35,7 +32,7 @@ export class BookingService {
// Validate rate quote exists
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
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
@ -51,7 +48,7 @@ export class BookingService {
shipper: input.shipper,
consignee: input.consignee,
cargoDescription: input.cargoDescription,
containers: input.containers.map((c) => ({
containers: input.containers.map(c => ({
id: uuidv4(),
type: c.type,
containerNumber: c.containerNumber,

View File

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