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 { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core';
import * as Joi from 'joi';
// Import feature modules
import { AuthModule } from './application/auth/auth.module';
import { RatesModule } from './application/rates/rates.module';
import { BookingsModule } from './application/bookings/bookings.module';
import { OrganizationsModule } from './application/organizations/organizations.module';
import { UsersModule } from './application/users/users.module';
import { DashboardModule } from './application/dashboard/dashboard.module';
import { AuditModule } from './application/audit/audit.module';
import { NotificationsModule } from './application/notifications/notifications.module';
import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().default(4000),
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432),
DATABASE_USER: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(),
DATABASE_NAME: Joi.string().required(),
REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
}),
}),
// Logging
LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
pinoHttp: {
transport:
configService.get('NODE_ENV') === 'development'
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
},
}),
inject: [ConfigService],
}),
// Database
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DATABASE_HOST'),
port: configService.get('DATABASE_PORT'),
username: configService.get('DATABASE_USER'),
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
synchronize: configService.get('DATABASE_SYNC', false),
logging: configService.get('DATABASE_LOGGING', false),
autoLoadEntities: true, // Auto-load entities from forFeature()
}),
inject: [ConfigService],
}),
// Infrastructure modules
SecurityModule,
CacheModule,
CarrierModule,
CsvRateModule,
// Feature modules
AuthModule,
RatesModule,
BookingsModule,
OrganizationsModule,
UsersModule,
DashboardModule,
AuditModule,
NotificationsModule,
WebhooksModule,
GDPRModule,
],
controllers: [],
providers: [
// Global JWT authentication guard
// All routes are protected by default, use @Public() to bypass
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global rate limiting guard
{
provide: APP_GUARD,
useClass: CustomThrottlerGuard,
},
],
})
export class AppModule {}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core';
import * as Joi from 'joi';
// Import feature modules
import { AuthModule } from './application/auth/auth.module';
import { RatesModule } from './application/rates/rates.module';
import { BookingsModule } from './application/bookings/bookings.module';
import { OrganizationsModule } from './application/organizations/organizations.module';
import { UsersModule } from './application/users/users.module';
import { DashboardModule } from './application/dashboard/dashboard.module';
import { AuditModule } from './application/audit/audit.module';
import { NotificationsModule } from './application/notifications/notifications.module';
import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(4000),
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432),
DATABASE_USER: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(),
DATABASE_NAME: Joi.string().required(),
REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
}),
}),
// Logging
LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
pinoHttp: {
transport:
configService.get('NODE_ENV') === 'development'
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
},
}),
inject: [ConfigService],
}),
// Database
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DATABASE_HOST'),
port: configService.get('DATABASE_PORT'),
username: configService.get('DATABASE_USER'),
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
synchronize: configService.get('DATABASE_SYNC', false),
logging: configService.get('DATABASE_LOGGING', false),
autoLoadEntities: true, // Auto-load entities from forFeature()
}),
inject: [ConfigService],
}),
// Infrastructure modules
SecurityModule,
CacheModule,
CarrierModule,
CsvRateModule,
// Feature modules
AuthModule,
RatesModule,
BookingsModule,
OrganizationsModule,
UsersModule,
DashboardModule,
AuditModule,
NotificationsModule,
WebhooksModule,
GDPRModule,
],
controllers: [],
providers: [
// Global JWT authentication guard
// All routes are protected by default, use @Public() to bypass
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global rate limiting guard
{
provide: APP_GUARD,
useClass: CustomThrottlerGuard,
},
],
})
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 { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
@ -22,7 +28,7 @@ export class AuthService {
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository, // ✅ Correct injection
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly configService: ConfigService
) {}
/**
@ -33,7 +39,7 @@ export class AuthService {
password: string,
firstName: string,
lastName: string,
organizationId?: string,
organizationId?: string
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Registering new user: ${email}`);
@ -87,7 +93,7 @@ export class AuthService {
*/
async login(
email: string,
password: string,
password: string
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Login attempt for: ${email}`);
@ -127,7 +133,9 @@ export class AuthService {
/**
* 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 {
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.configService.get('JWT_SECRET'),

View File

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

View File

@ -1,358 +1,331 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
HttpCode,
HttpStatus,
Logger,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { RolesGuard } from '../../guards/roles.guard';
import { Roles } from '../../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
import {
CsvRateUploadDto,
CsvRateUploadResponseDto,
CsvRateConfigDto,
CsvFileValidationDto,
} from '../../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
/**
* CSV Rates Admin Controller
*
* ADMIN-ONLY endpoints for managing CSV rate files
* Protected by JWT + Roles guard
*/
@ApiTags('Admin - CSV Rates')
@Controller('admin/csv-rates')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints
export class CsvRatesAdminController {
private readonly logger = new Logger(CsvRatesAdminController.name);
constructor(
private readonly csvLoader: CsvRateLoaderAdapter,
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
private readonly csvRateMapper: CsvRateMapper,
) {}
/**
* Upload CSV rate file (ADMIN only)
*/
@Post('upload')
@HttpCode(HttpStatus.CREATED)
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: './apps/backend/src/infrastructure/storage/csv-storage/rates',
filename: (req, file, cb) => {
// Generate filename: company-name.csv
const companyName = req.body.companyName || 'unknown';
const sanitized = companyName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
const filename = `${sanitized}.csv`;
cb(null, filename);
},
}),
fileFilter: (req, file, cb) => {
// Only allow CSV files
if (extname(file.originalname).toLowerCase() !== '.csv') {
return cb(new BadRequestException('Only CSV files are allowed'), false);
}
cb(null, true);
},
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
}),
)
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Upload CSV rate file (ADMIN only)',
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.',
})
@ApiBody({
schema: {
type: 'object',
required: ['companyName', 'file'],
properties: {
companyName: {
type: 'string',
description: 'Carrier company name',
example: 'SSC Consolidation',
},
file: {
type: 'string',
format: 'binary',
description: 'CSV file to upload',
},
},
},
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'CSV file uploaded and validated successfully',
type: CsvRateUploadResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid file format or validation failed',
})
@ApiResponse({
status: 403,
description: 'Forbidden - Admin role required',
})
async uploadCsv(
@UploadedFile() file: Express.Multer.File,
@Body() dto: CsvRateUploadDto,
@CurrentUser() user: UserPayload,
): Promise<CsvRateUploadResponseDto> {
this.logger.log(
`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`,
);
if (!file) {
throw new BadRequestException('File is required');
}
try {
// Validate CSV file structure
const validation = await this.csvLoader.validateCsvFile(file.filename);
if (!validation.valid) {
this.logger.error(
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`,
);
throw new BadRequestException({
message: 'CSV validation failed',
errors: validation.errors,
});
}
// Load rates to verify parsing
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
const ratesCount = rates.length;
this.logger.log(
`Successfully parsed ${ratesCount} rates from ${file.filename}`,
);
// Check if config exists for this company
const existingConfig = await this.csvConfigRepository.findByCompanyName(
dto.companyName,
);
if (existingConfig) {
// Update existing configuration
await this.csvConfigRepository.update(existingConfig.id, {
csvFilePath: file.filename,
uploadedAt: new Date(),
uploadedBy: user.id,
rowCount: ratesCount,
lastValidatedAt: new Date(),
metadata: {
...existingConfig.metadata,
lastUpload: {
timestamp: new Date().toISOString(),
by: user.email,
ratesCount,
},
},
});
this.logger.log(
`Updated CSV config for company: ${dto.companyName}`,
);
} else {
// Create new configuration
await this.csvConfigRepository.create({
companyName: dto.companyName,
csvFilePath: file.filename,
type: 'CSV_ONLY',
hasApi: false,
apiConnector: null,
isActive: true,
uploadedAt: new Date(),
uploadedBy: user.id,
rowCount: ratesCount,
lastValidatedAt: new Date(),
metadata: {
uploadedBy: user.email,
description: `${dto.companyName} shipping rates`,
},
});
this.logger.log(
`Created new CSV config for company: ${dto.companyName}`,
);
}
return {
success: true,
ratesCount,
csvFilePath: file.filename,
companyName: dto.companyName,
uploadedAt: new Date(),
};
} catch (error: any) {
this.logger.error(
`CSV upload failed: ${error?.message || 'Unknown error'}`,
error?.stack,
);
throw error;
}
}
/**
* Get all CSV rate configurations
*/
@Get('config')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get all CSV rate configurations (ADMIN only)',
description: 'Returns list of all CSV rate configurations with upload details.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of CSV rate configurations',
type: [CsvRateConfigDto],
})
async getAllConfigs(): Promise<CsvRateConfigDto[]> {
this.logger.log('Fetching all CSV rate configs (admin)');
const configs = await this.csvConfigRepository.findAll();
return this.csvRateMapper.mapConfigEntitiesToDtos(configs);
}
/**
* Get configuration for specific company
*/
@Get('config/:companyName')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get CSV configuration for specific company (ADMIN only)',
description: 'Returns CSV rate configuration details for a specific carrier.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV rate configuration',
type: CsvRateConfigDto,
})
@ApiResponse({
status: 404,
description: 'Company configuration not found',
})
async getConfigByCompany(
@Param('companyName') companyName: string,
): Promise<CsvRateConfigDto> {
this.logger.log(`Fetching CSV config for company: ${companyName}`);
const config = await this.csvConfigRepository.findByCompanyName(companyName);
if (!config) {
throw new BadRequestException(
`No CSV configuration found for company: ${companyName}`,
);
}
return this.csvRateMapper.mapConfigEntityToDto(config);
}
/**
* Validate CSV file
*/
@Post('validate/:companyName')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Validate CSV file for company (ADMIN only)',
description:
'Validates the CSV file structure and data for a specific company without uploading.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Validation result',
type: CsvFileValidationDto,
})
async validateCsvFile(
@Param('companyName') companyName: string,
): Promise<CsvFileValidationDto> {
this.logger.log(`Validating CSV file for company: ${companyName}`);
const config = await this.csvConfigRepository.findByCompanyName(companyName);
if (!config) {
throw new BadRequestException(
`No CSV configuration found for company: ${companyName}`,
);
}
const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
// Update validation timestamp
if (result.valid && result.rowCount) {
await this.csvConfigRepository.updateValidationInfo(
companyName,
result.rowCount,
result,
);
}
return result;
}
/**
* Delete CSV rate configuration
*/
@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}`);
}
}
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
HttpCode,
HttpStatus,
Logger,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiBody,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { RolesGuard } from '../../guards/roles.guard';
import { Roles } from '../../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
import {
CsvRateUploadDto,
CsvRateUploadResponseDto,
CsvRateConfigDto,
CsvFileValidationDto,
} from '../../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
/**
* CSV Rates Admin Controller
*
* ADMIN-ONLY endpoints for managing CSV rate files
* Protected by JWT + Roles guard
*/
@ApiTags('Admin - CSV Rates')
@Controller('admin/csv-rates')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints
export class CsvRatesAdminController {
private readonly logger = new Logger(CsvRatesAdminController.name);
constructor(
private readonly csvLoader: CsvRateLoaderAdapter,
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
private readonly csvRateMapper: CsvRateMapper
) {}
/**
* Upload CSV rate file (ADMIN only)
*/
@Post('upload')
@HttpCode(HttpStatus.CREATED)
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: './apps/backend/src/infrastructure/storage/csv-storage/rates',
filename: (req, file, cb) => {
// Generate filename: company-name.csv
const companyName = req.body.companyName || 'unknown';
const sanitized = companyName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
const filename = `${sanitized}.csv`;
cb(null, filename);
},
}),
fileFilter: (req, file, cb) => {
// Only allow CSV files
if (extname(file.originalname).toLowerCase() !== '.csv') {
return cb(new BadRequestException('Only CSV files are allowed'), false);
}
cb(null, true);
},
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
})
)
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Upload CSV rate file (ADMIN only)',
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.',
})
@ApiBody({
schema: {
type: 'object',
required: ['companyName', 'file'],
properties: {
companyName: {
type: 'string',
description: 'Carrier company name',
example: 'SSC Consolidation',
},
file: {
type: 'string',
format: 'binary',
description: 'CSV file to upload',
},
},
},
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'CSV file uploaded and validated successfully',
type: CsvRateUploadResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid file format or validation failed',
})
@ApiResponse({
status: 403,
description: 'Forbidden - Admin role required',
})
async uploadCsv(
@UploadedFile() file: Express.Multer.File,
@Body() dto: CsvRateUploadDto,
@CurrentUser() user: UserPayload
): Promise<CsvRateUploadResponseDto> {
this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`);
if (!file) {
throw new BadRequestException('File is required');
}
try {
// Validate CSV file structure
const validation = await this.csvLoader.validateCsvFile(file.filename);
if (!validation.valid) {
this.logger.error(
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`
);
throw new BadRequestException({
message: 'CSV validation failed',
errors: validation.errors,
});
}
// Load rates to verify parsing
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
const ratesCount = rates.length;
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
// Check if config exists for this company
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
if (existingConfig) {
// Update existing configuration
await this.csvConfigRepository.update(existingConfig.id, {
csvFilePath: file.filename,
uploadedAt: new Date(),
uploadedBy: user.id,
rowCount: ratesCount,
lastValidatedAt: new Date(),
metadata: {
...existingConfig.metadata,
lastUpload: {
timestamp: new Date().toISOString(),
by: user.email,
ratesCount,
},
},
});
this.logger.log(`Updated CSV config for company: ${dto.companyName}`);
} else {
// Create new configuration
await this.csvConfigRepository.create({
companyName: dto.companyName,
csvFilePath: file.filename,
type: 'CSV_ONLY',
hasApi: false,
apiConnector: null,
isActive: true,
uploadedAt: new Date(),
uploadedBy: user.id,
rowCount: ratesCount,
lastValidatedAt: new Date(),
metadata: {
uploadedBy: user.email,
description: `${dto.companyName} shipping rates`,
},
});
this.logger.log(`Created new CSV config for company: ${dto.companyName}`);
}
return {
success: true,
ratesCount,
csvFilePath: file.filename,
companyName: dto.companyName,
uploadedAt: new Date(),
};
} catch (error: any) {
this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack);
throw error;
}
}
/**
* Get all CSV rate configurations
*/
@Get('config')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get all CSV rate configurations (ADMIN only)',
description: 'Returns list of all CSV rate configurations with upload details.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of CSV rate configurations',
type: [CsvRateConfigDto],
})
async getAllConfigs(): Promise<CsvRateConfigDto[]> {
this.logger.log('Fetching all CSV rate configs (admin)');
const configs = await this.csvConfigRepository.findAll();
return this.csvRateMapper.mapConfigEntitiesToDtos(configs);
}
/**
* Get configuration for specific company
*/
@Get('config/:companyName')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get CSV configuration for specific company (ADMIN only)',
description: 'Returns CSV rate configuration details for a specific carrier.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV rate configuration',
type: CsvRateConfigDto,
})
@ApiResponse({
status: 404,
description: 'Company configuration not found',
})
async getConfigByCompany(@Param('companyName') companyName: string): Promise<CsvRateConfigDto> {
this.logger.log(`Fetching CSV config for company: ${companyName}`);
const config = await this.csvConfigRepository.findByCompanyName(companyName);
if (!config) {
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
}
return this.csvRateMapper.mapConfigEntityToDto(config);
}
/**
* Validate CSV file
*/
@Post('validate/:companyName')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Validate CSV file for company (ADMIN only)',
description:
'Validates the CSV file structure and data for a specific company without uploading.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Validation result',
type: CsvFileValidationDto,
})
async validateCsvFile(@Param('companyName') companyName: string): Promise<CsvFileValidationDto> {
this.logger.log(`Validating CSV file for company: ${companyName}`);
const config = await this.csvConfigRepository.findByCompanyName(companyName);
if (!config) {
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
}
const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
// Update validation timestamp
if (result.valid && result.rowCount) {
await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result);
}
return result;
}
/**
* Delete CSV rate configuration
*/
@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' })
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
@ApiQuery({ 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: '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: 'resourceId', required: false, description: 'Filter by resource ID' })
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
@ -84,7 +94,7 @@ export class AuditController {
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@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 }> {
page = page || 1;
limit = limit || 50;
@ -104,7 +114,7 @@ export class AuditController {
const { logs, total } = await this.auditService.getAuditLogs(filters);
return {
logs: logs.map((log) => this.mapToDto(log)),
logs: logs.map(log => this.mapToDto(log)),
total,
page,
pageSize: limit,
@ -121,7 +131,7 @@ export class AuditController {
@ApiResponse({ status: 404, description: 'Audit log not found' })
async getAuditLogById(
@Param('id') id: string,
@CurrentUser() user: UserPayload,
@CurrentUser() user: UserPayload
): Promise<AuditLogResponseDto> {
const log = await this.auditService.getAuditLogs({
organizationId: user.organizationId,
@ -145,14 +155,14 @@ export class AuditController {
async getResourceAuditTrail(
@Param('type') resourceType: string,
@Param('id') resourceId: string,
@CurrentUser() user: UserPayload,
@CurrentUser() user: UserPayload
): Promise<AuditLogResponseDto[]> {
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
// 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)' })
async getOrganizationActivity(
@CurrentUser() user: UserPayload,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
): Promise<AuditLogResponseDto[]> {
limit = limit || 50;
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(
@CurrentUser() user: UserPayload,
@Param('userId') userId: string,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
): Promise<AuditLogResponseDto[]> {
limit = limit || 50;
const logs = await this.auditService.getUserActivity(userId, limit);
// 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 {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
Get,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthService } from '../auth/auth.service';
import {
LoginDto,
RegisterDto,
AuthResponseDto,
RefreshTokenDto,
} from '../dto/auth-login.dto';
import { Public } from '../decorators/public.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
/**
* Authentication Controller
*
* Handles user authentication endpoints:
* - POST /auth/register - User registration
* - POST /auth/login - User login
* - POST /auth/refresh - Token refresh
* - POST /auth/logout - User logout (placeholder)
* - GET /auth/me - Get current user profile
*/
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* Register a new user
*
* Creates a new user account and returns access + refresh tokens.
*
* @param dto - Registration data (email, password, firstName, lastName, organizationId)
* @returns Access token, refresh token, and user info
*/
@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Register new user',
description:
'Create a new user account with email and password. Returns JWT tokens.',
})
@ApiResponse({
status: 201,
description: 'User successfully registered',
type: AuthResponseDto,
})
@ApiResponse({
status: 409,
description: 'User with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Validation error (invalid email, weak password, etc.)',
})
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
const result = await this.authService.register(
dto.email,
dto.password,
dto.firstName,
dto.lastName,
dto.organizationId,
);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
};
}
/**
* Login with email and password
*
* Authenticates a user and returns access + refresh tokens.
*
* @param dto - Login credentials (email, password)
* @returns Access token, refresh token, and user info
*/
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'User login',
description: 'Authenticate with email and password. Returns JWT tokens.',
})
@ApiResponse({
status: 200,
description: 'Login successful',
type: AuthResponseDto,
})
@ApiResponse({
status: 401,
description: 'Invalid credentials or inactive account',
})
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
const result = await this.authService.login(dto.email, dto.password);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
};
}
/**
* Refresh access token
*
* Obtains a new access token using a valid refresh token.
*
* @param dto - Refresh token
* @returns New access token
*/
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Refresh access token',
description:
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
})
@ApiResponse({
status: 200,
description: 'Token refreshed successfully',
schema: {
properties: {
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
},
},
})
@ApiResponse({
status: 401,
description: 'Invalid or expired refresh token',
})
async refresh(
@Body() dto: RefreshTokenDto,
): Promise<{ accessToken: string }> {
const result =
await this.authService.refreshAccessToken(dto.refreshToken);
return { accessToken: result.accessToken };
}
/**
* Logout (placeholder)
*
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
* by removing tokens. For more security, implement token blacklisting with Redis.
*
* @returns Success message
*/
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({
summary: 'Logout',
description:
'Logout the current user. Currently handled client-side by removing tokens.',
})
@ApiResponse({
status: 200,
description: 'Logout successful',
schema: {
properties: {
message: { type: 'string', example: 'Logout successful' },
},
},
})
async logout(): Promise<{ message: string }> {
// TODO: Implement token blacklisting with Redis for more security
// For now, logout is handled client-side by removing tokens
return { message: 'Logout successful' };
}
/**
* Get current user profile
*
* Returns the profile of the currently authenticated user.
*
* @param user - Current user from JWT token
* @returns User profile
*/
@UseGuards(JwtAuthGuard)
@Get('me')
@ApiBearerAuth()
@ApiOperation({
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;
}
}
import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from '../auth/auth.service';
import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
import { Public } from '../decorators/public.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
/**
* Authentication Controller
*
* Handles user authentication endpoints:
* - POST /auth/register - User registration
* - POST /auth/login - User login
* - POST /auth/refresh - Token refresh
* - POST /auth/logout - User logout (placeholder)
* - GET /auth/me - Get current user profile
*/
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* Register a new user
*
* Creates a new user account and returns access + refresh tokens.
*
* @param dto - Registration data (email, password, firstName, lastName, organizationId)
* @returns Access token, refresh token, and user info
*/
@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Register new user',
description: 'Create a new user account with email and password. Returns JWT tokens.',
})
@ApiResponse({
status: 201,
description: 'User successfully registered',
type: AuthResponseDto,
})
@ApiResponse({
status: 409,
description: 'User with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Validation error (invalid email, weak password, etc.)',
})
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
const result = await this.authService.register(
dto.email,
dto.password,
dto.firstName,
dto.lastName,
dto.organizationId
);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
};
}
/**
* Login with email and password
*
* Authenticates a user and returns access + refresh tokens.
*
* @param dto - Login credentials (email, password)
* @returns Access token, refresh token, and user info
*/
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'User login',
description: 'Authenticate with email and password. Returns JWT tokens.',
})
@ApiResponse({
status: 200,
description: 'Login successful',
type: AuthResponseDto,
})
@ApiResponse({
status: 401,
description: 'Invalid credentials or inactive account',
})
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
const result = await this.authService.login(dto.email, dto.password);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
};
}
/**
* Refresh access token
*
* Obtains a new access token using a valid refresh token.
*
* @param dto - Refresh token
* @returns New access token
*/
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Refresh access token',
description:
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
})
@ApiResponse({
status: 200,
description: 'Token refreshed successfully',
schema: {
properties: {
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
},
},
})
@ApiResponse({
status: 401,
description: 'Invalid or expired refresh token',
})
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
const result = await this.authService.refreshAccessToken(dto.refreshToken);
return { accessToken: result.accessToken };
}
/**
* Logout (placeholder)
*
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
* by removing tokens. For more security, implement token blacklisting with Redis.
*
* @returns Success message
*/
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({
summary: 'Logout',
description: 'Logout the current user. Currently handled client-side by removing tokens.',
})
@ApiResponse({
status: 200,
description: 'Logout successful',
schema: {
properties: {
message: { type: 'string', example: 'Logout successful' },
},
},
})
async logout(): Promise<{ message: string }> {
// TODO: Implement token blacklisting with Redis for more security
// For now, logout is handled client-side by removing tokens
return { message: 'Logout successful' };
}
/**
* Get current user profile
*
* Returns the profile of the currently authenticated user.
*
* @param user - Current user from JWT token
* @returns User profile
*/
@UseGuards(JwtAuthGuard)
@Get('me')
@ApiBearerAuth()
@ApiOperation({
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,
description: 'Data export successful',
})
async exportData(
@CurrentUser() user: UserPayload,
@Res() res: Response,
): Promise<void> {
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
const exportData = await this.gdprService.exportUserData(user.id);
// Set headers for file download
res.setHeader('Content-Type', 'application/json');
res.setHeader(
'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);
@ -69,10 +66,7 @@ export class GDPRController {
status: 200,
description: 'CSV export successful',
})
async exportDataCSV(
@CurrentUser() user: UserPayload,
@Res() res: Response,
): Promise<void> {
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
const exportData = await this.gdprService.exportUserData(user.id);
// Convert to CSV (simplified version)
@ -87,7 +81,7 @@ export class GDPRController {
res.setHeader('Content-Type', 'text/csv');
res.setHeader(
'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);
@ -108,7 +102,7 @@ export class GDPRController {
})
async deleteAccount(
@CurrentUser() user: UserPayload,
@Body() body: { reason?: string; confirmEmail: string },
@Body() body: { reason?: string; confirmEmail: string }
): Promise<void> {
// Verify email confirmation (security measure)
if (body.confirmEmail !== user.email) {
@ -133,7 +127,7 @@ export class GDPRController {
})
async recordConsent(
@CurrentUser() user: UserPayload,
@Body() body: Omit<ConsentData, 'userId'>,
@Body() body: Omit<ConsentData, 'userId'>
): Promise<{ success: boolean }> {
await this.gdprService.recordConsent({
...body,
@ -158,7 +152,7 @@ export class GDPRController {
})
async withdrawConsent(
@CurrentUser() user: UserPayload,
@Body() body: { consentType: 'marketing' | 'analytics' },
@Body() body: { consentType: 'marketing' | 'analytics' }
): Promise<{ success: boolean }> {
await this.gdprService.withdrawConsent(user.id, body.consentType);
@ -177,9 +171,7 @@ export class GDPRController {
status: 200,
description: 'Consent status retrieved',
})
async getConsentStatus(
@CurrentUser() user: UserPayload,
): Promise<any> {
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
return this.gdprService.getConsentStatus(user.id);
}
}

View File

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

View File

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

View File

@ -1,367 +1,357 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
NotFoundException,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
UseGuards,
ForbiddenException,
Inject,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiQuery,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
CreateOrganizationDto,
UpdateOrganizationDto,
OrganizationResponseDto,
OrganizationListResponseDto,
} from '../dto/organization.dto';
import { OrganizationMapper } from '../mappers/organization.mapper';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { v4 as uuidv4 } from 'uuid';
/**
* Organizations Controller
*
* Manages organization CRUD operations:
* - Create organization (admin only)
* - Get organization details
* - Update organization (admin/manager)
* - List organizations
*/
@ApiTags('Organizations')
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
export class OrganizationsController {
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.
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@Roles('admin')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Create new organization',
description:
'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Organization created successfully',
type: OrganizationResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
})
async createOrganization(
@Body() dto: CreateOrganizationDto,
@CurrentUser() user: UserPayload,
): Promise<OrganizationResponseDto> {
this.logger.log(
`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`,
);
try {
// Check for duplicate name
const existingByName = await this.organizationRepository.findByName(dto.name);
if (existingByName) {
throw new ForbiddenException(
`Organization with name "${dto.name}" already exists`,
);
}
// Check for duplicate SCAC if provided
if (dto.scac) {
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
if (existingBySCAC) {
throw new ForbiddenException(
`Organization with SCAC "${dto.scac}" already exists`,
);
}
}
// Create organization entity
const organization = Organization.create({
id: uuidv4(),
name: dto.name,
type: dto.type,
scac: dto.scac,
address: OrganizationMapper.mapDtoToAddress(dto.address),
logoUrl: dto.logoUrl,
documents: [],
isActive: true,
});
// Save to database
const savedOrg = await this.organizationRepository.save(organization);
this.logger.log(
`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`,
);
return OrganizationMapper.toDto(savedOrg);
} catch (error: any) {
this.logger.error(
`Organization creation failed: ${error?.message || 'Unknown error'}`,
error?.stack,
);
throw error;
}
}
/**
* Get organization by ID
*
* Retrieve details of a specific organization.
* Users can only view their own organization unless they are admins.
*/
@Get(':id')
@ApiOperation({
summary: 'Get organization by ID',
description:
'Retrieve organization details. Users can view their own organization, admins can view any.',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organization details retrieved successfully',
type: OrganizationResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async getOrganization(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
): Promise<OrganizationResponseDto> {
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
// 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');
}
return OrganizationMapper.toDto(organization);
}
/**
* Update organization
*
* Update organization details (name, address, logo, status).
* Requires admin or manager role.
*/
@Patch(':id')
@Roles('admin', 'manager')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Update organization',
description:
'Update organization details (name, address, logo, status). Requires admin or manager role.',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organization updated successfully',
type: OrganizationResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async updateOrganization(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateOrganizationDto,
@CurrentUser() user: UserPayload,
): Promise<OrganizationResponseDto> {
this.logger.log(
`[User: ${user.email}] Updating organization: ${id}`,
);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
// 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');
}
// Update fields
if (dto.name) {
organization.updateName(dto.name);
}
if (dto.address) {
organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address));
}
if (dto.logoUrl !== undefined) {
organization.updateLogoUrl(dto.logoUrl);
}
if (dto.isActive !== undefined) {
if (dto.isActive) {
organization.activate();
} else {
organization.deactivate();
}
}
// Save updated organization
const updatedOrg = await this.organizationRepository.save(organization);
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
return OrganizationMapper.toDto(updatedOrg);
}
/**
* List organizations
*
* Retrieve a paginated list of organizations.
* Admins can see all, others see only their own.
*/
@Get()
@ApiOperation({
summary: 'List organizations',
description:
'Retrieve a paginated list of organizations. Admins see all, others see only their own.',
})
@ApiQuery({
name: 'page',
required: false,
description: 'Page number (1-based)',
example: 1,
})
@ApiQuery({
name: 'pageSize',
required: false,
description: 'Number of items per page',
example: 20,
})
@ApiQuery({
name: 'type',
required: false,
description: 'Filter by organization type',
enum: OrganizationType,
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organizations list retrieved successfully',
type: OrganizationListResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
async listOrganizations(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
@Query('type') type: OrganizationType | undefined,
@CurrentUser() user: UserPayload,
): Promise<OrganizationListResponseDto> {
this.logger.log(
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`,
);
// Fetch organizations
let organizations: Organization[];
if (user.role === 'admin') {
// Admins can see all organizations
organizations = await this.organizationRepository.findAll();
} else {
// Others see only their own organization
const userOrg = await this.organizationRepository.findById(user.organizationId);
organizations = userOrg ? [userOrg] : [];
}
// Filter by type if provided
const filteredOrgs = type
? organizations.filter(org => org.type === type)
: organizations;
// Paginate
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex);
// Convert to DTOs
const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs);
const totalPages = Math.ceil(filteredOrgs.length / pageSize);
return {
organizations: orgDtos,
total: filteredOrgs.length,
page,
pageSize,
totalPages,
};
}
}
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
NotFoundException,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
UseGuards,
ForbiddenException,
Inject,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiQuery,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
CreateOrganizationDto,
UpdateOrganizationDto,
OrganizationResponseDto,
OrganizationListResponseDto,
} from '../dto/organization.dto';
import { OrganizationMapper } from '../mappers/organization.mapper';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '../../domain/ports/out/organization.repository';
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { v4 as uuidv4 } from 'uuid';
/**
* Organizations Controller
*
* Manages organization CRUD operations:
* - Create organization (admin only)
* - Get organization details
* - Update organization (admin/manager)
* - List organizations
*/
@ApiTags('Organizations')
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth()
export class OrganizationsController {
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.
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@Roles('admin')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Create new organization',
description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Organization created successfully',
type: OrganizationResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
})
async createOrganization(
@Body() dto: CreateOrganizationDto,
@CurrentUser() user: UserPayload
): Promise<OrganizationResponseDto> {
this.logger.log(`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`);
try {
// Check for duplicate name
const existingByName = await this.organizationRepository.findByName(dto.name);
if (existingByName) {
throw new ForbiddenException(`Organization with name "${dto.name}" already exists`);
}
// Check for duplicate SCAC if provided
if (dto.scac) {
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
if (existingBySCAC) {
throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`);
}
}
// Create organization entity
const organization = Organization.create({
id: uuidv4(),
name: dto.name,
type: dto.type,
scac: dto.scac,
address: OrganizationMapper.mapDtoToAddress(dto.address),
logoUrl: dto.logoUrl,
documents: [],
isActive: true,
});
// Save to database
const savedOrg = await this.organizationRepository.save(organization);
this.logger.log(`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`);
return OrganizationMapper.toDto(savedOrg);
} catch (error: any) {
this.logger.error(
`Organization creation failed: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/**
* Get organization by ID
*
* Retrieve details of a specific organization.
* Users can only view their own organization unless they are admins.
*/
@Get(':id')
@ApiOperation({
summary: 'Get organization by ID',
description:
'Retrieve organization details. Users can view their own organization, admins can view any.',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organization details retrieved successfully',
type: OrganizationResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async getOrganization(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload
): Promise<OrganizationResponseDto> {
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
// 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');
}
return OrganizationMapper.toDto(organization);
}
/**
* Update organization
*
* Update organization details (name, address, logo, status).
* Requires admin or manager role.
*/
@Patch(':id')
@Roles('admin', 'manager')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Update organization',
description:
'Update organization details (name, address, logo, status). Requires admin or manager role.',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organization updated successfully',
type: OrganizationResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async updateOrganization(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateOrganizationDto,
@CurrentUser() user: UserPayload
): Promise<OrganizationResponseDto> {
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
// 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');
}
// Update fields
if (dto.name) {
organization.updateName(dto.name);
}
if (dto.address) {
organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address));
}
if (dto.logoUrl !== undefined) {
organization.updateLogoUrl(dto.logoUrl);
}
if (dto.isActive !== undefined) {
if (dto.isActive) {
organization.activate();
} else {
organization.deactivate();
}
}
// Save updated organization
const updatedOrg = await this.organizationRepository.save(organization);
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
return OrganizationMapper.toDto(updatedOrg);
}
/**
* List organizations
*
* Retrieve a paginated list of organizations.
* Admins can see all, others see only their own.
*/
@Get()
@ApiOperation({
summary: 'List organizations',
description:
'Retrieve a paginated list of organizations. Admins see all, others see only their own.',
})
@ApiQuery({
name: 'page',
required: false,
description: 'Page number (1-based)',
example: 1,
})
@ApiQuery({
name: 'pageSize',
required: false,
description: 'Number of items per page',
example: 20,
})
@ApiQuery({
name: 'type',
required: false,
description: 'Filter by organization type',
enum: OrganizationType,
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organizations list retrieved successfully',
type: OrganizationListResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
async listOrganizations(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
@Query('type') type: OrganizationType | undefined,
@CurrentUser() user: UserPayload
): Promise<OrganizationListResponseDto> {
this.logger.log(
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`
);
// Fetch organizations
let organizations: Organization[];
if (user.role === 'admin') {
// Admins can see all organizations
organizations = await this.organizationRepository.findAll();
} else {
// Others see only their own organization
const userOrg = await this.organizationRepository.findById(user.organizationId);
organizations = userOrg ? [userOrg] : [];
}
// Filter by type if provided
const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations;
// Paginate
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex);
// Convert to DTOs
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 {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBadRequestResponse,
ApiInternalServerErrorResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
import { RateQuoteMapper } from '../mappers';
import { RateSearchService } from '../../domain/services/rate-search.service';
import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
@ApiTags('Rates')
@Controller('rates')
@ApiBearerAuth()
export class RatesController {
private readonly logger = new Logger(RatesController.name);
constructor(
private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper,
) {}
@Post('search')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Search shipping rates',
description:
'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Rate search completed successfully',
type: RateSearchResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
schema: {
example: {
statusCode: 400,
message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'],
error: 'Bad Request',
},
},
})
@ApiInternalServerErrorResponse({
description: 'Internal server error',
})
async searchRates(
@Body() dto: RateSearchRequestDto,
@CurrentUser() user: UserPayload,
): Promise<RateSearchResponseDto> {
const startTime = Date.now();
this.logger.log(
`[User: ${user.email}] Searching rates: ${dto.origin}${dto.destination}, ${dto.containerType}`,
);
try {
// Convert DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
containerType: dto.containerType,
mode: dto.mode,
departureDate: new Date(dto.departureDate),
quantity: dto.quantity,
weight: dto.weight,
volume: dto.volume,
isHazmat: dto.isHazmat,
imoClass: dto.imoClass,
};
// Execute search
const result = await this.rateSearchService.execute(searchInput);
// Convert domain entities to DTOs
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
const responseTimeMs = Date.now() - startTime;
this.logger.log(
`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`,
);
return {
quotes: quoteDtos,
count: quoteDtos.length,
origin: dto.origin,
destination: dto.destination,
departureDate: dto.departureDate,
containerType: dto.containerType,
mode: dto.mode,
fromCache: false, // TODO: Implement cache detection
responseTimeMs,
};
} catch (error: any) {
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)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Search CSV-based rates with advanced filters',
description:
'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.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV rate search completed successfully',
type: CsvRateSearchResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
})
async searchCsvRates(
@Body() dto: CsvRateSearchDto,
@CurrentUser() user: UserPayload,
): Promise<CsvRateSearchResponseDto> {
const startTime = Date.now();
this.logger.log(
`[User: ${user.email}] Searching CSV rates: ${dto.origin}${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`,
);
try {
// Map DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
};
// Execute CSV rate search
const result = await this.csvRateSearchService.execute(searchInput);
// Map domain output to response DTO
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
const responseTimeMs = Date.now() - startTime;
this.logger.log(
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`,
);
return response;
} catch (error: any) {
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)
@ApiOperation({
summary: 'Get available carrier companies',
description: 'Returns list of all available carrier companies in the CSV rate system.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available companies',
type: AvailableCompaniesDto,
})
async getCompanies(): Promise<AvailableCompaniesDto> {
this.logger.log('Fetching available companies');
try {
const companies = await this.csvRateSearchService.getAvailableCompanies();
return {
companies,
total: companies.length,
};
} catch (error: any) {
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)
@ApiOperation({
summary: 'Get available filter options',
description:
'Returns available options for all filters (companies, container types, currencies).',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Available filter options',
type: FilterOptionsDto,
})
async getFilterOptions(): Promise<FilterOptionsDto> {
this.logger.log('Fetching filter options');
try {
const [companies, containerTypes] = await Promise.all([
this.csvRateSearchService.getAvailableCompanies(),
this.csvRateSearchService.getAvailableContainerTypes(),
]);
return {
companies,
containerTypes,
currencies: ['USD', 'EUR'],
};
} catch (error: any) {
this.logger.error(
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
error?.stack,
);
throw error;
}
}
}
import {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBadRequestResponse,
ApiInternalServerErrorResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
import { RateQuoteMapper } from '../mappers';
import { RateSearchService } from '../../domain/services/rate-search.service';
import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
@ApiTags('Rates')
@Controller('rates')
@ApiBearerAuth()
export class RatesController {
private readonly logger = new Logger(RatesController.name);
constructor(
private readonly rateSearchService: RateSearchService,
private readonly csvRateSearchService: CsvRateSearchService,
private readonly csvRateMapper: CsvRateMapper
) {}
@Post('search')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Search shipping rates',
description:
'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Rate search completed successfully',
type: RateSearchResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
schema: {
example: {
statusCode: 400,
message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'],
error: 'Bad Request',
},
},
})
@ApiInternalServerErrorResponse({
description: 'Internal server error',
})
async searchRates(
@Body() dto: RateSearchRequestDto,
@CurrentUser() user: UserPayload
): Promise<RateSearchResponseDto> {
const startTime = Date.now();
this.logger.log(
`[User: ${user.email}] Searching rates: ${dto.origin}${dto.destination}, ${dto.containerType}`
);
try {
// Convert DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
containerType: dto.containerType,
mode: dto.mode,
departureDate: new Date(dto.departureDate),
quantity: dto.quantity,
weight: dto.weight,
volume: dto.volume,
isHazmat: dto.isHazmat,
imoClass: dto.imoClass,
};
// Execute search
const result = await this.rateSearchService.execute(searchInput);
// Convert domain entities to DTOs
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
const responseTimeMs = Date.now() - startTime;
this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`);
return {
quotes: quoteDtos,
count: quoteDtos.length,
origin: dto.origin,
destination: dto.destination,
departureDate: dto.departureDate,
containerType: dto.containerType,
mode: dto.mode,
fromCache: false, // TODO: Implement cache detection
responseTimeMs,
};
} catch (error: any) {
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)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Search CSV-based rates with advanced filters',
description:
'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.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV rate search completed successfully',
type: CsvRateSearchResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
})
async searchCsvRates(
@Body() dto: CsvRateSearchDto,
@CurrentUser() user: UserPayload
): Promise<CsvRateSearchResponseDto> {
const startTime = Date.now();
this.logger.log(
`[User: ${user.email}] Searching CSV rates: ${dto.origin}${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
);
try {
// Map DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
};
// Execute CSV rate search
const result = await this.csvRateSearchService.execute(searchInput);
// Map domain output to response DTO
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
const responseTimeMs = Date.now() - startTime;
this.logger.log(
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`
);
return response;
} catch (error: any) {
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)
@ApiOperation({
summary: 'Get available carrier companies',
description: 'Returns list of all available carrier companies in the CSV rate system.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available companies',
type: AvailableCompaniesDto,
})
async getCompanies(): Promise<AvailableCompaniesDto> {
this.logger.log('Fetching available companies');
try {
const companies = await this.csvRateSearchService.getAvailableCompanies();
return {
companies,
total: companies.length,
};
} catch (error: any) {
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)
@ApiOperation({
summary: 'Get available filter options',
description:
'Returns available options for all filters (companies, container types, currencies).',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Available filter options',
type: FilterOptionsDto,
})
async getFilterOptions(): Promise<FilterOptionsDto> {
this.logger.log('Fetching filter options');
try {
const [companies, containerTypes] = await Promise.all([
this.csvRateSearchService.getAvailableCompanies(),
this.csvRateSearchService.getAvailableContainerTypes(),
]);
return {
companies,
containerTypes,
currencies: ['USD', 'EUR'],
};
} catch (error: any) {
this.logger.error(
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,119 +1,135 @@
import { IsString, IsUUID, IsOptional, ValidateNested, IsArray, IsEmail, Matches, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AddressDto {
@ApiProperty({ example: '123 Main Street' })
@IsString()
@MinLength(5, { message: 'Street must be at least 5 characters' })
street: string;
@ApiProperty({ example: 'Rotterdam' })
@IsString()
@MinLength(2, { message: 'City must be at least 2 characters' })
city: string;
@ApiProperty({ example: '3000 AB' })
@IsString()
postalCode: string;
@ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' })
@IsString()
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' })
country: string;
}
export class PartyDto {
@ApiProperty({ example: 'Acme Corporation' })
@IsString()
@MinLength(2, { message: 'Name must be at least 2 characters' })
name: string;
@ApiProperty({ type: AddressDto })
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
@ApiProperty({ example: 'John Doe' })
@IsString()
@MinLength(2, { message: 'Contact name must be at least 2 characters' })
contactName: string;
@ApiProperty({ example: 'john.doe@acme.com' })
@IsEmail({}, { message: 'Contact email must be a valid email address' })
contactEmail: string;
@ApiProperty({ example: '+31612345678' })
@IsString()
@Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Contact phone must be a valid international phone number' })
contactPhone: string;
}
export class ContainerDto {
@ApiProperty({ example: '40HC', description: 'Container type' })
@IsString()
type: string;
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
@IsOptional()
@IsString()
@Matches(/^[A-Z]{4}\d{7}$/, { message: 'Container number must be 4 letters followed by 7 digits' })
containerNumber?: string;
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
@IsOptional()
vgm?: number;
@ApiPropertyOptional({ example: -18, description: 'Temperature in Celsius (for reefer containers)' })
@IsOptional()
temperature?: number;
@ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' })
@IsOptional()
@IsString()
sealNumber?: string;
}
export class CreateBookingRequestDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Rate quote ID from previous search'
})
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
rateQuoteId: string;
@ApiProperty({ type: PartyDto, description: 'Shipper details' })
@ValidateNested()
@Type(() => PartyDto)
shipper: PartyDto;
@ApiProperty({ type: PartyDto, description: 'Consignee details' })
@ValidateNested()
@Type(() => PartyDto)
consignee: PartyDto;
@ApiProperty({
example: 'Electronics and consumer goods',
description: 'Cargo description'
})
@IsString()
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
cargoDescription: 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;
}
import {
IsString,
IsUUID,
IsOptional,
ValidateNested,
IsArray,
IsEmail,
Matches,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AddressDto {
@ApiProperty({ example: '123 Main Street' })
@IsString()
@MinLength(5, { message: 'Street must be at least 5 characters' })
street: string;
@ApiProperty({ example: 'Rotterdam' })
@IsString()
@MinLength(2, { message: 'City must be at least 2 characters' })
city: string;
@ApiProperty({ example: '3000 AB' })
@IsString()
postalCode: string;
@ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' })
@IsString()
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' })
country: string;
}
export class PartyDto {
@ApiProperty({ example: 'Acme Corporation' })
@IsString()
@MinLength(2, { message: 'Name must be at least 2 characters' })
name: string;
@ApiProperty({ type: AddressDto })
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
@ApiProperty({ example: 'John Doe' })
@IsString()
@MinLength(2, { message: 'Contact name must be at least 2 characters' })
contactName: string;
@ApiProperty({ example: 'john.doe@acme.com' })
@IsEmail({}, { message: 'Contact email must be a valid email address' })
contactEmail: string;
@ApiProperty({ example: '+31612345678' })
@IsString()
@Matches(/^\+?[1-9]\d{1,14}$/, {
message: 'Contact phone must be a valid international phone number',
})
contactPhone: string;
}
export class ContainerDto {
@ApiProperty({ example: '40HC', description: 'Container type' })
@IsString()
type: string;
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
@IsOptional()
@IsString()
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'Container number must be 4 letters followed by 7 digits',
})
containerNumber?: string;
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
@IsOptional()
vgm?: number;
@ApiPropertyOptional({
example: -18,
description: 'Temperature in Celsius (for reefer containers)',
})
@IsOptional()
temperature?: number;
@ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' })
@IsOptional()
@IsString()
sealNumber?: string;
}
export class CreateBookingRequestDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Rate quote ID from previous search',
})
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
rateQuoteId: string;
@ApiProperty({ type: PartyDto, description: 'Shipper details' })
@ValidateNested()
@Type(() => PartyDto)
shipper: PartyDto;
@ApiProperty({ type: PartyDto, description: 'Consignee details' })
@ValidateNested()
@Type(() => PartyDto)
consignee: PartyDto;
@ApiProperty({
example: 'Electronics and consumer goods',
description: 'Cargo description',
})
@IsString()
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
cargoDescription: 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 {
IsNotEmpty,
IsString,
IsNumber,
Min,
IsOptional,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto';
/**
* CSV Rate Search Request DTO
*
* Request body for searching rates in CSV-based system
* Includes basic search parameters + optional advanced filters
*/
export class CsvRateSearchDto {
@ApiProperty({
description: 'Origin port code (UN/LOCODE format)',
example: 'NLRTM',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty()
@IsString()
origin: string;
@ApiProperty({
description: 'Destination port code (UN/LOCODE format)',
example: 'USNYC',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty()
@IsString()
destination: string;
@ApiProperty({
description: 'Volume in cubic meters (CBM)',
minimum: 0.01,
example: 25.5,
})
@IsNotEmpty()
@IsNumber()
@Min(0.01)
volumeCBM: number;
@ApiProperty({
description: 'Weight in kilograms',
minimum: 1,
example: 3500,
})
@IsNotEmpty()
@IsNumber()
@Min(1)
weightKG: number;
@ApiPropertyOptional({
description: 'Number of pallets (0 if no pallets)',
minimum: 0,
example: 10,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
example: 'LCL',
})
@IsOptional()
@IsString()
containerType?: string;
@ApiPropertyOptional({
description: 'Advanced filters for narrowing results',
type: RateSearchFiltersDto,
})
@IsOptional()
@ValidateNested()
@Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto;
}
/**
* CSV Rate Search Response DTO
*
* Response containing matching rates with calculated prices
*/
export class CsvRateSearchResponseDto {
@ApiProperty({
description: 'Array of matching rate results',
type: [Object], // Will be replaced with RateResultDto
})
results: CsvRateResultDto[];
@ApiProperty({
description: 'Total number of results found',
example: 15,
})
totalResults: number;
@ApiProperty({
description: 'CSV files that were searched',
type: [String],
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
})
searchedFiles: string[];
@ApiProperty({
description: 'Timestamp when search was executed',
example: '2025-10-23T10:30:00Z',
})
searchedAt: Date;
@ApiProperty({
description: 'Filters that were applied to the search',
type: RateSearchFiltersDto,
})
appliedFilters: RateSearchFiltersDto;
}
/**
* Single CSV Rate Result DTO
*/
export class CsvRateResultDto {
@ApiProperty({
description: 'Company name',
example: 'SSC Consolidation',
})
companyName: string;
@ApiProperty({
description: 'Origin port code',
example: 'NLRTM',
})
origin: string;
@ApiProperty({
description: 'Destination port code',
example: 'USNYC',
})
destination: string;
@ApiProperty({
description: 'Container type',
example: 'LCL',
})
containerType: string;
@ApiProperty({
description: 'Calculated price in USD',
example: 1850.50,
})
priceUSD: number;
@ApiProperty({
description: 'Calculated price in EUR',
example: 1665.45,
})
priceEUR: number;
@ApiProperty({
description: 'Primary currency of the rate',
enum: ['USD', 'EUR'],
example: 'USD',
})
primaryCurrency: string;
@ApiProperty({
description: 'Whether this rate has separate surcharges',
example: true,
})
hasSurcharges: boolean;
@ApiProperty({
description: 'Details of surcharges if any',
example: 'BAF+CAF included',
nullable: true,
})
surchargeDetails: string | null;
@ApiProperty({
description: 'Transit time in days',
example: 28,
})
transitDays: number;
@ApiProperty({
description: 'Rate validity end date',
example: '2025-12-31',
})
validUntil: string;
@ApiProperty({
description: 'Source of the rate',
enum: ['CSV', 'API'],
example: 'CSV',
})
source: 'CSV' | 'API';
@ApiProperty({
description: 'Match score (0-100) indicating how well this rate matches the search',
minimum: 0,
maximum: 100,
example: 95,
})
matchScore: number;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto';
/**
* CSV Rate Search Request DTO
*
* Request body for searching rates in CSV-based system
* Includes basic search parameters + optional advanced filters
*/
export class CsvRateSearchDto {
@ApiProperty({
description: 'Origin port code (UN/LOCODE format)',
example: 'NLRTM',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty()
@IsString()
origin: string;
@ApiProperty({
description: 'Destination port code (UN/LOCODE format)',
example: 'USNYC',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty()
@IsString()
destination: string;
@ApiProperty({
description: 'Volume in cubic meters (CBM)',
minimum: 0.01,
example: 25.5,
})
@IsNotEmpty()
@IsNumber()
@Min(0.01)
volumeCBM: number;
@ApiProperty({
description: 'Weight in kilograms',
minimum: 1,
example: 3500,
})
@IsNotEmpty()
@IsNumber()
@Min(1)
weightKG: number;
@ApiPropertyOptional({
description: 'Number of pallets (0 if no pallets)',
minimum: 0,
example: 10,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
example: 'LCL',
})
@IsOptional()
@IsString()
containerType?: string;
@ApiPropertyOptional({
description: 'Advanced filters for narrowing results',
type: RateSearchFiltersDto,
})
@IsOptional()
@ValidateNested()
@Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto;
}
/**
* CSV Rate Search Response DTO
*
* Response containing matching rates with calculated prices
*/
export class CsvRateSearchResponseDto {
@ApiProperty({
description: 'Array of matching rate results',
type: [Object], // Will be replaced with RateResultDto
})
results: CsvRateResultDto[];
@ApiProperty({
description: 'Total number of results found',
example: 15,
})
totalResults: number;
@ApiProperty({
description: 'CSV files that were searched',
type: [String],
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
})
searchedFiles: string[];
@ApiProperty({
description: 'Timestamp when search was executed',
example: '2025-10-23T10:30:00Z',
})
searchedAt: Date;
@ApiProperty({
description: 'Filters that were applied to the search',
type: RateSearchFiltersDto,
})
appliedFilters: RateSearchFiltersDto;
}
/**
* Single CSV Rate Result DTO
*/
export class CsvRateResultDto {
@ApiProperty({
description: 'Company name',
example: 'SSC Consolidation',
})
companyName: string;
@ApiProperty({
description: 'Origin port code',
example: 'NLRTM',
})
origin: string;
@ApiProperty({
description: 'Destination port code',
example: 'USNYC',
})
destination: string;
@ApiProperty({
description: 'Container type',
example: 'LCL',
})
containerType: string;
@ApiProperty({
description: 'Calculated price in USD',
example: 1850.5,
})
priceUSD: number;
@ApiProperty({
description: 'Calculated price in EUR',
example: 1665.45,
})
priceEUR: number;
@ApiProperty({
description: 'Primary currency of the rate',
enum: ['USD', 'EUR'],
example: 'USD',
})
primaryCurrency: string;
@ApiProperty({
description: 'Whether this rate has separate surcharges',
example: true,
})
hasSurcharges: boolean;
@ApiProperty({
description: 'Details of surcharges if any',
example: 'BAF+CAF included',
nullable: true,
})
surchargeDetails: string | null;
@ApiProperty({
description: 'Transit time in days',
example: 28,
})
transitDays: number;
@ApiProperty({
description: 'Rate validity end date',
example: '2025-12-31',
})
validUntil: string;
@ApiProperty({
description: 'Source of the rate',
enum: ['CSV', 'API'],
example: 'CSV',
})
source: 'CSV' | 'API';
@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 { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* CSV Rate Upload DTO
*
* Request DTO for uploading CSV rate files (ADMIN only)
*/
export class CsvRateUploadDto {
@ApiProperty({
description: 'Name of the carrier company',
example: 'SSC Consolidation',
maxLength: 255,
})
@IsNotEmpty()
@IsString()
@MaxLength(255)
companyName: string;
@ApiProperty({
description: 'CSV file containing shipping rates',
type: 'string',
format: 'binary',
})
file: any; // Will be handled by multer
}
/**
* CSV Rate Upload Response DTO
*/
export class CsvRateUploadResponseDto {
@ApiProperty({
description: 'Upload success status',
example: true,
})
success: boolean;
@ApiProperty({
description: 'Number of rate rows parsed from CSV',
example: 25,
})
ratesCount: number;
@ApiProperty({
description: 'Path where CSV file was saved',
example: 'ssc-consolidation.csv',
})
csvFilePath: string;
@ApiProperty({
description: 'Company name for which rates were uploaded',
example: 'SSC Consolidation',
})
companyName: string;
@ApiProperty({
description: 'Upload timestamp',
example: '2025-10-23T10:30:00Z',
})
uploadedAt: Date;
}
/**
* CSV Rate Config Response DTO
*
* Configuration entry for a company's CSV rates
*/
export class CsvRateConfigDto {
@ApiProperty({
description: 'Configuration ID',
example: '123e4567-e89b-12d3-a456-426614174000',
})
id: string;
@ApiProperty({
description: 'Company name',
example: 'SSC Consolidation',
})
companyName: string;
@ApiProperty({
description: 'CSV file path',
example: 'ssc-consolidation.csv',
})
csvFilePath: string;
@ApiProperty({
description: 'Integration type',
enum: ['CSV_ONLY', 'CSV_AND_API'],
example: 'CSV_ONLY',
})
type: 'CSV_ONLY' | 'CSV_AND_API';
@ApiProperty({
description: 'Whether company has API connector',
example: false,
})
hasApi: boolean;
@ApiProperty({
description: 'API connector name if hasApi is true',
example: null,
nullable: true,
})
apiConnector: string | null;
@ApiProperty({
description: 'Whether configuration is active',
example: true,
})
isActive: boolean;
@ApiProperty({
description: 'When CSV was last uploaded',
example: '2025-10-23T10:30:00Z',
})
uploadedAt: Date;
@ApiProperty({
description: 'Number of rate rows in CSV',
example: 25,
nullable: true,
})
rowCount: number | null;
@ApiProperty({
description: 'Additional metadata',
example: { description: 'LCL rates for Europe to US', coverage: 'Global' },
nullable: true,
})
metadata: Record<string, any> | null;
}
/**
* CSV File Validation Result DTO
*/
export class CsvFileValidationDto {
@ApiProperty({
description: 'Whether CSV file is valid',
example: true,
})
valid: boolean;
@ApiProperty({
description: 'Validation errors if any',
type: [String],
example: [],
})
errors: string[];
@ApiProperty({
description: 'Number of rows in CSV file',
example: 25,
required: false,
})
rowCount?: number;
}
/**
* Available Companies Response DTO
*/
export class AvailableCompaniesDto {
@ApiProperty({
description: 'List of available company names',
type: [String],
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
})
companies: string[];
@ApiProperty({
description: 'Total number of companies',
example: 4,
})
total: number;
}
/**
* Filter Options Response DTO
*/
export class FilterOptionsDto {
@ApiProperty({
description: 'Available company names',
type: [String],
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
})
companies: string[];
@ApiProperty({
description: 'Available container types',
type: [String],
example: ['LCL', '20DRY', '40HC', '40DRY'],
})
containerTypes: string[];
@ApiProperty({
description: 'Supported currencies',
type: [String],
example: ['USD', 'EUR'],
})
currencies: string[];
}
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* CSV Rate Upload DTO
*
* Request DTO for uploading CSV rate files (ADMIN only)
*/
export class CsvRateUploadDto {
@ApiProperty({
description: 'Name of the carrier company',
example: 'SSC Consolidation',
maxLength: 255,
})
@IsNotEmpty()
@IsString()
@MaxLength(255)
companyName: string;
@ApiProperty({
description: 'CSV file containing shipping rates',
type: 'string',
format: 'binary',
})
file: any; // Will be handled by multer
}
/**
* CSV Rate Upload Response DTO
*/
export class CsvRateUploadResponseDto {
@ApiProperty({
description: 'Upload success status',
example: true,
})
success: boolean;
@ApiProperty({
description: 'Number of rate rows parsed from CSV',
example: 25,
})
ratesCount: number;
@ApiProperty({
description: 'Path where CSV file was saved',
example: 'ssc-consolidation.csv',
})
csvFilePath: string;
@ApiProperty({
description: 'Company name for which rates were uploaded',
example: 'SSC Consolidation',
})
companyName: string;
@ApiProperty({
description: 'Upload timestamp',
example: '2025-10-23T10:30:00Z',
})
uploadedAt: Date;
}
/**
* CSV Rate Config Response DTO
*
* Configuration entry for a company's CSV rates
*/
export class CsvRateConfigDto {
@ApiProperty({
description: 'Configuration ID',
example: '123e4567-e89b-12d3-a456-426614174000',
})
id: string;
@ApiProperty({
description: 'Company name',
example: 'SSC Consolidation',
})
companyName: string;
@ApiProperty({
description: 'CSV file path',
example: 'ssc-consolidation.csv',
})
csvFilePath: string;
@ApiProperty({
description: 'Integration type',
enum: ['CSV_ONLY', 'CSV_AND_API'],
example: 'CSV_ONLY',
})
type: 'CSV_ONLY' | 'CSV_AND_API';
@ApiProperty({
description: 'Whether company has API connector',
example: false,
})
hasApi: boolean;
@ApiProperty({
description: 'API connector name if hasApi is true',
example: null,
nullable: true,
})
apiConnector: string | null;
@ApiProperty({
description: 'Whether configuration is active',
example: true,
})
isActive: boolean;
@ApiProperty({
description: 'When CSV was last uploaded',
example: '2025-10-23T10:30:00Z',
})
uploadedAt: Date;
@ApiProperty({
description: 'Number of rate rows in CSV',
example: 25,
nullable: true,
})
rowCount: number | null;
@ApiProperty({
description: 'Additional metadata',
example: { description: 'LCL rates for Europe to US', coverage: 'Global' },
nullable: true,
})
metadata: Record<string, any> | null;
}
/**
* CSV File Validation Result DTO
*/
export class CsvFileValidationDto {
@ApiProperty({
description: 'Whether CSV file is valid',
example: true,
})
valid: boolean;
@ApiProperty({
description: 'Validation errors if any',
type: [String],
example: [],
})
errors: string[];
@ApiProperty({
description: 'Number of rows in CSV file',
example: 25,
required: false,
})
rowCount?: number;
}
/**
* Available Companies Response DTO
*/
export class AvailableCompaniesDto {
@ApiProperty({
description: 'List of available company names',
type: [String],
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
})
companies: string[];
@ApiProperty({
description: 'Total number of companies',
example: 4,
})
total: number;
}
/**
* Filter Options Response DTO
*/
export class FilterOptionsDto {
@ApiProperty({
description: 'Available company names',
type: [String],
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
})
companies: string[];
@ApiProperty({
description: 'Available container types',
type: [String],
example: ['LCL', '20DRY', '40HC', '40DRY'],
})
containerTypes: string[];
@ApiProperty({
description: 'Supported currencies',
type: [String],
example: ['USD', 'EUR'],
})
currencies: string[];
}

View File

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

View File

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

View File

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

View File

@ -1,97 +1,110 @@
import { IsString, IsDateString, IsEnum, IsOptional, IsInt, Min, IsBoolean, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RateSearchRequestDto {
@ApiProperty({
description: 'Origin port code (UN/LOCODE)',
example: 'NLRTM',
pattern: '^[A-Z]{5}$',
})
@IsString()
@Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' })
origin: string;
@ApiProperty({
description: 'Destination port code (UN/LOCODE)',
example: 'CNSHA',
pattern: '^[A-Z]{5}$',
})
@IsString()
@Matches(/^[A-Z]{5}$/, { message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)' })
destination: string;
@ApiProperty({
description: 'Container type',
example: '40HC',
enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'],
})
@IsString()
@IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], {
message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC',
})
containerType: string;
@ApiProperty({
description: 'Shipping mode',
example: 'FCL',
enum: ['FCL', 'LCL'],
})
@IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' })
mode: 'FCL' | 'LCL';
@ApiProperty({
description: 'Desired departure date (ISO 8601 format)',
example: '2025-02-15',
})
@IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' })
departureDate: string;
@ApiPropertyOptional({
description: 'Number of containers',
example: 2,
minimum: 1,
default: 1,
})
@IsOptional()
@IsInt()
@Min(1, { message: 'Quantity must be at least 1' })
quantity?: number;
@ApiPropertyOptional({
description: 'Total cargo weight in kg',
example: 20000,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0, { message: 'Weight must be non-negative' })
weight?: number;
@ApiPropertyOptional({
description: 'Total cargo volume in cubic meters',
example: 50.5,
minimum: 0,
})
@IsOptional()
@Min(0, { message: 'Volume must be non-negative' })
volume?: number;
@ApiPropertyOptional({
description: 'Whether cargo is hazardous material',
example: false,
default: false,
})
@IsOptional()
@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;
}
import {
IsString,
IsDateString,
IsEnum,
IsOptional,
IsInt,
Min,
IsBoolean,
Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RateSearchRequestDto {
@ApiProperty({
description: 'Origin port code (UN/LOCODE)',
example: 'NLRTM',
pattern: '^[A-Z]{5}$',
})
@IsString()
@Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' })
origin: string;
@ApiProperty({
description: 'Destination port code (UN/LOCODE)',
example: 'CNSHA',
pattern: '^[A-Z]{5}$',
})
@IsString()
@Matches(/^[A-Z]{5}$/, {
message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)',
})
destination: string;
@ApiProperty({
description: 'Container type',
example: '40HC',
enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'],
})
@IsString()
@IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], {
message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC',
})
containerType: string;
@ApiProperty({
description: 'Shipping mode',
example: 'FCL',
enum: ['FCL', 'LCL'],
})
@IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' })
mode: 'FCL' | 'LCL';
@ApiProperty({
description: 'Desired departure date (ISO 8601 format)',
example: '2025-02-15',
})
@IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' })
departureDate: string;
@ApiPropertyOptional({
description: 'Number of containers',
example: 2,
minimum: 1,
default: 1,
})
@IsOptional()
@IsInt()
@Min(1, { message: 'Quantity must be at least 1' })
quantity?: number;
@ApiPropertyOptional({
description: 'Total cargo weight in kg',
example: 20000,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0, { message: 'Weight must be non-negative' })
weight?: number;
@ApiPropertyOptional({
description: 'Total cargo volume in cubic meters',
example: 50.5,
minimum: 0,
})
@IsOptional()
@Min(0, { message: 'Volume must be non-negative' })
volume?: number;
@ApiPropertyOptional({
description: 'Whether cargo is hazardous material',
example: false,
default: false,
})
@IsOptional()
@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';
export class PortDto {
@ApiProperty({ example: 'NLRTM' })
code: string;
@ApiProperty({ example: 'Rotterdam' })
name: string;
@ApiProperty({ example: 'Netherlands' })
country: string;
}
export class SurchargeDto {
@ApiProperty({ example: 'BAF', description: 'Surcharge type code' })
type: string;
@ApiProperty({ example: 'Bunker Adjustment Factor' })
description: string;
@ApiProperty({ example: 150.0 })
amount: number;
@ApiProperty({ example: 'USD' })
currency: string;
}
export class PricingDto {
@ApiProperty({ example: 1500.0, description: 'Base ocean freight' })
baseFreight: number;
@ApiProperty({ type: [SurchargeDto] })
surcharges: SurchargeDto[];
@ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' })
totalAmount: number;
@ApiProperty({ example: 'USD' })
currency: string;
}
export class RouteSegmentDto {
@ApiProperty({ example: 'NLRTM' })
portCode: string;
@ApiProperty({ example: 'Port of Rotterdam' })
portName: string;
@ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' })
arrival?: string;
@ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' })
departure?: string;
@ApiPropertyOptional({ example: 'MAERSK ESSEX' })
vesselName?: string;
@ApiPropertyOptional({ example: '025W' })
voyageNumber?: string;
}
export class RateQuoteDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string;
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' })
carrierId: string;
@ApiProperty({ example: 'Maersk Line' })
carrierName: string;
@ApiProperty({ example: 'MAERSK' })
carrierCode: string;
@ApiProperty({ type: PortDto })
origin: PortDto;
@ApiProperty({ type: PortDto })
destination: PortDto;
@ApiProperty({ type: PricingDto })
pricing: PricingDto;
@ApiProperty({ example: '40HC' })
containerType: string;
@ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] })
mode: 'FCL' | 'LCL';
@ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' })
etd: string;
@ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' })
eta: string;
@ApiProperty({ example: 30, description: 'Transit time in days' })
transitDays: number;
@ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' })
route: RouteSegmentDto[];
@ApiProperty({ example: 85, description: 'Available container slots' })
availability: number;
@ApiProperty({ example: 'Weekly' })
frequency: string;
@ApiPropertyOptional({ example: 'Container Ship' })
vesselType?: string;
@ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' })
co2EmissionsKg?: number;
@ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' })
validUntil: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
createdAt: string;
}
export class RateSearchResponseDto {
@ApiProperty({ type: [RateQuoteDto] })
quotes: RateQuoteDto[];
@ApiProperty({ example: 5, description: 'Total number of quotes returned' })
count: number;
@ApiProperty({ example: 'NLRTM' })
origin: string;
@ApiProperty({ example: 'CNSHA' })
destination: string;
@ApiProperty({ example: '2025-02-15' })
departureDate: string;
@ApiProperty({ example: '40HC' })
containerType: string;
@ApiProperty({ example: 'FCL' })
mode: string;
@ApiProperty({ example: true, description: 'Whether results were served from cache' })
fromCache: boolean;
@ApiProperty({ example: 234, description: 'Query response time in milliseconds' })
responseTimeMs: number;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PortDto {
@ApiProperty({ example: 'NLRTM' })
code: string;
@ApiProperty({ example: 'Rotterdam' })
name: string;
@ApiProperty({ example: 'Netherlands' })
country: string;
}
export class SurchargeDto {
@ApiProperty({ example: 'BAF', description: 'Surcharge type code' })
type: string;
@ApiProperty({ example: 'Bunker Adjustment Factor' })
description: string;
@ApiProperty({ example: 150.0 })
amount: number;
@ApiProperty({ example: 'USD' })
currency: string;
}
export class PricingDto {
@ApiProperty({ example: 1500.0, description: 'Base ocean freight' })
baseFreight: number;
@ApiProperty({ type: [SurchargeDto] })
surcharges: SurchargeDto[];
@ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' })
totalAmount: number;
@ApiProperty({ example: 'USD' })
currency: string;
}
export class RouteSegmentDto {
@ApiProperty({ example: 'NLRTM' })
portCode: string;
@ApiProperty({ example: 'Port of Rotterdam' })
portName: string;
@ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' })
arrival?: string;
@ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' })
departure?: string;
@ApiPropertyOptional({ example: 'MAERSK ESSEX' })
vesselName?: string;
@ApiPropertyOptional({ example: '025W' })
voyageNumber?: string;
}
export class RateQuoteDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string;
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' })
carrierId: string;
@ApiProperty({ example: 'Maersk Line' })
carrierName: string;
@ApiProperty({ example: 'MAERSK' })
carrierCode: string;
@ApiProperty({ type: PortDto })
origin: PortDto;
@ApiProperty({ type: PortDto })
destination: PortDto;
@ApiProperty({ type: PricingDto })
pricing: PricingDto;
@ApiProperty({ example: '40HC' })
containerType: string;
@ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] })
mode: 'FCL' | 'LCL';
@ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' })
etd: string;
@ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' })
eta: string;
@ApiProperty({ example: 30, description: 'Transit time in days' })
transitDays: number;
@ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' })
route: RouteSegmentDto[];
@ApiProperty({ example: 85, description: 'Available container slots' })
availability: number;
@ApiProperty({ example: 'Weekly' })
frequency: string;
@ApiPropertyOptional({ example: 'Container Ship' })
vesselType?: string;
@ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' })
co2EmissionsKg?: number;
@ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' })
validUntil: string;
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
createdAt: string;
}
export class RateSearchResponseDto {
@ApiProperty({ type: [RateQuoteDto] })
quotes: RateQuoteDto[];
@ApiProperty({ example: 5, description: 'Total number of quotes returned' })
count: number;
@ApiProperty({ example: 'NLRTM' })
origin: string;
@ApiProperty({ example: 'CNSHA' })
destination: string;
@ApiProperty({ example: '2025-02-15' })
departureDate: string;
@ApiProperty({ example: '40HC' })
containerType: string;
@ApiProperty({ example: 'FCL' })
mode: string;
@ApiProperty({ example: true, description: 'Whether results were served from cache' })
fromCache: boolean;
@ApiProperty({ example: 234, description: 'Query response time in milliseconds' })
responseTimeMs: number;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,10 @@
import { Test, TestingModule } from '@nestjs/testing';
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';
describe('AuditService', () => {

View File

@ -7,11 +7,7 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import {
AuditLog,
AuditAction,
AuditStatus,
} from '../../domain/entities/audit-log.entity';
import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
import {
AuditLogRepository,
AUDIT_LOG_REPOSITORY,
@ -39,7 +35,7 @@ export class AuditService {
constructor(
@Inject(AUDIT_LOG_REPOSITORY)
private readonly auditLogRepository: AuditLogRepository,
private readonly auditLogRepository: AuditLogRepository
) {}
/**
@ -54,14 +50,12 @@ export class AuditService {
await this.auditLogRepository.save(auditLog);
this.logger.log(
`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`,
);
this.logger.log(`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`);
} catch (error: any) {
// Never throw on audit logging failure - log the error and continue
this.logger.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>;
ipAddress?: string;
userAgent?: string;
},
}
): Promise<void> {
await this.log({
action,
@ -108,7 +102,7 @@ export class AuditService {
metadata?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
},
}
): Promise<void> {
await this.log({
action,
@ -139,20 +133,14 @@ export class AuditService {
/**
* Get audit trail for a specific resource
*/
async getResourceAuditTrail(
resourceType: string,
resourceId: string,
): Promise<AuditLog[]> {
async getResourceAuditTrail(resourceType: string, resourceId: string): Promise<AuditLog[]> {
return this.auditLogRepository.findByResource(resourceType, resourceId);
}
/**
* Get recent activity for an organization
*/
async getOrganizationActivity(
organizationId: string,
limit: number = 50,
): Promise<AuditLog[]> {
async getOrganizationActivity(organizationId: string, limit: number = 50): Promise<AuditLog[]> {
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 { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
import {
StoragePort,
STORAGE_PORT,
} from '../../domain/ports/out/storage.port';
import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port';
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()
export class BookingAutomationService {
@ -24,16 +24,14 @@ export class BookingAutomationService {
@Inject(PDF_PORT) private readonly pdfPort: PdfPort,
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
@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
*/
async executePostBookingTasks(booking: Booking): Promise<void> {
this.logger.log(
`Starting post-booking automation for booking: ${booking.bookingNumber.value}`
);
this.logger.log(`Starting post-booking automation for booking: ${booking.bookingNumber.value}`);
try {
// Get user and rate quote details
@ -42,9 +40,7 @@ export class BookingAutomationService {
throw new Error(`User not found: ${booking.userId}`);
}
const rateQuote = await this.rateQuoteRepository.findById(
booking.rateQuoteId
);
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
if (!rateQuote) {
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
}
@ -79,7 +75,7 @@ export class BookingAutomationService {
email: booking.consignee.contactEmail,
phone: booking.consignee.contactPhone,
},
containers: booking.containers.map((c) => ({
containers: booking.containers.map(c => ({
type: c.type,
quantity: 1,
containerNumber: c.containerNumber,
@ -173,10 +169,7 @@ export class BookingAutomationService {
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
);
} catch (error) {
this.logger.error(
`Failed to send booking update notification`,
error
);
this.logger.error(`Failed to send booking update notification`, error);
}
}
}

View File

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

View File

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

View File

@ -32,14 +32,14 @@ export class FileValidationService {
// Validate file size
if (file.size > fileUploadConfig.maxFileSize) {
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
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
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();
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
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();
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
// For now, just log
this.logger.log(
`Virus scan requested for file: ${file.originalname} (not implemented)`,
);
this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`);
return true;
}
@ -190,9 +188,7 @@ export class FileValidationService {
/**
* Validate multiple files
*/
async validateFiles(
files: Express.Multer.File[],
): Promise<FileValidationResult> {
async validateFiles(files: Express.Multer.File[]): Promise<FileValidationResult> {
const allErrors: string[] = [];
for (const file of files) {

View File

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

View File

@ -31,7 +31,7 @@ export class GDPRService {
constructor(
@InjectRepository(UserOrmEntity)
private readonly userRepository: Repository<UserOrmEntity>,
private readonly userRepository: Repository<UserOrmEntity>
) {}
/**
@ -63,7 +63,8 @@ export class GDPRService {
exportDate: new Date().toISOString(),
userId,
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}`);
@ -76,7 +77,9 @@ export class GDPRService {
* Note: This is a simplified version. In production, implement full anonymization logic.
*/
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
const user = await this.userRepository.findOne({ where: { id: userId } });
@ -117,7 +120,9 @@ export class GDPRService {
// In production, store in separate consent table
// 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 { NotificationService } from './notification.service';
import { NOTIFICATION_REPOSITORY, NotificationRepository } from '../../domain/ports/out/notification.repository';
import { Notification, NotificationType, NotificationPriority } from '../../domain/entities/notification.entity';
import {
NOTIFICATION_REPOSITORY,
NotificationRepository,
} from '../../domain/ports/out/notification.repository';
import {
Notification,
NotificationType,
NotificationPriority,
} from '../../domain/entities/notification.entity';
describe('NotificationService', () => {
let service: NotificationService;

View File

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

View File

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

View File

@ -9,11 +9,7 @@ import { HttpService } from '@nestjs/axios';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import { firstValueFrom } from 'rxjs';
import {
Webhook,
WebhookEvent,
WebhookStatus,
} from '../../domain/entities/webhook.entity';
import { Webhook, WebhookEvent, WebhookStatus } from '../../domain/entities/webhook.entity';
import {
WebhookRepository,
WEBHOOK_REPOSITORY,
@ -51,7 +47,7 @@ export class WebhookService {
constructor(
@Inject(WEBHOOK_REPOSITORY)
private readonly webhookRepository: WebhookRepository,
private readonly httpService: HttpService,
private readonly httpService: HttpService
) {}
/**
@ -72,9 +68,7 @@ export class WebhookService {
await this.webhookRepository.save(webhook);
this.logger.log(
`Webhook created: ${webhook.id} for organization ${input.organizationId}`,
);
this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`);
return webhook;
}
@ -158,11 +152,7 @@ export class WebhookService {
/**
* Trigger webhooks for an event
*/
async triggerWebhooks(
event: WebhookEvent,
organizationId: string,
data: any,
): Promise<void> {
async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise<void> {
try {
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
@ -179,17 +169,13 @@ export class WebhookService {
};
// Trigger all webhooks in parallel
await Promise.allSettled(
webhooks.map((webhook) => this.triggerWebhook(webhook, payload)),
);
await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload)));
this.logger.log(
`Triggered ${webhooks.length} webhooks for event: ${event}`,
);
this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`);
} catch (error: any) {
this.logger.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
*/
private async triggerWebhook(
webhook: Webhook,
payload: WebhookPayload,
): Promise<void> {
private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
@ -226,7 +209,7 @@ export class WebhookService {
this.httpService.post(webhook.url, payload, {
headers,
timeout: 10000, // 10 seconds
}),
})
);
if (response && response.status >= 200 && response.status < 300) {
@ -234,17 +217,17 @@ export class WebhookService {
const updatedWebhook = webhook.recordTrigger();
await this.webhookRepository.save(updatedWebhook);
this.logger.log(
`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`,
);
this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`);
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) {
lastError = error;
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);
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 {
const expectedSignature = this.generateSignature(payload, secret);
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
}
/**
* Delay helper for retries
*/
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
*
* Represents a freight booking
*
* Business Rules:
* - Must have valid rate quote
* - Shipper and consignee are required
* - Status transitions must follow allowed paths
* - Containers can be added/updated until confirmed
* - Cannot modify confirmed bookings (except status)
*/
import { BookingNumber } from '../value-objects/booking-number.vo';
import { BookingStatus } from '../value-objects/booking-status.vo';
export interface Address {
street: string;
city: string;
postalCode: string;
country: string;
}
export interface Party {
name: string;
address: Address;
contactName: string;
contactEmail: string;
contactPhone: string;
}
export interface BookingContainer {
id: string;
type: string;
containerNumber?: string;
vgm?: number; // Verified Gross Mass in kg
temperature?: number; // For reefer containers
sealNumber?: string;
}
export interface BookingProps {
id: string;
bookingNumber: BookingNumber;
userId: string;
organizationId: string;
rateQuoteId: string;
status: BookingStatus;
shipper: Party;
consignee: Party;
cargoDescription: string;
containers: BookingContainer[];
specialInstructions?: string;
createdAt: Date;
updatedAt: Date;
}
export class Booking {
private readonly props: BookingProps;
private constructor(props: BookingProps) {
this.props = props;
}
/**
* Factory method to create a new Booking
*/
static create(
props: Omit<BookingProps, 'bookingNumber' | 'status' | 'createdAt' | 'updatedAt'> & {
id: string;
bookingNumber?: BookingNumber;
status?: BookingStatus;
}
): Booking {
const now = new Date();
const bookingProps: BookingProps = {
...props,
bookingNumber: props.bookingNumber || BookingNumber.generate(),
status: props.status || BookingStatus.create('draft'),
createdAt: now,
updatedAt: now,
};
// Validate business rules
Booking.validate(bookingProps);
return new Booking(bookingProps);
}
/**
* Validate business rules
*/
private static validate(props: BookingProps): void {
if (!props.userId) {
throw new Error('User ID is required');
}
if (!props.organizationId) {
throw new Error('Organization ID is required');
}
if (!props.rateQuoteId) {
throw new Error('Rate quote ID is required');
}
if (!props.shipper || !props.shipper.name) {
throw new Error('Shipper information is required');
}
if (!props.consignee || !props.consignee.name) {
throw new Error('Consignee information is required');
}
if (!props.cargoDescription || props.cargoDescription.length < 10) {
throw new Error('Cargo description must be at least 10 characters');
}
}
// Getters
get id(): string {
return this.props.id;
}
get bookingNumber(): BookingNumber {
return this.props.bookingNumber;
}
get userId(): string {
return this.props.userId;
}
get organizationId(): string {
return this.props.organizationId;
}
get rateQuoteId(): string {
return this.props.rateQuoteId;
}
get status(): BookingStatus {
return this.props.status;
}
get shipper(): Party {
return { ...this.props.shipper };
}
get consignee(): Party {
return { ...this.props.consignee };
}
get cargoDescription(): string {
return this.props.cargoDescription;
}
get containers(): BookingContainer[] {
return [...this.props.containers];
}
get specialInstructions(): string | undefined {
return this.props.specialInstructions;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
/**
* Update booking status
*/
updateStatus(newStatus: BookingStatus): Booking {
if (!this.status.canTransitionTo(newStatus)) {
throw new Error(
`Cannot transition from ${this.status.value} to ${newStatus.value}`
);
}
return new Booking({
...this.props,
status: newStatus,
updatedAt: new Date(),
});
}
/**
* Add container to booking
*/
addContainer(container: BookingContainer): Booking {
if (!this.status.canBeModified()) {
throw new Error('Cannot modify containers after booking is confirmed');
}
return new Booking({
...this.props,
containers: [...this.props.containers, container],
updatedAt: new Date(),
});
}
/**
* Update container information
*/
updateContainer(containerId: string, updates: Partial<BookingContainer>): Booking {
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) {
throw new Error(`Container ${containerId} not found`);
}
const updatedContainers = [...this.props.containers];
updatedContainers[containerIndex] = {
...updatedContainers[containerIndex],
...updates,
};
return new Booking({
...this.props,
containers: updatedContainers,
updatedAt: new Date(),
});
}
/**
* Remove container from booking
*/
removeContainer(containerId: string): Booking {
if (!this.status.canBeModified()) {
throw new Error('Cannot modify containers after booking is confirmed');
}
return new Booking({
...this.props,
containers: this.props.containers.filter((c) => c.id !== containerId),
updatedAt: new Date(),
});
}
/**
* Update cargo description
*/
updateCargoDescription(description: string): Booking {
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');
}
return new Booking({
...this.props,
cargoDescription: description,
updatedAt: new Date(),
});
}
/**
* Update special instructions
*/
updateSpecialInstructions(instructions: string): Booking {
return new Booking({
...this.props,
specialInstructions: instructions,
updatedAt: new Date(),
});
}
/**
* Check if booking can be cancelled
*/
canBeCancelled(): boolean {
return !this.status.isFinal();
}
/**
* Cancel booking
*/
cancel(): Booking {
if (!this.canBeCancelled()) {
throw new Error('Cannot cancel booking in final state');
}
return this.updateStatus(BookingStatus.create('cancelled'));
}
/**
* Equality check
*/
equals(other: Booking): boolean {
return this.id === other.id;
}
}
/**
* Booking Entity
*
* Represents a freight booking
*
* Business Rules:
* - Must have valid rate quote
* - Shipper and consignee are required
* - Status transitions must follow allowed paths
* - Containers can be added/updated until confirmed
* - Cannot modify confirmed bookings (except status)
*/
import { BookingNumber } from '../value-objects/booking-number.vo';
import { BookingStatus } from '../value-objects/booking-status.vo';
export interface Address {
street: string;
city: string;
postalCode: string;
country: string;
}
export interface Party {
name: string;
address: Address;
contactName: string;
contactEmail: string;
contactPhone: string;
}
export interface BookingContainer {
id: string;
type: string;
containerNumber?: string;
vgm?: number; // Verified Gross Mass in kg
temperature?: number; // For reefer containers
sealNumber?: string;
}
export interface BookingProps {
id: string;
bookingNumber: BookingNumber;
userId: string;
organizationId: string;
rateQuoteId: string;
status: BookingStatus;
shipper: Party;
consignee: Party;
cargoDescription: string;
containers: BookingContainer[];
specialInstructions?: string;
createdAt: Date;
updatedAt: Date;
}
export class Booking {
private readonly props: BookingProps;
private constructor(props: BookingProps) {
this.props = props;
}
/**
* Factory method to create a new Booking
*/
static create(
props: Omit<BookingProps, 'bookingNumber' | 'status' | 'createdAt' | 'updatedAt'> & {
id: string;
bookingNumber?: BookingNumber;
status?: BookingStatus;
}
): Booking {
const now = new Date();
const bookingProps: BookingProps = {
...props,
bookingNumber: props.bookingNumber || BookingNumber.generate(),
status: props.status || BookingStatus.create('draft'),
createdAt: now,
updatedAt: now,
};
// Validate business rules
Booking.validate(bookingProps);
return new Booking(bookingProps);
}
/**
* Validate business rules
*/
private static validate(props: BookingProps): void {
if (!props.userId) {
throw new Error('User ID is required');
}
if (!props.organizationId) {
throw new Error('Organization ID is required');
}
if (!props.rateQuoteId) {
throw new Error('Rate quote ID is required');
}
if (!props.shipper || !props.shipper.name) {
throw new Error('Shipper information is required');
}
if (!props.consignee || !props.consignee.name) {
throw new Error('Consignee information is required');
}
if (!props.cargoDescription || props.cargoDescription.length < 10) {
throw new Error('Cargo description must be at least 10 characters');
}
}
// Getters
get id(): string {
return this.props.id;
}
get bookingNumber(): BookingNumber {
return this.props.bookingNumber;
}
get userId(): string {
return this.props.userId;
}
get organizationId(): string {
return this.props.organizationId;
}
get rateQuoteId(): string {
return this.props.rateQuoteId;
}
get status(): BookingStatus {
return this.props.status;
}
get shipper(): Party {
return { ...this.props.shipper };
}
get consignee(): Party {
return { ...this.props.consignee };
}
get cargoDescription(): string {
return this.props.cargoDescription;
}
get containers(): BookingContainer[] {
return [...this.props.containers];
}
get specialInstructions(): string | undefined {
return this.props.specialInstructions;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
/**
* Update booking status
*/
updateStatus(newStatus: BookingStatus): Booking {
if (!this.status.canTransitionTo(newStatus)) {
throw new Error(`Cannot transition from ${this.status.value} to ${newStatus.value}`);
}
return new Booking({
...this.props,
status: newStatus,
updatedAt: new Date(),
});
}
/**
* Add container to booking
*/
addContainer(container: BookingContainer): Booking {
if (!this.status.canBeModified()) {
throw new Error('Cannot modify containers after booking is confirmed');
}
return new Booking({
...this.props,
containers: [...this.props.containers, container],
updatedAt: new Date(),
});
}
/**
* Update container information
*/
updateContainer(containerId: string, updates: Partial<BookingContainer>): Booking {
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) {
throw new Error(`Container ${containerId} not found`);
}
const updatedContainers = [...this.props.containers];
updatedContainers[containerIndex] = {
...updatedContainers[containerIndex],
...updates,
};
return new Booking({
...this.props,
containers: updatedContainers,
updatedAt: new Date(),
});
}
/**
* Remove container from booking
*/
removeContainer(containerId: string): Booking {
if (!this.status.canBeModified()) {
throw new Error('Cannot modify containers after booking is confirmed');
}
return new Booking({
...this.props,
containers: this.props.containers.filter(c => c.id !== containerId),
updatedAt: new Date(),
});
}
/**
* Update cargo description
*/
updateCargoDescription(description: string): Booking {
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');
}
return new Booking({
...this.props,
cargoDescription: description,
updatedAt: new Date(),
});
}
/**
* Update special instructions
*/
updateSpecialInstructions(instructions: string): Booking {
return new Booking({
...this.props,
specialInstructions: instructions,
updatedAt: new Date(),
});
}
/**
* Check if booking can be cancelled
*/
canBeCancelled(): boolean {
return !this.status.isFinal();
}
/**
* Cancel booking
*/
cancel(): Booking {
if (!this.canBeCancelled()) {
throw new Error('Cannot cancel booking in final state');
}
return this.updateStatus(BookingStatus.create('cancelled'));
}
/**
* Equality check
*/
equals(other: Booking): boolean {
return this.id === other.id;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,7 +41,10 @@ export class Webhook {
private constructor(private readonly props: WebhookProps) {}
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 {
return new Webhook({
...props,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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