format prettier

This commit is contained in:
David 2025-10-27 20:54:01 +01:00
parent 07b08e3014
commit d809feecef
166 changed files with 13053 additions and 13332 deletions

View File

@ -1,123 +1,121 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import * as Joi from 'joi'; import * as Joi from 'joi';
// Import feature modules // Import feature modules
import { AuthModule } from './application/auth/auth.module'; import { AuthModule } from './application/auth/auth.module';
import { RatesModule } from './application/rates/rates.module'; import { RatesModule } from './application/rates/rates.module';
import { BookingsModule } from './application/bookings/bookings.module'; import { BookingsModule } from './application/bookings/bookings.module';
import { OrganizationsModule } from './application/organizations/organizations.module'; import { OrganizationsModule } from './application/organizations/organizations.module';
import { UsersModule } from './application/users/users.module'; import { UsersModule } from './application/users/users.module';
import { DashboardModule } from './application/dashboard/dashboard.module'; import { DashboardModule } from './application/dashboard/dashboard.module';
import { AuditModule } from './application/audit/audit.module'; import { AuditModule } from './application/audit/audit.module';
import { NotificationsModule } from './application/notifications/notifications.module'; import { NotificationsModule } from './application/notifications/notifications.module';
import { WebhooksModule } from './application/webhooks/webhooks.module'; import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module'; import { GDPRModule } from './application/gdpr/gdpr.module';
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';
import { SecurityModule } from './infrastructure/security/security.module'; import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards // Import global guards
import { JwtAuthGuard } from './application/guards/jwt-auth.guard'; import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard'; import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({ @Module({
imports: [ imports: [
// Configuration // Configuration
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
validationSchema: Joi.object({ validationSchema: Joi.object({
NODE_ENV: Joi.string() NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
.valid('development', 'production', 'test') PORT: Joi.number().default(4000),
.default('development'), DATABASE_HOST: Joi.string().required(),
PORT: Joi.number().default(4000), DATABASE_PORT: Joi.number().default(5432),
DATABASE_HOST: Joi.string().required(), DATABASE_USER: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432), DATABASE_PASSWORD: Joi.string().required(),
DATABASE_USER: Joi.string().required(), DATABASE_NAME: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(), REDIS_HOST: Joi.string().required(),
DATABASE_NAME: Joi.string().required(), REDIS_PORT: Joi.number().default(6379),
REDIS_HOST: Joi.string().required(), REDIS_PASSWORD: Joi.string().required(),
REDIS_PORT: Joi.number().default(6379), JWT_SECRET: Joi.string().required(),
REDIS_PASSWORD: Joi.string().required(), JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
JWT_SECRET: Joi.string().required(), JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'), }),
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'), }),
}),
}), // Logging
LoggerModule.forRootAsync({
// Logging useFactory: (configService: ConfigService) => ({
LoggerModule.forRootAsync({ pinoHttp: {
useFactory: (configService: ConfigService) => ({ transport:
pinoHttp: { configService.get('NODE_ENV') === 'development'
transport: ? {
configService.get('NODE_ENV') === 'development' target: 'pino-pretty',
? { options: {
target: 'pino-pretty', colorize: true,
options: { translateTime: 'SYS:standard',
colorize: true, ignore: 'pid,hostname',
translateTime: 'SYS:standard', },
ignore: 'pid,hostname', }
}, : undefined,
} level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
: undefined, },
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug', }),
}, inject: [ConfigService],
}), }),
inject: [ConfigService],
}), // Database
TypeOrmModule.forRootAsync({
// Database useFactory: (configService: ConfigService) => ({
TypeOrmModule.forRootAsync({ type: 'postgres',
useFactory: (configService: ConfigService) => ({ host: configService.get('DATABASE_HOST'),
type: 'postgres', port: configService.get('DATABASE_PORT'),
host: configService.get('DATABASE_HOST'), username: configService.get('DATABASE_USER'),
port: configService.get('DATABASE_PORT'), password: configService.get('DATABASE_PASSWORD'),
username: configService.get('DATABASE_USER'), database: configService.get('DATABASE_NAME'),
password: configService.get('DATABASE_PASSWORD'), entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
database: configService.get('DATABASE_NAME'), synchronize: configService.get('DATABASE_SYNC', false),
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'], logging: configService.get('DATABASE_LOGGING', false),
synchronize: configService.get('DATABASE_SYNC', false), autoLoadEntities: true, // Auto-load entities from forFeature()
logging: configService.get('DATABASE_LOGGING', false), }),
autoLoadEntities: true, // Auto-load entities from forFeature() inject: [ConfigService],
}), }),
inject: [ConfigService],
}), // Infrastructure modules
SecurityModule,
// Infrastructure modules CacheModule,
SecurityModule, CarrierModule,
CacheModule, CsvRateModule,
CarrierModule,
CsvRateModule, // Feature modules
AuthModule,
// Feature modules RatesModule,
AuthModule, BookingsModule,
RatesModule, OrganizationsModule,
BookingsModule, UsersModule,
OrganizationsModule, DashboardModule,
UsersModule, AuditModule,
DashboardModule, NotificationsModule,
AuditModule, WebhooksModule,
NotificationsModule, GDPRModule,
WebhooksModule, ],
GDPRModule, controllers: [],
], providers: [
controllers: [], // Global JWT authentication guard
providers: [ // All routes are protected by default, use @Public() to bypass
// Global JWT authentication guard {
// All routes are protected by default, use @Public() to bypass provide: APP_GUARD,
{ useClass: JwtAuthGuard,
provide: APP_GUARD, },
useClass: JwtAuthGuard, // Global rate limiting guard
}, {
// Global rate limiting guard provide: APP_GUARD,
{ useClass: CustomThrottlerGuard,
provide: APP_GUARD, },
useClass: CustomThrottlerGuard, ],
}, })
], export class AppModule {}
})
export class AppModule {}

View File

@ -1,4 +1,10 @@
import { Injectable, UnauthorizedException, ConflictException, Logger, Inject } 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';
@ -22,7 +28,7 @@ export class AuthService {
@Inject(USER_REPOSITORY) @Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository, // ✅ Correct injection private readonly userRepository: UserRepository, // ✅ Correct injection
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService
) {} ) {}
/** /**
@ -33,7 +39,7 @@ export class AuthService {
password: string, password: string,
firstName: string, firstName: string,
lastName: string, lastName: string,
organizationId?: string, organizationId?: string
): 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}`);
@ -87,7 +93,7 @@ export class AuthService {
*/ */
async login( async login(
email: string, email: string,
password: string, password: string
): 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}`);
@ -127,7 +133,9 @@ export class AuthService {
/** /**
* Refresh access token using refresh token * Refresh access token using refresh token
*/ */
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
try { try {
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'),

View File

@ -32,7 +32,7 @@ export interface JwtPayload {
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly authService: AuthService, private readonly authService: AuthService
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),

View File

@ -1,358 +1,331 @@
import { import {
Controller, Controller,
Post, Post,
Get, Get,
Delete, Delete,
Param, Param,
Body, Body,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiConsumes, ApiConsumes,
ApiBody, ApiBody,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { RolesGuard } from '../../guards/roles.guard'; import { RolesGuard } from '../../guards/roles.guard';
import { Roles } from '../../decorators/roles.decorator'; import { Roles } from '../../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter'; import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
import { import {
CsvRateUploadDto, CsvRateUploadDto,
CsvRateUploadResponseDto, CsvRateUploadResponseDto,
CsvRateConfigDto, CsvRateConfigDto,
CsvFileValidationDto, CsvFileValidationDto,
} from '../../dto/csv-rate-upload.dto'; } from '../../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../../mappers/csv-rate.mapper'; import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
/** /**
* CSV Rates Admin Controller * CSV Rates Admin Controller
* *
* ADMIN-ONLY endpoints for managing CSV rate files * ADMIN-ONLY endpoints for managing CSV rate files
* Protected by JWT + Roles guard * Protected by JWT + Roles guard
*/ */
@ApiTags('Admin - CSV Rates') @ApiTags('Admin - CSV Rates')
@Controller('admin/csv-rates') @Controller('admin/csv-rates')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints @Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints
export class CsvRatesAdminController { export class CsvRatesAdminController {
private readonly logger = new Logger(CsvRatesAdminController.name); private readonly logger = new Logger(CsvRatesAdminController.name);
constructor( constructor(
private readonly csvLoader: CsvRateLoaderAdapter, private readonly csvLoader: CsvRateLoaderAdapter,
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository, private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
private readonly csvRateMapper: CsvRateMapper, private readonly csvRateMapper: CsvRateMapper
) {} ) {}
/** /**
* Upload CSV rate file (ADMIN only) * Upload CSV rate file (ADMIN only)
*/ */
@Post('upload') @Post('upload')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
storage: diskStorage({ storage: diskStorage({
destination: './apps/backend/src/infrastructure/storage/csv-storage/rates', destination: './apps/backend/src/infrastructure/storage/csv-storage/rates',
filename: (req, file, cb) => { filename: (req, file, cb) => {
// Generate filename: company-name.csv // Generate filename: company-name.csv
const companyName = req.body.companyName || 'unknown'; const companyName = req.body.companyName || 'unknown';
const sanitized = companyName const sanitized = companyName
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, ''); .replace(/[^a-z0-9-]/g, '');
const filename = `${sanitized}.csv`; const filename = `${sanitized}.csv`;
cb(null, filename); cb(null, filename);
}, },
}), }),
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Only allow CSV files // Only allow CSV files
if (extname(file.originalname).toLowerCase() !== '.csv') { if (extname(file.originalname).toLowerCase() !== '.csv') {
return cb(new BadRequestException('Only CSV files are allowed'), false); return cb(new BadRequestException('Only CSV files are allowed'), false);
} }
cb(null, true); cb(null, true);
}, },
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB max fileSize: 10 * 1024 * 1024, // 10MB max
}, },
}), })
) )
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ @ApiOperation({
summary: 'Upload CSV rate file (ADMIN only)', summary: 'Upload CSV rate file (ADMIN only)',
description: description:
'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.', 'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.',
}) })
@ApiBody({ @ApiBody({
schema: { schema: {
type: 'object', type: 'object',
required: ['companyName', 'file'], required: ['companyName', 'file'],
properties: { properties: {
companyName: { companyName: {
type: 'string', type: 'string',
description: 'Carrier company name', description: 'Carrier company name',
example: 'SSC Consolidation', example: 'SSC Consolidation',
}, },
file: { file: {
type: 'string', type: 'string',
format: 'binary', format: 'binary',
description: 'CSV file to upload', description: 'CSV file to upload',
}, },
}, },
}, },
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.CREATED, status: HttpStatus.CREATED,
description: 'CSV file uploaded and validated successfully', description: 'CSV file uploaded and validated successfully',
type: CsvRateUploadResponseDto, type: CsvRateUploadResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: 400, status: 400,
description: 'Invalid file format or validation failed', description: 'Invalid file format or validation failed',
}) })
@ApiResponse({ @ApiResponse({
status: 403, status: 403,
description: 'Forbidden - Admin role required', description: 'Forbidden - Admin role required',
}) })
async uploadCsv( async uploadCsv(
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body() dto: CsvRateUploadDto, @Body() dto: CsvRateUploadDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<CsvRateUploadResponseDto> { ): Promise<CsvRateUploadResponseDto> {
this.logger.log( this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`);
`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`,
); if (!file) {
throw new BadRequestException('File is required');
if (!file) { }
throw new BadRequestException('File is required');
} try {
// Validate CSV file structure
try { const validation = await this.csvLoader.validateCsvFile(file.filename);
// Validate CSV file structure
const validation = await this.csvLoader.validateCsvFile(file.filename); if (!validation.valid) {
this.logger.error(
if (!validation.valid) { `CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`
this.logger.error( );
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`, throw new BadRequestException({
); message: 'CSV validation failed',
throw new BadRequestException({ errors: validation.errors,
message: 'CSV validation failed', });
errors: validation.errors, }
});
} // Load rates to verify parsing
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
// Load rates to verify parsing const ratesCount = rates.length;
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
const ratesCount = rates.length; this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
this.logger.log( // Check if config exists for this company
`Successfully parsed ${ratesCount} rates from ${file.filename}`, const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
);
if (existingConfig) {
// Check if config exists for this company // Update existing configuration
const existingConfig = await this.csvConfigRepository.findByCompanyName( await this.csvConfigRepository.update(existingConfig.id, {
dto.companyName, csvFilePath: file.filename,
); uploadedAt: new Date(),
uploadedBy: user.id,
if (existingConfig) { rowCount: ratesCount,
// Update existing configuration lastValidatedAt: new Date(),
await this.csvConfigRepository.update(existingConfig.id, { metadata: {
csvFilePath: file.filename, ...existingConfig.metadata,
uploadedAt: new Date(), lastUpload: {
uploadedBy: user.id, timestamp: new Date().toISOString(),
rowCount: ratesCount, by: user.email,
lastValidatedAt: new Date(), ratesCount,
metadata: { },
...existingConfig.metadata, },
lastUpload: { });
timestamp: new Date().toISOString(),
by: user.email, this.logger.log(`Updated CSV config for company: ${dto.companyName}`);
ratesCount, } else {
}, // Create new configuration
}, await this.csvConfigRepository.create({
}); companyName: dto.companyName,
csvFilePath: file.filename,
this.logger.log( type: 'CSV_ONLY',
`Updated CSV config for company: ${dto.companyName}`, hasApi: false,
); apiConnector: null,
} else { isActive: true,
// Create new configuration uploadedAt: new Date(),
await this.csvConfigRepository.create({ uploadedBy: user.id,
companyName: dto.companyName, rowCount: ratesCount,
csvFilePath: file.filename, lastValidatedAt: new Date(),
type: 'CSV_ONLY', metadata: {
hasApi: false, uploadedBy: user.email,
apiConnector: null, description: `${dto.companyName} shipping rates`,
isActive: true, },
uploadedAt: new Date(), });
uploadedBy: user.id,
rowCount: ratesCount, this.logger.log(`Created new CSV config for company: ${dto.companyName}`);
lastValidatedAt: new Date(), }
metadata: {
uploadedBy: user.email, return {
description: `${dto.companyName} shipping rates`, success: true,
}, ratesCount,
}); csvFilePath: file.filename,
companyName: dto.companyName,
this.logger.log( uploadedAt: new Date(),
`Created new CSV config for company: ${dto.companyName}`, };
); } catch (error: any) {
} this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack);
throw error;
return { }
success: true, }
ratesCount,
csvFilePath: file.filename, /**
companyName: dto.companyName, * Get all CSV rate configurations
uploadedAt: new Date(), */
}; @Get('config')
} catch (error: any) { @HttpCode(HttpStatus.OK)
this.logger.error( @ApiOperation({
`CSV upload failed: ${error?.message || 'Unknown error'}`, summary: 'Get all CSV rate configurations (ADMIN only)',
error?.stack, description: 'Returns list of all CSV rate configurations with upload details.',
); })
throw error; @ApiResponse({
} status: HttpStatus.OK,
} description: 'List of CSV rate configurations',
type: [CsvRateConfigDto],
/** })
* Get all CSV rate configurations async getAllConfigs(): Promise<CsvRateConfigDto[]> {
*/ this.logger.log('Fetching all CSV rate configs (admin)');
@Get('config')
@HttpCode(HttpStatus.OK) const configs = await this.csvConfigRepository.findAll();
@ApiOperation({ return this.csvRateMapper.mapConfigEntitiesToDtos(configs);
summary: 'Get all CSV rate configurations (ADMIN only)', }
description: 'Returns list of all CSV rate configurations with upload details.',
}) /**
@ApiResponse({ * Get configuration for specific company
status: HttpStatus.OK, */
description: 'List of CSV rate configurations', @Get('config/:companyName')
type: [CsvRateConfigDto], @HttpCode(HttpStatus.OK)
}) @ApiOperation({
async getAllConfigs(): Promise<CsvRateConfigDto[]> { summary: 'Get CSV configuration for specific company (ADMIN only)',
this.logger.log('Fetching all CSV rate configs (admin)'); description: 'Returns CSV rate configuration details for a specific carrier.',
})
const configs = await this.csvConfigRepository.findAll(); @ApiResponse({
return this.csvRateMapper.mapConfigEntitiesToDtos(configs); status: HttpStatus.OK,
} description: 'CSV rate configuration',
type: CsvRateConfigDto,
/** })
* Get configuration for specific company @ApiResponse({
*/ status: 404,
@Get('config/:companyName') description: 'Company configuration not found',
@HttpCode(HttpStatus.OK) })
@ApiOperation({ async getConfigByCompany(@Param('companyName') companyName: string): Promise<CsvRateConfigDto> {
summary: 'Get CSV configuration for specific company (ADMIN only)', this.logger.log(`Fetching CSV config for company: ${companyName}`);
description: 'Returns CSV rate configuration details for a specific carrier.',
}) const config = await this.csvConfigRepository.findByCompanyName(companyName);
@ApiResponse({
status: HttpStatus.OK, if (!config) {
description: 'CSV rate configuration', throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
type: CsvRateConfigDto, }
})
@ApiResponse({ return this.csvRateMapper.mapConfigEntityToDto(config);
status: 404, }
description: 'Company configuration not found',
}) /**
async getConfigByCompany( * Validate CSV file
@Param('companyName') companyName: string, */
): Promise<CsvRateConfigDto> { @Post('validate/:companyName')
this.logger.log(`Fetching CSV config for company: ${companyName}`); @HttpCode(HttpStatus.OK)
@ApiOperation({
const config = await this.csvConfigRepository.findByCompanyName(companyName); summary: 'Validate CSV file for company (ADMIN only)',
description:
if (!config) { 'Validates the CSV file structure and data for a specific company without uploading.',
throw new BadRequestException( })
`No CSV configuration found for company: ${companyName}`, @ApiResponse({
); status: HttpStatus.OK,
} description: 'Validation result',
type: CsvFileValidationDto,
return this.csvRateMapper.mapConfigEntityToDto(config); })
} async validateCsvFile(@Param('companyName') companyName: string): Promise<CsvFileValidationDto> {
this.logger.log(`Validating CSV file for company: ${companyName}`);
/**
* Validate CSV file const config = await this.csvConfigRepository.findByCompanyName(companyName);
*/
@Post('validate/:companyName') if (!config) {
@HttpCode(HttpStatus.OK) throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
@ApiOperation({ }
summary: 'Validate CSV file for company (ADMIN only)',
description: const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
'Validates the CSV file structure and data for a specific company without uploading.',
}) // Update validation timestamp
@ApiResponse({ if (result.valid && result.rowCount) {
status: HttpStatus.OK, await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result);
description: 'Validation result', }
type: CsvFileValidationDto,
}) return result;
async validateCsvFile( }
@Param('companyName') companyName: string,
): Promise<CsvFileValidationDto> { /**
this.logger.log(`Validating CSV file for company: ${companyName}`); * Delete CSV rate configuration
*/
const config = await this.csvConfigRepository.findByCompanyName(companyName); @Delete('config/:companyName')
@HttpCode(HttpStatus.NO_CONTENT)
if (!config) { @ApiOperation({
throw new BadRequestException( summary: 'Delete CSV rate configuration (ADMIN only)',
`No CSV configuration found for company: ${companyName}`, description:
); 'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.',
} })
@ApiResponse({
const result = await this.csvLoader.validateCsvFile(config.csvFilePath); status: HttpStatus.NO_CONTENT,
description: 'Configuration deleted successfully',
// Update validation timestamp })
if (result.valid && result.rowCount) { @ApiResponse({
await this.csvConfigRepository.updateValidationInfo( status: 404,
companyName, description: 'Company configuration not found',
result.rowCount, })
result, async deleteConfig(
); @Param('companyName') companyName: string,
} @CurrentUser() user: UserPayload
): Promise<void> {
return result; this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`);
}
await this.csvConfigRepository.delete(companyName);
/**
* Delete CSV rate configuration this.logger.log(`Deleted CSV config for company: ${companyName}`);
*/ }
@Delete('config/:companyName') }
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete CSV rate configuration (ADMIN only)',
description:
'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.',
})
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: 'Configuration deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Company configuration not found',
})
async deleteConfig(
@Param('companyName') companyName: string,
@CurrentUser() user: UserPayload,
): Promise<void> {
this.logger.warn(
`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`,
);
await this.csvConfigRepository.delete(companyName);
this.logger.log(`Deleted CSV config for company: ${companyName}`);
}
}

View File

@ -66,8 +66,18 @@ export class AuditController {
@ApiOperation({ summary: 'Get audit logs with filters' }) @ApiOperation({ summary: 'Get audit logs with filters' })
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' }) @ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' }) @ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true }) @ApiQuery({
@ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true }) name: 'action',
required: false,
description: 'Filter by action (comma-separated)',
isArray: true,
})
@ApiQuery({
name: 'status',
required: false,
description: 'Filter by status (comma-separated)',
isArray: true,
})
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' }) @ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' }) @ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' }) @ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
@ -84,7 +94,7 @@ export class AuditController {
@Query('startDate') startDate?: string, @Query('startDate') startDate?: string,
@Query('endDate') endDate?: string, @Query('endDate') endDate?: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> { ): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
page = page || 1; page = page || 1;
limit = limit || 50; limit = limit || 50;
@ -104,7 +114,7 @@ export class AuditController {
const { logs, total } = await this.auditService.getAuditLogs(filters); const { logs, total } = await this.auditService.getAuditLogs(filters);
return { return {
logs: logs.map((log) => this.mapToDto(log)), logs: logs.map(log => this.mapToDto(log)),
total, total,
page, page,
pageSize: limit, pageSize: limit,
@ -121,7 +131,7 @@ export class AuditController {
@ApiResponse({ status: 404, description: 'Audit log not found' }) @ApiResponse({ status: 404, description: 'Audit log not found' })
async getAuditLogById( async getAuditLogById(
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<AuditLogResponseDto> { ): Promise<AuditLogResponseDto> {
const log = await this.auditService.getAuditLogs({ const log = await this.auditService.getAuditLogs({
organizationId: user.organizationId, organizationId: user.organizationId,
@ -145,14 +155,14 @@ export class AuditController {
async getResourceAuditTrail( async getResourceAuditTrail(
@Param('type') resourceType: string, @Param('type') resourceType: string,
@Param('id') resourceId: string, @Param('id') resourceId: string,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<AuditLogResponseDto[]> { ): Promise<AuditLogResponseDto[]> {
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId); const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
// Filter by organization for security // Filter by organization for security
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId); const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
return filteredLogs.map((log) => this.mapToDto(log)); return filteredLogs.map(log => this.mapToDto(log));
} }
/** /**
@ -165,11 +175,11 @@ export class AuditController {
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' }) @ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
async getOrganizationActivity( async getOrganizationActivity(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
): Promise<AuditLogResponseDto[]> { ): Promise<AuditLogResponseDto[]> {
limit = limit || 50; limit = limit || 50;
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit); const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
return logs.map((log) => this.mapToDto(log)); return logs.map(log => this.mapToDto(log));
} }
/** /**
@ -183,15 +193,15 @@ export class AuditController {
async getUserActivity( async getUserActivity(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Param('userId') userId: string, @Param('userId') userId: string,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
): Promise<AuditLogResponseDto[]> { ): Promise<AuditLogResponseDto[]> {
limit = limit || 50; limit = limit || 50;
const logs = await this.auditService.getUserActivity(userId, limit); const logs = await this.auditService.getUserActivity(userId, limit);
// Filter by organization for security // Filter by organization for security
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId); const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
return filteredLogs.map((log) => this.mapToDto(log)); return filteredLogs.map(log => this.mapToDto(log));
} }
/** /**

View File

@ -1,227 +1,204 @@
import { import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get } from '@nestjs/common';
Controller, import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
Post, import { AuthService } from '../auth/auth.service';
Body, import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
HttpCode, import { Public } from '../decorators/public.decorator';
HttpStatus, import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
UseGuards, import { JwtAuthGuard } from '../guards/jwt-auth.guard';
Get,
} from '@nestjs/common'; /**
import { * Authentication Controller
ApiTags, *
ApiOperation, * Handles user authentication endpoints:
ApiResponse, * - POST /auth/register - User registration
ApiBearerAuth, * - POST /auth/login - User login
} from '@nestjs/swagger'; * - POST /auth/refresh - Token refresh
import { AuthService } from '../auth/auth.service'; * - POST /auth/logout - User logout (placeholder)
import { * - GET /auth/me - Get current user profile
LoginDto, */
RegisterDto, @ApiTags('Authentication')
AuthResponseDto, @Controller('auth')
RefreshTokenDto, export class AuthController {
} from '../dto/auth-login.dto'; constructor(private readonly authService: AuthService) {}
import { Public } from '../decorators/public.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; /**
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; * Register a new user
*
/** * Creates a new user account and returns access + refresh tokens.
* Authentication Controller *
* * @param dto - Registration data (email, password, firstName, lastName, organizationId)
* Handles user authentication endpoints: * @returns Access token, refresh token, and user info
* - POST /auth/register - User registration */
* - POST /auth/login - User login @Public()
* - POST /auth/refresh - Token refresh @Post('register')
* - POST /auth/logout - User logout (placeholder) @HttpCode(HttpStatus.CREATED)
* - GET /auth/me - Get current user profile @ApiOperation({
*/ summary: 'Register new user',
@ApiTags('Authentication') description: 'Create a new user account with email and password. Returns JWT tokens.',
@Controller('auth') })
export class AuthController { @ApiResponse({
constructor(private readonly authService: AuthService) {} status: 201,
description: 'User successfully registered',
/** type: AuthResponseDto,
* Register a new user })
* @ApiResponse({
* Creates a new user account and returns access + refresh tokens. status: 409,
* description: 'User with this email already exists',
* @param dto - Registration data (email, password, firstName, lastName, organizationId) })
* @returns Access token, refresh token, and user info @ApiResponse({
*/ status: 400,
@Public() description: 'Validation error (invalid email, weak password, etc.)',
@Post('register') })
@HttpCode(HttpStatus.CREATED) async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
@ApiOperation({ const result = await this.authService.register(
summary: 'Register new user', dto.email,
description: dto.password,
'Create a new user account with email and password. Returns JWT tokens.', dto.firstName,
}) dto.lastName,
@ApiResponse({ dto.organizationId
status: 201, );
description: 'User successfully registered',
type: AuthResponseDto, return {
}) accessToken: result.accessToken,
@ApiResponse({ refreshToken: result.refreshToken,
status: 409, user: result.user,
description: 'User with this email already exists', };
}) }
@ApiResponse({
status: 400, /**
description: 'Validation error (invalid email, weak password, etc.)', * Login with email and password
}) *
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> { * Authenticates a user and returns access + refresh tokens.
const result = await this.authService.register( *
dto.email, * @param dto - Login credentials (email, password)
dto.password, * @returns Access token, refresh token, and user info
dto.firstName, */
dto.lastName, @Public()
dto.organizationId, @Post('login')
); @HttpCode(HttpStatus.OK)
@ApiOperation({
return { summary: 'User login',
accessToken: result.accessToken, description: 'Authenticate with email and password. Returns JWT tokens.',
refreshToken: result.refreshToken, })
user: result.user, @ApiResponse({
}; status: 200,
} description: 'Login successful',
type: AuthResponseDto,
/** })
* Login with email and password @ApiResponse({
* status: 401,
* Authenticates a user and returns access + refresh tokens. description: 'Invalid credentials or inactive account',
* })
* @param dto - Login credentials (email, password) async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
* @returns Access token, refresh token, and user info const result = await this.authService.login(dto.email, dto.password);
*/
@Public() return {
@Post('login') accessToken: result.accessToken,
@HttpCode(HttpStatus.OK) refreshToken: result.refreshToken,
@ApiOperation({ user: result.user,
summary: 'User login', };
description: 'Authenticate with email and password. Returns JWT tokens.', }
})
@ApiResponse({ /**
status: 200, * Refresh access token
description: 'Login successful', *
type: AuthResponseDto, * Obtains a new access token using a valid refresh token.
}) *
@ApiResponse({ * @param dto - Refresh token
status: 401, * @returns New access token
description: 'Invalid credentials or inactive account', */
}) @Public()
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> { @Post('refresh')
const result = await this.authService.login(dto.email, dto.password); @HttpCode(HttpStatus.OK)
@ApiOperation({
return { summary: 'Refresh access token',
accessToken: result.accessToken, description:
refreshToken: result.refreshToken, 'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
user: result.user, })
}; @ApiResponse({
} status: 200,
description: 'Token refreshed successfully',
/** schema: {
* Refresh access token properties: {
* accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
* Obtains a new access token using a valid refresh token. },
* },
* @param dto - Refresh token })
* @returns New access token @ApiResponse({
*/ status: 401,
@Public() description: 'Invalid or expired refresh token',
@Post('refresh') })
@HttpCode(HttpStatus.OK) async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
@ApiOperation({ const result = await this.authService.refreshAccessToken(dto.refreshToken);
summary: 'Refresh access token',
description: return { accessToken: result.accessToken };
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).', }
})
@ApiResponse({ /**
status: 200, * Logout (placeholder)
description: 'Token refreshed successfully', *
schema: { * Currently a no-op endpoint. With JWT, logout is typically handled client-side
properties: { * by removing tokens. For more security, implement token blacklisting with Redis.
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' }, *
}, * @returns Success message
}, */
}) @UseGuards(JwtAuthGuard)
@ApiResponse({ @Post('logout')
status: 401, @HttpCode(HttpStatus.OK)
description: 'Invalid or expired refresh token', @ApiBearerAuth()
}) @ApiOperation({
async refresh( summary: 'Logout',
@Body() dto: RefreshTokenDto, description: 'Logout the current user. Currently handled client-side by removing tokens.',
): Promise<{ accessToken: string }> { })
const result = @ApiResponse({
await this.authService.refreshAccessToken(dto.refreshToken); status: 200,
description: 'Logout successful',
return { accessToken: result.accessToken }; schema: {
} properties: {
message: { type: 'string', example: 'Logout successful' },
/** },
* Logout (placeholder) },
* })
* Currently a no-op endpoint. With JWT, logout is typically handled client-side async logout(): Promise<{ message: string }> {
* by removing tokens. For more security, implement token blacklisting with Redis. // TODO: Implement token blacklisting with Redis for more security
* // For now, logout is handled client-side by removing tokens
* @returns Success message return { message: 'Logout successful' };
*/ }
@UseGuards(JwtAuthGuard)
@Post('logout') /**
@HttpCode(HttpStatus.OK) * Get current user profile
@ApiBearerAuth() *
@ApiOperation({ * Returns the profile of the currently authenticated user.
summary: 'Logout', *
description: * @param user - Current user from JWT token
'Logout the current user. Currently handled client-side by removing tokens.', * @returns User profile
}) */
@ApiResponse({ @UseGuards(JwtAuthGuard)
status: 200, @Get('me')
description: 'Logout successful', @ApiBearerAuth()
schema: { @ApiOperation({
properties: { summary: 'Get current user profile',
message: { type: 'string', example: 'Logout successful' }, description: 'Returns the profile of the authenticated user.',
}, })
}, @ApiResponse({
}) status: 200,
async logout(): Promise<{ message: string }> { description: 'User profile retrieved successfully',
// TODO: Implement token blacklisting with Redis for more security schema: {
// For now, logout is handled client-side by removing tokens properties: {
return { message: 'Logout successful' }; id: { type: 'string', format: 'uuid' },
} email: { type: 'string', format: 'email' },
firstName: { type: 'string' },
/** lastName: { type: 'string' },
* Get current user profile role: { type: 'string', enum: ['admin', 'manager', 'user', 'viewer'] },
* organizationId: { type: 'string', format: 'uuid' },
* Returns the profile of the currently authenticated user. },
* },
* @param user - Current user from JWT token })
* @returns User profile @ApiResponse({
*/ status: 401,
@UseGuards(JwtAuthGuard) description: 'Unauthorized - invalid or missing token',
@Get('me') })
@ApiBearerAuth() async getProfile(@CurrentUser() user: UserPayload) {
@ApiOperation({ return user;
summary: 'Get current user profile', }
description: 'Returns the profile of the authenticated user.', }
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
schema: {
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
firstName: { type: 'string' },
lastName: { type: 'string' },
role: { type: 'string', enum: ['admin', 'manager', 'user', 'viewer'] },
organizationId: { type: 'string', format: 'uuid' },
},
},
})
@ApiResponse({
status: 401,
description: 'Unauthorized - invalid or missing token',
})
async getProfile(@CurrentUser() user: UserPayload) {
return user;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -41,17 +41,14 @@ export class GDPRController {
status: 200, status: 200,
description: 'Data export successful', description: 'Data export successful',
}) })
async exportData( async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
@CurrentUser() user: UserPayload,
@Res() res: Response,
): Promise<void> {
const exportData = await this.gdprService.exportUserData(user.id); const exportData = await this.gdprService.exportUserData(user.id);
// Set headers for file download // Set headers for file download
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.setHeader( res.setHeader(
'Content-Disposition', 'Content-Disposition',
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`, `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
); );
res.json(exportData); res.json(exportData);
@ -69,10 +66,7 @@ export class GDPRController {
status: 200, status: 200,
description: 'CSV export successful', description: 'CSV export successful',
}) })
async exportDataCSV( async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
@CurrentUser() user: UserPayload,
@Res() res: Response,
): Promise<void> {
const exportData = await this.gdprService.exportUserData(user.id); const exportData = await this.gdprService.exportUserData(user.id);
// Convert to CSV (simplified version) // Convert to CSV (simplified version)
@ -87,7 +81,7 @@ export class GDPRController {
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader( res.setHeader(
'Content-Disposition', 'Content-Disposition',
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`, `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
); );
res.send(csv); res.send(csv);
@ -108,7 +102,7 @@ export class GDPRController {
}) })
async deleteAccount( async deleteAccount(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: { reason?: string; confirmEmail: string }, @Body() body: { reason?: string; confirmEmail: string }
): Promise<void> { ): Promise<void> {
// Verify email confirmation (security measure) // Verify email confirmation (security measure)
if (body.confirmEmail !== user.email) { if (body.confirmEmail !== user.email) {
@ -133,7 +127,7 @@ export class GDPRController {
}) })
async recordConsent( async recordConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: Omit<ConsentData, 'userId'>, @Body() body: Omit<ConsentData, 'userId'>
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
await this.gdprService.recordConsent({ await this.gdprService.recordConsent({
...body, ...body,
@ -158,7 +152,7 @@ export class GDPRController {
}) })
async withdrawConsent( async withdrawConsent(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Body() body: { consentType: 'marketing' | 'analytics' }, @Body() body: { consentType: 'marketing' | 'analytics' }
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
await this.gdprService.withdrawConsent(user.id, body.consentType); await this.gdprService.withdrawConsent(user.id, body.consentType);
@ -177,9 +171,7 @@ export class GDPRController {
status: 200, status: 200,
description: 'Consent status retrieved', description: 'Consent status retrieved',
}) })
async getConsentStatus( async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
@CurrentUser() user: UserPayload,
): Promise<any> {
return this.gdprService.getConsentStatus(user.id); return this.gdprService.getConsentStatus(user.id);
} }
} }

View File

@ -1,2 +1,2 @@
export * from './rates.controller'; export * from './rates.controller';
export * from './bookings.controller'; export * from './bookings.controller';

View File

@ -17,13 +17,7 @@ import {
DefaultValuePipe, DefaultValuePipe,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
@ -62,7 +56,7 @@ export class NotificationsController {
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Query('read') read?: string, @Query('read') read?: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number
): Promise<{ ): Promise<{
notifications: NotificationResponseDto[]; notifications: NotificationResponseDto[];
total: number; total: number;
@ -82,7 +76,7 @@ export class NotificationsController {
const { notifications, total } = await this.notificationService.getNotifications(filters); const { notifications, total } = await this.notificationService.getNotifications(filters);
return { return {
notifications: notifications.map((n) => this.mapToDto(n)), notifications: notifications.map(n => this.mapToDto(n)),
total, total,
page, page,
pageSize: limit, pageSize: limit,
@ -95,14 +89,18 @@ export class NotificationsController {
@Get('unread') @Get('unread')
@ApiOperation({ summary: 'Get unread notifications' }) @ApiOperation({ summary: 'Get unread notifications' })
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' }) @ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
@ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' }) @ApiQuery({
name: 'limit',
required: false,
description: 'Number of notifications (default: 50)',
})
async getUnreadNotifications( async getUnreadNotifications(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
): Promise<NotificationResponseDto[]> { ): Promise<NotificationResponseDto[]> {
limit = limit || 50; limit = limit || 50;
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit); const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
return notifications.map((n) => this.mapToDto(n)); return notifications.map(n => this.mapToDto(n));
} }
/** /**
@ -125,7 +123,7 @@ export class NotificationsController {
@ApiResponse({ status: 404, description: 'Notification not found' }) @ApiResponse({ status: 404, description: 'Notification not found' })
async getNotificationById( async getNotificationById(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Param('id') id: string, @Param('id') id: string
): Promise<NotificationResponseDto> { ): Promise<NotificationResponseDto> {
const notification = await this.notificationService.getNotificationById(id); const notification = await this.notificationService.getNotificationById(id);
@ -145,7 +143,7 @@ export class NotificationsController {
@ApiResponse({ status: 404, description: 'Notification not found' }) @ApiResponse({ status: 404, description: 'Notification not found' })
async markAsRead( async markAsRead(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Param('id') id: string, @Param('id') id: string
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const notification = await this.notificationService.getNotificationById(id); const notification = await this.notificationService.getNotificationById(id);
@ -177,7 +175,7 @@ export class NotificationsController {
@ApiResponse({ status: 404, description: 'Notification not found' }) @ApiResponse({ status: 404, description: 'Notification not found' })
async deleteNotification( async deleteNotification(
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload,
@Param('id') id: string, @Param('id') id: string
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const notification = await this.notificationService.getNotificationById(id); const notification = await this.notificationService.getNotificationById(id);

View File

@ -1,367 +1,357 @@
import { import {
Controller, Controller,
Get, Get,
Post, Post,
Patch, Patch,
Param, Param,
Body, Body,
Query, Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
NotFoundException, NotFoundException,
ParseUUIDPipe, ParseUUIDPipe,
ParseIntPipe, ParseIntPipe,
DefaultValuePipe, DefaultValuePipe,
UseGuards, UseGuards,
ForbiddenException, ForbiddenException,
Inject, Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBadRequestResponse, ApiBadRequestResponse,
ApiNotFoundResponse, ApiNotFoundResponse,
ApiQuery, ApiQuery,
ApiParam, ApiParam,
ApiBearerAuth, ApiBearerAuth,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { import {
CreateOrganizationDto, CreateOrganizationDto,
UpdateOrganizationDto, UpdateOrganizationDto,
OrganizationResponseDto, OrganizationResponseDto,
OrganizationListResponseDto, OrganizationListResponseDto,
} from '../dto/organization.dto'; } from '../dto/organization.dto';
import { OrganizationMapper } from '../mappers/organization.mapper'; import { OrganizationMapper } from '../mappers/organization.mapper';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository'; import {
import { Organization, OrganizationType } from '../../domain/entities/organization.entity'; OrganizationRepository,
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; ORGANIZATION_REPOSITORY,
import { RolesGuard } from '../guards/roles.guard'; } from '../../domain/ports/out/organization.repository';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
import { Roles } from '../decorators/roles.decorator'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { v4 as uuidv4 } from 'uuid'; import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
/** import { Roles } from '../decorators/roles.decorator';
* Organizations Controller import { v4 as uuidv4 } from 'uuid';
*
* Manages organization CRUD operations: /**
* - Create organization (admin only) * Organizations Controller
* - Get organization details *
* - Update organization (admin/manager) * Manages organization CRUD operations:
* - List organizations * - Create organization (admin only)
*/ * - Get organization details
@ApiTags('Organizations') * - Update organization (admin/manager)
@Controller('organizations') * - List organizations
@UseGuards(JwtAuthGuard, RolesGuard) */
@ApiBearerAuth() @ApiTags('Organizations')
export class OrganizationsController { @Controller('organizations')
private readonly logger = new Logger(OrganizationsController.name); @UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
constructor( export class OrganizationsController {
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, private readonly logger = new Logger(OrganizationsController.name);
) {}
constructor(
/** @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
* Create a new organization ) {}
*
* Admin-only endpoint to create a new organization. /**
*/ * Create a new organization
@Post() *
@HttpCode(HttpStatus.CREATED) * Admin-only endpoint to create a new organization.
@Roles('admin') */
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @Post()
@ApiOperation({ @HttpCode(HttpStatus.CREATED)
summary: 'Create new organization', @Roles('admin')
description: @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.', @ApiOperation({
}) summary: 'Create new organization',
@ApiResponse({ description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
status: HttpStatus.CREATED, })
description: 'Organization created successfully', @ApiResponse({
type: OrganizationResponseDto, status: HttpStatus.CREATED,
}) description: 'Organization created successfully',
@ApiResponse({ type: OrganizationResponseDto,
status: 401, })
description: 'Unauthorized - missing or invalid token', @ApiResponse({
}) status: 401,
@ApiResponse({ description: 'Unauthorized - missing or invalid token',
status: 403, })
description: 'Forbidden - requires admin role', @ApiResponse({
}) status: 403,
@ApiBadRequestResponse({ description: 'Forbidden - requires admin role',
description: 'Invalid request parameters', })
}) @ApiBadRequestResponse({
async createOrganization( description: 'Invalid request parameters',
@Body() dto: CreateOrganizationDto, })
@CurrentUser() user: UserPayload, async createOrganization(
): Promise<OrganizationResponseDto> { @Body() dto: CreateOrganizationDto,
this.logger.log( @CurrentUser() user: UserPayload
`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`, ): Promise<OrganizationResponseDto> {
); this.logger.log(`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`);
try { try {
// Check for duplicate name // Check for duplicate name
const existingByName = await this.organizationRepository.findByName(dto.name); const existingByName = await this.organizationRepository.findByName(dto.name);
if (existingByName) { if (existingByName) {
throw new ForbiddenException( throw new ForbiddenException(`Organization with name "${dto.name}" already exists`);
`Organization with name "${dto.name}" already exists`, }
);
} // Check for duplicate SCAC if provided
if (dto.scac) {
// Check for duplicate SCAC if provided const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
if (dto.scac) { if (existingBySCAC) {
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac); throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`);
if (existingBySCAC) { }
throw new ForbiddenException( }
`Organization with SCAC "${dto.scac}" already exists`,
); // Create organization entity
} const organization = Organization.create({
} id: uuidv4(),
name: dto.name,
// Create organization entity type: dto.type,
const organization = Organization.create({ scac: dto.scac,
id: uuidv4(), address: OrganizationMapper.mapDtoToAddress(dto.address),
name: dto.name, logoUrl: dto.logoUrl,
type: dto.type, documents: [],
scac: dto.scac, isActive: true,
address: OrganizationMapper.mapDtoToAddress(dto.address), });
logoUrl: dto.logoUrl,
documents: [], // Save to database
isActive: true, const savedOrg = await this.organizationRepository.save(organization);
});
this.logger.log(`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`);
// Save to database
const savedOrg = await this.organizationRepository.save(organization); return OrganizationMapper.toDto(savedOrg);
} catch (error: any) {
this.logger.log( this.logger.error(
`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`, `Organization creation failed: ${error?.message || 'Unknown error'}`,
); error?.stack
);
return OrganizationMapper.toDto(savedOrg); throw error;
} catch (error: any) { }
this.logger.error( }
`Organization creation failed: ${error?.message || 'Unknown error'}`,
error?.stack, /**
); * Get organization by ID
throw error; *
} * Retrieve details of a specific organization.
} * Users can only view their own organization unless they are admins.
*/
/** @Get(':id')
* Get organization by ID @ApiOperation({
* summary: 'Get organization by ID',
* Retrieve details of a specific organization. description:
* Users can only view their own organization unless they are admins. 'Retrieve organization details. Users can view their own organization, admins can view any.',
*/ })
@Get(':id') @ApiParam({
@ApiOperation({ name: 'id',
summary: 'Get organization by ID', description: 'Organization ID (UUID)',
description: example: '550e8400-e29b-41d4-a716-446655440000',
'Retrieve organization details. Users can view their own organization, admins can view any.', })
}) @ApiResponse({
@ApiParam({ status: HttpStatus.OK,
name: 'id', description: 'Organization details retrieved successfully',
description: 'Organization ID (UUID)', type: OrganizationResponseDto,
example: '550e8400-e29b-41d4-a716-446655440000', })
}) @ApiResponse({
@ApiResponse({ status: 401,
status: HttpStatus.OK, description: 'Unauthorized - missing or invalid token',
description: 'Organization details retrieved successfully', })
type: OrganizationResponseDto, @ApiNotFoundResponse({
}) description: 'Organization not found',
@ApiResponse({ })
status: 401, async getOrganization(
description: 'Unauthorized - missing or invalid token', @Param('id', ParseUUIDPipe) id: string,
}) @CurrentUser() user: UserPayload
@ApiNotFoundResponse({ ): Promise<OrganizationResponseDto> {
description: 'Organization not found', this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
})
async getOrganization( const organization = await this.organizationRepository.findById(id);
@Param('id', ParseUUIDPipe) id: string, if (!organization) {
@CurrentUser() user: UserPayload, throw new NotFoundException(`Organization ${id} not found`);
): Promise<OrganizationResponseDto> { }
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
// Authorization: Users can only view their own organization (unless admin)
const organization = await this.organizationRepository.findById(id); if (user.role !== 'admin' && organization.id !== user.organizationId) {
if (!organization) { throw new ForbiddenException('You can only view your own organization');
throw new NotFoundException(`Organization ${id} not found`); }
}
return OrganizationMapper.toDto(organization);
// Authorization: Users can only view their own organization (unless admin) }
if (user.role !== 'admin' && organization.id !== user.organizationId) {
throw new ForbiddenException('You can only view your own organization'); /**
} * Update organization
*
return OrganizationMapper.toDto(organization); * Update organization details (name, address, logo, status).
} * Requires admin or manager role.
*/
/** @Patch(':id')
* Update organization @Roles('admin', 'manager')
* @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
* Update organization details (name, address, logo, status). @ApiOperation({
* Requires admin or manager role. summary: 'Update organization',
*/ description:
@Patch(':id') 'Update organization details (name, address, logo, status). Requires admin or manager role.',
@Roles('admin', 'manager') })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @ApiParam({
@ApiOperation({ name: 'id',
summary: 'Update organization', description: 'Organization ID (UUID)',
description: })
'Update organization details (name, address, logo, status). Requires admin or manager role.', @ApiResponse({
}) status: HttpStatus.OK,
@ApiParam({ description: 'Organization updated successfully',
name: 'id', type: OrganizationResponseDto,
description: 'Organization ID (UUID)', })
}) @ApiResponse({
@ApiResponse({ status: 401,
status: HttpStatus.OK, description: 'Unauthorized - missing or invalid token',
description: 'Organization updated successfully', })
type: OrganizationResponseDto, @ApiResponse({
}) status: 403,
@ApiResponse({ description: 'Forbidden - requires admin or manager role',
status: 401, })
description: 'Unauthorized - missing or invalid token', @ApiNotFoundResponse({
}) description: 'Organization not found',
@ApiResponse({ })
status: 403, async updateOrganization(
description: 'Forbidden - requires admin or manager role', @Param('id', ParseUUIDPipe) id: string,
}) @Body() dto: UpdateOrganizationDto,
@ApiNotFoundResponse({ @CurrentUser() user: UserPayload
description: 'Organization not found', ): Promise<OrganizationResponseDto> {
}) this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
async updateOrganization(
@Param('id', ParseUUIDPipe) id: string, const organization = await this.organizationRepository.findById(id);
@Body() dto: UpdateOrganizationDto, if (!organization) {
@CurrentUser() user: UserPayload, throw new NotFoundException(`Organization ${id} not found`);
): Promise<OrganizationResponseDto> { }
this.logger.log(
`[User: ${user.email}] Updating organization: ${id}`, // Authorization: Managers can only update their own organization
); if (user.role === 'manager' && organization.id !== user.organizationId) {
throw new ForbiddenException('You can only update your own organization');
const organization = await this.organizationRepository.findById(id); }
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`); // Update fields
} if (dto.name) {
organization.updateName(dto.name);
// Authorization: Managers can only update their own organization }
if (user.role === 'manager' && organization.id !== user.organizationId) {
throw new ForbiddenException('You can only update your own organization'); if (dto.address) {
} organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address));
}
// Update fields
if (dto.name) { if (dto.logoUrl !== undefined) {
organization.updateName(dto.name); organization.updateLogoUrl(dto.logoUrl);
} }
if (dto.address) { if (dto.isActive !== undefined) {
organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address)); if (dto.isActive) {
} organization.activate();
} else {
if (dto.logoUrl !== undefined) { organization.deactivate();
organization.updateLogoUrl(dto.logoUrl); }
} }
if (dto.isActive !== undefined) { // Save updated organization
if (dto.isActive) { const updatedOrg = await this.organizationRepository.save(organization);
organization.activate();
} else { this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
organization.deactivate();
} return OrganizationMapper.toDto(updatedOrg);
} }
// Save updated organization /**
const updatedOrg = await this.organizationRepository.save(organization); * List organizations
*
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); * Retrieve a paginated list of organizations.
* Admins can see all, others see only their own.
return OrganizationMapper.toDto(updatedOrg); */
} @Get()
@ApiOperation({
/** summary: 'List organizations',
* List organizations description:
* 'Retrieve a paginated list of organizations. Admins see all, others see only their own.',
* Retrieve a paginated list of organizations. })
* Admins can see all, others see only their own. @ApiQuery({
*/ name: 'page',
@Get() required: false,
@ApiOperation({ description: 'Page number (1-based)',
summary: 'List organizations', example: 1,
description: })
'Retrieve a paginated list of organizations. Admins see all, others see only their own.', @ApiQuery({
}) name: 'pageSize',
@ApiQuery({ required: false,
name: 'page', description: 'Number of items per page',
required: false, example: 20,
description: 'Page number (1-based)', })
example: 1, @ApiQuery({
}) name: 'type',
@ApiQuery({ required: false,
name: 'pageSize', description: 'Filter by organization type',
required: false, enum: OrganizationType,
description: 'Number of items per page', })
example: 20, @ApiResponse({
}) status: HttpStatus.OK,
@ApiQuery({ description: 'Organizations list retrieved successfully',
name: 'type', type: OrganizationListResponseDto,
required: false, })
description: 'Filter by organization type', @ApiResponse({
enum: OrganizationType, status: 401,
}) description: 'Unauthorized - missing or invalid token',
@ApiResponse({ })
status: HttpStatus.OK, async listOrganizations(
description: 'Organizations list retrieved successfully', @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
type: OrganizationListResponseDto, @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
}) @Query('type') type: OrganizationType | undefined,
@ApiResponse({ @CurrentUser() user: UserPayload
status: 401, ): Promise<OrganizationListResponseDto> {
description: 'Unauthorized - missing or invalid token', this.logger.log(
}) `[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`
async listOrganizations( );
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, // Fetch organizations
@Query('type') type: OrganizationType | undefined, let organizations: Organization[];
@CurrentUser() user: UserPayload,
): Promise<OrganizationListResponseDto> { if (user.role === 'admin') {
this.logger.log( // Admins can see all organizations
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`, organizations = await this.organizationRepository.findAll();
); } else {
// Others see only their own organization
// Fetch organizations const userOrg = await this.organizationRepository.findById(user.organizationId);
let organizations: Organization[]; organizations = userOrg ? [userOrg] : [];
}
if (user.role === 'admin') {
// Admins can see all organizations // Filter by type if provided
organizations = await this.organizationRepository.findAll(); const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations;
} else {
// Others see only their own organization // Paginate
const userOrg = await this.organizationRepository.findById(user.organizationId); const startIndex = (page - 1) * pageSize;
organizations = userOrg ? [userOrg] : []; const endIndex = startIndex + pageSize;
} const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex);
// Filter by type if provided // Convert to DTOs
const filteredOrgs = type const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs);
? organizations.filter(org => org.type === type)
: organizations; const totalPages = Math.ceil(filteredOrgs.length / pageSize);
// Paginate return {
const startIndex = (page - 1) * pageSize; organizations: orgDtos,
const endIndex = startIndex + pageSize; total: filteredOrgs.length,
const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex); page,
pageSize,
// Convert to DTOs totalPages,
const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs); };
}
const totalPages = Math.ceil(filteredOrgs.length / pageSize); }
return {
organizations: orgDtos,
total: filteredOrgs.length,
page,
pageSize,
totalPages,
};
}
}

View File

@ -1,267 +1,262 @@
import { import {
Controller, Controller,
Post, Post,
Get, Get,
Body, Body,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBadRequestResponse, ApiBadRequestResponse,
ApiInternalServerErrorResponse, ApiInternalServerErrorResponse,
ApiBearerAuth, ApiBearerAuth,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto'; import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
import { RateQuoteMapper } from '../mappers'; import { RateQuoteMapper } from '../mappers';
import { RateSearchService } from '../../domain/services/rate-search.service'; import { RateSearchService } from '../../domain/services/rate-search.service';
import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service'; import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto'; import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper'; import { CsvRateMapper } from '../mappers/csv-rate.mapper';
@ApiTags('Rates') @ApiTags('Rates')
@Controller('rates') @Controller('rates')
@ApiBearerAuth() @ApiBearerAuth()
export class RatesController { export class RatesController {
private readonly logger = new Logger(RatesController.name); private readonly logger = new Logger(RatesController.name);
constructor( constructor(
private readonly rateSearchService: RateSearchService, private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService, private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper, private readonly csvRateMapper: CsvRateMapper
) {} ) {}
@Post('search') @Post('search')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({ @ApiOperation({
summary: 'Search shipping rates', summary: 'Search shipping rates',
description: description:
'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.', 'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: 'Rate search completed successfully', description: 'Rate search completed successfully',
type: RateSearchResponseDto, type: RateSearchResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: 401, status: 401,
description: 'Unauthorized - missing or invalid token', description: 'Unauthorized - missing or invalid token',
}) })
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'Invalid request parameters', description: 'Invalid request parameters',
schema: { schema: {
example: { example: {
statusCode: 400, statusCode: 400,
message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'], message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'],
error: 'Bad Request', error: 'Bad Request',
}, },
}, },
}) })
@ApiInternalServerErrorResponse({ @ApiInternalServerErrorResponse({
description: 'Internal server error', description: 'Internal server error',
}) })
async searchRates( async searchRates(
@Body() dto: RateSearchRequestDto, @Body() dto: RateSearchRequestDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<RateSearchResponseDto> { ): Promise<RateSearchResponseDto> {
const startTime = Date.now(); const startTime = Date.now();
this.logger.log( this.logger.log(
`[User: ${user.email}] Searching rates: ${dto.origin}${dto.destination}, ${dto.containerType}`, `[User: ${user.email}] Searching rates: ${dto.origin}${dto.destination}, ${dto.containerType}`
); );
try { try {
// Convert DTO to domain input // Convert DTO to domain input
const searchInput = { const searchInput = {
origin: dto.origin, origin: dto.origin,
destination: dto.destination, destination: dto.destination,
containerType: dto.containerType, containerType: dto.containerType,
mode: dto.mode, mode: dto.mode,
departureDate: new Date(dto.departureDate), departureDate: new Date(dto.departureDate),
quantity: dto.quantity, quantity: dto.quantity,
weight: dto.weight, weight: dto.weight,
volume: dto.volume, volume: dto.volume,
isHazmat: dto.isHazmat, isHazmat: dto.isHazmat,
imoClass: dto.imoClass, imoClass: dto.imoClass,
}; };
// Execute search // Execute search
const result = await this.rateSearchService.execute(searchInput); const result = await this.rateSearchService.execute(searchInput);
// Convert domain entities to DTOs // Convert domain entities to DTOs
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes); const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
const responseTimeMs = Date.now() - startTime; const responseTimeMs = Date.now() - startTime;
this.logger.log( this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`);
`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`,
); return {
quotes: quoteDtos,
return { count: quoteDtos.length,
quotes: quoteDtos, origin: dto.origin,
count: quoteDtos.length, destination: dto.destination,
origin: dto.origin, departureDate: dto.departureDate,
destination: dto.destination, containerType: dto.containerType,
departureDate: dto.departureDate, mode: dto.mode,
containerType: dto.containerType, fromCache: false, // TODO: Implement cache detection
mode: dto.mode, responseTimeMs,
fromCache: false, // TODO: Implement cache detection };
responseTimeMs, } catch (error: any) {
}; this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack);
} catch (error: any) { throw error;
this.logger.error( }
`Rate search failed: ${error?.message || 'Unknown error'}`, }
error?.stack,
); /**
throw error; * Search CSV-based rates with advanced filters
} */
} @Post('search-csv')
@UseGuards(JwtAuthGuard)
/** @HttpCode(HttpStatus.OK)
* Search CSV-based rates with advanced filters @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
*/ @ApiOperation({
@Post('search-csv') summary: 'Search CSV-based rates with advanced filters',
@UseGuards(JwtAuthGuard) description:
@HttpCode(HttpStatus.OK) 'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.',
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) })
@ApiOperation({ @ApiResponse({
summary: 'Search CSV-based rates with advanced filters', status: HttpStatus.OK,
description: description: 'CSV rate search completed successfully',
'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.', type: CsvRateSearchResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: 401,
description: 'CSV rate search completed successfully', description: 'Unauthorized - missing or invalid token',
type: CsvRateSearchResponseDto, })
}) @ApiBadRequestResponse({
@ApiResponse({ description: 'Invalid request parameters',
status: 401, })
description: 'Unauthorized - missing or invalid token', async searchCsvRates(
}) @Body() dto: CsvRateSearchDto,
@ApiBadRequestResponse({ @CurrentUser() user: UserPayload
description: 'Invalid request parameters', ): Promise<CsvRateSearchResponseDto> {
}) const startTime = Date.now();
async searchCsvRates( this.logger.log(
@Body() dto: CsvRateSearchDto, `[User: ${user.email}] Searching CSV rates: ${dto.origin}${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
@CurrentUser() user: UserPayload, );
): Promise<CsvRateSearchResponseDto> {
const startTime = Date.now(); try {
this.logger.log( // Map DTO to domain input
`[User: ${user.email}] Searching CSV rates: ${dto.origin}${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`, const searchInput = {
); origin: dto.origin,
destination: dto.destination,
try { volumeCBM: dto.volumeCBM,
// Map DTO to domain input weightKG: dto.weightKG,
const searchInput = { palletCount: dto.palletCount ?? 0,
origin: dto.origin, containerType: dto.containerType,
destination: dto.destination, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
volumeCBM: dto.volumeCBM, };
weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0, // Execute CSV rate search
containerType: dto.containerType, const result = await this.csvRateSearchService.execute(searchInput);
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
}; // Map domain output to response DTO
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
// Execute CSV rate search
const result = await this.csvRateSearchService.execute(searchInput); const responseTimeMs = Date.now() - startTime;
this.logger.log(
// Map domain output to response DTO `CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result); );
const responseTimeMs = Date.now() - startTime; return response;
this.logger.log( } catch (error: any) {
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`, this.logger.error(
); `CSV rate search failed: ${error?.message || 'Unknown error'}`,
error?.stack
return response; );
} catch (error: any) { throw error;
this.logger.error( }
`CSV rate search failed: ${error?.message || 'Unknown error'}`, }
error?.stack,
); /**
throw error; * Get available companies
} */
} @Get('companies')
@UseGuards(JwtAuthGuard)
/** @HttpCode(HttpStatus.OK)
* Get available companies @ApiOperation({
*/ summary: 'Get available carrier companies',
@Get('companies') description: 'Returns list of all available carrier companies in the CSV rate system.',
@UseGuards(JwtAuthGuard) })
@HttpCode(HttpStatus.OK) @ApiResponse({
@ApiOperation({ status: HttpStatus.OK,
summary: 'Get available carrier companies', description: 'List of available companies',
description: 'Returns list of all available carrier companies in the CSV rate system.', type: AvailableCompaniesDto,
}) })
@ApiResponse({ async getCompanies(): Promise<AvailableCompaniesDto> {
status: HttpStatus.OK, this.logger.log('Fetching available companies');
description: 'List of available companies',
type: AvailableCompaniesDto, try {
}) const companies = await this.csvRateSearchService.getAvailableCompanies();
async getCompanies(): Promise<AvailableCompaniesDto> {
this.logger.log('Fetching available companies'); return {
companies,
try { total: companies.length,
const companies = await this.csvRateSearchService.getAvailableCompanies(); };
} catch (error: any) {
return { this.logger.error(
companies, `Failed to fetch companies: ${error?.message || 'Unknown error'}`,
total: companies.length, error?.stack
}; );
} catch (error: any) { throw error;
this.logger.error( }
`Failed to fetch companies: ${error?.message || 'Unknown error'}`, }
error?.stack,
); /**
throw error; * Get filter options
} */
} @Get('filters/options')
@UseGuards(JwtAuthGuard)
/** @HttpCode(HttpStatus.OK)
* Get filter options @ApiOperation({
*/ summary: 'Get available filter options',
@Get('filters/options') description:
@UseGuards(JwtAuthGuard) 'Returns available options for all filters (companies, container types, currencies).',
@HttpCode(HttpStatus.OK) })
@ApiOperation({ @ApiResponse({
summary: 'Get available filter options', status: HttpStatus.OK,
description: description: 'Available filter options',
'Returns available options for all filters (companies, container types, currencies).', type: FilterOptionsDto,
}) })
@ApiResponse({ async getFilterOptions(): Promise<FilterOptionsDto> {
status: HttpStatus.OK, this.logger.log('Fetching filter options');
description: 'Available filter options',
type: FilterOptionsDto, try {
}) const [companies, containerTypes] = await Promise.all([
async getFilterOptions(): Promise<FilterOptionsDto> { this.csvRateSearchService.getAvailableCompanies(),
this.logger.log('Fetching filter options'); this.csvRateSearchService.getAvailableContainerTypes(),
]);
try {
const [companies, containerTypes] = await Promise.all([ return {
this.csvRateSearchService.getAvailableCompanies(), companies,
this.csvRateSearchService.getAvailableContainerTypes(), containerTypes,
]); currencies: ['USD', 'EUR'],
};
return { } catch (error: any) {
companies, this.logger.error(
containerTypes, `Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
currencies: ['USD', 'EUR'], error?.stack
}; );
} catch (error: any) { throw error;
this.logger.error( }
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`, }
error?.stack, }
);
throw error;
}
}
}

View File

@ -16,13 +16,12 @@ import {
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { import {
ApiTags, WebhookService,
ApiOperation, CreateWebhookInput,
ApiResponse, UpdateWebhookInput,
ApiBearerAuth, } from '../services/webhook.service';
} from '@nestjs/swagger';
import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
@ -74,7 +73,7 @@ export class WebhooksController {
@ApiResponse({ status: 201, description: 'Webhook created successfully' }) @ApiResponse({ status: 201, description: 'Webhook created successfully' })
async createWebhook( async createWebhook(
@Body() dto: CreateWebhookDto, @Body() dto: CreateWebhookDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<WebhookResponseDto> { ): Promise<WebhookResponseDto> {
const input: CreateWebhookInput = { const input: CreateWebhookInput = {
organizationId: user.organizationId, organizationId: user.organizationId,
@ -96,10 +95,8 @@ export class WebhooksController {
@ApiOperation({ summary: 'Get all webhooks for organization' }) @ApiOperation({ summary: 'Get all webhooks for organization' })
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' }) @ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> { async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
const webhooks = await this.webhookService.getWebhooksByOrganization( const webhooks = await this.webhookService.getWebhooksByOrganization(user.organizationId);
user.organizationId, return webhooks.map(w => this.mapToDto(w));
);
return webhooks.map((w) => this.mapToDto(w));
} }
/** /**
@ -112,7 +109,7 @@ export class WebhooksController {
@ApiResponse({ status: 404, description: 'Webhook not found' }) @ApiResponse({ status: 404, description: 'Webhook not found' })
async getWebhookById( async getWebhookById(
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<WebhookResponseDto> { ): Promise<WebhookResponseDto> {
const webhook = await this.webhookService.getWebhookById(id); const webhook = await this.webhookService.getWebhookById(id);
@ -139,7 +136,7 @@ export class WebhooksController {
async updateWebhook( async updateWebhook(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateWebhookDto, @Body() dto: UpdateWebhookDto,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<WebhookResponseDto> { ): Promise<WebhookResponseDto> {
const webhook = await this.webhookService.getWebhookById(id); const webhook = await this.webhookService.getWebhookById(id);
@ -166,7 +163,7 @@ export class WebhooksController {
@ApiResponse({ status: 404, description: 'Webhook not found' }) @ApiResponse({ status: 404, description: 'Webhook not found' })
async activateWebhook( async activateWebhook(
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const webhook = await this.webhookService.getWebhookById(id); const webhook = await this.webhookService.getWebhookById(id);
@ -193,7 +190,7 @@ export class WebhooksController {
@ApiResponse({ status: 404, description: 'Webhook not found' }) @ApiResponse({ status: 404, description: 'Webhook not found' })
async deactivateWebhook( async deactivateWebhook(
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const webhook = await this.webhookService.getWebhookById(id); const webhook = await this.webhookService.getWebhookById(id);
@ -220,7 +217,7 @@ export class WebhooksController {
@ApiResponse({ status: 404, description: 'Webhook not found' }) @ApiResponse({ status: 404, description: 'Webhook not found' })
async deleteWebhook( async deleteWebhook(
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: UserPayload, @CurrentUser() user: UserPayload
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const webhook = await this.webhookService.getWebhookById(id); const webhook = await this.webhookService.getWebhookById(id);

View File

@ -1,42 +1,42 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/** /**
* User payload interface extracted from JWT * User payload interface extracted from JWT
*/ */
export interface UserPayload { export interface UserPayload {
id: string; id: string;
email: string; email: string;
role: string; role: string;
organizationId: string; organizationId: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
} }
/** /**
* CurrentUser Decorator * CurrentUser Decorator
* *
* Extracts the authenticated user from the request object. * Extracts the authenticated user from the request object.
* Must be used with JwtAuthGuard. * Must be used with JwtAuthGuard.
* *
* Usage: * Usage:
* @UseGuards(JwtAuthGuard) * @UseGuards(JwtAuthGuard)
* @Get('me') * @Get('me')
* getProfile(@CurrentUser() user: UserPayload) { * getProfile(@CurrentUser() user: UserPayload) {
* return user; * return user;
* } * }
* *
* You can also extract a specific property: * You can also extract a specific property:
* @Get('my-bookings') * @Get('my-bookings')
* getMyBookings(@CurrentUser('id') userId: string) { * getMyBookings(@CurrentUser('id') userId: string) {
* return this.bookingService.findByUserId(userId); * return this.bookingService.findByUserId(userId);
* } * }
*/ */
export const CurrentUser = createParamDecorator( export const CurrentUser = createParamDecorator(
(data: keyof UserPayload | undefined, ctx: ExecutionContext) => { (data: keyof UserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest(); const request = ctx.switchToHttp().getRequest();
const user = request.user; const user = request.user;
// If a specific property is requested, return only that property // If a specific property is requested, return only that property
return data ? user?.[data] : user; return data ? user?.[data] : user;
}, }
); );

View File

@ -1,3 +1,3 @@
export * from './current-user.decorator'; export * from './current-user.decorator';
export * from './public.decorator'; export * from './public.decorator';
export * from './roles.decorator'; export * from './roles.decorator';

View File

@ -1,16 +1,16 @@
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
/** /**
* Public Decorator * Public Decorator
* *
* Marks a route as public, bypassing JWT authentication. * Marks a route as public, bypassing JWT authentication.
* Use this for routes that should be accessible without a token. * Use this for routes that should be accessible without a token.
* *
* Usage: * Usage:
* @Public() * @Public()
* @Post('login') * @Post('login')
* login(@Body() dto: LoginDto) { * login(@Body() dto: LoginDto) {
* return this.authService.login(dto.email, dto.password); * return this.authService.login(dto.email, dto.password);
* } * }
*/ */
export const Public = () => SetMetadata('isPublic', true); export const Public = () => SetMetadata('isPublic', true);

View File

@ -1,23 +1,23 @@
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
/** /**
* Roles Decorator * Roles Decorator
* *
* Specifies which roles are allowed to access a route. * Specifies which roles are allowed to access a route.
* Must be used with both JwtAuthGuard and RolesGuard. * Must be used with both JwtAuthGuard and RolesGuard.
* *
* Available roles: * Available roles:
* - 'admin': Full system access * - 'admin': Full system access
* - 'manager': Manage bookings and users within organization * - 'manager': Manage bookings and users within organization
* - 'user': Create and view bookings * - 'user': Create and view bookings
* - 'viewer': Read-only access * - 'viewer': Read-only access
* *
* Usage: * Usage:
* @UseGuards(JwtAuthGuard, RolesGuard) * @UseGuards(JwtAuthGuard, RolesGuard)
* @Roles('admin', 'manager') * @Roles('admin', 'manager')
* @Delete('bookings/:id') * @Delete('bookings/:id')
* deleteBooking(@Param('id') id: string) { * deleteBooking(@Param('id') id: string) {
* return this.bookingService.delete(id); * return this.bookingService.delete(id);
* } * }
*/ */
export const Roles = (...roles: string[]) => SetMetadata('roles', roles); export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

View File

@ -1,106 +1,106 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class LoginDto { export class LoginDto {
@ApiProperty({ @ApiProperty({
example: 'john.doe@acme.com', example: 'john.doe@acme.com',
description: 'Email address', description: 'Email address',
}) })
@IsEmail({}, { message: 'Invalid email format' }) @IsEmail({}, { message: 'Invalid email format' })
email: string; email: string;
@ApiProperty({ @ApiProperty({
example: 'SecurePassword123!', example: 'SecurePassword123!',
description: 'Password (minimum 12 characters)', description: 'Password (minimum 12 characters)',
minLength: 12, minLength: 12,
}) })
@IsString() @IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' }) @MinLength(12, { message: 'Password must be at least 12 characters' })
password: string; password: string;
} }
export class RegisterDto { export class RegisterDto {
@ApiProperty({ @ApiProperty({
example: 'john.doe@acme.com', example: 'john.doe@acme.com',
description: 'Email address', description: 'Email address',
}) })
@IsEmail({}, { message: 'Invalid email format' }) @IsEmail({}, { message: 'Invalid email format' })
email: string; email: string;
@ApiProperty({ @ApiProperty({
example: 'SecurePassword123!', example: 'SecurePassword123!',
description: 'Password (minimum 12 characters)', description: 'Password (minimum 12 characters)',
minLength: 12, minLength: 12,
}) })
@IsString() @IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' }) @MinLength(12, { message: 'Password must be at least 12 characters' })
password: string; password: string;
@ApiProperty({ @ApiProperty({
example: 'John', example: 'John',
description: 'First name', description: 'First name',
}) })
@IsString() @IsString()
@MinLength(2, { message: 'First name must be at least 2 characters' }) @MinLength(2, { message: 'First name must be at least 2 characters' })
firstName: string; firstName: string;
@ApiProperty({ @ApiProperty({
example: 'Doe', example: 'Doe',
description: 'Last name', description: 'Last name',
}) })
@IsString() @IsString()
@MinLength(2, { message: 'Last name must be at least 2 characters' }) @MinLength(2, { message: 'Last name must be at least 2 characters' })
lastName: string; lastName: string;
@ApiProperty({ @ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID (optional, will create default organization if not provided)', description: 'Organization ID (optional, will create default organization if not provided)',
required: false, required: false,
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
organizationId?: string; organizationId?: string;
} }
export class AuthResponseDto { export class AuthResponseDto {
@ApiProperty({ @ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'JWT access token (valid 15 minutes)', description: 'JWT access token (valid 15 minutes)',
}) })
accessToken: string; accessToken: string;
@ApiProperty({ @ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'JWT refresh token (valid 7 days)', description: 'JWT refresh token (valid 7 days)',
}) })
refreshToken: string; refreshToken: string;
@ApiProperty({ @ApiProperty({
example: { example: {
id: '550e8400-e29b-41d4-a716-446655440000', id: '550e8400-e29b-41d4-a716-446655440000',
email: 'john.doe@acme.com', email: 'john.doe@acme.com',
firstName: 'John', firstName: 'John',
lastName: 'Doe', lastName: 'Doe',
role: 'user', role: 'user',
organizationId: '550e8400-e29b-41d4-a716-446655440001', organizationId: '550e8400-e29b-41d4-a716-446655440001',
}, },
description: 'User information', description: 'User information',
}) })
user: { user: {
id: string; id: string;
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: string; role: string;
organizationId: string; organizationId: string;
}; };
} }
export class RefreshTokenDto { export class RefreshTokenDto {
@ApiProperty({ @ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Refresh token', description: 'Refresh token',
}) })
@IsString() @IsString()
refreshToken: string; refreshToken: string;
} }

View File

@ -1,184 +1,184 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PortDto, PricingDto } from './rate-search-response.dto'; import { PortDto, PricingDto } from './rate-search-response.dto';
export class BookingAddressDto { export class BookingAddressDto {
@ApiProperty({ example: '123 Main Street' }) @ApiProperty({ example: '123 Main Street' })
street: string; street: string;
@ApiProperty({ example: 'Rotterdam' }) @ApiProperty({ example: 'Rotterdam' })
city: string; city: string;
@ApiProperty({ example: '3000 AB' }) @ApiProperty({ example: '3000 AB' })
postalCode: string; postalCode: string;
@ApiProperty({ example: 'NL' }) @ApiProperty({ example: 'NL' })
country: string; country: string;
} }
export class BookingPartyDto { export class BookingPartyDto {
@ApiProperty({ example: 'Acme Corporation' }) @ApiProperty({ example: 'Acme Corporation' })
name: string; name: string;
@ApiProperty({ type: BookingAddressDto }) @ApiProperty({ type: BookingAddressDto })
address: BookingAddressDto; address: BookingAddressDto;
@ApiProperty({ example: 'John Doe' }) @ApiProperty({ example: 'John Doe' })
contactName: string; contactName: string;
@ApiProperty({ example: 'john.doe@acme.com' }) @ApiProperty({ example: 'john.doe@acme.com' })
contactEmail: string; contactEmail: string;
@ApiProperty({ example: '+31612345678' }) @ApiProperty({ example: '+31612345678' })
contactPhone: string; contactPhone: string;
} }
export class BookingContainerDto { export class BookingContainerDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string; id: string;
@ApiProperty({ example: '40HC' }) @ApiProperty({ example: '40HC' })
type: string; type: string;
@ApiPropertyOptional({ example: 'ABCU1234567' }) @ApiPropertyOptional({ example: 'ABCU1234567' })
containerNumber?: string; containerNumber?: string;
@ApiPropertyOptional({ example: 22000 }) @ApiPropertyOptional({ example: 22000 })
vgm?: number; vgm?: number;
@ApiPropertyOptional({ example: -18 }) @ApiPropertyOptional({ example: -18 })
temperature?: number; temperature?: number;
@ApiPropertyOptional({ example: 'SEAL123456' }) @ApiPropertyOptional({ example: 'SEAL123456' })
sealNumber?: string; sealNumber?: string;
} }
export class BookingRateQuoteDto { export class BookingRateQuoteDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string; id: string;
@ApiProperty({ example: 'Maersk Line' }) @ApiProperty({ example: 'Maersk Line' })
carrierName: string; carrierName: string;
@ApiProperty({ example: 'MAERSK' }) @ApiProperty({ example: 'MAERSK' })
carrierCode: string; carrierCode: string;
@ApiProperty({ type: PortDto }) @ApiProperty({ type: PortDto })
origin: PortDto; origin: PortDto;
@ApiProperty({ type: PortDto }) @ApiProperty({ type: PortDto })
destination: PortDto; destination: PortDto;
@ApiProperty({ type: PricingDto }) @ApiProperty({ type: PricingDto })
pricing: PricingDto; pricing: PricingDto;
@ApiProperty({ example: '40HC' }) @ApiProperty({ example: '40HC' })
containerType: string; containerType: string;
@ApiProperty({ example: 'FCL' }) @ApiProperty({ example: 'FCL' })
mode: string; mode: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' }) @ApiProperty({ example: '2025-02-15T10:00:00Z' })
etd: string; etd: string;
@ApiProperty({ example: '2025-03-17T14:00:00Z' }) @ApiProperty({ example: '2025-03-17T14:00:00Z' })
eta: string; eta: string;
@ApiProperty({ example: 30 }) @ApiProperty({ example: 30 })
transitDays: number; transitDays: number;
} }
export class BookingResponseDto { export class BookingResponseDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string; id: string;
@ApiProperty({ example: 'WCM-2025-ABC123', description: 'Unique booking number' }) @ApiProperty({ example: 'WCM-2025-ABC123', description: 'Unique booking number' })
bookingNumber: string; bookingNumber: string;
@ApiProperty({ @ApiProperty({
example: 'draft', example: 'draft',
enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'], enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'],
}) })
status: string; status: string;
@ApiProperty({ type: BookingPartyDto }) @ApiProperty({ type: BookingPartyDto })
shipper: BookingPartyDto; shipper: BookingPartyDto;
@ApiProperty({ type: BookingPartyDto }) @ApiProperty({ type: BookingPartyDto })
consignee: BookingPartyDto; consignee: BookingPartyDto;
@ApiProperty({ example: 'Electronics and consumer goods' }) @ApiProperty({ example: 'Electronics and consumer goods' })
cargoDescription: string; cargoDescription: string;
@ApiProperty({ type: [BookingContainerDto] }) @ApiProperty({ type: [BookingContainerDto] })
containers: BookingContainerDto[]; containers: BookingContainerDto[];
@ApiPropertyOptional({ example: 'Please handle with care. Delivery before 5 PM.' }) @ApiPropertyOptional({ example: 'Please handle with care. Delivery before 5 PM.' })
specialInstructions?: string; specialInstructions?: string;
@ApiProperty({ type: BookingRateQuoteDto, description: 'Associated rate quote details' }) @ApiProperty({ type: BookingRateQuoteDto, description: 'Associated rate quote details' })
rateQuote: BookingRateQuoteDto; rateQuote: BookingRateQuoteDto;
@ApiProperty({ example: '2025-02-15T10:00:00Z' }) @ApiProperty({ example: '2025-02-15T10:00:00Z' })
createdAt: string; createdAt: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' }) @ApiProperty({ example: '2025-02-15T10:00:00Z' })
updatedAt: string; updatedAt: string;
} }
export class BookingListItemDto { export class BookingListItemDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string; id: string;
@ApiProperty({ example: 'WCM-2025-ABC123' }) @ApiProperty({ example: 'WCM-2025-ABC123' })
bookingNumber: string; bookingNumber: string;
@ApiProperty({ example: 'draft' }) @ApiProperty({ example: 'draft' })
status: string; status: string;
@ApiProperty({ example: 'Acme Corporation' }) @ApiProperty({ example: 'Acme Corporation' })
shipperName: string; shipperName: string;
@ApiProperty({ example: 'Shanghai Imports Ltd' }) @ApiProperty({ example: 'Shanghai Imports Ltd' })
consigneeName: string; consigneeName: string;
@ApiProperty({ example: 'NLRTM' }) @ApiProperty({ example: 'NLRTM' })
originPort: string; originPort: string;
@ApiProperty({ example: 'CNSHA' }) @ApiProperty({ example: 'CNSHA' })
destinationPort: string; destinationPort: string;
@ApiProperty({ example: 'Maersk Line' }) @ApiProperty({ example: 'Maersk Line' })
carrierName: string; carrierName: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' }) @ApiProperty({ example: '2025-02-15T10:00:00Z' })
etd: string; etd: string;
@ApiProperty({ example: '2025-03-17T14:00:00Z' }) @ApiProperty({ example: '2025-03-17T14:00:00Z' })
eta: string; eta: string;
@ApiProperty({ example: 1700.0 }) @ApiProperty({ example: 1700.0 })
totalAmount: number; totalAmount: number;
@ApiProperty({ example: 'USD' }) @ApiProperty({ example: 'USD' })
currency: string; currency: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' }) @ApiProperty({ example: '2025-02-15T10:00:00Z' })
createdAt: string; createdAt: string;
} }
export class BookingListResponseDto { export class BookingListResponseDto {
@ApiProperty({ type: [BookingListItemDto] }) @ApiProperty({ type: [BookingListItemDto] })
bookings: BookingListItemDto[]; bookings: BookingListItemDto[];
@ApiProperty({ example: 25, description: 'Total number of bookings' }) @ApiProperty({ example: 25, description: 'Total number of bookings' })
total: number; total: number;
@ApiProperty({ example: 1, description: 'Current page number' }) @ApiProperty({ example: 1, description: 'Current page number' })
page: number; page: number;
@ApiProperty({ example: 20, description: 'Items per page' }) @ApiProperty({ example: 20, description: 'Items per page' })
pageSize: number; pageSize: number;
@ApiProperty({ example: 2, description: 'Total number of pages' }) @ApiProperty({ example: 2, description: 'Total number of pages' })
totalPages: number; totalPages: number;
} }

View File

@ -1,119 +1,135 @@
import { IsString, IsUUID, IsOptional, ValidateNested, IsArray, IsEmail, Matches, MinLength } from 'class-validator'; import {
import { Type } from 'class-transformer'; IsString,
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; IsUUID,
IsOptional,
export class AddressDto { ValidateNested,
@ApiProperty({ example: '123 Main Street' }) IsArray,
@IsString() IsEmail,
@MinLength(5, { message: 'Street must be at least 5 characters' }) Matches,
street: string; MinLength,
} from 'class-validator';
@ApiProperty({ example: 'Rotterdam' }) import { Type } from 'class-transformer';
@IsString() import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@MinLength(2, { message: 'City must be at least 2 characters' })
city: string; export class AddressDto {
@ApiProperty({ example: '123 Main Street' })
@ApiProperty({ example: '3000 AB' }) @IsString()
@IsString() @MinLength(5, { message: 'Street must be at least 5 characters' })
postalCode: string; street: string;
@ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' }) @ApiProperty({ example: 'Rotterdam' })
@IsString() @IsString()
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' }) @MinLength(2, { message: 'City must be at least 2 characters' })
country: string; city: string;
}
@ApiProperty({ example: '3000 AB' })
export class PartyDto { @IsString()
@ApiProperty({ example: 'Acme Corporation' }) postalCode: string;
@IsString()
@MinLength(2, { message: 'Name must be at least 2 characters' }) @ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' })
name: string; @IsString()
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' })
@ApiProperty({ type: AddressDto }) country: string;
@ValidateNested() }
@Type(() => AddressDto)
address: AddressDto; export class PartyDto {
@ApiProperty({ example: 'Acme Corporation' })
@ApiProperty({ example: 'John Doe' }) @IsString()
@IsString() @MinLength(2, { message: 'Name must be at least 2 characters' })
@MinLength(2, { message: 'Contact name must be at least 2 characters' }) name: string;
contactName: string;
@ApiProperty({ type: AddressDto })
@ApiProperty({ example: 'john.doe@acme.com' }) @ValidateNested()
@IsEmail({}, { message: 'Contact email must be a valid email address' }) @Type(() => AddressDto)
contactEmail: string; address: AddressDto;
@ApiProperty({ example: '+31612345678' }) @ApiProperty({ example: 'John Doe' })
@IsString() @IsString()
@Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Contact phone must be a valid international phone number' }) @MinLength(2, { message: 'Contact name must be at least 2 characters' })
contactPhone: string; contactName: string;
}
@ApiProperty({ example: 'john.doe@acme.com' })
export class ContainerDto { @IsEmail({}, { message: 'Contact email must be a valid email address' })
@ApiProperty({ example: '40HC', description: 'Container type' }) contactEmail: string;
@IsString()
type: string; @ApiProperty({ example: '+31612345678' })
@IsString()
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' }) @Matches(/^\+?[1-9]\d{1,14}$/, {
@IsOptional() message: 'Contact phone must be a valid international phone number',
@IsString() })
@Matches(/^[A-Z]{4}\d{7}$/, { message: 'Container number must be 4 letters followed by 7 digits' }) contactPhone: string;
containerNumber?: string; }
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' }) export class ContainerDto {
@IsOptional() @ApiProperty({ example: '40HC', description: 'Container type' })
vgm?: number; @IsString()
type: string;
@ApiPropertyOptional({ example: -18, description: 'Temperature in Celsius (for reefer containers)' })
@IsOptional() @ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
temperature?: number; @IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' }) @Matches(/^[A-Z]{4}\d{7}$/, {
@IsOptional() message: 'Container number must be 4 letters followed by 7 digits',
@IsString() })
sealNumber?: string; containerNumber?: string;
}
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
export class CreateBookingRequestDto { @IsOptional()
@ApiProperty({ vgm?: number;
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Rate quote ID from previous search' @ApiPropertyOptional({
}) example: -18,
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' }) description: 'Temperature in Celsius (for reefer containers)',
rateQuoteId: string; })
@IsOptional()
@ApiProperty({ type: PartyDto, description: 'Shipper details' }) temperature?: number;
@ValidateNested()
@Type(() => PartyDto) @ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' })
shipper: PartyDto; @IsOptional()
@IsString()
@ApiProperty({ type: PartyDto, description: 'Consignee details' }) sealNumber?: string;
@ValidateNested() }
@Type(() => PartyDto)
consignee: PartyDto; export class CreateBookingRequestDto {
@ApiProperty({
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000',
example: 'Electronics and consumer goods', description: 'Rate quote ID from previous search',
description: 'Cargo description' })
}) @IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
@IsString() rateQuoteId: string;
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
cargoDescription: string; @ApiProperty({ type: PartyDto, description: 'Shipper details' })
@ValidateNested()
@ApiProperty({ @Type(() => PartyDto)
type: [ContainerDto], shipper: PartyDto;
description: 'Container details (can be empty for initial booking)'
}) @ApiProperty({ type: PartyDto, description: 'Consignee details' })
@IsArray() @ValidateNested()
@ValidateNested({ each: true }) @Type(() => PartyDto)
@Type(() => ContainerDto) consignee: PartyDto;
containers: ContainerDto[];
@ApiProperty({
@ApiPropertyOptional({ example: 'Electronics and consumer goods',
example: 'Please handle with care. Delivery before 5 PM.', description: 'Cargo description',
description: 'Special instructions for the carrier' })
}) @IsString()
@IsOptional() @MinLength(10, { message: 'Cargo description must be at least 10 characters' })
@IsString() cargoDescription: string;
specialInstructions?: string;
} @ApiProperty({
type: [ContainerDto],
description: 'Container details (can be empty for initial booking)',
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerDto)
containers: ContainerDto[];
@ApiPropertyOptional({
example: 'Please handle with care. Delivery before 5 PM.',
description: 'Special instructions for the carrier',
})
@IsOptional()
@IsString()
specialInstructions?: string;
}

View File

@ -1,211 +1,204 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
IsNotEmpty, import { Type } from 'class-transformer';
IsString, import { RateSearchFiltersDto } from './rate-search-filters.dto';
IsNumber,
Min, /**
IsOptional, * CSV Rate Search Request DTO
ValidateNested, *
} from 'class-validator'; * Request body for searching rates in CSV-based system
import { Type } from 'class-transformer'; * Includes basic search parameters + optional advanced filters
import { RateSearchFiltersDto } from './rate-search-filters.dto'; */
export class CsvRateSearchDto {
/** @ApiProperty({
* CSV Rate Search Request DTO description: 'Origin port code (UN/LOCODE format)',
* example: 'NLRTM',
* Request body for searching rates in CSV-based system pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
* Includes basic search parameters + optional advanced filters })
*/ @IsNotEmpty()
export class CsvRateSearchDto { @IsString()
@ApiProperty({ origin: string;
description: 'Origin port code (UN/LOCODE format)',
example: 'NLRTM', @ApiProperty({
pattern: '^[A-Z]{2}[A-Z0-9]{3}$', description: 'Destination port code (UN/LOCODE format)',
}) example: 'USNYC',
@IsNotEmpty() pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
@IsString() })
origin: string; @IsNotEmpty()
@IsString()
@ApiProperty({ destination: string;
description: 'Destination port code (UN/LOCODE format)',
example: 'USNYC', @ApiProperty({
pattern: '^[A-Z]{2}[A-Z0-9]{3}$', description: 'Volume in cubic meters (CBM)',
}) minimum: 0.01,
@IsNotEmpty() example: 25.5,
@IsString() })
destination: string; @IsNotEmpty()
@IsNumber()
@ApiProperty({ @Min(0.01)
description: 'Volume in cubic meters (CBM)', volumeCBM: number;
minimum: 0.01,
example: 25.5, @ApiProperty({
}) description: 'Weight in kilograms',
@IsNotEmpty() minimum: 1,
@IsNumber() example: 3500,
@Min(0.01) })
volumeCBM: number; @IsNotEmpty()
@IsNumber()
@ApiProperty({ @Min(1)
description: 'Weight in kilograms', weightKG: number;
minimum: 1,
example: 3500, @ApiPropertyOptional({
}) description: 'Number of pallets (0 if no pallets)',
@IsNotEmpty() minimum: 0,
@IsNumber() example: 10,
@Min(1) default: 0,
weightKG: number; })
@IsOptional()
@ApiPropertyOptional({ @IsNumber()
description: 'Number of pallets (0 if no pallets)', @Min(0)
minimum: 0, palletCount?: number;
example: 10,
default: 0, @ApiPropertyOptional({
}) description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
@IsOptional() example: 'LCL',
@IsNumber() })
@Min(0) @IsOptional()
palletCount?: number; @IsString()
containerType?: string;
@ApiPropertyOptional({
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)', @ApiPropertyOptional({
example: 'LCL', description: 'Advanced filters for narrowing results',
}) type: RateSearchFiltersDto,
@IsOptional() })
@IsString() @IsOptional()
containerType?: string; @ValidateNested()
@Type(() => RateSearchFiltersDto)
@ApiPropertyOptional({ filters?: RateSearchFiltersDto;
description: 'Advanced filters for narrowing results', }
type: RateSearchFiltersDto,
}) /**
@IsOptional() * CSV Rate Search Response DTO
@ValidateNested() *
@Type(() => RateSearchFiltersDto) * Response containing matching rates with calculated prices
filters?: RateSearchFiltersDto; */
} export class CsvRateSearchResponseDto {
@ApiProperty({
/** description: 'Array of matching rate results',
* CSV Rate Search Response DTO type: [Object], // Will be replaced with RateResultDto
* })
* Response containing matching rates with calculated prices results: CsvRateResultDto[];
*/
export class CsvRateSearchResponseDto { @ApiProperty({
@ApiProperty({ description: 'Total number of results found',
description: 'Array of matching rate results', example: 15,
type: [Object], // Will be replaced with RateResultDto })
}) totalResults: number;
results: CsvRateResultDto[];
@ApiProperty({
@ApiProperty({ description: 'CSV files that were searched',
description: 'Total number of results found', type: [String],
example: 15, example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
}) })
totalResults: number; searchedFiles: string[];
@ApiProperty({ @ApiProperty({
description: 'CSV files that were searched', description: 'Timestamp when search was executed',
type: [String], example: '2025-10-23T10:30:00Z',
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'], })
}) searchedAt: Date;
searchedFiles: string[];
@ApiProperty({
@ApiProperty({ description: 'Filters that were applied to the search',
description: 'Timestamp when search was executed', type: RateSearchFiltersDto,
example: '2025-10-23T10:30:00Z', })
}) appliedFilters: RateSearchFiltersDto;
searchedAt: Date; }
@ApiProperty({ /**
description: 'Filters that were applied to the search', * Single CSV Rate Result DTO
type: RateSearchFiltersDto, */
}) export class CsvRateResultDto {
appliedFilters: RateSearchFiltersDto; @ApiProperty({
} description: 'Company name',
example: 'SSC Consolidation',
/** })
* Single CSV Rate Result DTO companyName: string;
*/
export class CsvRateResultDto { @ApiProperty({
@ApiProperty({ description: 'Origin port code',
description: 'Company name', example: 'NLRTM',
example: 'SSC Consolidation', })
}) origin: string;
companyName: string;
@ApiProperty({
@ApiProperty({ description: 'Destination port code',
description: 'Origin port code', example: 'USNYC',
example: 'NLRTM', })
}) destination: string;
origin: string;
@ApiProperty({
@ApiProperty({ description: 'Container type',
description: 'Destination port code', example: 'LCL',
example: 'USNYC', })
}) containerType: string;
destination: string;
@ApiProperty({
@ApiProperty({ description: 'Calculated price in USD',
description: 'Container type', example: 1850.5,
example: 'LCL', })
}) priceUSD: number;
containerType: string;
@ApiProperty({
@ApiProperty({ description: 'Calculated price in EUR',
description: 'Calculated price in USD', example: 1665.45,
example: 1850.50, })
}) priceEUR: number;
priceUSD: number;
@ApiProperty({
@ApiProperty({ description: 'Primary currency of the rate',
description: 'Calculated price in EUR', enum: ['USD', 'EUR'],
example: 1665.45, example: 'USD',
}) })
priceEUR: number; primaryCurrency: string;
@ApiProperty({ @ApiProperty({
description: 'Primary currency of the rate', description: 'Whether this rate has separate surcharges',
enum: ['USD', 'EUR'], example: true,
example: 'USD', })
}) hasSurcharges: boolean;
primaryCurrency: string;
@ApiProperty({
@ApiProperty({ description: 'Details of surcharges if any',
description: 'Whether this rate has separate surcharges', example: 'BAF+CAF included',
example: true, nullable: true,
}) })
hasSurcharges: boolean; surchargeDetails: string | null;
@ApiProperty({ @ApiProperty({
description: 'Details of surcharges if any', description: 'Transit time in days',
example: 'BAF+CAF included', example: 28,
nullable: true, })
}) transitDays: number;
surchargeDetails: string | null;
@ApiProperty({
@ApiProperty({ description: 'Rate validity end date',
description: 'Transit time in days', example: '2025-12-31',
example: 28, })
}) validUntil: string;
transitDays: number;
@ApiProperty({
@ApiProperty({ description: 'Source of the rate',
description: 'Rate validity end date', enum: ['CSV', 'API'],
example: '2025-12-31', example: 'CSV',
}) })
validUntil: string; source: 'CSV' | 'API';
@ApiProperty({ @ApiProperty({
description: 'Source of the rate', description: 'Match score (0-100) indicating how well this rate matches the search',
enum: ['CSV', 'API'], minimum: 0,
example: 'CSV', maximum: 100,
}) example: 95,
source: 'CSV' | 'API'; })
matchScore: number;
@ApiProperty({ }
description: 'Match score (0-100) indicating how well this rate matches the search',
minimum: 0,
maximum: 100,
example: 95,
})
matchScore: number;
}

View File

@ -1,201 +1,201 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/** /**
* CSV Rate Upload DTO * CSV Rate Upload DTO
* *
* Request DTO for uploading CSV rate files (ADMIN only) * Request DTO for uploading CSV rate files (ADMIN only)
*/ */
export class CsvRateUploadDto { export class CsvRateUploadDto {
@ApiProperty({ @ApiProperty({
description: 'Name of the carrier company', description: 'Name of the carrier company',
example: 'SSC Consolidation', example: 'SSC Consolidation',
maxLength: 255, maxLength: 255,
}) })
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@MaxLength(255) @MaxLength(255)
companyName: string; companyName: string;
@ApiProperty({ @ApiProperty({
description: 'CSV file containing shipping rates', description: 'CSV file containing shipping rates',
type: 'string', type: 'string',
format: 'binary', format: 'binary',
}) })
file: any; // Will be handled by multer file: any; // Will be handled by multer
} }
/** /**
* CSV Rate Upload Response DTO * CSV Rate Upload Response DTO
*/ */
export class CsvRateUploadResponseDto { export class CsvRateUploadResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Upload success status', description: 'Upload success status',
example: true, example: true,
}) })
success: boolean; success: boolean;
@ApiProperty({ @ApiProperty({
description: 'Number of rate rows parsed from CSV', description: 'Number of rate rows parsed from CSV',
example: 25, example: 25,
}) })
ratesCount: number; ratesCount: number;
@ApiProperty({ @ApiProperty({
description: 'Path where CSV file was saved', description: 'Path where CSV file was saved',
example: 'ssc-consolidation.csv', example: 'ssc-consolidation.csv',
}) })
csvFilePath: string; csvFilePath: string;
@ApiProperty({ @ApiProperty({
description: 'Company name for which rates were uploaded', description: 'Company name for which rates were uploaded',
example: 'SSC Consolidation', example: 'SSC Consolidation',
}) })
companyName: string; companyName: string;
@ApiProperty({ @ApiProperty({
description: 'Upload timestamp', description: 'Upload timestamp',
example: '2025-10-23T10:30:00Z', example: '2025-10-23T10:30:00Z',
}) })
uploadedAt: Date; uploadedAt: Date;
} }
/** /**
* CSV Rate Config Response DTO * CSV Rate Config Response DTO
* *
* Configuration entry for a company's CSV rates * Configuration entry for a company's CSV rates
*/ */
export class CsvRateConfigDto { export class CsvRateConfigDto {
@ApiProperty({ @ApiProperty({
description: 'Configuration ID', description: 'Configuration ID',
example: '123e4567-e89b-12d3-a456-426614174000', example: '123e4567-e89b-12d3-a456-426614174000',
}) })
id: string; id: string;
@ApiProperty({ @ApiProperty({
description: 'Company name', description: 'Company name',
example: 'SSC Consolidation', example: 'SSC Consolidation',
}) })
companyName: string; companyName: string;
@ApiProperty({ @ApiProperty({
description: 'CSV file path', description: 'CSV file path',
example: 'ssc-consolidation.csv', example: 'ssc-consolidation.csv',
}) })
csvFilePath: string; csvFilePath: string;
@ApiProperty({ @ApiProperty({
description: 'Integration type', description: 'Integration type',
enum: ['CSV_ONLY', 'CSV_AND_API'], enum: ['CSV_ONLY', 'CSV_AND_API'],
example: 'CSV_ONLY', example: 'CSV_ONLY',
}) })
type: 'CSV_ONLY' | 'CSV_AND_API'; type: 'CSV_ONLY' | 'CSV_AND_API';
@ApiProperty({ @ApiProperty({
description: 'Whether company has API connector', description: 'Whether company has API connector',
example: false, example: false,
}) })
hasApi: boolean; hasApi: boolean;
@ApiProperty({ @ApiProperty({
description: 'API connector name if hasApi is true', description: 'API connector name if hasApi is true',
example: null, example: null,
nullable: true, nullable: true,
}) })
apiConnector: string | null; apiConnector: string | null;
@ApiProperty({ @ApiProperty({
description: 'Whether configuration is active', description: 'Whether configuration is active',
example: true, example: true,
}) })
isActive: boolean; isActive: boolean;
@ApiProperty({ @ApiProperty({
description: 'When CSV was last uploaded', description: 'When CSV was last uploaded',
example: '2025-10-23T10:30:00Z', example: '2025-10-23T10:30:00Z',
}) })
uploadedAt: Date; uploadedAt: Date;
@ApiProperty({ @ApiProperty({
description: 'Number of rate rows in CSV', description: 'Number of rate rows in CSV',
example: 25, example: 25,
nullable: true, nullable: true,
}) })
rowCount: number | null; rowCount: number | null;
@ApiProperty({ @ApiProperty({
description: 'Additional metadata', description: 'Additional metadata',
example: { description: 'LCL rates for Europe to US', coverage: 'Global' }, example: { description: 'LCL rates for Europe to US', coverage: 'Global' },
nullable: true, nullable: true,
}) })
metadata: Record<string, any> | null; metadata: Record<string, any> | null;
} }
/** /**
* CSV File Validation Result DTO * CSV File Validation Result DTO
*/ */
export class CsvFileValidationDto { export class CsvFileValidationDto {
@ApiProperty({ @ApiProperty({
description: 'Whether CSV file is valid', description: 'Whether CSV file is valid',
example: true, example: true,
}) })
valid: boolean; valid: boolean;
@ApiProperty({ @ApiProperty({
description: 'Validation errors if any', description: 'Validation errors if any',
type: [String], type: [String],
example: [], example: [],
}) })
errors: string[]; errors: string[];
@ApiProperty({ @ApiProperty({
description: 'Number of rows in CSV file', description: 'Number of rows in CSV file',
example: 25, example: 25,
required: false, required: false,
}) })
rowCount?: number; rowCount?: number;
} }
/** /**
* Available Companies Response DTO * Available Companies Response DTO
*/ */
export class AvailableCompaniesDto { export class AvailableCompaniesDto {
@ApiProperty({ @ApiProperty({
description: 'List of available company names', description: 'List of available company names',
type: [String], type: [String],
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'], example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
}) })
companies: string[]; companies: string[];
@ApiProperty({ @ApiProperty({
description: 'Total number of companies', description: 'Total number of companies',
example: 4, example: 4,
}) })
total: number; total: number;
} }
/** /**
* Filter Options Response DTO * Filter Options Response DTO
*/ */
export class FilterOptionsDto { export class FilterOptionsDto {
@ApiProperty({ @ApiProperty({
description: 'Available company names', description: 'Available company names',
type: [String], type: [String],
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'], example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
}) })
companies: string[]; companies: string[];
@ApiProperty({ @ApiProperty({
description: 'Available container types', description: 'Available container types',
type: [String], type: [String],
example: ['LCL', '20DRY', '40HC', '40DRY'], example: ['LCL', '20DRY', '40HC', '40DRY'],
}) })
containerTypes: string[]; containerTypes: string[];
@ApiProperty({ @ApiProperty({
description: 'Supported currencies', description: 'Supported currencies',
type: [String], type: [String],
example: ['USD', 'EUR'], example: ['USD', 'EUR'],
}) })
currencies: string[]; currencies: string[];
} }

View File

@ -1,9 +1,9 @@
// Rate Search DTOs // Rate Search DTOs
export * from './rate-search-request.dto'; export * from './rate-search-request.dto';
export * from './rate-search-response.dto'; export * from './rate-search-response.dto';
// Booking DTOs // Booking DTOs
export * from './create-booking-request.dto'; export * from './create-booking-request.dto';
export * from './booking-response.dto'; export * from './booking-response.dto';
export * from './booking-filter.dto'; export * from './booking-filter.dto';
export * from './booking-export.dto'; export * from './booking-export.dto';

View File

@ -1,301 +1,301 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsString, IsString,
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
MinLength, MinLength,
MaxLength, MaxLength,
IsOptional, IsOptional,
IsUrl, IsUrl,
IsBoolean, IsBoolean,
ValidateNested, ValidateNested,
Matches, Matches,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { OrganizationType } from '../../domain/entities/organization.entity'; import { OrganizationType } from '../../domain/entities/organization.entity';
/** /**
* Address DTO * Address DTO
*/ */
export class AddressDto { export class AddressDto {
@ApiProperty({ @ApiProperty({
example: '123 Main Street', example: '123 Main Street',
description: 'Street address', description: 'Street address',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
street: string; street: string;
@ApiProperty({ @ApiProperty({
example: 'Rotterdam', example: 'Rotterdam',
description: 'City', description: 'City',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
city: string; city: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'South Holland', example: 'South Holland',
description: 'State or province', description: 'State or province',
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
state?: string; state?: string;
@ApiProperty({ @ApiProperty({
example: '3000 AB', example: '3000 AB',
description: 'Postal code', description: 'Postal code',
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
postalCode: string; postalCode: string;
@ApiProperty({ @ApiProperty({
example: 'NL', example: 'NL',
description: 'Country code (ISO 3166-1 alpha-2)', description: 'Country code (ISO 3166-1 alpha-2)',
minLength: 2, minLength: 2,
maxLength: 2, maxLength: 2,
}) })
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(2) @MaxLength(2)
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' }) @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
country: string; country: string;
} }
/** /**
* Create Organization DTO * Create Organization DTO
*/ */
export class CreateOrganizationDto { export class CreateOrganizationDto {
@ApiProperty({ @ApiProperty({
example: 'Acme Freight Forwarding', example: 'Acme Freight Forwarding',
description: 'Organization name', description: 'Organization name',
minLength: 2, minLength: 2,
maxLength: 200, maxLength: 200,
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(2) @MinLength(2)
@MaxLength(200) @MaxLength(200)
name: string; name: string;
@ApiProperty({ @ApiProperty({
example: OrganizationType.FREIGHT_FORWARDER, example: OrganizationType.FREIGHT_FORWARDER,
description: 'Organization type', description: 'Organization type',
enum: OrganizationType, enum: OrganizationType,
}) })
@IsEnum(OrganizationType) @IsEnum(OrganizationType)
type: OrganizationType; type: OrganizationType;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'MAEU', example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
minLength: 4, minLength: 4,
maxLength: 4, maxLength: 4,
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
@MinLength(4) @MinLength(4)
@MaxLength(4) @MaxLength(4)
@Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters (e.g., MAEU, MSCU)' }) @Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters (e.g., MAEU, MSCU)' })
scac?: string; scac?: string;
@ApiProperty({ @ApiProperty({
description: 'Organization address', description: 'Organization address',
type: AddressDto, type: AddressDto,
}) })
@ValidateNested() @ValidateNested()
@Type(() => AddressDto) @Type(() => AddressDto)
address: AddressDto; address: AddressDto;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'https://example.com/logo.png', example: 'https://example.com/logo.png',
description: 'Logo URL', description: 'Logo URL',
}) })
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
logoUrl?: string; logoUrl?: string;
} }
/** /**
* Update Organization DTO * Update Organization DTO
*/ */
export class UpdateOrganizationDto { export class UpdateOrganizationDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'Acme Freight Forwarding Inc.', example: 'Acme Freight Forwarding Inc.',
description: 'Organization name', description: 'Organization name',
minLength: 2, minLength: 2,
maxLength: 200, maxLength: 200,
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
@MinLength(2) @MinLength(2)
@MaxLength(200) @MaxLength(200)
name?: string; name?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Organization address', description: 'Organization address',
type: AddressDto, type: AddressDto,
}) })
@ValidateNested() @ValidateNested()
@Type(() => AddressDto) @Type(() => AddressDto)
@IsOptional() @IsOptional()
address?: AddressDto; address?: AddressDto;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'https://example.com/logo.png', example: 'https://example.com/logo.png',
description: 'Logo URL', description: 'Logo URL',
}) })
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
logoUrl?: string; logoUrl?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: true, example: true,
description: 'Active status', description: 'Active status',
}) })
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isActive?: boolean; isActive?: boolean;
} }
/** /**
* Organization Document DTO * Organization Document DTO
*/ */
export class OrganizationDocumentDto { export class OrganizationDocumentDto {
@ApiProperty({ @ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Document ID', description: 'Document ID',
}) })
@IsUUID() @IsUUID()
id: string; id: string;
@ApiProperty({ @ApiProperty({
example: 'business_license', example: 'business_license',
description: 'Document type', description: 'Document type',
}) })
@IsString() @IsString()
type: string; type: string;
@ApiProperty({ @ApiProperty({
example: 'Business License 2025', example: 'Business License 2025',
description: 'Document name', description: 'Document name',
}) })
@IsString() @IsString()
name: string; name: string;
@ApiProperty({ @ApiProperty({
example: 'https://s3.amazonaws.com/xpeditis/documents/doc123.pdf', example: 'https://s3.amazonaws.com/xpeditis/documents/doc123.pdf',
description: 'Document URL', description: 'Document URL',
}) })
@IsUrl() @IsUrl()
url: string; url: string;
@ApiProperty({ @ApiProperty({
example: '2025-01-15T10:00:00Z', example: '2025-01-15T10:00:00Z',
description: 'Upload timestamp', description: 'Upload timestamp',
}) })
uploadedAt: Date; uploadedAt: Date;
} }
/** /**
* Organization Response DTO * Organization Response DTO
*/ */
export class OrganizationResponseDto { export class OrganizationResponseDto {
@ApiProperty({ @ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID', description: 'Organization ID',
}) })
id: string; id: string;
@ApiProperty({ @ApiProperty({
example: 'Acme Freight Forwarding', example: 'Acme Freight Forwarding',
description: 'Organization name', description: 'Organization name',
}) })
name: string; name: string;
@ApiProperty({ @ApiProperty({
example: OrganizationType.FREIGHT_FORWARDER, example: OrganizationType.FREIGHT_FORWARDER,
description: 'Organization type', description: 'Organization type',
enum: OrganizationType, enum: OrganizationType,
}) })
type: OrganizationType; type: OrganizationType;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'MAEU', example: 'MAEU',
description: 'Standard Carrier Alpha Code (carriers only)', description: 'Standard Carrier Alpha Code (carriers only)',
}) })
scac?: string; scac?: string;
@ApiProperty({ @ApiProperty({
description: 'Organization address', description: 'Organization address',
type: AddressDto, type: AddressDto,
}) })
address: AddressDto; address: AddressDto;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'https://example.com/logo.png', example: 'https://example.com/logo.png',
description: 'Logo URL', description: 'Logo URL',
}) })
logoUrl?: string; logoUrl?: string;
@ApiProperty({ @ApiProperty({
description: 'Organization documents', description: 'Organization documents',
type: [OrganizationDocumentDto], type: [OrganizationDocumentDto],
}) })
documents: OrganizationDocumentDto[]; documents: OrganizationDocumentDto[];
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Active status', description: 'Active status',
}) })
isActive: boolean; isActive: boolean;
@ApiProperty({ @ApiProperty({
example: '2025-01-01T00:00:00Z', example: '2025-01-01T00:00:00Z',
description: 'Creation timestamp', description: 'Creation timestamp',
}) })
createdAt: Date; createdAt: Date;
@ApiProperty({ @ApiProperty({
example: '2025-01-15T10:00:00Z', example: '2025-01-15T10:00:00Z',
description: 'Last update timestamp', description: 'Last update timestamp',
}) })
updatedAt: Date; updatedAt: Date;
} }
/** /**
* Organization List Response DTO * Organization List Response DTO
*/ */
export class OrganizationListResponseDto { export class OrganizationListResponseDto {
@ApiProperty({ @ApiProperty({
description: 'List of organizations', description: 'List of organizations',
type: [OrganizationResponseDto], type: [OrganizationResponseDto],
}) })
organizations: OrganizationResponseDto[]; organizations: OrganizationResponseDto[];
@ApiProperty({ @ApiProperty({
example: 25, example: 25,
description: 'Total number of organizations', description: 'Total number of organizations',
}) })
total: number; total: number;
@ApiProperty({ @ApiProperty({
example: 1, example: 1,
description: 'Current page number', description: 'Current page number',
}) })
page: number; page: number;
@ApiProperty({ @ApiProperty({
example: 20, example: 20,
description: 'Page size', description: 'Page size',
}) })
pageSize: number; pageSize: number;
@ApiProperty({ @ApiProperty({
example: 2, example: 2,
description: 'Total number of pages', description: 'Total number of pages',
}) })
totalPages: number; totalPages: number;
} }

View File

@ -1,155 +1,155 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsOptional, IsOptional,
IsArray, IsArray,
IsNumber, IsNumber,
Min, Min,
Max, Max,
IsEnum, IsEnum,
IsBoolean, IsBoolean,
IsDateString, IsDateString,
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
/** /**
* Rate Search Filters DTO * Rate Search Filters DTO
* *
* Advanced filters for narrowing down rate search results * Advanced filters for narrowing down rate search results
* All filters are optional * All filters are optional
*/ */
export class RateSearchFiltersDto { export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'List of company names to include in search', description: 'List of company names to include in search',
type: [String], type: [String],
example: ['SSC Consolidation', 'ECU Worldwide'], example: ['SSC Consolidation', 'ECU Worldwide'],
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
companies?: string[]; companies?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum volume in CBM (cubic meters)', description: 'Minimum volume in CBM (cubic meters)',
minimum: 0, minimum: 0,
example: 1, example: 1,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
minVolumeCBM?: number; minVolumeCBM?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum volume in CBM (cubic meters)', description: 'Maximum volume in CBM (cubic meters)',
minimum: 0, minimum: 0,
example: 100, example: 100,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
maxVolumeCBM?: number; maxVolumeCBM?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum weight in kilograms', description: 'Minimum weight in kilograms',
minimum: 0, minimum: 0,
example: 100, example: 100,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
minWeightKG?: number; minWeightKG?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum weight in kilograms', description: 'Maximum weight in kilograms',
minimum: 0, minimum: 0,
example: 15000, example: 15000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
maxWeightKG?: number; maxWeightKG?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Exact number of pallets (0 means any)', description: 'Exact number of pallets (0 means any)',
minimum: 0, minimum: 0,
example: 10, example: 10,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
palletCount?: number; palletCount?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum price in selected currency', description: 'Minimum price in selected currency',
minimum: 0, minimum: 0,
example: 1000, example: 1000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
minPrice?: number; minPrice?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum price in selected currency', description: 'Maximum price in selected currency',
minimum: 0, minimum: 0,
example: 5000, example: 5000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
maxPrice?: number; maxPrice?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum transit time in days', description: 'Minimum transit time in days',
minimum: 0, minimum: 0,
example: 20, example: 20,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
minTransitDays?: number; minTransitDays?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum transit time in days', description: 'Maximum transit time in days',
minimum: 0, minimum: 0,
example: 40, example: 40,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
maxTransitDays?: number; maxTransitDays?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Container types to filter by', description: 'Container types to filter by',
type: [String], type: [String],
example: ['LCL', '20DRY', '40HC'], example: ['LCL', '20DRY', '40HC'],
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
containerTypes?: string[]; containerTypes?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Preferred currency for price filtering', description: 'Preferred currency for price filtering',
enum: ['USD', 'EUR'], enum: ['USD', 'EUR'],
example: 'USD', example: 'USD',
}) })
@IsOptional() @IsOptional()
@IsEnum(['USD', 'EUR']) @IsEnum(['USD', 'EUR'])
currency?: 'USD' | 'EUR'; currency?: 'USD' | 'EUR';
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Only show all-in prices (without separate surcharges)', description: 'Only show all-in prices (without separate surcharges)',
example: false, example: false,
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
onlyAllInPrices?: boolean; onlyAllInPrices?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Departure date to check rate validity (ISO 8601)', description: 'Departure date to check rate validity (ISO 8601)',
example: '2025-06-15', example: '2025-06-15',
}) })
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
departureDate?: string; departureDate?: string;
} }

View File

@ -1,97 +1,110 @@
import { IsString, IsDateString, IsEnum, IsOptional, IsInt, Min, IsBoolean, Matches } from 'class-validator'; import {
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; IsString,
IsDateString,
export class RateSearchRequestDto { IsEnum,
@ApiProperty({ IsOptional,
description: 'Origin port code (UN/LOCODE)', IsInt,
example: 'NLRTM', Min,
pattern: '^[A-Z]{5}$', IsBoolean,
}) Matches,
@IsString() } from 'class-validator';
@Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' }) import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
origin: string;
export class RateSearchRequestDto {
@ApiProperty({ @ApiProperty({
description: 'Destination port code (UN/LOCODE)', description: 'Origin port code (UN/LOCODE)',
example: 'CNSHA', example: 'NLRTM',
pattern: '^[A-Z]{5}$', pattern: '^[A-Z]{5}$',
}) })
@IsString() @IsString()
@Matches(/^[A-Z]{5}$/, { message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)' }) @Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' })
destination: string; origin: string;
@ApiProperty({ @ApiProperty({
description: 'Container type', description: 'Destination port code (UN/LOCODE)',
example: '40HC', example: 'CNSHA',
enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], pattern: '^[A-Z]{5}$',
}) })
@IsString() @IsString()
@IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], { @Matches(/^[A-Z]{5}$/, {
message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC', message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)',
}) })
containerType: string; destination: string;
@ApiProperty({ @ApiProperty({
description: 'Shipping mode', description: 'Container type',
example: 'FCL', example: '40HC',
enum: ['FCL', 'LCL'], enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'],
}) })
@IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' }) @IsString()
mode: 'FCL' | 'LCL'; @IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], {
message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC',
@ApiProperty({ })
description: 'Desired departure date (ISO 8601 format)', containerType: string;
example: '2025-02-15',
}) @ApiProperty({
@IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' }) description: 'Shipping mode',
departureDate: string; example: 'FCL',
enum: ['FCL', 'LCL'],
@ApiPropertyOptional({ })
description: 'Number of containers', @IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' })
example: 2, mode: 'FCL' | 'LCL';
minimum: 1,
default: 1, @ApiProperty({
}) description: 'Desired departure date (ISO 8601 format)',
@IsOptional() example: '2025-02-15',
@IsInt() })
@Min(1, { message: 'Quantity must be at least 1' }) @IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' })
quantity?: number; departureDate: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Total cargo weight in kg', description: 'Number of containers',
example: 20000, example: 2,
minimum: 0, minimum: 1,
}) default: 1,
@IsOptional() })
@IsInt() @IsOptional()
@Min(0, { message: 'Weight must be non-negative' }) @IsInt()
weight?: number; @Min(1, { message: 'Quantity must be at least 1' })
quantity?: number;
@ApiPropertyOptional({
description: 'Total cargo volume in cubic meters', @ApiPropertyOptional({
example: 50.5, description: 'Total cargo weight in kg',
minimum: 0, example: 20000,
}) minimum: 0,
@IsOptional() })
@Min(0, { message: 'Volume must be non-negative' }) @IsOptional()
volume?: number; @IsInt()
@Min(0, { message: 'Weight must be non-negative' })
@ApiPropertyOptional({ weight?: number;
description: 'Whether cargo is hazardous material',
example: false, @ApiPropertyOptional({
default: false, description: 'Total cargo volume in cubic meters',
}) example: 50.5,
@IsOptional() minimum: 0,
@IsBoolean() })
isHazmat?: boolean; @IsOptional()
@Min(0, { message: 'Volume must be non-negative' })
@ApiPropertyOptional({ volume?: number;
description: 'IMO hazmat class (required if isHazmat is true)',
example: '3', @ApiPropertyOptional({
pattern: '^[1-9](\\.[1-9])?$', description: 'Whether cargo is hazardous material',
}) example: false,
@IsOptional() default: false,
@IsString() })
@Matches(/^[1-9](\.[1-9])?$/, { message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)' }) @IsOptional()
imoClass?: string; @IsBoolean()
} isHazmat?: boolean;
@ApiPropertyOptional({
description: 'IMO hazmat class (required if isHazmat is true)',
example: '3',
pattern: '^[1-9](\\.[1-9])?$',
})
@IsOptional()
@IsString()
@Matches(/^[1-9](\.[1-9])?$/, {
message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)',
})
imoClass?: string;
}

View File

@ -1,148 +1,148 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PortDto { export class PortDto {
@ApiProperty({ example: 'NLRTM' }) @ApiProperty({ example: 'NLRTM' })
code: string; code: string;
@ApiProperty({ example: 'Rotterdam' }) @ApiProperty({ example: 'Rotterdam' })
name: string; name: string;
@ApiProperty({ example: 'Netherlands' }) @ApiProperty({ example: 'Netherlands' })
country: string; country: string;
} }
export class SurchargeDto { export class SurchargeDto {
@ApiProperty({ example: 'BAF', description: 'Surcharge type code' }) @ApiProperty({ example: 'BAF', description: 'Surcharge type code' })
type: string; type: string;
@ApiProperty({ example: 'Bunker Adjustment Factor' }) @ApiProperty({ example: 'Bunker Adjustment Factor' })
description: string; description: string;
@ApiProperty({ example: 150.0 }) @ApiProperty({ example: 150.0 })
amount: number; amount: number;
@ApiProperty({ example: 'USD' }) @ApiProperty({ example: 'USD' })
currency: string; currency: string;
} }
export class PricingDto { export class PricingDto {
@ApiProperty({ example: 1500.0, description: 'Base ocean freight' }) @ApiProperty({ example: 1500.0, description: 'Base ocean freight' })
baseFreight: number; baseFreight: number;
@ApiProperty({ type: [SurchargeDto] }) @ApiProperty({ type: [SurchargeDto] })
surcharges: SurchargeDto[]; surcharges: SurchargeDto[];
@ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' }) @ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' })
totalAmount: number; totalAmount: number;
@ApiProperty({ example: 'USD' }) @ApiProperty({ example: 'USD' })
currency: string; currency: string;
} }
export class RouteSegmentDto { export class RouteSegmentDto {
@ApiProperty({ example: 'NLRTM' }) @ApiProperty({ example: 'NLRTM' })
portCode: string; portCode: string;
@ApiProperty({ example: 'Port of Rotterdam' }) @ApiProperty({ example: 'Port of Rotterdam' })
portName: string; portName: string;
@ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' }) @ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' })
arrival?: string; arrival?: string;
@ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' }) @ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' })
departure?: string; departure?: string;
@ApiPropertyOptional({ example: 'MAERSK ESSEX' }) @ApiPropertyOptional({ example: 'MAERSK ESSEX' })
vesselName?: string; vesselName?: string;
@ApiPropertyOptional({ example: '025W' }) @ApiPropertyOptional({ example: '025W' })
voyageNumber?: string; voyageNumber?: string;
} }
export class RateQuoteDto { export class RateQuoteDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string; id: string;
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' }) @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' })
carrierId: string; carrierId: string;
@ApiProperty({ example: 'Maersk Line' }) @ApiProperty({ example: 'Maersk Line' })
carrierName: string; carrierName: string;
@ApiProperty({ example: 'MAERSK' }) @ApiProperty({ example: 'MAERSK' })
carrierCode: string; carrierCode: string;
@ApiProperty({ type: PortDto }) @ApiProperty({ type: PortDto })
origin: PortDto; origin: PortDto;
@ApiProperty({ type: PortDto }) @ApiProperty({ type: PortDto })
destination: PortDto; destination: PortDto;
@ApiProperty({ type: PricingDto }) @ApiProperty({ type: PricingDto })
pricing: PricingDto; pricing: PricingDto;
@ApiProperty({ example: '40HC' }) @ApiProperty({ example: '40HC' })
containerType: string; containerType: string;
@ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] }) @ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] })
mode: 'FCL' | 'LCL'; mode: 'FCL' | 'LCL';
@ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' }) @ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' })
etd: string; etd: string;
@ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' }) @ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' })
eta: string; eta: string;
@ApiProperty({ example: 30, description: 'Transit time in days' }) @ApiProperty({ example: 30, description: 'Transit time in days' })
transitDays: number; transitDays: number;
@ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' }) @ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' })
route: RouteSegmentDto[]; route: RouteSegmentDto[];
@ApiProperty({ example: 85, description: 'Available container slots' }) @ApiProperty({ example: 85, description: 'Available container slots' })
availability: number; availability: number;
@ApiProperty({ example: 'Weekly' }) @ApiProperty({ example: 'Weekly' })
frequency: string; frequency: string;
@ApiPropertyOptional({ example: 'Container Ship' }) @ApiPropertyOptional({ example: 'Container Ship' })
vesselType?: string; vesselType?: string;
@ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' }) @ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' })
co2EmissionsKg?: number; co2EmissionsKg?: number;
@ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' }) @ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' })
validUntil: string; validUntil: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' }) @ApiProperty({ example: '2025-02-15T10:00:00Z' })
createdAt: string; createdAt: string;
} }
export class RateSearchResponseDto { export class RateSearchResponseDto {
@ApiProperty({ type: [RateQuoteDto] }) @ApiProperty({ type: [RateQuoteDto] })
quotes: RateQuoteDto[]; quotes: RateQuoteDto[];
@ApiProperty({ example: 5, description: 'Total number of quotes returned' }) @ApiProperty({ example: 5, description: 'Total number of quotes returned' })
count: number; count: number;
@ApiProperty({ example: 'NLRTM' }) @ApiProperty({ example: 'NLRTM' })
origin: string; origin: string;
@ApiProperty({ example: 'CNSHA' }) @ApiProperty({ example: 'CNSHA' })
destination: string; destination: string;
@ApiProperty({ example: '2025-02-15' }) @ApiProperty({ example: '2025-02-15' })
departureDate: string; departureDate: string;
@ApiProperty({ example: '40HC' }) @ApiProperty({ example: '40HC' })
containerType: string; containerType: string;
@ApiProperty({ example: 'FCL' }) @ApiProperty({ example: 'FCL' })
mode: string; mode: string;
@ApiProperty({ example: true, description: 'Whether results were served from cache' }) @ApiProperty({ example: true, description: 'Whether results were served from cache' })
fromCache: boolean; fromCache: boolean;
@ApiProperty({ example: 234, description: 'Query response time in milliseconds' }) @ApiProperty({ example: 234, description: 'Query response time in milliseconds' })
responseTimeMs: number; responseTimeMs: number;
} }

View File

@ -1,236 +1,237 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsString, IsString,
IsEmail, IsEmail,
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
MinLength, MinLength,
MaxLength, MaxLength,
IsOptional, IsOptional,
IsBoolean, IsBoolean,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
/** /**
* User roles enum * User roles enum
*/ */
export enum UserRole { export enum UserRole {
ADMIN = 'admin', ADMIN = 'admin',
MANAGER = 'manager', MANAGER = 'manager',
USER = 'user', USER = 'user',
VIEWER = 'viewer', VIEWER = 'viewer',
} }
/** /**
* Create User DTO (for admin/manager inviting users) * Create User DTO (for admin/manager inviting users)
*/ */
export class CreateUserDto { export class CreateUserDto {
@ApiProperty({ @ApiProperty({
example: 'jane.doe@acme.com', example: 'jane.doe@acme.com',
description: 'User email address', description: 'User email address',
}) })
@IsEmail({}, { message: 'Invalid email format' }) @IsEmail({}, { message: 'Invalid email format' })
email: string; email: string;
@ApiProperty({ @ApiProperty({
example: 'Jane', example: 'Jane',
description: 'First name', description: 'First name',
minLength: 2, minLength: 2,
}) })
@IsString() @IsString()
@MinLength(2, { message: 'First name must be at least 2 characters' }) @MinLength(2, { message: 'First name must be at least 2 characters' })
firstName: string; firstName: string;
@ApiProperty({ @ApiProperty({
example: 'Doe', example: 'Doe',
description: 'Last name', description: 'Last name',
minLength: 2, minLength: 2,
}) })
@IsString() @IsString()
@MinLength(2, { message: 'Last name must be at least 2 characters' }) @MinLength(2, { message: 'Last name must be at least 2 characters' })
lastName: string; lastName: string;
@ApiProperty({ @ApiProperty({
example: UserRole.USER, example: UserRole.USER,
description: 'User role', description: 'User role',
enum: UserRole, enum: UserRole,
}) })
@IsEnum(UserRole) @IsEnum(UserRole)
role: UserRole; role: UserRole;
@ApiProperty({ @ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID', description: 'Organization ID',
}) })
@IsUUID() @IsUUID()
organizationId: string; organizationId: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'TempPassword123!', example: 'TempPassword123!',
description: 'Temporary password (min 12 characters). If not provided, a random one will be generated.', description:
minLength: 12, 'Temporary password (min 12 characters). If not provided, a random one will be generated.',
}) minLength: 12,
@IsString() })
@IsOptional() @IsString()
@MinLength(12, { message: 'Password must be at least 12 characters' }) @IsOptional()
password?: string; @MinLength(12, { message: 'Password must be at least 12 characters' })
} password?: string;
}
/**
* Update User DTO /**
*/ * Update User DTO
export class UpdateUserDto { */
@ApiPropertyOptional({ export class UpdateUserDto {
example: 'Jane', @ApiPropertyOptional({
description: 'First name', example: 'Jane',
minLength: 2, description: 'First name',
}) minLength: 2,
@IsString() })
@IsOptional() @IsString()
@MinLength(2) @IsOptional()
firstName?: string; @MinLength(2)
firstName?: string;
@ApiPropertyOptional({
example: 'Doe', @ApiPropertyOptional({
description: 'Last name', example: 'Doe',
minLength: 2, description: 'Last name',
}) minLength: 2,
@IsString() })
@IsOptional() @IsString()
@MinLength(2) @IsOptional()
lastName?: string; @MinLength(2)
lastName?: string;
@ApiPropertyOptional({
example: UserRole.MANAGER, @ApiPropertyOptional({
description: 'User role', example: UserRole.MANAGER,
enum: UserRole, description: 'User role',
}) enum: UserRole,
@IsEnum(UserRole) })
@IsOptional() @IsEnum(UserRole)
role?: UserRole; @IsOptional()
role?: UserRole;
@ApiPropertyOptional({
example: true, @ApiPropertyOptional({
description: 'Active status', example: true,
}) description: 'Active status',
@IsBoolean() })
@IsOptional() @IsBoolean()
isActive?: boolean; @IsOptional()
} isActive?: boolean;
}
/**
* Update Password DTO /**
*/ * Update Password DTO
export class UpdatePasswordDto { */
@ApiProperty({ export class UpdatePasswordDto {
example: 'OldPassword123!', @ApiProperty({
description: 'Current password', example: 'OldPassword123!',
}) description: 'Current password',
@IsString() })
@IsNotEmpty() @IsString()
currentPassword: string; @IsNotEmpty()
currentPassword: string;
@ApiProperty({
example: 'NewSecurePassword456!', @ApiProperty({
description: 'New password (min 12 characters)', example: 'NewSecurePassword456!',
minLength: 12, description: 'New password (min 12 characters)',
}) minLength: 12,
@IsString() })
@MinLength(12, { message: 'Password must be at least 12 characters' }) @IsString()
newPassword: string; @MinLength(12, { message: 'Password must be at least 12 characters' })
} newPassword: string;
}
/**
* User Response DTO /**
*/ * User Response DTO
export class UserResponseDto { */
@ApiProperty({ export class UserResponseDto {
example: '550e8400-e29b-41d4-a716-446655440000', @ApiProperty({
description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000',
}) description: 'User ID',
id: string; })
id: string;
@ApiProperty({
example: 'john.doe@acme.com', @ApiProperty({
description: 'User email', example: 'john.doe@acme.com',
}) description: 'User email',
email: string; })
email: string;
@ApiProperty({
example: 'John', @ApiProperty({
description: 'First name', example: 'John',
}) description: 'First name',
firstName: string; })
firstName: string;
@ApiProperty({
example: 'Doe', @ApiProperty({
description: 'Last name', example: 'Doe',
}) description: 'Last name',
lastName: string; })
lastName: string;
@ApiProperty({
example: UserRole.USER, @ApiProperty({
description: 'User role', example: UserRole.USER,
enum: UserRole, description: 'User role',
}) enum: UserRole,
role: UserRole; })
role: UserRole;
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', @ApiProperty({
description: 'Organization ID', example: '550e8400-e29b-41d4-a716-446655440000',
}) description: 'Organization ID',
organizationId: string; })
organizationId: string;
@ApiProperty({
example: true, @ApiProperty({
description: 'Active status', example: true,
}) description: 'Active status',
isActive: boolean; })
isActive: boolean;
@ApiProperty({
example: '2025-01-01T00:00:00Z', @ApiProperty({
description: 'Creation timestamp', example: '2025-01-01T00:00:00Z',
}) description: 'Creation timestamp',
createdAt: Date; })
createdAt: Date;
@ApiProperty({
example: '2025-01-15T10:00:00Z', @ApiProperty({
description: 'Last update timestamp', example: '2025-01-15T10:00:00Z',
}) description: 'Last update timestamp',
updatedAt: Date; })
} updatedAt: Date;
}
/**
* User List Response DTO /**
*/ * User List Response DTO
export class UserListResponseDto { */
@ApiProperty({ export class UserListResponseDto {
description: 'List of users', @ApiProperty({
type: [UserResponseDto], description: 'List of users',
}) type: [UserResponseDto],
users: UserResponseDto[]; })
users: UserResponseDto[];
@ApiProperty({
example: 15, @ApiProperty({
description: 'Total number of users', example: 15,
}) description: 'Total number of users',
total: number; })
total: number;
@ApiProperty({
example: 1, @ApiProperty({
description: 'Current page number', example: 1,
}) description: 'Current page number',
page: number; })
page: number;
@ApiProperty({
example: 20, @ApiProperty({
description: 'Page size', example: 20,
}) description: 'Page size',
pageSize: number; })
pageSize: number;
@ApiProperty({
example: 1, @ApiProperty({
description: 'Total number of pages', example: 1,
}) description: 'Total number of pages',
totalPages: number; })
} totalPages: number;
}

View File

@ -39,7 +39,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService
) {} ) {}
/** /**
@ -81,12 +81,12 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
// Send recent notifications on connection // Send recent notifications on connection
const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10); const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10);
client.emit('recent_notifications', { client.emit('recent_notifications', {
notifications: recentNotifications.map((n) => this.mapNotificationToDto(n)), notifications: recentNotifications.map(n => this.mapNotificationToDto(n)),
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error(
`Error during client connection: ${error?.message || 'Unknown error'}`, `Error during client connection: ${error?.message || 'Unknown error'}`,
error?.stack, error?.stack
); );
client.disconnect(); client.disconnect();
} }
@ -112,7 +112,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
@SubscribeMessage('mark_as_read') @SubscribeMessage('mark_as_read')
async handleMarkAsRead( async handleMarkAsRead(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { notificationId: string }, @MessageBody() data: { notificationId: string }
) { ) {
try { try {
const userId = client.data.userId; const userId = client.data.userId;

View File

@ -1,2 +1,2 @@
export * from './jwt-auth.guard'; export * from './jwt-auth.guard';
export * from './roles.guard'; export * from './roles.guard';

View File

@ -1,45 +1,45 @@
import { Injectable, ExecutionContext } from '@nestjs/common'; import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
/** /**
* JWT Authentication Guard * JWT Authentication Guard
* *
* This guard: * This guard:
* - Uses the JWT strategy to authenticate requests * - Uses the JWT strategy to authenticate requests
* - Checks for valid JWT token in Authorization header * - Checks for valid JWT token in Authorization header
* - Attaches user object to request if authentication succeeds * - Attaches user object to request if authentication succeeds
* - Can be bypassed with @Public() decorator * - Can be bypassed with @Public() decorator
* *
* Usage: * Usage:
* @UseGuards(JwtAuthGuard) * @UseGuards(JwtAuthGuard)
* @Get('protected') * @Get('protected')
* protectedRoute(@CurrentUser() user: UserPayload) { * protectedRoute(@CurrentUser() user: UserPayload) {
* return { user }; * return { user };
* } * }
*/ */
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { constructor(private reflector: Reflector) {
super(); super();
} }
/** /**
* Determine if the route should be accessible without authentication * Determine if the route should be accessible without authentication
* Routes decorated with @Public() will bypass this guard * Routes decorated with @Public() will bypass this guard
*/ */
canActivate(context: ExecutionContext) { canActivate(context: ExecutionContext) {
// Check if route is marked as public // Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [ const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (isPublic) { if (isPublic) {
return true; return true;
} }
// Otherwise, perform JWT authentication // Otherwise, perform JWT authentication
return super.canActivate(context); return super.canActivate(context);
} }
} }

View File

@ -1,46 +1,46 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
/** /**
* Roles Guard for Role-Based Access Control (RBAC) * Roles Guard for Role-Based Access Control (RBAC)
* *
* This guard: * This guard:
* - Checks if the authenticated user has the required role(s) * - Checks if the authenticated user has the required role(s)
* - Works in conjunction with JwtAuthGuard * - Works in conjunction with JwtAuthGuard
* - Uses @Roles() decorator to specify required roles * - Uses @Roles() decorator to specify required roles
* *
* Usage: * Usage:
* @UseGuards(JwtAuthGuard, RolesGuard) * @UseGuards(JwtAuthGuard, RolesGuard)
* @Roles('admin', 'manager') * @Roles('admin', 'manager')
* @Get('admin-only') * @Get('admin-only')
* adminRoute(@CurrentUser() user: UserPayload) { * adminRoute(@CurrentUser() user: UserPayload) {
* return { message: 'Admin access granted' }; * return { message: 'Admin access granted' };
* } * }
*/ */
@Injectable() @Injectable()
export class RolesGuard implements CanActivate { export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
// Get required roles from @Roles() decorator // Get required roles from @Roles() decorator
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [ const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
// If no roles are required, allow access // If no roles are required, allow access
if (!requiredRoles || requiredRoles.length === 0) { if (!requiredRoles || requiredRoles.length === 0) {
return true; return true;
} }
// Get user from request (should be set by JwtAuthGuard) // Get user from request (should be set by JwtAuthGuard)
const { user } = context.switchToHttp().getRequest(); const { user } = context.switchToHttp().getRequest();
// Check if user has any of the required roles // Check if user has any of the required roles
if (!user || !user.role) { if (!user || !user.role) {
return false; return false;
} }
return requiredRoles.includes(user.role); return requiredRoles.includes(user.role);
} }
} }

View File

@ -23,11 +23,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
/** /**
* Custom error message (override for new API) * Custom error message (override for new API)
*/ */
protected async throwThrottlingException( protected async throwThrottlingException(context: ExecutionContext): Promise<void> {
context: ExecutionContext, throw new ThrottlerException('Too many requests. Please try again later.');
): Promise<void> {
throw new ThrottlerException(
'Too many requests. Please try again later.',
);
} }
} }

View File

@ -4,13 +4,7 @@
* Tracks request duration and logs metrics * Tracks request duration and logs metrics
*/ */
import { import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators'; import { tap, catchError } from 'rxjs/operators';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
@ -25,33 +19,31 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
const startTime = Date.now(); const startTime = Date.now();
return next.handle().pipe( return next.handle().pipe(
tap((data) => { tap(data => {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const response = context.switchToHttp().getResponse(); const response = context.switchToHttp().getResponse();
// Log performance // Log performance
if (duration > 1000) { if (duration > 1000) {
this.logger.warn( this.logger.warn(
`Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`, `Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`
); );
} }
// Log successful request // Log successful request
this.logger.log( this.logger.log(`${method} ${url} - ${response.statusCode} - ${duration}ms`);
`${method} ${url} - ${response.statusCode} - ${duration}ms`,
);
}), }),
catchError((error) => { catchError(error => {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
// Log error // Log error
this.logger.error( this.logger.error(
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`, `Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
error.stack, error.stack
); );
// Capture exception in Sentry // Capture exception in Sentry
Sentry.withScope((scope) => { Sentry.withScope(scope => {
scope.setContext('request', { scope.setContext('request', {
method, method,
url, url,
@ -62,7 +54,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
}); });
throw error; throw error;
}), })
); );
} }
} }

View File

@ -1,168 +1,168 @@
import { Booking } from '../../domain/entities/booking.entity'; import { Booking } from '../../domain/entities/booking.entity';
import { RateQuote } from '../../domain/entities/rate-quote.entity'; import { RateQuote } from '../../domain/entities/rate-quote.entity';
import { import {
BookingResponseDto, BookingResponseDto,
BookingAddressDto, BookingAddressDto,
BookingPartyDto, BookingPartyDto,
BookingContainerDto, BookingContainerDto,
BookingRateQuoteDto, BookingRateQuoteDto,
BookingListItemDto, BookingListItemDto,
} from '../dto/booking-response.dto'; } from '../dto/booking-response.dto';
import { import {
CreateBookingRequestDto, CreateBookingRequestDto,
PartyDto, PartyDto,
AddressDto, AddressDto,
ContainerDto, ContainerDto,
} from '../dto/create-booking-request.dto'; } from '../dto/create-booking-request.dto';
export class BookingMapper { export class BookingMapper {
/** /**
* Map CreateBookingRequestDto to domain inputs * Map CreateBookingRequestDto to domain inputs
*/ */
static toCreateBookingInput(dto: CreateBookingRequestDto) { static toCreateBookingInput(dto: CreateBookingRequestDto) {
return { return {
rateQuoteId: dto.rateQuoteId, rateQuoteId: dto.rateQuoteId,
shipper: { shipper: {
name: dto.shipper.name, name: dto.shipper.name,
address: { address: {
street: dto.shipper.address.street, street: dto.shipper.address.street,
city: dto.shipper.address.city, city: dto.shipper.address.city,
postalCode: dto.shipper.address.postalCode, postalCode: dto.shipper.address.postalCode,
country: dto.shipper.address.country, country: dto.shipper.address.country,
}, },
contactName: dto.shipper.contactName, contactName: dto.shipper.contactName,
contactEmail: dto.shipper.contactEmail, contactEmail: dto.shipper.contactEmail,
contactPhone: dto.shipper.contactPhone, contactPhone: dto.shipper.contactPhone,
}, },
consignee: { consignee: {
name: dto.consignee.name, name: dto.consignee.name,
address: { address: {
street: dto.consignee.address.street, street: dto.consignee.address.street,
city: dto.consignee.address.city, city: dto.consignee.address.city,
postalCode: dto.consignee.address.postalCode, postalCode: dto.consignee.address.postalCode,
country: dto.consignee.address.country, country: dto.consignee.address.country,
}, },
contactName: dto.consignee.contactName, contactName: dto.consignee.contactName,
contactEmail: dto.consignee.contactEmail, contactEmail: dto.consignee.contactEmail,
contactPhone: dto.consignee.contactPhone, contactPhone: dto.consignee.contactPhone,
}, },
cargoDescription: dto.cargoDescription, cargoDescription: dto.cargoDescription,
containers: dto.containers.map((c) => ({ containers: dto.containers.map(c => ({
type: c.type, type: c.type,
containerNumber: c.containerNumber, containerNumber: c.containerNumber,
vgm: c.vgm, vgm: c.vgm,
temperature: c.temperature, temperature: c.temperature,
sealNumber: c.sealNumber, sealNumber: c.sealNumber,
})), })),
specialInstructions: dto.specialInstructions, specialInstructions: dto.specialInstructions,
}; };
} }
/** /**
* Map Booking entity and RateQuote to BookingResponseDto * Map Booking entity and RateQuote to BookingResponseDto
*/ */
static toDto(booking: Booking, rateQuote: RateQuote): BookingResponseDto { static toDto(booking: Booking, rateQuote: RateQuote): BookingResponseDto {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber.value, bookingNumber: booking.bookingNumber.value,
status: booking.status.value, status: booking.status.value,
shipper: { shipper: {
name: booking.shipper.name, name: booking.shipper.name,
address: { address: {
street: booking.shipper.address.street, street: booking.shipper.address.street,
city: booking.shipper.address.city, city: booking.shipper.address.city,
postalCode: booking.shipper.address.postalCode, postalCode: booking.shipper.address.postalCode,
country: booking.shipper.address.country, country: booking.shipper.address.country,
}, },
contactName: booking.shipper.contactName, contactName: booking.shipper.contactName,
contactEmail: booking.shipper.contactEmail, contactEmail: booking.shipper.contactEmail,
contactPhone: booking.shipper.contactPhone, contactPhone: booking.shipper.contactPhone,
}, },
consignee: { consignee: {
name: booking.consignee.name, name: booking.consignee.name,
address: { address: {
street: booking.consignee.address.street, street: booking.consignee.address.street,
city: booking.consignee.address.city, city: booking.consignee.address.city,
postalCode: booking.consignee.address.postalCode, postalCode: booking.consignee.address.postalCode,
country: booking.consignee.address.country, country: booking.consignee.address.country,
}, },
contactName: booking.consignee.contactName, contactName: booking.consignee.contactName,
contactEmail: booking.consignee.contactEmail, contactEmail: booking.consignee.contactEmail,
contactPhone: booking.consignee.contactPhone, contactPhone: booking.consignee.contactPhone,
}, },
cargoDescription: booking.cargoDescription, cargoDescription: booking.cargoDescription,
containers: booking.containers.map((c) => ({ containers: booking.containers.map(c => ({
id: c.id, id: c.id,
type: c.type, type: c.type,
containerNumber: c.containerNumber, containerNumber: c.containerNumber,
vgm: c.vgm, vgm: c.vgm,
temperature: c.temperature, temperature: c.temperature,
sealNumber: c.sealNumber, sealNumber: c.sealNumber,
})), })),
specialInstructions: booking.specialInstructions, specialInstructions: booking.specialInstructions,
rateQuote: { rateQuote: {
id: rateQuote.id, id: rateQuote.id,
carrierName: rateQuote.carrierName, carrierName: rateQuote.carrierName,
carrierCode: rateQuote.carrierCode, carrierCode: rateQuote.carrierCode,
origin: { origin: {
code: rateQuote.origin.code, code: rateQuote.origin.code,
name: rateQuote.origin.name, name: rateQuote.origin.name,
country: rateQuote.origin.country, country: rateQuote.origin.country,
}, },
destination: { destination: {
code: rateQuote.destination.code, code: rateQuote.destination.code,
name: rateQuote.destination.name, name: rateQuote.destination.name,
country: rateQuote.destination.country, country: rateQuote.destination.country,
}, },
pricing: { pricing: {
baseFreight: rateQuote.pricing.baseFreight, baseFreight: rateQuote.pricing.baseFreight,
surcharges: rateQuote.pricing.surcharges.map((s) => ({ surcharges: rateQuote.pricing.surcharges.map(s => ({
type: s.type, type: s.type,
description: s.description, description: s.description,
amount: s.amount, amount: s.amount,
currency: s.currency, currency: s.currency,
})), })),
totalAmount: rateQuote.pricing.totalAmount, totalAmount: rateQuote.pricing.totalAmount,
currency: rateQuote.pricing.currency, currency: rateQuote.pricing.currency,
}, },
containerType: rateQuote.containerType, containerType: rateQuote.containerType,
mode: rateQuote.mode, mode: rateQuote.mode,
etd: rateQuote.etd.toISOString(), etd: rateQuote.etd.toISOString(),
eta: rateQuote.eta.toISOString(), eta: rateQuote.eta.toISOString(),
transitDays: rateQuote.transitDays, transitDays: rateQuote.transitDays,
}, },
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
updatedAt: booking.updatedAt.toISOString(), updatedAt: booking.updatedAt.toISOString(),
}; };
} }
/** /**
* Map Booking entity to list item DTO (simplified view) * Map Booking entity to list item DTO (simplified view)
*/ */
static toListItemDto(booking: Booking, rateQuote: RateQuote): BookingListItemDto { static toListItemDto(booking: Booking, rateQuote: RateQuote): BookingListItemDto {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber.value, bookingNumber: booking.bookingNumber.value,
status: booking.status.value, status: booking.status.value,
shipperName: booking.shipper.name, shipperName: booking.shipper.name,
consigneeName: booking.consignee.name, consigneeName: booking.consignee.name,
originPort: rateQuote.origin.code, originPort: rateQuote.origin.code,
destinationPort: rateQuote.destination.code, destinationPort: rateQuote.destination.code,
carrierName: rateQuote.carrierName, carrierName: rateQuote.carrierName,
etd: rateQuote.etd.toISOString(), etd: rateQuote.etd.toISOString(),
eta: rateQuote.eta.toISOString(), eta: rateQuote.eta.toISOString(),
totalAmount: rateQuote.pricing.totalAmount, totalAmount: rateQuote.pricing.totalAmount,
currency: rateQuote.pricing.currency, currency: rateQuote.pricing.currency,
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
}; };
} }
/** /**
* Map array of bookings to list item DTOs * Map array of bookings to list item DTOs
*/ */
static toListItemDtoArray( static toListItemDtoArray(
bookings: Array<{ booking: Booking; rateQuote: RateQuote }> bookings: Array<{ booking: Booking; rateQuote: RateQuote }>
): BookingListItemDto[] { ): BookingListItemDto[] {
return bookings.map(({ booking, rateQuote }) => this.toListItemDto(booking, rateQuote)); return bookings.map(({ booking, rateQuote }) => this.toListItemDto(booking, rateQuote));
} }
} }

View File

@ -1,112 +1,109 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CsvRate } from '@domain/entities/csv-rate.entity'; import { CsvRate } from '@domain/entities/csv-rate.entity';
import { Volume } from '@domain/value-objects/volume.vo'; import { Volume } from '@domain/value-objects/volume.vo';
import { import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
CsvRateResultDto, import {
CsvRateSearchResponseDto, CsvRateSearchInput,
} from '../dto/csv-rate-search.dto'; CsvRateSearchOutput,
import { CsvRateSearchResult,
CsvRateSearchInput, RateSearchFilters,
CsvRateSearchOutput, } from '@domain/ports/in/search-csv-rates.port';
CsvRateSearchResult, import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
RateSearchFilters, import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
} from '@domain/ports/in/search-csv-rates.port'; import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto'; /**
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; * CSV Rate Mapper
*
/** * Maps between domain entities and DTOs
* CSV Rate Mapper * Follows hexagonal architecture principles
* */
* Maps between domain entities and DTOs @Injectable()
* Follows hexagonal architecture principles export class CsvRateMapper {
*/ /**
@Injectable() * Map DTO filters to domain filters
export class CsvRateMapper { */
/** mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
* Map DTO filters to domain filters if (!dto) {
*/ return undefined;
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined { }
if (!dto) {
return undefined; return {
} companies: dto.companies,
minVolumeCBM: dto.minVolumeCBM,
return { maxVolumeCBM: dto.maxVolumeCBM,
companies: dto.companies, minWeightKG: dto.minWeightKG,
minVolumeCBM: dto.minVolumeCBM, maxWeightKG: dto.maxWeightKG,
maxVolumeCBM: dto.maxVolumeCBM, palletCount: dto.palletCount,
minWeightKG: dto.minWeightKG, minPrice: dto.minPrice,
maxWeightKG: dto.maxWeightKG, maxPrice: dto.maxPrice,
palletCount: dto.palletCount, currency: dto.currency,
minPrice: dto.minPrice, minTransitDays: dto.minTransitDays,
maxPrice: dto.maxPrice, maxTransitDays: dto.maxTransitDays,
currency: dto.currency, containerTypes: dto.containerTypes,
minTransitDays: dto.minTransitDays, onlyAllInPrices: dto.onlyAllInPrices,
maxTransitDays: dto.maxTransitDays, departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
containerTypes: dto.containerTypes, };
onlyAllInPrices: dto.onlyAllInPrices, }
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
}; /**
} * Map domain search result to DTO
*/
/** mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
* Map domain search result to DTO const rate = result.rate;
*/
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto { return {
const rate = result.rate; companyName: rate.companyName,
origin: rate.origin.getValue(),
return { destination: rate.destination.getValue(),
companyName: rate.companyName, containerType: rate.containerType.getValue(),
origin: rate.origin.getValue(), priceUSD: result.calculatedPrice.usd,
destination: rate.destination.getValue(), priceEUR: result.calculatedPrice.eur,
containerType: rate.containerType.getValue(), primaryCurrency: result.calculatedPrice.primaryCurrency,
priceUSD: result.calculatedPrice.usd, hasSurcharges: rate.hasSurcharges(),
priceEUR: result.calculatedPrice.eur, surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
primaryCurrency: result.calculatedPrice.primaryCurrency, transitDays: rate.transitDays,
hasSurcharges: rate.hasSurcharges(), validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null, source: result.source,
transitDays: rate.transitDays, matchScore: result.matchScore,
validUntil: rate.validity.getEndDate().toISOString().split('T')[0], };
source: result.source, }
matchScore: result.matchScore,
}; /**
} * Map domain search output to response DTO
*/
/** mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
* Map domain search output to response DTO return {
*/ results: output.results.map(result => this.mapSearchResultToDto(result)),
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto { totalResults: output.totalResults,
return { searchedFiles: output.searchedFiles,
results: output.results.map((result) => this.mapSearchResultToDto(result)), searchedAt: output.searchedAt,
totalResults: output.totalResults, appliedFilters: output.appliedFilters as any, // Already matches DTO structure
searchedFiles: output.searchedFiles, };
searchedAt: output.searchedAt, }
appliedFilters: output.appliedFilters as any, // Already matches DTO structure
}; /**
} * Map ORM entity to DTO
*/
/** mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
* Map ORM entity to DTO return {
*/ id: entity.id,
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto { companyName: entity.companyName,
return { csvFilePath: entity.csvFilePath,
id: entity.id, type: entity.type,
companyName: entity.companyName, hasApi: entity.hasApi,
csvFilePath: entity.csvFilePath, apiConnector: entity.apiConnector,
type: entity.type, isActive: entity.isActive,
hasApi: entity.hasApi, uploadedAt: entity.uploadedAt,
apiConnector: entity.apiConnector, rowCount: entity.rowCount,
isActive: entity.isActive, metadata: entity.metadata,
uploadedAt: entity.uploadedAt, };
rowCount: entity.rowCount, }
metadata: entity.metadata,
}; /**
} * Map multiple config entities to DTOs
*/
/** mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
* Map multiple config entities to DTOs return entities.map(entity => this.mapConfigEntityToDto(entity));
*/ }
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] { }
return entities.map((entity) => this.mapConfigEntityToDto(entity));
}
}

View File

@ -1,2 +1,2 @@
export * from './rate-quote.mapper'; export * from './rate-quote.mapper';
export * from './booking.mapper'; export * from './booking.mapper';

View File

@ -1,83 +1,81 @@
import { import {
Organization, Organization,
OrganizationAddress, OrganizationAddress,
OrganizationDocument, OrganizationDocument,
} from '../../domain/entities/organization.entity'; } from '../../domain/entities/organization.entity';
import { import {
OrganizationResponseDto, OrganizationResponseDto,
OrganizationDocumentDto, OrganizationDocumentDto,
AddressDto, AddressDto,
} from '../dto/organization.dto'; } from '../dto/organization.dto';
/** /**
* Organization Mapper * Organization Mapper
* *
* Maps between Organization domain entities and DTOs * Maps between Organization domain entities and DTOs
*/ */
export class OrganizationMapper { export class OrganizationMapper {
/** /**
* Convert Organization entity to DTO * Convert Organization entity to DTO
*/ */
static toDto(organization: Organization): OrganizationResponseDto { static toDto(organization: Organization): OrganizationResponseDto {
return { return {
id: organization.id, id: organization.id,
name: organization.name, name: organization.name,
type: organization.type, type: organization.type,
scac: organization.scac, scac: organization.scac,
address: this.mapAddressToDto(organization.address), address: this.mapAddressToDto(organization.address),
logoUrl: organization.logoUrl, logoUrl: organization.logoUrl,
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
isActive: organization.isActive, isActive: organization.isActive,
createdAt: organization.createdAt, createdAt: organization.createdAt,
updatedAt: organization.updatedAt, updatedAt: organization.updatedAt,
}; };
} }
/** /**
* Convert array of Organization entities to DTOs * Convert array of Organization entities to DTOs
*/ */
static toDtoArray(organizations: Organization[]): OrganizationResponseDto[] { static toDtoArray(organizations: Organization[]): OrganizationResponseDto[] {
return organizations.map(org => this.toDto(org)); return organizations.map(org => this.toDto(org));
} }
/** /**
* Map Address entity to DTO * Map Address entity to DTO
*/ */
private static mapAddressToDto(address: OrganizationAddress): AddressDto { private static mapAddressToDto(address: OrganizationAddress): AddressDto {
return { return {
street: address.street, street: address.street,
city: address.city, city: address.city,
state: address.state, state: address.state,
postalCode: address.postalCode, postalCode: address.postalCode,
country: address.country, country: address.country,
}; };
} }
/** /**
* Map Document entity to DTO * Map Document entity to DTO
*/ */
private static mapDocumentToDto( private static mapDocumentToDto(document: OrganizationDocument): OrganizationDocumentDto {
document: OrganizationDocument, return {
): OrganizationDocumentDto { id: document.id,
return { type: document.type,
id: document.id, name: document.name,
type: document.type, url: document.url,
name: document.name, uploadedAt: document.uploadedAt,
url: document.url, };
uploadedAt: document.uploadedAt, }
};
} /**
* Map DTO Address to domain Address
/** */
* Map DTO Address to domain Address static mapDtoToAddress(dto: AddressDto): OrganizationAddress {
*/ return {
static mapDtoToAddress(dto: AddressDto): OrganizationAddress { street: dto.street,
return { city: dto.city,
street: dto.street, state: dto.state,
city: dto.city, postalCode: dto.postalCode,
state: dto.state, country: dto.country,
postalCode: dto.postalCode, };
country: dto.country, }
}; }
}
}

View File

@ -1,69 +1,69 @@
import { RateQuote } from '../../domain/entities/rate-quote.entity'; import { RateQuote } from '../../domain/entities/rate-quote.entity';
import { import {
RateQuoteDto, RateQuoteDto,
PortDto, PortDto,
SurchargeDto, SurchargeDto,
PricingDto, PricingDto,
RouteSegmentDto, RouteSegmentDto,
} from '../dto/rate-search-response.dto'; } from '../dto/rate-search-response.dto';
export class RateQuoteMapper { export class RateQuoteMapper {
/** /**
* Map domain RateQuote entity to DTO * Map domain RateQuote entity to DTO
*/ */
static toDto(entity: RateQuote): RateQuoteDto { static toDto(entity: RateQuote): RateQuoteDto {
return { return {
id: entity.id, id: entity.id,
carrierId: entity.carrierId, carrierId: entity.carrierId,
carrierName: entity.carrierName, carrierName: entity.carrierName,
carrierCode: entity.carrierCode, carrierCode: entity.carrierCode,
origin: { origin: {
code: entity.origin.code, code: entity.origin.code,
name: entity.origin.name, name: entity.origin.name,
country: entity.origin.country, country: entity.origin.country,
}, },
destination: { destination: {
code: entity.destination.code, code: entity.destination.code,
name: entity.destination.name, name: entity.destination.name,
country: entity.destination.country, country: entity.destination.country,
}, },
pricing: { pricing: {
baseFreight: entity.pricing.baseFreight, baseFreight: entity.pricing.baseFreight,
surcharges: entity.pricing.surcharges.map((s) => ({ surcharges: entity.pricing.surcharges.map(s => ({
type: s.type, type: s.type,
description: s.description, description: s.description,
amount: s.amount, amount: s.amount,
currency: s.currency, currency: s.currency,
})), })),
totalAmount: entity.pricing.totalAmount, totalAmount: entity.pricing.totalAmount,
currency: entity.pricing.currency, currency: entity.pricing.currency,
}, },
containerType: entity.containerType, containerType: entity.containerType,
mode: entity.mode, mode: entity.mode,
etd: entity.etd.toISOString(), etd: entity.etd.toISOString(),
eta: entity.eta.toISOString(), eta: entity.eta.toISOString(),
transitDays: entity.transitDays, transitDays: entity.transitDays,
route: entity.route.map((segment) => ({ route: entity.route.map(segment => ({
portCode: segment.portCode, portCode: segment.portCode,
portName: segment.portName, portName: segment.portName,
arrival: segment.arrival?.toISOString(), arrival: segment.arrival?.toISOString(),
departure: segment.departure?.toISOString(), departure: segment.departure?.toISOString(),
vesselName: segment.vesselName, vesselName: segment.vesselName,
voyageNumber: segment.voyageNumber, voyageNumber: segment.voyageNumber,
})), })),
availability: entity.availability, availability: entity.availability,
frequency: entity.frequency, frequency: entity.frequency,
vesselType: entity.vesselType, vesselType: entity.vesselType,
co2EmissionsKg: entity.co2EmissionsKg, co2EmissionsKg: entity.co2EmissionsKg,
validUntil: entity.validUntil.toISOString(), validUntil: entity.validUntil.toISOString(),
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
}; };
} }
/** /**
* Map array of RateQuote entities to DTOs * Map array of RateQuote entities to DTOs
*/ */
static toDtoArray(entities: RateQuote[]): RateQuoteDto[] { static toDtoArray(entities: RateQuote[]): RateQuoteDto[] {
return entities.map((entity) => this.toDto(entity)); return entities.map(entity => this.toDto(entity));
} }
} }

View File

@ -45,33 +45,14 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
}, },
{ {
provide: RateSearchService, provide: RateSearchService,
useFactory: ( useFactory: (cache: any, rateQuoteRepo: any, portRepo: any, carrierRepo: any) => {
cache: any,
rateQuoteRepo: any,
portRepo: any,
carrierRepo: any,
) => {
// For now, create service with empty connectors array // For now, create service with empty connectors array
// TODO: Inject actual carrier connectors // TODO: Inject actual carrier connectors
return new RateSearchService( return new RateSearchService([], cache, rateQuoteRepo, portRepo, carrierRepo);
[],
cache,
rateQuoteRepo,
portRepo,
carrierRepo,
);
}, },
inject: [ inject: [CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
CACHE_PORT,
RATE_QUOTE_REPOSITORY,
PORT_REPOSITORY,
CARRIER_REPOSITORY,
],
}, },
], ],
exports: [ exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
RATE_QUOTE_REPOSITORY,
RateSearchService,
],
}) })
export class RatesModule {} export class RatesModule {}

View File

@ -53,7 +53,7 @@ export class AnalyticsService {
@Inject(BOOKING_REPOSITORY) @Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository, private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY) @Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository, private readonly rateQuoteRepository: RateQuoteRepository
) {} ) {}
/** /**
@ -70,13 +70,11 @@ export class AnalyticsService {
const allBookings = await this.bookingRepository.findByOrganization(organizationId); const allBookings = await this.bookingRepository.findByOrganization(organizationId);
// This month bookings // This month bookings
const thisMonthBookings = allBookings.filter( const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart);
(b) => b.createdAt >= thisMonthStart
);
// Last month bookings // Last month bookings
const lastMonthBookings = allBookings.filter( const lastMonthBookings = allBookings.filter(
(b) => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
); );
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU) // Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
@ -118,10 +116,10 @@ export class AnalyticsService {
// Pending confirmations (status = pending_confirmation) // Pending confirmations (status = pending_confirmation)
const pendingThisMonth = thisMonthBookings.filter( const pendingThisMonth = thisMonthBookings.filter(
(b) => b.status.value === 'pending_confirmation' b => b.status.value === 'pending_confirmation'
).length; ).length;
const pendingLastMonth = lastMonthBookings.filter( const pendingLastMonth = lastMonthBookings.filter(
(b) => b.status.value === 'pending_confirmation' b => b.status.value === 'pending_confirmation'
).length; ).length;
// Calculate percentage changes // Calculate percentage changes
@ -135,15 +133,9 @@ export class AnalyticsService {
totalTEUs: totalTEUsThisMonth, totalTEUs: totalTEUsThisMonth,
estimatedRevenue: estimatedRevenueThisMonth, estimatedRevenue: estimatedRevenueThisMonth,
pendingConfirmations: pendingThisMonth, pendingConfirmations: pendingThisMonth,
bookingsThisMonthChange: calculateChange( bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length),
thisMonthBookings.length,
lastMonthBookings.length
),
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth), totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
estimatedRevenueChange: calculateChange( estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth),
estimatedRevenueThisMonth,
estimatedRevenueLastMonth
),
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth), pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
}; };
} }
@ -172,7 +164,7 @@ export class AnalyticsService {
// Count bookings in this month // Count bookings in this month
const count = allBookings.filter( const count = allBookings.filter(
(b) => b.createdAt >= monthDate && b.createdAt <= monthEnd b => b.createdAt >= monthDate && b.createdAt <= monthEnd
).length; ).length;
data.push(count); data.push(count);
} }
@ -187,13 +179,16 @@ export class AnalyticsService {
const allBookings = await this.bookingRepository.findByOrganization(organizationId); const allBookings = await this.bookingRepository.findByOrganization(organizationId);
// Group by route (origin-destination) // Group by route (origin-destination)
const routeMap = new Map<string, { const routeMap = new Map<
originPort: string; string,
destinationPort: string; {
bookingCount: number; originPort: string;
totalTEUs: number; destinationPort: string;
totalPrice: number; bookingCount: number;
}>(); totalTEUs: number;
totalPrice: number;
}
>();
for (const booking of allBookings) { for (const booking of allBookings) {
try { try {
@ -231,16 +226,14 @@ export class AnalyticsService {
} }
// Convert to array and sort by booking count // Convert to array and sort by booking count
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map( const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
([route, data]) => ({ route,
route, originPort: data.originPort,
originPort: data.originPort, destinationPort: data.destinationPort,
destinationPort: data.destinationPort, bookingCount: data.bookingCount,
bookingCount: data.bookingCount, totalTEUs: data.totalTEUs,
totalTEUs: data.totalTEUs, avgPrice: data.totalPrice / data.bookingCount,
avgPrice: data.totalPrice / data.bookingCount, }));
})
);
// Sort by booking count and return top 5 // Sort by booking count and return top 5
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5); return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
@ -256,7 +249,7 @@ export class AnalyticsService {
// Check for pending confirmations (older than 24h) // Check for pending confirmations (older than 24h)
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oldPendingBookings = allBookings.filter( const oldPendingBookings = allBookings.filter(
(b) => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
); );
for (const booking of oldPendingBookings) { for (const booking of oldPendingBookings) {

View File

@ -4,7 +4,10 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
import { AUDIT_LOG_REPOSITORY, AuditLogRepository } from '../../domain/ports/out/audit-log.repository'; import {
AUDIT_LOG_REPOSITORY,
AuditLogRepository,
} from '../../domain/ports/out/audit-log.repository';
import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity'; import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity';
describe('AuditService', () => { describe('AuditService', () => {

View File

@ -7,11 +7,7 @@
import { Injectable, Logger, Inject } from '@nestjs/common'; import { Injectable, Logger, Inject } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
AuditLog,
AuditAction,
AuditStatus,
} from '../../domain/entities/audit-log.entity';
import { import {
AuditLogRepository, AuditLogRepository,
AUDIT_LOG_REPOSITORY, AUDIT_LOG_REPOSITORY,
@ -39,7 +35,7 @@ export class AuditService {
constructor( constructor(
@Inject(AUDIT_LOG_REPOSITORY) @Inject(AUDIT_LOG_REPOSITORY)
private readonly auditLogRepository: AuditLogRepository, private readonly auditLogRepository: AuditLogRepository
) {} ) {}
/** /**
@ -54,14 +50,12 @@ export class AuditService {
await this.auditLogRepository.save(auditLog); await this.auditLogRepository.save(auditLog);
this.logger.log( this.logger.log(`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`);
`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`,
);
} catch (error: any) { } catch (error: any) {
// Never throw on audit logging failure - log the error and continue // Never throw on audit logging failure - log the error and continue
this.logger.error( this.logger.error(
`Failed to create audit log: ${error?.message || 'Unknown error'}`, `Failed to create audit log: ${error?.message || 'Unknown error'}`,
error?.stack, error?.stack
); );
} }
} }
@ -81,7 +75,7 @@ export class AuditService {
metadata?: Record<string, any>; metadata?: Record<string, any>;
ipAddress?: string; ipAddress?: string;
userAgent?: string; userAgent?: string;
}, }
): Promise<void> { ): Promise<void> {
await this.log({ await this.log({
action, action,
@ -108,7 +102,7 @@ export class AuditService {
metadata?: Record<string, any>; metadata?: Record<string, any>;
ipAddress?: string; ipAddress?: string;
userAgent?: string; userAgent?: string;
}, }
): Promise<void> { ): Promise<void> {
await this.log({ await this.log({
action, action,
@ -139,20 +133,14 @@ export class AuditService {
/** /**
* Get audit trail for a specific resource * Get audit trail for a specific resource
*/ */
async getResourceAuditTrail( async getResourceAuditTrail(resourceType: string, resourceId: string): Promise<AuditLog[]> {
resourceType: string,
resourceId: string,
): Promise<AuditLog[]> {
return this.auditLogRepository.findByResource(resourceType, resourceId); return this.auditLogRepository.findByResource(resourceType, resourceId);
} }
/** /**
* Get recent activity for an organization * Get recent activity for an organization
*/ */
async getOrganizationActivity( async getOrganizationActivity(organizationId: string, limit: number = 50): Promise<AuditLog[]> {
organizationId: string,
limit: number = 50,
): Promise<AuditLog[]> {
return this.auditLogRepository.findRecentByOrganization(organizationId, limit); return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
} }

View File

@ -8,12 +8,12 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
import { Booking } from '../../domain/entities/booking.entity'; import { Booking } from '../../domain/entities/booking.entity';
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port'; import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port'; import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
import { import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port';
StoragePort,
STORAGE_PORT,
} from '../../domain/ports/out/storage.port';
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
import { RateQuoteRepository, RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository'; import {
RateQuoteRepository,
RATE_QUOTE_REPOSITORY,
} from '../../domain/ports/out/rate-quote.repository';
@Injectable() @Injectable()
export class BookingAutomationService { export class BookingAutomationService {
@ -24,16 +24,14 @@ export class BookingAutomationService {
@Inject(PDF_PORT) private readonly pdfPort: PdfPort, @Inject(PDF_PORT) private readonly pdfPort: PdfPort,
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort, @Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository, @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository
) {} ) {}
/** /**
* Execute all post-booking automation tasks * Execute all post-booking automation tasks
*/ */
async executePostBookingTasks(booking: Booking): Promise<void> { async executePostBookingTasks(booking: Booking): Promise<void> {
this.logger.log( this.logger.log(`Starting post-booking automation for booking: ${booking.bookingNumber.value}`);
`Starting post-booking automation for booking: ${booking.bookingNumber.value}`
);
try { try {
// Get user and rate quote details // Get user and rate quote details
@ -42,9 +40,7 @@ export class BookingAutomationService {
throw new Error(`User not found: ${booking.userId}`); throw new Error(`User not found: ${booking.userId}`);
} }
const rateQuote = await this.rateQuoteRepository.findById( const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
booking.rateQuoteId
);
if (!rateQuote) { if (!rateQuote) {
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`); throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
} }
@ -79,7 +75,7 @@ export class BookingAutomationService {
email: booking.consignee.contactEmail, email: booking.consignee.contactEmail,
phone: booking.consignee.contactPhone, phone: booking.consignee.contactPhone,
}, },
containers: booking.containers.map((c) => ({ containers: booking.containers.map(c => ({
type: c.type, type: c.type,
quantity: 1, quantity: 1,
containerNumber: c.containerNumber, containerNumber: c.containerNumber,
@ -173,10 +169,7 @@ export class BookingAutomationService {
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}` `Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
); );
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(`Failed to send booking update notification`, error);
`Failed to send booking update notification`,
error
);
} }
} }
} }

View File

@ -38,13 +38,11 @@ export class BruteForceProtectionService {
// Calculate block time with exponential backoff // Calculate block time with exponential backoff
if (existing.count > bruteForceConfig.freeRetries) { if (existing.count > bruteForceConfig.freeRetries) {
const waitTime = this.calculateWaitTime( const waitTime = this.calculateWaitTime(existing.count - bruteForceConfig.freeRetries);
existing.count - bruteForceConfig.freeRetries,
);
existing.blockedUntil = new Date(now.getTime() + waitTime); existing.blockedUntil = new Date(now.getTime() + waitTime);
this.logger.warn( this.logger.warn(
`Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`, `Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`
); );
} }
@ -99,7 +97,7 @@ export class BruteForceProtectionService {
const now = new Date(); const now = new Date();
const remaining = Math.max( const remaining = Math.max(
0, 0,
Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000), Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000)
); );
return remaining; return remaining;
@ -116,8 +114,7 @@ export class BruteForceProtectionService {
* Calculate wait time with exponential backoff * Calculate wait time with exponential backoff
*/ */
private calculateWaitTime(failedAttempts: number): number { private calculateWaitTime(failedAttempts: number): number {
const waitTime = const waitTime = bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1);
bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1);
return Math.min(waitTime, bruteForceConfig.maxWait); return Math.min(waitTime, bruteForceConfig.maxWait);
} }
@ -163,10 +160,7 @@ export class BruteForceProtectionService {
return { return {
totalAttempts, totalAttempts,
currentlyBlocked, currentlyBlocked,
averageAttempts: averageAttempts: this.attempts.size > 0 ? Math.round(totalAttempts / this.attempts.size) : 0,
this.attempts.size > 0
? Math.round(totalAttempts / this.attempts.size)
: 0,
}; };
} }
@ -190,9 +184,7 @@ export class BruteForceProtectionService {
}); });
} }
this.logger.warn( this.logger.warn(`Manually blocked ${identifier} for ${durationMs / 1000} seconds`);
`Manually blocked ${identifier} for ${durationMs / 1000} seconds`,
);
} }
/** /**

View File

@ -25,10 +25,10 @@ export class ExportService {
async exportBookings( async exportBookings(
data: BookingExportData[], data: BookingExportData[],
format: ExportFormat, format: ExportFormat,
fields?: ExportField[], fields?: ExportField[]
): Promise<{ buffer: Buffer; contentType: string; filename: string }> { ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
this.logger.log( this.logger.log(
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`, `Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`
); );
switch (format) { switch (format) {
@ -48,17 +48,17 @@ export class ExportService {
*/ */
private async exportToCSV( private async exportToCSV(
data: BookingExportData[], data: BookingExportData[],
fields?: ExportField[], fields?: ExportField[]
): Promise<{ buffer: Buffer; contentType: string; filename: string }> { ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
const selectedFields = fields || Object.values(ExportField); const selectedFields = fields || Object.values(ExportField);
const rows = data.map((item) => this.extractFields(item, selectedFields)); const rows = data.map(item => this.extractFields(item, selectedFields));
// Build CSV header // Build CSV header
const header = selectedFields.map((field) => this.getFieldLabel(field)).join(','); const header = selectedFields.map(field => this.getFieldLabel(field)).join(',');
// Build CSV rows // Build CSV rows
const csvRows = rows.map((row) => const csvRows = rows.map(row =>
selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','), selectedFields.map(field => this.escapeCSVValue(row[field] || '')).join(',')
); );
const csv = [header, ...csvRows].join('\n'); const csv = [header, ...csvRows].join('\n');
@ -79,10 +79,10 @@ export class ExportService {
*/ */
private async exportToExcel( private async exportToExcel(
data: BookingExportData[], data: BookingExportData[],
fields?: ExportField[], fields?: ExportField[]
): Promise<{ buffer: Buffer; contentType: string; filename: string }> { ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
const selectedFields = fields || Object.values(ExportField); const selectedFields = fields || Object.values(ExportField);
const rows = data.map((item) => this.extractFields(item, selectedFields)); const rows = data.map(item => this.extractFields(item, selectedFields));
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = 'Xpeditis'; workbook.creator = 'Xpeditis';
@ -91,9 +91,7 @@ export class ExportService {
const worksheet = workbook.addWorksheet('Bookings'); const worksheet = workbook.addWorksheet('Bookings');
// Add header row with styling // Add header row with styling
const headerRow = worksheet.addRow( const headerRow = worksheet.addRow(selectedFields.map(field => this.getFieldLabel(field)));
selectedFields.map((field) => this.getFieldLabel(field)),
);
headerRow.font = { bold: true }; headerRow.font = { bold: true };
headerRow.fill = { headerRow.fill = {
type: 'pattern', type: 'pattern',
@ -102,15 +100,15 @@ export class ExportService {
}; };
// Add data rows // Add data rows
rows.forEach((row) => { rows.forEach(row => {
const values = selectedFields.map((field) => row[field] || ''); const values = selectedFields.map(field => row[field] || '');
worksheet.addRow(values); worksheet.addRow(values);
}); });
// Auto-fit columns // Auto-fit columns
worksheet.columns.forEach((column) => { worksheet.columns.forEach(column => {
let maxLength = 10; let maxLength = 10;
column.eachCell?.({ includeEmpty: false }, (cell) => { column.eachCell?.({ includeEmpty: false }, cell => {
const columnLength = cell.value ? String(cell.value).length : 10; const columnLength = cell.value ? String(cell.value).length : 10;
if (columnLength > maxLength) { if (columnLength > maxLength) {
maxLength = columnLength; maxLength = columnLength;
@ -136,10 +134,10 @@ export class ExportService {
*/ */
private async exportToJSON( private async exportToJSON(
data: BookingExportData[], data: BookingExportData[],
fields?: ExportField[], fields?: ExportField[]
): Promise<{ buffer: Buffer; contentType: string; filename: string }> { ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
const selectedFields = fields || Object.values(ExportField); const selectedFields = fields || Object.values(ExportField);
const rows = data.map((item) => this.extractFields(item, selectedFields)); const rows = data.map(item => this.extractFields(item, selectedFields));
const json = JSON.stringify( const json = JSON.stringify(
{ {
@ -148,7 +146,7 @@ export class ExportService {
bookings: rows, bookings: rows,
}, },
null, null,
2, 2
); );
const buffer = Buffer.from(json, 'utf-8'); const buffer = Buffer.from(json, 'utf-8');
@ -166,14 +164,11 @@ export class ExportService {
/** /**
* Extract specified fields from booking data * Extract specified fields from booking data
*/ */
private extractFields( private extractFields(data: BookingExportData, fields: ExportField[]): Record<string, any> {
data: BookingExportData,
fields: ExportField[],
): Record<string, any> {
const { booking, rateQuote } = data; const { booking, rateQuote } = data;
const result: Record<string, any> = {}; const result: Record<string, any> = {};
fields.forEach((field) => { fields.forEach(field => {
switch (field) { switch (field) {
case ExportField.BOOKING_NUMBER: case ExportField.BOOKING_NUMBER:
result[field] = booking.bookingNumber.value; result[field] = booking.bookingNumber.value;
@ -206,7 +201,7 @@ export class ExportService {
result[field] = booking.consignee.name; result[field] = booking.consignee.name;
break; break;
case ExportField.CONTAINER_TYPE: case ExportField.CONTAINER_TYPE:
result[field] = booking.containers.map((c) => c.type).join(', '); result[field] = booking.containers.map(c => c.type).join(', ');
break; break;
case ExportField.CONTAINER_COUNT: case ExportField.CONTAINER_COUNT:
result[field] = booking.containers.length; result[field] = booking.containers.length;
@ -217,7 +212,8 @@ export class ExportService {
}, 0); }, 0);
break; break;
case ExportField.PRICE: case ExportField.PRICE:
result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`; result[field] =
`${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
break; break;
} }
}); });
@ -253,11 +249,7 @@ export class ExportService {
*/ */
private escapeCSVValue(value: string): string { private escapeCSVValue(value: string): string {
const stringValue = String(value); const stringValue = String(value);
if ( if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
stringValue.includes(',') ||
stringValue.includes('"') ||
stringValue.includes('\n')
) {
return `"${stringValue.replace(/"/g, '""')}"`; return `"${stringValue.replace(/"/g, '""')}"`;
} }
return stringValue; return stringValue;

View File

@ -32,14 +32,14 @@ export class FileValidationService {
// Validate file size // Validate file size
if (file.size > fileUploadConfig.maxFileSize) { if (file.size > fileUploadConfig.maxFileSize) {
errors.push( errors.push(
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`, `File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`
); );
} }
// Validate MIME type // Validate MIME type
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) { if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
errors.push( errors.push(
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`, `File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`
); );
} }
@ -47,7 +47,7 @@ export class FileValidationService {
const ext = path.extname(file.originalname).toLowerCase(); const ext = path.extname(file.originalname).toLowerCase();
if (!fileUploadConfig.allowedExtensions.includes(ext)) { if (!fileUploadConfig.allowedExtensions.includes(ext)) {
errors.push( errors.push(
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`, `File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`
); );
} }
@ -129,7 +129,7 @@ export class FileValidationService {
]; ];
const lowerFilename = filename.toLowerCase(); const lowerFilename = filename.toLowerCase();
return dangerousExtensions.some((ext) => lowerFilename.includes(ext)); return dangerousExtensions.some(ext => lowerFilename.includes(ext));
} }
/** /**
@ -180,9 +180,7 @@ export class FileValidationService {
// TODO: Integrate with ClamAV or similar virus scanner // TODO: Integrate with ClamAV or similar virus scanner
// For now, just log // For now, just log
this.logger.log( this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`);
`Virus scan requested for file: ${file.originalname} (not implemented)`,
);
return true; return true;
} }
@ -190,9 +188,7 @@ export class FileValidationService {
/** /**
* Validate multiple files * Validate multiple files
*/ */
async validateFiles( async validateFiles(files: Express.Multer.File[]): Promise<FileValidationResult> {
files: Express.Multer.File[],
): Promise<FileValidationResult> {
const allErrors: string[] = []; const allErrors: string[] = [];
for (const file of files) { for (const file of files) {

View File

@ -16,7 +16,7 @@ export class FuzzySearchService {
constructor( constructor(
@InjectRepository(BookingOrmEntity) @InjectRepository(BookingOrmEntity)
private readonly bookingOrmRepository: Repository<BookingOrmEntity>, private readonly bookingOrmRepository: Repository<BookingOrmEntity>
) {} ) {}
/** /**
@ -26,15 +26,13 @@ export class FuzzySearchService {
async fuzzySearchBookings( async fuzzySearchBookings(
searchTerm: string, searchTerm: string,
organizationId: string, organizationId: string,
limit: number = 20, limit: number = 20
): Promise<BookingOrmEntity[]> { ): Promise<BookingOrmEntity[]> {
if (!searchTerm || searchTerm.length < 2) { if (!searchTerm || searchTerm.length < 2) {
return []; return [];
} }
this.logger.log( this.logger.log(`Fuzzy search for "${searchTerm}" in organization ${organizationId}`);
`Fuzzy search for "${searchTerm}" in organization ${organizationId}`,
);
// Use PostgreSQL full-text search with similarity // Use PostgreSQL full-text search with similarity
// This requires pg_trgm extension to be enabled // This requires pg_trgm extension to be enabled
@ -54,7 +52,7 @@ export class FuzzySearchService {
{ {
searchTerm, searchTerm,
likeTerm: `%${searchTerm}%`, likeTerm: `%${searchTerm}%`,
}, }
) )
.orderBy( .orderBy(
`GREATEST( `GREATEST(
@ -62,7 +60,7 @@ export class FuzzySearchService {
similarity(booking.shipper_name, :searchTerm), similarity(booking.shipper_name, :searchTerm),
similarity(booking.consignee_name, :searchTerm) similarity(booking.consignee_name, :searchTerm)
)`, )`,
'DESC', 'DESC'
) )
.setParameter('searchTerm', searchTerm) .setParameter('searchTerm', searchTerm)
.limit(limit) .limit(limit)
@ -80,21 +78,19 @@ export class FuzzySearchService {
async fullTextSearch( async fullTextSearch(
searchTerm: string, searchTerm: string,
organizationId: string, organizationId: string,
limit: number = 20, limit: number = 20
): Promise<BookingOrmEntity[]> { ): Promise<BookingOrmEntity[]> {
if (!searchTerm || searchTerm.length < 2) { if (!searchTerm || searchTerm.length < 2) {
return []; return [];
} }
this.logger.log( this.logger.log(`Full-text search for "${searchTerm}" in organization ${organizationId}`);
`Full-text search for "${searchTerm}" in organization ${organizationId}`,
);
// Convert search term to tsquery format // Convert search term to tsquery format
const tsquery = searchTerm const tsquery = searchTerm
.split(/\s+/) .split(/\s+/)
.filter((term) => term.length > 0) .filter(term => term.length > 0)
.map((term) => `${term}:*`) .map(term => `${term}:*`)
.join(' & '); .join(' & ');
const results = await this.bookingOrmRepository const results = await this.bookingOrmRepository
@ -111,7 +107,7 @@ export class FuzzySearchService {
{ {
tsquery, tsquery,
likeTerm: `%${searchTerm}%`, likeTerm: `%${searchTerm}%`,
}, }
) )
.orderBy('booking.created_at', 'DESC') .orderBy('booking.created_at', 'DESC')
.limit(limit) .limit(limit)
@ -128,7 +124,7 @@ export class FuzzySearchService {
async search( async search(
searchTerm: string, searchTerm: string,
organizationId: string, organizationId: string,
limit: number = 20, limit: number = 20
): Promise<BookingOrmEntity[]> { ): Promise<BookingOrmEntity[]> {
// Try fuzzy search first (more tolerant to typos) // Try fuzzy search first (more tolerant to typos)
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit); let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);

View File

@ -31,7 +31,7 @@ export class GDPRService {
constructor( constructor(
@InjectRepository(UserOrmEntity) @InjectRepository(UserOrmEntity)
private readonly userRepository: Repository<UserOrmEntity>, private readonly userRepository: Repository<UserOrmEntity>
) {} ) {}
/** /**
@ -63,7 +63,8 @@ export class GDPRService {
exportDate: new Date().toISOString(), exportDate: new Date().toISOString(),
userId, userId,
userData: sanitizedUser, userData: sanitizedUser,
message: 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', message:
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
}; };
this.logger.log(`Data export completed for user ${userId}`); this.logger.log(`Data export completed for user ${userId}`);
@ -76,7 +77,9 @@ export class GDPRService {
* Note: This is a simplified version. In production, implement full anonymization logic. * Note: This is a simplified version. In production, implement full anonymization logic.
*/ */
async deleteUserData(userId: string, reason?: string): Promise<void> { async deleteUserData(userId: string, reason?: string): Promise<void> {
this.logger.warn(`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`); this.logger.warn(
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
);
// Verify user exists // Verify user exists
const user = await this.userRepository.findOne({ where: { id: userId } }); const user = await this.userRepository.findOne({ where: { id: userId } });
@ -117,7 +120,9 @@ export class GDPRService {
// In production, store in separate consent table // In production, store in separate consent table
// For now, just log the consent // For now, just log the consent
this.logger.log(`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`); this.logger.log(
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
);
} }
/** /**

View File

@ -4,8 +4,15 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { NotificationService } from './notification.service'; import { NotificationService } from './notification.service';
import { NOTIFICATION_REPOSITORY, NotificationRepository } from '../../domain/ports/out/notification.repository'; import {
import { Notification, NotificationType, NotificationPriority } from '../../domain/entities/notification.entity'; NOTIFICATION_REPOSITORY,
NotificationRepository,
} from '../../domain/ports/out/notification.repository';
import {
Notification,
NotificationType,
NotificationPriority,
} from '../../domain/entities/notification.entity';
describe('NotificationService', () => { describe('NotificationService', () => {
let service: NotificationService; let service: NotificationService;

View File

@ -34,7 +34,7 @@ export class NotificationService {
constructor( constructor(
@Inject(NOTIFICATION_REPOSITORY) @Inject(NOTIFICATION_REPOSITORY)
private readonly notificationRepository: NotificationRepository, private readonly notificationRepository: NotificationRepository
) {} ) {}
/** /**
@ -50,14 +50,14 @@ export class NotificationService {
await this.notificationRepository.save(notification); await this.notificationRepository.save(notification);
this.logger.log( this.logger.log(
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`, `Notification created: ${input.type} for user ${input.userId} - ${input.title}`
); );
return notification; return notification;
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error(
`Failed to create notification: ${error?.message || 'Unknown error'}`, `Failed to create notification: ${error?.message || 'Unknown error'}`,
error?.stack, error?.stack
); );
throw error; throw error;
} }
@ -147,7 +147,7 @@ export class NotificationService {
userId: string, userId: string,
organizationId: string, organizationId: string,
bookingNumber: string, bookingNumber: string,
bookingId: string, bookingId: string
): Promise<Notification> { ): Promise<Notification> {
return this.createNotification({ return this.createNotification({
userId, userId,
@ -166,7 +166,7 @@ export class NotificationService {
organizationId: string, organizationId: string,
bookingNumber: string, bookingNumber: string,
bookingId: string, bookingId: string,
status: string, status: string
): Promise<Notification> { ): Promise<Notification> {
return this.createNotification({ return this.createNotification({
userId, userId,
@ -184,7 +184,7 @@ export class NotificationService {
userId: string, userId: string,
organizationId: string, organizationId: string,
bookingNumber: string, bookingNumber: string,
bookingId: string, bookingId: string
): Promise<Notification> { ): Promise<Notification> {
return this.createNotification({ return this.createNotification({
userId, userId,
@ -202,7 +202,7 @@ export class NotificationService {
userId: string, userId: string,
organizationId: string, organizationId: string,
documentName: string, documentName: string,
bookingId: string, bookingId: string
): Promise<Notification> { ): Promise<Notification> {
return this.createNotification({ return this.createNotification({
userId, userId,

View File

@ -123,11 +123,9 @@ describe('WebhookService', () => {
of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any }) of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any })
); );
await service.triggerWebhooks( await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', {
WebhookEvent.BOOKING_CREATED, bookingId: 'booking-123',
'org-123', });
{ bookingId: 'booking-123' }
);
expect(httpService.post).toHaveBeenCalledWith( expect(httpService.post).toHaveBeenCalledWith(
'https://example.com/webhook', 'https://example.com/webhook',
@ -151,11 +149,9 @@ describe('WebhookService', () => {
repository.findActiveByEvent.mockResolvedValue([webhook]); repository.findActiveByEvent.mockResolvedValue([webhook]);
httpService.post.mockReturnValue(throwError(() => new Error('Network error'))); httpService.post.mockReturnValue(throwError(() => new Error('Network error')));
await service.triggerWebhooks( await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', {
WebhookEvent.BOOKING_CREATED, bookingId: 'booking-123',
'org-123', });
{ bookingId: 'booking-123' }
);
// Should be saved as failed after retries // Should be saved as failed after retries
expect(repository.save).toHaveBeenCalledWith( expect(repository.save).toHaveBeenCalledWith(

View File

@ -9,11 +9,7 @@ import { HttpService } from '@nestjs/axios';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { import { Webhook, WebhookEvent, WebhookStatus } from '../../domain/entities/webhook.entity';
Webhook,
WebhookEvent,
WebhookStatus,
} from '../../domain/entities/webhook.entity';
import { import {
WebhookRepository, WebhookRepository,
WEBHOOK_REPOSITORY, WEBHOOK_REPOSITORY,
@ -51,7 +47,7 @@ export class WebhookService {
constructor( constructor(
@Inject(WEBHOOK_REPOSITORY) @Inject(WEBHOOK_REPOSITORY)
private readonly webhookRepository: WebhookRepository, private readonly webhookRepository: WebhookRepository,
private readonly httpService: HttpService, private readonly httpService: HttpService
) {} ) {}
/** /**
@ -72,9 +68,7 @@ export class WebhookService {
await this.webhookRepository.save(webhook); await this.webhookRepository.save(webhook);
this.logger.log( this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`);
`Webhook created: ${webhook.id} for organization ${input.organizationId}`,
);
return webhook; return webhook;
} }
@ -158,11 +152,7 @@ export class WebhookService {
/** /**
* Trigger webhooks for an event * Trigger webhooks for an event
*/ */
async triggerWebhooks( async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise<void> {
event: WebhookEvent,
organizationId: string,
data: any,
): Promise<void> {
try { try {
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId); const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
@ -179,17 +169,13 @@ export class WebhookService {
}; };
// Trigger all webhooks in parallel // Trigger all webhooks in parallel
await Promise.allSettled( await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload)));
webhooks.map((webhook) => this.triggerWebhook(webhook, payload)),
);
this.logger.log( this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`);
`Triggered ${webhooks.length} webhooks for event: ${event}`,
);
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error(
`Error triggering webhooks: ${error?.message || 'Unknown error'}`, `Error triggering webhooks: ${error?.message || 'Unknown error'}`,
error?.stack, error?.stack
); );
} }
} }
@ -197,10 +183,7 @@ export class WebhookService {
/** /**
* Trigger a single webhook with retries * Trigger a single webhook with retries
*/ */
private async triggerWebhook( private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise<void> {
webhook: Webhook,
payload: WebhookPayload,
): Promise<void> {
let lastError: Error | null = null; let lastError: Error | null = null;
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) { for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
@ -226,7 +209,7 @@ export class WebhookService {
this.httpService.post(webhook.url, payload, { this.httpService.post(webhook.url, payload, {
headers, headers,
timeout: 10000, // 10 seconds timeout: 10000, // 10 seconds
}), })
); );
if (response && response.status >= 200 && response.status < 300) { if (response && response.status >= 200 && response.status < 300) {
@ -234,17 +217,17 @@ export class WebhookService {
const updatedWebhook = webhook.recordTrigger(); const updatedWebhook = webhook.recordTrigger();
await this.webhookRepository.save(updatedWebhook); await this.webhookRepository.save(updatedWebhook);
this.logger.log( this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`);
`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`,
);
return; return;
} }
lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`); lastError = new Error(
`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`
);
} catch (error: any) { } catch (error: any) {
lastError = error; lastError = error;
this.logger.warn( this.logger.warn(
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`, `Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`
); );
} }
} }
@ -254,7 +237,7 @@ export class WebhookService {
await this.webhookRepository.save(failedWebhook); await this.webhookRepository.save(failedWebhook);
this.logger.error( this.logger.error(
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`, `Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`
); );
} }
@ -279,16 +262,13 @@ export class WebhookService {
*/ */
verifySignature(payload: any, signature: string, secret: string): boolean { verifySignature(payload: any, signature: string, secret: string): boolean {
const expectedSignature = this.generateSignature(payload, secret); const expectedSignature = this.generateSignature(payload, secret);
return crypto.timingSafeEqual( return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} }
/** /**
* Delay helper for retries * Delay helper for retries
*/ */
private delay(ms: number): Promise<void> { private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
} }

View File

@ -1,299 +1,297 @@
/** /**
* Booking Entity * Booking Entity
* *
* Represents a freight booking * Represents a freight booking
* *
* Business Rules: * Business Rules:
* - Must have valid rate quote * - Must have valid rate quote
* - Shipper and consignee are required * - Shipper and consignee are required
* - Status transitions must follow allowed paths * - Status transitions must follow allowed paths
* - Containers can be added/updated until confirmed * - Containers can be added/updated until confirmed
* - Cannot modify confirmed bookings (except status) * - Cannot modify confirmed bookings (except status)
*/ */
import { BookingNumber } from '../value-objects/booking-number.vo'; import { BookingNumber } from '../value-objects/booking-number.vo';
import { BookingStatus } from '../value-objects/booking-status.vo'; import { BookingStatus } from '../value-objects/booking-status.vo';
export interface Address { export interface Address {
street: string; street: string;
city: string; city: string;
postalCode: string; postalCode: string;
country: string; country: string;
} }
export interface Party { export interface Party {
name: string; name: string;
address: Address; address: Address;
contactName: string; contactName: string;
contactEmail: string; contactEmail: string;
contactPhone: string; contactPhone: string;
} }
export interface BookingContainer { export interface BookingContainer {
id: string; id: string;
type: string; type: string;
containerNumber?: string; containerNumber?: string;
vgm?: number; // Verified Gross Mass in kg vgm?: number; // Verified Gross Mass in kg
temperature?: number; // For reefer containers temperature?: number; // For reefer containers
sealNumber?: string; sealNumber?: string;
} }
export interface BookingProps { export interface BookingProps {
id: string; id: string;
bookingNumber: BookingNumber; bookingNumber: BookingNumber;
userId: string; userId: string;
organizationId: string; organizationId: string;
rateQuoteId: string; rateQuoteId: string;
status: BookingStatus; status: BookingStatus;
shipper: Party; shipper: Party;
consignee: Party; consignee: Party;
cargoDescription: string; cargoDescription: string;
containers: BookingContainer[]; containers: BookingContainer[];
specialInstructions?: string; specialInstructions?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export class Booking { export class Booking {
private readonly props: BookingProps; private readonly props: BookingProps;
private constructor(props: BookingProps) { private constructor(props: BookingProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new Booking * Factory method to create a new Booking
*/ */
static create( static create(
props: Omit<BookingProps, 'bookingNumber' | 'status' | 'createdAt' | 'updatedAt'> & { props: Omit<BookingProps, 'bookingNumber' | 'status' | 'createdAt' | 'updatedAt'> & {
id: string; id: string;
bookingNumber?: BookingNumber; bookingNumber?: BookingNumber;
status?: BookingStatus; status?: BookingStatus;
} }
): Booking { ): Booking {
const now = new Date(); const now = new Date();
const bookingProps: BookingProps = { const bookingProps: BookingProps = {
...props, ...props,
bookingNumber: props.bookingNumber || BookingNumber.generate(), bookingNumber: props.bookingNumber || BookingNumber.generate(),
status: props.status || BookingStatus.create('draft'), status: props.status || BookingStatus.create('draft'),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
// Validate business rules // Validate business rules
Booking.validate(bookingProps); Booking.validate(bookingProps);
return new Booking(bookingProps); return new Booking(bookingProps);
} }
/** /**
* Validate business rules * Validate business rules
*/ */
private static validate(props: BookingProps): void { private static validate(props: BookingProps): void {
if (!props.userId) { if (!props.userId) {
throw new Error('User ID is required'); throw new Error('User ID is required');
} }
if (!props.organizationId) { if (!props.organizationId) {
throw new Error('Organization ID is required'); throw new Error('Organization ID is required');
} }
if (!props.rateQuoteId) { if (!props.rateQuoteId) {
throw new Error('Rate quote ID is required'); throw new Error('Rate quote ID is required');
} }
if (!props.shipper || !props.shipper.name) { if (!props.shipper || !props.shipper.name) {
throw new Error('Shipper information is required'); throw new Error('Shipper information is required');
} }
if (!props.consignee || !props.consignee.name) { if (!props.consignee || !props.consignee.name) {
throw new Error('Consignee information is required'); throw new Error('Consignee information is required');
} }
if (!props.cargoDescription || props.cargoDescription.length < 10) { if (!props.cargoDescription || props.cargoDescription.length < 10) {
throw new Error('Cargo description must be at least 10 characters'); throw new Error('Cargo description must be at least 10 characters');
} }
} }
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
} }
get bookingNumber(): BookingNumber { get bookingNumber(): BookingNumber {
return this.props.bookingNumber; return this.props.bookingNumber;
} }
get userId(): string { get userId(): string {
return this.props.userId; return this.props.userId;
} }
get organizationId(): string { get organizationId(): string {
return this.props.organizationId; return this.props.organizationId;
} }
get rateQuoteId(): string { get rateQuoteId(): string {
return this.props.rateQuoteId; return this.props.rateQuoteId;
} }
get status(): BookingStatus { get status(): BookingStatus {
return this.props.status; return this.props.status;
} }
get shipper(): Party { get shipper(): Party {
return { ...this.props.shipper }; return { ...this.props.shipper };
} }
get consignee(): Party { get consignee(): Party {
return { ...this.props.consignee }; return { ...this.props.consignee };
} }
get cargoDescription(): string { get cargoDescription(): string {
return this.props.cargoDescription; return this.props.cargoDescription;
} }
get containers(): BookingContainer[] { get containers(): BookingContainer[] {
return [...this.props.containers]; return [...this.props.containers];
} }
get specialInstructions(): string | undefined { get specialInstructions(): string | undefined {
return this.props.specialInstructions; return this.props.specialInstructions;
} }
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; return this.props.createdAt;
} }
get updatedAt(): Date { get updatedAt(): Date {
return this.props.updatedAt; return this.props.updatedAt;
} }
/** /**
* Update booking status * Update booking status
*/ */
updateStatus(newStatus: BookingStatus): Booking { updateStatus(newStatus: BookingStatus): Booking {
if (!this.status.canTransitionTo(newStatus)) { if (!this.status.canTransitionTo(newStatus)) {
throw new Error( throw new Error(`Cannot transition from ${this.status.value} to ${newStatus.value}`);
`Cannot transition from ${this.status.value} to ${newStatus.value}` }
);
} return new Booking({
...this.props,
return new Booking({ status: newStatus,
...this.props, updatedAt: new Date(),
status: newStatus, });
updatedAt: new Date(), }
});
} /**
* Add container to booking
/** */
* Add container to booking addContainer(container: BookingContainer): Booking {
*/ if (!this.status.canBeModified()) {
addContainer(container: BookingContainer): Booking { throw new Error('Cannot modify containers after booking is confirmed');
if (!this.status.canBeModified()) { }
throw new Error('Cannot modify containers after booking is confirmed');
} return new Booking({
...this.props,
return new Booking({ containers: [...this.props.containers, container],
...this.props, updatedAt: new Date(),
containers: [...this.props.containers, container], });
updatedAt: new Date(), }
});
} /**
* Update container information
/** */
* Update container information updateContainer(containerId: string, updates: Partial<BookingContainer>): Booking {
*/ if (!this.status.canBeModified()) {
updateContainer(containerId: string, updates: Partial<BookingContainer>): Booking { throw new Error('Cannot modify containers after booking is confirmed');
if (!this.status.canBeModified()) { }
throw new Error('Cannot modify containers after booking is confirmed');
} const containerIndex = this.props.containers.findIndex(c => c.id === containerId);
if (containerIndex === -1) {
const containerIndex = this.props.containers.findIndex((c) => c.id === containerId); throw new Error(`Container ${containerId} not found`);
if (containerIndex === -1) { }
throw new Error(`Container ${containerId} not found`);
} const updatedContainers = [...this.props.containers];
updatedContainers[containerIndex] = {
const updatedContainers = [...this.props.containers]; ...updatedContainers[containerIndex],
updatedContainers[containerIndex] = { ...updates,
...updatedContainers[containerIndex], };
...updates,
}; return new Booking({
...this.props,
return new Booking({ containers: updatedContainers,
...this.props, updatedAt: new Date(),
containers: updatedContainers, });
updatedAt: new Date(), }
});
} /**
* Remove container from booking
/** */
* Remove container from booking removeContainer(containerId: string): Booking {
*/ if (!this.status.canBeModified()) {
removeContainer(containerId: string): Booking { throw new Error('Cannot modify containers after booking is confirmed');
if (!this.status.canBeModified()) { }
throw new Error('Cannot modify containers after booking is confirmed');
} return new Booking({
...this.props,
return new Booking({ containers: this.props.containers.filter(c => c.id !== containerId),
...this.props, updatedAt: new Date(),
containers: this.props.containers.filter((c) => c.id !== containerId), });
updatedAt: new Date(), }
});
} /**
* Update cargo description
/** */
* Update cargo description updateCargoDescription(description: string): Booking {
*/ if (!this.status.canBeModified()) {
updateCargoDescription(description: string): Booking { throw new Error('Cannot modify cargo description after booking is confirmed');
if (!this.status.canBeModified()) { }
throw new Error('Cannot modify cargo description after booking is confirmed');
} if (description.length < 10) {
throw new Error('Cargo description must be at least 10 characters');
if (description.length < 10) { }
throw new Error('Cargo description must be at least 10 characters');
} return new Booking({
...this.props,
return new Booking({ cargoDescription: description,
...this.props, updatedAt: new Date(),
cargoDescription: description, });
updatedAt: new Date(), }
});
} /**
* Update special instructions
/** */
* Update special instructions updateSpecialInstructions(instructions: string): Booking {
*/ return new Booking({
updateSpecialInstructions(instructions: string): Booking { ...this.props,
return new Booking({ specialInstructions: instructions,
...this.props, updatedAt: new Date(),
specialInstructions: instructions, });
updatedAt: new Date(), }
});
} /**
* Check if booking can be cancelled
/** */
* Check if booking can be cancelled canBeCancelled(): boolean {
*/ return !this.status.isFinal();
canBeCancelled(): boolean { }
return !this.status.isFinal();
} /**
* Cancel booking
/** */
* Cancel booking cancel(): Booking {
*/ if (!this.canBeCancelled()) {
cancel(): Booking { throw new Error('Cannot cancel booking in final state');
if (!this.canBeCancelled()) { }
throw new Error('Cannot cancel booking in final state');
} return this.updateStatus(BookingStatus.create('cancelled'));
}
return this.updateStatus(BookingStatus.create('cancelled'));
} /**
* Equality check
/** */
* Equality check equals(other: Booking): boolean {
*/ return this.id === other.id;
equals(other: Booking): boolean { }
return this.id === other.id; }
}
}

View File

@ -1,182 +1,184 @@
/** /**
* Carrier Entity * Carrier Entity
* *
* Represents a shipping carrier (e.g., Maersk, MSC, CMA CGM) * Represents a shipping carrier (e.g., Maersk, MSC, CMA CGM)
* *
* Business Rules: * Business Rules:
* - Carrier code must be unique * - Carrier code must be unique
* - SCAC code must be valid (4 uppercase letters) * - SCAC code must be valid (4 uppercase letters)
* - API configuration is optional (for carriers with API integration) * - API configuration is optional (for carriers with API integration)
*/ */
export interface CarrierApiConfig { export interface CarrierApiConfig {
baseUrl: string; baseUrl: string;
apiKey?: string; apiKey?: string;
clientId?: string; clientId?: string;
clientSecret?: string; clientSecret?: string;
timeout: number; // in milliseconds timeout: number; // in milliseconds
retryAttempts: number; retryAttempts: number;
circuitBreakerThreshold: number; circuitBreakerThreshold: number;
} }
export interface CarrierProps { export interface CarrierProps {
id: string; id: string;
name: string; name: string;
code: string; // Unique carrier code (e.g., 'MAERSK', 'MSC') code: string; // Unique carrier code (e.g., 'MAERSK', 'MSC')
scac: string; // Standard Carrier Alpha Code scac: string; // Standard Carrier Alpha Code
logoUrl?: string; logoUrl?: string;
website?: string; website?: string;
apiConfig?: CarrierApiConfig; apiConfig?: CarrierApiConfig;
isActive: boolean; isActive: boolean;
supportsApi: boolean; // True if carrier has API integration supportsApi: boolean; // True if carrier has API integration
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export class Carrier { export class Carrier {
private readonly props: CarrierProps; private readonly props: CarrierProps;
private constructor(props: CarrierProps) { private constructor(props: CarrierProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new Carrier * Factory method to create a new Carrier
*/ */
static create(props: Omit<CarrierProps, 'createdAt' | 'updatedAt'>): Carrier { static create(props: Omit<CarrierProps, 'createdAt' | 'updatedAt'>): Carrier {
const now = new Date(); const now = new Date();
// Validate SCAC code // Validate SCAC code
if (!Carrier.isValidSCAC(props.scac)) { if (!Carrier.isValidSCAC(props.scac)) {
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
} }
// Validate carrier code // Validate carrier code
if (!Carrier.isValidCarrierCode(props.code)) { if (!Carrier.isValidCarrierCode(props.code)) {
throw new Error('Invalid carrier code format. Must be uppercase letters and underscores only.'); throw new Error(
} 'Invalid carrier code format. Must be uppercase letters and underscores only.'
);
// Validate API config if carrier supports API }
if (props.supportsApi && !props.apiConfig) {
throw new Error('Carriers with API support must have API configuration.'); // Validate API config if carrier supports API
} if (props.supportsApi && !props.apiConfig) {
throw new Error('Carriers with API support must have API configuration.');
return new Carrier({ }
...props,
createdAt: now, return new Carrier({
updatedAt: now, ...props,
}); createdAt: now,
} updatedAt: now,
});
/** }
* Factory method to reconstitute from persistence
*/ /**
static fromPersistence(props: CarrierProps): Carrier { * Factory method to reconstitute from persistence
return new Carrier(props); */
} static fromPersistence(props: CarrierProps): Carrier {
return new Carrier(props);
/** }
* Validate SCAC code format
*/ /**
private static isValidSCAC(scac: string): boolean { * Validate SCAC code format
const scacPattern = /^[A-Z]{4}$/; */
return scacPattern.test(scac); private static isValidSCAC(scac: string): boolean {
} const scacPattern = /^[A-Z]{4}$/;
return scacPattern.test(scac);
/** }
* Validate carrier code format
*/ /**
private static isValidCarrierCode(code: string): boolean { * Validate carrier code format
const codePattern = /^[A-Z_]+$/; */
return codePattern.test(code); private static isValidCarrierCode(code: string): boolean {
} const codePattern = /^[A-Z_]+$/;
return codePattern.test(code);
// Getters }
get id(): string {
return this.props.id; // Getters
} get id(): string {
return this.props.id;
get name(): string { }
return this.props.name;
} get name(): string {
return this.props.name;
get code(): string { }
return this.props.code;
} get code(): string {
return this.props.code;
get scac(): string { }
return this.props.scac;
} get scac(): string {
return this.props.scac;
get logoUrl(): string | undefined { }
return this.props.logoUrl;
} get logoUrl(): string | undefined {
return this.props.logoUrl;
get website(): string | undefined { }
return this.props.website;
} get website(): string | undefined {
return this.props.website;
get apiConfig(): CarrierApiConfig | undefined { }
return this.props.apiConfig ? { ...this.props.apiConfig } : undefined;
} get apiConfig(): CarrierApiConfig | undefined {
return this.props.apiConfig ? { ...this.props.apiConfig } : undefined;
get isActive(): boolean { }
return this.props.isActive;
} get isActive(): boolean {
return this.props.isActive;
get supportsApi(): boolean { }
return this.props.supportsApi;
} get supportsApi(): boolean {
return this.props.supportsApi;
get createdAt(): Date { }
return this.props.createdAt;
} get createdAt(): Date {
return this.props.createdAt;
get updatedAt(): Date { }
return this.props.updatedAt;
} get updatedAt(): Date {
return this.props.updatedAt;
// Business methods }
hasApiIntegration(): boolean {
return this.props.supportsApi && !!this.props.apiConfig; // Business methods
} hasApiIntegration(): boolean {
return this.props.supportsApi && !!this.props.apiConfig;
updateApiConfig(apiConfig: CarrierApiConfig): void { }
if (!this.props.supportsApi) {
throw new Error('Cannot update API config for carrier without API support.'); updateApiConfig(apiConfig: CarrierApiConfig): void {
} if (!this.props.supportsApi) {
throw new Error('Cannot update API config for carrier without API support.');
this.props.apiConfig = { ...apiConfig }; }
this.props.updatedAt = new Date();
} this.props.apiConfig = { ...apiConfig };
this.props.updatedAt = new Date();
updateLogoUrl(logoUrl: string): void { }
this.props.logoUrl = logoUrl;
this.props.updatedAt = new Date(); updateLogoUrl(logoUrl: string): void {
} this.props.logoUrl = logoUrl;
this.props.updatedAt = new Date();
updateWebsite(website: string): void { }
this.props.website = website;
this.props.updatedAt = new Date(); updateWebsite(website: string): void {
} this.props.website = website;
this.props.updatedAt = new Date();
deactivate(): void { }
this.props.isActive = false;
this.props.updatedAt = new Date(); deactivate(): void {
} this.props.isActive = false;
this.props.updatedAt = new Date();
activate(): void { }
this.props.isActive = true;
this.props.updatedAt = new Date(); activate(): void {
} this.props.isActive = true;
this.props.updatedAt = new Date();
/** }
* Convert to plain object for persistence
*/ /**
toObject(): CarrierProps { * Convert to plain object for persistence
return { */
...this.props, toObject(): CarrierProps {
apiConfig: this.props.apiConfig ? { ...this.props.apiConfig } : undefined, return {
}; ...this.props,
} apiConfig: this.props.apiConfig ? { ...this.props.apiConfig } : undefined,
} };
}
}

View File

@ -1,297 +1,300 @@
/** /**
* Container Entity * Container Entity
* *
* Represents a shipping container in a booking * Represents a shipping container in a booking
* *
* Business Rules: * Business Rules:
* - Container number must follow ISO 6346 format (when provided) * - Container number must follow ISO 6346 format (when provided)
* - VGM (Verified Gross Mass) is required for export shipments * - VGM (Verified Gross Mass) is required for export shipments
* - Temperature must be within valid range for reefer containers * - Temperature must be within valid range for reefer containers
*/ */
export enum ContainerCategory { export enum ContainerCategory {
DRY = 'DRY', DRY = 'DRY',
REEFER = 'REEFER', REEFER = 'REEFER',
OPEN_TOP = 'OPEN_TOP', OPEN_TOP = 'OPEN_TOP',
FLAT_RACK = 'FLAT_RACK', FLAT_RACK = 'FLAT_RACK',
TANK = 'TANK', TANK = 'TANK',
} }
export enum ContainerSize { export enum ContainerSize {
TWENTY = '20', TWENTY = '20',
FORTY = '40', FORTY = '40',
FORTY_FIVE = '45', FORTY_FIVE = '45',
} }
export enum ContainerHeight { export enum ContainerHeight {
STANDARD = 'STANDARD', STANDARD = 'STANDARD',
HIGH_CUBE = 'HIGH_CUBE', HIGH_CUBE = 'HIGH_CUBE',
} }
export interface ContainerProps { export interface ContainerProps {
id: string; id: string;
bookingId?: string; // Optional until container is assigned to a booking bookingId?: string; // Optional until container is assigned to a booking
type: string; // e.g., '20DRY', '40HC', '40REEFER' type: string; // e.g., '20DRY', '40HC', '40REEFER'
category: ContainerCategory; category: ContainerCategory;
size: ContainerSize; size: ContainerSize;
height: ContainerHeight; height: ContainerHeight;
containerNumber?: string; // ISO 6346 format (assigned by carrier) containerNumber?: string; // ISO 6346 format (assigned by carrier)
sealNumber?: string; sealNumber?: string;
vgm?: number; // Verified Gross Mass in kg vgm?: number; // Verified Gross Mass in kg
tareWeight?: number; // Empty container weight in kg tareWeight?: number; // Empty container weight in kg
maxGrossWeight?: number; // Maximum gross weight in kg maxGrossWeight?: number; // Maximum gross weight in kg
temperature?: number; // For reefer containers (°C) temperature?: number; // For reefer containers (°C)
humidity?: number; // For reefer containers (%) humidity?: number; // For reefer containers (%)
ventilation?: string; // For reefer containers ventilation?: string; // For reefer containers
isHazmat: boolean; isHazmat: boolean;
imoClass?: string; // IMO hazmat class (if hazmat) imoClass?: string; // IMO hazmat class (if hazmat)
cargoDescription?: string; cargoDescription?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export class Container { export class Container {
private readonly props: ContainerProps; private readonly props: ContainerProps;
private constructor(props: ContainerProps) { private constructor(props: ContainerProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new Container * Factory method to create a new Container
*/ */
static create(props: Omit<ContainerProps, 'createdAt' | 'updatedAt'>): Container { static create(props: Omit<ContainerProps, 'createdAt' | 'updatedAt'>): Container {
const now = new Date(); const now = new Date();
// Validate container number format if provided // Validate container number format if provided
if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) { if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) {
throw new Error('Invalid container number format. Must follow ISO 6346 standard.'); throw new Error('Invalid container number format. Must follow ISO 6346 standard.');
} }
// Validate VGM if provided // Validate VGM if provided
if (props.vgm !== undefined && props.vgm <= 0) { if (props.vgm !== undefined && props.vgm <= 0) {
throw new Error('VGM must be positive.'); throw new Error('VGM must be positive.');
} }
// Validate temperature for reefer containers // Validate temperature for reefer containers
if (props.category === ContainerCategory.REEFER) { if (props.category === ContainerCategory.REEFER) {
if (props.temperature === undefined) { if (props.temperature === undefined) {
throw new Error('Temperature is required for reefer containers.'); throw new Error('Temperature is required for reefer containers.');
} }
if (props.temperature < -40 || props.temperature > 40) { if (props.temperature < -40 || props.temperature > 40) {
throw new Error('Temperature must be between -40°C and +40°C.'); throw new Error('Temperature must be between -40°C and +40°C.');
} }
} }
// Validate hazmat // Validate hazmat
if (props.isHazmat && !props.imoClass) { if (props.isHazmat && !props.imoClass) {
throw new Error('IMO class is required for hazmat containers.'); throw new Error('IMO class is required for hazmat containers.');
} }
return new Container({ return new Container({
...props, ...props,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
/** /**
* Factory method to reconstitute from persistence * Factory method to reconstitute from persistence
*/ */
static fromPersistence(props: ContainerProps): Container { static fromPersistence(props: ContainerProps): Container {
return new Container(props); return new Container(props);
} }
/** /**
* Validate ISO 6346 container number format * Validate ISO 6346 container number format
* Format: 4 letters (owner code) + 6 digits + 1 check digit * Format: 4 letters (owner code) + 6 digits + 1 check digit
* Example: MSCU1234567 * Example: MSCU1234567
*/ */
private static isValidContainerNumber(containerNumber: string): boolean { private static isValidContainerNumber(containerNumber: string): boolean {
const pattern = /^[A-Z]{4}\d{7}$/; const pattern = /^[A-Z]{4}\d{7}$/;
if (!pattern.test(containerNumber)) { if (!pattern.test(containerNumber)) {
return false; return false;
} }
// Validate check digit (ISO 6346 algorithm) // Validate check digit (ISO 6346 algorithm)
const ownerCode = containerNumber.substring(0, 4); const ownerCode = containerNumber.substring(0, 4);
const serialNumber = containerNumber.substring(4, 10); const serialNumber = containerNumber.substring(4, 10);
const checkDigit = parseInt(containerNumber.substring(10, 11), 10); const checkDigit = parseInt(containerNumber.substring(10, 11), 10);
// Convert letters to numbers (A=10, B=12, C=13, ..., Z=38) // Convert letters to numbers (A=10, B=12, C=13, ..., Z=38)
const letterValues: { [key: string]: number } = {}; const letterValues: { [key: string]: number } = {};
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => { 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => {
letterValues[letter] = 10 + index + Math.floor(index / 2); letterValues[letter] = 10 + index + Math.floor(index / 2);
}); });
// Calculate sum // Calculate sum
let sum = 0; let sum = 0;
for (let i = 0; i < ownerCode.length; i++) { for (let i = 0; i < ownerCode.length; i++) {
sum += letterValues[ownerCode[i]] * Math.pow(2, i); sum += letterValues[ownerCode[i]] * Math.pow(2, i);
} }
for (let i = 0; i < serialNumber.length; i++) { for (let i = 0; i < serialNumber.length; i++) {
sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4); sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4);
} }
// Check digit = sum % 11 (if 10, use 0) // Check digit = sum % 11 (if 10, use 0)
const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11; const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11;
return calculatedCheckDigit === checkDigit; return calculatedCheckDigit === checkDigit;
} }
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
} }
get bookingId(): string | undefined { get bookingId(): string | undefined {
return this.props.bookingId; return this.props.bookingId;
} }
get type(): string { get type(): string {
return this.props.type; return this.props.type;
} }
get category(): ContainerCategory { get category(): ContainerCategory {
return this.props.category; return this.props.category;
} }
get size(): ContainerSize { get size(): ContainerSize {
return this.props.size; return this.props.size;
} }
get height(): ContainerHeight { get height(): ContainerHeight {
return this.props.height; return this.props.height;
} }
get containerNumber(): string | undefined { get containerNumber(): string | undefined {
return this.props.containerNumber; return this.props.containerNumber;
} }
get sealNumber(): string | undefined { get sealNumber(): string | undefined {
return this.props.sealNumber; return this.props.sealNumber;
} }
get vgm(): number | undefined { get vgm(): number | undefined {
return this.props.vgm; return this.props.vgm;
} }
get tareWeight(): number | undefined { get tareWeight(): number | undefined {
return this.props.tareWeight; return this.props.tareWeight;
} }
get maxGrossWeight(): number | undefined { get maxGrossWeight(): number | undefined {
return this.props.maxGrossWeight; return this.props.maxGrossWeight;
} }
get temperature(): number | undefined { get temperature(): number | undefined {
return this.props.temperature; return this.props.temperature;
} }
get humidity(): number | undefined { get humidity(): number | undefined {
return this.props.humidity; return this.props.humidity;
} }
get ventilation(): string | undefined { get ventilation(): string | undefined {
return this.props.ventilation; return this.props.ventilation;
} }
get isHazmat(): boolean { get isHazmat(): boolean {
return this.props.isHazmat; return this.props.isHazmat;
} }
get imoClass(): string | undefined { get imoClass(): string | undefined {
return this.props.imoClass; return this.props.imoClass;
} }
get cargoDescription(): string | undefined { get cargoDescription(): string | undefined {
return this.props.cargoDescription; return this.props.cargoDescription;
} }
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; return this.props.createdAt;
} }
get updatedAt(): Date { get updatedAt(): Date {
return this.props.updatedAt; return this.props.updatedAt;
} }
// Business methods // Business methods
isReefer(): boolean { isReefer(): boolean {
return this.props.category === ContainerCategory.REEFER; return this.props.category === ContainerCategory.REEFER;
} }
isDry(): boolean { isDry(): boolean {
return this.props.category === ContainerCategory.DRY; return this.props.category === ContainerCategory.DRY;
} }
isHighCube(): boolean { isHighCube(): boolean {
return this.props.height === ContainerHeight.HIGH_CUBE; return this.props.height === ContainerHeight.HIGH_CUBE;
} }
getTEU(): number { getTEU(): number {
// Twenty-foot Equivalent Unit // Twenty-foot Equivalent Unit
if (this.props.size === ContainerSize.TWENTY) { if (this.props.size === ContainerSize.TWENTY) {
return 1; return 1;
} else if (this.props.size === ContainerSize.FORTY || this.props.size === ContainerSize.FORTY_FIVE) { } else if (
return 2; this.props.size === ContainerSize.FORTY ||
} this.props.size === ContainerSize.FORTY_FIVE
return 0; ) {
} return 2;
}
getPayload(): number | undefined { return 0;
if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) { }
return this.props.vgm - this.props.tareWeight;
} getPayload(): number | undefined {
return undefined; if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) {
} return this.props.vgm - this.props.tareWeight;
}
assignContainerNumber(containerNumber: string): void { return undefined;
if (!Container.isValidContainerNumber(containerNumber)) { }
throw new Error('Invalid container number format.');
} assignContainerNumber(containerNumber: string): void {
this.props.containerNumber = containerNumber; if (!Container.isValidContainerNumber(containerNumber)) {
this.props.updatedAt = new Date(); throw new Error('Invalid container number format.');
} }
this.props.containerNumber = containerNumber;
assignSealNumber(sealNumber: string): void { this.props.updatedAt = new Date();
this.props.sealNumber = sealNumber; }
this.props.updatedAt = new Date();
} assignSealNumber(sealNumber: string): void {
this.props.sealNumber = sealNumber;
setVGM(vgm: number): void { this.props.updatedAt = new Date();
if (vgm <= 0) { }
throw new Error('VGM must be positive.');
} setVGM(vgm: number): void {
this.props.vgm = vgm; if (vgm <= 0) {
this.props.updatedAt = new Date(); throw new Error('VGM must be positive.');
} }
this.props.vgm = vgm;
setTemperature(temperature: number): void { this.props.updatedAt = new Date();
if (!this.isReefer()) { }
throw new Error('Cannot set temperature for non-reefer container.');
} setTemperature(temperature: number): void {
if (temperature < -40 || temperature > 40) { if (!this.isReefer()) {
throw new Error('Temperature must be between -40°C and +40°C.'); throw new Error('Cannot set temperature for non-reefer container.');
} }
this.props.temperature = temperature; if (temperature < -40 || temperature > 40) {
this.props.updatedAt = new Date(); throw new Error('Temperature must be between -40°C and +40°C.');
} }
this.props.temperature = temperature;
setCargoDescription(description: string): void { this.props.updatedAt = new Date();
this.props.cargoDescription = description; }
this.props.updatedAt = new Date();
} setCargoDescription(description: string): void {
this.props.cargoDescription = description;
assignToBooking(bookingId: string): void { this.props.updatedAt = new Date();
this.props.bookingId = bookingId; }
this.props.updatedAt = new Date();
} assignToBooking(bookingId: string): void {
this.props.bookingId = bookingId;
/** this.props.updatedAt = new Date();
* Convert to plain object for persistence }
*/
toObject(): ContainerProps { /**
return { ...this.props }; * Convert to plain object for persistence
} */
} toObject(): ContainerProps {
return { ...this.props };
}
}

View File

@ -1,245 +1,239 @@
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo'; import { Money } from '../value-objects/money.vo';
import { Volume } from '../value-objects/volume.vo'; import { Volume } from '../value-objects/volume.vo';
import { Surcharge, SurchargeCollection } from '../value-objects/surcharge.vo'; import { Surcharge, SurchargeCollection } from '../value-objects/surcharge.vo';
import { DateRange } from '../value-objects/date-range.vo'; import { DateRange } from '../value-objects/date-range.vo';
/** /**
* Volume Range - Valid range for CBM * Volume Range - Valid range for CBM
*/ */
export interface VolumeRange { export interface VolumeRange {
minCBM: number; minCBM: number;
maxCBM: number; maxCBM: number;
} }
/** /**
* Weight Range - Valid range for KG * Weight Range - Valid range for KG
*/ */
export interface WeightRange { export interface WeightRange {
minKG: number; minKG: number;
maxKG: number; maxKG: number;
} }
/** /**
* Rate Pricing - Pricing structure for CSV rates * Rate Pricing - Pricing structure for CSV rates
*/ */
export interface RatePricing { export interface RatePricing {
pricePerCBM: number; pricePerCBM: number;
pricePerKG: number; pricePerKG: number;
basePriceUSD: Money; basePriceUSD: Money;
basePriceEUR: Money; basePriceEUR: Money;
} }
/** /**
* CSV Rate Entity * CSV Rate Entity
* *
* Represents a shipping rate loaded from CSV file. * Represents a shipping rate loaded from CSV file.
* Contains all information needed to calculate freight costs. * Contains all information needed to calculate freight costs.
* *
* Business Rules: * Business Rules:
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges * - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
* - Rate must be valid (within validity period) to be used * - Rate must be valid (within validity period) to be used
* - Volume and weight must be within specified ranges * - Volume and weight must be within specified ranges
*/ */
export class CsvRate { export class CsvRate {
constructor( constructor(
public readonly companyName: string, public readonly companyName: string,
public readonly origin: PortCode, public readonly origin: PortCode,
public readonly destination: PortCode, public readonly destination: PortCode,
public readonly containerType: ContainerType, public readonly containerType: ContainerType,
public readonly volumeRange: VolumeRange, public readonly volumeRange: VolumeRange,
public readonly weightRange: WeightRange, public readonly weightRange: WeightRange,
public readonly palletCount: number, public readonly palletCount: number,
public readonly pricing: RatePricing, public readonly pricing: RatePricing,
public readonly currency: string, // Primary currency (USD or EUR) public readonly currency: string, // Primary currency (USD or EUR)
public readonly surcharges: SurchargeCollection, public readonly surcharges: SurchargeCollection,
public readonly transitDays: number, public readonly transitDays: number,
public readonly validity: DateRange, public readonly validity: DateRange
) { ) {
this.validate(); this.validate();
} }
private validate(): void { private validate(): void {
if (!this.companyName || this.companyName.trim().length === 0) { if (!this.companyName || this.companyName.trim().length === 0) {
throw new Error('Company name is required'); throw new Error('Company name is required');
} }
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) { if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
throw new Error('Volume range cannot be negative'); throw new Error('Volume range cannot be negative');
} }
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) { if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
throw new Error('Min volume cannot be greater than max volume'); throw new Error('Min volume cannot be greater than max volume');
} }
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) { if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
throw new Error('Weight range cannot be negative'); throw new Error('Weight range cannot be negative');
} }
if (this.weightRange.minKG > this.weightRange.maxKG) { if (this.weightRange.minKG > this.weightRange.maxKG) {
throw new Error('Min weight cannot be greater than max weight'); throw new Error('Min weight cannot be greater than max weight');
} }
if (this.palletCount < 0) { if (this.palletCount < 0) {
throw new Error('Pallet count cannot be negative'); throw new Error('Pallet count cannot be negative');
} }
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) { if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
throw new Error('Prices cannot be negative'); throw new Error('Prices cannot be negative');
} }
if (this.transitDays <= 0) { if (this.transitDays <= 0) {
throw new Error('Transit days must be positive'); throw new Error('Transit days must be positive');
} }
if (this.currency !== 'USD' && this.currency !== 'EUR') { if (this.currency !== 'USD' && this.currency !== 'EUR') {
throw new Error('Currency must be USD or EUR'); throw new Error('Currency must be USD or EUR');
} }
} }
/** /**
* Calculate total price for given volume and weight * Calculate total price for given volume and weight
* *
* Business Logic: * Business Logic:
* 1. Calculate volume-based price: volumeCBM * pricePerCBM * 1. Calculate volume-based price: volumeCBM * pricePerCBM
* 2. Calculate weight-based price: weightKG * pricePerKG * 2. Calculate weight-based price: weightKG * pricePerKG
* 3. Take the maximum (freight class rule) * 3. Take the maximum (freight class rule)
* 4. Add surcharges * 4. Add surcharges
*/ */
calculatePrice(volume: Volume): Money { calculatePrice(volume: Volume): Money {
// Freight class rule: max(volume price, weight price) // Freight class rule: max(volume price, weight price)
const freightPrice = volume.calculateFreightPrice( const freightPrice = volume.calculateFreightPrice(
this.pricing.pricePerCBM, this.pricing.pricePerCBM,
this.pricing.pricePerKG, this.pricing.pricePerKG
); );
// Create Money object in the rate's currency // Create Money object in the rate's currency
let totalPrice = Money.create(freightPrice, this.currency); let totalPrice = Money.create(freightPrice, this.currency);
// Add surcharges in the same currency // Add surcharges in the same currency
const surchargeTotal = this.surcharges.getTotalAmount(this.currency); const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
totalPrice = totalPrice.add(surchargeTotal); totalPrice = totalPrice.add(surchargeTotal);
return totalPrice; return totalPrice;
} }
/** /**
* Get price in specific currency (USD or EUR) * Get price in specific currency (USD or EUR)
*/ */
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money { getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
const price = this.calculatePrice(volume); const price = this.calculatePrice(volume);
// If already in target currency, return as-is // If already in target currency, return as-is
if (price.getCurrency() === targetCurrency) { if (price.getCurrency() === targetCurrency) {
return price; return price;
} }
// Otherwise, use the pre-calculated base price in target currency // Otherwise, use the pre-calculated base price in target currency
// and recalculate proportionally // and recalculate proportionally
const basePriceInPrimaryCurrency = const basePriceInPrimaryCurrency =
this.currency === 'USD' this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
? this.pricing.basePriceUSD
: this.pricing.basePriceEUR; const basePriceInTargetCurrency =
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
const basePriceInTargetCurrency =
targetCurrency === 'USD' // Calculate conversion ratio
? this.pricing.basePriceUSD const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
: this.pricing.basePriceEUR;
// Apply ratio to calculated price
// Calculate conversion ratio const convertedAmount = price.getAmount() * ratio;
const ratio = return Money.create(convertedAmount, targetCurrency);
basePriceInTargetCurrency.getAmount() / }
basePriceInPrimaryCurrency.getAmount();
/**
// Apply ratio to calculated price * Check if rate is valid for a specific date
const convertedAmount = price.getAmount() * ratio; */
return Money.create(convertedAmount, targetCurrency); isValidForDate(date: Date): boolean {
} return this.validity.contains(date);
}
/**
* Check if rate is valid for a specific date /**
*/ * Check if rate is currently valid (today is within validity period)
isValidForDate(date: Date): boolean { */
return this.validity.contains(date); isCurrentlyValid(): boolean {
} return this.validity.isCurrentRange();
}
/**
* Check if rate is currently valid (today is within validity period) /**
*/ * Check if volume and weight match this rate's range
isCurrentlyValid(): boolean { */
return this.validity.isCurrentRange(); matchesVolume(volume: Volume): boolean {
} return volume.isWithinRange(
this.volumeRange.minCBM,
/** this.volumeRange.maxCBM,
* Check if volume and weight match this rate's range this.weightRange.minKG,
*/ this.weightRange.maxKG
matchesVolume(volume: Volume): boolean { );
return volume.isWithinRange( }
this.volumeRange.minCBM,
this.volumeRange.maxCBM, /**
this.weightRange.minKG, * Check if pallet count matches
this.weightRange.maxKG, * 0 means "any pallet count" (flexible)
); * Otherwise must match exactly or be within range
} */
matchesPalletCount(palletCount: number): boolean {
/** // If rate has 0 pallets, it's flexible
* Check if pallet count matches if (this.palletCount === 0) {
* 0 means "any pallet count" (flexible) return true;
* Otherwise must match exactly or be within range }
*/ // Otherwise must match exactly
matchesPalletCount(palletCount: number): boolean { return this.palletCount === palletCount;
// If rate has 0 pallets, it's flexible }
if (this.palletCount === 0) {
return true; /**
} * Check if rate matches a specific route
// Otherwise must match exactly */
return this.palletCount === palletCount; matchesRoute(origin: PortCode, destination: PortCode): boolean {
} return this.origin.equals(origin) && this.destination.equals(destination);
}
/**
* Check if rate matches a specific route /**
*/ * Check if rate has separate surcharges
matchesRoute(origin: PortCode, destination: PortCode): boolean { */
return this.origin.equals(origin) && this.destination.equals(destination); hasSurcharges(): boolean {
} return !this.surcharges.isEmpty();
}
/**
* Check if rate has separate surcharges /**
*/ * Get surcharge details as formatted string
hasSurcharges(): boolean { */
return !this.surcharges.isEmpty(); getSurchargeDetails(): string {
} return this.surcharges.getDetails();
}
/**
* Get surcharge details as formatted string /**
*/ * Check if this is an "all-in" rate (no separate surcharges)
getSurchargeDetails(): string { */
return this.surcharges.getDetails(); isAllInPrice(): boolean {
} return this.surcharges.isEmpty();
}
/**
* Check if this is an "all-in" rate (no separate surcharges) /**
*/ * Get route description
isAllInPrice(): boolean { */
return this.surcharges.isEmpty(); getRouteDescription(): string {
} return `${this.origin.getValue()}${this.destination.getValue()}`;
}
/**
* Get route description /**
*/ * Get company and route summary
getRouteDescription(): string { */
return `${this.origin.getValue()}${this.destination.getValue()}`; getSummary(): string {
} return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
}
/**
* Get company and route summary toString(): string {
*/ return this.getSummary();
getSummary(): string { }
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`; }
}
toString(): string {
return this.getSummary();
}
}

View File

@ -1,13 +1,13 @@
/** /**
* Domain Entities Barrel Export * Domain Entities Barrel Export
* *
* All core domain entities for the Xpeditis platform * All core domain entities for the Xpeditis platform
*/ */
export * from './organization.entity'; export * from './organization.entity';
export * from './user.entity'; export * from './user.entity';
export * from './carrier.entity'; export * from './carrier.entity';
export * from './port.entity'; export * from './port.entity';
export * from './rate-quote.entity'; export * from './rate-quote.entity';
export * from './container.entity'; export * from './container.entity';
export * from './booking.entity'; export * from './booking.entity';

View File

@ -42,7 +42,7 @@ export class Notification {
private constructor(private readonly props: NotificationProps) {} private constructor(private readonly props: NotificationProps) {}
static create( static create(
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string }, props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string }
): Notification { ): Notification {
return new Notification({ return new Notification({
...props, ...props,

View File

@ -1,201 +1,201 @@
/** /**
* Organization Entity * Organization Entity
* *
* Represents a business organization (freight forwarder, carrier, or shipper) * Represents a business organization (freight forwarder, carrier, or shipper)
* in the Xpeditis platform. * in the Xpeditis platform.
* *
* Business Rules: * Business Rules:
* - SCAC code must be unique across all carrier organizations * - SCAC code must be unique across all carrier organizations
* - Name must be unique * - Name must be unique
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER) * - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
*/ */
export enum OrganizationType { export enum OrganizationType {
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER', FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
CARRIER = 'CARRIER', CARRIER = 'CARRIER',
SHIPPER = 'SHIPPER', SHIPPER = 'SHIPPER',
} }
export interface OrganizationAddress { export interface OrganizationAddress {
street: string; street: string;
city: string; city: string;
state?: string; state?: string;
postalCode: string; postalCode: string;
country: string; country: string;
} }
export interface OrganizationDocument { export interface OrganizationDocument {
id: string; id: string;
type: string; type: string;
name: string; name: string;
url: string; url: string;
uploadedAt: Date; uploadedAt: Date;
} }
export interface OrganizationProps { export interface OrganizationProps {
id: string; id: string;
name: string; name: string;
type: OrganizationType; type: OrganizationType;
scac?: string; // Standard Carrier Alpha Code (for carriers only) scac?: string; // Standard Carrier Alpha Code (for carriers only)
address: OrganizationAddress; address: OrganizationAddress;
logoUrl?: string; logoUrl?: string;
documents: OrganizationDocument[]; documents: OrganizationDocument[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
isActive: boolean; isActive: boolean;
} }
export class Organization { export class Organization {
private readonly props: OrganizationProps; private readonly props: OrganizationProps;
private constructor(props: OrganizationProps) { private constructor(props: OrganizationProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new Organization * Factory method to create a new Organization
*/ */
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization { static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization {
const now = new Date(); const now = new Date();
// Validate SCAC code if provided // Validate SCAC code if provided
if (props.scac && !Organization.isValidSCAC(props.scac)) { if (props.scac && !Organization.isValidSCAC(props.scac)) {
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
} }
// Validate that carriers have SCAC codes // Validate that carriers have SCAC codes
if (props.type === OrganizationType.CARRIER && !props.scac) { if (props.type === OrganizationType.CARRIER && !props.scac) {
throw new Error('Carrier organizations must have a SCAC code.'); throw new Error('Carrier organizations must have a SCAC code.');
} }
// Validate that non-carriers don't have SCAC codes // Validate that non-carriers don't have SCAC codes
if (props.type !== OrganizationType.CARRIER && props.scac) { if (props.type !== OrganizationType.CARRIER && props.scac) {
throw new Error('Only carrier organizations can have SCAC codes.'); throw new Error('Only carrier organizations can have SCAC codes.');
} }
return new Organization({ return new Organization({
...props, ...props,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
/** /**
* Factory method to reconstitute from persistence * Factory method to reconstitute from persistence
*/ */
static fromPersistence(props: OrganizationProps): Organization { static fromPersistence(props: OrganizationProps): Organization {
return new Organization(props); return new Organization(props);
} }
/** /**
* Validate SCAC code format * Validate SCAC code format
* SCAC = Standard Carrier Alpha Code (4 uppercase letters) * SCAC = Standard Carrier Alpha Code (4 uppercase letters)
*/ */
private static isValidSCAC(scac: string): boolean { private static isValidSCAC(scac: string): boolean {
const scacPattern = /^[A-Z]{4}$/; const scacPattern = /^[A-Z]{4}$/;
return scacPattern.test(scac); return scacPattern.test(scac);
} }
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
} }
get name(): string { get name(): string {
return this.props.name; return this.props.name;
} }
get type(): OrganizationType { get type(): OrganizationType {
return this.props.type; return this.props.type;
} }
get scac(): string | undefined { get scac(): string | undefined {
return this.props.scac; return this.props.scac;
} }
get address(): OrganizationAddress { get address(): OrganizationAddress {
return { ...this.props.address }; return { ...this.props.address };
} }
get logoUrl(): string | undefined { get logoUrl(): string | undefined {
return this.props.logoUrl; return this.props.logoUrl;
} }
get documents(): OrganizationDocument[] { get documents(): OrganizationDocument[] {
return [...this.props.documents]; return [...this.props.documents];
} }
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; return this.props.createdAt;
} }
get updatedAt(): Date { get updatedAt(): Date {
return this.props.updatedAt; return this.props.updatedAt;
} }
get isActive(): boolean { get isActive(): boolean {
return this.props.isActive; return this.props.isActive;
} }
// Business methods // Business methods
isCarrier(): boolean { isCarrier(): boolean {
return this.props.type === OrganizationType.CARRIER; return this.props.type === OrganizationType.CARRIER;
} }
isFreightForwarder(): boolean { isFreightForwarder(): boolean {
return this.props.type === OrganizationType.FREIGHT_FORWARDER; return this.props.type === OrganizationType.FREIGHT_FORWARDER;
} }
isShipper(): boolean { isShipper(): boolean {
return this.props.type === OrganizationType.SHIPPER; return this.props.type === OrganizationType.SHIPPER;
} }
updateName(name: string): void { updateName(name: string): void {
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new Error('Organization name cannot be empty.'); throw new Error('Organization name cannot be empty.');
} }
this.props.name = name.trim(); this.props.name = name.trim();
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
updateAddress(address: OrganizationAddress): void { updateAddress(address: OrganizationAddress): void {
this.props.address = { ...address }; this.props.address = { ...address };
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
updateLogoUrl(logoUrl: string): void { updateLogoUrl(logoUrl: string): void {
this.props.logoUrl = logoUrl; this.props.logoUrl = logoUrl;
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
addDocument(document: OrganizationDocument): void { addDocument(document: OrganizationDocument): void {
this.props.documents.push(document); this.props.documents.push(document);
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
removeDocument(documentId: string): void { removeDocument(documentId: string): void {
this.props.documents = this.props.documents.filter(doc => doc.id !== documentId); this.props.documents = this.props.documents.filter(doc => doc.id !== documentId);
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
deactivate(): void { deactivate(): void {
this.props.isActive = false; this.props.isActive = false;
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
activate(): void { activate(): void {
this.props.isActive = true; this.props.isActive = true;
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
/** /**
* Convert to plain object for persistence * Convert to plain object for persistence
*/ */
toObject(): OrganizationProps { toObject(): OrganizationProps {
return { return {
...this.props, ...this.props,
address: { ...this.props.address }, address: { ...this.props.address },
documents: [...this.props.documents], documents: [...this.props.documents],
}; };
} }
} }

View File

@ -1,205 +1,209 @@
/** /**
* Port Entity * Port Entity
* *
* Represents a maritime port (based on UN/LOCODE standard) * Represents a maritime port (based on UN/LOCODE standard)
* *
* Business Rules: * Business Rules:
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter location) * - Port code must follow UN/LOCODE format (2-letter country + 3-letter location)
* - Coordinates must be valid latitude/longitude * - Coordinates must be valid latitude/longitude
*/ */
export interface PortCoordinates { export interface PortCoordinates {
latitude: number; latitude: number;
longitude: number; longitude: number;
} }
export interface PortProps { export interface PortProps {
id: string; id: string;
code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam) code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam)
name: string; // Port name name: string; // Port name
city: string; city: string;
country: string; // ISO 3166-1 alpha-2 country code country: string; // ISO 3166-1 alpha-2 country code
countryName: string; // Full country name countryName: string; // Full country name
coordinates: PortCoordinates; coordinates: PortCoordinates;
timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam') timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam')
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export class Port { export class Port {
private readonly props: PortProps; private readonly props: PortProps;
private constructor(props: PortProps) { private constructor(props: PortProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new Port * Factory method to create a new Port
*/ */
static create(props: Omit<PortProps, 'createdAt' | 'updatedAt'>): Port { static create(props: Omit<PortProps, 'createdAt' | 'updatedAt'>): Port {
const now = new Date(); const now = new Date();
// Validate UN/LOCODE format // Validate UN/LOCODE format
if (!Port.isValidUNLOCODE(props.code)) { if (!Port.isValidUNLOCODE(props.code)) {
throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).'); throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).');
} }
// Validate country code // Validate country code
if (!Port.isValidCountryCode(props.country)) { if (!Port.isValidCountryCode(props.country)) {
throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).'); throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).');
} }
// Validate coordinates // Validate coordinates
if (!Port.isValidCoordinates(props.coordinates)) { if (!Port.isValidCoordinates(props.coordinates)) {
throw new Error('Invalid coordinates.'); throw new Error('Invalid coordinates.');
} }
return new Port({ return new Port({
...props, ...props,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
/** /**
* Factory method to reconstitute from persistence * Factory method to reconstitute from persistence
*/ */
static fromPersistence(props: PortProps): Port { static fromPersistence(props: PortProps): Port {
return new Port(props); return new Port(props);
} }
/** /**
* Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code) * Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code)
*/ */
private static isValidUNLOCODE(code: string): boolean { private static isValidUNLOCODE(code: string): boolean {
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/; const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
return unlocodePattern.test(code); return unlocodePattern.test(code);
} }
/** /**
* Validate ISO 3166-1 alpha-2 country code * Validate ISO 3166-1 alpha-2 country code
*/ */
private static isValidCountryCode(code: string): boolean { private static isValidCountryCode(code: string): boolean {
const countryCodePattern = /^[A-Z]{2}$/; const countryCodePattern = /^[A-Z]{2}$/;
return countryCodePattern.test(code); return countryCodePattern.test(code);
} }
/** /**
* Validate coordinates * Validate coordinates
*/ */
private static isValidCoordinates(coords: PortCoordinates): boolean { private static isValidCoordinates(coords: PortCoordinates): boolean {
const { latitude, longitude } = coords; const { latitude, longitude } = coords;
return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180; return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
} }
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
} }
get code(): string { get code(): string {
return this.props.code; return this.props.code;
} }
get name(): string { get name(): string {
return this.props.name; return this.props.name;
} }
get city(): string { get city(): string {
return this.props.city; return this.props.city;
} }
get country(): string { get country(): string {
return this.props.country; return this.props.country;
} }
get countryName(): string { get countryName(): string {
return this.props.countryName; return this.props.countryName;
} }
get coordinates(): PortCoordinates { get coordinates(): PortCoordinates {
return { ...this.props.coordinates }; return { ...this.props.coordinates };
} }
get timezone(): string | undefined { get timezone(): string | undefined {
return this.props.timezone; return this.props.timezone;
} }
get isActive(): boolean { get isActive(): boolean {
return this.props.isActive; return this.props.isActive;
} }
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; return this.props.createdAt;
} }
get updatedAt(): Date { get updatedAt(): Date {
return this.props.updatedAt; return this.props.updatedAt;
} }
// Business methods // Business methods
/** /**
* Get display name (e.g., "Rotterdam, Netherlands (NLRTM)") * Get display name (e.g., "Rotterdam, Netherlands (NLRTM)")
*/ */
getDisplayName(): string { getDisplayName(): string {
return `${this.props.name}, ${this.props.countryName} (${this.props.code})`; return `${this.props.name}, ${this.props.countryName} (${this.props.code})`;
} }
/** /**
* Calculate distance to another port (Haversine formula) * Calculate distance to another port (Haversine formula)
* Returns distance in kilometers * Returns distance in kilometers
*/ */
distanceTo(otherPort: Port): number { distanceTo(otherPort: Port): number {
const R = 6371; // Earth's radius in kilometers const R = 6371; // Earth's radius in kilometers
const lat1 = this.toRadians(this.props.coordinates.latitude); const lat1 = this.toRadians(this.props.coordinates.latitude);
const lat2 = this.toRadians(otherPort.coordinates.latitude); const lat2 = this.toRadians(otherPort.coordinates.latitude);
const deltaLat = this.toRadians(otherPort.coordinates.latitude - this.props.coordinates.latitude); const deltaLat = this.toRadians(
const deltaLon = this.toRadians(otherPort.coordinates.longitude - this.props.coordinates.longitude); otherPort.coordinates.latitude - this.props.coordinates.latitude
);
const a = const deltaLon = this.toRadians(
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + otherPort.coordinates.longitude - this.props.coordinates.longitude
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); );
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
return R * c; Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
}
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
private toRadians(degrees: number): number {
return degrees * (Math.PI / 180); return R * c;
} }
updateCoordinates(coordinates: PortCoordinates): void { private toRadians(degrees: number): number {
if (!Port.isValidCoordinates(coordinates)) { return degrees * (Math.PI / 180);
throw new Error('Invalid coordinates.'); }
}
this.props.coordinates = { ...coordinates }; updateCoordinates(coordinates: PortCoordinates): void {
this.props.updatedAt = new Date(); if (!Port.isValidCoordinates(coordinates)) {
} throw new Error('Invalid coordinates.');
}
updateTimezone(timezone: string): void { this.props.coordinates = { ...coordinates };
this.props.timezone = timezone; this.props.updatedAt = new Date();
this.props.updatedAt = new Date(); }
}
updateTimezone(timezone: string): void {
deactivate(): void { this.props.timezone = timezone;
this.props.isActive = false; this.props.updatedAt = new Date();
this.props.updatedAt = new Date(); }
}
deactivate(): void {
activate(): void { this.props.isActive = false;
this.props.isActive = true; this.props.updatedAt = new Date();
this.props.updatedAt = new Date(); }
}
activate(): void {
/** this.props.isActive = true;
* Convert to plain object for persistence this.props.updatedAt = new Date();
*/ }
toObject(): PortProps {
return { /**
...this.props, * Convert to plain object for persistence
coordinates: { ...this.props.coordinates }, */
}; toObject(): PortProps {
} return {
} ...this.props,
coordinates: { ...this.props.coordinates },
};
}
}

View File

@ -1,240 +1,240 @@
/** /**
* RateQuote Entity Unit Tests * RateQuote Entity Unit Tests
*/ */
import { RateQuote } from './rate-quote.entity'; import { RateQuote } from './rate-quote.entity';
describe('RateQuote Entity', () => { describe('RateQuote Entity', () => {
const validProps = { const validProps = {
id: 'quote-1', id: 'quote-1',
carrierId: 'carrier-1', carrierId: 'carrier-1',
carrierName: 'Maersk', carrierName: 'Maersk',
carrierCode: 'MAERSK', carrierCode: 'MAERSK',
origin: { origin: {
code: 'NLRTM', code: 'NLRTM',
name: 'Rotterdam', name: 'Rotterdam',
country: 'Netherlands', country: 'Netherlands',
}, },
destination: { destination: {
code: 'USNYC', code: 'USNYC',
name: 'New York', name: 'New York',
country: 'United States', country: 'United States',
}, },
pricing: { pricing: {
baseFreight: 1000, baseFreight: 1000,
surcharges: [ surcharges: [
{ type: 'BAF', description: 'Bunker Adjustment Factor', amount: 100, currency: 'USD' }, { type: 'BAF', description: 'Bunker Adjustment Factor', amount: 100, currency: 'USD' },
], ],
totalAmount: 1100, totalAmount: 1100,
currency: 'USD', currency: 'USD',
}, },
containerType: '40HC', containerType: '40HC',
mode: 'FCL' as const, mode: 'FCL' as const,
etd: new Date('2025-11-01'), etd: new Date('2025-11-01'),
eta: new Date('2025-11-20'), eta: new Date('2025-11-20'),
transitDays: 19, transitDays: 19,
route: [ route: [
{ {
portCode: 'NLRTM', portCode: 'NLRTM',
portName: 'Rotterdam', portName: 'Rotterdam',
departure: new Date('2025-11-01'), departure: new Date('2025-11-01'),
}, },
{ {
portCode: 'USNYC', portCode: 'USNYC',
portName: 'New York', portName: 'New York',
arrival: new Date('2025-11-20'), arrival: new Date('2025-11-20'),
}, },
], ],
availability: 50, availability: 50,
frequency: 'Weekly', frequency: 'Weekly',
vesselType: 'Container Ship', vesselType: 'Container Ship',
co2EmissionsKg: 2500, co2EmissionsKg: 2500,
}; };
describe('create', () => { describe('create', () => {
it('should create rate quote with valid props', () => { it('should create rate quote with valid props', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
expect(rateQuote.id).toBe('quote-1'); expect(rateQuote.id).toBe('quote-1');
expect(rateQuote.carrierName).toBe('Maersk'); expect(rateQuote.carrierName).toBe('Maersk');
expect(rateQuote.origin.code).toBe('NLRTM'); expect(rateQuote.origin.code).toBe('NLRTM');
expect(rateQuote.destination.code).toBe('USNYC'); expect(rateQuote.destination.code).toBe('USNYC');
expect(rateQuote.pricing.totalAmount).toBe(1100); expect(rateQuote.pricing.totalAmount).toBe(1100);
}); });
it('should set validUntil to 15 minutes from now', () => { it('should set validUntil to 15 minutes from now', () => {
const before = new Date(); const before = new Date();
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
const after = new Date(); const after = new Date();
const expectedValidUntil = new Date(before.getTime() + 15 * 60 * 1000); const expectedValidUntil = new Date(before.getTime() + 15 * 60 * 1000);
const diff = Math.abs(rateQuote.validUntil.getTime() - expectedValidUntil.getTime()); const diff = Math.abs(rateQuote.validUntil.getTime() - expectedValidUntil.getTime());
// Allow 1 second tolerance for test execution time // Allow 1 second tolerance for test execution time
expect(diff).toBeLessThan(1000); expect(diff).toBeLessThan(1000);
}); });
it('should throw error for non-positive total price', () => { it('should throw error for non-positive total price', () => {
expect(() => expect(() =>
RateQuote.create({ RateQuote.create({
...validProps, ...validProps,
pricing: { ...validProps.pricing, totalAmount: 0 }, pricing: { ...validProps.pricing, totalAmount: 0 },
}) })
).toThrow('Total price must be positive'); ).toThrow('Total price must be positive');
}); });
it('should throw error for non-positive base freight', () => { it('should throw error for non-positive base freight', () => {
expect(() => expect(() =>
RateQuote.create({ RateQuote.create({
...validProps, ...validProps,
pricing: { ...validProps.pricing, baseFreight: 0 }, pricing: { ...validProps.pricing, baseFreight: 0 },
}) })
).toThrow('Base freight must be positive'); ).toThrow('Base freight must be positive');
}); });
it('should throw error if ETA is not after ETD', () => { it('should throw error if ETA is not after ETD', () => {
expect(() => expect(() =>
RateQuote.create({ RateQuote.create({
...validProps, ...validProps,
eta: new Date('2025-10-31'), eta: new Date('2025-10-31'),
}) })
).toThrow('ETA must be after ETD'); ).toThrow('ETA must be after ETD');
}); });
it('should throw error for non-positive transit days', () => { it('should throw error for non-positive transit days', () => {
expect(() => expect(() =>
RateQuote.create({ RateQuote.create({
...validProps, ...validProps,
transitDays: 0, transitDays: 0,
}) })
).toThrow('Transit days must be positive'); ).toThrow('Transit days must be positive');
}); });
it('should throw error for negative availability', () => { it('should throw error for negative availability', () => {
expect(() => expect(() =>
RateQuote.create({ RateQuote.create({
...validProps, ...validProps,
availability: -1, availability: -1,
}) })
).toThrow('Availability cannot be negative'); ).toThrow('Availability cannot be negative');
}); });
it('should throw error if route has less than 2 segments', () => { it('should throw error if route has less than 2 segments', () => {
expect(() => expect(() =>
RateQuote.create({ RateQuote.create({
...validProps, ...validProps,
route: [{ portCode: 'NLRTM', portName: 'Rotterdam' }], route: [{ portCode: 'NLRTM', portName: 'Rotterdam' }],
}) })
).toThrow('Route must have at least origin and destination'); ).toThrow('Route must have at least origin and destination');
}); });
}); });
describe('isValid', () => { describe('isValid', () => {
it('should return true for non-expired quote', () => { it('should return true for non-expired quote', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
expect(rateQuote.isValid()).toBe(true); expect(rateQuote.isValid()).toBe(true);
}); });
it('should return false for expired quote', () => { it('should return false for expired quote', () => {
const expiredQuote = RateQuote.fromPersistence({ const expiredQuote = RateQuote.fromPersistence({
...validProps, ...validProps,
validUntil: new Date(Date.now() - 1000), // 1 second ago validUntil: new Date(Date.now() - 1000), // 1 second ago
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); });
expect(expiredQuote.isValid()).toBe(false); expect(expiredQuote.isValid()).toBe(false);
}); });
}); });
describe('isExpired', () => { describe('isExpired', () => {
it('should return false for non-expired quote', () => { it('should return false for non-expired quote', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
expect(rateQuote.isExpired()).toBe(false); expect(rateQuote.isExpired()).toBe(false);
}); });
it('should return true for expired quote', () => { it('should return true for expired quote', () => {
const expiredQuote = RateQuote.fromPersistence({ const expiredQuote = RateQuote.fromPersistence({
...validProps, ...validProps,
validUntil: new Date(Date.now() - 1000), validUntil: new Date(Date.now() - 1000),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); });
expect(expiredQuote.isExpired()).toBe(true); expect(expiredQuote.isExpired()).toBe(true);
}); });
}); });
describe('hasAvailability', () => { describe('hasAvailability', () => {
it('should return true when availability > 0', () => { it('should return true when availability > 0', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
expect(rateQuote.hasAvailability()).toBe(true); expect(rateQuote.hasAvailability()).toBe(true);
}); });
it('should return false when availability = 0', () => { it('should return false when availability = 0', () => {
const rateQuote = RateQuote.create({ ...validProps, availability: 0 }); const rateQuote = RateQuote.create({ ...validProps, availability: 0 });
expect(rateQuote.hasAvailability()).toBe(false); expect(rateQuote.hasAvailability()).toBe(false);
}); });
}); });
describe('getTotalSurcharges', () => { describe('getTotalSurcharges', () => {
it('should calculate total surcharges', () => { it('should calculate total surcharges', () => {
const rateQuote = RateQuote.create({ const rateQuote = RateQuote.create({
...validProps, ...validProps,
pricing: { pricing: {
baseFreight: 1000, baseFreight: 1000,
surcharges: [ surcharges: [
{ type: 'BAF', description: 'BAF', amount: 100, currency: 'USD' }, { type: 'BAF', description: 'BAF', amount: 100, currency: 'USD' },
{ type: 'CAF', description: 'CAF', amount: 50, currency: 'USD' }, { type: 'CAF', description: 'CAF', amount: 50, currency: 'USD' },
], ],
totalAmount: 1150, totalAmount: 1150,
currency: 'USD', currency: 'USD',
}, },
}); });
expect(rateQuote.getTotalSurcharges()).toBe(150); expect(rateQuote.getTotalSurcharges()).toBe(150);
}); });
}); });
describe('getTransshipmentCount', () => { describe('getTransshipmentCount', () => {
it('should return 0 for direct route', () => { it('should return 0 for direct route', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
expect(rateQuote.getTransshipmentCount()).toBe(0); expect(rateQuote.getTransshipmentCount()).toBe(0);
}); });
it('should return correct count for route with transshipments', () => { it('should return correct count for route with transshipments', () => {
const rateQuote = RateQuote.create({ const rateQuote = RateQuote.create({
...validProps, ...validProps,
route: [ route: [
{ portCode: 'NLRTM', portName: 'Rotterdam' }, { portCode: 'NLRTM', portName: 'Rotterdam' },
{ portCode: 'ESBCN', portName: 'Barcelona' }, { portCode: 'ESBCN', portName: 'Barcelona' },
{ portCode: 'USNYC', portName: 'New York' }, { portCode: 'USNYC', portName: 'New York' },
], ],
}); });
expect(rateQuote.getTransshipmentCount()).toBe(1); expect(rateQuote.getTransshipmentCount()).toBe(1);
}); });
}); });
describe('isDirectRoute', () => { describe('isDirectRoute', () => {
it('should return true for direct route', () => { it('should return true for direct route', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
expect(rateQuote.isDirectRoute()).toBe(true); expect(rateQuote.isDirectRoute()).toBe(true);
}); });
it('should return false for route with transshipments', () => { it('should return false for route with transshipments', () => {
const rateQuote = RateQuote.create({ const rateQuote = RateQuote.create({
...validProps, ...validProps,
route: [ route: [
{ portCode: 'NLRTM', portName: 'Rotterdam' }, { portCode: 'NLRTM', portName: 'Rotterdam' },
{ portCode: 'ESBCN', portName: 'Barcelona' }, { portCode: 'ESBCN', portName: 'Barcelona' },
{ portCode: 'USNYC', portName: 'New York' }, { portCode: 'USNYC', portName: 'New York' },
], ],
}); });
expect(rateQuote.isDirectRoute()).toBe(false); expect(rateQuote.isDirectRoute()).toBe(false);
}); });
}); });
describe('getPricePerDay', () => { describe('getPricePerDay', () => {
it('should calculate price per day', () => { it('should calculate price per day', () => {
const rateQuote = RateQuote.create(validProps); const rateQuote = RateQuote.create(validProps);
const pricePerDay = rateQuote.getPricePerDay(); const pricePerDay = rateQuote.getPricePerDay();
expect(pricePerDay).toBeCloseTo(1100 / 19, 2); expect(pricePerDay).toBeCloseTo(1100 / 19, 2);
}); });
}); });
}); });

View File

@ -1,277 +1,277 @@
/** /**
* RateQuote Entity * RateQuote Entity
* *
* Represents a shipping rate quote from a carrier * Represents a shipping rate quote from a carrier
* *
* Business Rules: * Business Rules:
* - Price must be positive * - Price must be positive
* - ETA must be after ETD * - ETA must be after ETD
* - Transit days must be positive * - Transit days must be positive
* - Rate quotes expire after 15 minutes (cache TTL) * - Rate quotes expire after 15 minutes (cache TTL)
* - Availability must be between 0 and actual capacity * - Availability must be between 0 and actual capacity
*/ */
export interface RouteSegment { export interface RouteSegment {
portCode: string; portCode: string;
portName: string; portName: string;
arrival?: Date; arrival?: Date;
departure?: Date; departure?: Date;
vesselName?: string; vesselName?: string;
voyageNumber?: string; voyageNumber?: string;
} }
export interface Surcharge { export interface Surcharge {
type: string; // e.g., 'BAF', 'CAF', 'THC', 'ISPS' type: string; // e.g., 'BAF', 'CAF', 'THC', 'ISPS'
description: string; description: string;
amount: number; amount: number;
currency: string; currency: string;
} }
export interface PriceBreakdown { export interface PriceBreakdown {
baseFreight: number; baseFreight: number;
surcharges: Surcharge[]; surcharges: Surcharge[];
totalAmount: number; totalAmount: number;
currency: string; currency: string;
} }
export interface RateQuoteProps { export interface RateQuoteProps {
id: string; id: string;
carrierId: string; carrierId: string;
carrierName: string; carrierName: string;
carrierCode: string; carrierCode: string;
origin: { origin: {
code: string; code: string;
name: string; name: string;
country: string; country: string;
}; };
destination: { destination: {
code: string; code: string;
name: string; name: string;
country: string; country: string;
}; };
pricing: PriceBreakdown; pricing: PriceBreakdown;
containerType: string; // e.g., '20DRY', '40HC', '40REEFER' containerType: string; // e.g., '20DRY', '40HC', '40REEFER'
mode: 'FCL' | 'LCL'; mode: 'FCL' | 'LCL';
etd: Date; // Estimated Time of Departure etd: Date; // Estimated Time of Departure
eta: Date; // Estimated Time of Arrival eta: Date; // Estimated Time of Arrival
transitDays: number; transitDays: number;
route: RouteSegment[]; route: RouteSegment[];
availability: number; // Available container slots availability: number; // Available container slots
frequency: string; // e.g., 'Weekly', 'Bi-weekly' frequency: string; // e.g., 'Weekly', 'Bi-weekly'
vesselType?: string; // e.g., 'Container Ship', 'Ro-Ro' vesselType?: string; // e.g., 'Container Ship', 'Ro-Ro'
co2EmissionsKg?: number; // CO2 emissions in kg co2EmissionsKg?: number; // CO2 emissions in kg
validUntil: Date; // When this quote expires (typically createdAt + 15 min) validUntil: Date; // When this quote expires (typically createdAt + 15 min)
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export class RateQuote { export class RateQuote {
private readonly props: RateQuoteProps; private readonly props: RateQuoteProps;
private constructor(props: RateQuoteProps) { private constructor(props: RateQuoteProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new RateQuote * Factory method to create a new RateQuote
*/ */
static create( static create(
props: Omit<RateQuoteProps, 'id' | 'validUntil' | 'createdAt' | 'updatedAt'> & { id: string } props: Omit<RateQuoteProps, 'id' | 'validUntil' | 'createdAt' | 'updatedAt'> & { id: string }
): RateQuote { ): RateQuote {
const now = new Date(); const now = new Date();
const validUntil = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes const validUntil = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes
// Validate pricing // Validate pricing
if (props.pricing.totalAmount <= 0) { if (props.pricing.totalAmount <= 0) {
throw new Error('Total price must be positive.'); throw new Error('Total price must be positive.');
} }
if (props.pricing.baseFreight <= 0) { if (props.pricing.baseFreight <= 0) {
throw new Error('Base freight must be positive.'); throw new Error('Base freight must be positive.');
} }
// Validate dates // Validate dates
if (props.eta <= props.etd) { if (props.eta <= props.etd) {
throw new Error('ETA must be after ETD.'); throw new Error('ETA must be after ETD.');
} }
// Validate transit days // Validate transit days
if (props.transitDays <= 0) { if (props.transitDays <= 0) {
throw new Error('Transit days must be positive.'); throw new Error('Transit days must be positive.');
} }
// Validate availability // Validate availability
if (props.availability < 0) { if (props.availability < 0) {
throw new Error('Availability cannot be negative.'); throw new Error('Availability cannot be negative.');
} }
// Validate route has at least origin and destination // Validate route has at least origin and destination
if (props.route.length < 2) { if (props.route.length < 2) {
throw new Error('Route must have at least origin and destination ports.'); throw new Error('Route must have at least origin and destination ports.');
} }
return new RateQuote({ return new RateQuote({
...props, ...props,
validUntil, validUntil,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
/** /**
* Factory method to reconstitute from persistence * Factory method to reconstitute from persistence
*/ */
static fromPersistence(props: RateQuoteProps): RateQuote { static fromPersistence(props: RateQuoteProps): RateQuote {
return new RateQuote(props); return new RateQuote(props);
} }
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
} }
get carrierId(): string { get carrierId(): string {
return this.props.carrierId; return this.props.carrierId;
} }
get carrierName(): string { get carrierName(): string {
return this.props.carrierName; return this.props.carrierName;
} }
get carrierCode(): string { get carrierCode(): string {
return this.props.carrierCode; return this.props.carrierCode;
} }
get origin(): { code: string; name: string; country: string } { get origin(): { code: string; name: string; country: string } {
return { ...this.props.origin }; return { ...this.props.origin };
} }
get destination(): { code: string; name: string; country: string } { get destination(): { code: string; name: string; country: string } {
return { ...this.props.destination }; return { ...this.props.destination };
} }
get pricing(): PriceBreakdown { get pricing(): PriceBreakdown {
return { return {
...this.props.pricing, ...this.props.pricing,
surcharges: [...this.props.pricing.surcharges], surcharges: [...this.props.pricing.surcharges],
}; };
} }
get containerType(): string { get containerType(): string {
return this.props.containerType; return this.props.containerType;
} }
get mode(): 'FCL' | 'LCL' { get mode(): 'FCL' | 'LCL' {
return this.props.mode; return this.props.mode;
} }
get etd(): Date { get etd(): Date {
return this.props.etd; return this.props.etd;
} }
get eta(): Date { get eta(): Date {
return this.props.eta; return this.props.eta;
} }
get transitDays(): number { get transitDays(): number {
return this.props.transitDays; return this.props.transitDays;
} }
get route(): RouteSegment[] { get route(): RouteSegment[] {
return [...this.props.route]; return [...this.props.route];
} }
get availability(): number { get availability(): number {
return this.props.availability; return this.props.availability;
} }
get frequency(): string { get frequency(): string {
return this.props.frequency; return this.props.frequency;
} }
get vesselType(): string | undefined { get vesselType(): string | undefined {
return this.props.vesselType; return this.props.vesselType;
} }
get co2EmissionsKg(): number | undefined { get co2EmissionsKg(): number | undefined {
return this.props.co2EmissionsKg; return this.props.co2EmissionsKg;
} }
get validUntil(): Date { get validUntil(): Date {
return this.props.validUntil; return this.props.validUntil;
} }
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; return this.props.createdAt;
} }
get updatedAt(): Date { get updatedAt(): Date {
return this.props.updatedAt; return this.props.updatedAt;
} }
// Business methods // Business methods
/** /**
* Check if the rate quote is still valid (not expired) * Check if the rate quote is still valid (not expired)
*/ */
isValid(): boolean { isValid(): boolean {
return new Date() < this.props.validUntil; return new Date() < this.props.validUntil;
} }
/** /**
* Check if the rate quote has expired * Check if the rate quote has expired
*/ */
isExpired(): boolean { isExpired(): boolean {
return new Date() >= this.props.validUntil; return new Date() >= this.props.validUntil;
} }
/** /**
* Check if containers are available * Check if containers are available
*/ */
hasAvailability(): boolean { hasAvailability(): boolean {
return this.props.availability > 0; return this.props.availability > 0;
} }
/** /**
* Get total surcharges amount * Get total surcharges amount
*/ */
getTotalSurcharges(): number { getTotalSurcharges(): number {
return this.props.pricing.surcharges.reduce((sum, surcharge) => sum + surcharge.amount, 0); return this.props.pricing.surcharges.reduce((sum, surcharge) => sum + surcharge.amount, 0);
} }
/** /**
* Get number of transshipments (route segments minus 2 for origin and destination) * Get number of transshipments (route segments minus 2 for origin and destination)
*/ */
getTransshipmentCount(): number { getTransshipmentCount(): number {
return Math.max(0, this.props.route.length - 2); return Math.max(0, this.props.route.length - 2);
} }
/** /**
* Check if this is a direct route (no transshipments) * Check if this is a direct route (no transshipments)
*/ */
isDirectRoute(): boolean { isDirectRoute(): boolean {
return this.getTransshipmentCount() === 0; return this.getTransshipmentCount() === 0;
} }
/** /**
* Get price per day (for comparison) * Get price per day (for comparison)
*/ */
getPricePerDay(): number { getPricePerDay(): number {
return this.props.pricing.totalAmount / this.props.transitDays; return this.props.pricing.totalAmount / this.props.transitDays;
} }
/** /**
* Convert to plain object for persistence * Convert to plain object for persistence
*/ */
toObject(): RateQuoteProps { toObject(): RateQuoteProps {
return { return {
...this.props, ...this.props,
origin: { ...this.props.origin }, origin: { ...this.props.origin },
destination: { ...this.props.destination }, destination: { ...this.props.destination },
pricing: { pricing: {
...this.props.pricing, ...this.props.pricing,
surcharges: [...this.props.pricing.surcharges], surcharges: [...this.props.pricing.surcharges],
}, },
route: [...this.props.route], route: [...this.props.route],
}; };
} }
} }

View File

@ -1,250 +1,253 @@
/** /**
* User Entity * User Entity
* *
* Represents a user account in the Xpeditis platform. * Represents a user account in the Xpeditis platform.
* *
* Business Rules: * Business Rules:
* - Email must be valid and unique * - Email must be valid and unique
* - Password must meet complexity requirements (enforced at application layer) * - Password must meet complexity requirements (enforced at application layer)
* - Users belong to an organization * - Users belong to an organization
* - Role-based access control (Admin, Manager, User, Viewer) * - Role-based access control (Admin, Manager, User, Viewer)
*/ */
export enum UserRole { export enum UserRole {
ADMIN = 'admin', // Full system access ADMIN = 'admin', // Full system access
MANAGER = 'manager', // Manage bookings and users within organization MANAGER = 'manager', // Manage bookings and users within organization
USER = 'user', // Create and view bookings USER = 'user', // Create and view bookings
VIEWER = 'viewer', // Read-only access VIEWER = 'viewer', // Read-only access
} }
export interface UserProps { export interface UserProps {
id: string; id: string;
organizationId: string; organizationId: string;
email: string; email: string;
passwordHash: string; passwordHash: string;
role: UserRole; role: UserRole;
firstName: string; firstName: string;
lastName: string; lastName: string;
phoneNumber?: string; phoneNumber?: string;
totpSecret?: string; // For 2FA totpSecret?: string; // For 2FA
isEmailVerified: boolean; isEmailVerified: boolean;
isActive: boolean; isActive: boolean;
lastLoginAt?: Date; lastLoginAt?: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export class User { export class User {
private readonly props: UserProps; private readonly props: UserProps;
private constructor(props: UserProps) { private constructor(props: UserProps) {
this.props = props; this.props = props;
} }
/** /**
* Factory method to create a new User * Factory method to create a new User
*/ */
static create( static create(
props: Omit<UserProps, 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'> props: Omit<
): User { UserProps,
const now = new Date(); 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
>
// Validate email format (basic validation) ): User {
if (!User.isValidEmail(props.email)) { const now = new Date();
throw new Error('Invalid email format.');
} // Validate email format (basic validation)
if (!User.isValidEmail(props.email)) {
return new User({ throw new Error('Invalid email format.');
...props, }
isEmailVerified: false,
isActive: true, return new User({
createdAt: now, ...props,
updatedAt: now, isEmailVerified: false,
}); isActive: true,
} createdAt: now,
updatedAt: now,
/** });
* Factory method to reconstitute from persistence }
*/
static fromPersistence(props: UserProps): User { /**
return new User(props); * Factory method to reconstitute from persistence
} */
static fromPersistence(props: UserProps): User {
/** return new User(props);
* Validate email format }
*/
private static isValidEmail(email: string): boolean { /**
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * Validate email format
return emailPattern.test(email); */
} private static isValidEmail(email: string): boolean {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Getters return emailPattern.test(email);
get id(): string { }
return this.props.id;
} // Getters
get id(): string {
get organizationId(): string { return this.props.id;
return this.props.organizationId; }
}
get organizationId(): string {
get email(): string { return this.props.organizationId;
return this.props.email; }
}
get email(): string {
get passwordHash(): string { return this.props.email;
return this.props.passwordHash; }
}
get passwordHash(): string {
get role(): UserRole { return this.props.passwordHash;
return this.props.role; }
}
get role(): UserRole {
get firstName(): string { return this.props.role;
return this.props.firstName; }
}
get firstName(): string {
get lastName(): string { return this.props.firstName;
return this.props.lastName; }
}
get lastName(): string {
get fullName(): string { return this.props.lastName;
return `${this.props.firstName} ${this.props.lastName}`; }
}
get fullName(): string {
get phoneNumber(): string | undefined { return `${this.props.firstName} ${this.props.lastName}`;
return this.props.phoneNumber; }
}
get phoneNumber(): string | undefined {
get totpSecret(): string | undefined { return this.props.phoneNumber;
return this.props.totpSecret; }
}
get totpSecret(): string | undefined {
get isEmailVerified(): boolean { return this.props.totpSecret;
return this.props.isEmailVerified; }
}
get isEmailVerified(): boolean {
get isActive(): boolean { return this.props.isEmailVerified;
return this.props.isActive; }
}
get isActive(): boolean {
get lastLoginAt(): Date | undefined { return this.props.isActive;
return this.props.lastLoginAt; }
}
get lastLoginAt(): Date | undefined {
get createdAt(): Date { return this.props.lastLoginAt;
return this.props.createdAt; }
}
get createdAt(): Date {
get updatedAt(): Date { return this.props.createdAt;
return this.props.updatedAt; }
}
get updatedAt(): Date {
// Business methods return this.props.updatedAt;
has2FAEnabled(): boolean { }
return !!this.props.totpSecret;
} // Business methods
has2FAEnabled(): boolean {
isAdmin(): boolean { return !!this.props.totpSecret;
return this.props.role === UserRole.ADMIN; }
}
isAdmin(): boolean {
isManager(): boolean { return this.props.role === UserRole.ADMIN;
return this.props.role === UserRole.MANAGER; }
}
isManager(): boolean {
isRegularUser(): boolean { return this.props.role === UserRole.MANAGER;
return this.props.role === UserRole.USER; }
}
isRegularUser(): boolean {
isViewer(): boolean { return this.props.role === UserRole.USER;
return this.props.role === UserRole.VIEWER; }
}
isViewer(): boolean {
canManageUsers(): boolean { return this.props.role === UserRole.VIEWER;
return this.props.role === UserRole.ADMIN || this.props.role === UserRole.MANAGER; }
}
canManageUsers(): boolean {
canCreateBookings(): boolean { return this.props.role === UserRole.ADMIN || this.props.role === UserRole.MANAGER;
return ( }
this.props.role === UserRole.ADMIN ||
this.props.role === UserRole.MANAGER || canCreateBookings(): boolean {
this.props.role === UserRole.USER return (
); this.props.role === UserRole.ADMIN ||
} this.props.role === UserRole.MANAGER ||
this.props.role === UserRole.USER
updatePassword(newPasswordHash: string): void { );
this.props.passwordHash = newPasswordHash; }
this.props.updatedAt = new Date();
} updatePassword(newPasswordHash: string): void {
this.props.passwordHash = newPasswordHash;
updateRole(newRole: UserRole): void { this.props.updatedAt = new Date();
this.props.role = newRole; }
this.props.updatedAt = new Date();
} updateRole(newRole: UserRole): void {
this.props.role = newRole;
updateFirstName(firstName: string): void { this.props.updatedAt = new Date();
if (!firstName || firstName.trim().length === 0) { }
throw new Error('First name cannot be empty.');
} updateFirstName(firstName: string): void {
this.props.firstName = firstName.trim(); if (!firstName || firstName.trim().length === 0) {
this.props.updatedAt = new Date(); throw new Error('First name cannot be empty.');
} }
this.props.firstName = firstName.trim();
updateLastName(lastName: string): void { this.props.updatedAt = new Date();
if (!lastName || lastName.trim().length === 0) { }
throw new Error('Last name cannot be empty.');
} updateLastName(lastName: string): void {
this.props.lastName = lastName.trim(); if (!lastName || lastName.trim().length === 0) {
this.props.updatedAt = new Date(); throw new Error('Last name cannot be empty.');
} }
this.props.lastName = lastName.trim();
updateProfile(firstName: string, lastName: string, phoneNumber?: string): void { this.props.updatedAt = new Date();
if (!firstName || firstName.trim().length === 0) { }
throw new Error('First name cannot be empty.');
} updateProfile(firstName: string, lastName: string, phoneNumber?: string): void {
if (!lastName || lastName.trim().length === 0) { if (!firstName || firstName.trim().length === 0) {
throw new Error('Last name cannot be empty.'); throw new Error('First name cannot be empty.');
} }
if (!lastName || lastName.trim().length === 0) {
this.props.firstName = firstName.trim(); throw new Error('Last name cannot be empty.');
this.props.lastName = lastName.trim(); }
this.props.phoneNumber = phoneNumber;
this.props.updatedAt = new Date(); this.props.firstName = firstName.trim();
} this.props.lastName = lastName.trim();
this.props.phoneNumber = phoneNumber;
verifyEmail(): void { this.props.updatedAt = new Date();
this.props.isEmailVerified = true; }
this.props.updatedAt = new Date();
} verifyEmail(): void {
this.props.isEmailVerified = true;
enable2FA(totpSecret: string): void { this.props.updatedAt = new Date();
this.props.totpSecret = totpSecret; }
this.props.updatedAt = new Date();
} enable2FA(totpSecret: string): void {
this.props.totpSecret = totpSecret;
disable2FA(): void { this.props.updatedAt = new Date();
this.props.totpSecret = undefined; }
this.props.updatedAt = new Date();
} disable2FA(): void {
this.props.totpSecret = undefined;
recordLogin(): void { this.props.updatedAt = new Date();
this.props.lastLoginAt = new Date(); }
}
recordLogin(): void {
deactivate(): void { this.props.lastLoginAt = new Date();
this.props.isActive = false; }
this.props.updatedAt = new Date();
} deactivate(): void {
this.props.isActive = false;
activate(): void { this.props.updatedAt = new Date();
this.props.isActive = true; }
this.props.updatedAt = new Date();
} activate(): void {
this.props.isActive = true;
/** this.props.updatedAt = new Date();
* Convert to plain object for persistence }
*/
toObject(): UserProps { /**
return { ...this.props }; * Convert to plain object for persistence
} */
} toObject(): UserProps {
return { ...this.props };
}
}

View File

@ -41,7 +41,10 @@ export class Webhook {
private constructor(private readonly props: WebhookProps) {} private constructor(private readonly props: WebhookProps) {}
static create( static create(
props: Omit<WebhookProps, 'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'> & { id: string }, props: Omit<
WebhookProps,
'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'
> & { id: string }
): Webhook { ): Webhook {
return new Webhook({ return new Webhook({
...props, ...props,

View File

@ -1,16 +1,16 @@
/** /**
* CarrierTimeoutException * CarrierTimeoutException
* *
* Thrown when a carrier API call times out * Thrown when a carrier API call times out
*/ */
export class CarrierTimeoutException extends Error { export class CarrierTimeoutException extends Error {
constructor( constructor(
public readonly carrierName: string, public readonly carrierName: string,
public readonly timeoutMs: number public readonly timeoutMs: number
) { ) {
super(`Carrier ${carrierName} timed out after ${timeoutMs}ms`); super(`Carrier ${carrierName} timed out after ${timeoutMs}ms`);
this.name = 'CarrierTimeoutException'; this.name = 'CarrierTimeoutException';
Object.setPrototypeOf(this, CarrierTimeoutException.prototype); Object.setPrototypeOf(this, CarrierTimeoutException.prototype);
} }
} }

View File

@ -1,16 +1,16 @@
/** /**
* CarrierUnavailableException * CarrierUnavailableException
* *
* Thrown when a carrier is unavailable or not responding * Thrown when a carrier is unavailable or not responding
*/ */
export class CarrierUnavailableException extends Error { export class CarrierUnavailableException extends Error {
constructor( constructor(
public readonly carrierName: string, public readonly carrierName: string,
public readonly reason?: string public readonly reason?: string
) { ) {
super(`Carrier ${carrierName} is unavailable${reason ? `: ${reason}` : ''}`); super(`Carrier ${carrierName} is unavailable${reason ? `: ${reason}` : ''}`);
this.name = 'CarrierUnavailableException'; this.name = 'CarrierUnavailableException';
Object.setPrototypeOf(this, CarrierUnavailableException.prototype); Object.setPrototypeOf(this, CarrierUnavailableException.prototype);
} }
} }

View File

@ -1,12 +1,12 @@
/** /**
* Domain Exceptions Barrel Export * Domain Exceptions Barrel Export
* *
* All domain exceptions for the Xpeditis platform * All domain exceptions for the Xpeditis platform
*/ */
export * from './invalid-port-code.exception'; export * from './invalid-port-code.exception';
export * from './invalid-rate-quote.exception'; export * from './invalid-rate-quote.exception';
export * from './carrier-timeout.exception'; export * from './carrier-timeout.exception';
export * from './carrier-unavailable.exception'; export * from './carrier-unavailable.exception';
export * from './rate-quote-expired.exception'; export * from './rate-quote-expired.exception';
export * from './port-not-found.exception'; export * from './port-not-found.exception';

View File

@ -1,6 +1,6 @@
export class InvalidBookingNumberException extends Error { export class InvalidBookingNumberException extends Error {
constructor(value: string) { constructor(value: string) {
super(`Invalid booking number format: ${value}. Expected format: WCM-YYYY-XXXXXX`); super(`Invalid booking number format: ${value}. Expected format: WCM-YYYY-XXXXXX`);
this.name = 'InvalidBookingNumberException'; this.name = 'InvalidBookingNumberException';
} }
} }

View File

@ -1,8 +1,8 @@
export class InvalidBookingStatusException extends Error { export class InvalidBookingStatusException extends Error {
constructor(value: string) { constructor(value: string) {
super( super(
`Invalid booking status: ${value}. Valid statuses: draft, pending_confirmation, confirmed, in_transit, delivered, cancelled` `Invalid booking status: ${value}. Valid statuses: draft, pending_confirmation, confirmed, in_transit, delivered, cancelled`
); );
this.name = 'InvalidBookingStatusException'; this.name = 'InvalidBookingStatusException';
} }
} }

View File

@ -1,13 +1,13 @@
/** /**
* InvalidPortCodeException * InvalidPortCodeException
* *
* Thrown when a port code is invalid or not found * Thrown when a port code is invalid or not found
*/ */
export class InvalidPortCodeException extends Error { export class InvalidPortCodeException extends Error {
constructor(portCode: string, message?: string) { constructor(portCode: string, message?: string) {
super(message || `Invalid port code: ${portCode}`); super(message || `Invalid port code: ${portCode}`);
this.name = 'InvalidPortCodeException'; this.name = 'InvalidPortCodeException';
Object.setPrototypeOf(this, InvalidPortCodeException.prototype); Object.setPrototypeOf(this, InvalidPortCodeException.prototype);
} }
} }

View File

@ -1,13 +1,13 @@
/** /**
* InvalidRateQuoteException * InvalidRateQuoteException
* *
* Thrown when a rate quote is invalid or malformed * Thrown when a rate quote is invalid or malformed
*/ */
export class InvalidRateQuoteException extends Error { export class InvalidRateQuoteException extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'InvalidRateQuoteException'; this.name = 'InvalidRateQuoteException';
Object.setPrototypeOf(this, InvalidRateQuoteException.prototype); Object.setPrototypeOf(this, InvalidRateQuoteException.prototype);
} }
} }

View File

@ -1,13 +1,13 @@
/** /**
* PortNotFoundException * PortNotFoundException
* *
* Thrown when a port is not found in the database * Thrown when a port is not found in the database
*/ */
export class PortNotFoundException extends Error { export class PortNotFoundException extends Error {
constructor(public readonly portCode: string) { constructor(public readonly portCode: string) {
super(`Port not found: ${portCode}`); super(`Port not found: ${portCode}`);
this.name = 'PortNotFoundException'; this.name = 'PortNotFoundException';
Object.setPrototypeOf(this, PortNotFoundException.prototype); Object.setPrototypeOf(this, PortNotFoundException.prototype);
} }
} }

View File

@ -1,16 +1,16 @@
/** /**
* RateQuoteExpiredException * RateQuoteExpiredException
* *
* Thrown when attempting to use an expired rate quote * Thrown when attempting to use an expired rate quote
*/ */
export class RateQuoteExpiredException extends Error { export class RateQuoteExpiredException extends Error {
constructor( constructor(
public readonly rateQuoteId: string, public readonly rateQuoteId: string,
public readonly expiredAt: Date public readonly expiredAt: Date
) { ) {
super(`Rate quote ${rateQuoteId} expired at ${expiredAt.toISOString()}`); super(`Rate quote ${rateQuoteId} expired at ${expiredAt.toISOString()}`);
this.name = 'RateQuoteExpiredException'; this.name = 'RateQuoteExpiredException';
Object.setPrototypeOf(this, RateQuoteExpiredException.prototype); Object.setPrototypeOf(this, RateQuoteExpiredException.prototype);
} }
} }

View File

@ -1,45 +1,45 @@
/** /**
* GetPortsPort (API Port - Input) * GetPortsPort (API Port - Input)
* *
* Defines the interface for port autocomplete and retrieval * Defines the interface for port autocomplete and retrieval
*/ */
import { Port } from '../../entities/port.entity'; import { Port } from '../../entities/port.entity';
export interface PortSearchInput { export interface PortSearchInput {
query: string; // Search query (port name, city, or code) query: string; // Search query (port name, city, or code)
limit?: number; // Max results (default: 10) limit?: number; // Max results (default: 10)
countryFilter?: string; // ISO country code filter countryFilter?: string; // ISO country code filter
} }
export interface PortSearchOutput { export interface PortSearchOutput {
ports: Port[]; ports: Port[];
totalMatches: number; totalMatches: number;
} }
export interface GetPortInput { export interface GetPortInput {
portCode: string; // UN/LOCODE portCode: string; // UN/LOCODE
} }
export interface GetPortsPort { export interface GetPortsPort {
/** /**
* Search ports by query (autocomplete) * Search ports by query (autocomplete)
* @param input - Port search parameters * @param input - Port search parameters
* @returns Matching ports * @returns Matching ports
*/ */
search(input: PortSearchInput): Promise<PortSearchOutput>; search(input: PortSearchInput): Promise<PortSearchOutput>;
/** /**
* Get port by code * Get port by code
* @param input - Port code * @param input - Port code
* @returns Port entity * @returns Port entity
*/ */
getByCode(input: GetPortInput): Promise<Port>; getByCode(input: GetPortInput): Promise<Port>;
/** /**
* Get multiple ports by codes * Get multiple ports by codes
* @param portCodes - Array of port codes * @param portCodes - Array of port codes
* @returns Array of ports * @returns Array of ports
*/ */
getByCodes(portCodes: string[]): Promise<Port[]>; getByCodes(portCodes: string[]): Promise<Port[]>;
} }

View File

@ -1,9 +1,9 @@
/** /**
* API Ports (Input) Barrel Export * API Ports (Input) Barrel Export
* *
* All input ports (use case interfaces) for the Xpeditis platform * All input ports (use case interfaces) for the Xpeditis platform
*/ */
export * from './search-rates.port'; export * from './search-rates.port';
export * from './get-ports.port'; export * from './get-ports.port';
export * from './validate-availability.port'; export * from './validate-availability.port';

View File

@ -1,109 +1,109 @@
import { CsvRate } from '../../entities/csv-rate.entity'; import { CsvRate } from '../../entities/csv-rate.entity';
import { PortCode } from '../../value-objects/port-code.vo'; import { PortCode } from '../../value-objects/port-code.vo';
import { Volume } from '../../value-objects/volume.vo'; import { Volume } from '../../value-objects/volume.vo';
/** /**
* Advanced Rate Search Filters * Advanced Rate Search Filters
* *
* Filters for narrowing down rate search results * Filters for narrowing down rate search results
*/ */
export interface RateSearchFilters { export interface RateSearchFilters {
// Company filters // Company filters
companies?: string[]; // List of company names to include companies?: string[]; // List of company names to include
// Volume/Weight filters // Volume/Weight filters
minVolumeCBM?: number; minVolumeCBM?: number;
maxVolumeCBM?: number; maxVolumeCBM?: number;
minWeightKG?: number; minWeightKG?: number;
maxWeightKG?: number; maxWeightKG?: number;
palletCount?: number; // Exact pallet count (0 = any) palletCount?: number; // Exact pallet count (0 = any)
// Price filters // Price filters
minPrice?: number; minPrice?: number;
maxPrice?: number; maxPrice?: number;
currency?: 'USD' | 'EUR'; // Preferred currency for filtering currency?: 'USD' | 'EUR'; // Preferred currency for filtering
// Transit filters // Transit filters
minTransitDays?: number; minTransitDays?: number;
maxTransitDays?: number; maxTransitDays?: number;
// Container type filters // Container type filters
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC'] containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
// Surcharge filters // Surcharge filters
onlyAllInPrices?: boolean; // Only show rates without separate surcharges onlyAllInPrices?: boolean; // Only show rates without separate surcharges
// Date filters // Date filters
departureDate?: Date; // Filter by validity for specific date departureDate?: Date; // Filter by validity for specific date
} }
/** /**
* CSV Rate Search Input * CSV Rate Search Input
* *
* Parameters for searching rates in CSV system * Parameters for searching rates in CSV system
*/ */
export interface CsvRateSearchInput { export interface CsvRateSearchInput {
origin: string; // Port code (UN/LOCODE) origin: string; // Port code (UN/LOCODE)
destination: string; // Port code (UN/LOCODE) destination: string; // Port code (UN/LOCODE)
volumeCBM: number; // Volume in cubic meters volumeCBM: number; // Volume in cubic meters
weightKG: number; // Weight in kilograms weightKG: number; // Weight in kilograms
palletCount?: number; // Number of pallets (0 if none) palletCount?: number; // Number of pallets (0 if none)
containerType?: string; // Optional container type filter containerType?: string; // Optional container type filter
filters?: RateSearchFilters; // Advanced filters filters?: RateSearchFilters; // Advanced filters
} }
/** /**
* CSV Rate Search Result * CSV Rate Search Result
* *
* Single rate result with calculated price * Single rate result with calculated price
*/ */
export interface CsvRateSearchResult { export interface CsvRateSearchResult {
rate: CsvRate; rate: CsvRate;
calculatedPrice: { calculatedPrice: {
usd: number; usd: number;
eur: number; eur: number;
primaryCurrency: string; primaryCurrency: string;
}; };
source: 'CSV'; source: 'CSV';
matchScore: number; // 0-100, how well it matches filters matchScore: number; // 0-100, how well it matches filters
} }
/** /**
* CSV Rate Search Output * CSV Rate Search Output
* *
* Results from CSV rate search * Results from CSV rate search
*/ */
export interface CsvRateSearchOutput { export interface CsvRateSearchOutput {
results: CsvRateSearchResult[]; results: CsvRateSearchResult[];
totalResults: number; totalResults: number;
searchedFiles: string[]; // CSV files searched searchedFiles: string[]; // CSV files searched
searchedAt: Date; searchedAt: Date;
appliedFilters: RateSearchFilters; appliedFilters: RateSearchFilters;
} }
/** /**
* Search CSV Rates Port (Input Port) * Search CSV Rates Port (Input Port)
* *
* Use case for searching rates in CSV-based system * Use case for searching rates in CSV-based system
* Supports advanced filters for precise rate matching * Supports advanced filters for precise rate matching
*/ */
export interface SearchCsvRatesPort { export interface SearchCsvRatesPort {
/** /**
* Execute CSV rate search with filters * Execute CSV rate search with filters
* @param input - Search parameters and filters * @param input - Search parameters and filters
* @returns Matching rates with calculated prices * @returns Matching rates with calculated prices
*/ */
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>; execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/** /**
* Get available companies in CSV system * Get available companies in CSV system
* @returns List of company names that have CSV rates * @returns List of company names that have CSV rates
*/ */
getAvailableCompanies(): Promise<string[]>; getAvailableCompanies(): Promise<string[]>;
/** /**
* Get available container types in CSV system * Get available container types in CSV system
* @returns List of container types available * @returns List of container types available
*/ */
getAvailableContainerTypes(): Promise<string[]>; getAvailableContainerTypes(): Promise<string[]>;
} }

View File

@ -1,44 +1,44 @@
/** /**
* SearchRatesPort (API Port - Input) * SearchRatesPort (API Port - Input)
* *
* Defines the interface for searching shipping rates * Defines the interface for searching shipping rates
* This is the entry point for the rate search use case * This is the entry point for the rate search use case
*/ */
import { RateQuote } from '../../entities/rate-quote.entity'; import { RateQuote } from '../../entities/rate-quote.entity';
export interface RateSearchInput { export interface RateSearchInput {
origin: string; // Port code (UN/LOCODE) origin: string; // Port code (UN/LOCODE)
destination: string; // Port code (UN/LOCODE) destination: string; // Port code (UN/LOCODE)
containerType: string; // e.g., '20DRY', '40HC' containerType: string; // e.g., '20DRY', '40HC'
mode: 'FCL' | 'LCL'; mode: 'FCL' | 'LCL';
departureDate: Date; departureDate: Date;
quantity?: number; // Number of containers (default: 1) quantity?: number; // Number of containers (default: 1)
weight?: number; // For LCL (kg) weight?: number; // For LCL (kg)
volume?: number; // For LCL (CBM) volume?: number; // For LCL (CBM)
isHazmat?: boolean; isHazmat?: boolean;
imoClass?: string; // If hazmat imoClass?: string; // If hazmat
carrierPreferences?: string[]; // Specific carrier codes to query carrierPreferences?: string[]; // Specific carrier codes to query
} }
export interface RateSearchOutput { export interface RateSearchOutput {
quotes: RateQuote[]; quotes: RateQuote[];
searchId: string; searchId: string;
searchedAt: Date; searchedAt: Date;
totalResults: number; totalResults: number;
carrierResults: { carrierResults: {
carrierName: string; carrierName: string;
status: 'success' | 'error' | 'timeout'; status: 'success' | 'error' | 'timeout';
resultCount: number; resultCount: number;
errorMessage?: string; errorMessage?: string;
}[]; }[];
} }
export interface SearchRatesPort { export interface SearchRatesPort {
/** /**
* Execute rate search across multiple carriers * Execute rate search across multiple carriers
* @param input - Rate search parameters * @param input - Rate search parameters
* @returns Rate quotes from available carriers * @returns Rate quotes from available carriers
*/ */
execute(input: RateSearchInput): Promise<RateSearchOutput>; execute(input: RateSearchInput): Promise<RateSearchOutput>;
} }

View File

@ -1,27 +1,27 @@
/** /**
* ValidateAvailabilityPort (API Port - Input) * ValidateAvailabilityPort (API Port - Input)
* *
* Defines the interface for validating container availability * Defines the interface for validating container availability
*/ */
export interface AvailabilityInput { export interface AvailabilityInput {
rateQuoteId: string; rateQuoteId: string;
quantity: number; // Number of containers requested quantity: number; // Number of containers requested
} }
export interface AvailabilityOutput { export interface AvailabilityOutput {
isAvailable: boolean; isAvailable: boolean;
availableQuantity: number; availableQuantity: number;
requestedQuantity: number; requestedQuantity: number;
rateQuoteId: string; rateQuoteId: string;
validUntil: Date; validUntil: Date;
} }
export interface ValidateAvailabilityPort { export interface ValidateAvailabilityPort {
/** /**
* Validate if containers are available for a rate quote * Validate if containers are available for a rate quote
* @param input - Availability check parameters * @param input - Availability check parameters
* @returns Availability status * @returns Availability status
*/ */
execute(input: AvailabilityInput): Promise<AvailabilityOutput>; execute(input: AvailabilityInput): Promise<AvailabilityOutput>;
} }

View File

@ -1,48 +1,48 @@
/** /**
* AvailabilityValidationService * AvailabilityValidationService
* *
* Domain service for validating container availability * Domain service for validating container availability
* *
* Business Rules: * Business Rules:
* - Check if rate quote is still valid (not expired) * - Check if rate quote is still valid (not expired)
* - Verify requested quantity is available * - Verify requested quantity is available
*/ */
import { import {
ValidateAvailabilityPort, ValidateAvailabilityPort,
AvailabilityInput, AvailabilityInput,
AvailabilityOutput, AvailabilityOutput,
} from '../ports/in/validate-availability.port'; } from '../ports/in/validate-availability.port';
import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
import { InvalidRateQuoteException } from '../exceptions/invalid-rate-quote.exception'; import { InvalidRateQuoteException } from '../exceptions/invalid-rate-quote.exception';
import { RateQuoteExpiredException } from '../exceptions/rate-quote-expired.exception'; import { RateQuoteExpiredException } from '../exceptions/rate-quote-expired.exception';
export class AvailabilityValidationService implements ValidateAvailabilityPort { export class AvailabilityValidationService implements ValidateAvailabilityPort {
constructor(private readonly rateQuoteRepository: RateQuoteRepository) {} constructor(private readonly rateQuoteRepository: RateQuoteRepository) {}
async execute(input: AvailabilityInput): Promise<AvailabilityOutput> { async execute(input: AvailabilityInput): Promise<AvailabilityOutput> {
// Find rate quote // Find rate quote
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId); const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
if (!rateQuote) { if (!rateQuote) {
throw new InvalidRateQuoteException(`Rate quote not found: ${input.rateQuoteId}`); throw new InvalidRateQuoteException(`Rate quote not found: ${input.rateQuoteId}`);
} }
// Check if rate quote has expired // Check if rate quote has expired
if (rateQuote.isExpired()) { if (rateQuote.isExpired()) {
throw new RateQuoteExpiredException(rateQuote.id, rateQuote.validUntil); throw new RateQuoteExpiredException(rateQuote.id, rateQuote.validUntil);
} }
// Check availability // Check availability
const availableQuantity = rateQuote.availability; const availableQuantity = rateQuote.availability;
const isAvailable = availableQuantity >= input.quantity; const isAvailable = availableQuantity >= input.quantity;
return { return {
isAvailable, isAvailable,
availableQuantity, availableQuantity,
requestedQuantity: input.quantity, requestedQuantity: input.quantity,
rateQuoteId: rateQuote.id, rateQuoteId: rateQuote.id,
validUntil: rateQuote.validUntil, validUntil: rateQuote.validUntil,
}; };
} }
} }

View File

@ -1,284 +1,250 @@
import { CsvRate } from '../entities/csv-rate.entity'; import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Volume } from '../value-objects/volume.vo'; import { Volume } from '../value-objects/volume.vo';
import { Money } from '../value-objects/money.vo'; import { Money } from '../value-objects/money.vo';
import { import {
SearchCsvRatesPort, SearchCsvRatesPort,
CsvRateSearchInput, CsvRateSearchInput,
CsvRateSearchOutput, CsvRateSearchOutput,
CsvRateSearchResult, CsvRateSearchResult,
RateSearchFilters, RateSearchFilters,
} from '../ports/in/search-csv-rates.port'; } from '../ports/in/search-csv-rates.port';
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
/** /**
* CSV Rate Search Service * CSV Rate Search Service
* *
* Domain service implementing CSV rate search use case. * Domain service implementing CSV rate search use case.
* Applies business rules for matching rates and filtering. * Applies business rules for matching rates and filtering.
* *
* Pure domain logic - no framework dependencies. * Pure domain logic - no framework dependencies.
*/ */
export class CsvRateSearchService implements SearchCsvRatesPort { export class CsvRateSearchService implements SearchCsvRatesPort {
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {} constructor(private readonly csvRateLoader: CsvRateLoaderPort) {}
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> { async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date(); const searchStartTime = new Date();
// Parse and validate input // Parse and validate input
const origin = PortCode.create(input.origin); const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination); const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG); const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0; const palletCount = input.palletCount ?? 0;
// Load all CSV rates // Load all CSV rates
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
// Apply route and volume matching // Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination); let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume); matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount); matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified // Apply container type filter if specified
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter((rate) => matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
rate.containerType.equals(containerType), }
);
} // Apply advanced filters
if (input.filters) {
// Apply advanced filters matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
if (input.filters) { }
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
} // Calculate prices and create results
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
// Calculate prices and create results const priceUSD = rate.getPriceInCurrency(volume, 'USD');
const results: CsvRateSearchResult[] = matchingRates.map((rate) => { const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
const priceEUR = rate.getPriceInCurrency(volume, 'EUR'); return {
rate,
return { calculatedPrice: {
rate, usd: priceUSD.getAmount(),
calculatedPrice: { eur: priceEUR.getAmount(),
usd: priceUSD.getAmount(), primaryCurrency: rate.currency,
eur: priceEUR.getAmount(), },
primaryCurrency: rate.currency, source: 'CSV' as const,
}, matchScore: this.calculateMatchScore(rate, input),
source: 'CSV' as const, };
matchScore: this.calculateMatchScore(rate, input), });
};
}); // Sort by price (ascending) in primary currency
results.sort((a, b) => {
// Sort by price (ascending) in primary currency const priceA =
results.sort((a, b) => { a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur;
const priceA = const priceB =
a.calculatedPrice.primaryCurrency === 'USD' b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur;
? a.calculatedPrice.usd return priceA - priceB;
: a.calculatedPrice.eur; });
const priceB =
b.calculatedPrice.primaryCurrency === 'USD' return {
? b.calculatedPrice.usd results,
: b.calculatedPrice.eur; totalResults: results.length,
return priceA - priceB; searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(),
}); searchedAt: searchStartTime,
appliedFilters: input.filters || {},
return { };
results, }
totalResults: results.length,
searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(), async getAvailableCompanies(): Promise<string[]> {
searchedAt: searchStartTime, const allRates = await this.loadAllRates();
appliedFilters: input.filters || {}, const companies = new Set(allRates.map(rate => rate.companyName));
}; return Array.from(companies).sort();
} }
async getAvailableCompanies(): Promise<string[]> { async getAvailableContainerTypes(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const companies = new Set(allRates.map((rate) => rate.companyName)); const types = new Set(allRates.map(rate => rate.containerType.getValue()));
return Array.from(companies).sort(); return Array.from(types).sort();
} }
async getAvailableContainerTypes(): Promise<string[]> { /**
const allRates = await this.loadAllRates(); * Load all rates from all CSV files
const types = new Set(allRates.map((rate) => rate.containerType.getValue())); */
return Array.from(types).sort(); private async loadAllRates(): Promise<CsvRate[]> {
} const files = await this.csvRateLoader.getAvailableCsvFiles();
const ratePromises = files.map(file => this.csvRateLoader.loadRatesFromCsv(file));
/** const rateArrays = await Promise.all(ratePromises);
* Load all rates from all CSV files return rateArrays.flat();
*/ }
private async loadAllRates(): Promise<CsvRate[]> {
const files = await this.csvRateLoader.getAvailableCsvFiles(); /**
const ratePromises = files.map((file) => * Filter rates by route (origin/destination)
this.csvRateLoader.loadRatesFromCsv(file), */
); private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
const rateArrays = await Promise.all(ratePromises); return rates.filter(rate => rate.matchesRoute(origin, destination));
return rateArrays.flat(); }
}
/**
/** * Filter rates by volume/weight range
* Filter rates by route (origin/destination) */
*/ private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
private filterByRoute( return rates.filter(rate => rate.matchesVolume(volume));
rates: CsvRate[], }
origin: PortCode,
destination: PortCode, /**
): CsvRate[] { * Filter rates by pallet count
return rates.filter((rate) => rate.matchesRoute(origin, destination)); */
} private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
return rates.filter(rate => rate.matchesPalletCount(palletCount));
/** }
* Filter rates by volume/weight range
*/ /**
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] { * Apply advanced filters to rate list
return rates.filter((rate) => rate.matchesVolume(volume)); */
} private applyAdvancedFilters(
rates: CsvRate[],
/** filters: RateSearchFilters,
* Filter rates by pallet count volume: Volume
*/ ): CsvRate[] {
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] { let filtered = rates;
return rates.filter((rate) => rate.matchesPalletCount(palletCount));
} // Company filter
if (filters.companies && filters.companies.length > 0) {
/** filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
* Apply advanced filters to rate list }
*/
private applyAdvancedFilters( // Volume CBM filter
rates: CsvRate[], if (filters.minVolumeCBM !== undefined) {
filters: RateSearchFilters, filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
volume: Volume, }
): CsvRate[] { if (filters.maxVolumeCBM !== undefined) {
let filtered = rates; filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
}
// Company filter
if (filters.companies && filters.companies.length > 0) { // Weight KG filter
filtered = filtered.filter((rate) => if (filters.minWeightKG !== undefined) {
filters.companies!.includes(rate.companyName), filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
); }
} if (filters.maxWeightKG !== undefined) {
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
// Volume CBM filter }
if (filters.minVolumeCBM !== undefined) {
filtered = filtered.filter( // Pallet count filter
(rate) => rate.volumeRange.maxCBM >= filters.minVolumeCBM!, if (filters.palletCount !== undefined) {
); filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
} }
if (filters.maxVolumeCBM !== undefined) {
filtered = filtered.filter( // Price filter (calculate price first)
(rate) => rate.volumeRange.minCBM <= filters.maxVolumeCBM!, if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
); const currency = filters.currency || 'USD';
} filtered = filtered.filter(rate => {
const price = rate.getPriceInCurrency(volume, currency);
// Weight KG filter const amount = price.getAmount();
if (filters.minWeightKG !== undefined) {
filtered = filtered.filter( if (filters.minPrice !== undefined && amount < filters.minPrice) {
(rate) => rate.weightRange.maxKG >= filters.minWeightKG!, return false;
); }
} if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
if (filters.maxWeightKG !== undefined) { return false;
filtered = filtered.filter( }
(rate) => rate.weightRange.minKG <= filters.maxWeightKG!, return true;
); });
} }
// Pallet count filter // Transit days filter
if (filters.palletCount !== undefined) { if (filters.minTransitDays !== undefined) {
filtered = filtered.filter((rate) => filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
rate.matchesPalletCount(filters.palletCount!), }
); if (filters.maxTransitDays !== undefined) {
} filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
}
// Price filter (calculate price first)
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { // Container type filter
const currency = filters.currency || 'USD'; if (filters.containerTypes && filters.containerTypes.length > 0) {
filtered = filtered.filter((rate) => { filtered = filtered.filter(rate =>
const price = rate.getPriceInCurrency(volume, currency); filters.containerTypes!.includes(rate.containerType.getValue())
const amount = price.getAmount(); );
}
if (filters.minPrice !== undefined && amount < filters.minPrice) {
return false; // All-in prices only filter
} if (filters.onlyAllInPrices) {
if (filters.maxPrice !== undefined && amount > filters.maxPrice) { filtered = filtered.filter(rate => rate.isAllInPrice());
return false; }
}
return true; // Departure date / validity filter
}); if (filters.departureDate) {
} filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
}
// Transit days filter
if (filters.minTransitDays !== undefined) { return filtered;
filtered = filtered.filter( }
(rate) => rate.transitDays >= filters.minTransitDays!,
); /**
} * Calculate match score (0-100) based on how well rate matches input
if (filters.maxTransitDays !== undefined) { * Higher score = better match
filtered = filtered.filter( */
(rate) => rate.transitDays <= filters.maxTransitDays!, private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
); let score = 100;
}
// Reduce score if volume/weight is near boundaries
// Container type filter const volumeUtilization =
if (filters.containerTypes && filters.containerTypes.length > 0) { (input.volumeCBM - rate.volumeRange.minCBM) /
filtered = filtered.filter((rate) => (rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
filters.containerTypes!.includes(rate.containerType.getValue()), if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
); score -= 10; // Near boundaries
} }
// All-in prices only filter // Reduce score if pallet count doesn't match exactly
if (filters.onlyAllInPrices) { if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
filtered = filtered.filter((rate) => rate.isAllInPrice()); score -= 5;
} }
// Departure date / validity filter // Increase score for all-in prices (simpler for customers)
if (filters.departureDate) { if (rate.isAllInPrice()) {
filtered = filtered.filter((rate) => score += 5;
rate.isValidForDate(filters.departureDate!), }
);
} // Reduce score for rates expiring soon
const daysUntilExpiry = Math.floor(
return filtered; (rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
} );
if (daysUntilExpiry < 7) {
/** score -= 10;
* Calculate match score (0-100) based on how well rate matches input } else if (daysUntilExpiry < 30) {
* Higher score = better match score -= 5;
*/ }
private calculateMatchScore(
rate: CsvRate, return Math.max(0, Math.min(100, score));
input: CsvRateSearchInput, }
): number { }
let score = 100;
// Reduce score if volume/weight is near boundaries
const volumeUtilization =
(input.volumeCBM - rate.volumeRange.minCBM) /
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
score -= 10; // Near boundaries
}
// Reduce score if pallet count doesn't match exactly
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
score -= 5;
}
// Increase score for all-in prices (simpler for customers)
if (rate.isAllInPrice()) {
score += 5;
}
// Reduce score for rates expiring soon
const daysUntilExpiry = Math.floor(
(rate.validity.getEndDate().getTime() - Date.now()) /
(1000 * 60 * 60 * 24),
);
if (daysUntilExpiry < 7) {
score -= 10;
} else if (daysUntilExpiry < 30) {
score -= 5;
}
return Math.max(0, Math.min(100, score));
}
}

View File

@ -1,10 +1,10 @@
/** /**
* Domain Services Barrel Export * Domain Services Barrel Export
* *
* All domain services for the Xpeditis platform * All domain services for the Xpeditis platform
*/ */
export * from './rate-search.service'; export * from './rate-search.service';
export * from './port-search.service'; export * from './port-search.service';
export * from './availability-validation.service'; export * from './availability-validation.service';
export * from './booking.service'; export * from './booking.service';

View File

@ -1,65 +1,70 @@
/** /**
* PortSearchService * PortSearchService
* *
* Domain service for port search and autocomplete * Domain service for port search and autocomplete
* *
* Business Rules: * Business Rules:
* - Fuzzy search on port name, city, and code * - Fuzzy search on port name, city, and code
* - Return top 10 results by default * - Return top 10 results by default
* - Support country filtering * - Support country filtering
*/ */
import { Port } from '../entities/port.entity'; import { Port } from '../entities/port.entity';
import { GetPortsPort, PortSearchInput, PortSearchOutput, GetPortInput } from '../ports/in/get-ports.port'; import {
import { PortRepository } from '../ports/out/port.repository'; GetPortsPort,
import { PortNotFoundException } from '../exceptions/port-not-found.exception'; PortSearchInput,
PortSearchOutput,
export class PortSearchService implements GetPortsPort { GetPortInput,
private static readonly DEFAULT_LIMIT = 10; } from '../ports/in/get-ports.port';
import { PortRepository } from '../ports/out/port.repository';
constructor(private readonly portRepository: PortRepository) {} import { PortNotFoundException } from '../exceptions/port-not-found.exception';
async search(input: PortSearchInput): Promise<PortSearchOutput> { export class PortSearchService implements GetPortsPort {
const limit = input.limit || PortSearchService.DEFAULT_LIMIT; private static readonly DEFAULT_LIMIT = 10;
const query = input.query.trim();
constructor(private readonly portRepository: PortRepository) {}
if (query.length === 0) {
return { async search(input: PortSearchInput): Promise<PortSearchOutput> {
ports: [], const limit = input.limit || PortSearchService.DEFAULT_LIMIT;
totalMatches: 0, const query = input.query.trim();
};
} if (query.length === 0) {
return {
// Search using repository fuzzy search ports: [],
const ports = await this.portRepository.search(query, limit, input.countryFilter); totalMatches: 0,
};
return { }
ports,
totalMatches: ports.length, // Search using repository fuzzy search
}; const ports = await this.portRepository.search(query, limit, input.countryFilter);
}
return {
async getByCode(input: GetPortInput): Promise<Port> { ports,
const port = await this.portRepository.findByCode(input.portCode); totalMatches: ports.length,
};
if (!port) { }
throw new PortNotFoundException(input.portCode);
} async getByCode(input: GetPortInput): Promise<Port> {
const port = await this.portRepository.findByCode(input.portCode);
return port;
} if (!port) {
throw new PortNotFoundException(input.portCode);
async getByCodes(portCodes: string[]): Promise<Port[]> { }
const ports = await this.portRepository.findByCodes(portCodes);
return port;
// Check if all ports were found }
const foundCodes = ports.map((p) => p.code);
const missingCodes = portCodes.filter((code) => !foundCodes.includes(code)); async getByCodes(portCodes: string[]): Promise<Port[]> {
const ports = await this.portRepository.findByCodes(portCodes);
if (missingCodes.length > 0) {
throw new PortNotFoundException(missingCodes[0]); // Check if all ports were found
} const foundCodes = ports.map(p => p.code);
const missingCodes = portCodes.filter(code => !foundCodes.includes(code));
return ports;
} if (missingCodes.length > 0) {
} throw new PortNotFoundException(missingCodes[0]);
}
return ports;
}
}

View File

@ -1,165 +1,165 @@
/** /**
* RateSearchService * RateSearchService
* *
* Domain service implementing the rate search business logic * Domain service implementing the rate search business logic
* *
* Business Rules: * Business Rules:
* - Query multiple carriers in parallel * - Query multiple carriers in parallel
* - Cache results for 15 minutes * - Cache results for 15 minutes
* - Handle carrier timeouts gracefully (5s max) * - Handle carrier timeouts gracefully (5s max)
* - Return results even if some carriers fail * - Return results even if some carriers fail
*/ */
import { RateQuote } from '../entities/rate-quote.entity'; import { RateQuote } from '../entities/rate-quote.entity';
import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port'; import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port';
import { CarrierConnectorPort } from '../ports/out/carrier-connector.port'; import { CarrierConnectorPort } from '../ports/out/carrier-connector.port';
import { CachePort } from '../ports/out/cache.port'; import { CachePort } from '../ports/out/cache.port';
import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
import { PortRepository } from '../ports/out/port.repository'; import { PortRepository } from '../ports/out/port.repository';
import { CarrierRepository } from '../ports/out/carrier.repository'; import { CarrierRepository } from '../ports/out/carrier.repository';
import { PortNotFoundException } from '../exceptions/port-not-found.exception'; import { PortNotFoundException } from '../exceptions/port-not-found.exception';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export class RateSearchService implements SearchRatesPort { export class RateSearchService implements SearchRatesPort {
private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
constructor( constructor(
private readonly carrierConnectors: CarrierConnectorPort[], private readonly carrierConnectors: CarrierConnectorPort[],
private readonly cache: CachePort, private readonly cache: CachePort,
private readonly rateQuoteRepository: RateQuoteRepository, private readonly rateQuoteRepository: RateQuoteRepository,
private readonly portRepository: PortRepository, private readonly portRepository: PortRepository,
private readonly carrierRepository: CarrierRepository private readonly carrierRepository: CarrierRepository
) {} ) {}
async execute(input: RateSearchInput): Promise<RateSearchOutput> { async execute(input: RateSearchInput): Promise<RateSearchOutput> {
const searchId = uuidv4(); const searchId = uuidv4();
const searchedAt = new Date(); const searchedAt = new Date();
// Validate ports exist // Validate ports exist
await this.validatePorts(input.origin, input.destination); await this.validatePorts(input.origin, input.destination);
// Generate cache key // Generate cache key
const cacheKey = this.generateCacheKey(input); const cacheKey = this.generateCacheKey(input);
// Check cache first // Check cache first
const cachedResults = await this.cache.get<RateSearchOutput>(cacheKey); const cachedResults = await this.cache.get<RateSearchOutput>(cacheKey);
if (cachedResults) { if (cachedResults) {
return cachedResults; return cachedResults;
} }
// Filter carriers if preferences specified // Filter carriers if preferences specified
const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences); const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences);
// Query all carriers in parallel with Promise.allSettled // Query all carriers in parallel with Promise.allSettled
const carrierResults = await Promise.allSettled( const carrierResults = await Promise.allSettled(
connectorsToQuery.map((connector) => this.queryCarrier(connector, input)) connectorsToQuery.map(connector => this.queryCarrier(connector, input))
); );
// Process results // Process results
const quotes: RateQuote[] = []; const quotes: RateQuote[] = [];
const carrierResultsSummary: RateSearchOutput['carrierResults'] = []; const carrierResultsSummary: RateSearchOutput['carrierResults'] = [];
for (let i = 0; i < carrierResults.length; i++) { for (let i = 0; i < carrierResults.length; i++) {
const result = carrierResults[i]; const result = carrierResults[i];
const connector = connectorsToQuery[i]; const connector = connectorsToQuery[i];
const carrierName = connector.getCarrierName(); const carrierName = connector.getCarrierName();
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
const carrierQuotes = result.value; const carrierQuotes = result.value;
quotes.push(...carrierQuotes); quotes.push(...carrierQuotes);
carrierResultsSummary.push({ carrierResultsSummary.push({
carrierName, carrierName,
status: 'success', status: 'success',
resultCount: carrierQuotes.length, resultCount: carrierQuotes.length,
}); });
} else { } else {
// Handle error // Handle error
const error = result.reason; const error = result.reason;
carrierResultsSummary.push({ carrierResultsSummary.push({
carrierName, carrierName,
status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error', status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error',
resultCount: 0, resultCount: 0,
errorMessage: error.message, errorMessage: error.message,
}); });
} }
} }
// Save rate quotes to database // Save rate quotes to database
if (quotes.length > 0) { if (quotes.length > 0) {
await this.rateQuoteRepository.saveMany(quotes); await this.rateQuoteRepository.saveMany(quotes);
} }
// Build output // Build output
const output: RateSearchOutput = { const output: RateSearchOutput = {
quotes, quotes,
searchId, searchId,
searchedAt, searchedAt,
totalResults: quotes.length, totalResults: quotes.length,
carrierResults: carrierResultsSummary, carrierResults: carrierResultsSummary,
}; };
// Cache results // Cache results
await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS); await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS);
return output; return output;
} }
private async validatePorts(originCode: string, destinationCode: string): Promise<void> { private async validatePorts(originCode: string, destinationCode: string): Promise<void> {
const [origin, destination] = await Promise.all([ const [origin, destination] = await Promise.all([
this.portRepository.findByCode(originCode), this.portRepository.findByCode(originCode),
this.portRepository.findByCode(destinationCode), this.portRepository.findByCode(destinationCode),
]); ]);
if (!origin) { if (!origin) {
throw new PortNotFoundException(originCode); throw new PortNotFoundException(originCode);
} }
if (!destination) { if (!destination) {
throw new PortNotFoundException(destinationCode); throw new PortNotFoundException(destinationCode);
} }
} }
private generateCacheKey(input: RateSearchInput): string { private generateCacheKey(input: RateSearchInput): string {
const parts = [ const parts = [
'rate-search', 'rate-search',
input.origin, input.origin,
input.destination, input.destination,
input.containerType, input.containerType,
input.mode, input.mode,
input.departureDate.toISOString().split('T')[0], input.departureDate.toISOString().split('T')[0],
input.quantity || 1, input.quantity || 1,
input.isHazmat ? 'hazmat' : 'standard', input.isHazmat ? 'hazmat' : 'standard',
]; ];
return parts.join(':'); return parts.join(':');
} }
private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] { private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] {
if (!carrierPreferences || carrierPreferences.length === 0) { if (!carrierPreferences || carrierPreferences.length === 0) {
return this.carrierConnectors; return this.carrierConnectors;
} }
return this.carrierConnectors.filter((connector) => return this.carrierConnectors.filter(connector =>
carrierPreferences.includes(connector.getCarrierCode()) carrierPreferences.includes(connector.getCarrierCode())
); );
} }
private async queryCarrier( private async queryCarrier(
connector: CarrierConnectorPort, connector: CarrierConnectorPort,
input: RateSearchInput input: RateSearchInput
): Promise<RateQuote[]> { ): Promise<RateQuote[]> {
return connector.searchRates({ return connector.searchRates({
origin: input.origin, origin: input.origin,
destination: input.destination, destination: input.destination,
containerType: input.containerType, containerType: input.containerType,
mode: input.mode, mode: input.mode,
departureDate: input.departureDate, departureDate: input.departureDate,
quantity: input.quantity, quantity: input.quantity,
weight: input.weight, weight: input.weight,
volume: input.volume, volume: input.volume,
isHazmat: input.isHazmat, isHazmat: input.isHazmat,
imoClass: input.imoClass, imoClass: input.imoClass,
}); });
} }
} }

View File

@ -1,77 +1,77 @@
/** /**
* BookingNumber Value Object * BookingNumber Value Object
* *
* Represents a unique booking reference number * Represents a unique booking reference number
* Format: WCM-YYYY-XXXXXX (e.g., WCM-2025-ABC123) * Format: WCM-YYYY-XXXXXX (e.g., WCM-2025-ABC123)
* - WCM: WebCargo Maritime prefix * - WCM: WebCargo Maritime prefix
* - YYYY: Current year * - YYYY: Current year
* - XXXXXX: 6 alphanumeric characters * - XXXXXX: 6 alphanumeric characters
*/ */
import { InvalidBookingNumberException } from '../exceptions/invalid-booking-number.exception'; import { InvalidBookingNumberException } from '../exceptions/invalid-booking-number.exception';
export class BookingNumber { export class BookingNumber {
private readonly _value: string; private readonly _value: string;
private constructor(value: string) { private constructor(value: string) {
this._value = value; this._value = value;
} }
get value(): string { get value(): string {
return this._value; return this._value;
} }
/** /**
* Generate a new booking number * Generate a new booking number
*/ */
static generate(): BookingNumber { static generate(): BookingNumber {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const random = BookingNumber.generateRandomString(6); const random = BookingNumber.generateRandomString(6);
const value = `WCM-${year}-${random}`; const value = `WCM-${year}-${random}`;
return new BookingNumber(value); return new BookingNumber(value);
} }
/** /**
* Create BookingNumber from string * Create BookingNumber from string
*/ */
static fromString(value: string): BookingNumber { static fromString(value: string): BookingNumber {
if (!BookingNumber.isValid(value)) { if (!BookingNumber.isValid(value)) {
throw new InvalidBookingNumberException(value); throw new InvalidBookingNumberException(value);
} }
return new BookingNumber(value); return new BookingNumber(value);
} }
/** /**
* Validate booking number format * Validate booking number format
*/ */
static isValid(value: string): boolean { static isValid(value: string): boolean {
const pattern = /^WCM-\d{4}-[A-Z0-9]{6}$/; const pattern = /^WCM-\d{4}-[A-Z0-9]{6}$/;
return pattern.test(value); return pattern.test(value);
} }
/** /**
* Generate random alphanumeric string * Generate random alphanumeric string
*/ */
private static generateRandomString(length: number): string { private static generateRandomString(length: number): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous chars: 0,O,1,I const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous chars: 0,O,1,I
let result = ''; let result = '';
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)); result += chars.charAt(Math.floor(Math.random() * chars.length));
} }
return result; return result;
} }
/** /**
* Equality check * Equality check
*/ */
equals(other: BookingNumber): boolean { equals(other: BookingNumber): boolean {
return this._value === other._value; return this._value === other._value;
} }
/** /**
* String representation * String representation
*/ */
toString(): string { toString(): string {
return this._value; return this._value;
} }
} }

View File

@ -1,110 +1,108 @@
/** /**
* BookingStatus Value Object * BookingStatus Value Object
* *
* Represents the current status of a booking * Represents the current status of a booking
*/ */
import { InvalidBookingStatusException } from '../exceptions/invalid-booking-status.exception'; import { InvalidBookingStatusException } from '../exceptions/invalid-booking-status.exception';
export type BookingStatusValue = export type BookingStatusValue =
| 'draft' | 'draft'
| 'pending_confirmation' | 'pending_confirmation'
| 'confirmed' | 'confirmed'
| 'in_transit' | 'in_transit'
| 'delivered' | 'delivered'
| 'cancelled'; | 'cancelled';
export class BookingStatus { export class BookingStatus {
private static readonly VALID_STATUSES: BookingStatusValue[] = [ private static readonly VALID_STATUSES: BookingStatusValue[] = [
'draft', 'draft',
'pending_confirmation', 'pending_confirmation',
'confirmed', 'confirmed',
'in_transit', 'in_transit',
'delivered', 'delivered',
'cancelled', 'cancelled',
]; ];
private static readonly STATUS_TRANSITIONS: Record<BookingStatusValue, BookingStatusValue[]> = { private static readonly STATUS_TRANSITIONS: Record<BookingStatusValue, BookingStatusValue[]> = {
draft: ['pending_confirmation', 'cancelled'], draft: ['pending_confirmation', 'cancelled'],
pending_confirmation: ['confirmed', 'cancelled'], pending_confirmation: ['confirmed', 'cancelled'],
confirmed: ['in_transit', 'cancelled'], confirmed: ['in_transit', 'cancelled'],
in_transit: ['delivered', 'cancelled'], in_transit: ['delivered', 'cancelled'],
delivered: [], delivered: [],
cancelled: [], cancelled: [],
}; };
private readonly _value: BookingStatusValue; private readonly _value: BookingStatusValue;
private constructor(value: BookingStatusValue) { private constructor(value: BookingStatusValue) {
this._value = value; this._value = value;
} }
get value(): BookingStatusValue { get value(): BookingStatusValue {
return this._value; return this._value;
} }
/** /**
* Create BookingStatus from string * Create BookingStatus from string
*/ */
static create(value: string): BookingStatus { static create(value: string): BookingStatus {
if (!BookingStatus.isValid(value)) { if (!BookingStatus.isValid(value)) {
throw new InvalidBookingStatusException(value); throw new InvalidBookingStatusException(value);
} }
return new BookingStatus(value as BookingStatusValue); return new BookingStatus(value as BookingStatusValue);
} }
/** /**
* Validate status value * Validate status value
*/ */
static isValid(value: string): boolean { static isValid(value: string): boolean {
return BookingStatus.VALID_STATUSES.includes(value as BookingStatusValue); return BookingStatus.VALID_STATUSES.includes(value as BookingStatusValue);
} }
/** /**
* Check if transition to another status is allowed * Check if transition to another status is allowed
*/ */
canTransitionTo(newStatus: BookingStatus): boolean { canTransitionTo(newStatus: BookingStatus): boolean {
const allowedTransitions = BookingStatus.STATUS_TRANSITIONS[this._value]; const allowedTransitions = BookingStatus.STATUS_TRANSITIONS[this._value];
return allowedTransitions.includes(newStatus._value); return allowedTransitions.includes(newStatus._value);
} }
/** /**
* Transition to new status * Transition to new status
*/ */
transitionTo(newStatus: BookingStatus): BookingStatus { transitionTo(newStatus: BookingStatus): BookingStatus {
if (!this.canTransitionTo(newStatus)) { if (!this.canTransitionTo(newStatus)) {
throw new Error( throw new Error(`Invalid status transition from ${this._value} to ${newStatus._value}`);
`Invalid status transition from ${this._value} to ${newStatus._value}` }
); return newStatus;
} }
return newStatus;
} /**
* Check if booking is in a final state
/** */
* Check if booking is in a final state isFinal(): boolean {
*/ return this._value === 'delivered' || this._value === 'cancelled';
isFinal(): boolean { }
return this._value === 'delivered' || this._value === 'cancelled';
} /**
* Check if booking can be modified
/** */
* Check if booking can be modified canBeModified(): boolean {
*/ return this._value === 'draft' || this._value === 'pending_confirmation';
canBeModified(): boolean { }
return this._value === 'draft' || this._value === 'pending_confirmation';
} /**
* Equality check
/** */
* Equality check equals(other: BookingStatus): boolean {
*/ return this._value === other._value;
equals(other: BookingStatus): boolean { }
return this._value === other._value;
} /**
* String representation
/** */
* String representation toString(): string {
*/ return this._value;
toString(): string { }
return this._value; }
}
}

View File

@ -1,112 +1,112 @@
/** /**
* ContainerType Value Object * ContainerType Value Object
* *
* Encapsulates container type validation and behavior * Encapsulates container type validation and behavior
* *
* Business Rules: * Business Rules:
* - Container type must be valid (e.g., 20DRY, 40HC, 40REEFER) * - Container type must be valid (e.g., 20DRY, 40HC, 40REEFER)
* - Container type is immutable * - Container type is immutable
* *
* Format: {SIZE}{HEIGHT_MODIFIER?}{CATEGORY} * Format: {SIZE}{HEIGHT_MODIFIER?}{CATEGORY}
* Examples: 20DRY, 40HC, 40REEFER, 45HCREEFER * Examples: 20DRY, 40HC, 40REEFER, 45HCREEFER
*/ */
export class ContainerType { export class ContainerType {
private readonly value: string; private readonly value: string;
// Valid container types // Valid container types
private static readonly VALID_TYPES = [ private static readonly VALID_TYPES = [
'LCL', // Less than Container Load 'LCL', // Less than Container Load
'20DRY', '20DRY',
'40DRY', '40DRY',
'20HC', '20HC',
'40HC', '40HC',
'45HC', '45HC',
'20REEFER', '20REEFER',
'40REEFER', '40REEFER',
'40HCREEFER', '40HCREEFER',
'45HCREEFER', '45HCREEFER',
'20OT', // Open Top '20OT', // Open Top
'40OT', '40OT',
'20FR', // Flat Rack '20FR', // Flat Rack
'40FR', '40FR',
'20TANK', '20TANK',
'40TANK', '40TANK',
]; ];
private constructor(type: string) { private constructor(type: string) {
this.value = type; this.value = type;
} }
static create(type: string): ContainerType { static create(type: string): ContainerType {
if (!type || type.trim().length === 0) { if (!type || type.trim().length === 0) {
throw new Error('Container type cannot be empty.'); throw new Error('Container type cannot be empty.');
} }
const normalized = type.trim().toUpperCase(); const normalized = type.trim().toUpperCase();
if (!ContainerType.isValid(normalized)) { if (!ContainerType.isValid(normalized)) {
throw new Error( throw new Error(
`Invalid container type: ${type}. Valid types: ${ContainerType.VALID_TYPES.join(', ')}` `Invalid container type: ${type}. Valid types: ${ContainerType.VALID_TYPES.join(', ')}`
); );
} }
return new ContainerType(normalized); return new ContainerType(normalized);
} }
private static isValid(type: string): boolean { private static isValid(type: string): boolean {
return ContainerType.VALID_TYPES.includes(type); return ContainerType.VALID_TYPES.includes(type);
} }
getValue(): string { getValue(): string {
return this.value; return this.value;
} }
getSize(): string { getSize(): string {
// Extract size (first 2 digits) // Extract size (first 2 digits)
return this.value.match(/^\d+/)?.[0] || ''; return this.value.match(/^\d+/)?.[0] || '';
} }
getTEU(): number { getTEU(): number {
const size = this.getSize(); const size = this.getSize();
if (size === '20') return 1; if (size === '20') return 1;
if (size === '40' || size === '45') return 2; if (size === '40' || size === '45') return 2;
return 0; return 0;
} }
isDry(): boolean { isDry(): boolean {
return this.value.includes('DRY'); return this.value.includes('DRY');
} }
isReefer(): boolean { isReefer(): boolean {
return this.value.includes('REEFER'); return this.value.includes('REEFER');
} }
isHighCube(): boolean { isHighCube(): boolean {
return this.value.includes('HC'); return this.value.includes('HC');
} }
isOpenTop(): boolean { isOpenTop(): boolean {
return this.value.includes('OT'); return this.value.includes('OT');
} }
isFlatRack(): boolean { isFlatRack(): boolean {
return this.value.includes('FR'); return this.value.includes('FR');
} }
isTank(): boolean { isTank(): boolean {
return this.value.includes('TANK'); return this.value.includes('TANK');
} }
isLCL(): boolean { isLCL(): boolean {
return this.value === 'LCL'; return this.value === 'LCL';
} }
equals(other: ContainerType): boolean { equals(other: ContainerType): boolean {
return this.value === other.value; return this.value === other.value;
} }
toString(): string { toString(): string {
return this.value; return this.value;
} }
} }

View File

@ -1,120 +1,118 @@
/** /**
* DateRange Value Object * DateRange Value Object
* *
* Encapsulates ETD/ETA date range with validation * Encapsulates ETD/ETA date range with validation
* *
* Business Rules: * Business Rules:
* - End date must be after start date * - End date must be after start date
* - Dates cannot be in the past (for new shipments) * - Dates cannot be in the past (for new shipments)
* - Date range is immutable * - Date range is immutable
*/ */
export class DateRange { export class DateRange {
private readonly startDate: Date; private readonly startDate: Date;
private readonly endDate: Date; private readonly endDate: Date;
private constructor(startDate: Date, endDate: Date) { private constructor(startDate: Date, endDate: Date) {
this.startDate = startDate; this.startDate = startDate;
this.endDate = endDate; this.endDate = endDate;
} }
static create(startDate: Date, endDate: Date, allowPastDates = false): DateRange { static create(startDate: Date, endDate: Date, allowPastDates = false): DateRange {
if (!startDate || !endDate) { if (!startDate || !endDate) {
throw new Error('Start date and end date are required.'); throw new Error('Start date and end date are required.');
} }
if (endDate <= startDate) { if (endDate <= startDate) {
throw new Error('End date must be after start date.'); throw new Error('End date must be after start date.');
} }
if (!allowPastDates) { if (!allowPastDates) {
const now = new Date(); const now = new Date();
now.setHours(0, 0, 0, 0); // Reset time to start of day now.setHours(0, 0, 0, 0); // Reset time to start of day
if (startDate < now) { if (startDate < now) {
throw new Error('Start date cannot be in the past.'); throw new Error('Start date cannot be in the past.');
} }
} }
return new DateRange(new Date(startDate), new Date(endDate)); return new DateRange(new Date(startDate), new Date(endDate));
} }
/** /**
* Create from ETD and transit days * Create from ETD and transit days
*/ */
static fromTransitDays(etd: Date, transitDays: number): DateRange { static fromTransitDays(etd: Date, transitDays: number): DateRange {
if (transitDays <= 0) { if (transitDays <= 0) {
throw new Error('Transit days must be positive.'); throw new Error('Transit days must be positive.');
} }
const eta = new Date(etd); const eta = new Date(etd);
eta.setDate(eta.getDate() + transitDays); eta.setDate(eta.getDate() + transitDays);
return DateRange.create(etd, eta, true); return DateRange.create(etd, eta, true);
} }
getStartDate(): Date { getStartDate(): Date {
return new Date(this.startDate); return new Date(this.startDate);
} }
getEndDate(): Date { getEndDate(): Date {
return new Date(this.endDate); return new Date(this.endDate);
} }
getDurationInDays(): number { getDurationInDays(): number {
const diffTime = this.endDate.getTime() - this.startDate.getTime(); const diffTime = this.endDate.getTime() - this.startDate.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
} }
getDurationInHours(): number { getDurationInHours(): number {
const diffTime = this.endDate.getTime() - this.startDate.getTime(); const diffTime = this.endDate.getTime() - this.startDate.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60)); return Math.ceil(diffTime / (1000 * 60 * 60));
} }
contains(date: Date): boolean { contains(date: Date): boolean {
return date >= this.startDate && date <= this.endDate; return date >= this.startDate && date <= this.endDate;
} }
overlaps(other: DateRange): boolean { overlaps(other: DateRange): boolean {
return ( return this.startDate <= other.endDate && this.endDate >= other.startDate;
this.startDate <= other.endDate && this.endDate >= other.startDate }
);
} isFutureRange(): boolean {
const now = new Date();
isFutureRange(): boolean { return this.startDate > now;
const now = new Date(); }
return this.startDate > now;
} isPastRange(): boolean {
const now = new Date();
isPastRange(): boolean { return this.endDate < now;
const now = new Date(); }
return this.endDate < now;
} isCurrentRange(): boolean {
const now = new Date();
isCurrentRange(): boolean { return this.contains(now);
const now = new Date(); }
return this.contains(now);
} equals(other: DateRange): boolean {
return (
equals(other: DateRange): boolean { this.startDate.getTime() === other.startDate.getTime() &&
return ( this.endDate.getTime() === other.endDate.getTime()
this.startDate.getTime() === other.startDate.getTime() && );
this.endDate.getTime() === other.endDate.getTime() }
);
} toString(): string {
return `${this.formatDate(this.startDate)} - ${this.formatDate(this.endDate)}`;
toString(): string { }
return `${this.formatDate(this.startDate)} - ${this.formatDate(this.endDate)}`;
} private formatDate(date: Date): string {
return date.toISOString().split('T')[0];
private formatDate(date: Date): string { }
return date.toISOString().split('T')[0];
} toObject(): { startDate: Date; endDate: Date } {
return {
toObject(): { startDate: Date; endDate: Date } { startDate: new Date(this.startDate),
return { endDate: new Date(this.endDate),
startDate: new Date(this.startDate), };
endDate: new Date(this.endDate), }
}; }
}
}

View File

@ -1,70 +1,70 @@
/** /**
* Email Value Object Unit Tests * Email Value Object Unit Tests
*/ */
import { Email } from './email.vo'; import { Email } from './email.vo';
describe('Email Value Object', () => { describe('Email Value Object', () => {
describe('create', () => { describe('create', () => {
it('should create email with valid format', () => { it('should create email with valid format', () => {
const email = Email.create('user@example.com'); const email = Email.create('user@example.com');
expect(email.getValue()).toBe('user@example.com'); expect(email.getValue()).toBe('user@example.com');
}); });
it('should normalize email to lowercase', () => { it('should normalize email to lowercase', () => {
const email = Email.create('User@Example.COM'); const email = Email.create('User@Example.COM');
expect(email.getValue()).toBe('user@example.com'); expect(email.getValue()).toBe('user@example.com');
}); });
it('should trim whitespace', () => { it('should trim whitespace', () => {
const email = Email.create(' user@example.com '); const email = Email.create(' user@example.com ');
expect(email.getValue()).toBe('user@example.com'); expect(email.getValue()).toBe('user@example.com');
}); });
it('should throw error for empty email', () => { it('should throw error for empty email', () => {
expect(() => Email.create('')).toThrow('Email cannot be empty.'); expect(() => Email.create('')).toThrow('Email cannot be empty.');
}); });
it('should throw error for invalid format', () => { it('should throw error for invalid format', () => {
expect(() => Email.create('invalid-email')).toThrow('Invalid email format'); expect(() => Email.create('invalid-email')).toThrow('Invalid email format');
expect(() => Email.create('@example.com')).toThrow('Invalid email format'); expect(() => Email.create('@example.com')).toThrow('Invalid email format');
expect(() => Email.create('user@')).toThrow('Invalid email format'); expect(() => Email.create('user@')).toThrow('Invalid email format');
expect(() => Email.create('user@.com')).toThrow('Invalid email format'); expect(() => Email.create('user@.com')).toThrow('Invalid email format');
}); });
}); });
describe('getDomain', () => { describe('getDomain', () => {
it('should return email domain', () => { it('should return email domain', () => {
const email = Email.create('user@example.com'); const email = Email.create('user@example.com');
expect(email.getDomain()).toBe('example.com'); expect(email.getDomain()).toBe('example.com');
}); });
}); });
describe('getLocalPart', () => { describe('getLocalPart', () => {
it('should return email local part', () => { it('should return email local part', () => {
const email = Email.create('user@example.com'); const email = Email.create('user@example.com');
expect(email.getLocalPart()).toBe('user'); expect(email.getLocalPart()).toBe('user');
}); });
}); });
describe('equals', () => { describe('equals', () => {
it('should return true for same email', () => { it('should return true for same email', () => {
const email1 = Email.create('user@example.com'); const email1 = Email.create('user@example.com');
const email2 = Email.create('user@example.com'); const email2 = Email.create('user@example.com');
expect(email1.equals(email2)).toBe(true); expect(email1.equals(email2)).toBe(true);
}); });
it('should return false for different emails', () => { it('should return false for different emails', () => {
const email1 = Email.create('user1@example.com'); const email1 = Email.create('user1@example.com');
const email2 = Email.create('user2@example.com'); const email2 = Email.create('user2@example.com');
expect(email1.equals(email2)).toBe(false); expect(email1.equals(email2)).toBe(false);
}); });
}); });
describe('toString', () => { describe('toString', () => {
it('should return email as string', () => { it('should return email as string', () => {
const email = Email.create('user@example.com'); const email = Email.create('user@example.com');
expect(email.toString()).toBe('user@example.com'); expect(email.toString()).toBe('user@example.com');
}); });
}); });
}); });

View File

@ -1,60 +1,60 @@
/** /**
* Email Value Object * Email Value Object
* *
* Encapsulates email address validation and behavior * Encapsulates email address validation and behavior
* *
* Business Rules: * Business Rules:
* - Email must be valid format * - Email must be valid format
* - Email is case-insensitive (stored lowercase) * - Email is case-insensitive (stored lowercase)
* - Email is immutable * - Email is immutable
*/ */
export class Email { export class Email {
private readonly value: string; private readonly value: string;
private constructor(email: string) { private constructor(email: string) {
this.value = email; this.value = email;
} }
static create(email: string): Email { static create(email: string): Email {
if (!email || email.trim().length === 0) { if (!email || email.trim().length === 0) {
throw new Error('Email cannot be empty.'); throw new Error('Email cannot be empty.');
} }
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
if (!Email.isValid(normalized)) { if (!Email.isValid(normalized)) {
throw new Error(`Invalid email format: ${email}`); throw new Error(`Invalid email format: ${email}`);
} }
return new Email(normalized); return new Email(normalized);
} }
private static isValid(email: string): boolean { private static isValid(email: string): boolean {
// RFC 5322 simplified email regex // RFC 5322 simplified email regex
const emailPattern = const emailPattern =
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
return emailPattern.test(email); return emailPattern.test(email);
} }
getValue(): string { getValue(): string {
return this.value; return this.value;
} }
getDomain(): string { getDomain(): string {
return this.value.split('@')[1]; return this.value.split('@')[1];
} }
getLocalPart(): string { getLocalPart(): string {
return this.value.split('@')[0]; return this.value.split('@')[0];
} }
equals(other: Email): boolean { equals(other: Email): boolean {
return this.value === other.value; return this.value === other.value;
} }
toString(): string { toString(): string {
return this.value; return this.value;
} }
} }

View File

@ -1,13 +1,13 @@
/** /**
* Domain Value Objects Barrel Export * Domain Value Objects Barrel Export
* *
* All value objects for the Xpeditis platform * All value objects for the Xpeditis platform
*/ */
export * from './email.vo'; export * from './email.vo';
export * from './port-code.vo'; export * from './port-code.vo';
export * from './money.vo'; export * from './money.vo';
export * from './container-type.vo'; export * from './container-type.vo';
export * from './date-range.vo'; export * from './date-range.vo';
export * from './booking-number.vo'; export * from './booking-number.vo';
export * from './booking-status.vo'; export * from './booking-status.vo';

View File

@ -1,133 +1,133 @@
/** /**
* Money Value Object Unit Tests * Money Value Object Unit Tests
*/ */
import { Money } from './money.vo'; import { Money } from './money.vo';
describe('Money Value Object', () => { describe('Money Value Object', () => {
describe('create', () => { describe('create', () => {
it('should create money with valid amount and currency', () => { it('should create money with valid amount and currency', () => {
const money = Money.create(100, 'USD'); const money = Money.create(100, 'USD');
expect(money.getAmount()).toBe(100); expect(money.getAmount()).toBe(100);
expect(money.getCurrency()).toBe('USD'); expect(money.getCurrency()).toBe('USD');
}); });
it('should round to 2 decimal places', () => { it('should round to 2 decimal places', () => {
const money = Money.create(100.999, 'USD'); const money = Money.create(100.999, 'USD');
expect(money.getAmount()).toBe(101); expect(money.getAmount()).toBe(101);
}); });
it('should throw error for negative amount', () => { it('should throw error for negative amount', () => {
expect(() => Money.create(-100, 'USD')).toThrow('Amount cannot be negative'); expect(() => Money.create(-100, 'USD')).toThrow('Amount cannot be negative');
}); });
it('should throw error for invalid currency', () => { it('should throw error for invalid currency', () => {
expect(() => Money.create(100, 'XXX')).toThrow('Invalid currency code'); expect(() => Money.create(100, 'XXX')).toThrow('Invalid currency code');
}); });
it('should normalize currency to uppercase', () => { it('should normalize currency to uppercase', () => {
const money = Money.create(100, 'usd'); const money = Money.create(100, 'usd');
expect(money.getCurrency()).toBe('USD'); expect(money.getCurrency()).toBe('USD');
}); });
}); });
describe('zero', () => { describe('zero', () => {
it('should create zero amount', () => { it('should create zero amount', () => {
const money = Money.zero('USD'); const money = Money.zero('USD');
expect(money.getAmount()).toBe(0); expect(money.getAmount()).toBe(0);
expect(money.isZero()).toBe(true); expect(money.isZero()).toBe(true);
}); });
}); });
describe('add', () => { describe('add', () => {
it('should add two money amounts', () => { it('should add two money amounts', () => {
const money1 = Money.create(100, 'USD'); const money1 = Money.create(100, 'USD');
const money2 = Money.create(50, 'USD'); const money2 = Money.create(50, 'USD');
const result = money1.add(money2); const result = money1.add(money2);
expect(result.getAmount()).toBe(150); expect(result.getAmount()).toBe(150);
}); });
it('should throw error for currency mismatch', () => { it('should throw error for currency mismatch', () => {
const money1 = Money.create(100, 'USD'); const money1 = Money.create(100, 'USD');
const money2 = Money.create(50, 'EUR'); const money2 = Money.create(50, 'EUR');
expect(() => money1.add(money2)).toThrow('Currency mismatch'); expect(() => money1.add(money2)).toThrow('Currency mismatch');
}); });
}); });
describe('subtract', () => { describe('subtract', () => {
it('should subtract two money amounts', () => { it('should subtract two money amounts', () => {
const money1 = Money.create(100, 'USD'); const money1 = Money.create(100, 'USD');
const money2 = Money.create(30, 'USD'); const money2 = Money.create(30, 'USD');
const result = money1.subtract(money2); const result = money1.subtract(money2);
expect(result.getAmount()).toBe(70); expect(result.getAmount()).toBe(70);
}); });
it('should throw error for negative result', () => { it('should throw error for negative result', () => {
const money1 = Money.create(50, 'USD'); const money1 = Money.create(50, 'USD');
const money2 = Money.create(100, 'USD'); const money2 = Money.create(100, 'USD');
expect(() => money1.subtract(money2)).toThrow('negative amount'); expect(() => money1.subtract(money2)).toThrow('negative amount');
}); });
}); });
describe('multiply', () => { describe('multiply', () => {
it('should multiply money amount', () => { it('should multiply money amount', () => {
const money = Money.create(100, 'USD'); const money = Money.create(100, 'USD');
const result = money.multiply(2); const result = money.multiply(2);
expect(result.getAmount()).toBe(200); expect(result.getAmount()).toBe(200);
}); });
it('should throw error for negative multiplier', () => { it('should throw error for negative multiplier', () => {
const money = Money.create(100, 'USD'); const money = Money.create(100, 'USD');
expect(() => money.multiply(-2)).toThrow('Multiplier cannot be negative'); expect(() => money.multiply(-2)).toThrow('Multiplier cannot be negative');
}); });
}); });
describe('divide', () => { describe('divide', () => {
it('should divide money amount', () => { it('should divide money amount', () => {
const money = Money.create(100, 'USD'); const money = Money.create(100, 'USD');
const result = money.divide(2); const result = money.divide(2);
expect(result.getAmount()).toBe(50); expect(result.getAmount()).toBe(50);
}); });
it('should throw error for zero divisor', () => { it('should throw error for zero divisor', () => {
const money = Money.create(100, 'USD'); const money = Money.create(100, 'USD');
expect(() => money.divide(0)).toThrow('Divisor must be positive'); expect(() => money.divide(0)).toThrow('Divisor must be positive');
}); });
}); });
describe('comparisons', () => { describe('comparisons', () => {
it('should compare greater than', () => { it('should compare greater than', () => {
const money1 = Money.create(100, 'USD'); const money1 = Money.create(100, 'USD');
const money2 = Money.create(50, 'USD'); const money2 = Money.create(50, 'USD');
expect(money1.isGreaterThan(money2)).toBe(true); expect(money1.isGreaterThan(money2)).toBe(true);
expect(money2.isGreaterThan(money1)).toBe(false); expect(money2.isGreaterThan(money1)).toBe(false);
}); });
it('should compare less than', () => { it('should compare less than', () => {
const money1 = Money.create(50, 'USD'); const money1 = Money.create(50, 'USD');
const money2 = Money.create(100, 'USD'); const money2 = Money.create(100, 'USD');
expect(money1.isLessThan(money2)).toBe(true); expect(money1.isLessThan(money2)).toBe(true);
expect(money2.isLessThan(money1)).toBe(false); expect(money2.isLessThan(money1)).toBe(false);
}); });
it('should compare equality', () => { it('should compare equality', () => {
const money1 = Money.create(100, 'USD'); const money1 = Money.create(100, 'USD');
const money2 = Money.create(100, 'USD'); const money2 = Money.create(100, 'USD');
const money3 = Money.create(50, 'USD'); const money3 = Money.create(50, 'USD');
expect(money1.isEqualTo(money2)).toBe(true); expect(money1.isEqualTo(money2)).toBe(true);
expect(money1.isEqualTo(money3)).toBe(false); expect(money1.isEqualTo(money3)).toBe(false);
}); });
}); });
describe('format', () => { describe('format', () => {
it('should format USD with $ symbol', () => { it('should format USD with $ symbol', () => {
const money = Money.create(100.5, 'USD'); const money = Money.create(100.5, 'USD');
expect(money.format()).toBe('$100.50'); expect(money.format()).toBe('$100.50');
}); });
it('should format EUR with € symbol', () => { it('should format EUR with € symbol', () => {
const money = Money.create(100.5, 'EUR'); const money = Money.create(100.5, 'EUR');
expect(money.format()).toBe('€100.50'); expect(money.format()).toBe('€100.50');
}); });
}); });
}); });

View File

@ -1,137 +1,137 @@
/** /**
* Money Value Object * Money Value Object
* *
* Encapsulates currency and amount with proper validation * Encapsulates currency and amount with proper validation
* *
* Business Rules: * Business Rules:
* - Amount must be non-negative * - Amount must be non-negative
* - Currency must be valid ISO 4217 code * - Currency must be valid ISO 4217 code
* - Money is immutable * - Money is immutable
* - Arithmetic operations return new Money instances * - Arithmetic operations return new Money instances
*/ */
export class Money { export class Money {
private readonly amount: number; private readonly amount: number;
private readonly currency: string; private readonly currency: string;
private static readonly SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'CNY', 'JPY']; private static readonly SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'CNY', 'JPY'];
private constructor(amount: number, currency: string) { private constructor(amount: number, currency: string) {
this.amount = amount; this.amount = amount;
this.currency = currency; this.currency = currency;
} }
static create(amount: number, currency: string): Money { static create(amount: number, currency: string): Money {
if (amount < 0) { if (amount < 0) {
throw new Error('Amount cannot be negative.'); throw new Error('Amount cannot be negative.');
} }
const normalizedCurrency = currency.trim().toUpperCase(); const normalizedCurrency = currency.trim().toUpperCase();
if (!Money.isValidCurrency(normalizedCurrency)) { if (!Money.isValidCurrency(normalizedCurrency)) {
throw new Error( throw new Error(
`Invalid currency code: ${currency}. Supported currencies: ${Money.SUPPORTED_CURRENCIES.join(', ')}` `Invalid currency code: ${currency}. Supported currencies: ${Money.SUPPORTED_CURRENCIES.join(', ')}`
); );
} }
// Round to 2 decimal places to avoid floating point issues // Round to 2 decimal places to avoid floating point issues
const roundedAmount = Math.round(amount * 100) / 100; const roundedAmount = Math.round(amount * 100) / 100;
return new Money(roundedAmount, normalizedCurrency); return new Money(roundedAmount, normalizedCurrency);
} }
static zero(currency: string): Money { static zero(currency: string): Money {
return Money.create(0, currency); return Money.create(0, currency);
} }
private static isValidCurrency(currency: string): boolean { private static isValidCurrency(currency: string): boolean {
return Money.SUPPORTED_CURRENCIES.includes(currency); return Money.SUPPORTED_CURRENCIES.includes(currency);
} }
getAmount(): number { getAmount(): number {
return this.amount; return this.amount;
} }
getCurrency(): string { getCurrency(): string {
return this.currency; return this.currency;
} }
add(other: Money): Money { add(other: Money): Money {
this.ensureSameCurrency(other); this.ensureSameCurrency(other);
return Money.create(this.amount + other.amount, this.currency); return Money.create(this.amount + other.amount, this.currency);
} }
subtract(other: Money): Money { subtract(other: Money): Money {
this.ensureSameCurrency(other); this.ensureSameCurrency(other);
const result = this.amount - other.amount; const result = this.amount - other.amount;
if (result < 0) { if (result < 0) {
throw new Error('Subtraction would result in negative amount.'); throw new Error('Subtraction would result in negative amount.');
} }
return Money.create(result, this.currency); return Money.create(result, this.currency);
} }
multiply(multiplier: number): Money { multiply(multiplier: number): Money {
if (multiplier < 0) { if (multiplier < 0) {
throw new Error('Multiplier cannot be negative.'); throw new Error('Multiplier cannot be negative.');
} }
return Money.create(this.amount * multiplier, this.currency); return Money.create(this.amount * multiplier, this.currency);
} }
divide(divisor: number): Money { divide(divisor: number): Money {
if (divisor <= 0) { if (divisor <= 0) {
throw new Error('Divisor must be positive.'); throw new Error('Divisor must be positive.');
} }
return Money.create(this.amount / divisor, this.currency); return Money.create(this.amount / divisor, this.currency);
} }
isGreaterThan(other: Money): boolean { isGreaterThan(other: Money): boolean {
this.ensureSameCurrency(other); this.ensureSameCurrency(other);
return this.amount > other.amount; return this.amount > other.amount;
} }
isLessThan(other: Money): boolean { isLessThan(other: Money): boolean {
this.ensureSameCurrency(other); this.ensureSameCurrency(other);
return this.amount < other.amount; return this.amount < other.amount;
} }
isEqualTo(other: Money): boolean { isEqualTo(other: Money): boolean {
return this.currency === other.currency && this.amount === other.amount; return this.currency === other.currency && this.amount === other.amount;
} }
isZero(): boolean { isZero(): boolean {
return this.amount === 0; return this.amount === 0;
} }
private ensureSameCurrency(other: Money): void { private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) { if (this.currency !== other.currency) {
throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`); throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
} }
} }
/** /**
* Format as string with currency symbol * Format as string with currency symbol
*/ */
format(): string { format(): string {
const symbols: { [key: string]: string } = { const symbols: { [key: string]: string } = {
USD: '$', USD: '$',
EUR: '€', EUR: '€',
GBP: '£', GBP: '£',
CNY: '¥', CNY: '¥',
JPY: '¥', JPY: '¥',
}; };
const symbol = symbols[this.currency] || this.currency; const symbol = symbols[this.currency] || this.currency;
return `${symbol}${this.amount.toFixed(2)}`; return `${symbol}${this.amount.toFixed(2)}`;
} }
toString(): string { toString(): string {
return this.format(); return this.format();
} }
toObject(): { amount: number; currency: string } { toObject(): { amount: number; currency: string } {
return { return {
amount: this.amount, amount: this.amount,
currency: this.currency, currency: this.currency,
}; };
} }
} }

View File

@ -1,66 +1,66 @@
/** /**
* PortCode Value Object * PortCode Value Object
* *
* Encapsulates UN/LOCODE port code validation and behavior * Encapsulates UN/LOCODE port code validation and behavior
* *
* Business Rules: * Business Rules:
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter/digit location) * - Port code must follow UN/LOCODE format (2-letter country + 3-letter/digit location)
* - Port code is always uppercase * - Port code is always uppercase
* - Port code is immutable * - Port code is immutable
* *
* Format: CCLLL * Format: CCLLL
* - CC: ISO 3166-1 alpha-2 country code * - CC: ISO 3166-1 alpha-2 country code
* - LLL: 3-character location code (letters or digits) * - LLL: 3-character location code (letters or digits)
* *
* Examples: NLRTM (Rotterdam), USNYC (New York), SGSIN (Singapore) * Examples: NLRTM (Rotterdam), USNYC (New York), SGSIN (Singapore)
*/ */
export class PortCode { export class PortCode {
private readonly value: string; private readonly value: string;
private constructor(code: string) { private constructor(code: string) {
this.value = code; this.value = code;
} }
static create(code: string): PortCode { static create(code: string): PortCode {
if (!code || code.trim().length === 0) { if (!code || code.trim().length === 0) {
throw new Error('Port code cannot be empty.'); throw new Error('Port code cannot be empty.');
} }
const normalized = code.trim().toUpperCase(); const normalized = code.trim().toUpperCase();
if (!PortCode.isValid(normalized)) { if (!PortCode.isValid(normalized)) {
throw new Error( throw new Error(
`Invalid port code format: ${code}. Must follow UN/LOCODE format (e.g., NLRTM, USNYC).` `Invalid port code format: ${code}. Must follow UN/LOCODE format (e.g., NLRTM, USNYC).`
); );
} }
return new PortCode(normalized); return new PortCode(normalized);
} }
private static isValid(code: string): boolean { private static isValid(code: string): boolean {
// UN/LOCODE format: 2-letter country code + 3-character location code // UN/LOCODE format: 2-letter country code + 3-character location code
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/; const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
return unlocodePattern.test(code); return unlocodePattern.test(code);
} }
getValue(): string { getValue(): string {
return this.value; return this.value;
} }
getCountryCode(): string { getCountryCode(): string {
return this.value.substring(0, 2); return this.value.substring(0, 2);
} }
getLocationCode(): string { getLocationCode(): string {
return this.value.substring(2); return this.value.substring(2);
} }
equals(other: PortCode): boolean { equals(other: PortCode): boolean {
return this.value === other.value; return this.value === other.value;
} }
toString(): string { toString(): string {
return this.value; return this.value;
} }
} }

View File

@ -1,107 +1,105 @@
import { Money } from './money.vo'; import { Money } from './money.vo';
/** /**
* Surcharge Type Enumeration * Surcharge Type Enumeration
* Common maritime shipping surcharges * Common maritime shipping surcharges
*/ */
export enum SurchargeType { export enum SurchargeType {
BAF = 'BAF', // Bunker Adjustment Factor BAF = 'BAF', // Bunker Adjustment Factor
CAF = 'CAF', // Currency Adjustment Factor CAF = 'CAF', // Currency Adjustment Factor
PSS = 'PSS', // Peak Season Surcharge PSS = 'PSS', // Peak Season Surcharge
THC = 'THC', // Terminal Handling Charge THC = 'THC', // Terminal Handling Charge
OTHER = 'OTHER', OTHER = 'OTHER',
} }
/** /**
* Surcharge Value Object * Surcharge Value Object
* Represents additional fees applied to base freight rates * Represents additional fees applied to base freight rates
*/ */
export class Surcharge { export class Surcharge {
constructor( constructor(
public readonly type: SurchargeType, public readonly type: SurchargeType,
public readonly amount: Money, public readonly amount: Money,
public readonly description?: string, public readonly description?: string
) { ) {
this.validate(); this.validate();
} }
private validate(): void { private validate(): void {
if (!Object.values(SurchargeType).includes(this.type)) { if (!Object.values(SurchargeType).includes(this.type)) {
throw new Error(`Invalid surcharge type: ${this.type}`); throw new Error(`Invalid surcharge type: ${this.type}`);
} }
} }
/** /**
* Get human-readable surcharge label * Get human-readable surcharge label
*/ */
getLabel(): string { getLabel(): string {
const labels: Record<SurchargeType, string> = { const labels: Record<SurchargeType, string> = {
[SurchargeType.BAF]: 'Bunker Adjustment Factor', [SurchargeType.BAF]: 'Bunker Adjustment Factor',
[SurchargeType.CAF]: 'Currency Adjustment Factor', [SurchargeType.CAF]: 'Currency Adjustment Factor',
[SurchargeType.PSS]: 'Peak Season Surcharge', [SurchargeType.PSS]: 'Peak Season Surcharge',
[SurchargeType.THC]: 'Terminal Handling Charge', [SurchargeType.THC]: 'Terminal Handling Charge',
[SurchargeType.OTHER]: 'Other Surcharge', [SurchargeType.OTHER]: 'Other Surcharge',
}; };
return labels[this.type]; return labels[this.type];
} }
equals(other: Surcharge): boolean { equals(other: Surcharge): boolean {
return ( return this.type === other.type && this.amount.isEqualTo(other.amount);
this.type === other.type && }
this.amount.isEqualTo(other.amount)
); toString(): string {
} const label = this.description || this.getLabel();
return `${label}: ${this.amount.toString()}`;
toString(): string { }
const label = this.description || this.getLabel(); }
return `${label}: ${this.amount.toString()}`;
} /**
} * Collection of surcharges with utility methods
*/
/** export class SurchargeCollection {
* Collection of surcharges with utility methods constructor(public readonly surcharges: Surcharge[]) {}
*/
export class SurchargeCollection { /**
constructor(public readonly surcharges: Surcharge[]) {} * Calculate total surcharge amount in a specific currency
* Note: This assumes all surcharges are in the same currency
/** * In production, currency conversion would be needed
* Calculate total surcharge amount in a specific currency */
* Note: This assumes all surcharges are in the same currency getTotalAmount(currency: string): Money {
* In production, currency conversion would be needed const relevantSurcharges = this.surcharges.filter(s => s.amount.getCurrency() === currency);
*/
getTotalAmount(currency: string): Money { if (relevantSurcharges.length === 0) {
const relevantSurcharges = this.surcharges return Money.zero(currency);
.filter((s) => s.amount.getCurrency() === currency); }
if (relevantSurcharges.length === 0) { return relevantSurcharges.reduce(
return Money.zero(currency); (total, surcharge) => total.add(surcharge.amount),
} Money.zero(currency)
);
return relevantSurcharges }
.reduce((total, surcharge) => total.add(surcharge.amount), Money.zero(currency));
} /**
* Check if collection has any surcharges
/** */
* Check if collection has any surcharges isEmpty(): boolean {
*/ return this.surcharges.length === 0;
isEmpty(): boolean { }
return this.surcharges.length === 0;
} /**
* Get surcharges by type
/** */
* Get surcharges by type getByType(type: SurchargeType): Surcharge[] {
*/ return this.surcharges.filter(s => s.type === type);
getByType(type: SurchargeType): Surcharge[] { }
return this.surcharges.filter((s) => s.type === type);
} /**
* Get formatted surcharge details for display
/** */
* Get formatted surcharge details for display getDetails(): string {
*/ if (this.isEmpty()) {
getDetails(): string { return 'All-in price (no separate surcharges)';
if (this.isEmpty()) { }
return 'All-in price (no separate surcharges)'; return this.surcharges.map(s => s.toString()).join(', ');
} }
return this.surcharges.map((s) => s.toString()).join(', '); }
}
}

View File

@ -1,54 +1,54 @@
/** /**
* Volume Value Object * Volume Value Object
* Represents shipping volume in CBM (Cubic Meters) and weight in KG * Represents shipping volume in CBM (Cubic Meters) and weight in KG
* *
* Business Rule: Price is calculated using freight class rule: * Business Rule: Price is calculated using freight class rule:
* - Take the higher of: (volumeCBM * pricePerCBM) or (weightKG * pricePerKG) * - Take the higher of: (volumeCBM * pricePerCBM) or (weightKG * pricePerKG)
*/ */
export class Volume { export class Volume {
constructor( constructor(
public readonly cbm: number, public readonly cbm: number,
public readonly weightKG: number, public readonly weightKG: number
) { ) {
this.validate(); this.validate();
} }
private validate(): void { private validate(): void {
if (this.cbm < 0) { if (this.cbm < 0) {
throw new Error('Volume in CBM cannot be negative'); throw new Error('Volume in CBM cannot be negative');
} }
if (this.weightKG < 0) { if (this.weightKG < 0) {
throw new Error('Weight in KG cannot be negative'); throw new Error('Weight in KG cannot be negative');
} }
if (this.cbm === 0 && this.weightKG === 0) { if (this.cbm === 0 && this.weightKG === 0) {
throw new Error('Either volume or weight must be greater than zero'); throw new Error('Either volume or weight must be greater than zero');
} }
} }
/** /**
* Check if this volume is within the specified range * Check if this volume is within the specified range
*/ */
isWithinRange(minCBM: number, maxCBM: number, minKG: number, maxKG: number): boolean { isWithinRange(minCBM: number, maxCBM: number, minKG: number, maxKG: number): boolean {
const cbmInRange = this.cbm >= minCBM && this.cbm <= maxCBM; const cbmInRange = this.cbm >= minCBM && this.cbm <= maxCBM;
const weightInRange = this.weightKG >= minKG && this.weightKG <= maxKG; const weightInRange = this.weightKG >= minKG && this.weightKG <= maxKG;
return cbmInRange && weightInRange; return cbmInRange && weightInRange;
} }
/** /**
* Calculate freight price using the freight class rule * Calculate freight price using the freight class rule
* Returns the higher value between volume-based and weight-based pricing * Returns the higher value between volume-based and weight-based pricing
*/ */
calculateFreightPrice(pricePerCBM: number, pricePerKG: number): number { calculateFreightPrice(pricePerCBM: number, pricePerKG: number): number {
const volumePrice = this.cbm * pricePerCBM; const volumePrice = this.cbm * pricePerCBM;
const weightPrice = this.weightKG * pricePerKG; const weightPrice = this.weightKG * pricePerKG;
return Math.max(volumePrice, weightPrice); return Math.max(volumePrice, weightPrice);
} }
equals(other: Volume): boolean { equals(other: Volume): boolean {
return this.cbm === other.cbm && this.weightKG === other.weightKG; return this.cbm === other.cbm && this.weightKG === other.weightKG;
} }
toString(): string { toString(): string {
return `${this.cbm} CBM / ${this.weightKG} KG`; return `${this.cbm} CBM / ${this.weightKG} KG`;
} }
} }

View File

@ -1,22 +1,22 @@
/** /**
* Cache Module * Cache Module
* *
* Provides Redis cache adapter as CachePort implementation * Provides Redis cache adapter as CachePort implementation
*/ */
import { Module, Global } from '@nestjs/common'; import { Module, Global } from '@nestjs/common';
import { RedisCacheAdapter } from './redis-cache.adapter'; import { RedisCacheAdapter } from './redis-cache.adapter';
import { CACHE_PORT } from '../../domain/ports/out/cache.port'; import { CACHE_PORT } from '../../domain/ports/out/cache.port';
@Global() @Global()
@Module({ @Module({
providers: [ providers: [
{ {
provide: CACHE_PORT, provide: CACHE_PORT,
useClass: RedisCacheAdapter, useClass: RedisCacheAdapter,
}, },
RedisCacheAdapter, RedisCacheAdapter,
], ],
exports: [CACHE_PORT, RedisCacheAdapter], exports: [CACHE_PORT, RedisCacheAdapter],
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,181 +1,183 @@
/** /**
* Redis Cache Adapter * Redis Cache Adapter
* *
* Implements CachePort interface using Redis (ioredis) * Implements CachePort interface using Redis (ioredis)
*/ */
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { CachePort } from '../../domain/ports/out/cache.port'; import { CachePort } from '../../domain/ports/out/cache.port';
@Injectable() @Injectable()
export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestroy { export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisCacheAdapter.name); private readonly logger = new Logger(RedisCacheAdapter.name);
private client: Redis; private client: Redis;
private stats = { private stats = {
hits: 0, hits: 0,
misses: 0, misses: 0,
}; };
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
const host = this.configService.get<string>('REDIS_HOST', 'localhost'); const host = this.configService.get<string>('REDIS_HOST', 'localhost');
const port = this.configService.get<number>('REDIS_PORT', 6379); const port = this.configService.get<number>('REDIS_PORT', 6379);
const password = this.configService.get<string>('REDIS_PASSWORD'); const password = this.configService.get<string>('REDIS_PASSWORD');
const db = this.configService.get<number>('REDIS_DB', 0); const db = this.configService.get<number>('REDIS_DB', 0);
this.client = new Redis({ this.client = new Redis({
host, host,
port, port,
password, password,
db, db,
retryStrategy: (times) => { retryStrategy: times => {
const delay = Math.min(times * 50, 2000); const delay = Math.min(times * 50, 2000);
return delay; return delay;
}, },
maxRetriesPerRequest: 3, maxRetriesPerRequest: 3,
}); });
this.client.on('connect', () => { this.client.on('connect', () => {
this.logger.log(`Connected to Redis at ${host}:${port}`); this.logger.log(`Connected to Redis at ${host}:${port}`);
}); });
this.client.on('error', (err) => { this.client.on('error', err => {
this.logger.error(`Redis connection error: ${err.message}`); this.logger.error(`Redis connection error: ${err.message}`);
}); });
this.client.on('ready', () => { this.client.on('ready', () => {
this.logger.log('Redis client ready'); this.logger.log('Redis client ready');
}); });
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
await this.client.quit(); await this.client.quit();
this.logger.log('Redis connection closed'); this.logger.log('Redis connection closed');
} }
async get<T>(key: string): Promise<T | null> { async get<T>(key: string): Promise<T | null> {
try { try {
const value = await this.client.get(key); const value = await this.client.get(key);
if (value === null) { if (value === null) {
this.stats.misses++; this.stats.misses++;
return null; return null;
} }
this.stats.hits++; this.stats.hits++;
return JSON.parse(value) as T; return JSON.parse(value) as T;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error getting key ${key}: ${error?.message || 'Unknown error'}`); this.logger.error(`Error getting key ${key}: ${error?.message || 'Unknown error'}`);
return null; return null;
} }
} }
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> { async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
try { try {
const serialized = JSON.stringify(value); const serialized = JSON.stringify(value);
if (ttlSeconds) { if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, serialized); await this.client.setex(key, ttlSeconds, serialized);
} else { } else {
await this.client.set(key, serialized); await this.client.set(key, serialized);
} }
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error setting key ${key}: ${error?.message || 'Unknown error'}`); this.logger.error(`Error setting key ${key}: ${error?.message || 'Unknown error'}`);
throw error; throw error;
} }
} }
async delete(key: string): Promise<void> { async delete(key: string): Promise<void> {
try { try {
await this.client.del(key); await this.client.del(key);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error deleting key ${key}: ${error?.message || 'Unknown error'}`); this.logger.error(`Error deleting key ${key}: ${error?.message || 'Unknown error'}`);
throw error; throw error;
} }
} }
async deleteMany(keys: string[]): Promise<void> { async deleteMany(keys: string[]): Promise<void> {
if (keys.length === 0) return; if (keys.length === 0) return;
try { try {
await this.client.del(...keys); await this.client.del(...keys);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error deleting keys: ${error?.message || 'Unknown error'}`); this.logger.error(`Error deleting keys: ${error?.message || 'Unknown error'}`);
throw error; throw error;
} }
} }
async exists(key: string): Promise<boolean> { async exists(key: string): Promise<boolean> {
try { try {
const result = await this.client.exists(key); const result = await this.client.exists(key);
return result === 1; return result === 1;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`); this.logger.error(
return false; `Error checking key existence ${key}: ${error?.message || 'Unknown error'}`
} );
} return false;
}
async ttl(key: string): Promise<number> { }
try {
return await this.client.ttl(key); async ttl(key: string): Promise<number> {
} catch (error: any) { try {
this.logger.error(`Error getting TTL for key ${key}: ${error?.message || 'Unknown error'}`); return await this.client.ttl(key);
return -2; } catch (error: any) {
} this.logger.error(`Error getting TTL for key ${key}: ${error?.message || 'Unknown error'}`);
} return -2;
}
async clear(): Promise<void> { }
try {
await this.client.flushdb(); async clear(): Promise<void> {
this.logger.warn('Redis database cleared'); try {
} catch (error: any) { await this.client.flushdb();
this.logger.error(`Error clearing cache: ${error?.message || 'Unknown error'}`); this.logger.warn('Redis database cleared');
throw error; } catch (error: any) {
} this.logger.error(`Error clearing cache: ${error?.message || 'Unknown error'}`);
} throw error;
}
async getStats(): Promise<{ }
hits: number;
misses: number; async getStats(): Promise<{
hitRate: number; hits: number;
keyCount: number; misses: number;
}> { hitRate: number;
try { keyCount: number;
const keyCount = await this.client.dbsize(); }> {
const total = this.stats.hits + this.stats.misses; try {
const hitRate = total > 0 ? this.stats.hits / total : 0; const keyCount = await this.client.dbsize();
const total = this.stats.hits + this.stats.misses;
return { const hitRate = total > 0 ? this.stats.hits / total : 0;
hits: this.stats.hits,
misses: this.stats.misses, return {
hitRate: Math.round(hitRate * 10000) / 100, // Percentage with 2 decimals hits: this.stats.hits,
keyCount, misses: this.stats.misses,
}; hitRate: Math.round(hitRate * 10000) / 100, // Percentage with 2 decimals
} catch (error: any) { keyCount,
this.logger.error(`Error getting stats: ${error?.message || 'Unknown error'}`); };
return { } catch (error: any) {
hits: this.stats.hits, this.logger.error(`Error getting stats: ${error?.message || 'Unknown error'}`);
misses: this.stats.misses, return {
hitRate: 0, hits: this.stats.hits,
keyCount: 0, misses: this.stats.misses,
}; hitRate: 0,
} keyCount: 0,
} };
}
/** }
* Reset statistics (useful for testing)
*/ /**
resetStats(): void { * Reset statistics (useful for testing)
this.stats.hits = 0; */
this.stats.misses = 0; resetStats(): void {
} this.stats.hits = 0;
this.stats.misses = 0;
/** }
* Get Redis client (for advanced usage)
*/ /**
getClient(): Redis { * Get Redis client (for advanced usage)
return this.client; */
} getClient(): Redis {
} return this.client;
}
}

View File

@ -1,75 +1,69 @@
/** /**
* Carrier Module * Carrier Module
* *
* Provides all carrier connector implementations * Provides all carrier connector implementations
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MaerskConnector } from './maersk/maersk.connector'; import { MaerskConnector } from './maersk/maersk.connector';
import { MSCConnectorAdapter } from './msc/msc.connector'; import { MSCConnectorAdapter } from './msc/msc.connector';
import { MSCRequestMapper } from './msc/msc.mapper'; import { MSCRequestMapper } from './msc/msc.mapper';
import { CMACGMConnectorAdapter } from './cma-cgm/cma-cgm.connector'; import { CMACGMConnectorAdapter } from './cma-cgm/cma-cgm.connector';
import { CMACGMRequestMapper } from './cma-cgm/cma-cgm.mapper'; import { CMACGMRequestMapper } from './cma-cgm/cma-cgm.mapper';
import { HapagLloydConnectorAdapter } from './hapag-lloyd/hapag-lloyd.connector'; import { HapagLloydConnectorAdapter } from './hapag-lloyd/hapag-lloyd.connector';
import { HapagLloydRequestMapper } from './hapag-lloyd/hapag-lloyd.mapper'; import { HapagLloydRequestMapper } from './hapag-lloyd/hapag-lloyd.mapper';
import { ONEConnectorAdapter } from './one/one.connector'; import { ONEConnectorAdapter } from './one/one.connector';
import { ONERequestMapper } from './one/one.mapper'; import { ONERequestMapper } from './one/one.mapper';
@Module({ @Module({
providers: [ providers: [
// Maersk // Maersk
MaerskConnector, MaerskConnector,
// MSC // MSC
MSCRequestMapper, MSCRequestMapper,
MSCConnectorAdapter, MSCConnectorAdapter,
// CMA CGM // CMA CGM
CMACGMRequestMapper, CMACGMRequestMapper,
CMACGMConnectorAdapter, CMACGMConnectorAdapter,
// Hapag-Lloyd // Hapag-Lloyd
HapagLloydRequestMapper, HapagLloydRequestMapper,
HapagLloydConnectorAdapter, HapagLloydConnectorAdapter,
// ONE // ONE
ONERequestMapper, ONERequestMapper,
ONEConnectorAdapter, ONEConnectorAdapter,
// Factory that provides all connectors // Factory that provides all connectors
{ {
provide: 'CarrierConnectors', provide: 'CarrierConnectors',
useFactory: ( useFactory: (
maerskConnector: MaerskConnector, maerskConnector: MaerskConnector,
mscConnector: MSCConnectorAdapter, mscConnector: MSCConnectorAdapter,
cmacgmConnector: CMACGMConnectorAdapter, cmacgmConnector: CMACGMConnectorAdapter,
hapagConnector: HapagLloydConnectorAdapter, hapagConnector: HapagLloydConnectorAdapter,
oneConnector: ONEConnectorAdapter, oneConnector: ONEConnectorAdapter
) => { ) => {
return [ return [maerskConnector, mscConnector, cmacgmConnector, hapagConnector, oneConnector];
maerskConnector, },
mscConnector, inject: [
cmacgmConnector, MaerskConnector,
hapagConnector, MSCConnectorAdapter,
oneConnector, CMACGMConnectorAdapter,
]; HapagLloydConnectorAdapter,
}, ONEConnectorAdapter,
inject: [ ],
MaerskConnector, },
MSCConnectorAdapter, ],
CMACGMConnectorAdapter, exports: [
HapagLloydConnectorAdapter, 'CarrierConnectors',
ONEConnectorAdapter, MaerskConnector,
], MSCConnectorAdapter,
}, CMACGMConnectorAdapter,
], HapagLloydConnectorAdapter,
exports: [ ONEConnectorAdapter,
'CarrierConnectors', ],
MaerskConnector, })
MSCConnectorAdapter, export class CarrierModule {}
CMACGMConnectorAdapter,
HapagLloydConnectorAdapter,
ONEConnectorAdapter,
],
})
export class CarrierModule {}

View File

@ -9,24 +9,21 @@ import { ConfigService } from '@nestjs/config';
import { import {
CarrierConnectorPort, CarrierConnectorPort,
CarrierRateSearchInput, CarrierRateSearchInput,
CarrierAvailabilityInput CarrierAvailabilityInput,
} from '../../../domain/ports/out/carrier-connector.port'; } from '../../../domain/ports/out/carrier-connector.port';
import { RateQuote } from '../../../domain/entities/rate-quote.entity'; import { RateQuote } from '../../../domain/entities/rate-quote.entity';
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
import { CMACGMRequestMapper } from './cma-cgm.mapper'; import { CMACGMRequestMapper } from './cma-cgm.mapper';
@Injectable() @Injectable()
export class CMACGMConnectorAdapter export class CMACGMConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort {
extends BaseCarrierConnector
implements CarrierConnectorPort
{
private readonly apiUrl: string; private readonly apiUrl: string;
private readonly clientId: string; private readonly clientId: string;
private readonly clientSecret: string; private readonly clientSecret: string;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly requestMapper: CMACGMRequestMapper, private readonly requestMapper: CMACGMRequestMapper
) { ) {
const config: CarrierConfig = { const config: CarrierConfig = {
name: 'CMA CGM', name: 'CMA CGM',

Some files were not shown because too many files have changed in this diff Show More