format prettier
This commit is contained in:
parent
07b08e3014
commit
d809feecef
@ -1,123 +1,121 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import * as Joi from 'joi';
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
// Import feature modules
|
// Import feature modules
|
||||||
import { AuthModule } from './application/auth/auth.module';
|
import { AuthModule } from './application/auth/auth.module';
|
||||||
import { RatesModule } from './application/rates/rates.module';
|
import { RatesModule } from './application/rates/rates.module';
|
||||||
import { BookingsModule } from './application/bookings/bookings.module';
|
import { BookingsModule } from './application/bookings/bookings.module';
|
||||||
import { OrganizationsModule } from './application/organizations/organizations.module';
|
import { OrganizationsModule } from './application/organizations/organizations.module';
|
||||||
import { UsersModule } from './application/users/users.module';
|
import { UsersModule } from './application/users/users.module';
|
||||||
import { DashboardModule } from './application/dashboard/dashboard.module';
|
import { DashboardModule } from './application/dashboard/dashboard.module';
|
||||||
import { AuditModule } from './application/audit/audit.module';
|
import { AuditModule } from './application/audit/audit.module';
|
||||||
import { NotificationsModule } from './application/notifications/notifications.module';
|
import { NotificationsModule } from './application/notifications/notifications.module';
|
||||||
import { WebhooksModule } from './application/webhooks/webhooks.module';
|
import { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||||
import { SecurityModule } from './infrastructure/security/security.module';
|
import { SecurityModule } from './infrastructure/security/security.module';
|
||||||
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
|
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
|
||||||
|
|
||||||
// Import global guards
|
// Import global guards
|
||||||
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
|
||||||
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Configuration
|
// Configuration
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validationSchema: Joi.object({
|
validationSchema: Joi.object({
|
||||||
NODE_ENV: Joi.string()
|
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
|
||||||
.valid('development', 'production', 'test')
|
PORT: Joi.number().default(4000),
|
||||||
.default('development'),
|
DATABASE_HOST: Joi.string().required(),
|
||||||
PORT: Joi.number().default(4000),
|
DATABASE_PORT: Joi.number().default(5432),
|
||||||
DATABASE_HOST: Joi.string().required(),
|
DATABASE_USER: Joi.string().required(),
|
||||||
DATABASE_PORT: Joi.number().default(5432),
|
DATABASE_PASSWORD: Joi.string().required(),
|
||||||
DATABASE_USER: Joi.string().required(),
|
DATABASE_NAME: Joi.string().required(),
|
||||||
DATABASE_PASSWORD: Joi.string().required(),
|
REDIS_HOST: Joi.string().required(),
|
||||||
DATABASE_NAME: Joi.string().required(),
|
REDIS_PORT: Joi.number().default(6379),
|
||||||
REDIS_HOST: Joi.string().required(),
|
REDIS_PASSWORD: Joi.string().required(),
|
||||||
REDIS_PORT: Joi.number().default(6379),
|
JWT_SECRET: Joi.string().required(),
|
||||||
REDIS_PASSWORD: Joi.string().required(),
|
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
|
||||||
JWT_SECRET: Joi.string().required(),
|
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
|
||||||
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
|
}),
|
||||||
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
|
}),
|
||||||
}),
|
|
||||||
}),
|
// Logging
|
||||||
|
LoggerModule.forRootAsync({
|
||||||
// Logging
|
useFactory: (configService: ConfigService) => ({
|
||||||
LoggerModule.forRootAsync({
|
pinoHttp: {
|
||||||
useFactory: (configService: ConfigService) => ({
|
transport:
|
||||||
pinoHttp: {
|
configService.get('NODE_ENV') === 'development'
|
||||||
transport:
|
? {
|
||||||
configService.get('NODE_ENV') === 'development'
|
target: 'pino-pretty',
|
||||||
? {
|
options: {
|
||||||
target: 'pino-pretty',
|
colorize: true,
|
||||||
options: {
|
translateTime: 'SYS:standard',
|
||||||
colorize: true,
|
ignore: 'pid,hostname',
|
||||||
translateTime: 'SYS:standard',
|
},
|
||||||
ignore: 'pid,hostname',
|
}
|
||||||
},
|
: undefined,
|
||||||
}
|
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
|
||||||
: undefined,
|
},
|
||||||
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
|
}),
|
||||||
},
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
|
||||||
}),
|
// Database
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
// Database
|
useFactory: (configService: ConfigService) => ({
|
||||||
TypeOrmModule.forRootAsync({
|
type: 'postgres',
|
||||||
useFactory: (configService: ConfigService) => ({
|
host: configService.get('DATABASE_HOST'),
|
||||||
type: 'postgres',
|
port: configService.get('DATABASE_PORT'),
|
||||||
host: configService.get('DATABASE_HOST'),
|
username: configService.get('DATABASE_USER'),
|
||||||
port: configService.get('DATABASE_PORT'),
|
password: configService.get('DATABASE_PASSWORD'),
|
||||||
username: configService.get('DATABASE_USER'),
|
database: configService.get('DATABASE_NAME'),
|
||||||
password: configService.get('DATABASE_PASSWORD'),
|
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
|
||||||
database: configService.get('DATABASE_NAME'),
|
synchronize: configService.get('DATABASE_SYNC', false),
|
||||||
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
|
logging: configService.get('DATABASE_LOGGING', false),
|
||||||
synchronize: configService.get('DATABASE_SYNC', false),
|
autoLoadEntities: true, // Auto-load entities from forFeature()
|
||||||
logging: configService.get('DATABASE_LOGGING', false),
|
}),
|
||||||
autoLoadEntities: true, // Auto-load entities from forFeature()
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
|
||||||
}),
|
// Infrastructure modules
|
||||||
|
SecurityModule,
|
||||||
// Infrastructure modules
|
CacheModule,
|
||||||
SecurityModule,
|
CarrierModule,
|
||||||
CacheModule,
|
CsvRateModule,
|
||||||
CarrierModule,
|
|
||||||
CsvRateModule,
|
// Feature modules
|
||||||
|
AuthModule,
|
||||||
// Feature modules
|
RatesModule,
|
||||||
AuthModule,
|
BookingsModule,
|
||||||
RatesModule,
|
OrganizationsModule,
|
||||||
BookingsModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
DashboardModule,
|
||||||
UsersModule,
|
AuditModule,
|
||||||
DashboardModule,
|
NotificationsModule,
|
||||||
AuditModule,
|
WebhooksModule,
|
||||||
NotificationsModule,
|
GDPRModule,
|
||||||
WebhooksModule,
|
],
|
||||||
GDPRModule,
|
controllers: [],
|
||||||
],
|
providers: [
|
||||||
controllers: [],
|
// Global JWT authentication guard
|
||||||
providers: [
|
// All routes are protected by default, use @Public() to bypass
|
||||||
// Global JWT authentication guard
|
{
|
||||||
// All routes are protected by default, use @Public() to bypass
|
provide: APP_GUARD,
|
||||||
{
|
useClass: JwtAuthGuard,
|
||||||
provide: APP_GUARD,
|
},
|
||||||
useClass: JwtAuthGuard,
|
// Global rate limiting guard
|
||||||
},
|
{
|
||||||
// Global rate limiting guard
|
provide: APP_GUARD,
|
||||||
{
|
useClass: CustomThrottlerGuard,
|
||||||
provide: APP_GUARD,
|
},
|
||||||
useClass: CustomThrottlerGuard,
|
],
|
||||||
},
|
})
|
||||||
],
|
export class AppModule {}
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { Injectable, UnauthorizedException, ConflictException, Logger, Inject } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
Logger,
|
||||||
|
Inject,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
@ -22,7 +28,7 @@ export class AuthService {
|
|||||||
@Inject(USER_REPOSITORY)
|
@Inject(USER_REPOSITORY)
|
||||||
private readonly userRepository: UserRepository, // ✅ Correct injection
|
private readonly userRepository: UserRepository, // ✅ Correct injection
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +39,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
firstName: string,
|
firstName: string,
|
||||||
lastName: string,
|
lastName: string,
|
||||||
organizationId?: string,
|
organizationId?: string
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Registering new user: ${email}`);
|
this.logger.log(`Registering new user: ${email}`);
|
||||||
|
|
||||||
@ -87,7 +93,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Login attempt for: ${email}`);
|
this.logger.log(`Login attempt for: ${email}`);
|
||||||
|
|
||||||
@ -127,7 +133,9 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
*/
|
*/
|
||||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
async refreshAccessToken(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||||
secret: this.configService.get('JWT_SECRET'),
|
secret: this.configService.get('JWT_SECRET'),
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export interface JwtPayload {
|
|||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
|||||||
@ -1,358 +1,331 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../../guards/roles.guard';
|
import { RolesGuard } from '../../guards/roles.guard';
|
||||||
import { Roles } from '../../decorators/roles.decorator';
|
import { Roles } from '../../decorators/roles.decorator';
|
||||||
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
|
||||||
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
|
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
|
||||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
import {
|
import {
|
||||||
CsvRateUploadDto,
|
CsvRateUploadDto,
|
||||||
CsvRateUploadResponseDto,
|
CsvRateUploadResponseDto,
|
||||||
CsvRateConfigDto,
|
CsvRateConfigDto,
|
||||||
CsvFileValidationDto,
|
CsvFileValidationDto,
|
||||||
} from '../../dto/csv-rate-upload.dto';
|
} from '../../dto/csv-rate-upload.dto';
|
||||||
import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
|
import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rates Admin Controller
|
* CSV Rates Admin Controller
|
||||||
*
|
*
|
||||||
* ADMIN-ONLY endpoints for managing CSV rate files
|
* ADMIN-ONLY endpoints for managing CSV rate files
|
||||||
* Protected by JWT + Roles guard
|
* Protected by JWT + Roles guard
|
||||||
*/
|
*/
|
||||||
@ApiTags('Admin - CSV Rates')
|
@ApiTags('Admin - CSV Rates')
|
||||||
@Controller('admin/csv-rates')
|
@Controller('admin/csv-rates')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints
|
@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints
|
||||||
export class CsvRatesAdminController {
|
export class CsvRatesAdminController {
|
||||||
private readonly logger = new Logger(CsvRatesAdminController.name);
|
private readonly logger = new Logger(CsvRatesAdminController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csvLoader: CsvRateLoaderAdapter,
|
private readonly csvLoader: CsvRateLoaderAdapter,
|
||||||
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
||||||
private readonly csvRateMapper: CsvRateMapper,
|
private readonly csvRateMapper: CsvRateMapper
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload CSV rate file (ADMIN only)
|
* Upload CSV rate file (ADMIN only)
|
||||||
*/
|
*/
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: './apps/backend/src/infrastructure/storage/csv-storage/rates',
|
destination: './apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
// Generate filename: company-name.csv
|
// Generate filename: company-name.csv
|
||||||
const companyName = req.body.companyName || 'unknown';
|
const companyName = req.body.companyName || 'unknown';
|
||||||
const sanitized = companyName
|
const sanitized = companyName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/[^a-z0-9-]/g, '');
|
.replace(/[^a-z0-9-]/g, '');
|
||||||
const filename = `${sanitized}.csv`;
|
const filename = `${sanitized}.csv`;
|
||||||
cb(null, filename);
|
cb(null, filename);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
// Only allow CSV files
|
// Only allow CSV files
|
||||||
if (extname(file.originalname).toLowerCase() !== '.csv') {
|
if (extname(file.originalname).toLowerCase() !== '.csv') {
|
||||||
return cb(new BadRequestException('Only CSV files are allowed'), false);
|
return cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
fileSize: 10 * 1024 * 1024, // 10MB max
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Upload CSV rate file (ADMIN only)',
|
summary: 'Upload CSV rate file (ADMIN only)',
|
||||||
description:
|
description:
|
||||||
'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.',
|
'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.',
|
||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['companyName', 'file'],
|
required: ['companyName', 'file'],
|
||||||
properties: {
|
properties: {
|
||||||
companyName: {
|
companyName: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Carrier company name',
|
description: 'Carrier company name',
|
||||||
example: 'SSC Consolidation',
|
example: 'SSC Consolidation',
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'binary',
|
format: 'binary',
|
||||||
description: 'CSV file to upload',
|
description: 'CSV file to upload',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.CREATED,
|
status: HttpStatus.CREATED,
|
||||||
description: 'CSV file uploaded and validated successfully',
|
description: 'CSV file uploaded and validated successfully',
|
||||||
type: CsvRateUploadResponseDto,
|
type: CsvRateUploadResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 400,
|
status: 400,
|
||||||
description: 'Invalid file format or validation failed',
|
description: 'Invalid file format or validation failed',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 403,
|
status: 403,
|
||||||
description: 'Forbidden - Admin role required',
|
description: 'Forbidden - Admin role required',
|
||||||
})
|
})
|
||||||
async uploadCsv(
|
async uploadCsv(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body() dto: CsvRateUploadDto,
|
@Body() dto: CsvRateUploadDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<CsvRateUploadResponseDto> {
|
): Promise<CsvRateUploadResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`);
|
||||||
`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`,
|
|
||||||
);
|
if (!file) {
|
||||||
|
throw new BadRequestException('File is required');
|
||||||
if (!file) {
|
}
|
||||||
throw new BadRequestException('File is required');
|
|
||||||
}
|
try {
|
||||||
|
// Validate CSV file structure
|
||||||
try {
|
const validation = await this.csvLoader.validateCsvFile(file.filename);
|
||||||
// Validate CSV file structure
|
|
||||||
const validation = await this.csvLoader.validateCsvFile(file.filename);
|
if (!validation.valid) {
|
||||||
|
this.logger.error(
|
||||||
if (!validation.valid) {
|
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`
|
||||||
this.logger.error(
|
);
|
||||||
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`,
|
throw new BadRequestException({
|
||||||
);
|
message: 'CSV validation failed',
|
||||||
throw new BadRequestException({
|
errors: validation.errors,
|
||||||
message: 'CSV validation failed',
|
});
|
||||||
errors: validation.errors,
|
}
|
||||||
});
|
|
||||||
}
|
// Load rates to verify parsing
|
||||||
|
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
||||||
// Load rates to verify parsing
|
const ratesCount = rates.length;
|
||||||
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
|
||||||
const ratesCount = rates.length;
|
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||||
|
|
||||||
this.logger.log(
|
// Check if config exists for this company
|
||||||
`Successfully parsed ${ratesCount} rates from ${file.filename}`,
|
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
|
||||||
);
|
|
||||||
|
if (existingConfig) {
|
||||||
// Check if config exists for this company
|
// Update existing configuration
|
||||||
const existingConfig = await this.csvConfigRepository.findByCompanyName(
|
await this.csvConfigRepository.update(existingConfig.id, {
|
||||||
dto.companyName,
|
csvFilePath: file.filename,
|
||||||
);
|
uploadedAt: new Date(),
|
||||||
|
uploadedBy: user.id,
|
||||||
if (existingConfig) {
|
rowCount: ratesCount,
|
||||||
// Update existing configuration
|
lastValidatedAt: new Date(),
|
||||||
await this.csvConfigRepository.update(existingConfig.id, {
|
metadata: {
|
||||||
csvFilePath: file.filename,
|
...existingConfig.metadata,
|
||||||
uploadedAt: new Date(),
|
lastUpload: {
|
||||||
uploadedBy: user.id,
|
timestamp: new Date().toISOString(),
|
||||||
rowCount: ratesCount,
|
by: user.email,
|
||||||
lastValidatedAt: new Date(),
|
ratesCount,
|
||||||
metadata: {
|
},
|
||||||
...existingConfig.metadata,
|
},
|
||||||
lastUpload: {
|
});
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
by: user.email,
|
this.logger.log(`Updated CSV config for company: ${dto.companyName}`);
|
||||||
ratesCount,
|
} else {
|
||||||
},
|
// Create new configuration
|
||||||
},
|
await this.csvConfigRepository.create({
|
||||||
});
|
companyName: dto.companyName,
|
||||||
|
csvFilePath: file.filename,
|
||||||
this.logger.log(
|
type: 'CSV_ONLY',
|
||||||
`Updated CSV config for company: ${dto.companyName}`,
|
hasApi: false,
|
||||||
);
|
apiConnector: null,
|
||||||
} else {
|
isActive: true,
|
||||||
// Create new configuration
|
uploadedAt: new Date(),
|
||||||
await this.csvConfigRepository.create({
|
uploadedBy: user.id,
|
||||||
companyName: dto.companyName,
|
rowCount: ratesCount,
|
||||||
csvFilePath: file.filename,
|
lastValidatedAt: new Date(),
|
||||||
type: 'CSV_ONLY',
|
metadata: {
|
||||||
hasApi: false,
|
uploadedBy: user.email,
|
||||||
apiConnector: null,
|
description: `${dto.companyName} shipping rates`,
|
||||||
isActive: true,
|
},
|
||||||
uploadedAt: new Date(),
|
});
|
||||||
uploadedBy: user.id,
|
|
||||||
rowCount: ratesCount,
|
this.logger.log(`Created new CSV config for company: ${dto.companyName}`);
|
||||||
lastValidatedAt: new Date(),
|
}
|
||||||
metadata: {
|
|
||||||
uploadedBy: user.email,
|
return {
|
||||||
description: `${dto.companyName} shipping rates`,
|
success: true,
|
||||||
},
|
ratesCount,
|
||||||
});
|
csvFilePath: file.filename,
|
||||||
|
companyName: dto.companyName,
|
||||||
this.logger.log(
|
uploadedAt: new Date(),
|
||||||
`Created new CSV config for company: ${dto.companyName}`,
|
};
|
||||||
);
|
} catch (error: any) {
|
||||||
}
|
this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
||||||
|
throw error;
|
||||||
return {
|
}
|
||||||
success: true,
|
}
|
||||||
ratesCount,
|
|
||||||
csvFilePath: file.filename,
|
/**
|
||||||
companyName: dto.companyName,
|
* Get all CSV rate configurations
|
||||||
uploadedAt: new Date(),
|
*/
|
||||||
};
|
@Get('config')
|
||||||
} catch (error: any) {
|
@HttpCode(HttpStatus.OK)
|
||||||
this.logger.error(
|
@ApiOperation({
|
||||||
`CSV upload failed: ${error?.message || 'Unknown error'}`,
|
summary: 'Get all CSV rate configurations (ADMIN only)',
|
||||||
error?.stack,
|
description: 'Returns list of all CSV rate configurations with upload details.',
|
||||||
);
|
})
|
||||||
throw error;
|
@ApiResponse({
|
||||||
}
|
status: HttpStatus.OK,
|
||||||
}
|
description: 'List of CSV rate configurations',
|
||||||
|
type: [CsvRateConfigDto],
|
||||||
/**
|
})
|
||||||
* Get all CSV rate configurations
|
async getAllConfigs(): Promise<CsvRateConfigDto[]> {
|
||||||
*/
|
this.logger.log('Fetching all CSV rate configs (admin)');
|
||||||
@Get('config')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
const configs = await this.csvConfigRepository.findAll();
|
||||||
@ApiOperation({
|
return this.csvRateMapper.mapConfigEntitiesToDtos(configs);
|
||||||
summary: 'Get all CSV rate configurations (ADMIN only)',
|
}
|
||||||
description: 'Returns list of all CSV rate configurations with upload details.',
|
|
||||||
})
|
/**
|
||||||
@ApiResponse({
|
* Get configuration for specific company
|
||||||
status: HttpStatus.OK,
|
*/
|
||||||
description: 'List of CSV rate configurations',
|
@Get('config/:companyName')
|
||||||
type: [CsvRateConfigDto],
|
@HttpCode(HttpStatus.OK)
|
||||||
})
|
@ApiOperation({
|
||||||
async getAllConfigs(): Promise<CsvRateConfigDto[]> {
|
summary: 'Get CSV configuration for specific company (ADMIN only)',
|
||||||
this.logger.log('Fetching all CSV rate configs (admin)');
|
description: 'Returns CSV rate configuration details for a specific carrier.',
|
||||||
|
})
|
||||||
const configs = await this.csvConfigRepository.findAll();
|
@ApiResponse({
|
||||||
return this.csvRateMapper.mapConfigEntitiesToDtos(configs);
|
status: HttpStatus.OK,
|
||||||
}
|
description: 'CSV rate configuration',
|
||||||
|
type: CsvRateConfigDto,
|
||||||
/**
|
})
|
||||||
* Get configuration for specific company
|
@ApiResponse({
|
||||||
*/
|
status: 404,
|
||||||
@Get('config/:companyName')
|
description: 'Company configuration not found',
|
||||||
@HttpCode(HttpStatus.OK)
|
})
|
||||||
@ApiOperation({
|
async getConfigByCompany(@Param('companyName') companyName: string): Promise<CsvRateConfigDto> {
|
||||||
summary: 'Get CSV configuration for specific company (ADMIN only)',
|
this.logger.log(`Fetching CSV config for company: ${companyName}`);
|
||||||
description: 'Returns CSV rate configuration details for a specific carrier.',
|
|
||||||
})
|
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
if (!config) {
|
||||||
description: 'CSV rate configuration',
|
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
|
||||||
type: CsvRateConfigDto,
|
}
|
||||||
})
|
|
||||||
@ApiResponse({
|
return this.csvRateMapper.mapConfigEntityToDto(config);
|
||||||
status: 404,
|
}
|
||||||
description: 'Company configuration not found',
|
|
||||||
})
|
/**
|
||||||
async getConfigByCompany(
|
* Validate CSV file
|
||||||
@Param('companyName') companyName: string,
|
*/
|
||||||
): Promise<CsvRateConfigDto> {
|
@Post('validate/:companyName')
|
||||||
this.logger.log(`Fetching CSV config for company: ${companyName}`);
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
summary: 'Validate CSV file for company (ADMIN only)',
|
||||||
|
description:
|
||||||
if (!config) {
|
'Validates the CSV file structure and data for a specific company without uploading.',
|
||||||
throw new BadRequestException(
|
})
|
||||||
`No CSV configuration found for company: ${companyName}`,
|
@ApiResponse({
|
||||||
);
|
status: HttpStatus.OK,
|
||||||
}
|
description: 'Validation result',
|
||||||
|
type: CsvFileValidationDto,
|
||||||
return this.csvRateMapper.mapConfigEntityToDto(config);
|
})
|
||||||
}
|
async validateCsvFile(@Param('companyName') companyName: string): Promise<CsvFileValidationDto> {
|
||||||
|
this.logger.log(`Validating CSV file for company: ${companyName}`);
|
||||||
/**
|
|
||||||
* Validate CSV file
|
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
||||||
*/
|
|
||||||
@Post('validate/:companyName')
|
if (!config) {
|
||||||
@HttpCode(HttpStatus.OK)
|
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
|
||||||
@ApiOperation({
|
}
|
||||||
summary: 'Validate CSV file for company (ADMIN only)',
|
|
||||||
description:
|
const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
|
||||||
'Validates the CSV file structure and data for a specific company without uploading.',
|
|
||||||
})
|
// Update validation timestamp
|
||||||
@ApiResponse({
|
if (result.valid && result.rowCount) {
|
||||||
status: HttpStatus.OK,
|
await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result);
|
||||||
description: 'Validation result',
|
}
|
||||||
type: CsvFileValidationDto,
|
|
||||||
})
|
return result;
|
||||||
async validateCsvFile(
|
}
|
||||||
@Param('companyName') companyName: string,
|
|
||||||
): Promise<CsvFileValidationDto> {
|
/**
|
||||||
this.logger.log(`Validating CSV file for company: ${companyName}`);
|
* Delete CSV rate configuration
|
||||||
|
*/
|
||||||
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
@Delete('config/:companyName')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
if (!config) {
|
@ApiOperation({
|
||||||
throw new BadRequestException(
|
summary: 'Delete CSV rate configuration (ADMIN only)',
|
||||||
`No CSV configuration found for company: ${companyName}`,
|
description:
|
||||||
);
|
'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.',
|
||||||
}
|
})
|
||||||
|
@ApiResponse({
|
||||||
const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
|
status: HttpStatus.NO_CONTENT,
|
||||||
|
description: 'Configuration deleted successfully',
|
||||||
// Update validation timestamp
|
})
|
||||||
if (result.valid && result.rowCount) {
|
@ApiResponse({
|
||||||
await this.csvConfigRepository.updateValidationInfo(
|
status: 404,
|
||||||
companyName,
|
description: 'Company configuration not found',
|
||||||
result.rowCount,
|
})
|
||||||
result,
|
async deleteConfig(
|
||||||
);
|
@Param('companyName') companyName: string,
|
||||||
}
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<void> {
|
||||||
return result;
|
this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`);
|
||||||
}
|
|
||||||
|
await this.csvConfigRepository.delete(companyName);
|
||||||
/**
|
|
||||||
* Delete CSV rate configuration
|
this.logger.log(`Deleted CSV config for company: ${companyName}`);
|
||||||
*/
|
}
|
||||||
@Delete('config/:companyName')
|
}
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Delete CSV rate configuration (ADMIN only)',
|
|
||||||
description:
|
|
||||||
'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NO_CONTENT,
|
|
||||||
description: 'Configuration deleted successfully',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 404,
|
|
||||||
description: 'Company configuration not found',
|
|
||||||
})
|
|
||||||
async deleteConfig(
|
|
||||||
@Param('companyName') companyName: string,
|
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<void> {
|
|
||||||
this.logger.warn(
|
|
||||||
`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.csvConfigRepository.delete(companyName);
|
|
||||||
|
|
||||||
this.logger.log(`Deleted CSV config for company: ${companyName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -66,8 +66,18 @@ export class AuditController {
|
|||||||
@ApiOperation({ summary: 'Get audit logs with filters' })
|
@ApiOperation({ summary: 'Get audit logs with filters' })
|
||||||
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
||||||
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
|
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
|
||||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true })
|
@ApiQuery({
|
||||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true })
|
name: 'action',
|
||||||
|
required: false,
|
||||||
|
description: 'Filter by action (comma-separated)',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'status',
|
||||||
|
required: false,
|
||||||
|
description: 'Filter by status (comma-separated)',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
|
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
|
||||||
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
|
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
|
||||||
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
|
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
|
||||||
@ -84,7 +94,7 @@ export class AuditController {
|
|||||||
@Query('startDate') startDate?: string,
|
@Query('startDate') startDate?: string,
|
||||||
@Query('endDate') endDate?: string,
|
@Query('endDate') endDate?: string,
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
|
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
|
||||||
page = page || 1;
|
page = page || 1;
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
@ -104,7 +114,7 @@ export class AuditController {
|
|||||||
const { logs, total } = await this.auditService.getAuditLogs(filters);
|
const { logs, total } = await this.auditService.getAuditLogs(filters);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs: logs.map((log) => this.mapToDto(log)),
|
logs: logs.map(log => this.mapToDto(log)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
@ -121,7 +131,7 @@ export class AuditController {
|
|||||||
@ApiResponse({ status: 404, description: 'Audit log not found' })
|
@ApiResponse({ status: 404, description: 'Audit log not found' })
|
||||||
async getAuditLogById(
|
async getAuditLogById(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<AuditLogResponseDto> {
|
): Promise<AuditLogResponseDto> {
|
||||||
const log = await this.auditService.getAuditLogs({
|
const log = await this.auditService.getAuditLogs({
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
@ -145,14 +155,14 @@ export class AuditController {
|
|||||||
async getResourceAuditTrail(
|
async getResourceAuditTrail(
|
||||||
@Param('type') resourceType: string,
|
@Param('type') resourceType: string,
|
||||||
@Param('id') resourceId: string,
|
@Param('id') resourceId: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<AuditLogResponseDto[]> {
|
): Promise<AuditLogResponseDto[]> {
|
||||||
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
|
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
|
||||||
|
|
||||||
// Filter by organization for security
|
// Filter by organization for security
|
||||||
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
|
||||||
|
|
||||||
return filteredLogs.map((log) => this.mapToDto(log));
|
return filteredLogs.map(log => this.mapToDto(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -165,11 +175,11 @@ export class AuditController {
|
|||||||
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
||||||
async getOrganizationActivity(
|
async getOrganizationActivity(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<AuditLogResponseDto[]> {
|
): Promise<AuditLogResponseDto[]> {
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
|
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
|
||||||
return logs.map((log) => this.mapToDto(log));
|
return logs.map(log => this.mapToDto(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -183,15 +193,15 @@ export class AuditController {
|
|||||||
async getUserActivity(
|
async getUserActivity(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<AuditLogResponseDto[]> {
|
): Promise<AuditLogResponseDto[]> {
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
const logs = await this.auditService.getUserActivity(userId, limit);
|
const logs = await this.auditService.getUserActivity(userId, limit);
|
||||||
|
|
||||||
// Filter by organization for security
|
// Filter by organization for security
|
||||||
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
|
||||||
|
|
||||||
return filteredLogs.map((log) => this.mapToDto(log));
|
return filteredLogs.map(log => this.mapToDto(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,227 +1,204 @@
|
|||||||
import {
|
import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get } from '@nestjs/common';
|
||||||
Controller,
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
Post,
|
import { AuthService } from '../auth/auth.service';
|
||||||
Body,
|
import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
|
||||||
HttpCode,
|
import { Public } from '../decorators/public.decorator';
|
||||||
HttpStatus,
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
UseGuards,
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
Get,
|
|
||||||
} from '@nestjs/common';
|
/**
|
||||||
import {
|
* Authentication Controller
|
||||||
ApiTags,
|
*
|
||||||
ApiOperation,
|
* Handles user authentication endpoints:
|
||||||
ApiResponse,
|
* - POST /auth/register - User registration
|
||||||
ApiBearerAuth,
|
* - POST /auth/login - User login
|
||||||
} from '@nestjs/swagger';
|
* - POST /auth/refresh - Token refresh
|
||||||
import { AuthService } from '../auth/auth.service';
|
* - POST /auth/logout - User logout (placeholder)
|
||||||
import {
|
* - GET /auth/me - Get current user profile
|
||||||
LoginDto,
|
*/
|
||||||
RegisterDto,
|
@ApiTags('Authentication')
|
||||||
AuthResponseDto,
|
@Controller('auth')
|
||||||
RefreshTokenDto,
|
export class AuthController {
|
||||||
} from '../dto/auth-login.dto';
|
constructor(private readonly authService: AuthService) {}
|
||||||
import { Public } from '../decorators/public.decorator';
|
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
/**
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
* Register a new user
|
||||||
|
*
|
||||||
/**
|
* Creates a new user account and returns access + refresh tokens.
|
||||||
* Authentication Controller
|
*
|
||||||
*
|
* @param dto - Registration data (email, password, firstName, lastName, organizationId)
|
||||||
* Handles user authentication endpoints:
|
* @returns Access token, refresh token, and user info
|
||||||
* - POST /auth/register - User registration
|
*/
|
||||||
* - POST /auth/login - User login
|
@Public()
|
||||||
* - POST /auth/refresh - Token refresh
|
@Post('register')
|
||||||
* - POST /auth/logout - User logout (placeholder)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
* - GET /auth/me - Get current user profile
|
@ApiOperation({
|
||||||
*/
|
summary: 'Register new user',
|
||||||
@ApiTags('Authentication')
|
description: 'Create a new user account with email and password. Returns JWT tokens.',
|
||||||
@Controller('auth')
|
})
|
||||||
export class AuthController {
|
@ApiResponse({
|
||||||
constructor(private readonly authService: AuthService) {}
|
status: 201,
|
||||||
|
description: 'User successfully registered',
|
||||||
/**
|
type: AuthResponseDto,
|
||||||
* Register a new user
|
})
|
||||||
*
|
@ApiResponse({
|
||||||
* Creates a new user account and returns access + refresh tokens.
|
status: 409,
|
||||||
*
|
description: 'User with this email already exists',
|
||||||
* @param dto - Registration data (email, password, firstName, lastName, organizationId)
|
})
|
||||||
* @returns Access token, refresh token, and user info
|
@ApiResponse({
|
||||||
*/
|
status: 400,
|
||||||
@Public()
|
description: 'Validation error (invalid email, weak password, etc.)',
|
||||||
@Post('register')
|
})
|
||||||
@HttpCode(HttpStatus.CREATED)
|
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
|
||||||
@ApiOperation({
|
const result = await this.authService.register(
|
||||||
summary: 'Register new user',
|
dto.email,
|
||||||
description:
|
dto.password,
|
||||||
'Create a new user account with email and password. Returns JWT tokens.',
|
dto.firstName,
|
||||||
})
|
dto.lastName,
|
||||||
@ApiResponse({
|
dto.organizationId
|
||||||
status: 201,
|
);
|
||||||
description: 'User successfully registered',
|
|
||||||
type: AuthResponseDto,
|
return {
|
||||||
})
|
accessToken: result.accessToken,
|
||||||
@ApiResponse({
|
refreshToken: result.refreshToken,
|
||||||
status: 409,
|
user: result.user,
|
||||||
description: 'User with this email already exists',
|
};
|
||||||
})
|
}
|
||||||
@ApiResponse({
|
|
||||||
status: 400,
|
/**
|
||||||
description: 'Validation error (invalid email, weak password, etc.)',
|
* Login with email and password
|
||||||
})
|
*
|
||||||
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
|
* Authenticates a user and returns access + refresh tokens.
|
||||||
const result = await this.authService.register(
|
*
|
||||||
dto.email,
|
* @param dto - Login credentials (email, password)
|
||||||
dto.password,
|
* @returns Access token, refresh token, and user info
|
||||||
dto.firstName,
|
*/
|
||||||
dto.lastName,
|
@Public()
|
||||||
dto.organizationId,
|
@Post('login')
|
||||||
);
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
return {
|
summary: 'User login',
|
||||||
accessToken: result.accessToken,
|
description: 'Authenticate with email and password. Returns JWT tokens.',
|
||||||
refreshToken: result.refreshToken,
|
})
|
||||||
user: result.user,
|
@ApiResponse({
|
||||||
};
|
status: 200,
|
||||||
}
|
description: 'Login successful',
|
||||||
|
type: AuthResponseDto,
|
||||||
/**
|
})
|
||||||
* Login with email and password
|
@ApiResponse({
|
||||||
*
|
status: 401,
|
||||||
* Authenticates a user and returns access + refresh tokens.
|
description: 'Invalid credentials or inactive account',
|
||||||
*
|
})
|
||||||
* @param dto - Login credentials (email, password)
|
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
|
||||||
* @returns Access token, refresh token, and user info
|
const result = await this.authService.login(dto.email, dto.password);
|
||||||
*/
|
|
||||||
@Public()
|
return {
|
||||||
@Post('login')
|
accessToken: result.accessToken,
|
||||||
@HttpCode(HttpStatus.OK)
|
refreshToken: result.refreshToken,
|
||||||
@ApiOperation({
|
user: result.user,
|
||||||
summary: 'User login',
|
};
|
||||||
description: 'Authenticate with email and password. Returns JWT tokens.',
|
}
|
||||||
})
|
|
||||||
@ApiResponse({
|
/**
|
||||||
status: 200,
|
* Refresh access token
|
||||||
description: 'Login successful',
|
*
|
||||||
type: AuthResponseDto,
|
* Obtains a new access token using a valid refresh token.
|
||||||
})
|
*
|
||||||
@ApiResponse({
|
* @param dto - Refresh token
|
||||||
status: 401,
|
* @returns New access token
|
||||||
description: 'Invalid credentials or inactive account',
|
*/
|
||||||
})
|
@Public()
|
||||||
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
|
@Post('refresh')
|
||||||
const result = await this.authService.login(dto.email, dto.password);
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
return {
|
summary: 'Refresh access token',
|
||||||
accessToken: result.accessToken,
|
description:
|
||||||
refreshToken: result.refreshToken,
|
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
|
||||||
user: result.user,
|
})
|
||||||
};
|
@ApiResponse({
|
||||||
}
|
status: 200,
|
||||||
|
description: 'Token refreshed successfully',
|
||||||
/**
|
schema: {
|
||||||
* Refresh access token
|
properties: {
|
||||||
*
|
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
|
||||||
* Obtains a new access token using a valid refresh token.
|
},
|
||||||
*
|
},
|
||||||
* @param dto - Refresh token
|
})
|
||||||
* @returns New access token
|
@ApiResponse({
|
||||||
*/
|
status: 401,
|
||||||
@Public()
|
description: 'Invalid or expired refresh token',
|
||||||
@Post('refresh')
|
})
|
||||||
@HttpCode(HttpStatus.OK)
|
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
|
||||||
@ApiOperation({
|
const result = await this.authService.refreshAccessToken(dto.refreshToken);
|
||||||
summary: 'Refresh access token',
|
|
||||||
description:
|
return { accessToken: result.accessToken };
|
||||||
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
|
}
|
||||||
})
|
|
||||||
@ApiResponse({
|
/**
|
||||||
status: 200,
|
* Logout (placeholder)
|
||||||
description: 'Token refreshed successfully',
|
*
|
||||||
schema: {
|
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
|
||||||
properties: {
|
* by removing tokens. For more security, implement token blacklisting with Redis.
|
||||||
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
|
*
|
||||||
},
|
* @returns Success message
|
||||||
},
|
*/
|
||||||
})
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiResponse({
|
@Post('logout')
|
||||||
status: 401,
|
@HttpCode(HttpStatus.OK)
|
||||||
description: 'Invalid or expired refresh token',
|
@ApiBearerAuth()
|
||||||
})
|
@ApiOperation({
|
||||||
async refresh(
|
summary: 'Logout',
|
||||||
@Body() dto: RefreshTokenDto,
|
description: 'Logout the current user. Currently handled client-side by removing tokens.',
|
||||||
): Promise<{ accessToken: string }> {
|
})
|
||||||
const result =
|
@ApiResponse({
|
||||||
await this.authService.refreshAccessToken(dto.refreshToken);
|
status: 200,
|
||||||
|
description: 'Logout successful',
|
||||||
return { accessToken: result.accessToken };
|
schema: {
|
||||||
}
|
properties: {
|
||||||
|
message: { type: 'string', example: 'Logout successful' },
|
||||||
/**
|
},
|
||||||
* Logout (placeholder)
|
},
|
||||||
*
|
})
|
||||||
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
|
async logout(): Promise<{ message: string }> {
|
||||||
* by removing tokens. For more security, implement token blacklisting with Redis.
|
// TODO: Implement token blacklisting with Redis for more security
|
||||||
*
|
// For now, logout is handled client-side by removing tokens
|
||||||
* @returns Success message
|
return { message: 'Logout successful' };
|
||||||
*/
|
}
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('logout')
|
/**
|
||||||
@HttpCode(HttpStatus.OK)
|
* Get current user profile
|
||||||
@ApiBearerAuth()
|
*
|
||||||
@ApiOperation({
|
* Returns the profile of the currently authenticated user.
|
||||||
summary: 'Logout',
|
*
|
||||||
description:
|
* @param user - Current user from JWT token
|
||||||
'Logout the current user. Currently handled client-side by removing tokens.',
|
* @returns User profile
|
||||||
})
|
*/
|
||||||
@ApiResponse({
|
@UseGuards(JwtAuthGuard)
|
||||||
status: 200,
|
@Get('me')
|
||||||
description: 'Logout successful',
|
@ApiBearerAuth()
|
||||||
schema: {
|
@ApiOperation({
|
||||||
properties: {
|
summary: 'Get current user profile',
|
||||||
message: { type: 'string', example: 'Logout successful' },
|
description: 'Returns the profile of the authenticated user.',
|
||||||
},
|
})
|
||||||
},
|
@ApiResponse({
|
||||||
})
|
status: 200,
|
||||||
async logout(): Promise<{ message: string }> {
|
description: 'User profile retrieved successfully',
|
||||||
// TODO: Implement token blacklisting with Redis for more security
|
schema: {
|
||||||
// For now, logout is handled client-side by removing tokens
|
properties: {
|
||||||
return { message: 'Logout successful' };
|
id: { type: 'string', format: 'uuid' },
|
||||||
}
|
email: { type: 'string', format: 'email' },
|
||||||
|
firstName: { type: 'string' },
|
||||||
/**
|
lastName: { type: 'string' },
|
||||||
* Get current user profile
|
role: { type: 'string', enum: ['admin', 'manager', 'user', 'viewer'] },
|
||||||
*
|
organizationId: { type: 'string', format: 'uuid' },
|
||||||
* Returns the profile of the currently authenticated user.
|
},
|
||||||
*
|
},
|
||||||
* @param user - Current user from JWT token
|
})
|
||||||
* @returns User profile
|
@ApiResponse({
|
||||||
*/
|
status: 401,
|
||||||
@UseGuards(JwtAuthGuard)
|
description: 'Unauthorized - invalid or missing token',
|
||||||
@Get('me')
|
})
|
||||||
@ApiBearerAuth()
|
async getProfile(@CurrentUser() user: UserPayload) {
|
||||||
@ApiOperation({
|
return user;
|
||||||
summary: 'Get current user profile',
|
}
|
||||||
description: 'Returns the profile of the authenticated user.',
|
}
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'User profile retrieved successfully',
|
|
||||||
schema: {
|
|
||||||
properties: {
|
|
||||||
id: { type: 'string', format: 'uuid' },
|
|
||||||
email: { type: 'string', format: 'email' },
|
|
||||||
firstName: { type: 'string' },
|
|
||||||
lastName: { type: 'string' },
|
|
||||||
role: { type: 'string', enum: ['admin', 'manager', 'user', 'viewer'] },
|
|
||||||
organizationId: { type: 'string', format: 'uuid' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - invalid or missing token',
|
|
||||||
})
|
|
||||||
async getProfile(@CurrentUser() user: UserPayload) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -41,17 +41,14 @@ export class GDPRController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'Data export successful',
|
description: 'Data export successful',
|
||||||
})
|
})
|
||||||
async exportData(
|
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const exportData = await this.gdprService.exportUserData(user.id);
|
const exportData = await this.gdprService.exportUserData(user.id);
|
||||||
|
|
||||||
// Set headers for file download
|
// Set headers for file download
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`,
|
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(exportData);
|
res.json(exportData);
|
||||||
@ -69,10 +66,7 @@ export class GDPRController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'CSV export successful',
|
description: 'CSV export successful',
|
||||||
})
|
})
|
||||||
async exportDataCSV(
|
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const exportData = await this.gdprService.exportUserData(user.id);
|
const exportData = await this.gdprService.exportUserData(user.id);
|
||||||
|
|
||||||
// Convert to CSV (simplified version)
|
// Convert to CSV (simplified version)
|
||||||
@ -87,7 +81,7 @@ export class GDPRController {
|
|||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`,
|
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
|
||||||
);
|
);
|
||||||
|
|
||||||
res.send(csv);
|
res.send(csv);
|
||||||
@ -108,7 +102,7 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
async deleteAccount(
|
async deleteAccount(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: { reason?: string; confirmEmail: string },
|
@Body() body: { reason?: string; confirmEmail: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Verify email confirmation (security measure)
|
// Verify email confirmation (security measure)
|
||||||
if (body.confirmEmail !== user.email) {
|
if (body.confirmEmail !== user.email) {
|
||||||
@ -133,7 +127,7 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
async recordConsent(
|
async recordConsent(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: Omit<ConsentData, 'userId'>,
|
@Body() body: Omit<ConsentData, 'userId'>
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
await this.gdprService.recordConsent({
|
await this.gdprService.recordConsent({
|
||||||
...body,
|
...body,
|
||||||
@ -158,7 +152,7 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
async withdrawConsent(
|
async withdrawConsent(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: { consentType: 'marketing' | 'analytics' },
|
@Body() body: { consentType: 'marketing' | 'analytics' }
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||||
|
|
||||||
@ -177,9 +171,7 @@ export class GDPRController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'Consent status retrieved',
|
description: 'Consent status retrieved',
|
||||||
})
|
})
|
||||||
async getConsentStatus(
|
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<any> {
|
|
||||||
return this.gdprService.getConsentStatus(user.id);
|
return this.gdprService.getConsentStatus(user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export * from './rates.controller';
|
export * from './rates.controller';
|
||||||
export * from './bookings.controller';
|
export * from './bookings.controller';
|
||||||
|
|||||||
@ -17,13 +17,7 @@ import {
|
|||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiQuery,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { NotificationService } from '../services/notification.service';
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
@ -62,7 +56,7 @@ export class NotificationsController {
|
|||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Query('read') read?: string,
|
@Query('read') read?: string,
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number
|
||||||
): Promise<{
|
): Promise<{
|
||||||
notifications: NotificationResponseDto[];
|
notifications: NotificationResponseDto[];
|
||||||
total: number;
|
total: number;
|
||||||
@ -82,7 +76,7 @@ export class NotificationsController {
|
|||||||
const { notifications, total } = await this.notificationService.getNotifications(filters);
|
const { notifications, total } = await this.notificationService.getNotifications(filters);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications: notifications.map((n) => this.mapToDto(n)),
|
notifications: notifications.map(n => this.mapToDto(n)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
@ -95,14 +89,18 @@ export class NotificationsController {
|
|||||||
@Get('unread')
|
@Get('unread')
|
||||||
@ApiOperation({ summary: 'Get unread notifications' })
|
@ApiOperation({ summary: 'Get unread notifications' })
|
||||||
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' })
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
required: false,
|
||||||
|
description: 'Number of notifications (default: 50)',
|
||||||
|
})
|
||||||
async getUnreadNotifications(
|
async getUnreadNotifications(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<NotificationResponseDto[]> {
|
): Promise<NotificationResponseDto[]> {
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
|
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
|
||||||
return notifications.map((n) => this.mapToDto(n));
|
return notifications.map(n => this.mapToDto(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,7 +123,7 @@ export class NotificationsController {
|
|||||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
async getNotificationById(
|
async getNotificationById(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string
|
||||||
): Promise<NotificationResponseDto> {
|
): Promise<NotificationResponseDto> {
|
||||||
const notification = await this.notificationService.getNotificationById(id);
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
@ -145,7 +143,7 @@ export class NotificationsController {
|
|||||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
async markAsRead(
|
async markAsRead(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const notification = await this.notificationService.getNotificationById(id);
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
@ -177,7 +175,7 @@ export class NotificationsController {
|
|||||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
async deleteNotification(
|
async deleteNotification(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const notification = await this.notificationService.getNotificationById(id);
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
|
|||||||
@ -1,367 +1,357 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
Query,
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
CreateOrganizationDto,
|
CreateOrganizationDto,
|
||||||
UpdateOrganizationDto,
|
UpdateOrganizationDto,
|
||||||
OrganizationResponseDto,
|
OrganizationResponseDto,
|
||||||
OrganizationListResponseDto,
|
OrganizationListResponseDto,
|
||||||
} from '../dto/organization.dto';
|
} from '../dto/organization.dto';
|
||||||
import { OrganizationMapper } from '../mappers/organization.mapper';
|
import { OrganizationMapper } from '../mappers/organization.mapper';
|
||||||
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
|
import {
|
||||||
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
|
OrganizationRepository,
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
ORGANIZATION_REPOSITORY,
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
} from '../../domain/ports/out/organization.repository';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
/**
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
* Organizations Controller
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
*
|
|
||||||
* Manages organization CRUD operations:
|
/**
|
||||||
* - Create organization (admin only)
|
* Organizations Controller
|
||||||
* - Get organization details
|
*
|
||||||
* - Update organization (admin/manager)
|
* Manages organization CRUD operations:
|
||||||
* - List organizations
|
* - Create organization (admin only)
|
||||||
*/
|
* - Get organization details
|
||||||
@ApiTags('Organizations')
|
* - Update organization (admin/manager)
|
||||||
@Controller('organizations')
|
* - List organizations
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
*/
|
||||||
@ApiBearerAuth()
|
@ApiTags('Organizations')
|
||||||
export class OrganizationsController {
|
@Controller('organizations')
|
||||||
private readonly logger = new Logger(OrganizationsController.name);
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
constructor(
|
export class OrganizationsController {
|
||||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
private readonly logger = new Logger(OrganizationsController.name);
|
||||||
) {}
|
|
||||||
|
constructor(
|
||||||
/**
|
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
|
||||||
* Create a new organization
|
) {}
|
||||||
*
|
|
||||||
* Admin-only endpoint to create a new organization.
|
/**
|
||||||
*/
|
* Create a new organization
|
||||||
@Post()
|
*
|
||||||
@HttpCode(HttpStatus.CREATED)
|
* Admin-only endpoint to create a new organization.
|
||||||
@Roles('admin')
|
*/
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@Post()
|
||||||
@ApiOperation({
|
@HttpCode(HttpStatus.CREATED)
|
||||||
summary: 'Create new organization',
|
@Roles('admin')
|
||||||
description:
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
|
@ApiOperation({
|
||||||
})
|
summary: 'Create new organization',
|
||||||
@ApiResponse({
|
description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
|
||||||
status: HttpStatus.CREATED,
|
})
|
||||||
description: 'Organization created successfully',
|
@ApiResponse({
|
||||||
type: OrganizationResponseDto,
|
status: HttpStatus.CREATED,
|
||||||
})
|
description: 'Organization created successfully',
|
||||||
@ApiResponse({
|
type: OrganizationResponseDto,
|
||||||
status: 401,
|
})
|
||||||
description: 'Unauthorized - missing or invalid token',
|
@ApiResponse({
|
||||||
})
|
status: 401,
|
||||||
@ApiResponse({
|
description: 'Unauthorized - missing or invalid token',
|
||||||
status: 403,
|
})
|
||||||
description: 'Forbidden - requires admin role',
|
@ApiResponse({
|
||||||
})
|
status: 403,
|
||||||
@ApiBadRequestResponse({
|
description: 'Forbidden - requires admin role',
|
||||||
description: 'Invalid request parameters',
|
})
|
||||||
})
|
@ApiBadRequestResponse({
|
||||||
async createOrganization(
|
description: 'Invalid request parameters',
|
||||||
@Body() dto: CreateOrganizationDto,
|
})
|
||||||
@CurrentUser() user: UserPayload,
|
async createOrganization(
|
||||||
): Promise<OrganizationResponseDto> {
|
@Body() dto: CreateOrganizationDto,
|
||||||
this.logger.log(
|
@CurrentUser() user: UserPayload
|
||||||
`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`,
|
): Promise<OrganizationResponseDto> {
|
||||||
);
|
this.logger.log(`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for duplicate name
|
// Check for duplicate name
|
||||||
const existingByName = await this.organizationRepository.findByName(dto.name);
|
const existingByName = await this.organizationRepository.findByName(dto.name);
|
||||||
if (existingByName) {
|
if (existingByName) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(`Organization with name "${dto.name}" already exists`);
|
||||||
`Organization with name "${dto.name}" already exists`,
|
}
|
||||||
);
|
|
||||||
}
|
// Check for duplicate SCAC if provided
|
||||||
|
if (dto.scac) {
|
||||||
// Check for duplicate SCAC if provided
|
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
|
||||||
if (dto.scac) {
|
if (existingBySCAC) {
|
||||||
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
|
throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`);
|
||||||
if (existingBySCAC) {
|
}
|
||||||
throw new ForbiddenException(
|
}
|
||||||
`Organization with SCAC "${dto.scac}" already exists`,
|
|
||||||
);
|
// Create organization entity
|
||||||
}
|
const organization = Organization.create({
|
||||||
}
|
id: uuidv4(),
|
||||||
|
name: dto.name,
|
||||||
// Create organization entity
|
type: dto.type,
|
||||||
const organization = Organization.create({
|
scac: dto.scac,
|
||||||
id: uuidv4(),
|
address: OrganizationMapper.mapDtoToAddress(dto.address),
|
||||||
name: dto.name,
|
logoUrl: dto.logoUrl,
|
||||||
type: dto.type,
|
documents: [],
|
||||||
scac: dto.scac,
|
isActive: true,
|
||||||
address: OrganizationMapper.mapDtoToAddress(dto.address),
|
});
|
||||||
logoUrl: dto.logoUrl,
|
|
||||||
documents: [],
|
// Save to database
|
||||||
isActive: true,
|
const savedOrg = await this.organizationRepository.save(organization);
|
||||||
});
|
|
||||||
|
this.logger.log(`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`);
|
||||||
// Save to database
|
|
||||||
const savedOrg = await this.organizationRepository.save(organization);
|
return OrganizationMapper.toDto(savedOrg);
|
||||||
|
} catch (error: any) {
|
||||||
this.logger.log(
|
this.logger.error(
|
||||||
`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`,
|
`Organization creation failed: ${error?.message || 'Unknown error'}`,
|
||||||
);
|
error?.stack
|
||||||
|
);
|
||||||
return OrganizationMapper.toDto(savedOrg);
|
throw error;
|
||||||
} catch (error: any) {
|
}
|
||||||
this.logger.error(
|
}
|
||||||
`Organization creation failed: ${error?.message || 'Unknown error'}`,
|
|
||||||
error?.stack,
|
/**
|
||||||
);
|
* Get organization by ID
|
||||||
throw error;
|
*
|
||||||
}
|
* Retrieve details of a specific organization.
|
||||||
}
|
* Users can only view their own organization unless they are admins.
|
||||||
|
*/
|
||||||
/**
|
@Get(':id')
|
||||||
* Get organization by ID
|
@ApiOperation({
|
||||||
*
|
summary: 'Get organization by ID',
|
||||||
* Retrieve details of a specific organization.
|
description:
|
||||||
* Users can only view their own organization unless they are admins.
|
'Retrieve organization details. Users can view their own organization, admins can view any.',
|
||||||
*/
|
})
|
||||||
@Get(':id')
|
@ApiParam({
|
||||||
@ApiOperation({
|
name: 'id',
|
||||||
summary: 'Get organization by ID',
|
description: 'Organization ID (UUID)',
|
||||||
description:
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
'Retrieve organization details. Users can view their own organization, admins can view any.',
|
})
|
||||||
})
|
@ApiResponse({
|
||||||
@ApiParam({
|
status: HttpStatus.OK,
|
||||||
name: 'id',
|
description: 'Organization details retrieved successfully',
|
||||||
description: 'Organization ID (UUID)',
|
type: OrganizationResponseDto,
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
})
|
||||||
})
|
@ApiResponse({
|
||||||
@ApiResponse({
|
status: 401,
|
||||||
status: HttpStatus.OK,
|
description: 'Unauthorized - missing or invalid token',
|
||||||
description: 'Organization details retrieved successfully',
|
})
|
||||||
type: OrganizationResponseDto,
|
@ApiNotFoundResponse({
|
||||||
})
|
description: 'Organization not found',
|
||||||
@ApiResponse({
|
})
|
||||||
status: 401,
|
async getOrganization(
|
||||||
description: 'Unauthorized - missing or invalid token',
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
})
|
@CurrentUser() user: UserPayload
|
||||||
@ApiNotFoundResponse({
|
): Promise<OrganizationResponseDto> {
|
||||||
description: 'Organization not found',
|
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
|
||||||
})
|
|
||||||
async getOrganization(
|
const organization = await this.organizationRepository.findById(id);
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
if (!organization) {
|
||||||
@CurrentUser() user: UserPayload,
|
throw new NotFoundException(`Organization ${id} not found`);
|
||||||
): Promise<OrganizationResponseDto> {
|
}
|
||||||
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
|
|
||||||
|
// Authorization: Users can only view their own organization (unless admin)
|
||||||
const organization = await this.organizationRepository.findById(id);
|
if (user.role !== 'admin' && organization.id !== user.organizationId) {
|
||||||
if (!organization) {
|
throw new ForbiddenException('You can only view your own organization');
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
}
|
||||||
}
|
|
||||||
|
return OrganizationMapper.toDto(organization);
|
||||||
// Authorization: Users can only view their own organization (unless admin)
|
}
|
||||||
if (user.role !== 'admin' && organization.id !== user.organizationId) {
|
|
||||||
throw new ForbiddenException('You can only view your own organization');
|
/**
|
||||||
}
|
* Update organization
|
||||||
|
*
|
||||||
return OrganizationMapper.toDto(organization);
|
* Update organization details (name, address, logo, status).
|
||||||
}
|
* Requires admin or manager role.
|
||||||
|
*/
|
||||||
/**
|
@Patch(':id')
|
||||||
* Update organization
|
@Roles('admin', 'manager')
|
||||||
*
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
* Update organization details (name, address, logo, status).
|
@ApiOperation({
|
||||||
* Requires admin or manager role.
|
summary: 'Update organization',
|
||||||
*/
|
description:
|
||||||
@Patch(':id')
|
'Update organization details (name, address, logo, status). Requires admin or manager role.',
|
||||||
@Roles('admin', 'manager')
|
})
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@ApiParam({
|
||||||
@ApiOperation({
|
name: 'id',
|
||||||
summary: 'Update organization',
|
description: 'Organization ID (UUID)',
|
||||||
description:
|
})
|
||||||
'Update organization details (name, address, logo, status). Requires admin or manager role.',
|
@ApiResponse({
|
||||||
})
|
status: HttpStatus.OK,
|
||||||
@ApiParam({
|
description: 'Organization updated successfully',
|
||||||
name: 'id',
|
type: OrganizationResponseDto,
|
||||||
description: 'Organization ID (UUID)',
|
})
|
||||||
})
|
@ApiResponse({
|
||||||
@ApiResponse({
|
status: 401,
|
||||||
status: HttpStatus.OK,
|
description: 'Unauthorized - missing or invalid token',
|
||||||
description: 'Organization updated successfully',
|
})
|
||||||
type: OrganizationResponseDto,
|
@ApiResponse({
|
||||||
})
|
status: 403,
|
||||||
@ApiResponse({
|
description: 'Forbidden - requires admin or manager role',
|
||||||
status: 401,
|
})
|
||||||
description: 'Unauthorized - missing or invalid token',
|
@ApiNotFoundResponse({
|
||||||
})
|
description: 'Organization not found',
|
||||||
@ApiResponse({
|
})
|
||||||
status: 403,
|
async updateOrganization(
|
||||||
description: 'Forbidden - requires admin or manager role',
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
})
|
@Body() dto: UpdateOrganizationDto,
|
||||||
@ApiNotFoundResponse({
|
@CurrentUser() user: UserPayload
|
||||||
description: 'Organization not found',
|
): Promise<OrganizationResponseDto> {
|
||||||
})
|
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
|
||||||
async updateOrganization(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
const organization = await this.organizationRepository.findById(id);
|
||||||
@Body() dto: UpdateOrganizationDto,
|
if (!organization) {
|
||||||
@CurrentUser() user: UserPayload,
|
throw new NotFoundException(`Organization ${id} not found`);
|
||||||
): Promise<OrganizationResponseDto> {
|
}
|
||||||
this.logger.log(
|
|
||||||
`[User: ${user.email}] Updating organization: ${id}`,
|
// Authorization: Managers can only update their own organization
|
||||||
);
|
if (user.role === 'manager' && organization.id !== user.organizationId) {
|
||||||
|
throw new ForbiddenException('You can only update your own organization');
|
||||||
const organization = await this.organizationRepository.findById(id);
|
}
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
// Update fields
|
||||||
}
|
if (dto.name) {
|
||||||
|
organization.updateName(dto.name);
|
||||||
// Authorization: Managers can only update their own organization
|
}
|
||||||
if (user.role === 'manager' && organization.id !== user.organizationId) {
|
|
||||||
throw new ForbiddenException('You can only update your own organization');
|
if (dto.address) {
|
||||||
}
|
organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address));
|
||||||
|
}
|
||||||
// Update fields
|
|
||||||
if (dto.name) {
|
if (dto.logoUrl !== undefined) {
|
||||||
organization.updateName(dto.name);
|
organization.updateLogoUrl(dto.logoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.address) {
|
if (dto.isActive !== undefined) {
|
||||||
organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address));
|
if (dto.isActive) {
|
||||||
}
|
organization.activate();
|
||||||
|
} else {
|
||||||
if (dto.logoUrl !== undefined) {
|
organization.deactivate();
|
||||||
organization.updateLogoUrl(dto.logoUrl);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.isActive !== undefined) {
|
// Save updated organization
|
||||||
if (dto.isActive) {
|
const updatedOrg = await this.organizationRepository.save(organization);
|
||||||
organization.activate();
|
|
||||||
} else {
|
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
|
||||||
organization.deactivate();
|
|
||||||
}
|
return OrganizationMapper.toDto(updatedOrg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save updated organization
|
/**
|
||||||
const updatedOrg = await this.organizationRepository.save(organization);
|
* List organizations
|
||||||
|
*
|
||||||
this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);
|
* Retrieve a paginated list of organizations.
|
||||||
|
* Admins can see all, others see only their own.
|
||||||
return OrganizationMapper.toDto(updatedOrg);
|
*/
|
||||||
}
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
/**
|
summary: 'List organizations',
|
||||||
* List organizations
|
description:
|
||||||
*
|
'Retrieve a paginated list of organizations. Admins see all, others see only their own.',
|
||||||
* Retrieve a paginated list of organizations.
|
})
|
||||||
* Admins can see all, others see only their own.
|
@ApiQuery({
|
||||||
*/
|
name: 'page',
|
||||||
@Get()
|
required: false,
|
||||||
@ApiOperation({
|
description: 'Page number (1-based)',
|
||||||
summary: 'List organizations',
|
example: 1,
|
||||||
description:
|
})
|
||||||
'Retrieve a paginated list of organizations. Admins see all, others see only their own.',
|
@ApiQuery({
|
||||||
})
|
name: 'pageSize',
|
||||||
@ApiQuery({
|
required: false,
|
||||||
name: 'page',
|
description: 'Number of items per page',
|
||||||
required: false,
|
example: 20,
|
||||||
description: 'Page number (1-based)',
|
})
|
||||||
example: 1,
|
@ApiQuery({
|
||||||
})
|
name: 'type',
|
||||||
@ApiQuery({
|
required: false,
|
||||||
name: 'pageSize',
|
description: 'Filter by organization type',
|
||||||
required: false,
|
enum: OrganizationType,
|
||||||
description: 'Number of items per page',
|
})
|
||||||
example: 20,
|
@ApiResponse({
|
||||||
})
|
status: HttpStatus.OK,
|
||||||
@ApiQuery({
|
description: 'Organizations list retrieved successfully',
|
||||||
name: 'type',
|
type: OrganizationListResponseDto,
|
||||||
required: false,
|
})
|
||||||
description: 'Filter by organization type',
|
@ApiResponse({
|
||||||
enum: OrganizationType,
|
status: 401,
|
||||||
})
|
description: 'Unauthorized - missing or invalid token',
|
||||||
@ApiResponse({
|
})
|
||||||
status: HttpStatus.OK,
|
async listOrganizations(
|
||||||
description: 'Organizations list retrieved successfully',
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
type: OrganizationListResponseDto,
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||||
})
|
@Query('type') type: OrganizationType | undefined,
|
||||||
@ApiResponse({
|
@CurrentUser() user: UserPayload
|
||||||
status: 401,
|
): Promise<OrganizationListResponseDto> {
|
||||||
description: 'Unauthorized - missing or invalid token',
|
this.logger.log(
|
||||||
})
|
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`
|
||||||
async listOrganizations(
|
);
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
||||||
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
// Fetch organizations
|
||||||
@Query('type') type: OrganizationType | undefined,
|
let organizations: Organization[];
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<OrganizationListResponseDto> {
|
if (user.role === 'admin') {
|
||||||
this.logger.log(
|
// Admins can see all organizations
|
||||||
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`,
|
organizations = await this.organizationRepository.findAll();
|
||||||
);
|
} else {
|
||||||
|
// Others see only their own organization
|
||||||
// Fetch organizations
|
const userOrg = await this.organizationRepository.findById(user.organizationId);
|
||||||
let organizations: Organization[];
|
organizations = userOrg ? [userOrg] : [];
|
||||||
|
}
|
||||||
if (user.role === 'admin') {
|
|
||||||
// Admins can see all organizations
|
// Filter by type if provided
|
||||||
organizations = await this.organizationRepository.findAll();
|
const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations;
|
||||||
} else {
|
|
||||||
// Others see only their own organization
|
// Paginate
|
||||||
const userOrg = await this.organizationRepository.findById(user.organizationId);
|
const startIndex = (page - 1) * pageSize;
|
||||||
organizations = userOrg ? [userOrg] : [];
|
const endIndex = startIndex + pageSize;
|
||||||
}
|
const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Filter by type if provided
|
// Convert to DTOs
|
||||||
const filteredOrgs = type
|
const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs);
|
||||||
? organizations.filter(org => org.type === type)
|
|
||||||
: organizations;
|
const totalPages = Math.ceil(filteredOrgs.length / pageSize);
|
||||||
|
|
||||||
// Paginate
|
return {
|
||||||
const startIndex = (page - 1) * pageSize;
|
organizations: orgDtos,
|
||||||
const endIndex = startIndex + pageSize;
|
total: filteredOrgs.length,
|
||||||
const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex);
|
page,
|
||||||
|
pageSize,
|
||||||
// Convert to DTOs
|
totalPages,
|
||||||
const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs);
|
};
|
||||||
|
}
|
||||||
const totalPages = Math.ceil(filteredOrgs.length / pageSize);
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
organizations: orgDtos,
|
|
||||||
total: filteredOrgs.length,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,267 +1,262 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Body,
|
Body,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
ApiInternalServerErrorResponse,
|
ApiInternalServerErrorResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
||||||
import { RateQuoteMapper } from '../mappers';
|
import { RateQuoteMapper } from '../mappers';
|
||||||
import { RateSearchService } from '../../domain/services/rate-search.service';
|
import { RateSearchService } from '../../domain/services/rate-search.service';
|
||||||
import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service';
|
import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||||
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
|
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
|
||||||
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
||||||
|
|
||||||
@ApiTags('Rates')
|
@ApiTags('Rates')
|
||||||
@Controller('rates')
|
@Controller('rates')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class RatesController {
|
export class RatesController {
|
||||||
private readonly logger = new Logger(RatesController.name);
|
private readonly logger = new Logger(RatesController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rateSearchService: RateSearchService,
|
private readonly rateSearchService: RateSearchService,
|
||||||
private readonly csvRateSearchService: CsvRateSearchService,
|
private readonly csvRateSearchService: CsvRateSearchService,
|
||||||
private readonly csvRateMapper: CsvRateMapper,
|
private readonly csvRateMapper: CsvRateMapper
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Search shipping rates',
|
summary: 'Search shipping rates',
|
||||||
description:
|
description:
|
||||||
'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.',
|
'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.OK,
|
status: HttpStatus.OK,
|
||||||
description: 'Rate search completed successfully',
|
description: 'Rate search completed successfully',
|
||||||
type: RateSearchResponseDto,
|
type: RateSearchResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 401,
|
status: 401,
|
||||||
description: 'Unauthorized - missing or invalid token',
|
description: 'Unauthorized - missing or invalid token',
|
||||||
})
|
})
|
||||||
@ApiBadRequestResponse({
|
@ApiBadRequestResponse({
|
||||||
description: 'Invalid request parameters',
|
description: 'Invalid request parameters',
|
||||||
schema: {
|
schema: {
|
||||||
example: {
|
example: {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'],
|
message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'],
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiInternalServerErrorResponse({
|
@ApiInternalServerErrorResponse({
|
||||||
description: 'Internal server error',
|
description: 'Internal server error',
|
||||||
})
|
})
|
||||||
async searchRates(
|
async searchRates(
|
||||||
@Body() dto: RateSearchRequestDto,
|
@Body() dto: RateSearchRequestDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<RateSearchResponseDto> {
|
): Promise<RateSearchResponseDto> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`,
|
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert DTO to domain input
|
// Convert DTO to domain input
|
||||||
const searchInput = {
|
const searchInput = {
|
||||||
origin: dto.origin,
|
origin: dto.origin,
|
||||||
destination: dto.destination,
|
destination: dto.destination,
|
||||||
containerType: dto.containerType,
|
containerType: dto.containerType,
|
||||||
mode: dto.mode,
|
mode: dto.mode,
|
||||||
departureDate: new Date(dto.departureDate),
|
departureDate: new Date(dto.departureDate),
|
||||||
quantity: dto.quantity,
|
quantity: dto.quantity,
|
||||||
weight: dto.weight,
|
weight: dto.weight,
|
||||||
volume: dto.volume,
|
volume: dto.volume,
|
||||||
isHazmat: dto.isHazmat,
|
isHazmat: dto.isHazmat,
|
||||||
imoClass: dto.imoClass,
|
imoClass: dto.imoClass,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute search
|
// Execute search
|
||||||
const result = await this.rateSearchService.execute(searchInput);
|
const result = await this.rateSearchService.execute(searchInput);
|
||||||
|
|
||||||
// Convert domain entities to DTOs
|
// Convert domain entities to DTOs
|
||||||
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
|
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
|
||||||
|
|
||||||
const responseTimeMs = Date.now() - startTime;
|
const responseTimeMs = Date.now() - startTime;
|
||||||
this.logger.log(
|
this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`);
|
||||||
`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`,
|
|
||||||
);
|
return {
|
||||||
|
quotes: quoteDtos,
|
||||||
return {
|
count: quoteDtos.length,
|
||||||
quotes: quoteDtos,
|
origin: dto.origin,
|
||||||
count: quoteDtos.length,
|
destination: dto.destination,
|
||||||
origin: dto.origin,
|
departureDate: dto.departureDate,
|
||||||
destination: dto.destination,
|
containerType: dto.containerType,
|
||||||
departureDate: dto.departureDate,
|
mode: dto.mode,
|
||||||
containerType: dto.containerType,
|
fromCache: false, // TODO: Implement cache detection
|
||||||
mode: dto.mode,
|
responseTimeMs,
|
||||||
fromCache: false, // TODO: Implement cache detection
|
};
|
||||||
responseTimeMs,
|
} catch (error: any) {
|
||||||
};
|
this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
||||||
} catch (error: any) {
|
throw error;
|
||||||
this.logger.error(
|
}
|
||||||
`Rate search failed: ${error?.message || 'Unknown error'}`,
|
}
|
||||||
error?.stack,
|
|
||||||
);
|
/**
|
||||||
throw error;
|
* Search CSV-based rates with advanced filters
|
||||||
}
|
*/
|
||||||
}
|
@Post('search-csv')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
/**
|
@HttpCode(HttpStatus.OK)
|
||||||
* Search CSV-based rates with advanced filters
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
*/
|
@ApiOperation({
|
||||||
@Post('search-csv')
|
summary: 'Search CSV-based rates with advanced filters',
|
||||||
@UseGuards(JwtAuthGuard)
|
description:
|
||||||
@HttpCode(HttpStatus.OK)
|
'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.',
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
})
|
||||||
@ApiOperation({
|
@ApiResponse({
|
||||||
summary: 'Search CSV-based rates with advanced filters',
|
status: HttpStatus.OK,
|
||||||
description:
|
description: 'CSV rate search completed successfully',
|
||||||
'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.',
|
type: CsvRateSearchResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.OK,
|
status: 401,
|
||||||
description: 'CSV rate search completed successfully',
|
description: 'Unauthorized - missing or invalid token',
|
||||||
type: CsvRateSearchResponseDto,
|
})
|
||||||
})
|
@ApiBadRequestResponse({
|
||||||
@ApiResponse({
|
description: 'Invalid request parameters',
|
||||||
status: 401,
|
})
|
||||||
description: 'Unauthorized - missing or invalid token',
|
async searchCsvRates(
|
||||||
})
|
@Body() dto: CsvRateSearchDto,
|
||||||
@ApiBadRequestResponse({
|
@CurrentUser() user: UserPayload
|
||||||
description: 'Invalid request parameters',
|
): Promise<CsvRateSearchResponseDto> {
|
||||||
})
|
const startTime = Date.now();
|
||||||
async searchCsvRates(
|
this.logger.log(
|
||||||
@Body() dto: CsvRateSearchDto,
|
`[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
|
||||||
@CurrentUser() user: UserPayload,
|
);
|
||||||
): Promise<CsvRateSearchResponseDto> {
|
|
||||||
const startTime = Date.now();
|
try {
|
||||||
this.logger.log(
|
// Map DTO to domain input
|
||||||
`[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`,
|
const searchInput = {
|
||||||
);
|
origin: dto.origin,
|
||||||
|
destination: dto.destination,
|
||||||
try {
|
volumeCBM: dto.volumeCBM,
|
||||||
// Map DTO to domain input
|
weightKG: dto.weightKG,
|
||||||
const searchInput = {
|
palletCount: dto.palletCount ?? 0,
|
||||||
origin: dto.origin,
|
containerType: dto.containerType,
|
||||||
destination: dto.destination,
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||||
volumeCBM: dto.volumeCBM,
|
};
|
||||||
weightKG: dto.weightKG,
|
|
||||||
palletCount: dto.palletCount ?? 0,
|
// Execute CSV rate search
|
||||||
containerType: dto.containerType,
|
const result = await this.csvRateSearchService.execute(searchInput);
|
||||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
|
||||||
};
|
// Map domain output to response DTO
|
||||||
|
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
|
||||||
// Execute CSV rate search
|
|
||||||
const result = await this.csvRateSearchService.execute(searchInput);
|
const responseTimeMs = Date.now() - startTime;
|
||||||
|
this.logger.log(
|
||||||
// Map domain output to response DTO
|
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`
|
||||||
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
|
);
|
||||||
|
|
||||||
const responseTimeMs = Date.now() - startTime;
|
return response;
|
||||||
this.logger.log(
|
} catch (error: any) {
|
||||||
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`,
|
this.logger.error(
|
||||||
);
|
`CSV rate search failed: ${error?.message || 'Unknown error'}`,
|
||||||
|
error?.stack
|
||||||
return response;
|
);
|
||||||
} catch (error: any) {
|
throw error;
|
||||||
this.logger.error(
|
}
|
||||||
`CSV rate search failed: ${error?.message || 'Unknown error'}`,
|
}
|
||||||
error?.stack,
|
|
||||||
);
|
/**
|
||||||
throw error;
|
* Get available companies
|
||||||
}
|
*/
|
||||||
}
|
@Get('companies')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
/**
|
@HttpCode(HttpStatus.OK)
|
||||||
* Get available companies
|
@ApiOperation({
|
||||||
*/
|
summary: 'Get available carrier companies',
|
||||||
@Get('companies')
|
description: 'Returns list of all available carrier companies in the CSV rate system.',
|
||||||
@UseGuards(JwtAuthGuard)
|
})
|
||||||
@HttpCode(HttpStatus.OK)
|
@ApiResponse({
|
||||||
@ApiOperation({
|
status: HttpStatus.OK,
|
||||||
summary: 'Get available carrier companies',
|
description: 'List of available companies',
|
||||||
description: 'Returns list of all available carrier companies in the CSV rate system.',
|
type: AvailableCompaniesDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
async getCompanies(): Promise<AvailableCompaniesDto> {
|
||||||
status: HttpStatus.OK,
|
this.logger.log('Fetching available companies');
|
||||||
description: 'List of available companies',
|
|
||||||
type: AvailableCompaniesDto,
|
try {
|
||||||
})
|
const companies = await this.csvRateSearchService.getAvailableCompanies();
|
||||||
async getCompanies(): Promise<AvailableCompaniesDto> {
|
|
||||||
this.logger.log('Fetching available companies');
|
return {
|
||||||
|
companies,
|
||||||
try {
|
total: companies.length,
|
||||||
const companies = await this.csvRateSearchService.getAvailableCompanies();
|
};
|
||||||
|
} catch (error: any) {
|
||||||
return {
|
this.logger.error(
|
||||||
companies,
|
`Failed to fetch companies: ${error?.message || 'Unknown error'}`,
|
||||||
total: companies.length,
|
error?.stack
|
||||||
};
|
);
|
||||||
} catch (error: any) {
|
throw error;
|
||||||
this.logger.error(
|
}
|
||||||
`Failed to fetch companies: ${error?.message || 'Unknown error'}`,
|
}
|
||||||
error?.stack,
|
|
||||||
);
|
/**
|
||||||
throw error;
|
* Get filter options
|
||||||
}
|
*/
|
||||||
}
|
@Get('filters/options')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
/**
|
@HttpCode(HttpStatus.OK)
|
||||||
* Get filter options
|
@ApiOperation({
|
||||||
*/
|
summary: 'Get available filter options',
|
||||||
@Get('filters/options')
|
description:
|
||||||
@UseGuards(JwtAuthGuard)
|
'Returns available options for all filters (companies, container types, currencies).',
|
||||||
@HttpCode(HttpStatus.OK)
|
})
|
||||||
@ApiOperation({
|
@ApiResponse({
|
||||||
summary: 'Get available filter options',
|
status: HttpStatus.OK,
|
||||||
description:
|
description: 'Available filter options',
|
||||||
'Returns available options for all filters (companies, container types, currencies).',
|
type: FilterOptionsDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
async getFilterOptions(): Promise<FilterOptionsDto> {
|
||||||
status: HttpStatus.OK,
|
this.logger.log('Fetching filter options');
|
||||||
description: 'Available filter options',
|
|
||||||
type: FilterOptionsDto,
|
try {
|
||||||
})
|
const [companies, containerTypes] = await Promise.all([
|
||||||
async getFilterOptions(): Promise<FilterOptionsDto> {
|
this.csvRateSearchService.getAvailableCompanies(),
|
||||||
this.logger.log('Fetching filter options');
|
this.csvRateSearchService.getAvailableContainerTypes(),
|
||||||
|
]);
|
||||||
try {
|
|
||||||
const [companies, containerTypes] = await Promise.all([
|
return {
|
||||||
this.csvRateSearchService.getAvailableCompanies(),
|
companies,
|
||||||
this.csvRateSearchService.getAvailableContainerTypes(),
|
containerTypes,
|
||||||
]);
|
currencies: ['USD', 'EUR'],
|
||||||
|
};
|
||||||
return {
|
} catch (error: any) {
|
||||||
companies,
|
this.logger.error(
|
||||||
containerTypes,
|
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
|
||||||
currencies: ['USD', 'EUR'],
|
error?.stack
|
||||||
};
|
);
|
||||||
} catch (error: any) {
|
throw error;
|
||||||
this.logger.error(
|
}
|
||||||
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
|
}
|
||||||
error?.stack,
|
}
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -16,13 +16,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
WebhookService,
|
||||||
ApiOperation,
|
CreateWebhookInput,
|
||||||
ApiResponse,
|
UpdateWebhookInput,
|
||||||
ApiBearerAuth,
|
} from '../services/webhook.service';
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service';
|
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
@ -74,7 +73,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 201, description: 'Webhook created successfully' })
|
@ApiResponse({ status: 201, description: 'Webhook created successfully' })
|
||||||
async createWebhook(
|
async createWebhook(
|
||||||
@Body() dto: CreateWebhookDto,
|
@Body() dto: CreateWebhookDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<WebhookResponseDto> {
|
): Promise<WebhookResponseDto> {
|
||||||
const input: CreateWebhookInput = {
|
const input: CreateWebhookInput = {
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
@ -96,10 +95,8 @@ export class WebhooksController {
|
|||||||
@ApiOperation({ summary: 'Get all webhooks for organization' })
|
@ApiOperation({ summary: 'Get all webhooks for organization' })
|
||||||
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
|
||||||
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
|
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
|
||||||
const webhooks = await this.webhookService.getWebhooksByOrganization(
|
const webhooks = await this.webhookService.getWebhooksByOrganization(user.organizationId);
|
||||||
user.organizationId,
|
return webhooks.map(w => this.mapToDto(w));
|
||||||
);
|
|
||||||
return webhooks.map((w) => this.mapToDto(w));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,7 +109,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async getWebhookById(
|
async getWebhookById(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<WebhookResponseDto> {
|
): Promise<WebhookResponseDto> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -139,7 +136,7 @@ export class WebhooksController {
|
|||||||
async updateWebhook(
|
async updateWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateWebhookDto,
|
@Body() dto: UpdateWebhookDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<WebhookResponseDto> {
|
): Promise<WebhookResponseDto> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -166,7 +163,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async activateWebhook(
|
async activateWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -193,7 +190,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async deactivateWebhook(
|
async deactivateWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -220,7 +217,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async deleteWebhook(
|
async deleteWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
|||||||
@ -1,42 +1,42 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User payload interface extracted from JWT
|
* User payload interface extracted from JWT
|
||||||
*/
|
*/
|
||||||
export interface UserPayload {
|
export interface UserPayload {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CurrentUser Decorator
|
* CurrentUser Decorator
|
||||||
*
|
*
|
||||||
* Extracts the authenticated user from the request object.
|
* Extracts the authenticated user from the request object.
|
||||||
* Must be used with JwtAuthGuard.
|
* Must be used with JwtAuthGuard.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @UseGuards(JwtAuthGuard)
|
* @UseGuards(JwtAuthGuard)
|
||||||
* @Get('me')
|
* @Get('me')
|
||||||
* getProfile(@CurrentUser() user: UserPayload) {
|
* getProfile(@CurrentUser() user: UserPayload) {
|
||||||
* return user;
|
* return user;
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* You can also extract a specific property:
|
* You can also extract a specific property:
|
||||||
* @Get('my-bookings')
|
* @Get('my-bookings')
|
||||||
* getMyBookings(@CurrentUser('id') userId: string) {
|
* getMyBookings(@CurrentUser('id') userId: string) {
|
||||||
* return this.bookingService.findByUserId(userId);
|
* return this.bookingService.findByUserId(userId);
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
(data: keyof UserPayload | undefined, ctx: ExecutionContext) => {
|
(data: keyof UserPayload | undefined, ctx: ExecutionContext) => {
|
||||||
const request = ctx.switchToHttp().getRequest();
|
const request = ctx.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
|
|
||||||
// If a specific property is requested, return only that property
|
// If a specific property is requested, return only that property
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export * from './current-user.decorator';
|
export * from './current-user.decorator';
|
||||||
export * from './public.decorator';
|
export * from './public.decorator';
|
||||||
export * from './roles.decorator';
|
export * from './roles.decorator';
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public Decorator
|
* Public Decorator
|
||||||
*
|
*
|
||||||
* Marks a route as public, bypassing JWT authentication.
|
* Marks a route as public, bypassing JWT authentication.
|
||||||
* Use this for routes that should be accessible without a token.
|
* Use this for routes that should be accessible without a token.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @Public()
|
* @Public()
|
||||||
* @Post('login')
|
* @Post('login')
|
||||||
* login(@Body() dto: LoginDto) {
|
* login(@Body() dto: LoginDto) {
|
||||||
* return this.authService.login(dto.email, dto.password);
|
* return this.authService.login(dto.email, dto.password);
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const Public = () => SetMetadata('isPublic', true);
|
export const Public = () => SetMetadata('isPublic', true);
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roles Decorator
|
* Roles Decorator
|
||||||
*
|
*
|
||||||
* Specifies which roles are allowed to access a route.
|
* Specifies which roles are allowed to access a route.
|
||||||
* Must be used with both JwtAuthGuard and RolesGuard.
|
* Must be used with both JwtAuthGuard and RolesGuard.
|
||||||
*
|
*
|
||||||
* Available roles:
|
* Available roles:
|
||||||
* - 'admin': Full system access
|
* - 'admin': Full system access
|
||||||
* - 'manager': Manage bookings and users within organization
|
* - 'manager': Manage bookings and users within organization
|
||||||
* - 'user': Create and view bookings
|
* - 'user': Create and view bookings
|
||||||
* - 'viewer': Read-only access
|
* - 'viewer': Read-only access
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @UseGuards(JwtAuthGuard, RolesGuard)
|
* @UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
* @Roles('admin', 'manager')
|
* @Roles('admin', 'manager')
|
||||||
* @Delete('bookings/:id')
|
* @Delete('bookings/:id')
|
||||||
* deleteBooking(@Param('id') id: string) {
|
* deleteBooking(@Param('id') id: string) {
|
||||||
* return this.bookingService.delete(id);
|
* return this.bookingService.delete(id);
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
|
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
|
||||||
|
|||||||
@ -1,106 +1,106 @@
|
|||||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'john.doe@acme.com',
|
example: 'john.doe@acme.com',
|
||||||
description: 'Email address',
|
description: 'Email address',
|
||||||
})
|
})
|
||||||
@IsEmail({}, { message: 'Invalid email format' })
|
@IsEmail({}, { message: 'Invalid email format' })
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'SecurePassword123!',
|
example: 'SecurePassword123!',
|
||||||
description: 'Password (minimum 12 characters)',
|
description: 'Password (minimum 12 characters)',
|
||||||
minLength: 12,
|
minLength: 12,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'john.doe@acme.com',
|
example: 'john.doe@acme.com',
|
||||||
description: 'Email address',
|
description: 'Email address',
|
||||||
})
|
})
|
||||||
@IsEmail({}, { message: 'Invalid email format' })
|
@IsEmail({}, { message: 'Invalid email format' })
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'SecurePassword123!',
|
example: 'SecurePassword123!',
|
||||||
description: 'Password (minimum 12 characters)',
|
description: 'Password (minimum 12 characters)',
|
||||||
minLength: 12,
|
minLength: 12,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'John',
|
example: 'John',
|
||||||
description: 'First name',
|
description: 'First name',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2, { message: 'First name must be at least 2 characters' })
|
@MinLength(2, { message: 'First name must be at least 2 characters' })
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Doe',
|
example: 'Doe',
|
||||||
description: 'Last name',
|
description: 'Last name',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2, { message: 'Last name must be at least 2 characters' })
|
@MinLength(2, { message: 'Last name must be at least 2 characters' })
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
description: 'Organization ID (optional, will create default organization if not provided)',
|
description: 'Organization ID (optional, will create default organization if not provided)',
|
||||||
required: false,
|
required: false,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthResponseDto {
|
export class AuthResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
description: 'JWT access token (valid 15 minutes)',
|
description: 'JWT access token (valid 15 minutes)',
|
||||||
})
|
})
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
description: 'JWT refresh token (valid 7 days)',
|
description: 'JWT refresh token (valid 7 days)',
|
||||||
})
|
})
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: {
|
example: {
|
||||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
email: 'john.doe@acme.com',
|
email: 'john.doe@acme.com',
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
organizationId: '550e8400-e29b-41d4-a716-446655440001',
|
organizationId: '550e8400-e29b-41d4-a716-446655440001',
|
||||||
},
|
},
|
||||||
description: 'User information',
|
description: 'User information',
|
||||||
})
|
})
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RefreshTokenDto {
|
export class RefreshTokenDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
description: 'Refresh token',
|
description: 'Refresh token',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,184 +1,184 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { PortDto, PricingDto } from './rate-search-response.dto';
|
import { PortDto, PricingDto } from './rate-search-response.dto';
|
||||||
|
|
||||||
export class BookingAddressDto {
|
export class BookingAddressDto {
|
||||||
@ApiProperty({ example: '123 Main Street' })
|
@ApiProperty({ example: '123 Main Street' })
|
||||||
street: string;
|
street: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Rotterdam' })
|
@ApiProperty({ example: 'Rotterdam' })
|
||||||
city: string;
|
city: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '3000 AB' })
|
@ApiProperty({ example: '3000 AB' })
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'NL' })
|
@ApiProperty({ example: 'NL' })
|
||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingPartyDto {
|
export class BookingPartyDto {
|
||||||
@ApiProperty({ example: 'Acme Corporation' })
|
@ApiProperty({ example: 'Acme Corporation' })
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiProperty({ type: BookingAddressDto })
|
@ApiProperty({ type: BookingAddressDto })
|
||||||
address: BookingAddressDto;
|
address: BookingAddressDto;
|
||||||
|
|
||||||
@ApiProperty({ example: 'John Doe' })
|
@ApiProperty({ example: 'John Doe' })
|
||||||
contactName: string;
|
contactName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'john.doe@acme.com' })
|
@ApiProperty({ example: 'john.doe@acme.com' })
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '+31612345678' })
|
@ApiProperty({ example: '+31612345678' })
|
||||||
contactPhone: string;
|
contactPhone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingContainerDto {
|
export class BookingContainerDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '40HC' })
|
@ApiProperty({ example: '40HC' })
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'ABCU1234567' })
|
@ApiPropertyOptional({ example: 'ABCU1234567' })
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 22000 })
|
@ApiPropertyOptional({ example: 22000 })
|
||||||
vgm?: number;
|
vgm?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: -18 })
|
@ApiPropertyOptional({ example: -18 })
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'SEAL123456' })
|
@ApiPropertyOptional({ example: 'SEAL123456' })
|
||||||
sealNumber?: string;
|
sealNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingRateQuoteDto {
|
export class BookingRateQuoteDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Maersk Line' })
|
@ApiProperty({ example: 'Maersk Line' })
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'MAERSK' })
|
@ApiProperty({ example: 'MAERSK' })
|
||||||
carrierCode: string;
|
carrierCode: string;
|
||||||
|
|
||||||
@ApiProperty({ type: PortDto })
|
@ApiProperty({ type: PortDto })
|
||||||
origin: PortDto;
|
origin: PortDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortDto })
|
@ApiProperty({ type: PortDto })
|
||||||
destination: PortDto;
|
destination: PortDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PricingDto })
|
@ApiProperty({ type: PricingDto })
|
||||||
pricing: PricingDto;
|
pricing: PricingDto;
|
||||||
|
|
||||||
@ApiProperty({ example: '40HC' })
|
@ApiProperty({ example: '40HC' })
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'FCL' })
|
@ApiProperty({ example: 'FCL' })
|
||||||
mode: string;
|
mode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
||||||
etd: string;
|
etd: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-03-17T14:00:00Z' })
|
@ApiProperty({ example: '2025-03-17T14:00:00Z' })
|
||||||
eta: string;
|
eta: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 30 })
|
@ApiProperty({ example: 30 })
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingResponseDto {
|
export class BookingResponseDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'WCM-2025-ABC123', description: 'Unique booking number' })
|
@ApiProperty({ example: 'WCM-2025-ABC123', description: 'Unique booking number' })
|
||||||
bookingNumber: string;
|
bookingNumber: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'draft',
|
example: 'draft',
|
||||||
enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'],
|
enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'],
|
||||||
})
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ApiProperty({ type: BookingPartyDto })
|
@ApiProperty({ type: BookingPartyDto })
|
||||||
shipper: BookingPartyDto;
|
shipper: BookingPartyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BookingPartyDto })
|
@ApiProperty({ type: BookingPartyDto })
|
||||||
consignee: BookingPartyDto;
|
consignee: BookingPartyDto;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Electronics and consumer goods' })
|
@ApiProperty({ example: 'Electronics and consumer goods' })
|
||||||
cargoDescription: string;
|
cargoDescription: string;
|
||||||
|
|
||||||
@ApiProperty({ type: [BookingContainerDto] })
|
@ApiProperty({ type: [BookingContainerDto] })
|
||||||
containers: BookingContainerDto[];
|
containers: BookingContainerDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Please handle with care. Delivery before 5 PM.' })
|
@ApiPropertyOptional({ example: 'Please handle with care. Delivery before 5 PM.' })
|
||||||
specialInstructions?: string;
|
specialInstructions?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: BookingRateQuoteDto, description: 'Associated rate quote details' })
|
@ApiProperty({ type: BookingRateQuoteDto, description: 'Associated rate quote details' })
|
||||||
rateQuote: BookingRateQuoteDto;
|
rateQuote: BookingRateQuoteDto;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingListItemDto {
|
export class BookingListItemDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'WCM-2025-ABC123' })
|
@ApiProperty({ example: 'WCM-2025-ABC123' })
|
||||||
bookingNumber: string;
|
bookingNumber: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'draft' })
|
@ApiProperty({ example: 'draft' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Acme Corporation' })
|
@ApiProperty({ example: 'Acme Corporation' })
|
||||||
shipperName: string;
|
shipperName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Shanghai Imports Ltd' })
|
@ApiProperty({ example: 'Shanghai Imports Ltd' })
|
||||||
consigneeName: string;
|
consigneeName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'NLRTM' })
|
@ApiProperty({ example: 'NLRTM' })
|
||||||
originPort: string;
|
originPort: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'CNSHA' })
|
@ApiProperty({ example: 'CNSHA' })
|
||||||
destinationPort: string;
|
destinationPort: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Maersk Line' })
|
@ApiProperty({ example: 'Maersk Line' })
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
||||||
etd: string;
|
etd: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-03-17T14:00:00Z' })
|
@ApiProperty({ example: '2025-03-17T14:00:00Z' })
|
||||||
eta: string;
|
eta: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 1700.0 })
|
@ApiProperty({ example: 1700.0 })
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'USD' })
|
@ApiProperty({ example: 'USD' })
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingListResponseDto {
|
export class BookingListResponseDto {
|
||||||
@ApiProperty({ type: [BookingListItemDto] })
|
@ApiProperty({ type: [BookingListItemDto] })
|
||||||
bookings: BookingListItemDto[];
|
bookings: BookingListItemDto[];
|
||||||
|
|
||||||
@ApiProperty({ example: 25, description: 'Total number of bookings' })
|
@ApiProperty({ example: 25, description: 'Total number of bookings' })
|
||||||
total: number;
|
total: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 1, description: 'Current page number' })
|
@ApiProperty({ example: 1, description: 'Current page number' })
|
||||||
page: number;
|
page: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 20, description: 'Items per page' })
|
@ApiProperty({ example: 20, description: 'Items per page' })
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 2, description: 'Total number of pages' })
|
@ApiProperty({ example: 2, description: 'Total number of pages' })
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,119 +1,135 @@
|
|||||||
import { IsString, IsUUID, IsOptional, ValidateNested, IsArray, IsEmail, Matches, MinLength } from 'class-validator';
|
import {
|
||||||
import { Type } from 'class-transformer';
|
IsString,
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
export class AddressDto {
|
ValidateNested,
|
||||||
@ApiProperty({ example: '123 Main Street' })
|
IsArray,
|
||||||
@IsString()
|
IsEmail,
|
||||||
@MinLength(5, { message: 'Street must be at least 5 characters' })
|
Matches,
|
||||||
street: string;
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
@ApiProperty({ example: 'Rotterdam' })
|
import { Type } from 'class-transformer';
|
||||||
@IsString()
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
@MinLength(2, { message: 'City must be at least 2 characters' })
|
|
||||||
city: string;
|
export class AddressDto {
|
||||||
|
@ApiProperty({ example: '123 Main Street' })
|
||||||
@ApiProperty({ example: '3000 AB' })
|
@IsString()
|
||||||
@IsString()
|
@MinLength(5, { message: 'Street must be at least 5 characters' })
|
||||||
postalCode: string;
|
street: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' })
|
@ApiProperty({ example: 'Rotterdam' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' })
|
@MinLength(2, { message: 'City must be at least 2 characters' })
|
||||||
country: string;
|
city: string;
|
||||||
}
|
|
||||||
|
@ApiProperty({ example: '3000 AB' })
|
||||||
export class PartyDto {
|
@IsString()
|
||||||
@ApiProperty({ example: 'Acme Corporation' })
|
postalCode: string;
|
||||||
@IsString()
|
|
||||||
@MinLength(2, { message: 'Name must be at least 2 characters' })
|
@ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' })
|
||||||
name: string;
|
@IsString()
|
||||||
|
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' })
|
||||||
@ApiProperty({ type: AddressDto })
|
country: string;
|
||||||
@ValidateNested()
|
}
|
||||||
@Type(() => AddressDto)
|
|
||||||
address: AddressDto;
|
export class PartyDto {
|
||||||
|
@ApiProperty({ example: 'Acme Corporation' })
|
||||||
@ApiProperty({ example: 'John Doe' })
|
@IsString()
|
||||||
@IsString()
|
@MinLength(2, { message: 'Name must be at least 2 characters' })
|
||||||
@MinLength(2, { message: 'Contact name must be at least 2 characters' })
|
name: string;
|
||||||
contactName: string;
|
|
||||||
|
@ApiProperty({ type: AddressDto })
|
||||||
@ApiProperty({ example: 'john.doe@acme.com' })
|
@ValidateNested()
|
||||||
@IsEmail({}, { message: 'Contact email must be a valid email address' })
|
@Type(() => AddressDto)
|
||||||
contactEmail: string;
|
address: AddressDto;
|
||||||
|
|
||||||
@ApiProperty({ example: '+31612345678' })
|
@ApiProperty({ example: 'John Doe' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Contact phone must be a valid international phone number' })
|
@MinLength(2, { message: 'Contact name must be at least 2 characters' })
|
||||||
contactPhone: string;
|
contactName: string;
|
||||||
}
|
|
||||||
|
@ApiProperty({ example: 'john.doe@acme.com' })
|
||||||
export class ContainerDto {
|
@IsEmail({}, { message: 'Contact email must be a valid email address' })
|
||||||
@ApiProperty({ example: '40HC', description: 'Container type' })
|
contactEmail: string;
|
||||||
@IsString()
|
|
||||||
type: string;
|
@ApiProperty({ example: '+31612345678' })
|
||||||
|
@IsString()
|
||||||
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
|
@Matches(/^\+?[1-9]\d{1,14}$/, {
|
||||||
@IsOptional()
|
message: 'Contact phone must be a valid international phone number',
|
||||||
@IsString()
|
})
|
||||||
@Matches(/^[A-Z]{4}\d{7}$/, { message: 'Container number must be 4 letters followed by 7 digits' })
|
contactPhone: string;
|
||||||
containerNumber?: string;
|
}
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
|
export class ContainerDto {
|
||||||
@IsOptional()
|
@ApiProperty({ example: '40HC', description: 'Container type' })
|
||||||
vgm?: number;
|
@IsString()
|
||||||
|
type: string;
|
||||||
@ApiPropertyOptional({ example: -18, description: 'Temperature in Celsius (for reefer containers)' })
|
|
||||||
@IsOptional()
|
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
|
||||||
temperature?: number;
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
@ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' })
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
@IsOptional()
|
message: 'Container number must be 4 letters followed by 7 digits',
|
||||||
@IsString()
|
})
|
||||||
sealNumber?: string;
|
containerNumber?: string;
|
||||||
}
|
|
||||||
|
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
|
||||||
export class CreateBookingRequestDto {
|
@IsOptional()
|
||||||
@ApiProperty({
|
vgm?: number;
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
description: 'Rate quote ID from previous search'
|
@ApiPropertyOptional({
|
||||||
})
|
example: -18,
|
||||||
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
|
description: 'Temperature in Celsius (for reefer containers)',
|
||||||
rateQuoteId: string;
|
})
|
||||||
|
@IsOptional()
|
||||||
@ApiProperty({ type: PartyDto, description: 'Shipper details' })
|
temperature?: number;
|
||||||
@ValidateNested()
|
|
||||||
@Type(() => PartyDto)
|
@ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' })
|
||||||
shipper: PartyDto;
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
@ApiProperty({ type: PartyDto, description: 'Consignee details' })
|
sealNumber?: string;
|
||||||
@ValidateNested()
|
}
|
||||||
@Type(() => PartyDto)
|
|
||||||
consignee: PartyDto;
|
export class CreateBookingRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
example: 'Electronics and consumer goods',
|
description: 'Rate quote ID from previous search',
|
||||||
description: 'Cargo description'
|
})
|
||||||
})
|
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
|
||||||
@IsString()
|
rateQuoteId: string;
|
||||||
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
|
|
||||||
cargoDescription: string;
|
@ApiProperty({ type: PartyDto, description: 'Shipper details' })
|
||||||
|
@ValidateNested()
|
||||||
@ApiProperty({
|
@Type(() => PartyDto)
|
||||||
type: [ContainerDto],
|
shipper: PartyDto;
|
||||||
description: 'Container details (can be empty for initial booking)'
|
|
||||||
})
|
@ApiProperty({ type: PartyDto, description: 'Consignee details' })
|
||||||
@IsArray()
|
@ValidateNested()
|
||||||
@ValidateNested({ each: true })
|
@Type(() => PartyDto)
|
||||||
@Type(() => ContainerDto)
|
consignee: PartyDto;
|
||||||
containers: ContainerDto[];
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiPropertyOptional({
|
example: 'Electronics and consumer goods',
|
||||||
example: 'Please handle with care. Delivery before 5 PM.',
|
description: 'Cargo description',
|
||||||
description: 'Special instructions for the carrier'
|
})
|
||||||
})
|
@IsString()
|
||||||
@IsOptional()
|
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
|
||||||
@IsString()
|
cargoDescription: string;
|
||||||
specialInstructions?: string;
|
|
||||||
}
|
@ApiProperty({
|
||||||
|
type: [ContainerDto],
|
||||||
|
description: 'Container details (can be empty for initial booking)',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ContainerDto)
|
||||||
|
containers: ContainerDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Please handle with care. Delivery before 5 PM.',
|
||||||
|
description: 'Special instructions for the carrier',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
specialInstructions?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,211 +1,204 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
|
||||||
IsNotEmpty,
|
import { Type } from 'class-transformer';
|
||||||
IsString,
|
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
||||||
IsNumber,
|
|
||||||
Min,
|
/**
|
||||||
IsOptional,
|
* CSV Rate Search Request DTO
|
||||||
ValidateNested,
|
*
|
||||||
} from 'class-validator';
|
* Request body for searching rates in CSV-based system
|
||||||
import { Type } from 'class-transformer';
|
* Includes basic search parameters + optional advanced filters
|
||||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
*/
|
||||||
|
export class CsvRateSearchDto {
|
||||||
/**
|
@ApiProperty({
|
||||||
* CSV Rate Search Request DTO
|
description: 'Origin port code (UN/LOCODE format)',
|
||||||
*
|
example: 'NLRTM',
|
||||||
* Request body for searching rates in CSV-based system
|
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
||||||
* Includes basic search parameters + optional advanced filters
|
})
|
||||||
*/
|
@IsNotEmpty()
|
||||||
export class CsvRateSearchDto {
|
@IsString()
|
||||||
@ApiProperty({
|
origin: string;
|
||||||
description: 'Origin port code (UN/LOCODE format)',
|
|
||||||
example: 'NLRTM',
|
@ApiProperty({
|
||||||
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
description: 'Destination port code (UN/LOCODE format)',
|
||||||
})
|
example: 'USNYC',
|
||||||
@IsNotEmpty()
|
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
||||||
@IsString()
|
})
|
||||||
origin: string;
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
@ApiProperty({
|
destination: string;
|
||||||
description: 'Destination port code (UN/LOCODE format)',
|
|
||||||
example: 'USNYC',
|
@ApiProperty({
|
||||||
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
description: 'Volume in cubic meters (CBM)',
|
||||||
})
|
minimum: 0.01,
|
||||||
@IsNotEmpty()
|
example: 25.5,
|
||||||
@IsString()
|
})
|
||||||
destination: string;
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
@ApiProperty({
|
@Min(0.01)
|
||||||
description: 'Volume in cubic meters (CBM)',
|
volumeCBM: number;
|
||||||
minimum: 0.01,
|
|
||||||
example: 25.5,
|
@ApiProperty({
|
||||||
})
|
description: 'Weight in kilograms',
|
||||||
@IsNotEmpty()
|
minimum: 1,
|
||||||
@IsNumber()
|
example: 3500,
|
||||||
@Min(0.01)
|
})
|
||||||
volumeCBM: number;
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
@ApiProperty({
|
@Min(1)
|
||||||
description: 'Weight in kilograms',
|
weightKG: number;
|
||||||
minimum: 1,
|
|
||||||
example: 3500,
|
@ApiPropertyOptional({
|
||||||
})
|
description: 'Number of pallets (0 if no pallets)',
|
||||||
@IsNotEmpty()
|
minimum: 0,
|
||||||
@IsNumber()
|
example: 10,
|
||||||
@Min(1)
|
default: 0,
|
||||||
weightKG: number;
|
})
|
||||||
|
@IsOptional()
|
||||||
@ApiPropertyOptional({
|
@IsNumber()
|
||||||
description: 'Number of pallets (0 if no pallets)',
|
@Min(0)
|
||||||
minimum: 0,
|
palletCount?: number;
|
||||||
example: 10,
|
|
||||||
default: 0,
|
@ApiPropertyOptional({
|
||||||
})
|
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
|
||||||
@IsOptional()
|
example: 'LCL',
|
||||||
@IsNumber()
|
})
|
||||||
@Min(0)
|
@IsOptional()
|
||||||
palletCount?: number;
|
@IsString()
|
||||||
|
containerType?: string;
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
|
@ApiPropertyOptional({
|
||||||
example: 'LCL',
|
description: 'Advanced filters for narrowing results',
|
||||||
})
|
type: RateSearchFiltersDto,
|
||||||
@IsOptional()
|
})
|
||||||
@IsString()
|
@IsOptional()
|
||||||
containerType?: string;
|
@ValidateNested()
|
||||||
|
@Type(() => RateSearchFiltersDto)
|
||||||
@ApiPropertyOptional({
|
filters?: RateSearchFiltersDto;
|
||||||
description: 'Advanced filters for narrowing results',
|
}
|
||||||
type: RateSearchFiltersDto,
|
|
||||||
})
|
/**
|
||||||
@IsOptional()
|
* CSV Rate Search Response DTO
|
||||||
@ValidateNested()
|
*
|
||||||
@Type(() => RateSearchFiltersDto)
|
* Response containing matching rates with calculated prices
|
||||||
filters?: RateSearchFiltersDto;
|
*/
|
||||||
}
|
export class CsvRateSearchResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
/**
|
description: 'Array of matching rate results',
|
||||||
* CSV Rate Search Response DTO
|
type: [Object], // Will be replaced with RateResultDto
|
||||||
*
|
})
|
||||||
* Response containing matching rates with calculated prices
|
results: CsvRateResultDto[];
|
||||||
*/
|
|
||||||
export class CsvRateSearchResponseDto {
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Total number of results found',
|
||||||
description: 'Array of matching rate results',
|
example: 15,
|
||||||
type: [Object], // Will be replaced with RateResultDto
|
})
|
||||||
})
|
totalResults: number;
|
||||||
results: CsvRateResultDto[];
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'CSV files that were searched',
|
||||||
description: 'Total number of results found',
|
type: [String],
|
||||||
example: 15,
|
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
|
||||||
})
|
})
|
||||||
totalResults: number;
|
searchedFiles: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'CSV files that were searched',
|
description: 'Timestamp when search was executed',
|
||||||
type: [String],
|
example: '2025-10-23T10:30:00Z',
|
||||||
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
|
})
|
||||||
})
|
searchedAt: Date;
|
||||||
searchedFiles: string[];
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Filters that were applied to the search',
|
||||||
description: 'Timestamp when search was executed',
|
type: RateSearchFiltersDto,
|
||||||
example: '2025-10-23T10:30:00Z',
|
})
|
||||||
})
|
appliedFilters: RateSearchFiltersDto;
|
||||||
searchedAt: Date;
|
}
|
||||||
|
|
||||||
@ApiProperty({
|
/**
|
||||||
description: 'Filters that were applied to the search',
|
* Single CSV Rate Result DTO
|
||||||
type: RateSearchFiltersDto,
|
*/
|
||||||
})
|
export class CsvRateResultDto {
|
||||||
appliedFilters: RateSearchFiltersDto;
|
@ApiProperty({
|
||||||
}
|
description: 'Company name',
|
||||||
|
example: 'SSC Consolidation',
|
||||||
/**
|
})
|
||||||
* Single CSV Rate Result DTO
|
companyName: string;
|
||||||
*/
|
|
||||||
export class CsvRateResultDto {
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Origin port code',
|
||||||
description: 'Company name',
|
example: 'NLRTM',
|
||||||
example: 'SSC Consolidation',
|
})
|
||||||
})
|
origin: string;
|
||||||
companyName: string;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Destination port code',
|
||||||
description: 'Origin port code',
|
example: 'USNYC',
|
||||||
example: 'NLRTM',
|
})
|
||||||
})
|
destination: string;
|
||||||
origin: string;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Container type',
|
||||||
description: 'Destination port code',
|
example: 'LCL',
|
||||||
example: 'USNYC',
|
})
|
||||||
})
|
containerType: string;
|
||||||
destination: string;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Calculated price in USD',
|
||||||
description: 'Container type',
|
example: 1850.5,
|
||||||
example: 'LCL',
|
})
|
||||||
})
|
priceUSD: number;
|
||||||
containerType: string;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Calculated price in EUR',
|
||||||
description: 'Calculated price in USD',
|
example: 1665.45,
|
||||||
example: 1850.50,
|
})
|
||||||
})
|
priceEUR: number;
|
||||||
priceUSD: number;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Primary currency of the rate',
|
||||||
description: 'Calculated price in EUR',
|
enum: ['USD', 'EUR'],
|
||||||
example: 1665.45,
|
example: 'USD',
|
||||||
})
|
})
|
||||||
priceEUR: number;
|
primaryCurrency: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Primary currency of the rate',
|
description: 'Whether this rate has separate surcharges',
|
||||||
enum: ['USD', 'EUR'],
|
example: true,
|
||||||
example: 'USD',
|
})
|
||||||
})
|
hasSurcharges: boolean;
|
||||||
primaryCurrency: string;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Details of surcharges if any',
|
||||||
description: 'Whether this rate has separate surcharges',
|
example: 'BAF+CAF included',
|
||||||
example: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
hasSurcharges: boolean;
|
surchargeDetails: string | null;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Details of surcharges if any',
|
description: 'Transit time in days',
|
||||||
example: 'BAF+CAF included',
|
example: 28,
|
||||||
nullable: true,
|
})
|
||||||
})
|
transitDays: number;
|
||||||
surchargeDetails: string | null;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Rate validity end date',
|
||||||
description: 'Transit time in days',
|
example: '2025-12-31',
|
||||||
example: 28,
|
})
|
||||||
})
|
validUntil: string;
|
||||||
transitDays: number;
|
|
||||||
|
@ApiProperty({
|
||||||
@ApiProperty({
|
description: 'Source of the rate',
|
||||||
description: 'Rate validity end date',
|
enum: ['CSV', 'API'],
|
||||||
example: '2025-12-31',
|
example: 'CSV',
|
||||||
})
|
})
|
||||||
validUntil: string;
|
source: 'CSV' | 'API';
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Source of the rate',
|
description: 'Match score (0-100) indicating how well this rate matches the search',
|
||||||
enum: ['CSV', 'API'],
|
minimum: 0,
|
||||||
example: 'CSV',
|
maximum: 100,
|
||||||
})
|
example: 95,
|
||||||
source: 'CSV' | 'API';
|
})
|
||||||
|
matchScore: number;
|
||||||
@ApiProperty({
|
}
|
||||||
description: 'Match score (0-100) indicating how well this rate matches the search',
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 100,
|
|
||||||
example: 95,
|
|
||||||
})
|
|
||||||
matchScore: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,201 +1,201 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Upload DTO
|
* CSV Rate Upload DTO
|
||||||
*
|
*
|
||||||
* Request DTO for uploading CSV rate files (ADMIN only)
|
* Request DTO for uploading CSV rate files (ADMIN only)
|
||||||
*/
|
*/
|
||||||
export class CsvRateUploadDto {
|
export class CsvRateUploadDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Name of the carrier company',
|
description: 'Name of the carrier company',
|
||||||
example: 'SSC Consolidation',
|
example: 'SSC Consolidation',
|
||||||
maxLength: 255,
|
maxLength: 255,
|
||||||
})
|
})
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'CSV file containing shipping rates',
|
description: 'CSV file containing shipping rates',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'binary',
|
format: 'binary',
|
||||||
})
|
})
|
||||||
file: any; // Will be handled by multer
|
file: any; // Will be handled by multer
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Upload Response DTO
|
* CSV Rate Upload Response DTO
|
||||||
*/
|
*/
|
||||||
export class CsvRateUploadResponseDto {
|
export class CsvRateUploadResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Upload success status',
|
description: 'Upload success status',
|
||||||
example: true,
|
example: true,
|
||||||
})
|
})
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Number of rate rows parsed from CSV',
|
description: 'Number of rate rows parsed from CSV',
|
||||||
example: 25,
|
example: 25,
|
||||||
})
|
})
|
||||||
ratesCount: number;
|
ratesCount: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Path where CSV file was saved',
|
description: 'Path where CSV file was saved',
|
||||||
example: 'ssc-consolidation.csv',
|
example: 'ssc-consolidation.csv',
|
||||||
})
|
})
|
||||||
csvFilePath: string;
|
csvFilePath: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Company name for which rates were uploaded',
|
description: 'Company name for which rates were uploaded',
|
||||||
example: 'SSC Consolidation',
|
example: 'SSC Consolidation',
|
||||||
})
|
})
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Upload timestamp',
|
description: 'Upload timestamp',
|
||||||
example: '2025-10-23T10:30:00Z',
|
example: '2025-10-23T10:30:00Z',
|
||||||
})
|
})
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Config Response DTO
|
* CSV Rate Config Response DTO
|
||||||
*
|
*
|
||||||
* Configuration entry for a company's CSV rates
|
* Configuration entry for a company's CSV rates
|
||||||
*/
|
*/
|
||||||
export class CsvRateConfigDto {
|
export class CsvRateConfigDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Configuration ID',
|
description: 'Configuration ID',
|
||||||
example: '123e4567-e89b-12d3-a456-426614174000',
|
example: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
})
|
})
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Company name',
|
description: 'Company name',
|
||||||
example: 'SSC Consolidation',
|
example: 'SSC Consolidation',
|
||||||
})
|
})
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'CSV file path',
|
description: 'CSV file path',
|
||||||
example: 'ssc-consolidation.csv',
|
example: 'ssc-consolidation.csv',
|
||||||
})
|
})
|
||||||
csvFilePath: string;
|
csvFilePath: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Integration type',
|
description: 'Integration type',
|
||||||
enum: ['CSV_ONLY', 'CSV_AND_API'],
|
enum: ['CSV_ONLY', 'CSV_AND_API'],
|
||||||
example: 'CSV_ONLY',
|
example: 'CSV_ONLY',
|
||||||
})
|
})
|
||||||
type: 'CSV_ONLY' | 'CSV_AND_API';
|
type: 'CSV_ONLY' | 'CSV_AND_API';
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Whether company has API connector',
|
description: 'Whether company has API connector',
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
hasApi: boolean;
|
hasApi: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'API connector name if hasApi is true',
|
description: 'API connector name if hasApi is true',
|
||||||
example: null,
|
example: null,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
apiConnector: string | null;
|
apiConnector: string | null;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Whether configuration is active',
|
description: 'Whether configuration is active',
|
||||||
example: true,
|
example: true,
|
||||||
})
|
})
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'When CSV was last uploaded',
|
description: 'When CSV was last uploaded',
|
||||||
example: '2025-10-23T10:30:00Z',
|
example: '2025-10-23T10:30:00Z',
|
||||||
})
|
})
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Number of rate rows in CSV',
|
description: 'Number of rate rows in CSV',
|
||||||
example: 25,
|
example: 25,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
rowCount: number | null;
|
rowCount: number | null;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Additional metadata',
|
description: 'Additional metadata',
|
||||||
example: { description: 'LCL rates for Europe to US', coverage: 'Global' },
|
example: { description: 'LCL rates for Europe to US', coverage: 'Global' },
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
metadata: Record<string, any> | null;
|
metadata: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV File Validation Result DTO
|
* CSV File Validation Result DTO
|
||||||
*/
|
*/
|
||||||
export class CsvFileValidationDto {
|
export class CsvFileValidationDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Whether CSV file is valid',
|
description: 'Whether CSV file is valid',
|
||||||
example: true,
|
example: true,
|
||||||
})
|
})
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Validation errors if any',
|
description: 'Validation errors if any',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: [],
|
example: [],
|
||||||
})
|
})
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Number of rows in CSV file',
|
description: 'Number of rows in CSV file',
|
||||||
example: 25,
|
example: 25,
|
||||||
required: false,
|
required: false,
|
||||||
})
|
})
|
||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Available Companies Response DTO
|
* Available Companies Response DTO
|
||||||
*/
|
*/
|
||||||
export class AvailableCompaniesDto {
|
export class AvailableCompaniesDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'List of available company names',
|
description: 'List of available company names',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
|
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
|
||||||
})
|
})
|
||||||
companies: string[];
|
companies: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Total number of companies',
|
description: 'Total number of companies',
|
||||||
example: 4,
|
example: 4,
|
||||||
})
|
})
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter Options Response DTO
|
* Filter Options Response DTO
|
||||||
*/
|
*/
|
||||||
export class FilterOptionsDto {
|
export class FilterOptionsDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Available company names',
|
description: 'Available company names',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
|
example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'],
|
||||||
})
|
})
|
||||||
companies: string[];
|
companies: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Available container types',
|
description: 'Available container types',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['LCL', '20DRY', '40HC', '40DRY'],
|
example: ['LCL', '20DRY', '40HC', '40DRY'],
|
||||||
})
|
})
|
||||||
containerTypes: string[];
|
containerTypes: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Supported currencies',
|
description: 'Supported currencies',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['USD', 'EUR'],
|
example: ['USD', 'EUR'],
|
||||||
})
|
})
|
||||||
currencies: string[];
|
currencies: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
// Rate Search DTOs
|
// Rate Search DTOs
|
||||||
export * from './rate-search-request.dto';
|
export * from './rate-search-request.dto';
|
||||||
export * from './rate-search-response.dto';
|
export * from './rate-search-response.dto';
|
||||||
|
|
||||||
// Booking DTOs
|
// Booking DTOs
|
||||||
export * from './create-booking-request.dto';
|
export * from './create-booking-request.dto';
|
||||||
export * from './booking-response.dto';
|
export * from './booking-response.dto';
|
||||||
export * from './booking-filter.dto';
|
export * from './booking-filter.dto';
|
||||||
export * from './booking-export.dto';
|
export * from './booking-export.dto';
|
||||||
|
|||||||
@ -1,301 +1,301 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
Matches,
|
Matches,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { OrganizationType } from '../../domain/entities/organization.entity';
|
import { OrganizationType } from '../../domain/entities/organization.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Address DTO
|
* Address DTO
|
||||||
*/
|
*/
|
||||||
export class AddressDto {
|
export class AddressDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '123 Main Street',
|
example: '123 Main Street',
|
||||||
description: 'Street address',
|
description: 'Street address',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
street: string;
|
street: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Rotterdam',
|
example: 'Rotterdam',
|
||||||
description: 'City',
|
description: 'City',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
city: string;
|
city: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'South Holland',
|
example: 'South Holland',
|
||||||
description: 'State or province',
|
description: 'State or province',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
state?: string;
|
state?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '3000 AB',
|
example: '3000 AB',
|
||||||
description: 'Postal code',
|
description: 'Postal code',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'NL',
|
example: 'NL',
|
||||||
description: 'Country code (ISO 3166-1 alpha-2)',
|
description: 'Country code (ISO 3166-1 alpha-2)',
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
maxLength: 2,
|
maxLength: 2,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(2)
|
@MaxLength(2)
|
||||||
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
|
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
|
||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Organization DTO
|
* Create Organization DTO
|
||||||
*/
|
*/
|
||||||
export class CreateOrganizationDto {
|
export class CreateOrganizationDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Acme Freight Forwarding',
|
example: 'Acme Freight Forwarding',
|
||||||
description: 'Organization name',
|
description: 'Organization name',
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: OrganizationType.FREIGHT_FORWARDER,
|
example: OrganizationType.FREIGHT_FORWARDER,
|
||||||
description: 'Organization type',
|
description: 'Organization type',
|
||||||
enum: OrganizationType,
|
enum: OrganizationType,
|
||||||
})
|
})
|
||||||
@IsEnum(OrganizationType)
|
@IsEnum(OrganizationType)
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'MAEU',
|
example: 'MAEU',
|
||||||
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
|
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
|
||||||
minLength: 4,
|
minLength: 4,
|
||||||
maxLength: 4,
|
maxLength: 4,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MinLength(4)
|
@MinLength(4)
|
||||||
@MaxLength(4)
|
@MaxLength(4)
|
||||||
@Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters (e.g., MAEU, MSCU)' })
|
@Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters (e.g., MAEU, MSCU)' })
|
||||||
scac?: string;
|
scac?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Organization address',
|
description: 'Organization address',
|
||||||
type: AddressDto,
|
type: AddressDto,
|
||||||
})
|
})
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => AddressDto)
|
@Type(() => AddressDto)
|
||||||
address: AddressDto;
|
address: AddressDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'https://example.com/logo.png',
|
example: 'https://example.com/logo.png',
|
||||||
description: 'Logo URL',
|
description: 'Logo URL',
|
||||||
})
|
})
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update Organization DTO
|
* Update Organization DTO
|
||||||
*/
|
*/
|
||||||
export class UpdateOrganizationDto {
|
export class UpdateOrganizationDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'Acme Freight Forwarding Inc.',
|
example: 'Acme Freight Forwarding Inc.',
|
||||||
description: 'Organization name',
|
description: 'Organization name',
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Organization address',
|
description: 'Organization address',
|
||||||
type: AddressDto,
|
type: AddressDto,
|
||||||
})
|
})
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => AddressDto)
|
@Type(() => AddressDto)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
address?: AddressDto;
|
address?: AddressDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'https://example.com/logo.png',
|
example: 'https://example.com/logo.png',
|
||||||
description: 'Logo URL',
|
description: 'Logo URL',
|
||||||
})
|
})
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Active status',
|
description: 'Active status',
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization Document DTO
|
* Organization Document DTO
|
||||||
*/
|
*/
|
||||||
export class OrganizationDocumentDto {
|
export class OrganizationDocumentDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
description: 'Document ID',
|
description: 'Document ID',
|
||||||
})
|
})
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'business_license',
|
example: 'business_license',
|
||||||
description: 'Document type',
|
description: 'Document type',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Business License 2025',
|
example: 'Business License 2025',
|
||||||
description: 'Document name',
|
description: 'Document name',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'https://s3.amazonaws.com/xpeditis/documents/doc123.pdf',
|
example: 'https://s3.amazonaws.com/xpeditis/documents/doc123.pdf',
|
||||||
description: 'Document URL',
|
description: 'Document URL',
|
||||||
})
|
})
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
url: string;
|
url: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2025-01-15T10:00:00Z',
|
example: '2025-01-15T10:00:00Z',
|
||||||
description: 'Upload timestamp',
|
description: 'Upload timestamp',
|
||||||
})
|
})
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization Response DTO
|
* Organization Response DTO
|
||||||
*/
|
*/
|
||||||
export class OrganizationResponseDto {
|
export class OrganizationResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
description: 'Organization ID',
|
description: 'Organization ID',
|
||||||
})
|
})
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Acme Freight Forwarding',
|
example: 'Acme Freight Forwarding',
|
||||||
description: 'Organization name',
|
description: 'Organization name',
|
||||||
})
|
})
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: OrganizationType.FREIGHT_FORWARDER,
|
example: OrganizationType.FREIGHT_FORWARDER,
|
||||||
description: 'Organization type',
|
description: 'Organization type',
|
||||||
enum: OrganizationType,
|
enum: OrganizationType,
|
||||||
})
|
})
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'MAEU',
|
example: 'MAEU',
|
||||||
description: 'Standard Carrier Alpha Code (carriers only)',
|
description: 'Standard Carrier Alpha Code (carriers only)',
|
||||||
})
|
})
|
||||||
scac?: string;
|
scac?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Organization address',
|
description: 'Organization address',
|
||||||
type: AddressDto,
|
type: AddressDto,
|
||||||
})
|
})
|
||||||
address: AddressDto;
|
address: AddressDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'https://example.com/logo.png',
|
example: 'https://example.com/logo.png',
|
||||||
description: 'Logo URL',
|
description: 'Logo URL',
|
||||||
})
|
})
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Organization documents',
|
description: 'Organization documents',
|
||||||
type: [OrganizationDocumentDto],
|
type: [OrganizationDocumentDto],
|
||||||
})
|
})
|
||||||
documents: OrganizationDocumentDto[];
|
documents: OrganizationDocumentDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Active status',
|
description: 'Active status',
|
||||||
})
|
})
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2025-01-01T00:00:00Z',
|
example: '2025-01-01T00:00:00Z',
|
||||||
description: 'Creation timestamp',
|
description: 'Creation timestamp',
|
||||||
})
|
})
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2025-01-15T10:00:00Z',
|
example: '2025-01-15T10:00:00Z',
|
||||||
description: 'Last update timestamp',
|
description: 'Last update timestamp',
|
||||||
})
|
})
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization List Response DTO
|
* Organization List Response DTO
|
||||||
*/
|
*/
|
||||||
export class OrganizationListResponseDto {
|
export class OrganizationListResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'List of organizations',
|
description: 'List of organizations',
|
||||||
type: [OrganizationResponseDto],
|
type: [OrganizationResponseDto],
|
||||||
})
|
})
|
||||||
organizations: OrganizationResponseDto[];
|
organizations: OrganizationResponseDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 25,
|
example: 25,
|
||||||
description: 'Total number of organizations',
|
description: 'Total number of organizations',
|
||||||
})
|
})
|
||||||
total: number;
|
total: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 1,
|
example: 1,
|
||||||
description: 'Current page number',
|
description: 'Current page number',
|
||||||
})
|
})
|
||||||
page: number;
|
page: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 20,
|
example: 20,
|
||||||
description: 'Page size',
|
description: 'Page size',
|
||||||
})
|
})
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 2,
|
example: 2,
|
||||||
description: 'Total number of pages',
|
description: 'Total number of pages',
|
||||||
})
|
})
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,155 +1,155 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsString,
|
IsString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Search Filters DTO
|
* Rate Search Filters DTO
|
||||||
*
|
*
|
||||||
* Advanced filters for narrowing down rate search results
|
* Advanced filters for narrowing down rate search results
|
||||||
* All filters are optional
|
* All filters are optional
|
||||||
*/
|
*/
|
||||||
export class RateSearchFiltersDto {
|
export class RateSearchFiltersDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'List of company names to include in search',
|
description: 'List of company names to include in search',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['SSC Consolidation', 'ECU Worldwide'],
|
example: ['SSC Consolidation', 'ECU Worldwide'],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
companies?: string[];
|
companies?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum volume in CBM (cubic meters)',
|
description: 'Minimum volume in CBM (cubic meters)',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 1,
|
example: 1,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
minVolumeCBM?: number;
|
minVolumeCBM?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum volume in CBM (cubic meters)',
|
description: 'Maximum volume in CBM (cubic meters)',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 100,
|
example: 100,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxVolumeCBM?: number;
|
maxVolumeCBM?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum weight in kilograms',
|
description: 'Minimum weight in kilograms',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 100,
|
example: 100,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
minWeightKG?: number;
|
minWeightKG?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum weight in kilograms',
|
description: 'Maximum weight in kilograms',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 15000,
|
example: 15000,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxWeightKG?: number;
|
maxWeightKG?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Exact number of pallets (0 means any)',
|
description: 'Exact number of pallets (0 means any)',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 10,
|
example: 10,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
palletCount?: number;
|
palletCount?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum price in selected currency',
|
description: 'Minimum price in selected currency',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 1000,
|
example: 1000,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum price in selected currency',
|
description: 'Maximum price in selected currency',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 5000,
|
example: 5000,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum transit time in days',
|
description: 'Minimum transit time in days',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 20,
|
example: 20,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
minTransitDays?: number;
|
minTransitDays?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum transit time in days',
|
description: 'Maximum transit time in days',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 40,
|
example: 40,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxTransitDays?: number;
|
maxTransitDays?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Container types to filter by',
|
description: 'Container types to filter by',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['LCL', '20DRY', '40HC'],
|
example: ['LCL', '20DRY', '40HC'],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
containerTypes?: string[];
|
containerTypes?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Preferred currency for price filtering',
|
description: 'Preferred currency for price filtering',
|
||||||
enum: ['USD', 'EUR'],
|
enum: ['USD', 'EUR'],
|
||||||
example: 'USD',
|
example: 'USD',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['USD', 'EUR'])
|
@IsEnum(['USD', 'EUR'])
|
||||||
currency?: 'USD' | 'EUR';
|
currency?: 'USD' | 'EUR';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Only show all-in prices (without separate surcharges)',
|
description: 'Only show all-in prices (without separate surcharges)',
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
onlyAllInPrices?: boolean;
|
onlyAllInPrices?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Departure date to check rate validity (ISO 8601)',
|
description: 'Departure date to check rate validity (ISO 8601)',
|
||||||
example: '2025-06-15',
|
example: '2025-06-15',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
departureDate?: string;
|
departureDate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,97 +1,110 @@
|
|||||||
import { IsString, IsDateString, IsEnum, IsOptional, IsInt, Min, IsBoolean, Matches } from 'class-validator';
|
import {
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
IsString,
|
||||||
|
IsDateString,
|
||||||
export class RateSearchRequestDto {
|
IsEnum,
|
||||||
@ApiProperty({
|
IsOptional,
|
||||||
description: 'Origin port code (UN/LOCODE)',
|
IsInt,
|
||||||
example: 'NLRTM',
|
Min,
|
||||||
pattern: '^[A-Z]{5}$',
|
IsBoolean,
|
||||||
})
|
Matches,
|
||||||
@IsString()
|
} from 'class-validator';
|
||||||
@Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' })
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
origin: string;
|
|
||||||
|
export class RateSearchRequestDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Destination port code (UN/LOCODE)',
|
description: 'Origin port code (UN/LOCODE)',
|
||||||
example: 'CNSHA',
|
example: 'NLRTM',
|
||||||
pattern: '^[A-Z]{5}$',
|
pattern: '^[A-Z]{5}$',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[A-Z]{5}$/, { message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)' })
|
@Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' })
|
||||||
destination: string;
|
origin: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Container type',
|
description: 'Destination port code (UN/LOCODE)',
|
||||||
example: '40HC',
|
example: 'CNSHA',
|
||||||
enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'],
|
pattern: '^[A-Z]{5}$',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], {
|
@Matches(/^[A-Z]{5}$/, {
|
||||||
message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC',
|
message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)',
|
||||||
})
|
})
|
||||||
containerType: string;
|
destination: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Shipping mode',
|
description: 'Container type',
|
||||||
example: 'FCL',
|
example: '40HC',
|
||||||
enum: ['FCL', 'LCL'],
|
enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'],
|
||||||
})
|
})
|
||||||
@IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' })
|
@IsString()
|
||||||
mode: 'FCL' | 'LCL';
|
@IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], {
|
||||||
|
message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC',
|
||||||
@ApiProperty({
|
})
|
||||||
description: 'Desired departure date (ISO 8601 format)',
|
containerType: string;
|
||||||
example: '2025-02-15',
|
|
||||||
})
|
@ApiProperty({
|
||||||
@IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' })
|
description: 'Shipping mode',
|
||||||
departureDate: string;
|
example: 'FCL',
|
||||||
|
enum: ['FCL', 'LCL'],
|
||||||
@ApiPropertyOptional({
|
})
|
||||||
description: 'Number of containers',
|
@IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' })
|
||||||
example: 2,
|
mode: 'FCL' | 'LCL';
|
||||||
minimum: 1,
|
|
||||||
default: 1,
|
@ApiProperty({
|
||||||
})
|
description: 'Desired departure date (ISO 8601 format)',
|
||||||
@IsOptional()
|
example: '2025-02-15',
|
||||||
@IsInt()
|
})
|
||||||
@Min(1, { message: 'Quantity must be at least 1' })
|
@IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' })
|
||||||
quantity?: number;
|
departureDate: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Total cargo weight in kg',
|
description: 'Number of containers',
|
||||||
example: 20000,
|
example: 2,
|
||||||
minimum: 0,
|
minimum: 1,
|
||||||
})
|
default: 1,
|
||||||
@IsOptional()
|
})
|
||||||
@IsInt()
|
@IsOptional()
|
||||||
@Min(0, { message: 'Weight must be non-negative' })
|
@IsInt()
|
||||||
weight?: number;
|
@Min(1, { message: 'Quantity must be at least 1' })
|
||||||
|
quantity?: number;
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Total cargo volume in cubic meters',
|
@ApiPropertyOptional({
|
||||||
example: 50.5,
|
description: 'Total cargo weight in kg',
|
||||||
minimum: 0,
|
example: 20000,
|
||||||
})
|
minimum: 0,
|
||||||
@IsOptional()
|
})
|
||||||
@Min(0, { message: 'Volume must be non-negative' })
|
@IsOptional()
|
||||||
volume?: number;
|
@IsInt()
|
||||||
|
@Min(0, { message: 'Weight must be non-negative' })
|
||||||
@ApiPropertyOptional({
|
weight?: number;
|
||||||
description: 'Whether cargo is hazardous material',
|
|
||||||
example: false,
|
@ApiPropertyOptional({
|
||||||
default: false,
|
description: 'Total cargo volume in cubic meters',
|
||||||
})
|
example: 50.5,
|
||||||
@IsOptional()
|
minimum: 0,
|
||||||
@IsBoolean()
|
})
|
||||||
isHazmat?: boolean;
|
@IsOptional()
|
||||||
|
@Min(0, { message: 'Volume must be non-negative' })
|
||||||
@ApiPropertyOptional({
|
volume?: number;
|
||||||
description: 'IMO hazmat class (required if isHazmat is true)',
|
|
||||||
example: '3',
|
@ApiPropertyOptional({
|
||||||
pattern: '^[1-9](\\.[1-9])?$',
|
description: 'Whether cargo is hazardous material',
|
||||||
})
|
example: false,
|
||||||
@IsOptional()
|
default: false,
|
||||||
@IsString()
|
})
|
||||||
@Matches(/^[1-9](\.[1-9])?$/, { message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)' })
|
@IsOptional()
|
||||||
imoClass?: string;
|
@IsBoolean()
|
||||||
}
|
isHazmat?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'IMO hazmat class (required if isHazmat is true)',
|
||||||
|
example: '3',
|
||||||
|
pattern: '^[1-9](\\.[1-9])?$',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[1-9](\.[1-9])?$/, {
|
||||||
|
message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)',
|
||||||
|
})
|
||||||
|
imoClass?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,148 +1,148 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class PortDto {
|
export class PortDto {
|
||||||
@ApiProperty({ example: 'NLRTM' })
|
@ApiProperty({ example: 'NLRTM' })
|
||||||
code: string;
|
code: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Rotterdam' })
|
@ApiProperty({ example: 'Rotterdam' })
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Netherlands' })
|
@ApiProperty({ example: 'Netherlands' })
|
||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SurchargeDto {
|
export class SurchargeDto {
|
||||||
@ApiProperty({ example: 'BAF', description: 'Surcharge type code' })
|
@ApiProperty({ example: 'BAF', description: 'Surcharge type code' })
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Bunker Adjustment Factor' })
|
@ApiProperty({ example: 'Bunker Adjustment Factor' })
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 150.0 })
|
@ApiProperty({ example: 150.0 })
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'USD' })
|
@ApiProperty({ example: 'USD' })
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PricingDto {
|
export class PricingDto {
|
||||||
@ApiProperty({ example: 1500.0, description: 'Base ocean freight' })
|
@ApiProperty({ example: 1500.0, description: 'Base ocean freight' })
|
||||||
baseFreight: number;
|
baseFreight: number;
|
||||||
|
|
||||||
@ApiProperty({ type: [SurchargeDto] })
|
@ApiProperty({ type: [SurchargeDto] })
|
||||||
surcharges: SurchargeDto[];
|
surcharges: SurchargeDto[];
|
||||||
|
|
||||||
@ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' })
|
@ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' })
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'USD' })
|
@ApiProperty({ example: 'USD' })
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RouteSegmentDto {
|
export class RouteSegmentDto {
|
||||||
@ApiProperty({ example: 'NLRTM' })
|
@ApiProperty({ example: 'NLRTM' })
|
||||||
portCode: string;
|
portCode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Port of Rotterdam' })
|
@ApiProperty({ example: 'Port of Rotterdam' })
|
||||||
portName: string;
|
portName: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' })
|
@ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' })
|
||||||
arrival?: string;
|
arrival?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' })
|
@ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' })
|
||||||
departure?: string;
|
departure?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'MAERSK ESSEX' })
|
@ApiPropertyOptional({ example: 'MAERSK ESSEX' })
|
||||||
vesselName?: string;
|
vesselName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '025W' })
|
@ApiPropertyOptional({ example: '025W' })
|
||||||
voyageNumber?: string;
|
voyageNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RateQuoteDto {
|
export class RateQuoteDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' })
|
||||||
carrierId: string;
|
carrierId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Maersk Line' })
|
@ApiProperty({ example: 'Maersk Line' })
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'MAERSK' })
|
@ApiProperty({ example: 'MAERSK' })
|
||||||
carrierCode: string;
|
carrierCode: string;
|
||||||
|
|
||||||
@ApiProperty({ type: PortDto })
|
@ApiProperty({ type: PortDto })
|
||||||
origin: PortDto;
|
origin: PortDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortDto })
|
@ApiProperty({ type: PortDto })
|
||||||
destination: PortDto;
|
destination: PortDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PricingDto })
|
@ApiProperty({ type: PricingDto })
|
||||||
pricing: PricingDto;
|
pricing: PricingDto;
|
||||||
|
|
||||||
@ApiProperty({ example: '40HC' })
|
@ApiProperty({ example: '40HC' })
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] })
|
@ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] })
|
||||||
mode: 'FCL' | 'LCL';
|
mode: 'FCL' | 'LCL';
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' })
|
||||||
etd: string;
|
etd: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' })
|
@ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' })
|
||||||
eta: string;
|
eta: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 30, description: 'Transit time in days' })
|
@ApiProperty({ example: 30, description: 'Transit time in days' })
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
|
|
||||||
@ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' })
|
@ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' })
|
||||||
route: RouteSegmentDto[];
|
route: RouteSegmentDto[];
|
||||||
|
|
||||||
@ApiProperty({ example: 85, description: 'Available container slots' })
|
@ApiProperty({ example: 85, description: 'Available container slots' })
|
||||||
availability: number;
|
availability: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Weekly' })
|
@ApiProperty({ example: 'Weekly' })
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Container Ship' })
|
@ApiPropertyOptional({ example: 'Container Ship' })
|
||||||
vesselType?: string;
|
vesselType?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' })
|
@ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' })
|
||||||
co2EmissionsKg?: number;
|
co2EmissionsKg?: number;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' })
|
@ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' })
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
@ApiProperty({ example: '2025-02-15T10:00:00Z' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RateSearchResponseDto {
|
export class RateSearchResponseDto {
|
||||||
@ApiProperty({ type: [RateQuoteDto] })
|
@ApiProperty({ type: [RateQuoteDto] })
|
||||||
quotes: RateQuoteDto[];
|
quotes: RateQuoteDto[];
|
||||||
|
|
||||||
@ApiProperty({ example: 5, description: 'Total number of quotes returned' })
|
@ApiProperty({ example: 5, description: 'Total number of quotes returned' })
|
||||||
count: number;
|
count: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'NLRTM' })
|
@ApiProperty({ example: 'NLRTM' })
|
||||||
origin: string;
|
origin: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'CNSHA' })
|
@ApiProperty({ example: 'CNSHA' })
|
||||||
destination: string;
|
destination: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2025-02-15' })
|
@ApiProperty({ example: '2025-02-15' })
|
||||||
departureDate: string;
|
departureDate: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '40HC' })
|
@ApiProperty({ example: '40HC' })
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'FCL' })
|
@ApiProperty({ example: 'FCL' })
|
||||||
mode: string;
|
mode: string;
|
||||||
|
|
||||||
@ApiProperty({ example: true, description: 'Whether results were served from cache' })
|
@ApiProperty({ example: true, description: 'Whether results were served from cache' })
|
||||||
fromCache: boolean;
|
fromCache: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: 234, description: 'Query response time in milliseconds' })
|
@ApiProperty({ example: 234, description: 'Query response time in milliseconds' })
|
||||||
responseTimeMs: number;
|
responseTimeMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,236 +1,237 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
MinLength,
|
MinLength,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User roles enum
|
* User roles enum
|
||||||
*/
|
*/
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
MANAGER = 'manager',
|
MANAGER = 'manager',
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
VIEWER = 'viewer',
|
VIEWER = 'viewer',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create User DTO (for admin/manager inviting users)
|
* Create User DTO (for admin/manager inviting users)
|
||||||
*/
|
*/
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'jane.doe@acme.com',
|
example: 'jane.doe@acme.com',
|
||||||
description: 'User email address',
|
description: 'User email address',
|
||||||
})
|
})
|
||||||
@IsEmail({}, { message: 'Invalid email format' })
|
@IsEmail({}, { message: 'Invalid email format' })
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Jane',
|
example: 'Jane',
|
||||||
description: 'First name',
|
description: 'First name',
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2, { message: 'First name must be at least 2 characters' })
|
@MinLength(2, { message: 'First name must be at least 2 characters' })
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Doe',
|
example: 'Doe',
|
||||||
description: 'Last name',
|
description: 'Last name',
|
||||||
minLength: 2,
|
minLength: 2,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2, { message: 'Last name must be at least 2 characters' })
|
@MinLength(2, { message: 'Last name must be at least 2 characters' })
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: UserRole.USER,
|
example: UserRole.USER,
|
||||||
description: 'User role',
|
description: 'User role',
|
||||||
enum: UserRole,
|
enum: UserRole,
|
||||||
})
|
})
|
||||||
@IsEnum(UserRole)
|
@IsEnum(UserRole)
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
description: 'Organization ID',
|
description: 'Organization ID',
|
||||||
})
|
})
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'TempPassword123!',
|
example: 'TempPassword123!',
|
||||||
description: 'Temporary password (min 12 characters). If not provided, a random one will be generated.',
|
description:
|
||||||
minLength: 12,
|
'Temporary password (min 12 characters). If not provided, a random one will be generated.',
|
||||||
})
|
minLength: 12,
|
||||||
@IsString()
|
})
|
||||||
@IsOptional()
|
@IsString()
|
||||||
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
@IsOptional()
|
||||||
password?: string;
|
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
||||||
}
|
password?: string;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Update User DTO
|
/**
|
||||||
*/
|
* Update User DTO
|
||||||
export class UpdateUserDto {
|
*/
|
||||||
@ApiPropertyOptional({
|
export class UpdateUserDto {
|
||||||
example: 'Jane',
|
@ApiPropertyOptional({
|
||||||
description: 'First name',
|
example: 'Jane',
|
||||||
minLength: 2,
|
description: 'First name',
|
||||||
})
|
minLength: 2,
|
||||||
@IsString()
|
})
|
||||||
@IsOptional()
|
@IsString()
|
||||||
@MinLength(2)
|
@IsOptional()
|
||||||
firstName?: string;
|
@MinLength(2)
|
||||||
|
firstName?: string;
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'Doe',
|
@ApiPropertyOptional({
|
||||||
description: 'Last name',
|
example: 'Doe',
|
||||||
minLength: 2,
|
description: 'Last name',
|
||||||
})
|
minLength: 2,
|
||||||
@IsString()
|
})
|
||||||
@IsOptional()
|
@IsString()
|
||||||
@MinLength(2)
|
@IsOptional()
|
||||||
lastName?: string;
|
@MinLength(2)
|
||||||
|
lastName?: string;
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: UserRole.MANAGER,
|
@ApiPropertyOptional({
|
||||||
description: 'User role',
|
example: UserRole.MANAGER,
|
||||||
enum: UserRole,
|
description: 'User role',
|
||||||
})
|
enum: UserRole,
|
||||||
@IsEnum(UserRole)
|
})
|
||||||
@IsOptional()
|
@IsEnum(UserRole)
|
||||||
role?: UserRole;
|
@IsOptional()
|
||||||
|
role?: UserRole;
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: true,
|
@ApiPropertyOptional({
|
||||||
description: 'Active status',
|
example: true,
|
||||||
})
|
description: 'Active status',
|
||||||
@IsBoolean()
|
})
|
||||||
@IsOptional()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
@IsOptional()
|
||||||
}
|
isActive?: boolean;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Update Password DTO
|
/**
|
||||||
*/
|
* Update Password DTO
|
||||||
export class UpdatePasswordDto {
|
*/
|
||||||
@ApiProperty({
|
export class UpdatePasswordDto {
|
||||||
example: 'OldPassword123!',
|
@ApiProperty({
|
||||||
description: 'Current password',
|
example: 'OldPassword123!',
|
||||||
})
|
description: 'Current password',
|
||||||
@IsString()
|
})
|
||||||
@IsNotEmpty()
|
@IsString()
|
||||||
currentPassword: string;
|
@IsNotEmpty()
|
||||||
|
currentPassword: string;
|
||||||
@ApiProperty({
|
|
||||||
example: 'NewSecurePassword456!',
|
@ApiProperty({
|
||||||
description: 'New password (min 12 characters)',
|
example: 'NewSecurePassword456!',
|
||||||
minLength: 12,
|
description: 'New password (min 12 characters)',
|
||||||
})
|
minLength: 12,
|
||||||
@IsString()
|
})
|
||||||
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
@IsString()
|
||||||
newPassword: string;
|
@MinLength(12, { message: 'Password must be at least 12 characters' })
|
||||||
}
|
newPassword: string;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* User Response DTO
|
/**
|
||||||
*/
|
* User Response DTO
|
||||||
export class UserResponseDto {
|
*/
|
||||||
@ApiProperty({
|
export class UserResponseDto {
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
@ApiProperty({
|
||||||
description: 'User ID',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
})
|
description: 'User ID',
|
||||||
id: string;
|
})
|
||||||
|
id: string;
|
||||||
@ApiProperty({
|
|
||||||
example: 'john.doe@acme.com',
|
@ApiProperty({
|
||||||
description: 'User email',
|
example: 'john.doe@acme.com',
|
||||||
})
|
description: 'User email',
|
||||||
email: string;
|
})
|
||||||
|
email: string;
|
||||||
@ApiProperty({
|
|
||||||
example: 'John',
|
@ApiProperty({
|
||||||
description: 'First name',
|
example: 'John',
|
||||||
})
|
description: 'First name',
|
||||||
firstName: string;
|
})
|
||||||
|
firstName: string;
|
||||||
@ApiProperty({
|
|
||||||
example: 'Doe',
|
@ApiProperty({
|
||||||
description: 'Last name',
|
example: 'Doe',
|
||||||
})
|
description: 'Last name',
|
||||||
lastName: string;
|
})
|
||||||
|
lastName: string;
|
||||||
@ApiProperty({
|
|
||||||
example: UserRole.USER,
|
@ApiProperty({
|
||||||
description: 'User role',
|
example: UserRole.USER,
|
||||||
enum: UserRole,
|
description: 'User role',
|
||||||
})
|
enum: UserRole,
|
||||||
role: UserRole;
|
})
|
||||||
|
role: UserRole;
|
||||||
@ApiProperty({
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
@ApiProperty({
|
||||||
description: 'Organization ID',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
})
|
description: 'Organization ID',
|
||||||
organizationId: string;
|
})
|
||||||
|
organizationId: string;
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
@ApiProperty({
|
||||||
description: 'Active status',
|
example: true,
|
||||||
})
|
description: 'Active status',
|
||||||
isActive: boolean;
|
})
|
||||||
|
isActive: boolean;
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-01T00:00:00Z',
|
@ApiProperty({
|
||||||
description: 'Creation timestamp',
|
example: '2025-01-01T00:00:00Z',
|
||||||
})
|
description: 'Creation timestamp',
|
||||||
createdAt: Date;
|
})
|
||||||
|
createdAt: Date;
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-15T10:00:00Z',
|
@ApiProperty({
|
||||||
description: 'Last update timestamp',
|
example: '2025-01-15T10:00:00Z',
|
||||||
})
|
description: 'Last update timestamp',
|
||||||
updatedAt: Date;
|
})
|
||||||
}
|
updatedAt: Date;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* User List Response DTO
|
/**
|
||||||
*/
|
* User List Response DTO
|
||||||
export class UserListResponseDto {
|
*/
|
||||||
@ApiProperty({
|
export class UserListResponseDto {
|
||||||
description: 'List of users',
|
@ApiProperty({
|
||||||
type: [UserResponseDto],
|
description: 'List of users',
|
||||||
})
|
type: [UserResponseDto],
|
||||||
users: UserResponseDto[];
|
})
|
||||||
|
users: UserResponseDto[];
|
||||||
@ApiProperty({
|
|
||||||
example: 15,
|
@ApiProperty({
|
||||||
description: 'Total number of users',
|
example: 15,
|
||||||
})
|
description: 'Total number of users',
|
||||||
total: number;
|
})
|
||||||
|
total: number;
|
||||||
@ApiProperty({
|
|
||||||
example: 1,
|
@ApiProperty({
|
||||||
description: 'Current page number',
|
example: 1,
|
||||||
})
|
description: 'Current page number',
|
||||||
page: number;
|
})
|
||||||
|
page: number;
|
||||||
@ApiProperty({
|
|
||||||
example: 20,
|
@ApiProperty({
|
||||||
description: 'Page size',
|
example: 20,
|
||||||
})
|
description: 'Page size',
|
||||||
pageSize: number;
|
})
|
||||||
|
pageSize: number;
|
||||||
@ApiProperty({
|
|
||||||
example: 1,
|
@ApiProperty({
|
||||||
description: 'Total number of pages',
|
example: 1,
|
||||||
})
|
description: 'Total number of pages',
|
||||||
totalPages: number;
|
})
|
||||||
}
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,12 +81,12 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
// Send recent notifications on connection
|
// Send recent notifications on connection
|
||||||
const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10);
|
const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10);
|
||||||
client.emit('recent_notifications', {
|
client.emit('recent_notifications', {
|
||||||
notifications: recentNotifications.map((n) => this.mapNotificationToDto(n)),
|
notifications: recentNotifications.map(n => this.mapNotificationToDto(n)),
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error during client connection: ${error?.message || 'Unknown error'}`,
|
`Error during client connection: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
@ -112,7 +112,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
@SubscribeMessage('mark_as_read')
|
@SubscribeMessage('mark_as_read')
|
||||||
async handleMarkAsRead(
|
async handleMarkAsRead(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: { notificationId: string },
|
@MessageBody() data: { notificationId: string }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = client.data.userId;
|
const userId = client.data.userId;
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export * from './jwt-auth.guard';
|
export * from './jwt-auth.guard';
|
||||||
export * from './roles.guard';
|
export * from './roles.guard';
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT Authentication Guard
|
* JWT Authentication Guard
|
||||||
*
|
*
|
||||||
* This guard:
|
* This guard:
|
||||||
* - Uses the JWT strategy to authenticate requests
|
* - Uses the JWT strategy to authenticate requests
|
||||||
* - Checks for valid JWT token in Authorization header
|
* - Checks for valid JWT token in Authorization header
|
||||||
* - Attaches user object to request if authentication succeeds
|
* - Attaches user object to request if authentication succeeds
|
||||||
* - Can be bypassed with @Public() decorator
|
* - Can be bypassed with @Public() decorator
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @UseGuards(JwtAuthGuard)
|
* @UseGuards(JwtAuthGuard)
|
||||||
* @Get('protected')
|
* @Get('protected')
|
||||||
* protectedRoute(@CurrentUser() user: UserPayload) {
|
* protectedRoute(@CurrentUser() user: UserPayload) {
|
||||||
* return { user };
|
* return { user };
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(private reflector: Reflector) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the route should be accessible without authentication
|
* Determine if the route should be accessible without authentication
|
||||||
* Routes decorated with @Public() will bypass this guard
|
* Routes decorated with @Public() will bypass this guard
|
||||||
*/
|
*/
|
||||||
canActivate(context: ExecutionContext) {
|
canActivate(context: ExecutionContext) {
|
||||||
// Check if route is marked as public
|
// Check if route is marked as public
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, perform JWT authentication
|
// Otherwise, perform JWT authentication
|
||||||
return super.canActivate(context);
|
return super.canActivate(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +1,46 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roles Guard for Role-Based Access Control (RBAC)
|
* Roles Guard for Role-Based Access Control (RBAC)
|
||||||
*
|
*
|
||||||
* This guard:
|
* This guard:
|
||||||
* - Checks if the authenticated user has the required role(s)
|
* - Checks if the authenticated user has the required role(s)
|
||||||
* - Works in conjunction with JwtAuthGuard
|
* - Works in conjunction with JwtAuthGuard
|
||||||
* - Uses @Roles() decorator to specify required roles
|
* - Uses @Roles() decorator to specify required roles
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* @UseGuards(JwtAuthGuard, RolesGuard)
|
* @UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
* @Roles('admin', 'manager')
|
* @Roles('admin', 'manager')
|
||||||
* @Get('admin-only')
|
* @Get('admin-only')
|
||||||
* adminRoute(@CurrentUser() user: UserPayload) {
|
* adminRoute(@CurrentUser() user: UserPayload) {
|
||||||
* return { message: 'Admin access granted' };
|
* return { message: 'Admin access granted' };
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
constructor(private reflector: Reflector) {}
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
// Get required roles from @Roles() decorator
|
// Get required roles from @Roles() decorator
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// If no roles are required, allow access
|
// If no roles are required, allow access
|
||||||
if (!requiredRoles || requiredRoles.length === 0) {
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from request (should be set by JwtAuthGuard)
|
// Get user from request (should be set by JwtAuthGuard)
|
||||||
const { user } = context.switchToHttp().getRequest();
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
// Check if user has any of the required roles
|
// Check if user has any of the required roles
|
||||||
if (!user || !user.role) {
|
if (!user || !user.role) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return requiredRoles.includes(user.role);
|
return requiredRoles.includes(user.role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,11 +23,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
/**
|
/**
|
||||||
* Custom error message (override for new API)
|
* Custom error message (override for new API)
|
||||||
*/
|
*/
|
||||||
protected async throwThrottlingException(
|
protected async throwThrottlingException(context: ExecutionContext): Promise<void> {
|
||||||
context: ExecutionContext,
|
throw new ThrottlerException('Too many requests. Please try again later.');
|
||||||
): Promise<void> {
|
|
||||||
throw new ThrottlerException(
|
|
||||||
'Too many requests. Please try again later.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,7 @@
|
|||||||
* Tracks request duration and logs metrics
|
* Tracks request duration and logs metrics
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NestInterceptor,
|
|
||||||
ExecutionContext,
|
|
||||||
CallHandler,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { tap, catchError } from 'rxjs/operators';
|
import { tap, catchError } from 'rxjs/operators';
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
@ -25,33 +19,31 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
tap((data) => {
|
tap(data => {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const response = context.switchToHttp().getResponse();
|
const response = context.switchToHttp().getResponse();
|
||||||
|
|
||||||
// Log performance
|
// Log performance
|
||||||
if (duration > 1000) {
|
if (duration > 1000) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`,
|
`Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful request
|
// Log successful request
|
||||||
this.logger.log(
|
this.logger.log(`${method} ${url} - ${response.statusCode} - ${duration}ms`);
|
||||||
`${method} ${url} - ${response.statusCode} - ${duration}ms`,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
catchError((error) => {
|
catchError(error => {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
// Log error
|
// Log error
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
|
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
|
||||||
error.stack,
|
error.stack
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture exception in Sentry
|
// Capture exception in Sentry
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope(scope => {
|
||||||
scope.setContext('request', {
|
scope.setContext('request', {
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
@ -62,7 +54,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,168 +1,168 @@
|
|||||||
import { Booking } from '../../domain/entities/booking.entity';
|
import { Booking } from '../../domain/entities/booking.entity';
|
||||||
import { RateQuote } from '../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../domain/entities/rate-quote.entity';
|
||||||
import {
|
import {
|
||||||
BookingResponseDto,
|
BookingResponseDto,
|
||||||
BookingAddressDto,
|
BookingAddressDto,
|
||||||
BookingPartyDto,
|
BookingPartyDto,
|
||||||
BookingContainerDto,
|
BookingContainerDto,
|
||||||
BookingRateQuoteDto,
|
BookingRateQuoteDto,
|
||||||
BookingListItemDto,
|
BookingListItemDto,
|
||||||
} from '../dto/booking-response.dto';
|
} from '../dto/booking-response.dto';
|
||||||
import {
|
import {
|
||||||
CreateBookingRequestDto,
|
CreateBookingRequestDto,
|
||||||
PartyDto,
|
PartyDto,
|
||||||
AddressDto,
|
AddressDto,
|
||||||
ContainerDto,
|
ContainerDto,
|
||||||
} from '../dto/create-booking-request.dto';
|
} from '../dto/create-booking-request.dto';
|
||||||
|
|
||||||
export class BookingMapper {
|
export class BookingMapper {
|
||||||
/**
|
/**
|
||||||
* Map CreateBookingRequestDto to domain inputs
|
* Map CreateBookingRequestDto to domain inputs
|
||||||
*/
|
*/
|
||||||
static toCreateBookingInput(dto: CreateBookingRequestDto) {
|
static toCreateBookingInput(dto: CreateBookingRequestDto) {
|
||||||
return {
|
return {
|
||||||
rateQuoteId: dto.rateQuoteId,
|
rateQuoteId: dto.rateQuoteId,
|
||||||
shipper: {
|
shipper: {
|
||||||
name: dto.shipper.name,
|
name: dto.shipper.name,
|
||||||
address: {
|
address: {
|
||||||
street: dto.shipper.address.street,
|
street: dto.shipper.address.street,
|
||||||
city: dto.shipper.address.city,
|
city: dto.shipper.address.city,
|
||||||
postalCode: dto.shipper.address.postalCode,
|
postalCode: dto.shipper.address.postalCode,
|
||||||
country: dto.shipper.address.country,
|
country: dto.shipper.address.country,
|
||||||
},
|
},
|
||||||
contactName: dto.shipper.contactName,
|
contactName: dto.shipper.contactName,
|
||||||
contactEmail: dto.shipper.contactEmail,
|
contactEmail: dto.shipper.contactEmail,
|
||||||
contactPhone: dto.shipper.contactPhone,
|
contactPhone: dto.shipper.contactPhone,
|
||||||
},
|
},
|
||||||
consignee: {
|
consignee: {
|
||||||
name: dto.consignee.name,
|
name: dto.consignee.name,
|
||||||
address: {
|
address: {
|
||||||
street: dto.consignee.address.street,
|
street: dto.consignee.address.street,
|
||||||
city: dto.consignee.address.city,
|
city: dto.consignee.address.city,
|
||||||
postalCode: dto.consignee.address.postalCode,
|
postalCode: dto.consignee.address.postalCode,
|
||||||
country: dto.consignee.address.country,
|
country: dto.consignee.address.country,
|
||||||
},
|
},
|
||||||
contactName: dto.consignee.contactName,
|
contactName: dto.consignee.contactName,
|
||||||
contactEmail: dto.consignee.contactEmail,
|
contactEmail: dto.consignee.contactEmail,
|
||||||
contactPhone: dto.consignee.contactPhone,
|
contactPhone: dto.consignee.contactPhone,
|
||||||
},
|
},
|
||||||
cargoDescription: dto.cargoDescription,
|
cargoDescription: dto.cargoDescription,
|
||||||
containers: dto.containers.map((c) => ({
|
containers: dto.containers.map(c => ({
|
||||||
type: c.type,
|
type: c.type,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
vgm: c.vgm,
|
vgm: c.vgm,
|
||||||
temperature: c.temperature,
|
temperature: c.temperature,
|
||||||
sealNumber: c.sealNumber,
|
sealNumber: c.sealNumber,
|
||||||
})),
|
})),
|
||||||
specialInstructions: dto.specialInstructions,
|
specialInstructions: dto.specialInstructions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Booking entity and RateQuote to BookingResponseDto
|
* Map Booking entity and RateQuote to BookingResponseDto
|
||||||
*/
|
*/
|
||||||
static toDto(booking: Booking, rateQuote: RateQuote): BookingResponseDto {
|
static toDto(booking: Booking, rateQuote: RateQuote): BookingResponseDto {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingNumber: booking.bookingNumber.value,
|
bookingNumber: booking.bookingNumber.value,
|
||||||
status: booking.status.value,
|
status: booking.status.value,
|
||||||
shipper: {
|
shipper: {
|
||||||
name: booking.shipper.name,
|
name: booking.shipper.name,
|
||||||
address: {
|
address: {
|
||||||
street: booking.shipper.address.street,
|
street: booking.shipper.address.street,
|
||||||
city: booking.shipper.address.city,
|
city: booking.shipper.address.city,
|
||||||
postalCode: booking.shipper.address.postalCode,
|
postalCode: booking.shipper.address.postalCode,
|
||||||
country: booking.shipper.address.country,
|
country: booking.shipper.address.country,
|
||||||
},
|
},
|
||||||
contactName: booking.shipper.contactName,
|
contactName: booking.shipper.contactName,
|
||||||
contactEmail: booking.shipper.contactEmail,
|
contactEmail: booking.shipper.contactEmail,
|
||||||
contactPhone: booking.shipper.contactPhone,
|
contactPhone: booking.shipper.contactPhone,
|
||||||
},
|
},
|
||||||
consignee: {
|
consignee: {
|
||||||
name: booking.consignee.name,
|
name: booking.consignee.name,
|
||||||
address: {
|
address: {
|
||||||
street: booking.consignee.address.street,
|
street: booking.consignee.address.street,
|
||||||
city: booking.consignee.address.city,
|
city: booking.consignee.address.city,
|
||||||
postalCode: booking.consignee.address.postalCode,
|
postalCode: booking.consignee.address.postalCode,
|
||||||
country: booking.consignee.address.country,
|
country: booking.consignee.address.country,
|
||||||
},
|
},
|
||||||
contactName: booking.consignee.contactName,
|
contactName: booking.consignee.contactName,
|
||||||
contactEmail: booking.consignee.contactEmail,
|
contactEmail: booking.consignee.contactEmail,
|
||||||
contactPhone: booking.consignee.contactPhone,
|
contactPhone: booking.consignee.contactPhone,
|
||||||
},
|
},
|
||||||
cargoDescription: booking.cargoDescription,
|
cargoDescription: booking.cargoDescription,
|
||||||
containers: booking.containers.map((c) => ({
|
containers: booking.containers.map(c => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
vgm: c.vgm,
|
vgm: c.vgm,
|
||||||
temperature: c.temperature,
|
temperature: c.temperature,
|
||||||
sealNumber: c.sealNumber,
|
sealNumber: c.sealNumber,
|
||||||
})),
|
})),
|
||||||
specialInstructions: booking.specialInstructions,
|
specialInstructions: booking.specialInstructions,
|
||||||
rateQuote: {
|
rateQuote: {
|
||||||
id: rateQuote.id,
|
id: rateQuote.id,
|
||||||
carrierName: rateQuote.carrierName,
|
carrierName: rateQuote.carrierName,
|
||||||
carrierCode: rateQuote.carrierCode,
|
carrierCode: rateQuote.carrierCode,
|
||||||
origin: {
|
origin: {
|
||||||
code: rateQuote.origin.code,
|
code: rateQuote.origin.code,
|
||||||
name: rateQuote.origin.name,
|
name: rateQuote.origin.name,
|
||||||
country: rateQuote.origin.country,
|
country: rateQuote.origin.country,
|
||||||
},
|
},
|
||||||
destination: {
|
destination: {
|
||||||
code: rateQuote.destination.code,
|
code: rateQuote.destination.code,
|
||||||
name: rateQuote.destination.name,
|
name: rateQuote.destination.name,
|
||||||
country: rateQuote.destination.country,
|
country: rateQuote.destination.country,
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
baseFreight: rateQuote.pricing.baseFreight,
|
baseFreight: rateQuote.pricing.baseFreight,
|
||||||
surcharges: rateQuote.pricing.surcharges.map((s) => ({
|
surcharges: rateQuote.pricing.surcharges.map(s => ({
|
||||||
type: s.type,
|
type: s.type,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
amount: s.amount,
|
amount: s.amount,
|
||||||
currency: s.currency,
|
currency: s.currency,
|
||||||
})),
|
})),
|
||||||
totalAmount: rateQuote.pricing.totalAmount,
|
totalAmount: rateQuote.pricing.totalAmount,
|
||||||
currency: rateQuote.pricing.currency,
|
currency: rateQuote.pricing.currency,
|
||||||
},
|
},
|
||||||
containerType: rateQuote.containerType,
|
containerType: rateQuote.containerType,
|
||||||
mode: rateQuote.mode,
|
mode: rateQuote.mode,
|
||||||
etd: rateQuote.etd.toISOString(),
|
etd: rateQuote.etd.toISOString(),
|
||||||
eta: rateQuote.eta.toISOString(),
|
eta: rateQuote.eta.toISOString(),
|
||||||
transitDays: rateQuote.transitDays,
|
transitDays: rateQuote.transitDays,
|
||||||
},
|
},
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
updatedAt: booking.updatedAt.toISOString(),
|
updatedAt: booking.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Booking entity to list item DTO (simplified view)
|
* Map Booking entity to list item DTO (simplified view)
|
||||||
*/
|
*/
|
||||||
static toListItemDto(booking: Booking, rateQuote: RateQuote): BookingListItemDto {
|
static toListItemDto(booking: Booking, rateQuote: RateQuote): BookingListItemDto {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingNumber: booking.bookingNumber.value,
|
bookingNumber: booking.bookingNumber.value,
|
||||||
status: booking.status.value,
|
status: booking.status.value,
|
||||||
shipperName: booking.shipper.name,
|
shipperName: booking.shipper.name,
|
||||||
consigneeName: booking.consignee.name,
|
consigneeName: booking.consignee.name,
|
||||||
originPort: rateQuote.origin.code,
|
originPort: rateQuote.origin.code,
|
||||||
destinationPort: rateQuote.destination.code,
|
destinationPort: rateQuote.destination.code,
|
||||||
carrierName: rateQuote.carrierName,
|
carrierName: rateQuote.carrierName,
|
||||||
etd: rateQuote.etd.toISOString(),
|
etd: rateQuote.etd.toISOString(),
|
||||||
eta: rateQuote.eta.toISOString(),
|
eta: rateQuote.eta.toISOString(),
|
||||||
totalAmount: rateQuote.pricing.totalAmount,
|
totalAmount: rateQuote.pricing.totalAmount,
|
||||||
currency: rateQuote.pricing.currency,
|
currency: rateQuote.pricing.currency,
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map array of bookings to list item DTOs
|
* Map array of bookings to list item DTOs
|
||||||
*/
|
*/
|
||||||
static toListItemDtoArray(
|
static toListItemDtoArray(
|
||||||
bookings: Array<{ booking: Booking; rateQuote: RateQuote }>
|
bookings: Array<{ booking: Booking; rateQuote: RateQuote }>
|
||||||
): BookingListItemDto[] {
|
): BookingListItemDto[] {
|
||||||
return bookings.map(({ booking, rateQuote }) => this.toListItemDto(booking, rateQuote));
|
return bookings.map(({ booking, rateQuote }) => this.toListItemDto(booking, rateQuote));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,112 +1,109 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CsvRate } from '@domain/entities/csv-rate.entity';
|
import { CsvRate } from '@domain/entities/csv-rate.entity';
|
||||||
import { Volume } from '@domain/value-objects/volume.vo';
|
import { Volume } from '@domain/value-objects/volume.vo';
|
||||||
import {
|
import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||||
CsvRateResultDto,
|
import {
|
||||||
CsvRateSearchResponseDto,
|
CsvRateSearchInput,
|
||||||
} from '../dto/csv-rate-search.dto';
|
CsvRateSearchOutput,
|
||||||
import {
|
CsvRateSearchResult,
|
||||||
CsvRateSearchInput,
|
RateSearchFilters,
|
||||||
CsvRateSearchOutput,
|
} from '@domain/ports/in/search-csv-rates.port';
|
||||||
CsvRateSearchResult,
|
import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
|
||||||
RateSearchFilters,
|
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
|
||||||
} from '@domain/ports/in/search-csv-rates.port';
|
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
|
||||||
import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
|
|
||||||
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
|
/**
|
||||||
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
|
* CSV Rate Mapper
|
||||||
|
*
|
||||||
/**
|
* Maps between domain entities and DTOs
|
||||||
* CSV Rate Mapper
|
* Follows hexagonal architecture principles
|
||||||
*
|
*/
|
||||||
* Maps between domain entities and DTOs
|
@Injectable()
|
||||||
* Follows hexagonal architecture principles
|
export class CsvRateMapper {
|
||||||
*/
|
/**
|
||||||
@Injectable()
|
* Map DTO filters to domain filters
|
||||||
export class CsvRateMapper {
|
*/
|
||||||
/**
|
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
|
||||||
* Map DTO filters to domain filters
|
if (!dto) {
|
||||||
*/
|
return undefined;
|
||||||
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
|
}
|
||||||
if (!dto) {
|
|
||||||
return undefined;
|
return {
|
||||||
}
|
companies: dto.companies,
|
||||||
|
minVolumeCBM: dto.minVolumeCBM,
|
||||||
return {
|
maxVolumeCBM: dto.maxVolumeCBM,
|
||||||
companies: dto.companies,
|
minWeightKG: dto.minWeightKG,
|
||||||
minVolumeCBM: dto.minVolumeCBM,
|
maxWeightKG: dto.maxWeightKG,
|
||||||
maxVolumeCBM: dto.maxVolumeCBM,
|
palletCount: dto.palletCount,
|
||||||
minWeightKG: dto.minWeightKG,
|
minPrice: dto.minPrice,
|
||||||
maxWeightKG: dto.maxWeightKG,
|
maxPrice: dto.maxPrice,
|
||||||
palletCount: dto.palletCount,
|
currency: dto.currency,
|
||||||
minPrice: dto.minPrice,
|
minTransitDays: dto.minTransitDays,
|
||||||
maxPrice: dto.maxPrice,
|
maxTransitDays: dto.maxTransitDays,
|
||||||
currency: dto.currency,
|
containerTypes: dto.containerTypes,
|
||||||
minTransitDays: dto.minTransitDays,
|
onlyAllInPrices: dto.onlyAllInPrices,
|
||||||
maxTransitDays: dto.maxTransitDays,
|
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
|
||||||
containerTypes: dto.containerTypes,
|
};
|
||||||
onlyAllInPrices: dto.onlyAllInPrices,
|
}
|
||||||
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
|
|
||||||
};
|
/**
|
||||||
}
|
* Map domain search result to DTO
|
||||||
|
*/
|
||||||
/**
|
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
|
||||||
* Map domain search result to DTO
|
const rate = result.rate;
|
||||||
*/
|
|
||||||
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
|
return {
|
||||||
const rate = result.rate;
|
companyName: rate.companyName,
|
||||||
|
origin: rate.origin.getValue(),
|
||||||
return {
|
destination: rate.destination.getValue(),
|
||||||
companyName: rate.companyName,
|
containerType: rate.containerType.getValue(),
|
||||||
origin: rate.origin.getValue(),
|
priceUSD: result.calculatedPrice.usd,
|
||||||
destination: rate.destination.getValue(),
|
priceEUR: result.calculatedPrice.eur,
|
||||||
containerType: rate.containerType.getValue(),
|
primaryCurrency: result.calculatedPrice.primaryCurrency,
|
||||||
priceUSD: result.calculatedPrice.usd,
|
hasSurcharges: rate.hasSurcharges(),
|
||||||
priceEUR: result.calculatedPrice.eur,
|
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
||||||
primaryCurrency: result.calculatedPrice.primaryCurrency,
|
transitDays: rate.transitDays,
|
||||||
hasSurcharges: rate.hasSurcharges(),
|
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
|
||||||
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
source: result.source,
|
||||||
transitDays: rate.transitDays,
|
matchScore: result.matchScore,
|
||||||
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
|
};
|
||||||
source: result.source,
|
}
|
||||||
matchScore: result.matchScore,
|
|
||||||
};
|
/**
|
||||||
}
|
* Map domain search output to response DTO
|
||||||
|
*/
|
||||||
/**
|
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
||||||
* Map domain search output to response DTO
|
return {
|
||||||
*/
|
results: output.results.map(result => this.mapSearchResultToDto(result)),
|
||||||
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
totalResults: output.totalResults,
|
||||||
return {
|
searchedFiles: output.searchedFiles,
|
||||||
results: output.results.map((result) => this.mapSearchResultToDto(result)),
|
searchedAt: output.searchedAt,
|
||||||
totalResults: output.totalResults,
|
appliedFilters: output.appliedFilters as any, // Already matches DTO structure
|
||||||
searchedFiles: output.searchedFiles,
|
};
|
||||||
searchedAt: output.searchedAt,
|
}
|
||||||
appliedFilters: output.appliedFilters as any, // Already matches DTO structure
|
|
||||||
};
|
/**
|
||||||
}
|
* Map ORM entity to DTO
|
||||||
|
*/
|
||||||
/**
|
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
|
||||||
* Map ORM entity to DTO
|
return {
|
||||||
*/
|
id: entity.id,
|
||||||
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
|
companyName: entity.companyName,
|
||||||
return {
|
csvFilePath: entity.csvFilePath,
|
||||||
id: entity.id,
|
type: entity.type,
|
||||||
companyName: entity.companyName,
|
hasApi: entity.hasApi,
|
||||||
csvFilePath: entity.csvFilePath,
|
apiConnector: entity.apiConnector,
|
||||||
type: entity.type,
|
isActive: entity.isActive,
|
||||||
hasApi: entity.hasApi,
|
uploadedAt: entity.uploadedAt,
|
||||||
apiConnector: entity.apiConnector,
|
rowCount: entity.rowCount,
|
||||||
isActive: entity.isActive,
|
metadata: entity.metadata,
|
||||||
uploadedAt: entity.uploadedAt,
|
};
|
||||||
rowCount: entity.rowCount,
|
}
|
||||||
metadata: entity.metadata,
|
|
||||||
};
|
/**
|
||||||
}
|
* Map multiple config entities to DTOs
|
||||||
|
*/
|
||||||
/**
|
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
||||||
* Map multiple config entities to DTOs
|
return entities.map(entity => this.mapConfigEntityToDto(entity));
|
||||||
*/
|
}
|
||||||
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
}
|
||||||
return entities.map((entity) => this.mapConfigEntityToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export * from './rate-quote.mapper';
|
export * from './rate-quote.mapper';
|
||||||
export * from './booking.mapper';
|
export * from './booking.mapper';
|
||||||
|
|||||||
@ -1,83 +1,81 @@
|
|||||||
import {
|
import {
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationAddress,
|
OrganizationAddress,
|
||||||
OrganizationDocument,
|
OrganizationDocument,
|
||||||
} from '../../domain/entities/organization.entity';
|
} from '../../domain/entities/organization.entity';
|
||||||
import {
|
import {
|
||||||
OrganizationResponseDto,
|
OrganizationResponseDto,
|
||||||
OrganizationDocumentDto,
|
OrganizationDocumentDto,
|
||||||
AddressDto,
|
AddressDto,
|
||||||
} from '../dto/organization.dto';
|
} from '../dto/organization.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organization Mapper
|
* Organization Mapper
|
||||||
*
|
*
|
||||||
* Maps between Organization domain entities and DTOs
|
* Maps between Organization domain entities and DTOs
|
||||||
*/
|
*/
|
||||||
export class OrganizationMapper {
|
export class OrganizationMapper {
|
||||||
/**
|
/**
|
||||||
* Convert Organization entity to DTO
|
* Convert Organization entity to DTO
|
||||||
*/
|
*/
|
||||||
static toDto(organization: Organization): OrganizationResponseDto {
|
static toDto(organization: Organization): OrganizationResponseDto {
|
||||||
return {
|
return {
|
||||||
id: organization.id,
|
id: organization.id,
|
||||||
name: organization.name,
|
name: organization.name,
|
||||||
type: organization.type,
|
type: organization.type,
|
||||||
scac: organization.scac,
|
scac: organization.scac,
|
||||||
address: this.mapAddressToDto(organization.address),
|
address: this.mapAddressToDto(organization.address),
|
||||||
logoUrl: organization.logoUrl,
|
logoUrl: organization.logoUrl,
|
||||||
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
|
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
|
||||||
isActive: organization.isActive,
|
isActive: organization.isActive,
|
||||||
createdAt: organization.createdAt,
|
createdAt: organization.createdAt,
|
||||||
updatedAt: organization.updatedAt,
|
updatedAt: organization.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert array of Organization entities to DTOs
|
* Convert array of Organization entities to DTOs
|
||||||
*/
|
*/
|
||||||
static toDtoArray(organizations: Organization[]): OrganizationResponseDto[] {
|
static toDtoArray(organizations: Organization[]): OrganizationResponseDto[] {
|
||||||
return organizations.map(org => this.toDto(org));
|
return organizations.map(org => this.toDto(org));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Address entity to DTO
|
* Map Address entity to DTO
|
||||||
*/
|
*/
|
||||||
private static mapAddressToDto(address: OrganizationAddress): AddressDto {
|
private static mapAddressToDto(address: OrganizationAddress): AddressDto {
|
||||||
return {
|
return {
|
||||||
street: address.street,
|
street: address.street,
|
||||||
city: address.city,
|
city: address.city,
|
||||||
state: address.state,
|
state: address.state,
|
||||||
postalCode: address.postalCode,
|
postalCode: address.postalCode,
|
||||||
country: address.country,
|
country: address.country,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Document entity to DTO
|
* Map Document entity to DTO
|
||||||
*/
|
*/
|
||||||
private static mapDocumentToDto(
|
private static mapDocumentToDto(document: OrganizationDocument): OrganizationDocumentDto {
|
||||||
document: OrganizationDocument,
|
return {
|
||||||
): OrganizationDocumentDto {
|
id: document.id,
|
||||||
return {
|
type: document.type,
|
||||||
id: document.id,
|
name: document.name,
|
||||||
type: document.type,
|
url: document.url,
|
||||||
name: document.name,
|
uploadedAt: document.uploadedAt,
|
||||||
url: document.url,
|
};
|
||||||
uploadedAt: document.uploadedAt,
|
}
|
||||||
};
|
|
||||||
}
|
/**
|
||||||
|
* Map DTO Address to domain Address
|
||||||
/**
|
*/
|
||||||
* Map DTO Address to domain Address
|
static mapDtoToAddress(dto: AddressDto): OrganizationAddress {
|
||||||
*/
|
return {
|
||||||
static mapDtoToAddress(dto: AddressDto): OrganizationAddress {
|
street: dto.street,
|
||||||
return {
|
city: dto.city,
|
||||||
street: dto.street,
|
state: dto.state,
|
||||||
city: dto.city,
|
postalCode: dto.postalCode,
|
||||||
state: dto.state,
|
country: dto.country,
|
||||||
postalCode: dto.postalCode,
|
};
|
||||||
country: dto.country,
|
}
|
||||||
};
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,69 +1,69 @@
|
|||||||
import { RateQuote } from '../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../domain/entities/rate-quote.entity';
|
||||||
import {
|
import {
|
||||||
RateQuoteDto,
|
RateQuoteDto,
|
||||||
PortDto,
|
PortDto,
|
||||||
SurchargeDto,
|
SurchargeDto,
|
||||||
PricingDto,
|
PricingDto,
|
||||||
RouteSegmentDto,
|
RouteSegmentDto,
|
||||||
} from '../dto/rate-search-response.dto';
|
} from '../dto/rate-search-response.dto';
|
||||||
|
|
||||||
export class RateQuoteMapper {
|
export class RateQuoteMapper {
|
||||||
/**
|
/**
|
||||||
* Map domain RateQuote entity to DTO
|
* Map domain RateQuote entity to DTO
|
||||||
*/
|
*/
|
||||||
static toDto(entity: RateQuote): RateQuoteDto {
|
static toDto(entity: RateQuote): RateQuoteDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
carrierId: entity.carrierId,
|
carrierId: entity.carrierId,
|
||||||
carrierName: entity.carrierName,
|
carrierName: entity.carrierName,
|
||||||
carrierCode: entity.carrierCode,
|
carrierCode: entity.carrierCode,
|
||||||
origin: {
|
origin: {
|
||||||
code: entity.origin.code,
|
code: entity.origin.code,
|
||||||
name: entity.origin.name,
|
name: entity.origin.name,
|
||||||
country: entity.origin.country,
|
country: entity.origin.country,
|
||||||
},
|
},
|
||||||
destination: {
|
destination: {
|
||||||
code: entity.destination.code,
|
code: entity.destination.code,
|
||||||
name: entity.destination.name,
|
name: entity.destination.name,
|
||||||
country: entity.destination.country,
|
country: entity.destination.country,
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
baseFreight: entity.pricing.baseFreight,
|
baseFreight: entity.pricing.baseFreight,
|
||||||
surcharges: entity.pricing.surcharges.map((s) => ({
|
surcharges: entity.pricing.surcharges.map(s => ({
|
||||||
type: s.type,
|
type: s.type,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
amount: s.amount,
|
amount: s.amount,
|
||||||
currency: s.currency,
|
currency: s.currency,
|
||||||
})),
|
})),
|
||||||
totalAmount: entity.pricing.totalAmount,
|
totalAmount: entity.pricing.totalAmount,
|
||||||
currency: entity.pricing.currency,
|
currency: entity.pricing.currency,
|
||||||
},
|
},
|
||||||
containerType: entity.containerType,
|
containerType: entity.containerType,
|
||||||
mode: entity.mode,
|
mode: entity.mode,
|
||||||
etd: entity.etd.toISOString(),
|
etd: entity.etd.toISOString(),
|
||||||
eta: entity.eta.toISOString(),
|
eta: entity.eta.toISOString(),
|
||||||
transitDays: entity.transitDays,
|
transitDays: entity.transitDays,
|
||||||
route: entity.route.map((segment) => ({
|
route: entity.route.map(segment => ({
|
||||||
portCode: segment.portCode,
|
portCode: segment.portCode,
|
||||||
portName: segment.portName,
|
portName: segment.portName,
|
||||||
arrival: segment.arrival?.toISOString(),
|
arrival: segment.arrival?.toISOString(),
|
||||||
departure: segment.departure?.toISOString(),
|
departure: segment.departure?.toISOString(),
|
||||||
vesselName: segment.vesselName,
|
vesselName: segment.vesselName,
|
||||||
voyageNumber: segment.voyageNumber,
|
voyageNumber: segment.voyageNumber,
|
||||||
})),
|
})),
|
||||||
availability: entity.availability,
|
availability: entity.availability,
|
||||||
frequency: entity.frequency,
|
frequency: entity.frequency,
|
||||||
vesselType: entity.vesselType,
|
vesselType: entity.vesselType,
|
||||||
co2EmissionsKg: entity.co2EmissionsKg,
|
co2EmissionsKg: entity.co2EmissionsKg,
|
||||||
validUntil: entity.validUntil.toISOString(),
|
validUntil: entity.validUntil.toISOString(),
|
||||||
createdAt: entity.createdAt.toISOString(),
|
createdAt: entity.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map array of RateQuote entities to DTOs
|
* Map array of RateQuote entities to DTOs
|
||||||
*/
|
*/
|
||||||
static toDtoArray(entities: RateQuote[]): RateQuoteDto[] {
|
static toDtoArray(entities: RateQuote[]): RateQuoteDto[] {
|
||||||
return entities.map((entity) => this.toDto(entity));
|
return entities.map(entity => this.toDto(entity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,33 +45,14 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: RateSearchService,
|
provide: RateSearchService,
|
||||||
useFactory: (
|
useFactory: (cache: any, rateQuoteRepo: any, portRepo: any, carrierRepo: any) => {
|
||||||
cache: any,
|
|
||||||
rateQuoteRepo: any,
|
|
||||||
portRepo: any,
|
|
||||||
carrierRepo: any,
|
|
||||||
) => {
|
|
||||||
// For now, create service with empty connectors array
|
// For now, create service with empty connectors array
|
||||||
// TODO: Inject actual carrier connectors
|
// TODO: Inject actual carrier connectors
|
||||||
return new RateSearchService(
|
return new RateSearchService([], cache, rateQuoteRepo, portRepo, carrierRepo);
|
||||||
[],
|
|
||||||
cache,
|
|
||||||
rateQuoteRepo,
|
|
||||||
portRepo,
|
|
||||||
carrierRepo,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
inject: [
|
inject: [CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
|
||||||
CACHE_PORT,
|
|
||||||
RATE_QUOTE_REPOSITORY,
|
|
||||||
PORT_REPOSITORY,
|
|
||||||
CARRIER_REPOSITORY,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
||||||
RATE_QUOTE_REPOSITORY,
|
|
||||||
RateSearchService,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class RatesModule {}
|
export class RatesModule {}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export class AnalyticsService {
|
|||||||
@Inject(BOOKING_REPOSITORY)
|
@Inject(BOOKING_REPOSITORY)
|
||||||
private readonly bookingRepository: BookingRepository,
|
private readonly bookingRepository: BookingRepository,
|
||||||
@Inject(RATE_QUOTE_REPOSITORY)
|
@Inject(RATE_QUOTE_REPOSITORY)
|
||||||
private readonly rateQuoteRepository: RateQuoteRepository,
|
private readonly rateQuoteRepository: RateQuoteRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,13 +70,11 @@ export class AnalyticsService {
|
|||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||||
|
|
||||||
// This month bookings
|
// This month bookings
|
||||||
const thisMonthBookings = allBookings.filter(
|
const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart);
|
||||||
(b) => b.createdAt >= thisMonthStart
|
|
||||||
);
|
|
||||||
|
|
||||||
// Last month bookings
|
// Last month bookings
|
||||||
const lastMonthBookings = allBookings.filter(
|
const lastMonthBookings = allBookings.filter(
|
||||||
(b) => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
|
b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
|
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
|
||||||
@ -118,10 +116,10 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
// Pending confirmations (status = pending_confirmation)
|
// Pending confirmations (status = pending_confirmation)
|
||||||
const pendingThisMonth = thisMonthBookings.filter(
|
const pendingThisMonth = thisMonthBookings.filter(
|
||||||
(b) => b.status.value === 'pending_confirmation'
|
b => b.status.value === 'pending_confirmation'
|
||||||
).length;
|
).length;
|
||||||
const pendingLastMonth = lastMonthBookings.filter(
|
const pendingLastMonth = lastMonthBookings.filter(
|
||||||
(b) => b.status.value === 'pending_confirmation'
|
b => b.status.value === 'pending_confirmation'
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Calculate percentage changes
|
// Calculate percentage changes
|
||||||
@ -135,15 +133,9 @@ export class AnalyticsService {
|
|||||||
totalTEUs: totalTEUsThisMonth,
|
totalTEUs: totalTEUsThisMonth,
|
||||||
estimatedRevenue: estimatedRevenueThisMonth,
|
estimatedRevenue: estimatedRevenueThisMonth,
|
||||||
pendingConfirmations: pendingThisMonth,
|
pendingConfirmations: pendingThisMonth,
|
||||||
bookingsThisMonthChange: calculateChange(
|
bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length),
|
||||||
thisMonthBookings.length,
|
|
||||||
lastMonthBookings.length
|
|
||||||
),
|
|
||||||
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
|
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
|
||||||
estimatedRevenueChange: calculateChange(
|
estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth),
|
||||||
estimatedRevenueThisMonth,
|
|
||||||
estimatedRevenueLastMonth
|
|
||||||
),
|
|
||||||
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
|
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -172,7 +164,7 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
// Count bookings in this month
|
// Count bookings in this month
|
||||||
const count = allBookings.filter(
|
const count = allBookings.filter(
|
||||||
(b) => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
b => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
||||||
).length;
|
).length;
|
||||||
data.push(count);
|
data.push(count);
|
||||||
}
|
}
|
||||||
@ -187,13 +179,16 @@ export class AnalyticsService {
|
|||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||||
|
|
||||||
// Group by route (origin-destination)
|
// Group by route (origin-destination)
|
||||||
const routeMap = new Map<string, {
|
const routeMap = new Map<
|
||||||
originPort: string;
|
string,
|
||||||
destinationPort: string;
|
{
|
||||||
bookingCount: number;
|
originPort: string;
|
||||||
totalTEUs: number;
|
destinationPort: string;
|
||||||
totalPrice: number;
|
bookingCount: number;
|
||||||
}>();
|
totalTEUs: number;
|
||||||
|
totalPrice: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
for (const booking of allBookings) {
|
for (const booking of allBookings) {
|
||||||
try {
|
try {
|
||||||
@ -231,16 +226,14 @@ export class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert to array and sort by booking count
|
// Convert to array and sort by booking count
|
||||||
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(
|
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
|
||||||
([route, data]) => ({
|
route,
|
||||||
route,
|
originPort: data.originPort,
|
||||||
originPort: data.originPort,
|
destinationPort: data.destinationPort,
|
||||||
destinationPort: data.destinationPort,
|
bookingCount: data.bookingCount,
|
||||||
bookingCount: data.bookingCount,
|
totalTEUs: data.totalTEUs,
|
||||||
totalTEUs: data.totalTEUs,
|
avgPrice: data.totalPrice / data.bookingCount,
|
||||||
avgPrice: data.totalPrice / data.bookingCount,
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by booking count and return top 5
|
// Sort by booking count and return top 5
|
||||||
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
|
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
|
||||||
@ -256,7 +249,7 @@ export class AnalyticsService {
|
|||||||
// Check for pending confirmations (older than 24h)
|
// Check for pending confirmations (older than 24h)
|
||||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
const oldPendingBookings = allBookings.filter(
|
const oldPendingBookings = allBookings.filter(
|
||||||
(b) => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const booking of oldPendingBookings) {
|
for (const booking of oldPendingBookings) {
|
||||||
|
|||||||
@ -4,7 +4,10 @@
|
|||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
import { AUDIT_LOG_REPOSITORY, AuditLogRepository } from '../../domain/ports/out/audit-log.repository';
|
import {
|
||||||
|
AUDIT_LOG_REPOSITORY,
|
||||||
|
AuditLogRepository,
|
||||||
|
} from '../../domain/ports/out/audit-log.repository';
|
||||||
import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity';
|
import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity';
|
||||||
|
|
||||||
describe('AuditService', () => {
|
describe('AuditService', () => {
|
||||||
|
|||||||
@ -7,11 +7,7 @@
|
|||||||
|
|
||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
|
||||||
AuditLog,
|
|
||||||
AuditAction,
|
|
||||||
AuditStatus,
|
|
||||||
} from '../../domain/entities/audit-log.entity';
|
|
||||||
import {
|
import {
|
||||||
AuditLogRepository,
|
AuditLogRepository,
|
||||||
AUDIT_LOG_REPOSITORY,
|
AUDIT_LOG_REPOSITORY,
|
||||||
@ -39,7 +35,7 @@ export class AuditService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AUDIT_LOG_REPOSITORY)
|
@Inject(AUDIT_LOG_REPOSITORY)
|
||||||
private readonly auditLogRepository: AuditLogRepository,
|
private readonly auditLogRepository: AuditLogRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,14 +50,12 @@ export class AuditService {
|
|||||||
|
|
||||||
await this.auditLogRepository.save(auditLog);
|
await this.auditLogRepository.save(auditLog);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`);
|
||||||
`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`,
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Never throw on audit logging failure - log the error and continue
|
// Never throw on audit logging failure - log the error and continue
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to create audit log: ${error?.message || 'Unknown error'}`,
|
`Failed to create audit log: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,7 +75,7 @@ export class AuditService {
|
|||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
},
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.log({
|
await this.log({
|
||||||
action,
|
action,
|
||||||
@ -108,7 +102,7 @@ export class AuditService {
|
|||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
},
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.log({
|
await this.log({
|
||||||
action,
|
action,
|
||||||
@ -139,20 +133,14 @@ export class AuditService {
|
|||||||
/**
|
/**
|
||||||
* Get audit trail for a specific resource
|
* Get audit trail for a specific resource
|
||||||
*/
|
*/
|
||||||
async getResourceAuditTrail(
|
async getResourceAuditTrail(resourceType: string, resourceId: string): Promise<AuditLog[]> {
|
||||||
resourceType: string,
|
|
||||||
resourceId: string,
|
|
||||||
): Promise<AuditLog[]> {
|
|
||||||
return this.auditLogRepository.findByResource(resourceType, resourceId);
|
return this.auditLogRepository.findByResource(resourceType, resourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recent activity for an organization
|
* Get recent activity for an organization
|
||||||
*/
|
*/
|
||||||
async getOrganizationActivity(
|
async getOrganizationActivity(organizationId: string, limit: number = 50): Promise<AuditLog[]> {
|
||||||
organizationId: string,
|
|
||||||
limit: number = 50,
|
|
||||||
): Promise<AuditLog[]> {
|
|
||||||
return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
|
return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,12 +8,12 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
|
|||||||
import { Booking } from '../../domain/entities/booking.entity';
|
import { Booking } from '../../domain/entities/booking.entity';
|
||||||
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
|
||||||
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
|
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
|
||||||
import {
|
import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port';
|
||||||
StoragePort,
|
|
||||||
STORAGE_PORT,
|
|
||||||
} from '../../domain/ports/out/storage.port';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
||||||
import { RateQuoteRepository, RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository';
|
import {
|
||||||
|
RateQuoteRepository,
|
||||||
|
RATE_QUOTE_REPOSITORY,
|
||||||
|
} from '../../domain/ports/out/rate-quote.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingAutomationService {
|
export class BookingAutomationService {
|
||||||
@ -24,16 +24,14 @@ export class BookingAutomationService {
|
|||||||
@Inject(PDF_PORT) private readonly pdfPort: PdfPort,
|
@Inject(PDF_PORT) private readonly pdfPort: PdfPort,
|
||||||
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
|
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository,
|
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute all post-booking automation tasks
|
* Execute all post-booking automation tasks
|
||||||
*/
|
*/
|
||||||
async executePostBookingTasks(booking: Booking): Promise<void> {
|
async executePostBookingTasks(booking: Booking): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(`Starting post-booking automation for booking: ${booking.bookingNumber.value}`);
|
||||||
`Starting post-booking automation for booking: ${booking.bookingNumber.value}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get user and rate quote details
|
// Get user and rate quote details
|
||||||
@ -42,9 +40,7 @@ export class BookingAutomationService {
|
|||||||
throw new Error(`User not found: ${booking.userId}`);
|
throw new Error(`User not found: ${booking.userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
booking.rateQuoteId
|
|
||||||
);
|
|
||||||
if (!rateQuote) {
|
if (!rateQuote) {
|
||||||
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
|
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
|
||||||
}
|
}
|
||||||
@ -79,7 +75,7 @@ export class BookingAutomationService {
|
|||||||
email: booking.consignee.contactEmail,
|
email: booking.consignee.contactEmail,
|
||||||
phone: booking.consignee.contactPhone,
|
phone: booking.consignee.contactPhone,
|
||||||
},
|
},
|
||||||
containers: booking.containers.map((c) => ({
|
containers: booking.containers.map(c => ({
|
||||||
type: c.type,
|
type: c.type,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
@ -173,10 +169,7 @@ export class BookingAutomationService {
|
|||||||
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
|
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(`Failed to send booking update notification`, error);
|
||||||
`Failed to send booking update notification`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,13 +38,11 @@ export class BruteForceProtectionService {
|
|||||||
|
|
||||||
// Calculate block time with exponential backoff
|
// Calculate block time with exponential backoff
|
||||||
if (existing.count > bruteForceConfig.freeRetries) {
|
if (existing.count > bruteForceConfig.freeRetries) {
|
||||||
const waitTime = this.calculateWaitTime(
|
const waitTime = this.calculateWaitTime(existing.count - bruteForceConfig.freeRetries);
|
||||||
existing.count - bruteForceConfig.freeRetries,
|
|
||||||
);
|
|
||||||
existing.blockedUntil = new Date(now.getTime() + waitTime);
|
existing.blockedUntil = new Date(now.getTime() + waitTime);
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`,
|
`Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +97,7 @@ export class BruteForceProtectionService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const remaining = Math.max(
|
const remaining = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000),
|
Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000)
|
||||||
);
|
);
|
||||||
|
|
||||||
return remaining;
|
return remaining;
|
||||||
@ -116,8 +114,7 @@ export class BruteForceProtectionService {
|
|||||||
* Calculate wait time with exponential backoff
|
* Calculate wait time with exponential backoff
|
||||||
*/
|
*/
|
||||||
private calculateWaitTime(failedAttempts: number): number {
|
private calculateWaitTime(failedAttempts: number): number {
|
||||||
const waitTime =
|
const waitTime = bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1);
|
||||||
bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1);
|
|
||||||
return Math.min(waitTime, bruteForceConfig.maxWait);
|
return Math.min(waitTime, bruteForceConfig.maxWait);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,10 +160,7 @@ export class BruteForceProtectionService {
|
|||||||
return {
|
return {
|
||||||
totalAttempts,
|
totalAttempts,
|
||||||
currentlyBlocked,
|
currentlyBlocked,
|
||||||
averageAttempts:
|
averageAttempts: this.attempts.size > 0 ? Math.round(totalAttempts / this.attempts.size) : 0,
|
||||||
this.attempts.size > 0
|
|
||||||
? Math.round(totalAttempts / this.attempts.size)
|
|
||||||
: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,9 +184,7 @@ export class BruteForceProtectionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(`Manually blocked ${identifier} for ${durationMs / 1000} seconds`);
|
||||||
`Manually blocked ${identifier} for ${durationMs / 1000} seconds`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -25,10 +25,10 @@ export class ExportService {
|
|||||||
async exportBookings(
|
async exportBookings(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`,
|
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
@ -48,17 +48,17 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportToCSV(
|
private async exportToCSV(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
const selectedFields = fields || Object.values(ExportField);
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
// Build CSV header
|
// Build CSV header
|
||||||
const header = selectedFields.map((field) => this.getFieldLabel(field)).join(',');
|
const header = selectedFields.map(field => this.getFieldLabel(field)).join(',');
|
||||||
|
|
||||||
// Build CSV rows
|
// Build CSV rows
|
||||||
const csvRows = rows.map((row) =>
|
const csvRows = rows.map(row =>
|
||||||
selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','),
|
selectedFields.map(field => this.escapeCSVValue(row[field] || '')).join(',')
|
||||||
);
|
);
|
||||||
|
|
||||||
const csv = [header, ...csvRows].join('\n');
|
const csv = [header, ...csvRows].join('\n');
|
||||||
@ -79,10 +79,10 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportToExcel(
|
private async exportToExcel(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
const selectedFields = fields || Object.values(ExportField);
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
workbook.creator = 'Xpeditis';
|
workbook.creator = 'Xpeditis';
|
||||||
@ -91,9 +91,7 @@ export class ExportService {
|
|||||||
const worksheet = workbook.addWorksheet('Bookings');
|
const worksheet = workbook.addWorksheet('Bookings');
|
||||||
|
|
||||||
// Add header row with styling
|
// Add header row with styling
|
||||||
const headerRow = worksheet.addRow(
|
const headerRow = worksheet.addRow(selectedFields.map(field => this.getFieldLabel(field)));
|
||||||
selectedFields.map((field) => this.getFieldLabel(field)),
|
|
||||||
);
|
|
||||||
headerRow.font = { bold: true };
|
headerRow.font = { bold: true };
|
||||||
headerRow.fill = {
|
headerRow.fill = {
|
||||||
type: 'pattern',
|
type: 'pattern',
|
||||||
@ -102,15 +100,15 @@ export class ExportService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add data rows
|
// Add data rows
|
||||||
rows.forEach((row) => {
|
rows.forEach(row => {
|
||||||
const values = selectedFields.map((field) => row[field] || '');
|
const values = selectedFields.map(field => row[field] || '');
|
||||||
worksheet.addRow(values);
|
worksheet.addRow(values);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-fit columns
|
// Auto-fit columns
|
||||||
worksheet.columns.forEach((column) => {
|
worksheet.columns.forEach(column => {
|
||||||
let maxLength = 10;
|
let maxLength = 10;
|
||||||
column.eachCell?.({ includeEmpty: false }, (cell) => {
|
column.eachCell?.({ includeEmpty: false }, cell => {
|
||||||
const columnLength = cell.value ? String(cell.value).length : 10;
|
const columnLength = cell.value ? String(cell.value).length : 10;
|
||||||
if (columnLength > maxLength) {
|
if (columnLength > maxLength) {
|
||||||
maxLength = columnLength;
|
maxLength = columnLength;
|
||||||
@ -136,10 +134,10 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportToJSON(
|
private async exportToJSON(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
const selectedFields = fields || Object.values(ExportField);
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
const json = JSON.stringify(
|
const json = JSON.stringify(
|
||||||
{
|
{
|
||||||
@ -148,7 +146,7 @@ export class ExportService {
|
|||||||
bookings: rows,
|
bookings: rows,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = Buffer.from(json, 'utf-8');
|
const buffer = Buffer.from(json, 'utf-8');
|
||||||
@ -166,14 +164,11 @@ export class ExportService {
|
|||||||
/**
|
/**
|
||||||
* Extract specified fields from booking data
|
* Extract specified fields from booking data
|
||||||
*/
|
*/
|
||||||
private extractFields(
|
private extractFields(data: BookingExportData, fields: ExportField[]): Record<string, any> {
|
||||||
data: BookingExportData,
|
|
||||||
fields: ExportField[],
|
|
||||||
): Record<string, any> {
|
|
||||||
const { booking, rateQuote } = data;
|
const { booking, rateQuote } = data;
|
||||||
const result: Record<string, any> = {};
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
fields.forEach((field) => {
|
fields.forEach(field => {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case ExportField.BOOKING_NUMBER:
|
case ExportField.BOOKING_NUMBER:
|
||||||
result[field] = booking.bookingNumber.value;
|
result[field] = booking.bookingNumber.value;
|
||||||
@ -206,7 +201,7 @@ export class ExportService {
|
|||||||
result[field] = booking.consignee.name;
|
result[field] = booking.consignee.name;
|
||||||
break;
|
break;
|
||||||
case ExportField.CONTAINER_TYPE:
|
case ExportField.CONTAINER_TYPE:
|
||||||
result[field] = booking.containers.map((c) => c.type).join(', ');
|
result[field] = booking.containers.map(c => c.type).join(', ');
|
||||||
break;
|
break;
|
||||||
case ExportField.CONTAINER_COUNT:
|
case ExportField.CONTAINER_COUNT:
|
||||||
result[field] = booking.containers.length;
|
result[field] = booking.containers.length;
|
||||||
@ -217,7 +212,8 @@ export class ExportService {
|
|||||||
}, 0);
|
}, 0);
|
||||||
break;
|
break;
|
||||||
case ExportField.PRICE:
|
case ExportField.PRICE:
|
||||||
result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
result[field] =
|
||||||
|
`${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -253,11 +249,7 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private escapeCSVValue(value: string): string {
|
private escapeCSVValue(value: string): string {
|
||||||
const stringValue = String(value);
|
const stringValue = String(value);
|
||||||
if (
|
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||||
stringValue.includes(',') ||
|
|
||||||
stringValue.includes('"') ||
|
|
||||||
stringValue.includes('\n')
|
|
||||||
) {
|
|
||||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
return stringValue;
|
return stringValue;
|
||||||
|
|||||||
@ -32,14 +32,14 @@ export class FileValidationService {
|
|||||||
// Validate file size
|
// Validate file size
|
||||||
if (file.size > fileUploadConfig.maxFileSize) {
|
if (file.size > fileUploadConfig.maxFileSize) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`,
|
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate MIME type
|
// Validate MIME type
|
||||||
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
|
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`,
|
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ export class FileValidationService {
|
|||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
|
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`,
|
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ export class FileValidationService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const lowerFilename = filename.toLowerCase();
|
const lowerFilename = filename.toLowerCase();
|
||||||
return dangerousExtensions.some((ext) => lowerFilename.includes(ext));
|
return dangerousExtensions.some(ext => lowerFilename.includes(ext));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -180,9 +180,7 @@ export class FileValidationService {
|
|||||||
|
|
||||||
// TODO: Integrate with ClamAV or similar virus scanner
|
// TODO: Integrate with ClamAV or similar virus scanner
|
||||||
// For now, just log
|
// For now, just log
|
||||||
this.logger.log(
|
this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`);
|
||||||
`Virus scan requested for file: ${file.originalname} (not implemented)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -190,9 +188,7 @@ export class FileValidationService {
|
|||||||
/**
|
/**
|
||||||
* Validate multiple files
|
* Validate multiple files
|
||||||
*/
|
*/
|
||||||
async validateFiles(
|
async validateFiles(files: Express.Multer.File[]): Promise<FileValidationResult> {
|
||||||
files: Express.Multer.File[],
|
|
||||||
): Promise<FileValidationResult> {
|
|
||||||
const allErrors: string[] = [];
|
const allErrors: string[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export class FuzzySearchService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(BookingOrmEntity)
|
@InjectRepository(BookingOrmEntity)
|
||||||
private readonly bookingOrmRepository: Repository<BookingOrmEntity>,
|
private readonly bookingOrmRepository: Repository<BookingOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,15 +26,13 @@ export class FuzzySearchService {
|
|||||||
async fuzzySearchBookings(
|
async fuzzySearchBookings(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
limit: number = 20,
|
limit: number = 20
|
||||||
): Promise<BookingOrmEntity[]> {
|
): Promise<BookingOrmEntity[]> {
|
||||||
if (!searchTerm || searchTerm.length < 2) {
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Fuzzy search for "${searchTerm}" in organization ${organizationId}`);
|
||||||
`Fuzzy search for "${searchTerm}" in organization ${organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use PostgreSQL full-text search with similarity
|
// Use PostgreSQL full-text search with similarity
|
||||||
// This requires pg_trgm extension to be enabled
|
// This requires pg_trgm extension to be enabled
|
||||||
@ -54,7 +52,7 @@ export class FuzzySearchService {
|
|||||||
{
|
{
|
||||||
searchTerm,
|
searchTerm,
|
||||||
likeTerm: `%${searchTerm}%`,
|
likeTerm: `%${searchTerm}%`,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
`GREATEST(
|
`GREATEST(
|
||||||
@ -62,7 +60,7 @@ export class FuzzySearchService {
|
|||||||
similarity(booking.shipper_name, :searchTerm),
|
similarity(booking.shipper_name, :searchTerm),
|
||||||
similarity(booking.consignee_name, :searchTerm)
|
similarity(booking.consignee_name, :searchTerm)
|
||||||
)`,
|
)`,
|
||||||
'DESC',
|
'DESC'
|
||||||
)
|
)
|
||||||
.setParameter('searchTerm', searchTerm)
|
.setParameter('searchTerm', searchTerm)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -80,21 +78,19 @@ export class FuzzySearchService {
|
|||||||
async fullTextSearch(
|
async fullTextSearch(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
limit: number = 20,
|
limit: number = 20
|
||||||
): Promise<BookingOrmEntity[]> {
|
): Promise<BookingOrmEntity[]> {
|
||||||
if (!searchTerm || searchTerm.length < 2) {
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Full-text search for "${searchTerm}" in organization ${organizationId}`);
|
||||||
`Full-text search for "${searchTerm}" in organization ${organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert search term to tsquery format
|
// Convert search term to tsquery format
|
||||||
const tsquery = searchTerm
|
const tsquery = searchTerm
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter((term) => term.length > 0)
|
.filter(term => term.length > 0)
|
||||||
.map((term) => `${term}:*`)
|
.map(term => `${term}:*`)
|
||||||
.join(' & ');
|
.join(' & ');
|
||||||
|
|
||||||
const results = await this.bookingOrmRepository
|
const results = await this.bookingOrmRepository
|
||||||
@ -111,7 +107,7 @@ export class FuzzySearchService {
|
|||||||
{
|
{
|
||||||
tsquery,
|
tsquery,
|
||||||
likeTerm: `%${searchTerm}%`,
|
likeTerm: `%${searchTerm}%`,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
.orderBy('booking.created_at', 'DESC')
|
.orderBy('booking.created_at', 'DESC')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -128,7 +124,7 @@ export class FuzzySearchService {
|
|||||||
async search(
|
async search(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
limit: number = 20,
|
limit: number = 20
|
||||||
): Promise<BookingOrmEntity[]> {
|
): Promise<BookingOrmEntity[]> {
|
||||||
// Try fuzzy search first (more tolerant to typos)
|
// Try fuzzy search first (more tolerant to typos)
|
||||||
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
|
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export class GDPRService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserOrmEntity)
|
@InjectRepository(UserOrmEntity)
|
||||||
private readonly userRepository: Repository<UserOrmEntity>,
|
private readonly userRepository: Repository<UserOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,7 +63,8 @@ export class GDPRService {
|
|||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
userId,
|
userId,
|
||||||
userData: sanitizedUser,
|
userData: sanitizedUser,
|
||||||
message: 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
message:
|
||||||
|
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Data export completed for user ${userId}`);
|
this.logger.log(`Data export completed for user ${userId}`);
|
||||||
@ -76,7 +77,9 @@ export class GDPRService {
|
|||||||
* Note: This is a simplified version. In production, implement full anonymization logic.
|
* Note: This is a simplified version. In production, implement full anonymization logic.
|
||||||
*/
|
*/
|
||||||
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||||
this.logger.warn(`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`);
|
this.logger.warn(
|
||||||
|
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||||
|
);
|
||||||
|
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
@ -117,7 +120,9 @@ export class GDPRService {
|
|||||||
|
|
||||||
// In production, store in separate consent table
|
// In production, store in separate consent table
|
||||||
// For now, just log the consent
|
// For now, just log the consent
|
||||||
this.logger.log(`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`);
|
this.logger.log(
|
||||||
|
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,8 +4,15 @@
|
|||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { NotificationService } from './notification.service';
|
import { NotificationService } from './notification.service';
|
||||||
import { NOTIFICATION_REPOSITORY, NotificationRepository } from '../../domain/ports/out/notification.repository';
|
import {
|
||||||
import { Notification, NotificationType, NotificationPriority } from '../../domain/entities/notification.entity';
|
NOTIFICATION_REPOSITORY,
|
||||||
|
NotificationRepository,
|
||||||
|
} from '../../domain/ports/out/notification.repository';
|
||||||
|
import {
|
||||||
|
Notification,
|
||||||
|
NotificationType,
|
||||||
|
NotificationPriority,
|
||||||
|
} from '../../domain/entities/notification.entity';
|
||||||
|
|
||||||
describe('NotificationService', () => {
|
describe('NotificationService', () => {
|
||||||
let service: NotificationService;
|
let service: NotificationService;
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export class NotificationService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(NOTIFICATION_REPOSITORY)
|
@Inject(NOTIFICATION_REPOSITORY)
|
||||||
private readonly notificationRepository: NotificationRepository,
|
private readonly notificationRepository: NotificationRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,14 +50,14 @@ export class NotificationService {
|
|||||||
await this.notificationRepository.save(notification);
|
await this.notificationRepository.save(notification);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`,
|
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return notification;
|
return notification;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to create notification: ${error?.message || 'Unknown error'}`,
|
`Failed to create notification: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -147,7 +147,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
bookingNumber: string,
|
bookingNumber: string,
|
||||||
bookingId: string,
|
bookingId: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
@ -166,7 +166,7 @@ export class NotificationService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
bookingNumber: string,
|
bookingNumber: string,
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
status: string,
|
status: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
@ -184,7 +184,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
bookingNumber: string,
|
bookingNumber: string,
|
||||||
bookingId: string,
|
bookingId: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
@ -202,7 +202,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
documentName: string,
|
documentName: string,
|
||||||
bookingId: string,
|
bookingId: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -123,11 +123,9 @@ describe('WebhookService', () => {
|
|||||||
of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any })
|
of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any })
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.triggerWebhooks(
|
await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', {
|
||||||
WebhookEvent.BOOKING_CREATED,
|
bookingId: 'booking-123',
|
||||||
'org-123',
|
});
|
||||||
{ bookingId: 'booking-123' }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(httpService.post).toHaveBeenCalledWith(
|
expect(httpService.post).toHaveBeenCalledWith(
|
||||||
'https://example.com/webhook',
|
'https://example.com/webhook',
|
||||||
@ -151,11 +149,9 @@ describe('WebhookService', () => {
|
|||||||
repository.findActiveByEvent.mockResolvedValue([webhook]);
|
repository.findActiveByEvent.mockResolvedValue([webhook]);
|
||||||
httpService.post.mockReturnValue(throwError(() => new Error('Network error')));
|
httpService.post.mockReturnValue(throwError(() => new Error('Network error')));
|
||||||
|
|
||||||
await service.triggerWebhooks(
|
await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', {
|
||||||
WebhookEvent.BOOKING_CREATED,
|
bookingId: 'booking-123',
|
||||||
'org-123',
|
});
|
||||||
{ bookingId: 'booking-123' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should be saved as failed after retries
|
// Should be saved as failed after retries
|
||||||
expect(repository.save).toHaveBeenCalledWith(
|
expect(repository.save).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import { HttpService } from '@nestjs/axios';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import {
|
import { Webhook, WebhookEvent, WebhookStatus } from '../../domain/entities/webhook.entity';
|
||||||
Webhook,
|
|
||||||
WebhookEvent,
|
|
||||||
WebhookStatus,
|
|
||||||
} from '../../domain/entities/webhook.entity';
|
|
||||||
import {
|
import {
|
||||||
WebhookRepository,
|
WebhookRepository,
|
||||||
WEBHOOK_REPOSITORY,
|
WEBHOOK_REPOSITORY,
|
||||||
@ -51,7 +47,7 @@ export class WebhookService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(WEBHOOK_REPOSITORY)
|
@Inject(WEBHOOK_REPOSITORY)
|
||||||
private readonly webhookRepository: WebhookRepository,
|
private readonly webhookRepository: WebhookRepository,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -72,9 +68,7 @@ export class WebhookService {
|
|||||||
|
|
||||||
await this.webhookRepository.save(webhook);
|
await this.webhookRepository.save(webhook);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`);
|
||||||
`Webhook created: ${webhook.id} for organization ${input.organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return webhook;
|
return webhook;
|
||||||
}
|
}
|
||||||
@ -158,11 +152,7 @@ export class WebhookService {
|
|||||||
/**
|
/**
|
||||||
* Trigger webhooks for an event
|
* Trigger webhooks for an event
|
||||||
*/
|
*/
|
||||||
async triggerWebhooks(
|
async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise<void> {
|
||||||
event: WebhookEvent,
|
|
||||||
organizationId: string,
|
|
||||||
data: any,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
|
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
|
||||||
|
|
||||||
@ -179,17 +169,13 @@ export class WebhookService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Trigger all webhooks in parallel
|
// Trigger all webhooks in parallel
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload)));
|
||||||
webhooks.map((webhook) => this.triggerWebhook(webhook, payload)),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`);
|
||||||
`Triggered ${webhooks.length} webhooks for event: ${event}`,
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error triggering webhooks: ${error?.message || 'Unknown error'}`,
|
`Error triggering webhooks: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,10 +183,7 @@ export class WebhookService {
|
|||||||
/**
|
/**
|
||||||
* Trigger a single webhook with retries
|
* Trigger a single webhook with retries
|
||||||
*/
|
*/
|
||||||
private async triggerWebhook(
|
private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise<void> {
|
||||||
webhook: Webhook,
|
|
||||||
payload: WebhookPayload,
|
|
||||||
): Promise<void> {
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
|
||||||
@ -226,7 +209,7 @@ export class WebhookService {
|
|||||||
this.httpService.post(webhook.url, payload, {
|
this.httpService.post(webhook.url, payload, {
|
||||||
headers,
|
headers,
|
||||||
timeout: 10000, // 10 seconds
|
timeout: 10000, // 10 seconds
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response && response.status >= 200 && response.status < 300) {
|
if (response && response.status >= 200 && response.status < 300) {
|
||||||
@ -234,17 +217,17 @@ export class WebhookService {
|
|||||||
const updatedWebhook = webhook.recordTrigger();
|
const updatedWebhook = webhook.recordTrigger();
|
||||||
await this.webhookRepository.save(updatedWebhook);
|
await this.webhookRepository.save(updatedWebhook);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`);
|
||||||
`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`);
|
lastError = new Error(
|
||||||
|
`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`,
|
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -254,7 +237,7 @@ export class WebhookService {
|
|||||||
await this.webhookRepository.save(failedWebhook);
|
await this.webhookRepository.save(failedWebhook);
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`,
|
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,16 +262,13 @@ export class WebhookService {
|
|||||||
*/
|
*/
|
||||||
verifySignature(payload: any, signature: string, secret: string): boolean {
|
verifySignature(payload: any, signature: string, secret: string): boolean {
|
||||||
const expectedSignature = this.generateSignature(payload, secret);
|
const expectedSignature = this.generateSignature(payload, secret);
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||||
Buffer.from(signature),
|
|
||||||
Buffer.from(expectedSignature),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delay helper for retries
|
* Delay helper for retries
|
||||||
*/
|
*/
|
||||||
private delay(ms: number): Promise<void> {
|
private delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,299 +1,297 @@
|
|||||||
/**
|
/**
|
||||||
* Booking Entity
|
* Booking Entity
|
||||||
*
|
*
|
||||||
* Represents a freight booking
|
* Represents a freight booking
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Must have valid rate quote
|
* - Must have valid rate quote
|
||||||
* - Shipper and consignee are required
|
* - Shipper and consignee are required
|
||||||
* - Status transitions must follow allowed paths
|
* - Status transitions must follow allowed paths
|
||||||
* - Containers can be added/updated until confirmed
|
* - Containers can be added/updated until confirmed
|
||||||
* - Cannot modify confirmed bookings (except status)
|
* - Cannot modify confirmed bookings (except status)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BookingNumber } from '../value-objects/booking-number.vo';
|
import { BookingNumber } from '../value-objects/booking-number.vo';
|
||||||
import { BookingStatus } from '../value-objects/booking-status.vo';
|
import { BookingStatus } from '../value-objects/booking-status.vo';
|
||||||
|
|
||||||
export interface Address {
|
export interface Address {
|
||||||
street: string;
|
street: string;
|
||||||
city: string;
|
city: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Party {
|
export interface Party {
|
||||||
name: string;
|
name: string;
|
||||||
address: Address;
|
address: Address;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
contactPhone: string;
|
contactPhone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingContainer {
|
export interface BookingContainer {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
vgm?: number; // Verified Gross Mass in kg
|
vgm?: number; // Verified Gross Mass in kg
|
||||||
temperature?: number; // For reefer containers
|
temperature?: number; // For reefer containers
|
||||||
sealNumber?: string;
|
sealNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingProps {
|
export interface BookingProps {
|
||||||
id: string;
|
id: string;
|
||||||
bookingNumber: BookingNumber;
|
bookingNumber: BookingNumber;
|
||||||
userId: string;
|
userId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
rateQuoteId: string;
|
rateQuoteId: string;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
shipper: Party;
|
shipper: Party;
|
||||||
consignee: Party;
|
consignee: Party;
|
||||||
cargoDescription: string;
|
cargoDescription: string;
|
||||||
containers: BookingContainer[];
|
containers: BookingContainer[];
|
||||||
specialInstructions?: string;
|
specialInstructions?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Booking {
|
export class Booking {
|
||||||
private readonly props: BookingProps;
|
private readonly props: BookingProps;
|
||||||
|
|
||||||
private constructor(props: BookingProps) {
|
private constructor(props: BookingProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new Booking
|
* Factory method to create a new Booking
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(
|
||||||
props: Omit<BookingProps, 'bookingNumber' | 'status' | 'createdAt' | 'updatedAt'> & {
|
props: Omit<BookingProps, 'bookingNumber' | 'status' | 'createdAt' | 'updatedAt'> & {
|
||||||
id: string;
|
id: string;
|
||||||
bookingNumber?: BookingNumber;
|
bookingNumber?: BookingNumber;
|
||||||
status?: BookingStatus;
|
status?: BookingStatus;
|
||||||
}
|
}
|
||||||
): Booking {
|
): Booking {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const bookingProps: BookingProps = {
|
const bookingProps: BookingProps = {
|
||||||
...props,
|
...props,
|
||||||
bookingNumber: props.bookingNumber || BookingNumber.generate(),
|
bookingNumber: props.bookingNumber || BookingNumber.generate(),
|
||||||
status: props.status || BookingStatus.create('draft'),
|
status: props.status || BookingStatus.create('draft'),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate business rules
|
// Validate business rules
|
||||||
Booking.validate(bookingProps);
|
Booking.validate(bookingProps);
|
||||||
|
|
||||||
return new Booking(bookingProps);
|
return new Booking(bookingProps);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate business rules
|
* Validate business rules
|
||||||
*/
|
*/
|
||||||
private static validate(props: BookingProps): void {
|
private static validate(props: BookingProps): void {
|
||||||
if (!props.userId) {
|
if (!props.userId) {
|
||||||
throw new Error('User ID is required');
|
throw new Error('User ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.organizationId) {
|
if (!props.organizationId) {
|
||||||
throw new Error('Organization ID is required');
|
throw new Error('Organization ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.rateQuoteId) {
|
if (!props.rateQuoteId) {
|
||||||
throw new Error('Rate quote ID is required');
|
throw new Error('Rate quote ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.shipper || !props.shipper.name) {
|
if (!props.shipper || !props.shipper.name) {
|
||||||
throw new Error('Shipper information is required');
|
throw new Error('Shipper information is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.consignee || !props.consignee.name) {
|
if (!props.consignee || !props.consignee.name) {
|
||||||
throw new Error('Consignee information is required');
|
throw new Error('Consignee information is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.cargoDescription || props.cargoDescription.length < 10) {
|
if (!props.cargoDescription || props.cargoDescription.length < 10) {
|
||||||
throw new Error('Cargo description must be at least 10 characters');
|
throw new Error('Cargo description must be at least 10 characters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this.props.id;
|
return this.props.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get bookingNumber(): BookingNumber {
|
get bookingNumber(): BookingNumber {
|
||||||
return this.props.bookingNumber;
|
return this.props.bookingNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
get userId(): string {
|
get userId(): string {
|
||||||
return this.props.userId;
|
return this.props.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get organizationId(): string {
|
get organizationId(): string {
|
||||||
return this.props.organizationId;
|
return this.props.organizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get rateQuoteId(): string {
|
get rateQuoteId(): string {
|
||||||
return this.props.rateQuoteId;
|
return this.props.rateQuoteId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get status(): BookingStatus {
|
get status(): BookingStatus {
|
||||||
return this.props.status;
|
return this.props.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
get shipper(): Party {
|
get shipper(): Party {
|
||||||
return { ...this.props.shipper };
|
return { ...this.props.shipper };
|
||||||
}
|
}
|
||||||
|
|
||||||
get consignee(): Party {
|
get consignee(): Party {
|
||||||
return { ...this.props.consignee };
|
return { ...this.props.consignee };
|
||||||
}
|
}
|
||||||
|
|
||||||
get cargoDescription(): string {
|
get cargoDescription(): string {
|
||||||
return this.props.cargoDescription;
|
return this.props.cargoDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
get containers(): BookingContainer[] {
|
get containers(): BookingContainer[] {
|
||||||
return [...this.props.containers];
|
return [...this.props.containers];
|
||||||
}
|
}
|
||||||
|
|
||||||
get specialInstructions(): string | undefined {
|
get specialInstructions(): string | undefined {
|
||||||
return this.props.specialInstructions;
|
return this.props.specialInstructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updatedAt(): Date {
|
get updatedAt(): Date {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update booking status
|
* Update booking status
|
||||||
*/
|
*/
|
||||||
updateStatus(newStatus: BookingStatus): Booking {
|
updateStatus(newStatus: BookingStatus): Booking {
|
||||||
if (!this.status.canTransitionTo(newStatus)) {
|
if (!this.status.canTransitionTo(newStatus)) {
|
||||||
throw new Error(
|
throw new Error(`Cannot transition from ${this.status.value} to ${newStatus.value}`);
|
||||||
`Cannot transition from ${this.status.value} to ${newStatus.value}`
|
}
|
||||||
);
|
|
||||||
}
|
return new Booking({
|
||||||
|
...this.props,
|
||||||
return new Booking({
|
status: newStatus,
|
||||||
...this.props,
|
updatedAt: new Date(),
|
||||||
status: newStatus,
|
});
|
||||||
updatedAt: new Date(),
|
}
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Add container to booking
|
||||||
/**
|
*/
|
||||||
* Add container to booking
|
addContainer(container: BookingContainer): Booking {
|
||||||
*/
|
if (!this.status.canBeModified()) {
|
||||||
addContainer(container: BookingContainer): Booking {
|
throw new Error('Cannot modify containers after booking is confirmed');
|
||||||
if (!this.status.canBeModified()) {
|
}
|
||||||
throw new Error('Cannot modify containers after booking is confirmed');
|
|
||||||
}
|
return new Booking({
|
||||||
|
...this.props,
|
||||||
return new Booking({
|
containers: [...this.props.containers, container],
|
||||||
...this.props,
|
updatedAt: new Date(),
|
||||||
containers: [...this.props.containers, container],
|
});
|
||||||
updatedAt: new Date(),
|
}
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Update container information
|
||||||
/**
|
*/
|
||||||
* Update container information
|
updateContainer(containerId: string, updates: Partial<BookingContainer>): Booking {
|
||||||
*/
|
if (!this.status.canBeModified()) {
|
||||||
updateContainer(containerId: string, updates: Partial<BookingContainer>): Booking {
|
throw new Error('Cannot modify containers after booking is confirmed');
|
||||||
if (!this.status.canBeModified()) {
|
}
|
||||||
throw new Error('Cannot modify containers after booking is confirmed');
|
|
||||||
}
|
const containerIndex = this.props.containers.findIndex(c => c.id === containerId);
|
||||||
|
if (containerIndex === -1) {
|
||||||
const containerIndex = this.props.containers.findIndex((c) => c.id === containerId);
|
throw new Error(`Container ${containerId} not found`);
|
||||||
if (containerIndex === -1) {
|
}
|
||||||
throw new Error(`Container ${containerId} not found`);
|
|
||||||
}
|
const updatedContainers = [...this.props.containers];
|
||||||
|
updatedContainers[containerIndex] = {
|
||||||
const updatedContainers = [...this.props.containers];
|
...updatedContainers[containerIndex],
|
||||||
updatedContainers[containerIndex] = {
|
...updates,
|
||||||
...updatedContainers[containerIndex],
|
};
|
||||||
...updates,
|
|
||||||
};
|
return new Booking({
|
||||||
|
...this.props,
|
||||||
return new Booking({
|
containers: updatedContainers,
|
||||||
...this.props,
|
updatedAt: new Date(),
|
||||||
containers: updatedContainers,
|
});
|
||||||
updatedAt: new Date(),
|
}
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Remove container from booking
|
||||||
/**
|
*/
|
||||||
* Remove container from booking
|
removeContainer(containerId: string): Booking {
|
||||||
*/
|
if (!this.status.canBeModified()) {
|
||||||
removeContainer(containerId: string): Booking {
|
throw new Error('Cannot modify containers after booking is confirmed');
|
||||||
if (!this.status.canBeModified()) {
|
}
|
||||||
throw new Error('Cannot modify containers after booking is confirmed');
|
|
||||||
}
|
return new Booking({
|
||||||
|
...this.props,
|
||||||
return new Booking({
|
containers: this.props.containers.filter(c => c.id !== containerId),
|
||||||
...this.props,
|
updatedAt: new Date(),
|
||||||
containers: this.props.containers.filter((c) => c.id !== containerId),
|
});
|
||||||
updatedAt: new Date(),
|
}
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Update cargo description
|
||||||
/**
|
*/
|
||||||
* Update cargo description
|
updateCargoDescription(description: string): Booking {
|
||||||
*/
|
if (!this.status.canBeModified()) {
|
||||||
updateCargoDescription(description: string): Booking {
|
throw new Error('Cannot modify cargo description after booking is confirmed');
|
||||||
if (!this.status.canBeModified()) {
|
}
|
||||||
throw new Error('Cannot modify cargo description after booking is confirmed');
|
|
||||||
}
|
if (description.length < 10) {
|
||||||
|
throw new Error('Cargo description must be at least 10 characters');
|
||||||
if (description.length < 10) {
|
}
|
||||||
throw new Error('Cargo description must be at least 10 characters');
|
|
||||||
}
|
return new Booking({
|
||||||
|
...this.props,
|
||||||
return new Booking({
|
cargoDescription: description,
|
||||||
...this.props,
|
updatedAt: new Date(),
|
||||||
cargoDescription: description,
|
});
|
||||||
updatedAt: new Date(),
|
}
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Update special instructions
|
||||||
/**
|
*/
|
||||||
* Update special instructions
|
updateSpecialInstructions(instructions: string): Booking {
|
||||||
*/
|
return new Booking({
|
||||||
updateSpecialInstructions(instructions: string): Booking {
|
...this.props,
|
||||||
return new Booking({
|
specialInstructions: instructions,
|
||||||
...this.props,
|
updatedAt: new Date(),
|
||||||
specialInstructions: instructions,
|
});
|
||||||
updatedAt: new Date(),
|
}
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Check if booking can be cancelled
|
||||||
/**
|
*/
|
||||||
* Check if booking can be cancelled
|
canBeCancelled(): boolean {
|
||||||
*/
|
return !this.status.isFinal();
|
||||||
canBeCancelled(): boolean {
|
}
|
||||||
return !this.status.isFinal();
|
|
||||||
}
|
/**
|
||||||
|
* Cancel booking
|
||||||
/**
|
*/
|
||||||
* Cancel booking
|
cancel(): Booking {
|
||||||
*/
|
if (!this.canBeCancelled()) {
|
||||||
cancel(): Booking {
|
throw new Error('Cannot cancel booking in final state');
|
||||||
if (!this.canBeCancelled()) {
|
}
|
||||||
throw new Error('Cannot cancel booking in final state');
|
|
||||||
}
|
return this.updateStatus(BookingStatus.create('cancelled'));
|
||||||
|
}
|
||||||
return this.updateStatus(BookingStatus.create('cancelled'));
|
|
||||||
}
|
/**
|
||||||
|
* Equality check
|
||||||
/**
|
*/
|
||||||
* Equality check
|
equals(other: Booking): boolean {
|
||||||
*/
|
return this.id === other.id;
|
||||||
equals(other: Booking): boolean {
|
}
|
||||||
return this.id === other.id;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,182 +1,184 @@
|
|||||||
/**
|
/**
|
||||||
* Carrier Entity
|
* Carrier Entity
|
||||||
*
|
*
|
||||||
* Represents a shipping carrier (e.g., Maersk, MSC, CMA CGM)
|
* Represents a shipping carrier (e.g., Maersk, MSC, CMA CGM)
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Carrier code must be unique
|
* - Carrier code must be unique
|
||||||
* - SCAC code must be valid (4 uppercase letters)
|
* - SCAC code must be valid (4 uppercase letters)
|
||||||
* - API configuration is optional (for carriers with API integration)
|
* - API configuration is optional (for carriers with API integration)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CarrierApiConfig {
|
export interface CarrierApiConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
timeout: number; // in milliseconds
|
timeout: number; // in milliseconds
|
||||||
retryAttempts: number;
|
retryAttempts: number;
|
||||||
circuitBreakerThreshold: number;
|
circuitBreakerThreshold: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CarrierProps {
|
export interface CarrierProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string; // Unique carrier code (e.g., 'MAERSK', 'MSC')
|
code: string; // Unique carrier code (e.g., 'MAERSK', 'MSC')
|
||||||
scac: string; // Standard Carrier Alpha Code
|
scac: string; // Standard Carrier Alpha Code
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
apiConfig?: CarrierApiConfig;
|
apiConfig?: CarrierApiConfig;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
supportsApi: boolean; // True if carrier has API integration
|
supportsApi: boolean; // True if carrier has API integration
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Carrier {
|
export class Carrier {
|
||||||
private readonly props: CarrierProps;
|
private readonly props: CarrierProps;
|
||||||
|
|
||||||
private constructor(props: CarrierProps) {
|
private constructor(props: CarrierProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new Carrier
|
* Factory method to create a new Carrier
|
||||||
*/
|
*/
|
||||||
static create(props: Omit<CarrierProps, 'createdAt' | 'updatedAt'>): Carrier {
|
static create(props: Omit<CarrierProps, 'createdAt' | 'updatedAt'>): Carrier {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Validate SCAC code
|
// Validate SCAC code
|
||||||
if (!Carrier.isValidSCAC(props.scac)) {
|
if (!Carrier.isValidSCAC(props.scac)) {
|
||||||
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
|
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate carrier code
|
// Validate carrier code
|
||||||
if (!Carrier.isValidCarrierCode(props.code)) {
|
if (!Carrier.isValidCarrierCode(props.code)) {
|
||||||
throw new Error('Invalid carrier code format. Must be uppercase letters and underscores only.');
|
throw new Error(
|
||||||
}
|
'Invalid carrier code format. Must be uppercase letters and underscores only.'
|
||||||
|
);
|
||||||
// Validate API config if carrier supports API
|
}
|
||||||
if (props.supportsApi && !props.apiConfig) {
|
|
||||||
throw new Error('Carriers with API support must have API configuration.');
|
// Validate API config if carrier supports API
|
||||||
}
|
if (props.supportsApi && !props.apiConfig) {
|
||||||
|
throw new Error('Carriers with API support must have API configuration.');
|
||||||
return new Carrier({
|
}
|
||||||
...props,
|
|
||||||
createdAt: now,
|
return new Carrier({
|
||||||
updatedAt: now,
|
...props,
|
||||||
});
|
createdAt: now,
|
||||||
}
|
updatedAt: now,
|
||||||
|
});
|
||||||
/**
|
}
|
||||||
* Factory method to reconstitute from persistence
|
|
||||||
*/
|
/**
|
||||||
static fromPersistence(props: CarrierProps): Carrier {
|
* Factory method to reconstitute from persistence
|
||||||
return new Carrier(props);
|
*/
|
||||||
}
|
static fromPersistence(props: CarrierProps): Carrier {
|
||||||
|
return new Carrier(props);
|
||||||
/**
|
}
|
||||||
* Validate SCAC code format
|
|
||||||
*/
|
/**
|
||||||
private static isValidSCAC(scac: string): boolean {
|
* Validate SCAC code format
|
||||||
const scacPattern = /^[A-Z]{4}$/;
|
*/
|
||||||
return scacPattern.test(scac);
|
private static isValidSCAC(scac: string): boolean {
|
||||||
}
|
const scacPattern = /^[A-Z]{4}$/;
|
||||||
|
return scacPattern.test(scac);
|
||||||
/**
|
}
|
||||||
* Validate carrier code format
|
|
||||||
*/
|
/**
|
||||||
private static isValidCarrierCode(code: string): boolean {
|
* Validate carrier code format
|
||||||
const codePattern = /^[A-Z_]+$/;
|
*/
|
||||||
return codePattern.test(code);
|
private static isValidCarrierCode(code: string): boolean {
|
||||||
}
|
const codePattern = /^[A-Z_]+$/;
|
||||||
|
return codePattern.test(code);
|
||||||
// Getters
|
}
|
||||||
get id(): string {
|
|
||||||
return this.props.id;
|
// Getters
|
||||||
}
|
get id(): string {
|
||||||
|
return this.props.id;
|
||||||
get name(): string {
|
}
|
||||||
return this.props.name;
|
|
||||||
}
|
get name(): string {
|
||||||
|
return this.props.name;
|
||||||
get code(): string {
|
}
|
||||||
return this.props.code;
|
|
||||||
}
|
get code(): string {
|
||||||
|
return this.props.code;
|
||||||
get scac(): string {
|
}
|
||||||
return this.props.scac;
|
|
||||||
}
|
get scac(): string {
|
||||||
|
return this.props.scac;
|
||||||
get logoUrl(): string | undefined {
|
}
|
||||||
return this.props.logoUrl;
|
|
||||||
}
|
get logoUrl(): string | undefined {
|
||||||
|
return this.props.logoUrl;
|
||||||
get website(): string | undefined {
|
}
|
||||||
return this.props.website;
|
|
||||||
}
|
get website(): string | undefined {
|
||||||
|
return this.props.website;
|
||||||
get apiConfig(): CarrierApiConfig | undefined {
|
}
|
||||||
return this.props.apiConfig ? { ...this.props.apiConfig } : undefined;
|
|
||||||
}
|
get apiConfig(): CarrierApiConfig | undefined {
|
||||||
|
return this.props.apiConfig ? { ...this.props.apiConfig } : undefined;
|
||||||
get isActive(): boolean {
|
}
|
||||||
return this.props.isActive;
|
|
||||||
}
|
get isActive(): boolean {
|
||||||
|
return this.props.isActive;
|
||||||
get supportsApi(): boolean {
|
}
|
||||||
return this.props.supportsApi;
|
|
||||||
}
|
get supportsApi(): boolean {
|
||||||
|
return this.props.supportsApi;
|
||||||
get createdAt(): Date {
|
}
|
||||||
return this.props.createdAt;
|
|
||||||
}
|
get createdAt(): Date {
|
||||||
|
return this.props.createdAt;
|
||||||
get updatedAt(): Date {
|
}
|
||||||
return this.props.updatedAt;
|
|
||||||
}
|
get updatedAt(): Date {
|
||||||
|
return this.props.updatedAt;
|
||||||
// Business methods
|
}
|
||||||
hasApiIntegration(): boolean {
|
|
||||||
return this.props.supportsApi && !!this.props.apiConfig;
|
// Business methods
|
||||||
}
|
hasApiIntegration(): boolean {
|
||||||
|
return this.props.supportsApi && !!this.props.apiConfig;
|
||||||
updateApiConfig(apiConfig: CarrierApiConfig): void {
|
}
|
||||||
if (!this.props.supportsApi) {
|
|
||||||
throw new Error('Cannot update API config for carrier without API support.');
|
updateApiConfig(apiConfig: CarrierApiConfig): void {
|
||||||
}
|
if (!this.props.supportsApi) {
|
||||||
|
throw new Error('Cannot update API config for carrier without API support.');
|
||||||
this.props.apiConfig = { ...apiConfig };
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
this.props.apiConfig = { ...apiConfig };
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
updateLogoUrl(logoUrl: string): void {
|
}
|
||||||
this.props.logoUrl = logoUrl;
|
|
||||||
this.props.updatedAt = new Date();
|
updateLogoUrl(logoUrl: string): void {
|
||||||
}
|
this.props.logoUrl = logoUrl;
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
updateWebsite(website: string): void {
|
}
|
||||||
this.props.website = website;
|
|
||||||
this.props.updatedAt = new Date();
|
updateWebsite(website: string): void {
|
||||||
}
|
this.props.website = website;
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
deactivate(): void {
|
}
|
||||||
this.props.isActive = false;
|
|
||||||
this.props.updatedAt = new Date();
|
deactivate(): void {
|
||||||
}
|
this.props.isActive = false;
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
activate(): void {
|
}
|
||||||
this.props.isActive = true;
|
|
||||||
this.props.updatedAt = new Date();
|
activate(): void {
|
||||||
}
|
this.props.isActive = true;
|
||||||
|
this.props.updatedAt = new Date();
|
||||||
/**
|
}
|
||||||
* Convert to plain object for persistence
|
|
||||||
*/
|
/**
|
||||||
toObject(): CarrierProps {
|
* Convert to plain object for persistence
|
||||||
return {
|
*/
|
||||||
...this.props,
|
toObject(): CarrierProps {
|
||||||
apiConfig: this.props.apiConfig ? { ...this.props.apiConfig } : undefined,
|
return {
|
||||||
};
|
...this.props,
|
||||||
}
|
apiConfig: this.props.apiConfig ? { ...this.props.apiConfig } : undefined,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,297 +1,300 @@
|
|||||||
/**
|
/**
|
||||||
* Container Entity
|
* Container Entity
|
||||||
*
|
*
|
||||||
* Represents a shipping container in a booking
|
* Represents a shipping container in a booking
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Container number must follow ISO 6346 format (when provided)
|
* - Container number must follow ISO 6346 format (when provided)
|
||||||
* - VGM (Verified Gross Mass) is required for export shipments
|
* - VGM (Verified Gross Mass) is required for export shipments
|
||||||
* - Temperature must be within valid range for reefer containers
|
* - Temperature must be within valid range for reefer containers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum ContainerCategory {
|
export enum ContainerCategory {
|
||||||
DRY = 'DRY',
|
DRY = 'DRY',
|
||||||
REEFER = 'REEFER',
|
REEFER = 'REEFER',
|
||||||
OPEN_TOP = 'OPEN_TOP',
|
OPEN_TOP = 'OPEN_TOP',
|
||||||
FLAT_RACK = 'FLAT_RACK',
|
FLAT_RACK = 'FLAT_RACK',
|
||||||
TANK = 'TANK',
|
TANK = 'TANK',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ContainerSize {
|
export enum ContainerSize {
|
||||||
TWENTY = '20',
|
TWENTY = '20',
|
||||||
FORTY = '40',
|
FORTY = '40',
|
||||||
FORTY_FIVE = '45',
|
FORTY_FIVE = '45',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ContainerHeight {
|
export enum ContainerHeight {
|
||||||
STANDARD = 'STANDARD',
|
STANDARD = 'STANDARD',
|
||||||
HIGH_CUBE = 'HIGH_CUBE',
|
HIGH_CUBE = 'HIGH_CUBE',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerProps {
|
export interface ContainerProps {
|
||||||
id: string;
|
id: string;
|
||||||
bookingId?: string; // Optional until container is assigned to a booking
|
bookingId?: string; // Optional until container is assigned to a booking
|
||||||
type: string; // e.g., '20DRY', '40HC', '40REEFER'
|
type: string; // e.g., '20DRY', '40HC', '40REEFER'
|
||||||
category: ContainerCategory;
|
category: ContainerCategory;
|
||||||
size: ContainerSize;
|
size: ContainerSize;
|
||||||
height: ContainerHeight;
|
height: ContainerHeight;
|
||||||
containerNumber?: string; // ISO 6346 format (assigned by carrier)
|
containerNumber?: string; // ISO 6346 format (assigned by carrier)
|
||||||
sealNumber?: string;
|
sealNumber?: string;
|
||||||
vgm?: number; // Verified Gross Mass in kg
|
vgm?: number; // Verified Gross Mass in kg
|
||||||
tareWeight?: number; // Empty container weight in kg
|
tareWeight?: number; // Empty container weight in kg
|
||||||
maxGrossWeight?: number; // Maximum gross weight in kg
|
maxGrossWeight?: number; // Maximum gross weight in kg
|
||||||
temperature?: number; // For reefer containers (°C)
|
temperature?: number; // For reefer containers (°C)
|
||||||
humidity?: number; // For reefer containers (%)
|
humidity?: number; // For reefer containers (%)
|
||||||
ventilation?: string; // For reefer containers
|
ventilation?: string; // For reefer containers
|
||||||
isHazmat: boolean;
|
isHazmat: boolean;
|
||||||
imoClass?: string; // IMO hazmat class (if hazmat)
|
imoClass?: string; // IMO hazmat class (if hazmat)
|
||||||
cargoDescription?: string;
|
cargoDescription?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Container {
|
export class Container {
|
||||||
private readonly props: ContainerProps;
|
private readonly props: ContainerProps;
|
||||||
|
|
||||||
private constructor(props: ContainerProps) {
|
private constructor(props: ContainerProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new Container
|
* Factory method to create a new Container
|
||||||
*/
|
*/
|
||||||
static create(props: Omit<ContainerProps, 'createdAt' | 'updatedAt'>): Container {
|
static create(props: Omit<ContainerProps, 'createdAt' | 'updatedAt'>): Container {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Validate container number format if provided
|
// Validate container number format if provided
|
||||||
if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) {
|
if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) {
|
||||||
throw new Error('Invalid container number format. Must follow ISO 6346 standard.');
|
throw new Error('Invalid container number format. Must follow ISO 6346 standard.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate VGM if provided
|
// Validate VGM if provided
|
||||||
if (props.vgm !== undefined && props.vgm <= 0) {
|
if (props.vgm !== undefined && props.vgm <= 0) {
|
||||||
throw new Error('VGM must be positive.');
|
throw new Error('VGM must be positive.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate temperature for reefer containers
|
// Validate temperature for reefer containers
|
||||||
if (props.category === ContainerCategory.REEFER) {
|
if (props.category === ContainerCategory.REEFER) {
|
||||||
if (props.temperature === undefined) {
|
if (props.temperature === undefined) {
|
||||||
throw new Error('Temperature is required for reefer containers.');
|
throw new Error('Temperature is required for reefer containers.');
|
||||||
}
|
}
|
||||||
if (props.temperature < -40 || props.temperature > 40) {
|
if (props.temperature < -40 || props.temperature > 40) {
|
||||||
throw new Error('Temperature must be between -40°C and +40°C.');
|
throw new Error('Temperature must be between -40°C and +40°C.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate hazmat
|
// Validate hazmat
|
||||||
if (props.isHazmat && !props.imoClass) {
|
if (props.isHazmat && !props.imoClass) {
|
||||||
throw new Error('IMO class is required for hazmat containers.');
|
throw new Error('IMO class is required for hazmat containers.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Container({
|
return new Container({
|
||||||
...props,
|
...props,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to reconstitute from persistence
|
* Factory method to reconstitute from persistence
|
||||||
*/
|
*/
|
||||||
static fromPersistence(props: ContainerProps): Container {
|
static fromPersistence(props: ContainerProps): Container {
|
||||||
return new Container(props);
|
return new Container(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate ISO 6346 container number format
|
* Validate ISO 6346 container number format
|
||||||
* Format: 4 letters (owner code) + 6 digits + 1 check digit
|
* Format: 4 letters (owner code) + 6 digits + 1 check digit
|
||||||
* Example: MSCU1234567
|
* Example: MSCU1234567
|
||||||
*/
|
*/
|
||||||
private static isValidContainerNumber(containerNumber: string): boolean {
|
private static isValidContainerNumber(containerNumber: string): boolean {
|
||||||
const pattern = /^[A-Z]{4}\d{7}$/;
|
const pattern = /^[A-Z]{4}\d{7}$/;
|
||||||
if (!pattern.test(containerNumber)) {
|
if (!pattern.test(containerNumber)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate check digit (ISO 6346 algorithm)
|
// Validate check digit (ISO 6346 algorithm)
|
||||||
const ownerCode = containerNumber.substring(0, 4);
|
const ownerCode = containerNumber.substring(0, 4);
|
||||||
const serialNumber = containerNumber.substring(4, 10);
|
const serialNumber = containerNumber.substring(4, 10);
|
||||||
const checkDigit = parseInt(containerNumber.substring(10, 11), 10);
|
const checkDigit = parseInt(containerNumber.substring(10, 11), 10);
|
||||||
|
|
||||||
// Convert letters to numbers (A=10, B=12, C=13, ..., Z=38)
|
// Convert letters to numbers (A=10, B=12, C=13, ..., Z=38)
|
||||||
const letterValues: { [key: string]: number } = {};
|
const letterValues: { [key: string]: number } = {};
|
||||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => {
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => {
|
||||||
letterValues[letter] = 10 + index + Math.floor(index / 2);
|
letterValues[letter] = 10 + index + Math.floor(index / 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate sum
|
// Calculate sum
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < ownerCode.length; i++) {
|
for (let i = 0; i < ownerCode.length; i++) {
|
||||||
sum += letterValues[ownerCode[i]] * Math.pow(2, i);
|
sum += letterValues[ownerCode[i]] * Math.pow(2, i);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < serialNumber.length; i++) {
|
for (let i = 0; i < serialNumber.length; i++) {
|
||||||
sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4);
|
sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check digit = sum % 11 (if 10, use 0)
|
// Check digit = sum % 11 (if 10, use 0)
|
||||||
const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11;
|
const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11;
|
||||||
|
|
||||||
return calculatedCheckDigit === checkDigit;
|
return calculatedCheckDigit === checkDigit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this.props.id;
|
return this.props.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get bookingId(): string | undefined {
|
get bookingId(): string | undefined {
|
||||||
return this.props.bookingId;
|
return this.props.bookingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): string {
|
get type(): string {
|
||||||
return this.props.type;
|
return this.props.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
get category(): ContainerCategory {
|
get category(): ContainerCategory {
|
||||||
return this.props.category;
|
return this.props.category;
|
||||||
}
|
}
|
||||||
|
|
||||||
get size(): ContainerSize {
|
get size(): ContainerSize {
|
||||||
return this.props.size;
|
return this.props.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
get height(): ContainerHeight {
|
get height(): ContainerHeight {
|
||||||
return this.props.height;
|
return this.props.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
get containerNumber(): string | undefined {
|
get containerNumber(): string | undefined {
|
||||||
return this.props.containerNumber;
|
return this.props.containerNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
get sealNumber(): string | undefined {
|
get sealNumber(): string | undefined {
|
||||||
return this.props.sealNumber;
|
return this.props.sealNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
get vgm(): number | undefined {
|
get vgm(): number | undefined {
|
||||||
return this.props.vgm;
|
return this.props.vgm;
|
||||||
}
|
}
|
||||||
|
|
||||||
get tareWeight(): number | undefined {
|
get tareWeight(): number | undefined {
|
||||||
return this.props.tareWeight;
|
return this.props.tareWeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
get maxGrossWeight(): number | undefined {
|
get maxGrossWeight(): number | undefined {
|
||||||
return this.props.maxGrossWeight;
|
return this.props.maxGrossWeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
get temperature(): number | undefined {
|
get temperature(): number | undefined {
|
||||||
return this.props.temperature;
|
return this.props.temperature;
|
||||||
}
|
}
|
||||||
|
|
||||||
get humidity(): number | undefined {
|
get humidity(): number | undefined {
|
||||||
return this.props.humidity;
|
return this.props.humidity;
|
||||||
}
|
}
|
||||||
|
|
||||||
get ventilation(): string | undefined {
|
get ventilation(): string | undefined {
|
||||||
return this.props.ventilation;
|
return this.props.ventilation;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isHazmat(): boolean {
|
get isHazmat(): boolean {
|
||||||
return this.props.isHazmat;
|
return this.props.isHazmat;
|
||||||
}
|
}
|
||||||
|
|
||||||
get imoClass(): string | undefined {
|
get imoClass(): string | undefined {
|
||||||
return this.props.imoClass;
|
return this.props.imoClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
get cargoDescription(): string | undefined {
|
get cargoDescription(): string | undefined {
|
||||||
return this.props.cargoDescription;
|
return this.props.cargoDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updatedAt(): Date {
|
get updatedAt(): Date {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business methods
|
// Business methods
|
||||||
isReefer(): boolean {
|
isReefer(): boolean {
|
||||||
return this.props.category === ContainerCategory.REEFER;
|
return this.props.category === ContainerCategory.REEFER;
|
||||||
}
|
}
|
||||||
|
|
||||||
isDry(): boolean {
|
isDry(): boolean {
|
||||||
return this.props.category === ContainerCategory.DRY;
|
return this.props.category === ContainerCategory.DRY;
|
||||||
}
|
}
|
||||||
|
|
||||||
isHighCube(): boolean {
|
isHighCube(): boolean {
|
||||||
return this.props.height === ContainerHeight.HIGH_CUBE;
|
return this.props.height === ContainerHeight.HIGH_CUBE;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTEU(): number {
|
getTEU(): number {
|
||||||
// Twenty-foot Equivalent Unit
|
// Twenty-foot Equivalent Unit
|
||||||
if (this.props.size === ContainerSize.TWENTY) {
|
if (this.props.size === ContainerSize.TWENTY) {
|
||||||
return 1;
|
return 1;
|
||||||
} else if (this.props.size === ContainerSize.FORTY || this.props.size === ContainerSize.FORTY_FIVE) {
|
} else if (
|
||||||
return 2;
|
this.props.size === ContainerSize.FORTY ||
|
||||||
}
|
this.props.size === ContainerSize.FORTY_FIVE
|
||||||
return 0;
|
) {
|
||||||
}
|
return 2;
|
||||||
|
}
|
||||||
getPayload(): number | undefined {
|
return 0;
|
||||||
if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) {
|
}
|
||||||
return this.props.vgm - this.props.tareWeight;
|
|
||||||
}
|
getPayload(): number | undefined {
|
||||||
return undefined;
|
if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) {
|
||||||
}
|
return this.props.vgm - this.props.tareWeight;
|
||||||
|
}
|
||||||
assignContainerNumber(containerNumber: string): void {
|
return undefined;
|
||||||
if (!Container.isValidContainerNumber(containerNumber)) {
|
}
|
||||||
throw new Error('Invalid container number format.');
|
|
||||||
}
|
assignContainerNumber(containerNumber: string): void {
|
||||||
this.props.containerNumber = containerNumber;
|
if (!Container.isValidContainerNumber(containerNumber)) {
|
||||||
this.props.updatedAt = new Date();
|
throw new Error('Invalid container number format.');
|
||||||
}
|
}
|
||||||
|
this.props.containerNumber = containerNumber;
|
||||||
assignSealNumber(sealNumber: string): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.sealNumber = sealNumber;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
assignSealNumber(sealNumber: string): void {
|
||||||
|
this.props.sealNumber = sealNumber;
|
||||||
setVGM(vgm: number): void {
|
this.props.updatedAt = new Date();
|
||||||
if (vgm <= 0) {
|
}
|
||||||
throw new Error('VGM must be positive.');
|
|
||||||
}
|
setVGM(vgm: number): void {
|
||||||
this.props.vgm = vgm;
|
if (vgm <= 0) {
|
||||||
this.props.updatedAt = new Date();
|
throw new Error('VGM must be positive.');
|
||||||
}
|
}
|
||||||
|
this.props.vgm = vgm;
|
||||||
setTemperature(temperature: number): void {
|
this.props.updatedAt = new Date();
|
||||||
if (!this.isReefer()) {
|
}
|
||||||
throw new Error('Cannot set temperature for non-reefer container.');
|
|
||||||
}
|
setTemperature(temperature: number): void {
|
||||||
if (temperature < -40 || temperature > 40) {
|
if (!this.isReefer()) {
|
||||||
throw new Error('Temperature must be between -40°C and +40°C.');
|
throw new Error('Cannot set temperature for non-reefer container.');
|
||||||
}
|
}
|
||||||
this.props.temperature = temperature;
|
if (temperature < -40 || temperature > 40) {
|
||||||
this.props.updatedAt = new Date();
|
throw new Error('Temperature must be between -40°C and +40°C.');
|
||||||
}
|
}
|
||||||
|
this.props.temperature = temperature;
|
||||||
setCargoDescription(description: string): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.cargoDescription = description;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
setCargoDescription(description: string): void {
|
||||||
|
this.props.cargoDescription = description;
|
||||||
assignToBooking(bookingId: string): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.bookingId = bookingId;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
assignToBooking(bookingId: string): void {
|
||||||
|
this.props.bookingId = bookingId;
|
||||||
/**
|
this.props.updatedAt = new Date();
|
||||||
* Convert to plain object for persistence
|
}
|
||||||
*/
|
|
||||||
toObject(): ContainerProps {
|
/**
|
||||||
return { ...this.props };
|
* Convert to plain object for persistence
|
||||||
}
|
*/
|
||||||
}
|
toObject(): ContainerProps {
|
||||||
|
return { ...this.props };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,245 +1,239 @@
|
|||||||
import { PortCode } from '../value-objects/port-code.vo';
|
import { PortCode } from '../value-objects/port-code.vo';
|
||||||
import { ContainerType } from '../value-objects/container-type.vo';
|
import { ContainerType } from '../value-objects/container-type.vo';
|
||||||
import { Money } from '../value-objects/money.vo';
|
import { Money } from '../value-objects/money.vo';
|
||||||
import { Volume } from '../value-objects/volume.vo';
|
import { Volume } from '../value-objects/volume.vo';
|
||||||
import { Surcharge, SurchargeCollection } from '../value-objects/surcharge.vo';
|
import { Surcharge, SurchargeCollection } from '../value-objects/surcharge.vo';
|
||||||
import { DateRange } from '../value-objects/date-range.vo';
|
import { DateRange } from '../value-objects/date-range.vo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Volume Range - Valid range for CBM
|
* Volume Range - Valid range for CBM
|
||||||
*/
|
*/
|
||||||
export interface VolumeRange {
|
export interface VolumeRange {
|
||||||
minCBM: number;
|
minCBM: number;
|
||||||
maxCBM: number;
|
maxCBM: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weight Range - Valid range for KG
|
* Weight Range - Valid range for KG
|
||||||
*/
|
*/
|
||||||
export interface WeightRange {
|
export interface WeightRange {
|
||||||
minKG: number;
|
minKG: number;
|
||||||
maxKG: number;
|
maxKG: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Pricing - Pricing structure for CSV rates
|
* Rate Pricing - Pricing structure for CSV rates
|
||||||
*/
|
*/
|
||||||
export interface RatePricing {
|
export interface RatePricing {
|
||||||
pricePerCBM: number;
|
pricePerCBM: number;
|
||||||
pricePerKG: number;
|
pricePerKG: number;
|
||||||
basePriceUSD: Money;
|
basePriceUSD: Money;
|
||||||
basePriceEUR: Money;
|
basePriceEUR: Money;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Entity
|
* CSV Rate Entity
|
||||||
*
|
*
|
||||||
* Represents a shipping rate loaded from CSV file.
|
* Represents a shipping rate loaded from CSV file.
|
||||||
* Contains all information needed to calculate freight costs.
|
* Contains all information needed to calculate freight costs.
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
|
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
|
||||||
* - Rate must be valid (within validity period) to be used
|
* - Rate must be valid (within validity period) to be used
|
||||||
* - Volume and weight must be within specified ranges
|
* - Volume and weight must be within specified ranges
|
||||||
*/
|
*/
|
||||||
export class CsvRate {
|
export class CsvRate {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly companyName: string,
|
public readonly companyName: string,
|
||||||
public readonly origin: PortCode,
|
public readonly origin: PortCode,
|
||||||
public readonly destination: PortCode,
|
public readonly destination: PortCode,
|
||||||
public readonly containerType: ContainerType,
|
public readonly containerType: ContainerType,
|
||||||
public readonly volumeRange: VolumeRange,
|
public readonly volumeRange: VolumeRange,
|
||||||
public readonly weightRange: WeightRange,
|
public readonly weightRange: WeightRange,
|
||||||
public readonly palletCount: number,
|
public readonly palletCount: number,
|
||||||
public readonly pricing: RatePricing,
|
public readonly pricing: RatePricing,
|
||||||
public readonly currency: string, // Primary currency (USD or EUR)
|
public readonly currency: string, // Primary currency (USD or EUR)
|
||||||
public readonly surcharges: SurchargeCollection,
|
public readonly surcharges: SurchargeCollection,
|
||||||
public readonly transitDays: number,
|
public readonly transitDays: number,
|
||||||
public readonly validity: DateRange,
|
public readonly validity: DateRange
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private validate(): void {
|
private validate(): void {
|
||||||
if (!this.companyName || this.companyName.trim().length === 0) {
|
if (!this.companyName || this.companyName.trim().length === 0) {
|
||||||
throw new Error('Company name is required');
|
throw new Error('Company name is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
|
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
|
||||||
throw new Error('Volume range cannot be negative');
|
throw new Error('Volume range cannot be negative');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
|
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
|
||||||
throw new Error('Min volume cannot be greater than max volume');
|
throw new Error('Min volume cannot be greater than max volume');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
|
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
|
||||||
throw new Error('Weight range cannot be negative');
|
throw new Error('Weight range cannot be negative');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.weightRange.minKG > this.weightRange.maxKG) {
|
if (this.weightRange.minKG > this.weightRange.maxKG) {
|
||||||
throw new Error('Min weight cannot be greater than max weight');
|
throw new Error('Min weight cannot be greater than max weight');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.palletCount < 0) {
|
if (this.palletCount < 0) {
|
||||||
throw new Error('Pallet count cannot be negative');
|
throw new Error('Pallet count cannot be negative');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
|
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
|
||||||
throw new Error('Prices cannot be negative');
|
throw new Error('Prices cannot be negative');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.transitDays <= 0) {
|
if (this.transitDays <= 0) {
|
||||||
throw new Error('Transit days must be positive');
|
throw new Error('Transit days must be positive');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currency !== 'USD' && this.currency !== 'EUR') {
|
if (this.currency !== 'USD' && this.currency !== 'EUR') {
|
||||||
throw new Error('Currency must be USD or EUR');
|
throw new Error('Currency must be USD or EUR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate total price for given volume and weight
|
* Calculate total price for given volume and weight
|
||||||
*
|
*
|
||||||
* Business Logic:
|
* Business Logic:
|
||||||
* 1. Calculate volume-based price: volumeCBM * pricePerCBM
|
* 1. Calculate volume-based price: volumeCBM * pricePerCBM
|
||||||
* 2. Calculate weight-based price: weightKG * pricePerKG
|
* 2. Calculate weight-based price: weightKG * pricePerKG
|
||||||
* 3. Take the maximum (freight class rule)
|
* 3. Take the maximum (freight class rule)
|
||||||
* 4. Add surcharges
|
* 4. Add surcharges
|
||||||
*/
|
*/
|
||||||
calculatePrice(volume: Volume): Money {
|
calculatePrice(volume: Volume): Money {
|
||||||
// Freight class rule: max(volume price, weight price)
|
// Freight class rule: max(volume price, weight price)
|
||||||
const freightPrice = volume.calculateFreightPrice(
|
const freightPrice = volume.calculateFreightPrice(
|
||||||
this.pricing.pricePerCBM,
|
this.pricing.pricePerCBM,
|
||||||
this.pricing.pricePerKG,
|
this.pricing.pricePerKG
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create Money object in the rate's currency
|
// Create Money object in the rate's currency
|
||||||
let totalPrice = Money.create(freightPrice, this.currency);
|
let totalPrice = Money.create(freightPrice, this.currency);
|
||||||
|
|
||||||
// Add surcharges in the same currency
|
// Add surcharges in the same currency
|
||||||
const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
|
const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
|
||||||
totalPrice = totalPrice.add(surchargeTotal);
|
totalPrice = totalPrice.add(surchargeTotal);
|
||||||
|
|
||||||
return totalPrice;
|
return totalPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get price in specific currency (USD or EUR)
|
* Get price in specific currency (USD or EUR)
|
||||||
*/
|
*/
|
||||||
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
|
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
|
||||||
const price = this.calculatePrice(volume);
|
const price = this.calculatePrice(volume);
|
||||||
|
|
||||||
// If already in target currency, return as-is
|
// If already in target currency, return as-is
|
||||||
if (price.getCurrency() === targetCurrency) {
|
if (price.getCurrency() === targetCurrency) {
|
||||||
return price;
|
return price;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, use the pre-calculated base price in target currency
|
// Otherwise, use the pre-calculated base price in target currency
|
||||||
// and recalculate proportionally
|
// and recalculate proportionally
|
||||||
const basePriceInPrimaryCurrency =
|
const basePriceInPrimaryCurrency =
|
||||||
this.currency === 'USD'
|
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||||
? this.pricing.basePriceUSD
|
|
||||||
: this.pricing.basePriceEUR;
|
const basePriceInTargetCurrency =
|
||||||
|
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||||
const basePriceInTargetCurrency =
|
|
||||||
targetCurrency === 'USD'
|
// Calculate conversion ratio
|
||||||
? this.pricing.basePriceUSD
|
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
|
||||||
: this.pricing.basePriceEUR;
|
|
||||||
|
// Apply ratio to calculated price
|
||||||
// Calculate conversion ratio
|
const convertedAmount = price.getAmount() * ratio;
|
||||||
const ratio =
|
return Money.create(convertedAmount, targetCurrency);
|
||||||
basePriceInTargetCurrency.getAmount() /
|
}
|
||||||
basePriceInPrimaryCurrency.getAmount();
|
|
||||||
|
/**
|
||||||
// Apply ratio to calculated price
|
* Check if rate is valid for a specific date
|
||||||
const convertedAmount = price.getAmount() * ratio;
|
*/
|
||||||
return Money.create(convertedAmount, targetCurrency);
|
isValidForDate(date: Date): boolean {
|
||||||
}
|
return this.validity.contains(date);
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Check if rate is valid for a specific date
|
/**
|
||||||
*/
|
* Check if rate is currently valid (today is within validity period)
|
||||||
isValidForDate(date: Date): boolean {
|
*/
|
||||||
return this.validity.contains(date);
|
isCurrentlyValid(): boolean {
|
||||||
}
|
return this.validity.isCurrentRange();
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Check if rate is currently valid (today is within validity period)
|
/**
|
||||||
*/
|
* Check if volume and weight match this rate's range
|
||||||
isCurrentlyValid(): boolean {
|
*/
|
||||||
return this.validity.isCurrentRange();
|
matchesVolume(volume: Volume): boolean {
|
||||||
}
|
return volume.isWithinRange(
|
||||||
|
this.volumeRange.minCBM,
|
||||||
/**
|
this.volumeRange.maxCBM,
|
||||||
* Check if volume and weight match this rate's range
|
this.weightRange.minKG,
|
||||||
*/
|
this.weightRange.maxKG
|
||||||
matchesVolume(volume: Volume): boolean {
|
);
|
||||||
return volume.isWithinRange(
|
}
|
||||||
this.volumeRange.minCBM,
|
|
||||||
this.volumeRange.maxCBM,
|
/**
|
||||||
this.weightRange.minKG,
|
* Check if pallet count matches
|
||||||
this.weightRange.maxKG,
|
* 0 means "any pallet count" (flexible)
|
||||||
);
|
* Otherwise must match exactly or be within range
|
||||||
}
|
*/
|
||||||
|
matchesPalletCount(palletCount: number): boolean {
|
||||||
/**
|
// If rate has 0 pallets, it's flexible
|
||||||
* Check if pallet count matches
|
if (this.palletCount === 0) {
|
||||||
* 0 means "any pallet count" (flexible)
|
return true;
|
||||||
* Otherwise must match exactly or be within range
|
}
|
||||||
*/
|
// Otherwise must match exactly
|
||||||
matchesPalletCount(palletCount: number): boolean {
|
return this.palletCount === palletCount;
|
||||||
// If rate has 0 pallets, it's flexible
|
}
|
||||||
if (this.palletCount === 0) {
|
|
||||||
return true;
|
/**
|
||||||
}
|
* Check if rate matches a specific route
|
||||||
// Otherwise must match exactly
|
*/
|
||||||
return this.palletCount === palletCount;
|
matchesRoute(origin: PortCode, destination: PortCode): boolean {
|
||||||
}
|
return this.origin.equals(origin) && this.destination.equals(destination);
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Check if rate matches a specific route
|
/**
|
||||||
*/
|
* Check if rate has separate surcharges
|
||||||
matchesRoute(origin: PortCode, destination: PortCode): boolean {
|
*/
|
||||||
return this.origin.equals(origin) && this.destination.equals(destination);
|
hasSurcharges(): boolean {
|
||||||
}
|
return !this.surcharges.isEmpty();
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Check if rate has separate surcharges
|
/**
|
||||||
*/
|
* Get surcharge details as formatted string
|
||||||
hasSurcharges(): boolean {
|
*/
|
||||||
return !this.surcharges.isEmpty();
|
getSurchargeDetails(): string {
|
||||||
}
|
return this.surcharges.getDetails();
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Get surcharge details as formatted string
|
/**
|
||||||
*/
|
* Check if this is an "all-in" rate (no separate surcharges)
|
||||||
getSurchargeDetails(): string {
|
*/
|
||||||
return this.surcharges.getDetails();
|
isAllInPrice(): boolean {
|
||||||
}
|
return this.surcharges.isEmpty();
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Check if this is an "all-in" rate (no separate surcharges)
|
/**
|
||||||
*/
|
* Get route description
|
||||||
isAllInPrice(): boolean {
|
*/
|
||||||
return this.surcharges.isEmpty();
|
getRouteDescription(): string {
|
||||||
}
|
return `${this.origin.getValue()} → ${this.destination.getValue()}`;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Get route description
|
/**
|
||||||
*/
|
* Get company and route summary
|
||||||
getRouteDescription(): string {
|
*/
|
||||||
return `${this.origin.getValue()} → ${this.destination.getValue()}`;
|
getSummary(): string {
|
||||||
}
|
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Get company and route summary
|
toString(): string {
|
||||||
*/
|
return this.getSummary();
|
||||||
getSummary(): string {
|
}
|
||||||
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.getSummary();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Domain Entities Barrel Export
|
* Domain Entities Barrel Export
|
||||||
*
|
*
|
||||||
* All core domain entities for the Xpeditis platform
|
* All core domain entities for the Xpeditis platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './organization.entity';
|
export * from './organization.entity';
|
||||||
export * from './user.entity';
|
export * from './user.entity';
|
||||||
export * from './carrier.entity';
|
export * from './carrier.entity';
|
||||||
export * from './port.entity';
|
export * from './port.entity';
|
||||||
export * from './rate-quote.entity';
|
export * from './rate-quote.entity';
|
||||||
export * from './container.entity';
|
export * from './container.entity';
|
||||||
export * from './booking.entity';
|
export * from './booking.entity';
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class Notification {
|
|||||||
private constructor(private readonly props: NotificationProps) {}
|
private constructor(private readonly props: NotificationProps) {}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string },
|
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string }
|
||||||
): Notification {
|
): Notification {
|
||||||
return new Notification({
|
return new Notification({
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@ -1,201 +1,201 @@
|
|||||||
/**
|
/**
|
||||||
* Organization Entity
|
* Organization Entity
|
||||||
*
|
*
|
||||||
* Represents a business organization (freight forwarder, carrier, or shipper)
|
* Represents a business organization (freight forwarder, carrier, or shipper)
|
||||||
* in the Xpeditis platform.
|
* in the Xpeditis platform.
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - SCAC code must be unique across all carrier organizations
|
* - SCAC code must be unique across all carrier organizations
|
||||||
* - Name must be unique
|
* - Name must be unique
|
||||||
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
|
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum OrganizationType {
|
export enum OrganizationType {
|
||||||
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
|
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
|
||||||
CARRIER = 'CARRIER',
|
CARRIER = 'CARRIER',
|
||||||
SHIPPER = 'SHIPPER',
|
SHIPPER = 'SHIPPER',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizationAddress {
|
export interface OrganizationAddress {
|
||||||
street: string;
|
street: string;
|
||||||
city: string;
|
city: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizationDocument {
|
export interface OrganizationDocument {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizationProps {
|
export interface OrganizationProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
scac?: string; // Standard Carrier Alpha Code (for carriers only)
|
scac?: string; // Standard Carrier Alpha Code (for carriers only)
|
||||||
address: OrganizationAddress;
|
address: OrganizationAddress;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
documents: OrganizationDocument[];
|
documents: OrganizationDocument[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Organization {
|
export class Organization {
|
||||||
private readonly props: OrganizationProps;
|
private readonly props: OrganizationProps;
|
||||||
|
|
||||||
private constructor(props: OrganizationProps) {
|
private constructor(props: OrganizationProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new Organization
|
* Factory method to create a new Organization
|
||||||
*/
|
*/
|
||||||
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization {
|
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Validate SCAC code if provided
|
// Validate SCAC code if provided
|
||||||
if (props.scac && !Organization.isValidSCAC(props.scac)) {
|
if (props.scac && !Organization.isValidSCAC(props.scac)) {
|
||||||
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
|
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that carriers have SCAC codes
|
// Validate that carriers have SCAC codes
|
||||||
if (props.type === OrganizationType.CARRIER && !props.scac) {
|
if (props.type === OrganizationType.CARRIER && !props.scac) {
|
||||||
throw new Error('Carrier organizations must have a SCAC code.');
|
throw new Error('Carrier organizations must have a SCAC code.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that non-carriers don't have SCAC codes
|
// Validate that non-carriers don't have SCAC codes
|
||||||
if (props.type !== OrganizationType.CARRIER && props.scac) {
|
if (props.type !== OrganizationType.CARRIER && props.scac) {
|
||||||
throw new Error('Only carrier organizations can have SCAC codes.');
|
throw new Error('Only carrier organizations can have SCAC codes.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Organization({
|
return new Organization({
|
||||||
...props,
|
...props,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to reconstitute from persistence
|
* Factory method to reconstitute from persistence
|
||||||
*/
|
*/
|
||||||
static fromPersistence(props: OrganizationProps): Organization {
|
static fromPersistence(props: OrganizationProps): Organization {
|
||||||
return new Organization(props);
|
return new Organization(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate SCAC code format
|
* Validate SCAC code format
|
||||||
* SCAC = Standard Carrier Alpha Code (4 uppercase letters)
|
* SCAC = Standard Carrier Alpha Code (4 uppercase letters)
|
||||||
*/
|
*/
|
||||||
private static isValidSCAC(scac: string): boolean {
|
private static isValidSCAC(scac: string): boolean {
|
||||||
const scacPattern = /^[A-Z]{4}$/;
|
const scacPattern = /^[A-Z]{4}$/;
|
||||||
return scacPattern.test(scac);
|
return scacPattern.test(scac);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this.props.id;
|
return this.props.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return this.props.name;
|
return this.props.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): OrganizationType {
|
get type(): OrganizationType {
|
||||||
return this.props.type;
|
return this.props.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
get scac(): string | undefined {
|
get scac(): string | undefined {
|
||||||
return this.props.scac;
|
return this.props.scac;
|
||||||
}
|
}
|
||||||
|
|
||||||
get address(): OrganizationAddress {
|
get address(): OrganizationAddress {
|
||||||
return { ...this.props.address };
|
return { ...this.props.address };
|
||||||
}
|
}
|
||||||
|
|
||||||
get logoUrl(): string | undefined {
|
get logoUrl(): string | undefined {
|
||||||
return this.props.logoUrl;
|
return this.props.logoUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
get documents(): OrganizationDocument[] {
|
get documents(): OrganizationDocument[] {
|
||||||
return [...this.props.documents];
|
return [...this.props.documents];
|
||||||
}
|
}
|
||||||
|
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updatedAt(): Date {
|
get updatedAt(): Date {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return this.props.isActive;
|
return this.props.isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business methods
|
// Business methods
|
||||||
isCarrier(): boolean {
|
isCarrier(): boolean {
|
||||||
return this.props.type === OrganizationType.CARRIER;
|
return this.props.type === OrganizationType.CARRIER;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFreightForwarder(): boolean {
|
isFreightForwarder(): boolean {
|
||||||
return this.props.type === OrganizationType.FREIGHT_FORWARDER;
|
return this.props.type === OrganizationType.FREIGHT_FORWARDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
isShipper(): boolean {
|
isShipper(): boolean {
|
||||||
return this.props.type === OrganizationType.SHIPPER;
|
return this.props.type === OrganizationType.SHIPPER;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateName(name: string): void {
|
updateName(name: string): void {
|
||||||
if (!name || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
throw new Error('Organization name cannot be empty.');
|
throw new Error('Organization name cannot be empty.');
|
||||||
}
|
}
|
||||||
this.props.name = name.trim();
|
this.props.name = name.trim();
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAddress(address: OrganizationAddress): void {
|
updateAddress(address: OrganizationAddress): void {
|
||||||
this.props.address = { ...address };
|
this.props.address = { ...address };
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLogoUrl(logoUrl: string): void {
|
updateLogoUrl(logoUrl: string): void {
|
||||||
this.props.logoUrl = logoUrl;
|
this.props.logoUrl = logoUrl;
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
addDocument(document: OrganizationDocument): void {
|
addDocument(document: OrganizationDocument): void {
|
||||||
this.props.documents.push(document);
|
this.props.documents.push(document);
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeDocument(documentId: string): void {
|
removeDocument(documentId: string): void {
|
||||||
this.props.documents = this.props.documents.filter(doc => doc.id !== documentId);
|
this.props.documents = this.props.documents.filter(doc => doc.id !== documentId);
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate(): void {
|
deactivate(): void {
|
||||||
this.props.isActive = false;
|
this.props.isActive = false;
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
activate(): void {
|
activate(): void {
|
||||||
this.props.isActive = true;
|
this.props.isActive = true;
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert to plain object for persistence
|
* Convert to plain object for persistence
|
||||||
*/
|
*/
|
||||||
toObject(): OrganizationProps {
|
toObject(): OrganizationProps {
|
||||||
return {
|
return {
|
||||||
...this.props,
|
...this.props,
|
||||||
address: { ...this.props.address },
|
address: { ...this.props.address },
|
||||||
documents: [...this.props.documents],
|
documents: [...this.props.documents],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,205 +1,209 @@
|
|||||||
/**
|
/**
|
||||||
* Port Entity
|
* Port Entity
|
||||||
*
|
*
|
||||||
* Represents a maritime port (based on UN/LOCODE standard)
|
* Represents a maritime port (based on UN/LOCODE standard)
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter location)
|
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter location)
|
||||||
* - Coordinates must be valid latitude/longitude
|
* - Coordinates must be valid latitude/longitude
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PortCoordinates {
|
export interface PortCoordinates {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PortProps {
|
export interface PortProps {
|
||||||
id: string;
|
id: string;
|
||||||
code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam)
|
code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam)
|
||||||
name: string; // Port name
|
name: string; // Port name
|
||||||
city: string;
|
city: string;
|
||||||
country: string; // ISO 3166-1 alpha-2 country code
|
country: string; // ISO 3166-1 alpha-2 country code
|
||||||
countryName: string; // Full country name
|
countryName: string; // Full country name
|
||||||
coordinates: PortCoordinates;
|
coordinates: PortCoordinates;
|
||||||
timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam')
|
timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam')
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Port {
|
export class Port {
|
||||||
private readonly props: PortProps;
|
private readonly props: PortProps;
|
||||||
|
|
||||||
private constructor(props: PortProps) {
|
private constructor(props: PortProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new Port
|
* Factory method to create a new Port
|
||||||
*/
|
*/
|
||||||
static create(props: Omit<PortProps, 'createdAt' | 'updatedAt'>): Port {
|
static create(props: Omit<PortProps, 'createdAt' | 'updatedAt'>): Port {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Validate UN/LOCODE format
|
// Validate UN/LOCODE format
|
||||||
if (!Port.isValidUNLOCODE(props.code)) {
|
if (!Port.isValidUNLOCODE(props.code)) {
|
||||||
throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).');
|
throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate country code
|
// Validate country code
|
||||||
if (!Port.isValidCountryCode(props.country)) {
|
if (!Port.isValidCountryCode(props.country)) {
|
||||||
throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).');
|
throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate coordinates
|
// Validate coordinates
|
||||||
if (!Port.isValidCoordinates(props.coordinates)) {
|
if (!Port.isValidCoordinates(props.coordinates)) {
|
||||||
throw new Error('Invalid coordinates.');
|
throw new Error('Invalid coordinates.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Port({
|
return new Port({
|
||||||
...props,
|
...props,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to reconstitute from persistence
|
* Factory method to reconstitute from persistence
|
||||||
*/
|
*/
|
||||||
static fromPersistence(props: PortProps): Port {
|
static fromPersistence(props: PortProps): Port {
|
||||||
return new Port(props);
|
return new Port(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code)
|
* Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code)
|
||||||
*/
|
*/
|
||||||
private static isValidUNLOCODE(code: string): boolean {
|
private static isValidUNLOCODE(code: string): boolean {
|
||||||
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
|
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
|
||||||
return unlocodePattern.test(code);
|
return unlocodePattern.test(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate ISO 3166-1 alpha-2 country code
|
* Validate ISO 3166-1 alpha-2 country code
|
||||||
*/
|
*/
|
||||||
private static isValidCountryCode(code: string): boolean {
|
private static isValidCountryCode(code: string): boolean {
|
||||||
const countryCodePattern = /^[A-Z]{2}$/;
|
const countryCodePattern = /^[A-Z]{2}$/;
|
||||||
return countryCodePattern.test(code);
|
return countryCodePattern.test(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate coordinates
|
* Validate coordinates
|
||||||
*/
|
*/
|
||||||
private static isValidCoordinates(coords: PortCoordinates): boolean {
|
private static isValidCoordinates(coords: PortCoordinates): boolean {
|
||||||
const { latitude, longitude } = coords;
|
const { latitude, longitude } = coords;
|
||||||
return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
|
return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this.props.id;
|
return this.props.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get code(): string {
|
get code(): string {
|
||||||
return this.props.code;
|
return this.props.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return this.props.name;
|
return this.props.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
get city(): string {
|
get city(): string {
|
||||||
return this.props.city;
|
return this.props.city;
|
||||||
}
|
}
|
||||||
|
|
||||||
get country(): string {
|
get country(): string {
|
||||||
return this.props.country;
|
return this.props.country;
|
||||||
}
|
}
|
||||||
|
|
||||||
get countryName(): string {
|
get countryName(): string {
|
||||||
return this.props.countryName;
|
return this.props.countryName;
|
||||||
}
|
}
|
||||||
|
|
||||||
get coordinates(): PortCoordinates {
|
get coordinates(): PortCoordinates {
|
||||||
return { ...this.props.coordinates };
|
return { ...this.props.coordinates };
|
||||||
}
|
}
|
||||||
|
|
||||||
get timezone(): string | undefined {
|
get timezone(): string | undefined {
|
||||||
return this.props.timezone;
|
return this.props.timezone;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return this.props.isActive;
|
return this.props.isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updatedAt(): Date {
|
get updatedAt(): Date {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business methods
|
// Business methods
|
||||||
/**
|
/**
|
||||||
* Get display name (e.g., "Rotterdam, Netherlands (NLRTM)")
|
* Get display name (e.g., "Rotterdam, Netherlands (NLRTM)")
|
||||||
*/
|
*/
|
||||||
getDisplayName(): string {
|
getDisplayName(): string {
|
||||||
return `${this.props.name}, ${this.props.countryName} (${this.props.code})`;
|
return `${this.props.name}, ${this.props.countryName} (${this.props.code})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate distance to another port (Haversine formula)
|
* Calculate distance to another port (Haversine formula)
|
||||||
* Returns distance in kilometers
|
* Returns distance in kilometers
|
||||||
*/
|
*/
|
||||||
distanceTo(otherPort: Port): number {
|
distanceTo(otherPort: Port): number {
|
||||||
const R = 6371; // Earth's radius in kilometers
|
const R = 6371; // Earth's radius in kilometers
|
||||||
const lat1 = this.toRadians(this.props.coordinates.latitude);
|
const lat1 = this.toRadians(this.props.coordinates.latitude);
|
||||||
const lat2 = this.toRadians(otherPort.coordinates.latitude);
|
const lat2 = this.toRadians(otherPort.coordinates.latitude);
|
||||||
const deltaLat = this.toRadians(otherPort.coordinates.latitude - this.props.coordinates.latitude);
|
const deltaLat = this.toRadians(
|
||||||
const deltaLon = this.toRadians(otherPort.coordinates.longitude - this.props.coordinates.longitude);
|
otherPort.coordinates.latitude - this.props.coordinates.latitude
|
||||||
|
);
|
||||||
const a =
|
const deltaLon = this.toRadians(
|
||||||
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
otherPort.coordinates.longitude - this.props.coordinates.longitude
|
||||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
|
);
|
||||||
|
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
const a =
|
||||||
|
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
||||||
return R * c;
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
|
||||||
}
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
private toRadians(degrees: number): number {
|
|
||||||
return degrees * (Math.PI / 180);
|
return R * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCoordinates(coordinates: PortCoordinates): void {
|
private toRadians(degrees: number): number {
|
||||||
if (!Port.isValidCoordinates(coordinates)) {
|
return degrees * (Math.PI / 180);
|
||||||
throw new Error('Invalid coordinates.');
|
}
|
||||||
}
|
|
||||||
this.props.coordinates = { ...coordinates };
|
updateCoordinates(coordinates: PortCoordinates): void {
|
||||||
this.props.updatedAt = new Date();
|
if (!Port.isValidCoordinates(coordinates)) {
|
||||||
}
|
throw new Error('Invalid coordinates.');
|
||||||
|
}
|
||||||
updateTimezone(timezone: string): void {
|
this.props.coordinates = { ...coordinates };
|
||||||
this.props.timezone = timezone;
|
this.props.updatedAt = new Date();
|
||||||
this.props.updatedAt = new Date();
|
}
|
||||||
}
|
|
||||||
|
updateTimezone(timezone: string): void {
|
||||||
deactivate(): void {
|
this.props.timezone = timezone;
|
||||||
this.props.isActive = false;
|
this.props.updatedAt = new Date();
|
||||||
this.props.updatedAt = new Date();
|
}
|
||||||
}
|
|
||||||
|
deactivate(): void {
|
||||||
activate(): void {
|
this.props.isActive = false;
|
||||||
this.props.isActive = true;
|
this.props.updatedAt = new Date();
|
||||||
this.props.updatedAt = new Date();
|
}
|
||||||
}
|
|
||||||
|
activate(): void {
|
||||||
/**
|
this.props.isActive = true;
|
||||||
* Convert to plain object for persistence
|
this.props.updatedAt = new Date();
|
||||||
*/
|
}
|
||||||
toObject(): PortProps {
|
|
||||||
return {
|
/**
|
||||||
...this.props,
|
* Convert to plain object for persistence
|
||||||
coordinates: { ...this.props.coordinates },
|
*/
|
||||||
};
|
toObject(): PortProps {
|
||||||
}
|
return {
|
||||||
}
|
...this.props,
|
||||||
|
coordinates: { ...this.props.coordinates },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,240 +1,240 @@
|
|||||||
/**
|
/**
|
||||||
* RateQuote Entity Unit Tests
|
* RateQuote Entity Unit Tests
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RateQuote } from './rate-quote.entity';
|
import { RateQuote } from './rate-quote.entity';
|
||||||
|
|
||||||
describe('RateQuote Entity', () => {
|
describe('RateQuote Entity', () => {
|
||||||
const validProps = {
|
const validProps = {
|
||||||
id: 'quote-1',
|
id: 'quote-1',
|
||||||
carrierId: 'carrier-1',
|
carrierId: 'carrier-1',
|
||||||
carrierName: 'Maersk',
|
carrierName: 'Maersk',
|
||||||
carrierCode: 'MAERSK',
|
carrierCode: 'MAERSK',
|
||||||
origin: {
|
origin: {
|
||||||
code: 'NLRTM',
|
code: 'NLRTM',
|
||||||
name: 'Rotterdam',
|
name: 'Rotterdam',
|
||||||
country: 'Netherlands',
|
country: 'Netherlands',
|
||||||
},
|
},
|
||||||
destination: {
|
destination: {
|
||||||
code: 'USNYC',
|
code: 'USNYC',
|
||||||
name: 'New York',
|
name: 'New York',
|
||||||
country: 'United States',
|
country: 'United States',
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
baseFreight: 1000,
|
baseFreight: 1000,
|
||||||
surcharges: [
|
surcharges: [
|
||||||
{ type: 'BAF', description: 'Bunker Adjustment Factor', amount: 100, currency: 'USD' },
|
{ type: 'BAF', description: 'Bunker Adjustment Factor', amount: 100, currency: 'USD' },
|
||||||
],
|
],
|
||||||
totalAmount: 1100,
|
totalAmount: 1100,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
},
|
},
|
||||||
containerType: '40HC',
|
containerType: '40HC',
|
||||||
mode: 'FCL' as const,
|
mode: 'FCL' as const,
|
||||||
etd: new Date('2025-11-01'),
|
etd: new Date('2025-11-01'),
|
||||||
eta: new Date('2025-11-20'),
|
eta: new Date('2025-11-20'),
|
||||||
transitDays: 19,
|
transitDays: 19,
|
||||||
route: [
|
route: [
|
||||||
{
|
{
|
||||||
portCode: 'NLRTM',
|
portCode: 'NLRTM',
|
||||||
portName: 'Rotterdam',
|
portName: 'Rotterdam',
|
||||||
departure: new Date('2025-11-01'),
|
departure: new Date('2025-11-01'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
portCode: 'USNYC',
|
portCode: 'USNYC',
|
||||||
portName: 'New York',
|
portName: 'New York',
|
||||||
arrival: new Date('2025-11-20'),
|
arrival: new Date('2025-11-20'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
availability: 50,
|
availability: 50,
|
||||||
frequency: 'Weekly',
|
frequency: 'Weekly',
|
||||||
vesselType: 'Container Ship',
|
vesselType: 'Container Ship',
|
||||||
co2EmissionsKg: 2500,
|
co2EmissionsKg: 2500,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create rate quote with valid props', () => {
|
it('should create rate quote with valid props', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
expect(rateQuote.id).toBe('quote-1');
|
expect(rateQuote.id).toBe('quote-1');
|
||||||
expect(rateQuote.carrierName).toBe('Maersk');
|
expect(rateQuote.carrierName).toBe('Maersk');
|
||||||
expect(rateQuote.origin.code).toBe('NLRTM');
|
expect(rateQuote.origin.code).toBe('NLRTM');
|
||||||
expect(rateQuote.destination.code).toBe('USNYC');
|
expect(rateQuote.destination.code).toBe('USNYC');
|
||||||
expect(rateQuote.pricing.totalAmount).toBe(1100);
|
expect(rateQuote.pricing.totalAmount).toBe(1100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set validUntil to 15 minutes from now', () => {
|
it('should set validUntil to 15 minutes from now', () => {
|
||||||
const before = new Date();
|
const before = new Date();
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
const after = new Date();
|
const after = new Date();
|
||||||
|
|
||||||
const expectedValidUntil = new Date(before.getTime() + 15 * 60 * 1000);
|
const expectedValidUntil = new Date(before.getTime() + 15 * 60 * 1000);
|
||||||
const diff = Math.abs(rateQuote.validUntil.getTime() - expectedValidUntil.getTime());
|
const diff = Math.abs(rateQuote.validUntil.getTime() - expectedValidUntil.getTime());
|
||||||
|
|
||||||
// Allow 1 second tolerance for test execution time
|
// Allow 1 second tolerance for test execution time
|
||||||
expect(diff).toBeLessThan(1000);
|
expect(diff).toBeLessThan(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for non-positive total price', () => {
|
it('should throw error for non-positive total price', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
RateQuote.create({
|
RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
pricing: { ...validProps.pricing, totalAmount: 0 },
|
pricing: { ...validProps.pricing, totalAmount: 0 },
|
||||||
})
|
})
|
||||||
).toThrow('Total price must be positive');
|
).toThrow('Total price must be positive');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for non-positive base freight', () => {
|
it('should throw error for non-positive base freight', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
RateQuote.create({
|
RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
pricing: { ...validProps.pricing, baseFreight: 0 },
|
pricing: { ...validProps.pricing, baseFreight: 0 },
|
||||||
})
|
})
|
||||||
).toThrow('Base freight must be positive');
|
).toThrow('Base freight must be positive');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error if ETA is not after ETD', () => {
|
it('should throw error if ETA is not after ETD', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
RateQuote.create({
|
RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
eta: new Date('2025-10-31'),
|
eta: new Date('2025-10-31'),
|
||||||
})
|
})
|
||||||
).toThrow('ETA must be after ETD');
|
).toThrow('ETA must be after ETD');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for non-positive transit days', () => {
|
it('should throw error for non-positive transit days', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
RateQuote.create({
|
RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
transitDays: 0,
|
transitDays: 0,
|
||||||
})
|
})
|
||||||
).toThrow('Transit days must be positive');
|
).toThrow('Transit days must be positive');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for negative availability', () => {
|
it('should throw error for negative availability', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
RateQuote.create({
|
RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
availability: -1,
|
availability: -1,
|
||||||
})
|
})
|
||||||
).toThrow('Availability cannot be negative');
|
).toThrow('Availability cannot be negative');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error if route has less than 2 segments', () => {
|
it('should throw error if route has less than 2 segments', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
RateQuote.create({
|
RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
route: [{ portCode: 'NLRTM', portName: 'Rotterdam' }],
|
route: [{ portCode: 'NLRTM', portName: 'Rotterdam' }],
|
||||||
})
|
})
|
||||||
).toThrow('Route must have at least origin and destination');
|
).toThrow('Route must have at least origin and destination');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isValid', () => {
|
describe('isValid', () => {
|
||||||
it('should return true for non-expired quote', () => {
|
it('should return true for non-expired quote', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
expect(rateQuote.isValid()).toBe(true);
|
expect(rateQuote.isValid()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for expired quote', () => {
|
it('should return false for expired quote', () => {
|
||||||
const expiredQuote = RateQuote.fromPersistence({
|
const expiredQuote = RateQuote.fromPersistence({
|
||||||
...validProps,
|
...validProps,
|
||||||
validUntil: new Date(Date.now() - 1000), // 1 second ago
|
validUntil: new Date(Date.now() - 1000), // 1 second ago
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
expect(expiredQuote.isValid()).toBe(false);
|
expect(expiredQuote.isValid()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isExpired', () => {
|
describe('isExpired', () => {
|
||||||
it('should return false for non-expired quote', () => {
|
it('should return false for non-expired quote', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
expect(rateQuote.isExpired()).toBe(false);
|
expect(rateQuote.isExpired()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for expired quote', () => {
|
it('should return true for expired quote', () => {
|
||||||
const expiredQuote = RateQuote.fromPersistence({
|
const expiredQuote = RateQuote.fromPersistence({
|
||||||
...validProps,
|
...validProps,
|
||||||
validUntil: new Date(Date.now() - 1000),
|
validUntil: new Date(Date.now() - 1000),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
expect(expiredQuote.isExpired()).toBe(true);
|
expect(expiredQuote.isExpired()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasAvailability', () => {
|
describe('hasAvailability', () => {
|
||||||
it('should return true when availability > 0', () => {
|
it('should return true when availability > 0', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
expect(rateQuote.hasAvailability()).toBe(true);
|
expect(rateQuote.hasAvailability()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when availability = 0', () => {
|
it('should return false when availability = 0', () => {
|
||||||
const rateQuote = RateQuote.create({ ...validProps, availability: 0 });
|
const rateQuote = RateQuote.create({ ...validProps, availability: 0 });
|
||||||
expect(rateQuote.hasAvailability()).toBe(false);
|
expect(rateQuote.hasAvailability()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTotalSurcharges', () => {
|
describe('getTotalSurcharges', () => {
|
||||||
it('should calculate total surcharges', () => {
|
it('should calculate total surcharges', () => {
|
||||||
const rateQuote = RateQuote.create({
|
const rateQuote = RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
pricing: {
|
pricing: {
|
||||||
baseFreight: 1000,
|
baseFreight: 1000,
|
||||||
surcharges: [
|
surcharges: [
|
||||||
{ type: 'BAF', description: 'BAF', amount: 100, currency: 'USD' },
|
{ type: 'BAF', description: 'BAF', amount: 100, currency: 'USD' },
|
||||||
{ type: 'CAF', description: 'CAF', amount: 50, currency: 'USD' },
|
{ type: 'CAF', description: 'CAF', amount: 50, currency: 'USD' },
|
||||||
],
|
],
|
||||||
totalAmount: 1150,
|
totalAmount: 1150,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(rateQuote.getTotalSurcharges()).toBe(150);
|
expect(rateQuote.getTotalSurcharges()).toBe(150);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTransshipmentCount', () => {
|
describe('getTransshipmentCount', () => {
|
||||||
it('should return 0 for direct route', () => {
|
it('should return 0 for direct route', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
expect(rateQuote.getTransshipmentCount()).toBe(0);
|
expect(rateQuote.getTransshipmentCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return correct count for route with transshipments', () => {
|
it('should return correct count for route with transshipments', () => {
|
||||||
const rateQuote = RateQuote.create({
|
const rateQuote = RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
route: [
|
route: [
|
||||||
{ portCode: 'NLRTM', portName: 'Rotterdam' },
|
{ portCode: 'NLRTM', portName: 'Rotterdam' },
|
||||||
{ portCode: 'ESBCN', portName: 'Barcelona' },
|
{ portCode: 'ESBCN', portName: 'Barcelona' },
|
||||||
{ portCode: 'USNYC', portName: 'New York' },
|
{ portCode: 'USNYC', portName: 'New York' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(rateQuote.getTransshipmentCount()).toBe(1);
|
expect(rateQuote.getTransshipmentCount()).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isDirectRoute', () => {
|
describe('isDirectRoute', () => {
|
||||||
it('should return true for direct route', () => {
|
it('should return true for direct route', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
expect(rateQuote.isDirectRoute()).toBe(true);
|
expect(rateQuote.isDirectRoute()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for route with transshipments', () => {
|
it('should return false for route with transshipments', () => {
|
||||||
const rateQuote = RateQuote.create({
|
const rateQuote = RateQuote.create({
|
||||||
...validProps,
|
...validProps,
|
||||||
route: [
|
route: [
|
||||||
{ portCode: 'NLRTM', portName: 'Rotterdam' },
|
{ portCode: 'NLRTM', portName: 'Rotterdam' },
|
||||||
{ portCode: 'ESBCN', portName: 'Barcelona' },
|
{ portCode: 'ESBCN', portName: 'Barcelona' },
|
||||||
{ portCode: 'USNYC', portName: 'New York' },
|
{ portCode: 'USNYC', portName: 'New York' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(rateQuote.isDirectRoute()).toBe(false);
|
expect(rateQuote.isDirectRoute()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPricePerDay', () => {
|
describe('getPricePerDay', () => {
|
||||||
it('should calculate price per day', () => {
|
it('should calculate price per day', () => {
|
||||||
const rateQuote = RateQuote.create(validProps);
|
const rateQuote = RateQuote.create(validProps);
|
||||||
const pricePerDay = rateQuote.getPricePerDay();
|
const pricePerDay = rateQuote.getPricePerDay();
|
||||||
expect(pricePerDay).toBeCloseTo(1100 / 19, 2);
|
expect(pricePerDay).toBeCloseTo(1100 / 19, 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,277 +1,277 @@
|
|||||||
/**
|
/**
|
||||||
* RateQuote Entity
|
* RateQuote Entity
|
||||||
*
|
*
|
||||||
* Represents a shipping rate quote from a carrier
|
* Represents a shipping rate quote from a carrier
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Price must be positive
|
* - Price must be positive
|
||||||
* - ETA must be after ETD
|
* - ETA must be after ETD
|
||||||
* - Transit days must be positive
|
* - Transit days must be positive
|
||||||
* - Rate quotes expire after 15 minutes (cache TTL)
|
* - Rate quotes expire after 15 minutes (cache TTL)
|
||||||
* - Availability must be between 0 and actual capacity
|
* - Availability must be between 0 and actual capacity
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RouteSegment {
|
export interface RouteSegment {
|
||||||
portCode: string;
|
portCode: string;
|
||||||
portName: string;
|
portName: string;
|
||||||
arrival?: Date;
|
arrival?: Date;
|
||||||
departure?: Date;
|
departure?: Date;
|
||||||
vesselName?: string;
|
vesselName?: string;
|
||||||
voyageNumber?: string;
|
voyageNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Surcharge {
|
export interface Surcharge {
|
||||||
type: string; // e.g., 'BAF', 'CAF', 'THC', 'ISPS'
|
type: string; // e.g., 'BAF', 'CAF', 'THC', 'ISPS'
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceBreakdown {
|
export interface PriceBreakdown {
|
||||||
baseFreight: number;
|
baseFreight: number;
|
||||||
surcharges: Surcharge[];
|
surcharges: Surcharge[];
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RateQuoteProps {
|
export interface RateQuoteProps {
|
||||||
id: string;
|
id: string;
|
||||||
carrierId: string;
|
carrierId: string;
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
carrierCode: string;
|
carrierCode: string;
|
||||||
origin: {
|
origin: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
country: string;
|
country: string;
|
||||||
};
|
};
|
||||||
destination: {
|
destination: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
country: string;
|
country: string;
|
||||||
};
|
};
|
||||||
pricing: PriceBreakdown;
|
pricing: PriceBreakdown;
|
||||||
containerType: string; // e.g., '20DRY', '40HC', '40REEFER'
|
containerType: string; // e.g., '20DRY', '40HC', '40REEFER'
|
||||||
mode: 'FCL' | 'LCL';
|
mode: 'FCL' | 'LCL';
|
||||||
etd: Date; // Estimated Time of Departure
|
etd: Date; // Estimated Time of Departure
|
||||||
eta: Date; // Estimated Time of Arrival
|
eta: Date; // Estimated Time of Arrival
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
route: RouteSegment[];
|
route: RouteSegment[];
|
||||||
availability: number; // Available container slots
|
availability: number; // Available container slots
|
||||||
frequency: string; // e.g., 'Weekly', 'Bi-weekly'
|
frequency: string; // e.g., 'Weekly', 'Bi-weekly'
|
||||||
vesselType?: string; // e.g., 'Container Ship', 'Ro-Ro'
|
vesselType?: string; // e.g., 'Container Ship', 'Ro-Ro'
|
||||||
co2EmissionsKg?: number; // CO2 emissions in kg
|
co2EmissionsKg?: number; // CO2 emissions in kg
|
||||||
validUntil: Date; // When this quote expires (typically createdAt + 15 min)
|
validUntil: Date; // When this quote expires (typically createdAt + 15 min)
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RateQuote {
|
export class RateQuote {
|
||||||
private readonly props: RateQuoteProps;
|
private readonly props: RateQuoteProps;
|
||||||
|
|
||||||
private constructor(props: RateQuoteProps) {
|
private constructor(props: RateQuoteProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new RateQuote
|
* Factory method to create a new RateQuote
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(
|
||||||
props: Omit<RateQuoteProps, 'id' | 'validUntil' | 'createdAt' | 'updatedAt'> & { id: string }
|
props: Omit<RateQuoteProps, 'id' | 'validUntil' | 'createdAt' | 'updatedAt'> & { id: string }
|
||||||
): RateQuote {
|
): RateQuote {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const validUntil = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes
|
const validUntil = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes
|
||||||
|
|
||||||
// Validate pricing
|
// Validate pricing
|
||||||
if (props.pricing.totalAmount <= 0) {
|
if (props.pricing.totalAmount <= 0) {
|
||||||
throw new Error('Total price must be positive.');
|
throw new Error('Total price must be positive.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.pricing.baseFreight <= 0) {
|
if (props.pricing.baseFreight <= 0) {
|
||||||
throw new Error('Base freight must be positive.');
|
throw new Error('Base freight must be positive.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate dates
|
// Validate dates
|
||||||
if (props.eta <= props.etd) {
|
if (props.eta <= props.etd) {
|
||||||
throw new Error('ETA must be after ETD.');
|
throw new Error('ETA must be after ETD.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate transit days
|
// Validate transit days
|
||||||
if (props.transitDays <= 0) {
|
if (props.transitDays <= 0) {
|
||||||
throw new Error('Transit days must be positive.');
|
throw new Error('Transit days must be positive.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate availability
|
// Validate availability
|
||||||
if (props.availability < 0) {
|
if (props.availability < 0) {
|
||||||
throw new Error('Availability cannot be negative.');
|
throw new Error('Availability cannot be negative.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate route has at least origin and destination
|
// Validate route has at least origin and destination
|
||||||
if (props.route.length < 2) {
|
if (props.route.length < 2) {
|
||||||
throw new Error('Route must have at least origin and destination ports.');
|
throw new Error('Route must have at least origin and destination ports.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RateQuote({
|
return new RateQuote({
|
||||||
...props,
|
...props,
|
||||||
validUntil,
|
validUntil,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to reconstitute from persistence
|
* Factory method to reconstitute from persistence
|
||||||
*/
|
*/
|
||||||
static fromPersistence(props: RateQuoteProps): RateQuote {
|
static fromPersistence(props: RateQuoteProps): RateQuote {
|
||||||
return new RateQuote(props);
|
return new RateQuote(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this.props.id;
|
return this.props.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get carrierId(): string {
|
get carrierId(): string {
|
||||||
return this.props.carrierId;
|
return this.props.carrierId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get carrierName(): string {
|
get carrierName(): string {
|
||||||
return this.props.carrierName;
|
return this.props.carrierName;
|
||||||
}
|
}
|
||||||
|
|
||||||
get carrierCode(): string {
|
get carrierCode(): string {
|
||||||
return this.props.carrierCode;
|
return this.props.carrierCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
get origin(): { code: string; name: string; country: string } {
|
get origin(): { code: string; name: string; country: string } {
|
||||||
return { ...this.props.origin };
|
return { ...this.props.origin };
|
||||||
}
|
}
|
||||||
|
|
||||||
get destination(): { code: string; name: string; country: string } {
|
get destination(): { code: string; name: string; country: string } {
|
||||||
return { ...this.props.destination };
|
return { ...this.props.destination };
|
||||||
}
|
}
|
||||||
|
|
||||||
get pricing(): PriceBreakdown {
|
get pricing(): PriceBreakdown {
|
||||||
return {
|
return {
|
||||||
...this.props.pricing,
|
...this.props.pricing,
|
||||||
surcharges: [...this.props.pricing.surcharges],
|
surcharges: [...this.props.pricing.surcharges],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get containerType(): string {
|
get containerType(): string {
|
||||||
return this.props.containerType;
|
return this.props.containerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
get mode(): 'FCL' | 'LCL' {
|
get mode(): 'FCL' | 'LCL' {
|
||||||
return this.props.mode;
|
return this.props.mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
get etd(): Date {
|
get etd(): Date {
|
||||||
return this.props.etd;
|
return this.props.etd;
|
||||||
}
|
}
|
||||||
|
|
||||||
get eta(): Date {
|
get eta(): Date {
|
||||||
return this.props.eta;
|
return this.props.eta;
|
||||||
}
|
}
|
||||||
|
|
||||||
get transitDays(): number {
|
get transitDays(): number {
|
||||||
return this.props.transitDays;
|
return this.props.transitDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
get route(): RouteSegment[] {
|
get route(): RouteSegment[] {
|
||||||
return [...this.props.route];
|
return [...this.props.route];
|
||||||
}
|
}
|
||||||
|
|
||||||
get availability(): number {
|
get availability(): number {
|
||||||
return this.props.availability;
|
return this.props.availability;
|
||||||
}
|
}
|
||||||
|
|
||||||
get frequency(): string {
|
get frequency(): string {
|
||||||
return this.props.frequency;
|
return this.props.frequency;
|
||||||
}
|
}
|
||||||
|
|
||||||
get vesselType(): string | undefined {
|
get vesselType(): string | undefined {
|
||||||
return this.props.vesselType;
|
return this.props.vesselType;
|
||||||
}
|
}
|
||||||
|
|
||||||
get co2EmissionsKg(): number | undefined {
|
get co2EmissionsKg(): number | undefined {
|
||||||
return this.props.co2EmissionsKg;
|
return this.props.co2EmissionsKg;
|
||||||
}
|
}
|
||||||
|
|
||||||
get validUntil(): Date {
|
get validUntil(): Date {
|
||||||
return this.props.validUntil;
|
return this.props.validUntil;
|
||||||
}
|
}
|
||||||
|
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updatedAt(): Date {
|
get updatedAt(): Date {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business methods
|
// Business methods
|
||||||
/**
|
/**
|
||||||
* Check if the rate quote is still valid (not expired)
|
* Check if the rate quote is still valid (not expired)
|
||||||
*/
|
*/
|
||||||
isValid(): boolean {
|
isValid(): boolean {
|
||||||
return new Date() < this.props.validUntil;
|
return new Date() < this.props.validUntil;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the rate quote has expired
|
* Check if the rate quote has expired
|
||||||
*/
|
*/
|
||||||
isExpired(): boolean {
|
isExpired(): boolean {
|
||||||
return new Date() >= this.props.validUntil;
|
return new Date() >= this.props.validUntil;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if containers are available
|
* Check if containers are available
|
||||||
*/
|
*/
|
||||||
hasAvailability(): boolean {
|
hasAvailability(): boolean {
|
||||||
return this.props.availability > 0;
|
return this.props.availability > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total surcharges amount
|
* Get total surcharges amount
|
||||||
*/
|
*/
|
||||||
getTotalSurcharges(): number {
|
getTotalSurcharges(): number {
|
||||||
return this.props.pricing.surcharges.reduce((sum, surcharge) => sum + surcharge.amount, 0);
|
return this.props.pricing.surcharges.reduce((sum, surcharge) => sum + surcharge.amount, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get number of transshipments (route segments minus 2 for origin and destination)
|
* Get number of transshipments (route segments minus 2 for origin and destination)
|
||||||
*/
|
*/
|
||||||
getTransshipmentCount(): number {
|
getTransshipmentCount(): number {
|
||||||
return Math.max(0, this.props.route.length - 2);
|
return Math.max(0, this.props.route.length - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this is a direct route (no transshipments)
|
* Check if this is a direct route (no transshipments)
|
||||||
*/
|
*/
|
||||||
isDirectRoute(): boolean {
|
isDirectRoute(): boolean {
|
||||||
return this.getTransshipmentCount() === 0;
|
return this.getTransshipmentCount() === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get price per day (for comparison)
|
* Get price per day (for comparison)
|
||||||
*/
|
*/
|
||||||
getPricePerDay(): number {
|
getPricePerDay(): number {
|
||||||
return this.props.pricing.totalAmount / this.props.transitDays;
|
return this.props.pricing.totalAmount / this.props.transitDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert to plain object for persistence
|
* Convert to plain object for persistence
|
||||||
*/
|
*/
|
||||||
toObject(): RateQuoteProps {
|
toObject(): RateQuoteProps {
|
||||||
return {
|
return {
|
||||||
...this.props,
|
...this.props,
|
||||||
origin: { ...this.props.origin },
|
origin: { ...this.props.origin },
|
||||||
destination: { ...this.props.destination },
|
destination: { ...this.props.destination },
|
||||||
pricing: {
|
pricing: {
|
||||||
...this.props.pricing,
|
...this.props.pricing,
|
||||||
surcharges: [...this.props.pricing.surcharges],
|
surcharges: [...this.props.pricing.surcharges],
|
||||||
},
|
},
|
||||||
route: [...this.props.route],
|
route: [...this.props.route],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,250 +1,253 @@
|
|||||||
/**
|
/**
|
||||||
* User Entity
|
* User Entity
|
||||||
*
|
*
|
||||||
* Represents a user account in the Xpeditis platform.
|
* Represents a user account in the Xpeditis platform.
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Email must be valid and unique
|
* - Email must be valid and unique
|
||||||
* - Password must meet complexity requirements (enforced at application layer)
|
* - Password must meet complexity requirements (enforced at application layer)
|
||||||
* - Users belong to an organization
|
* - Users belong to an organization
|
||||||
* - Role-based access control (Admin, Manager, User, Viewer)
|
* - Role-based access control (Admin, Manager, User, Viewer)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
ADMIN = 'admin', // Full system access
|
ADMIN = 'admin', // Full system access
|
||||||
MANAGER = 'manager', // Manage bookings and users within organization
|
MANAGER = 'manager', // Manage bookings and users within organization
|
||||||
USER = 'user', // Create and view bookings
|
USER = 'user', // Create and view bookings
|
||||||
VIEWER = 'viewer', // Read-only access
|
VIEWER = 'viewer', // Read-only access
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProps {
|
export interface UserProps {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
email: string;
|
email: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
totpSecret?: string; // For 2FA
|
totpSecret?: string; // For 2FA
|
||||||
isEmailVerified: boolean;
|
isEmailVerified: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLoginAt?: Date;
|
lastLoginAt?: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class User {
|
export class User {
|
||||||
private readonly props: UserProps;
|
private readonly props: UserProps;
|
||||||
|
|
||||||
private constructor(props: UserProps) {
|
private constructor(props: UserProps) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new User
|
* Factory method to create a new User
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(
|
||||||
props: Omit<UserProps, 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'>
|
props: Omit<
|
||||||
): User {
|
UserProps,
|
||||||
const now = new Date();
|
'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
|
||||||
|
>
|
||||||
// Validate email format (basic validation)
|
): User {
|
||||||
if (!User.isValidEmail(props.email)) {
|
const now = new Date();
|
||||||
throw new Error('Invalid email format.');
|
|
||||||
}
|
// Validate email format (basic validation)
|
||||||
|
if (!User.isValidEmail(props.email)) {
|
||||||
return new User({
|
throw new Error('Invalid email format.');
|
||||||
...props,
|
}
|
||||||
isEmailVerified: false,
|
|
||||||
isActive: true,
|
return new User({
|
||||||
createdAt: now,
|
...props,
|
||||||
updatedAt: now,
|
isEmailVerified: false,
|
||||||
});
|
isActive: true,
|
||||||
}
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
/**
|
});
|
||||||
* Factory method to reconstitute from persistence
|
}
|
||||||
*/
|
|
||||||
static fromPersistence(props: UserProps): User {
|
/**
|
||||||
return new User(props);
|
* Factory method to reconstitute from persistence
|
||||||
}
|
*/
|
||||||
|
static fromPersistence(props: UserProps): User {
|
||||||
/**
|
return new User(props);
|
||||||
* Validate email format
|
}
|
||||||
*/
|
|
||||||
private static isValidEmail(email: string): boolean {
|
/**
|
||||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
* Validate email format
|
||||||
return emailPattern.test(email);
|
*/
|
||||||
}
|
private static isValidEmail(email: string): boolean {
|
||||||
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
// Getters
|
return emailPattern.test(email);
|
||||||
get id(): string {
|
}
|
||||||
return this.props.id;
|
|
||||||
}
|
// Getters
|
||||||
|
get id(): string {
|
||||||
get organizationId(): string {
|
return this.props.id;
|
||||||
return this.props.organizationId;
|
}
|
||||||
}
|
|
||||||
|
get organizationId(): string {
|
||||||
get email(): string {
|
return this.props.organizationId;
|
||||||
return this.props.email;
|
}
|
||||||
}
|
|
||||||
|
get email(): string {
|
||||||
get passwordHash(): string {
|
return this.props.email;
|
||||||
return this.props.passwordHash;
|
}
|
||||||
}
|
|
||||||
|
get passwordHash(): string {
|
||||||
get role(): UserRole {
|
return this.props.passwordHash;
|
||||||
return this.props.role;
|
}
|
||||||
}
|
|
||||||
|
get role(): UserRole {
|
||||||
get firstName(): string {
|
return this.props.role;
|
||||||
return this.props.firstName;
|
}
|
||||||
}
|
|
||||||
|
get firstName(): string {
|
||||||
get lastName(): string {
|
return this.props.firstName;
|
||||||
return this.props.lastName;
|
}
|
||||||
}
|
|
||||||
|
get lastName(): string {
|
||||||
get fullName(): string {
|
return this.props.lastName;
|
||||||
return `${this.props.firstName} ${this.props.lastName}`;
|
}
|
||||||
}
|
|
||||||
|
get fullName(): string {
|
||||||
get phoneNumber(): string | undefined {
|
return `${this.props.firstName} ${this.props.lastName}`;
|
||||||
return this.props.phoneNumber;
|
}
|
||||||
}
|
|
||||||
|
get phoneNumber(): string | undefined {
|
||||||
get totpSecret(): string | undefined {
|
return this.props.phoneNumber;
|
||||||
return this.props.totpSecret;
|
}
|
||||||
}
|
|
||||||
|
get totpSecret(): string | undefined {
|
||||||
get isEmailVerified(): boolean {
|
return this.props.totpSecret;
|
||||||
return this.props.isEmailVerified;
|
}
|
||||||
}
|
|
||||||
|
get isEmailVerified(): boolean {
|
||||||
get isActive(): boolean {
|
return this.props.isEmailVerified;
|
||||||
return this.props.isActive;
|
}
|
||||||
}
|
|
||||||
|
get isActive(): boolean {
|
||||||
get lastLoginAt(): Date | undefined {
|
return this.props.isActive;
|
||||||
return this.props.lastLoginAt;
|
}
|
||||||
}
|
|
||||||
|
get lastLoginAt(): Date | undefined {
|
||||||
get createdAt(): Date {
|
return this.props.lastLoginAt;
|
||||||
return this.props.createdAt;
|
}
|
||||||
}
|
|
||||||
|
get createdAt(): Date {
|
||||||
get updatedAt(): Date {
|
return this.props.createdAt;
|
||||||
return this.props.updatedAt;
|
}
|
||||||
}
|
|
||||||
|
get updatedAt(): Date {
|
||||||
// Business methods
|
return this.props.updatedAt;
|
||||||
has2FAEnabled(): boolean {
|
}
|
||||||
return !!this.props.totpSecret;
|
|
||||||
}
|
// Business methods
|
||||||
|
has2FAEnabled(): boolean {
|
||||||
isAdmin(): boolean {
|
return !!this.props.totpSecret;
|
||||||
return this.props.role === UserRole.ADMIN;
|
}
|
||||||
}
|
|
||||||
|
isAdmin(): boolean {
|
||||||
isManager(): boolean {
|
return this.props.role === UserRole.ADMIN;
|
||||||
return this.props.role === UserRole.MANAGER;
|
}
|
||||||
}
|
|
||||||
|
isManager(): boolean {
|
||||||
isRegularUser(): boolean {
|
return this.props.role === UserRole.MANAGER;
|
||||||
return this.props.role === UserRole.USER;
|
}
|
||||||
}
|
|
||||||
|
isRegularUser(): boolean {
|
||||||
isViewer(): boolean {
|
return this.props.role === UserRole.USER;
|
||||||
return this.props.role === UserRole.VIEWER;
|
}
|
||||||
}
|
|
||||||
|
isViewer(): boolean {
|
||||||
canManageUsers(): boolean {
|
return this.props.role === UserRole.VIEWER;
|
||||||
return this.props.role === UserRole.ADMIN || this.props.role === UserRole.MANAGER;
|
}
|
||||||
}
|
|
||||||
|
canManageUsers(): boolean {
|
||||||
canCreateBookings(): boolean {
|
return this.props.role === UserRole.ADMIN || this.props.role === UserRole.MANAGER;
|
||||||
return (
|
}
|
||||||
this.props.role === UserRole.ADMIN ||
|
|
||||||
this.props.role === UserRole.MANAGER ||
|
canCreateBookings(): boolean {
|
||||||
this.props.role === UserRole.USER
|
return (
|
||||||
);
|
this.props.role === UserRole.ADMIN ||
|
||||||
}
|
this.props.role === UserRole.MANAGER ||
|
||||||
|
this.props.role === UserRole.USER
|
||||||
updatePassword(newPasswordHash: string): void {
|
);
|
||||||
this.props.passwordHash = newPasswordHash;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
updatePassword(newPasswordHash: string): void {
|
||||||
|
this.props.passwordHash = newPasswordHash;
|
||||||
updateRole(newRole: UserRole): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.role = newRole;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
updateRole(newRole: UserRole): void {
|
||||||
|
this.props.role = newRole;
|
||||||
updateFirstName(firstName: string): void {
|
this.props.updatedAt = new Date();
|
||||||
if (!firstName || firstName.trim().length === 0) {
|
}
|
||||||
throw new Error('First name cannot be empty.');
|
|
||||||
}
|
updateFirstName(firstName: string): void {
|
||||||
this.props.firstName = firstName.trim();
|
if (!firstName || firstName.trim().length === 0) {
|
||||||
this.props.updatedAt = new Date();
|
throw new Error('First name cannot be empty.');
|
||||||
}
|
}
|
||||||
|
this.props.firstName = firstName.trim();
|
||||||
updateLastName(lastName: string): void {
|
this.props.updatedAt = new Date();
|
||||||
if (!lastName || lastName.trim().length === 0) {
|
}
|
||||||
throw new Error('Last name cannot be empty.');
|
|
||||||
}
|
updateLastName(lastName: string): void {
|
||||||
this.props.lastName = lastName.trim();
|
if (!lastName || lastName.trim().length === 0) {
|
||||||
this.props.updatedAt = new Date();
|
throw new Error('Last name cannot be empty.');
|
||||||
}
|
}
|
||||||
|
this.props.lastName = lastName.trim();
|
||||||
updateProfile(firstName: string, lastName: string, phoneNumber?: string): void {
|
this.props.updatedAt = new Date();
|
||||||
if (!firstName || firstName.trim().length === 0) {
|
}
|
||||||
throw new Error('First name cannot be empty.');
|
|
||||||
}
|
updateProfile(firstName: string, lastName: string, phoneNumber?: string): void {
|
||||||
if (!lastName || lastName.trim().length === 0) {
|
if (!firstName || firstName.trim().length === 0) {
|
||||||
throw new Error('Last name cannot be empty.');
|
throw new Error('First name cannot be empty.');
|
||||||
}
|
}
|
||||||
|
if (!lastName || lastName.trim().length === 0) {
|
||||||
this.props.firstName = firstName.trim();
|
throw new Error('Last name cannot be empty.');
|
||||||
this.props.lastName = lastName.trim();
|
}
|
||||||
this.props.phoneNumber = phoneNumber;
|
|
||||||
this.props.updatedAt = new Date();
|
this.props.firstName = firstName.trim();
|
||||||
}
|
this.props.lastName = lastName.trim();
|
||||||
|
this.props.phoneNumber = phoneNumber;
|
||||||
verifyEmail(): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.isEmailVerified = true;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
verifyEmail(): void {
|
||||||
|
this.props.isEmailVerified = true;
|
||||||
enable2FA(totpSecret: string): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.totpSecret = totpSecret;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
enable2FA(totpSecret: string): void {
|
||||||
|
this.props.totpSecret = totpSecret;
|
||||||
disable2FA(): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.totpSecret = undefined;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
disable2FA(): void {
|
||||||
|
this.props.totpSecret = undefined;
|
||||||
recordLogin(): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.lastLoginAt = new Date();
|
}
|
||||||
}
|
|
||||||
|
recordLogin(): void {
|
||||||
deactivate(): void {
|
this.props.lastLoginAt = new Date();
|
||||||
this.props.isActive = false;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
deactivate(): void {
|
||||||
|
this.props.isActive = false;
|
||||||
activate(): void {
|
this.props.updatedAt = new Date();
|
||||||
this.props.isActive = true;
|
}
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
activate(): void {
|
||||||
|
this.props.isActive = true;
|
||||||
/**
|
this.props.updatedAt = new Date();
|
||||||
* Convert to plain object for persistence
|
}
|
||||||
*/
|
|
||||||
toObject(): UserProps {
|
/**
|
||||||
return { ...this.props };
|
* Convert to plain object for persistence
|
||||||
}
|
*/
|
||||||
}
|
toObject(): UserProps {
|
||||||
|
return { ...this.props };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -41,7 +41,10 @@ export class Webhook {
|
|||||||
private constructor(private readonly props: WebhookProps) {}
|
private constructor(private readonly props: WebhookProps) {}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
props: Omit<WebhookProps, 'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'> & { id: string },
|
props: Omit<
|
||||||
|
WebhookProps,
|
||||||
|
'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'
|
||||||
|
> & { id: string }
|
||||||
): Webhook {
|
): Webhook {
|
||||||
return new Webhook({
|
return new Webhook({
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* CarrierTimeoutException
|
* CarrierTimeoutException
|
||||||
*
|
*
|
||||||
* Thrown when a carrier API call times out
|
* Thrown when a carrier API call times out
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class CarrierTimeoutException extends Error {
|
export class CarrierTimeoutException extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly carrierName: string,
|
public readonly carrierName: string,
|
||||||
public readonly timeoutMs: number
|
public readonly timeoutMs: number
|
||||||
) {
|
) {
|
||||||
super(`Carrier ${carrierName} timed out after ${timeoutMs}ms`);
|
super(`Carrier ${carrierName} timed out after ${timeoutMs}ms`);
|
||||||
this.name = 'CarrierTimeoutException';
|
this.name = 'CarrierTimeoutException';
|
||||||
Object.setPrototypeOf(this, CarrierTimeoutException.prototype);
|
Object.setPrototypeOf(this, CarrierTimeoutException.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* CarrierUnavailableException
|
* CarrierUnavailableException
|
||||||
*
|
*
|
||||||
* Thrown when a carrier is unavailable or not responding
|
* Thrown when a carrier is unavailable or not responding
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class CarrierUnavailableException extends Error {
|
export class CarrierUnavailableException extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly carrierName: string,
|
public readonly carrierName: string,
|
||||||
public readonly reason?: string
|
public readonly reason?: string
|
||||||
) {
|
) {
|
||||||
super(`Carrier ${carrierName} is unavailable${reason ? `: ${reason}` : ''}`);
|
super(`Carrier ${carrierName} is unavailable${reason ? `: ${reason}` : ''}`);
|
||||||
this.name = 'CarrierUnavailableException';
|
this.name = 'CarrierUnavailableException';
|
||||||
Object.setPrototypeOf(this, CarrierUnavailableException.prototype);
|
Object.setPrototypeOf(this, CarrierUnavailableException.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Domain Exceptions Barrel Export
|
* Domain Exceptions Barrel Export
|
||||||
*
|
*
|
||||||
* All domain exceptions for the Xpeditis platform
|
* All domain exceptions for the Xpeditis platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './invalid-port-code.exception';
|
export * from './invalid-port-code.exception';
|
||||||
export * from './invalid-rate-quote.exception';
|
export * from './invalid-rate-quote.exception';
|
||||||
export * from './carrier-timeout.exception';
|
export * from './carrier-timeout.exception';
|
||||||
export * from './carrier-unavailable.exception';
|
export * from './carrier-unavailable.exception';
|
||||||
export * from './rate-quote-expired.exception';
|
export * from './rate-quote-expired.exception';
|
||||||
export * from './port-not-found.exception';
|
export * from './port-not-found.exception';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export class InvalidBookingNumberException extends Error {
|
export class InvalidBookingNumberException extends Error {
|
||||||
constructor(value: string) {
|
constructor(value: string) {
|
||||||
super(`Invalid booking number format: ${value}. Expected format: WCM-YYYY-XXXXXX`);
|
super(`Invalid booking number format: ${value}. Expected format: WCM-YYYY-XXXXXX`);
|
||||||
this.name = 'InvalidBookingNumberException';
|
this.name = 'InvalidBookingNumberException';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
export class InvalidBookingStatusException extends Error {
|
export class InvalidBookingStatusException extends Error {
|
||||||
constructor(value: string) {
|
constructor(value: string) {
|
||||||
super(
|
super(
|
||||||
`Invalid booking status: ${value}. Valid statuses: draft, pending_confirmation, confirmed, in_transit, delivered, cancelled`
|
`Invalid booking status: ${value}. Valid statuses: draft, pending_confirmation, confirmed, in_transit, delivered, cancelled`
|
||||||
);
|
);
|
||||||
this.name = 'InvalidBookingStatusException';
|
this.name = 'InvalidBookingStatusException';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* InvalidPortCodeException
|
* InvalidPortCodeException
|
||||||
*
|
*
|
||||||
* Thrown when a port code is invalid or not found
|
* Thrown when a port code is invalid or not found
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class InvalidPortCodeException extends Error {
|
export class InvalidPortCodeException extends Error {
|
||||||
constructor(portCode: string, message?: string) {
|
constructor(portCode: string, message?: string) {
|
||||||
super(message || `Invalid port code: ${portCode}`);
|
super(message || `Invalid port code: ${portCode}`);
|
||||||
this.name = 'InvalidPortCodeException';
|
this.name = 'InvalidPortCodeException';
|
||||||
Object.setPrototypeOf(this, InvalidPortCodeException.prototype);
|
Object.setPrototypeOf(this, InvalidPortCodeException.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* InvalidRateQuoteException
|
* InvalidRateQuoteException
|
||||||
*
|
*
|
||||||
* Thrown when a rate quote is invalid or malformed
|
* Thrown when a rate quote is invalid or malformed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class InvalidRateQuoteException extends Error {
|
export class InvalidRateQuoteException extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'InvalidRateQuoteException';
|
this.name = 'InvalidRateQuoteException';
|
||||||
Object.setPrototypeOf(this, InvalidRateQuoteException.prototype);
|
Object.setPrototypeOf(this, InvalidRateQuoteException.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* PortNotFoundException
|
* PortNotFoundException
|
||||||
*
|
*
|
||||||
* Thrown when a port is not found in the database
|
* Thrown when a port is not found in the database
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class PortNotFoundException extends Error {
|
export class PortNotFoundException extends Error {
|
||||||
constructor(public readonly portCode: string) {
|
constructor(public readonly portCode: string) {
|
||||||
super(`Port not found: ${portCode}`);
|
super(`Port not found: ${portCode}`);
|
||||||
this.name = 'PortNotFoundException';
|
this.name = 'PortNotFoundException';
|
||||||
Object.setPrototypeOf(this, PortNotFoundException.prototype);
|
Object.setPrototypeOf(this, PortNotFoundException.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* RateQuoteExpiredException
|
* RateQuoteExpiredException
|
||||||
*
|
*
|
||||||
* Thrown when attempting to use an expired rate quote
|
* Thrown when attempting to use an expired rate quote
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class RateQuoteExpiredException extends Error {
|
export class RateQuoteExpiredException extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly rateQuoteId: string,
|
public readonly rateQuoteId: string,
|
||||||
public readonly expiredAt: Date
|
public readonly expiredAt: Date
|
||||||
) {
|
) {
|
||||||
super(`Rate quote ${rateQuoteId} expired at ${expiredAt.toISOString()}`);
|
super(`Rate quote ${rateQuoteId} expired at ${expiredAt.toISOString()}`);
|
||||||
this.name = 'RateQuoteExpiredException';
|
this.name = 'RateQuoteExpiredException';
|
||||||
Object.setPrototypeOf(this, RateQuoteExpiredException.prototype);
|
Object.setPrototypeOf(this, RateQuoteExpiredException.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* GetPortsPort (API Port - Input)
|
* GetPortsPort (API Port - Input)
|
||||||
*
|
*
|
||||||
* Defines the interface for port autocomplete and retrieval
|
* Defines the interface for port autocomplete and retrieval
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Port } from '../../entities/port.entity';
|
import { Port } from '../../entities/port.entity';
|
||||||
|
|
||||||
export interface PortSearchInput {
|
export interface PortSearchInput {
|
||||||
query: string; // Search query (port name, city, or code)
|
query: string; // Search query (port name, city, or code)
|
||||||
limit?: number; // Max results (default: 10)
|
limit?: number; // Max results (default: 10)
|
||||||
countryFilter?: string; // ISO country code filter
|
countryFilter?: string; // ISO country code filter
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PortSearchOutput {
|
export interface PortSearchOutput {
|
||||||
ports: Port[];
|
ports: Port[];
|
||||||
totalMatches: number;
|
totalMatches: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetPortInput {
|
export interface GetPortInput {
|
||||||
portCode: string; // UN/LOCODE
|
portCode: string; // UN/LOCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetPortsPort {
|
export interface GetPortsPort {
|
||||||
/**
|
/**
|
||||||
* Search ports by query (autocomplete)
|
* Search ports by query (autocomplete)
|
||||||
* @param input - Port search parameters
|
* @param input - Port search parameters
|
||||||
* @returns Matching ports
|
* @returns Matching ports
|
||||||
*/
|
*/
|
||||||
search(input: PortSearchInput): Promise<PortSearchOutput>;
|
search(input: PortSearchInput): Promise<PortSearchOutput>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get port by code
|
* Get port by code
|
||||||
* @param input - Port code
|
* @param input - Port code
|
||||||
* @returns Port entity
|
* @returns Port entity
|
||||||
*/
|
*/
|
||||||
getByCode(input: GetPortInput): Promise<Port>;
|
getByCode(input: GetPortInput): Promise<Port>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get multiple ports by codes
|
* Get multiple ports by codes
|
||||||
* @param portCodes - Array of port codes
|
* @param portCodes - Array of port codes
|
||||||
* @returns Array of ports
|
* @returns Array of ports
|
||||||
*/
|
*/
|
||||||
getByCodes(portCodes: string[]): Promise<Port[]>;
|
getByCodes(portCodes: string[]): Promise<Port[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* API Ports (Input) Barrel Export
|
* API Ports (Input) Barrel Export
|
||||||
*
|
*
|
||||||
* All input ports (use case interfaces) for the Xpeditis platform
|
* All input ports (use case interfaces) for the Xpeditis platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './search-rates.port';
|
export * from './search-rates.port';
|
||||||
export * from './get-ports.port';
|
export * from './get-ports.port';
|
||||||
export * from './validate-availability.port';
|
export * from './validate-availability.port';
|
||||||
|
|||||||
@ -1,109 +1,109 @@
|
|||||||
import { CsvRate } from '../../entities/csv-rate.entity';
|
import { CsvRate } from '../../entities/csv-rate.entity';
|
||||||
import { PortCode } from '../../value-objects/port-code.vo';
|
import { PortCode } from '../../value-objects/port-code.vo';
|
||||||
import { Volume } from '../../value-objects/volume.vo';
|
import { Volume } from '../../value-objects/volume.vo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advanced Rate Search Filters
|
* Advanced Rate Search Filters
|
||||||
*
|
*
|
||||||
* Filters for narrowing down rate search results
|
* Filters for narrowing down rate search results
|
||||||
*/
|
*/
|
||||||
export interface RateSearchFilters {
|
export interface RateSearchFilters {
|
||||||
// Company filters
|
// Company filters
|
||||||
companies?: string[]; // List of company names to include
|
companies?: string[]; // List of company names to include
|
||||||
|
|
||||||
// Volume/Weight filters
|
// Volume/Weight filters
|
||||||
minVolumeCBM?: number;
|
minVolumeCBM?: number;
|
||||||
maxVolumeCBM?: number;
|
maxVolumeCBM?: number;
|
||||||
minWeightKG?: number;
|
minWeightKG?: number;
|
||||||
maxWeightKG?: number;
|
maxWeightKG?: number;
|
||||||
palletCount?: number; // Exact pallet count (0 = any)
|
palletCount?: number; // Exact pallet count (0 = any)
|
||||||
|
|
||||||
// Price filters
|
// Price filters
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
currency?: 'USD' | 'EUR'; // Preferred currency for filtering
|
currency?: 'USD' | 'EUR'; // Preferred currency for filtering
|
||||||
|
|
||||||
// Transit filters
|
// Transit filters
|
||||||
minTransitDays?: number;
|
minTransitDays?: number;
|
||||||
maxTransitDays?: number;
|
maxTransitDays?: number;
|
||||||
|
|
||||||
// Container type filters
|
// Container type filters
|
||||||
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
|
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
|
||||||
|
|
||||||
// Surcharge filters
|
// Surcharge filters
|
||||||
onlyAllInPrices?: boolean; // Only show rates without separate surcharges
|
onlyAllInPrices?: boolean; // Only show rates without separate surcharges
|
||||||
|
|
||||||
// Date filters
|
// Date filters
|
||||||
departureDate?: Date; // Filter by validity for specific date
|
departureDate?: Date; // Filter by validity for specific date
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Search Input
|
* CSV Rate Search Input
|
||||||
*
|
*
|
||||||
* Parameters for searching rates in CSV system
|
* Parameters for searching rates in CSV system
|
||||||
*/
|
*/
|
||||||
export interface CsvRateSearchInput {
|
export interface CsvRateSearchInput {
|
||||||
origin: string; // Port code (UN/LOCODE)
|
origin: string; // Port code (UN/LOCODE)
|
||||||
destination: string; // Port code (UN/LOCODE)
|
destination: string; // Port code (UN/LOCODE)
|
||||||
volumeCBM: number; // Volume in cubic meters
|
volumeCBM: number; // Volume in cubic meters
|
||||||
weightKG: number; // Weight in kilograms
|
weightKG: number; // Weight in kilograms
|
||||||
palletCount?: number; // Number of pallets (0 if none)
|
palletCount?: number; // Number of pallets (0 if none)
|
||||||
containerType?: string; // Optional container type filter
|
containerType?: string; // Optional container type filter
|
||||||
filters?: RateSearchFilters; // Advanced filters
|
filters?: RateSearchFilters; // Advanced filters
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Search Result
|
* CSV Rate Search Result
|
||||||
*
|
*
|
||||||
* Single rate result with calculated price
|
* Single rate result with calculated price
|
||||||
*/
|
*/
|
||||||
export interface CsvRateSearchResult {
|
export interface CsvRateSearchResult {
|
||||||
rate: CsvRate;
|
rate: CsvRate;
|
||||||
calculatedPrice: {
|
calculatedPrice: {
|
||||||
usd: number;
|
usd: number;
|
||||||
eur: number;
|
eur: number;
|
||||||
primaryCurrency: string;
|
primaryCurrency: string;
|
||||||
};
|
};
|
||||||
source: 'CSV';
|
source: 'CSV';
|
||||||
matchScore: number; // 0-100, how well it matches filters
|
matchScore: number; // 0-100, how well it matches filters
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Search Output
|
* CSV Rate Search Output
|
||||||
*
|
*
|
||||||
* Results from CSV rate search
|
* Results from CSV rate search
|
||||||
*/
|
*/
|
||||||
export interface CsvRateSearchOutput {
|
export interface CsvRateSearchOutput {
|
||||||
results: CsvRateSearchResult[];
|
results: CsvRateSearchResult[];
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
searchedFiles: string[]; // CSV files searched
|
searchedFiles: string[]; // CSV files searched
|
||||||
searchedAt: Date;
|
searchedAt: Date;
|
||||||
appliedFilters: RateSearchFilters;
|
appliedFilters: RateSearchFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search CSV Rates Port (Input Port)
|
* Search CSV Rates Port (Input Port)
|
||||||
*
|
*
|
||||||
* Use case for searching rates in CSV-based system
|
* Use case for searching rates in CSV-based system
|
||||||
* Supports advanced filters for precise rate matching
|
* Supports advanced filters for precise rate matching
|
||||||
*/
|
*/
|
||||||
export interface SearchCsvRatesPort {
|
export interface SearchCsvRatesPort {
|
||||||
/**
|
/**
|
||||||
* Execute CSV rate search with filters
|
* Execute CSV rate search with filters
|
||||||
* @param input - Search parameters and filters
|
* @param input - Search parameters and filters
|
||||||
* @returns Matching rates with calculated prices
|
* @returns Matching rates with calculated prices
|
||||||
*/
|
*/
|
||||||
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
|
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available companies in CSV system
|
* Get available companies in CSV system
|
||||||
* @returns List of company names that have CSV rates
|
* @returns List of company names that have CSV rates
|
||||||
*/
|
*/
|
||||||
getAvailableCompanies(): Promise<string[]>;
|
getAvailableCompanies(): Promise<string[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available container types in CSV system
|
* Get available container types in CSV system
|
||||||
* @returns List of container types available
|
* @returns List of container types available
|
||||||
*/
|
*/
|
||||||
getAvailableContainerTypes(): Promise<string[]>;
|
getAvailableContainerTypes(): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* SearchRatesPort (API Port - Input)
|
* SearchRatesPort (API Port - Input)
|
||||||
*
|
*
|
||||||
* Defines the interface for searching shipping rates
|
* Defines the interface for searching shipping rates
|
||||||
* This is the entry point for the rate search use case
|
* This is the entry point for the rate search use case
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RateQuote } from '../../entities/rate-quote.entity';
|
import { RateQuote } from '../../entities/rate-quote.entity';
|
||||||
|
|
||||||
export interface RateSearchInput {
|
export interface RateSearchInput {
|
||||||
origin: string; // Port code (UN/LOCODE)
|
origin: string; // Port code (UN/LOCODE)
|
||||||
destination: string; // Port code (UN/LOCODE)
|
destination: string; // Port code (UN/LOCODE)
|
||||||
containerType: string; // e.g., '20DRY', '40HC'
|
containerType: string; // e.g., '20DRY', '40HC'
|
||||||
mode: 'FCL' | 'LCL';
|
mode: 'FCL' | 'LCL';
|
||||||
departureDate: Date;
|
departureDate: Date;
|
||||||
quantity?: number; // Number of containers (default: 1)
|
quantity?: number; // Number of containers (default: 1)
|
||||||
weight?: number; // For LCL (kg)
|
weight?: number; // For LCL (kg)
|
||||||
volume?: number; // For LCL (CBM)
|
volume?: number; // For LCL (CBM)
|
||||||
isHazmat?: boolean;
|
isHazmat?: boolean;
|
||||||
imoClass?: string; // If hazmat
|
imoClass?: string; // If hazmat
|
||||||
carrierPreferences?: string[]; // Specific carrier codes to query
|
carrierPreferences?: string[]; // Specific carrier codes to query
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RateSearchOutput {
|
export interface RateSearchOutput {
|
||||||
quotes: RateQuote[];
|
quotes: RateQuote[];
|
||||||
searchId: string;
|
searchId: string;
|
||||||
searchedAt: Date;
|
searchedAt: Date;
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
carrierResults: {
|
carrierResults: {
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
status: 'success' | 'error' | 'timeout';
|
status: 'success' | 'error' | 'timeout';
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchRatesPort {
|
export interface SearchRatesPort {
|
||||||
/**
|
/**
|
||||||
* Execute rate search across multiple carriers
|
* Execute rate search across multiple carriers
|
||||||
* @param input - Rate search parameters
|
* @param input - Rate search parameters
|
||||||
* @returns Rate quotes from available carriers
|
* @returns Rate quotes from available carriers
|
||||||
*/
|
*/
|
||||||
execute(input: RateSearchInput): Promise<RateSearchOutput>;
|
execute(input: RateSearchInput): Promise<RateSearchOutput>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* ValidateAvailabilityPort (API Port - Input)
|
* ValidateAvailabilityPort (API Port - Input)
|
||||||
*
|
*
|
||||||
* Defines the interface for validating container availability
|
* Defines the interface for validating container availability
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface AvailabilityInput {
|
export interface AvailabilityInput {
|
||||||
rateQuoteId: string;
|
rateQuoteId: string;
|
||||||
quantity: number; // Number of containers requested
|
quantity: number; // Number of containers requested
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailabilityOutput {
|
export interface AvailabilityOutput {
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
availableQuantity: number;
|
availableQuantity: number;
|
||||||
requestedQuantity: number;
|
requestedQuantity: number;
|
||||||
rateQuoteId: string;
|
rateQuoteId: string;
|
||||||
validUntil: Date;
|
validUntil: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidateAvailabilityPort {
|
export interface ValidateAvailabilityPort {
|
||||||
/**
|
/**
|
||||||
* Validate if containers are available for a rate quote
|
* Validate if containers are available for a rate quote
|
||||||
* @param input - Availability check parameters
|
* @param input - Availability check parameters
|
||||||
* @returns Availability status
|
* @returns Availability status
|
||||||
*/
|
*/
|
||||||
execute(input: AvailabilityInput): Promise<AvailabilityOutput>;
|
execute(input: AvailabilityInput): Promise<AvailabilityOutput>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* AvailabilityValidationService
|
* AvailabilityValidationService
|
||||||
*
|
*
|
||||||
* Domain service for validating container availability
|
* Domain service for validating container availability
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Check if rate quote is still valid (not expired)
|
* - Check if rate quote is still valid (not expired)
|
||||||
* - Verify requested quantity is available
|
* - Verify requested quantity is available
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ValidateAvailabilityPort,
|
ValidateAvailabilityPort,
|
||||||
AvailabilityInput,
|
AvailabilityInput,
|
||||||
AvailabilityOutput,
|
AvailabilityOutput,
|
||||||
} from '../ports/in/validate-availability.port';
|
} from '../ports/in/validate-availability.port';
|
||||||
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
|
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
|
||||||
import { InvalidRateQuoteException } from '../exceptions/invalid-rate-quote.exception';
|
import { InvalidRateQuoteException } from '../exceptions/invalid-rate-quote.exception';
|
||||||
import { RateQuoteExpiredException } from '../exceptions/rate-quote-expired.exception';
|
import { RateQuoteExpiredException } from '../exceptions/rate-quote-expired.exception';
|
||||||
|
|
||||||
export class AvailabilityValidationService implements ValidateAvailabilityPort {
|
export class AvailabilityValidationService implements ValidateAvailabilityPort {
|
||||||
constructor(private readonly rateQuoteRepository: RateQuoteRepository) {}
|
constructor(private readonly rateQuoteRepository: RateQuoteRepository) {}
|
||||||
|
|
||||||
async execute(input: AvailabilityInput): Promise<AvailabilityOutput> {
|
async execute(input: AvailabilityInput): Promise<AvailabilityOutput> {
|
||||||
// Find rate quote
|
// Find rate quote
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId);
|
||||||
|
|
||||||
if (!rateQuote) {
|
if (!rateQuote) {
|
||||||
throw new InvalidRateQuoteException(`Rate quote not found: ${input.rateQuoteId}`);
|
throw new InvalidRateQuoteException(`Rate quote not found: ${input.rateQuoteId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if rate quote has expired
|
// Check if rate quote has expired
|
||||||
if (rateQuote.isExpired()) {
|
if (rateQuote.isExpired()) {
|
||||||
throw new RateQuoteExpiredException(rateQuote.id, rateQuote.validUntil);
|
throw new RateQuoteExpiredException(rateQuote.id, rateQuote.validUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check availability
|
// Check availability
|
||||||
const availableQuantity = rateQuote.availability;
|
const availableQuantity = rateQuote.availability;
|
||||||
const isAvailable = availableQuantity >= input.quantity;
|
const isAvailable = availableQuantity >= input.quantity;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAvailable,
|
isAvailable,
|
||||||
availableQuantity,
|
availableQuantity,
|
||||||
requestedQuantity: input.quantity,
|
requestedQuantity: input.quantity,
|
||||||
rateQuoteId: rateQuote.id,
|
rateQuoteId: rateQuote.id,
|
||||||
validUntil: rateQuote.validUntil,
|
validUntil: rateQuote.validUntil,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,284 +1,250 @@
|
|||||||
import { CsvRate } from '../entities/csv-rate.entity';
|
import { CsvRate } from '../entities/csv-rate.entity';
|
||||||
import { PortCode } from '../value-objects/port-code.vo';
|
import { PortCode } from '../value-objects/port-code.vo';
|
||||||
import { ContainerType } from '../value-objects/container-type.vo';
|
import { ContainerType } from '../value-objects/container-type.vo';
|
||||||
import { Volume } from '../value-objects/volume.vo';
|
import { Volume } from '../value-objects/volume.vo';
|
||||||
import { Money } from '../value-objects/money.vo';
|
import { Money } from '../value-objects/money.vo';
|
||||||
import {
|
import {
|
||||||
SearchCsvRatesPort,
|
SearchCsvRatesPort,
|
||||||
CsvRateSearchInput,
|
CsvRateSearchInput,
|
||||||
CsvRateSearchOutput,
|
CsvRateSearchOutput,
|
||||||
CsvRateSearchResult,
|
CsvRateSearchResult,
|
||||||
RateSearchFilters,
|
RateSearchFilters,
|
||||||
} from '../ports/in/search-csv-rates.port';
|
} from '../ports/in/search-csv-rates.port';
|
||||||
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
|
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Search Service
|
* CSV Rate Search Service
|
||||||
*
|
*
|
||||||
* Domain service implementing CSV rate search use case.
|
* Domain service implementing CSV rate search use case.
|
||||||
* Applies business rules for matching rates and filtering.
|
* Applies business rules for matching rates and filtering.
|
||||||
*
|
*
|
||||||
* Pure domain logic - no framework dependencies.
|
* Pure domain logic - no framework dependencies.
|
||||||
*/
|
*/
|
||||||
export class CsvRateSearchService implements SearchCsvRatesPort {
|
export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||||
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {}
|
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {}
|
||||||
|
|
||||||
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
||||||
const searchStartTime = new Date();
|
const searchStartTime = new Date();
|
||||||
|
|
||||||
// Parse and validate input
|
// Parse and validate input
|
||||||
const origin = PortCode.create(input.origin);
|
const origin = PortCode.create(input.origin);
|
||||||
const destination = PortCode.create(input.destination);
|
const destination = PortCode.create(input.destination);
|
||||||
const volume = new Volume(input.volumeCBM, input.weightKG);
|
const volume = new Volume(input.volumeCBM, input.weightKG);
|
||||||
const palletCount = input.palletCount ?? 0;
|
const palletCount = input.palletCount ?? 0;
|
||||||
|
|
||||||
// Load all CSV rates
|
// Load all CSV rates
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
|
|
||||||
// Apply route and volume matching
|
// Apply route and volume matching
|
||||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
||||||
matchingRates = this.filterByVolume(matchingRates, volume);
|
matchingRates = this.filterByVolume(matchingRates, volume);
|
||||||
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
|
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
|
||||||
|
|
||||||
// Apply container type filter if specified
|
// Apply container type filter if specified
|
||||||
if (input.containerType) {
|
if (input.containerType) {
|
||||||
const containerType = ContainerType.create(input.containerType);
|
const containerType = ContainerType.create(input.containerType);
|
||||||
matchingRates = matchingRates.filter((rate) =>
|
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||||
rate.containerType.equals(containerType),
|
}
|
||||||
);
|
|
||||||
}
|
// Apply advanced filters
|
||||||
|
if (input.filters) {
|
||||||
// Apply advanced filters
|
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
|
||||||
if (input.filters) {
|
}
|
||||||
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
|
|
||||||
}
|
// Calculate prices and create results
|
||||||
|
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
||||||
// Calculate prices and create results
|
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
||||||
const results: CsvRateSearchResult[] = matchingRates.map((rate) => {
|
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
||||||
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
|
||||||
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
return {
|
||||||
|
rate,
|
||||||
return {
|
calculatedPrice: {
|
||||||
rate,
|
usd: priceUSD.getAmount(),
|
||||||
calculatedPrice: {
|
eur: priceEUR.getAmount(),
|
||||||
usd: priceUSD.getAmount(),
|
primaryCurrency: rate.currency,
|
||||||
eur: priceEUR.getAmount(),
|
},
|
||||||
primaryCurrency: rate.currency,
|
source: 'CSV' as const,
|
||||||
},
|
matchScore: this.calculateMatchScore(rate, input),
|
||||||
source: 'CSV' as const,
|
};
|
||||||
matchScore: this.calculateMatchScore(rate, input),
|
});
|
||||||
};
|
|
||||||
});
|
// Sort by price (ascending) in primary currency
|
||||||
|
results.sort((a, b) => {
|
||||||
// Sort by price (ascending) in primary currency
|
const priceA =
|
||||||
results.sort((a, b) => {
|
a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur;
|
||||||
const priceA =
|
const priceB =
|
||||||
a.calculatedPrice.primaryCurrency === 'USD'
|
b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur;
|
||||||
? a.calculatedPrice.usd
|
return priceA - priceB;
|
||||||
: a.calculatedPrice.eur;
|
});
|
||||||
const priceB =
|
|
||||||
b.calculatedPrice.primaryCurrency === 'USD'
|
return {
|
||||||
? b.calculatedPrice.usd
|
results,
|
||||||
: b.calculatedPrice.eur;
|
totalResults: results.length,
|
||||||
return priceA - priceB;
|
searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(),
|
||||||
});
|
searchedAt: searchStartTime,
|
||||||
|
appliedFilters: input.filters || {},
|
||||||
return {
|
};
|
||||||
results,
|
}
|
||||||
totalResults: results.length,
|
|
||||||
searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(),
|
async getAvailableCompanies(): Promise<string[]> {
|
||||||
searchedAt: searchStartTime,
|
const allRates = await this.loadAllRates();
|
||||||
appliedFilters: input.filters || {},
|
const companies = new Set(allRates.map(rate => rate.companyName));
|
||||||
};
|
return Array.from(companies).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableCompanies(): Promise<string[]> {
|
async getAvailableContainerTypes(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const companies = new Set(allRates.map((rate) => rate.companyName));
|
const types = new Set(allRates.map(rate => rate.containerType.getValue()));
|
||||||
return Array.from(companies).sort();
|
return Array.from(types).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableContainerTypes(): Promise<string[]> {
|
/**
|
||||||
const allRates = await this.loadAllRates();
|
* Load all rates from all CSV files
|
||||||
const types = new Set(allRates.map((rate) => rate.containerType.getValue()));
|
*/
|
||||||
return Array.from(types).sort();
|
private async loadAllRates(): Promise<CsvRate[]> {
|
||||||
}
|
const files = await this.csvRateLoader.getAvailableCsvFiles();
|
||||||
|
const ratePromises = files.map(file => this.csvRateLoader.loadRatesFromCsv(file));
|
||||||
/**
|
const rateArrays = await Promise.all(ratePromises);
|
||||||
* Load all rates from all CSV files
|
return rateArrays.flat();
|
||||||
*/
|
}
|
||||||
private async loadAllRates(): Promise<CsvRate[]> {
|
|
||||||
const files = await this.csvRateLoader.getAvailableCsvFiles();
|
/**
|
||||||
const ratePromises = files.map((file) =>
|
* Filter rates by route (origin/destination)
|
||||||
this.csvRateLoader.loadRatesFromCsv(file),
|
*/
|
||||||
);
|
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
|
||||||
const rateArrays = await Promise.all(ratePromises);
|
return rates.filter(rate => rate.matchesRoute(origin, destination));
|
||||||
return rateArrays.flat();
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
/**
|
* Filter rates by volume/weight range
|
||||||
* Filter rates by route (origin/destination)
|
*/
|
||||||
*/
|
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
|
||||||
private filterByRoute(
|
return rates.filter(rate => rate.matchesVolume(volume));
|
||||||
rates: CsvRate[],
|
}
|
||||||
origin: PortCode,
|
|
||||||
destination: PortCode,
|
/**
|
||||||
): CsvRate[] {
|
* Filter rates by pallet count
|
||||||
return rates.filter((rate) => rate.matchesRoute(origin, destination));
|
*/
|
||||||
}
|
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
|
||||||
|
return rates.filter(rate => rate.matchesPalletCount(palletCount));
|
||||||
/**
|
}
|
||||||
* Filter rates by volume/weight range
|
|
||||||
*/
|
/**
|
||||||
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
|
* Apply advanced filters to rate list
|
||||||
return rates.filter((rate) => rate.matchesVolume(volume));
|
*/
|
||||||
}
|
private applyAdvancedFilters(
|
||||||
|
rates: CsvRate[],
|
||||||
/**
|
filters: RateSearchFilters,
|
||||||
* Filter rates by pallet count
|
volume: Volume
|
||||||
*/
|
): CsvRate[] {
|
||||||
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
|
let filtered = rates;
|
||||||
return rates.filter((rate) => rate.matchesPalletCount(palletCount));
|
|
||||||
}
|
// Company filter
|
||||||
|
if (filters.companies && filters.companies.length > 0) {
|
||||||
/**
|
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
|
||||||
* Apply advanced filters to rate list
|
}
|
||||||
*/
|
|
||||||
private applyAdvancedFilters(
|
// Volume CBM filter
|
||||||
rates: CsvRate[],
|
if (filters.minVolumeCBM !== undefined) {
|
||||||
filters: RateSearchFilters,
|
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
|
||||||
volume: Volume,
|
}
|
||||||
): CsvRate[] {
|
if (filters.maxVolumeCBM !== undefined) {
|
||||||
let filtered = rates;
|
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
|
||||||
|
}
|
||||||
// Company filter
|
|
||||||
if (filters.companies && filters.companies.length > 0) {
|
// Weight KG filter
|
||||||
filtered = filtered.filter((rate) =>
|
if (filters.minWeightKG !== undefined) {
|
||||||
filters.companies!.includes(rate.companyName),
|
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
|
||||||
);
|
}
|
||||||
}
|
if (filters.maxWeightKG !== undefined) {
|
||||||
|
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
|
||||||
// Volume CBM filter
|
}
|
||||||
if (filters.minVolumeCBM !== undefined) {
|
|
||||||
filtered = filtered.filter(
|
// Pallet count filter
|
||||||
(rate) => rate.volumeRange.maxCBM >= filters.minVolumeCBM!,
|
if (filters.palletCount !== undefined) {
|
||||||
);
|
filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
|
||||||
}
|
}
|
||||||
if (filters.maxVolumeCBM !== undefined) {
|
|
||||||
filtered = filtered.filter(
|
// Price filter (calculate price first)
|
||||||
(rate) => rate.volumeRange.minCBM <= filters.maxVolumeCBM!,
|
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
||||||
);
|
const currency = filters.currency || 'USD';
|
||||||
}
|
filtered = filtered.filter(rate => {
|
||||||
|
const price = rate.getPriceInCurrency(volume, currency);
|
||||||
// Weight KG filter
|
const amount = price.getAmount();
|
||||||
if (filters.minWeightKG !== undefined) {
|
|
||||||
filtered = filtered.filter(
|
if (filters.minPrice !== undefined && amount < filters.minPrice) {
|
||||||
(rate) => rate.weightRange.maxKG >= filters.minWeightKG!,
|
return false;
|
||||||
);
|
}
|
||||||
}
|
if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
|
||||||
if (filters.maxWeightKG !== undefined) {
|
return false;
|
||||||
filtered = filtered.filter(
|
}
|
||||||
(rate) => rate.weightRange.minKG <= filters.maxWeightKG!,
|
return true;
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pallet count filter
|
// Transit days filter
|
||||||
if (filters.palletCount !== undefined) {
|
if (filters.minTransitDays !== undefined) {
|
||||||
filtered = filtered.filter((rate) =>
|
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
|
||||||
rate.matchesPalletCount(filters.palletCount!),
|
}
|
||||||
);
|
if (filters.maxTransitDays !== undefined) {
|
||||||
}
|
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
|
||||||
|
}
|
||||||
// Price filter (calculate price first)
|
|
||||||
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
// Container type filter
|
||||||
const currency = filters.currency || 'USD';
|
if (filters.containerTypes && filters.containerTypes.length > 0) {
|
||||||
filtered = filtered.filter((rate) => {
|
filtered = filtered.filter(rate =>
|
||||||
const price = rate.getPriceInCurrency(volume, currency);
|
filters.containerTypes!.includes(rate.containerType.getValue())
|
||||||
const amount = price.getAmount();
|
);
|
||||||
|
}
|
||||||
if (filters.minPrice !== undefined && amount < filters.minPrice) {
|
|
||||||
return false;
|
// All-in prices only filter
|
||||||
}
|
if (filters.onlyAllInPrices) {
|
||||||
if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
|
filtered = filtered.filter(rate => rate.isAllInPrice());
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
return true;
|
// Departure date / validity filter
|
||||||
});
|
if (filters.departureDate) {
|
||||||
}
|
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
||||||
|
}
|
||||||
// Transit days filter
|
|
||||||
if (filters.minTransitDays !== undefined) {
|
return filtered;
|
||||||
filtered = filtered.filter(
|
}
|
||||||
(rate) => rate.transitDays >= filters.minTransitDays!,
|
|
||||||
);
|
/**
|
||||||
}
|
* Calculate match score (0-100) based on how well rate matches input
|
||||||
if (filters.maxTransitDays !== undefined) {
|
* Higher score = better match
|
||||||
filtered = filtered.filter(
|
*/
|
||||||
(rate) => rate.transitDays <= filters.maxTransitDays!,
|
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
|
||||||
);
|
let score = 100;
|
||||||
}
|
|
||||||
|
// Reduce score if volume/weight is near boundaries
|
||||||
// Container type filter
|
const volumeUtilization =
|
||||||
if (filters.containerTypes && filters.containerTypes.length > 0) {
|
(input.volumeCBM - rate.volumeRange.minCBM) /
|
||||||
filtered = filtered.filter((rate) =>
|
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
|
||||||
filters.containerTypes!.includes(rate.containerType.getValue()),
|
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
|
||||||
);
|
score -= 10; // Near boundaries
|
||||||
}
|
}
|
||||||
|
|
||||||
// All-in prices only filter
|
// Reduce score if pallet count doesn't match exactly
|
||||||
if (filters.onlyAllInPrices) {
|
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
|
||||||
filtered = filtered.filter((rate) => rate.isAllInPrice());
|
score -= 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Departure date / validity filter
|
// Increase score for all-in prices (simpler for customers)
|
||||||
if (filters.departureDate) {
|
if (rate.isAllInPrice()) {
|
||||||
filtered = filtered.filter((rate) =>
|
score += 5;
|
||||||
rate.isValidForDate(filters.departureDate!),
|
}
|
||||||
);
|
|
||||||
}
|
// Reduce score for rates expiring soon
|
||||||
|
const daysUntilExpiry = Math.floor(
|
||||||
return filtered;
|
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
}
|
);
|
||||||
|
if (daysUntilExpiry < 7) {
|
||||||
/**
|
score -= 10;
|
||||||
* Calculate match score (0-100) based on how well rate matches input
|
} else if (daysUntilExpiry < 30) {
|
||||||
* Higher score = better match
|
score -= 5;
|
||||||
*/
|
}
|
||||||
private calculateMatchScore(
|
|
||||||
rate: CsvRate,
|
return Math.max(0, Math.min(100, score));
|
||||||
input: CsvRateSearchInput,
|
}
|
||||||
): number {
|
}
|
||||||
let score = 100;
|
|
||||||
|
|
||||||
// Reduce score if volume/weight is near boundaries
|
|
||||||
const volumeUtilization =
|
|
||||||
(input.volumeCBM - rate.volumeRange.minCBM) /
|
|
||||||
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
|
|
||||||
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
|
|
||||||
score -= 10; // Near boundaries
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce score if pallet count doesn't match exactly
|
|
||||||
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
|
|
||||||
score -= 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increase score for all-in prices (simpler for customers)
|
|
||||||
if (rate.isAllInPrice()) {
|
|
||||||
score += 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce score for rates expiring soon
|
|
||||||
const daysUntilExpiry = Math.floor(
|
|
||||||
(rate.validity.getEndDate().getTime() - Date.now()) /
|
|
||||||
(1000 * 60 * 60 * 24),
|
|
||||||
);
|
|
||||||
if (daysUntilExpiry < 7) {
|
|
||||||
score -= 10;
|
|
||||||
} else if (daysUntilExpiry < 30) {
|
|
||||||
score -= 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(0, Math.min(100, score));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Domain Services Barrel Export
|
* Domain Services Barrel Export
|
||||||
*
|
*
|
||||||
* All domain services for the Xpeditis platform
|
* All domain services for the Xpeditis platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './rate-search.service';
|
export * from './rate-search.service';
|
||||||
export * from './port-search.service';
|
export * from './port-search.service';
|
||||||
export * from './availability-validation.service';
|
export * from './availability-validation.service';
|
||||||
export * from './booking.service';
|
export * from './booking.service';
|
||||||
|
|||||||
@ -1,65 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* PortSearchService
|
* PortSearchService
|
||||||
*
|
*
|
||||||
* Domain service for port search and autocomplete
|
* Domain service for port search and autocomplete
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Fuzzy search on port name, city, and code
|
* - Fuzzy search on port name, city, and code
|
||||||
* - Return top 10 results by default
|
* - Return top 10 results by default
|
||||||
* - Support country filtering
|
* - Support country filtering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Port } from '../entities/port.entity';
|
import { Port } from '../entities/port.entity';
|
||||||
import { GetPortsPort, PortSearchInput, PortSearchOutput, GetPortInput } from '../ports/in/get-ports.port';
|
import {
|
||||||
import { PortRepository } from '../ports/out/port.repository';
|
GetPortsPort,
|
||||||
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
|
PortSearchInput,
|
||||||
|
PortSearchOutput,
|
||||||
export class PortSearchService implements GetPortsPort {
|
GetPortInput,
|
||||||
private static readonly DEFAULT_LIMIT = 10;
|
} from '../ports/in/get-ports.port';
|
||||||
|
import { PortRepository } from '../ports/out/port.repository';
|
||||||
constructor(private readonly portRepository: PortRepository) {}
|
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
|
||||||
|
|
||||||
async search(input: PortSearchInput): Promise<PortSearchOutput> {
|
export class PortSearchService implements GetPortsPort {
|
||||||
const limit = input.limit || PortSearchService.DEFAULT_LIMIT;
|
private static readonly DEFAULT_LIMIT = 10;
|
||||||
const query = input.query.trim();
|
|
||||||
|
constructor(private readonly portRepository: PortRepository) {}
|
||||||
if (query.length === 0) {
|
|
||||||
return {
|
async search(input: PortSearchInput): Promise<PortSearchOutput> {
|
||||||
ports: [],
|
const limit = input.limit || PortSearchService.DEFAULT_LIMIT;
|
||||||
totalMatches: 0,
|
const query = input.query.trim();
|
||||||
};
|
|
||||||
}
|
if (query.length === 0) {
|
||||||
|
return {
|
||||||
// Search using repository fuzzy search
|
ports: [],
|
||||||
const ports = await this.portRepository.search(query, limit, input.countryFilter);
|
totalMatches: 0,
|
||||||
|
};
|
||||||
return {
|
}
|
||||||
ports,
|
|
||||||
totalMatches: ports.length,
|
// Search using repository fuzzy search
|
||||||
};
|
const ports = await this.portRepository.search(query, limit, input.countryFilter);
|
||||||
}
|
|
||||||
|
return {
|
||||||
async getByCode(input: GetPortInput): Promise<Port> {
|
ports,
|
||||||
const port = await this.portRepository.findByCode(input.portCode);
|
totalMatches: ports.length,
|
||||||
|
};
|
||||||
if (!port) {
|
}
|
||||||
throw new PortNotFoundException(input.portCode);
|
|
||||||
}
|
async getByCode(input: GetPortInput): Promise<Port> {
|
||||||
|
const port = await this.portRepository.findByCode(input.portCode);
|
||||||
return port;
|
|
||||||
}
|
if (!port) {
|
||||||
|
throw new PortNotFoundException(input.portCode);
|
||||||
async getByCodes(portCodes: string[]): Promise<Port[]> {
|
}
|
||||||
const ports = await this.portRepository.findByCodes(portCodes);
|
|
||||||
|
return port;
|
||||||
// Check if all ports were found
|
}
|
||||||
const foundCodes = ports.map((p) => p.code);
|
|
||||||
const missingCodes = portCodes.filter((code) => !foundCodes.includes(code));
|
async getByCodes(portCodes: string[]): Promise<Port[]> {
|
||||||
|
const ports = await this.portRepository.findByCodes(portCodes);
|
||||||
if (missingCodes.length > 0) {
|
|
||||||
throw new PortNotFoundException(missingCodes[0]);
|
// Check if all ports were found
|
||||||
}
|
const foundCodes = ports.map(p => p.code);
|
||||||
|
const missingCodes = portCodes.filter(code => !foundCodes.includes(code));
|
||||||
return ports;
|
|
||||||
}
|
if (missingCodes.length > 0) {
|
||||||
}
|
throw new PortNotFoundException(missingCodes[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,165 +1,165 @@
|
|||||||
/**
|
/**
|
||||||
* RateSearchService
|
* RateSearchService
|
||||||
*
|
*
|
||||||
* Domain service implementing the rate search business logic
|
* Domain service implementing the rate search business logic
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Query multiple carriers in parallel
|
* - Query multiple carriers in parallel
|
||||||
* - Cache results for 15 minutes
|
* - Cache results for 15 minutes
|
||||||
* - Handle carrier timeouts gracefully (5s max)
|
* - Handle carrier timeouts gracefully (5s max)
|
||||||
* - Return results even if some carriers fail
|
* - Return results even if some carriers fail
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RateQuote } from '../entities/rate-quote.entity';
|
import { RateQuote } from '../entities/rate-quote.entity';
|
||||||
import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port';
|
import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port';
|
||||||
import { CarrierConnectorPort } from '../ports/out/carrier-connector.port';
|
import { CarrierConnectorPort } from '../ports/out/carrier-connector.port';
|
||||||
import { CachePort } from '../ports/out/cache.port';
|
import { CachePort } from '../ports/out/cache.port';
|
||||||
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
|
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
|
||||||
import { PortRepository } from '../ports/out/port.repository';
|
import { PortRepository } from '../ports/out/port.repository';
|
||||||
import { CarrierRepository } from '../ports/out/carrier.repository';
|
import { CarrierRepository } from '../ports/out/carrier.repository';
|
||||||
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
|
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export class RateSearchService implements SearchRatesPort {
|
export class RateSearchService implements SearchRatesPort {
|
||||||
private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
|
private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly carrierConnectors: CarrierConnectorPort[],
|
private readonly carrierConnectors: CarrierConnectorPort[],
|
||||||
private readonly cache: CachePort,
|
private readonly cache: CachePort,
|
||||||
private readonly rateQuoteRepository: RateQuoteRepository,
|
private readonly rateQuoteRepository: RateQuoteRepository,
|
||||||
private readonly portRepository: PortRepository,
|
private readonly portRepository: PortRepository,
|
||||||
private readonly carrierRepository: CarrierRepository
|
private readonly carrierRepository: CarrierRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(input: RateSearchInput): Promise<RateSearchOutput> {
|
async execute(input: RateSearchInput): Promise<RateSearchOutput> {
|
||||||
const searchId = uuidv4();
|
const searchId = uuidv4();
|
||||||
const searchedAt = new Date();
|
const searchedAt = new Date();
|
||||||
|
|
||||||
// Validate ports exist
|
// Validate ports exist
|
||||||
await this.validatePorts(input.origin, input.destination);
|
await this.validatePorts(input.origin, input.destination);
|
||||||
|
|
||||||
// Generate cache key
|
// Generate cache key
|
||||||
const cacheKey = this.generateCacheKey(input);
|
const cacheKey = this.generateCacheKey(input);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cachedResults = await this.cache.get<RateSearchOutput>(cacheKey);
|
const cachedResults = await this.cache.get<RateSearchOutput>(cacheKey);
|
||||||
if (cachedResults) {
|
if (cachedResults) {
|
||||||
return cachedResults;
|
return cachedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter carriers if preferences specified
|
// Filter carriers if preferences specified
|
||||||
const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences);
|
const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences);
|
||||||
|
|
||||||
// Query all carriers in parallel with Promise.allSettled
|
// Query all carriers in parallel with Promise.allSettled
|
||||||
const carrierResults = await Promise.allSettled(
|
const carrierResults = await Promise.allSettled(
|
||||||
connectorsToQuery.map((connector) => this.queryCarrier(connector, input))
|
connectorsToQuery.map(connector => this.queryCarrier(connector, input))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process results
|
// Process results
|
||||||
const quotes: RateQuote[] = [];
|
const quotes: RateQuote[] = [];
|
||||||
const carrierResultsSummary: RateSearchOutput['carrierResults'] = [];
|
const carrierResultsSummary: RateSearchOutput['carrierResults'] = [];
|
||||||
|
|
||||||
for (let i = 0; i < carrierResults.length; i++) {
|
for (let i = 0; i < carrierResults.length; i++) {
|
||||||
const result = carrierResults[i];
|
const result = carrierResults[i];
|
||||||
const connector = connectorsToQuery[i];
|
const connector = connectorsToQuery[i];
|
||||||
const carrierName = connector.getCarrierName();
|
const carrierName = connector.getCarrierName();
|
||||||
|
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
const carrierQuotes = result.value;
|
const carrierQuotes = result.value;
|
||||||
quotes.push(...carrierQuotes);
|
quotes.push(...carrierQuotes);
|
||||||
|
|
||||||
carrierResultsSummary.push({
|
carrierResultsSummary.push({
|
||||||
carrierName,
|
carrierName,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
resultCount: carrierQuotes.length,
|
resultCount: carrierQuotes.length,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Handle error
|
// Handle error
|
||||||
const error = result.reason;
|
const error = result.reason;
|
||||||
carrierResultsSummary.push({
|
carrierResultsSummary.push({
|
||||||
carrierName,
|
carrierName,
|
||||||
status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error',
|
status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error',
|
||||||
resultCount: 0,
|
resultCount: 0,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save rate quotes to database
|
// Save rate quotes to database
|
||||||
if (quotes.length > 0) {
|
if (quotes.length > 0) {
|
||||||
await this.rateQuoteRepository.saveMany(quotes);
|
await this.rateQuoteRepository.saveMany(quotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build output
|
// Build output
|
||||||
const output: RateSearchOutput = {
|
const output: RateSearchOutput = {
|
||||||
quotes,
|
quotes,
|
||||||
searchId,
|
searchId,
|
||||||
searchedAt,
|
searchedAt,
|
||||||
totalResults: quotes.length,
|
totalResults: quotes.length,
|
||||||
carrierResults: carrierResultsSummary,
|
carrierResults: carrierResultsSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache results
|
// Cache results
|
||||||
await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS);
|
await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validatePorts(originCode: string, destinationCode: string): Promise<void> {
|
private async validatePorts(originCode: string, destinationCode: string): Promise<void> {
|
||||||
const [origin, destination] = await Promise.all([
|
const [origin, destination] = await Promise.all([
|
||||||
this.portRepository.findByCode(originCode),
|
this.portRepository.findByCode(originCode),
|
||||||
this.portRepository.findByCode(destinationCode),
|
this.portRepository.findByCode(destinationCode),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
throw new PortNotFoundException(originCode);
|
throw new PortNotFoundException(originCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!destination) {
|
if (!destination) {
|
||||||
throw new PortNotFoundException(destinationCode);
|
throw new PortNotFoundException(destinationCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCacheKey(input: RateSearchInput): string {
|
private generateCacheKey(input: RateSearchInput): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
'rate-search',
|
'rate-search',
|
||||||
input.origin,
|
input.origin,
|
||||||
input.destination,
|
input.destination,
|
||||||
input.containerType,
|
input.containerType,
|
||||||
input.mode,
|
input.mode,
|
||||||
input.departureDate.toISOString().split('T')[0],
|
input.departureDate.toISOString().split('T')[0],
|
||||||
input.quantity || 1,
|
input.quantity || 1,
|
||||||
input.isHazmat ? 'hazmat' : 'standard',
|
input.isHazmat ? 'hazmat' : 'standard',
|
||||||
];
|
];
|
||||||
|
|
||||||
return parts.join(':');
|
return parts.join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] {
|
private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] {
|
||||||
if (!carrierPreferences || carrierPreferences.length === 0) {
|
if (!carrierPreferences || carrierPreferences.length === 0) {
|
||||||
return this.carrierConnectors;
|
return this.carrierConnectors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.carrierConnectors.filter((connector) =>
|
return this.carrierConnectors.filter(connector =>
|
||||||
carrierPreferences.includes(connector.getCarrierCode())
|
carrierPreferences.includes(connector.getCarrierCode())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queryCarrier(
|
private async queryCarrier(
|
||||||
connector: CarrierConnectorPort,
|
connector: CarrierConnectorPort,
|
||||||
input: RateSearchInput
|
input: RateSearchInput
|
||||||
): Promise<RateQuote[]> {
|
): Promise<RateQuote[]> {
|
||||||
return connector.searchRates({
|
return connector.searchRates({
|
||||||
origin: input.origin,
|
origin: input.origin,
|
||||||
destination: input.destination,
|
destination: input.destination,
|
||||||
containerType: input.containerType,
|
containerType: input.containerType,
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
departureDate: input.departureDate,
|
departureDate: input.departureDate,
|
||||||
quantity: input.quantity,
|
quantity: input.quantity,
|
||||||
weight: input.weight,
|
weight: input.weight,
|
||||||
volume: input.volume,
|
volume: input.volume,
|
||||||
isHazmat: input.isHazmat,
|
isHazmat: input.isHazmat,
|
||||||
imoClass: input.imoClass,
|
imoClass: input.imoClass,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* BookingNumber Value Object
|
* BookingNumber Value Object
|
||||||
*
|
*
|
||||||
* Represents a unique booking reference number
|
* Represents a unique booking reference number
|
||||||
* Format: WCM-YYYY-XXXXXX (e.g., WCM-2025-ABC123)
|
* Format: WCM-YYYY-XXXXXX (e.g., WCM-2025-ABC123)
|
||||||
* - WCM: WebCargo Maritime prefix
|
* - WCM: WebCargo Maritime prefix
|
||||||
* - YYYY: Current year
|
* - YYYY: Current year
|
||||||
* - XXXXXX: 6 alphanumeric characters
|
* - XXXXXX: 6 alphanumeric characters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { InvalidBookingNumberException } from '../exceptions/invalid-booking-number.exception';
|
import { InvalidBookingNumberException } from '../exceptions/invalid-booking-number.exception';
|
||||||
|
|
||||||
export class BookingNumber {
|
export class BookingNumber {
|
||||||
private readonly _value: string;
|
private readonly _value: string;
|
||||||
|
|
||||||
private constructor(value: string) {
|
private constructor(value: string) {
|
||||||
this._value = value;
|
this._value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): string {
|
get value(): string {
|
||||||
return this._value;
|
return this._value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a new booking number
|
* Generate a new booking number
|
||||||
*/
|
*/
|
||||||
static generate(): BookingNumber {
|
static generate(): BookingNumber {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const random = BookingNumber.generateRandomString(6);
|
const random = BookingNumber.generateRandomString(6);
|
||||||
const value = `WCM-${year}-${random}`;
|
const value = `WCM-${year}-${random}`;
|
||||||
return new BookingNumber(value);
|
return new BookingNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create BookingNumber from string
|
* Create BookingNumber from string
|
||||||
*/
|
*/
|
||||||
static fromString(value: string): BookingNumber {
|
static fromString(value: string): BookingNumber {
|
||||||
if (!BookingNumber.isValid(value)) {
|
if (!BookingNumber.isValid(value)) {
|
||||||
throw new InvalidBookingNumberException(value);
|
throw new InvalidBookingNumberException(value);
|
||||||
}
|
}
|
||||||
return new BookingNumber(value);
|
return new BookingNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate booking number format
|
* Validate booking number format
|
||||||
*/
|
*/
|
||||||
static isValid(value: string): boolean {
|
static isValid(value: string): boolean {
|
||||||
const pattern = /^WCM-\d{4}-[A-Z0-9]{6}$/;
|
const pattern = /^WCM-\d{4}-[A-Z0-9]{6}$/;
|
||||||
return pattern.test(value);
|
return pattern.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate random alphanumeric string
|
* Generate random alphanumeric string
|
||||||
*/
|
*/
|
||||||
private static generateRandomString(length: number): string {
|
private static generateRandomString(length: number): string {
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous chars: 0,O,1,I
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous chars: 0,O,1,I
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Equality check
|
* Equality check
|
||||||
*/
|
*/
|
||||||
equals(other: BookingNumber): boolean {
|
equals(other: BookingNumber): boolean {
|
||||||
return this._value === other._value;
|
return this._value === other._value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String representation
|
* String representation
|
||||||
*/
|
*/
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return this._value;
|
return this._value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,110 +1,108 @@
|
|||||||
/**
|
/**
|
||||||
* BookingStatus Value Object
|
* BookingStatus Value Object
|
||||||
*
|
*
|
||||||
* Represents the current status of a booking
|
* Represents the current status of a booking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { InvalidBookingStatusException } from '../exceptions/invalid-booking-status.exception';
|
import { InvalidBookingStatusException } from '../exceptions/invalid-booking-status.exception';
|
||||||
|
|
||||||
export type BookingStatusValue =
|
export type BookingStatusValue =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'pending_confirmation'
|
| 'pending_confirmation'
|
||||||
| 'confirmed'
|
| 'confirmed'
|
||||||
| 'in_transit'
|
| 'in_transit'
|
||||||
| 'delivered'
|
| 'delivered'
|
||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
|
|
||||||
export class BookingStatus {
|
export class BookingStatus {
|
||||||
private static readonly VALID_STATUSES: BookingStatusValue[] = [
|
private static readonly VALID_STATUSES: BookingStatusValue[] = [
|
||||||
'draft',
|
'draft',
|
||||||
'pending_confirmation',
|
'pending_confirmation',
|
||||||
'confirmed',
|
'confirmed',
|
||||||
'in_transit',
|
'in_transit',
|
||||||
'delivered',
|
'delivered',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly STATUS_TRANSITIONS: Record<BookingStatusValue, BookingStatusValue[]> = {
|
private static readonly STATUS_TRANSITIONS: Record<BookingStatusValue, BookingStatusValue[]> = {
|
||||||
draft: ['pending_confirmation', 'cancelled'],
|
draft: ['pending_confirmation', 'cancelled'],
|
||||||
pending_confirmation: ['confirmed', 'cancelled'],
|
pending_confirmation: ['confirmed', 'cancelled'],
|
||||||
confirmed: ['in_transit', 'cancelled'],
|
confirmed: ['in_transit', 'cancelled'],
|
||||||
in_transit: ['delivered', 'cancelled'],
|
in_transit: ['delivered', 'cancelled'],
|
||||||
delivered: [],
|
delivered: [],
|
||||||
cancelled: [],
|
cancelled: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _value: BookingStatusValue;
|
private readonly _value: BookingStatusValue;
|
||||||
|
|
||||||
private constructor(value: BookingStatusValue) {
|
private constructor(value: BookingStatusValue) {
|
||||||
this._value = value;
|
this._value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): BookingStatusValue {
|
get value(): BookingStatusValue {
|
||||||
return this._value;
|
return this._value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create BookingStatus from string
|
* Create BookingStatus from string
|
||||||
*/
|
*/
|
||||||
static create(value: string): BookingStatus {
|
static create(value: string): BookingStatus {
|
||||||
if (!BookingStatus.isValid(value)) {
|
if (!BookingStatus.isValid(value)) {
|
||||||
throw new InvalidBookingStatusException(value);
|
throw new InvalidBookingStatusException(value);
|
||||||
}
|
}
|
||||||
return new BookingStatus(value as BookingStatusValue);
|
return new BookingStatus(value as BookingStatusValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate status value
|
* Validate status value
|
||||||
*/
|
*/
|
||||||
static isValid(value: string): boolean {
|
static isValid(value: string): boolean {
|
||||||
return BookingStatus.VALID_STATUSES.includes(value as BookingStatusValue);
|
return BookingStatus.VALID_STATUSES.includes(value as BookingStatusValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if transition to another status is allowed
|
* Check if transition to another status is allowed
|
||||||
*/
|
*/
|
||||||
canTransitionTo(newStatus: BookingStatus): boolean {
|
canTransitionTo(newStatus: BookingStatus): boolean {
|
||||||
const allowedTransitions = BookingStatus.STATUS_TRANSITIONS[this._value];
|
const allowedTransitions = BookingStatus.STATUS_TRANSITIONS[this._value];
|
||||||
return allowedTransitions.includes(newStatus._value);
|
return allowedTransitions.includes(newStatus._value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transition to new status
|
* Transition to new status
|
||||||
*/
|
*/
|
||||||
transitionTo(newStatus: BookingStatus): BookingStatus {
|
transitionTo(newStatus: BookingStatus): BookingStatus {
|
||||||
if (!this.canTransitionTo(newStatus)) {
|
if (!this.canTransitionTo(newStatus)) {
|
||||||
throw new Error(
|
throw new Error(`Invalid status transition from ${this._value} to ${newStatus._value}`);
|
||||||
`Invalid status transition from ${this._value} to ${newStatus._value}`
|
}
|
||||||
);
|
return newStatus;
|
||||||
}
|
}
|
||||||
return newStatus;
|
|
||||||
}
|
/**
|
||||||
|
* Check if booking is in a final state
|
||||||
/**
|
*/
|
||||||
* Check if booking is in a final state
|
isFinal(): boolean {
|
||||||
*/
|
return this._value === 'delivered' || this._value === 'cancelled';
|
||||||
isFinal(): boolean {
|
}
|
||||||
return this._value === 'delivered' || this._value === 'cancelled';
|
|
||||||
}
|
/**
|
||||||
|
* Check if booking can be modified
|
||||||
/**
|
*/
|
||||||
* Check if booking can be modified
|
canBeModified(): boolean {
|
||||||
*/
|
return this._value === 'draft' || this._value === 'pending_confirmation';
|
||||||
canBeModified(): boolean {
|
}
|
||||||
return this._value === 'draft' || this._value === 'pending_confirmation';
|
|
||||||
}
|
/**
|
||||||
|
* Equality check
|
||||||
/**
|
*/
|
||||||
* Equality check
|
equals(other: BookingStatus): boolean {
|
||||||
*/
|
return this._value === other._value;
|
||||||
equals(other: BookingStatus): boolean {
|
}
|
||||||
return this._value === other._value;
|
|
||||||
}
|
/**
|
||||||
|
* String representation
|
||||||
/**
|
*/
|
||||||
* String representation
|
toString(): string {
|
||||||
*/
|
return this._value;
|
||||||
toString(): string {
|
}
|
||||||
return this._value;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,112 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* ContainerType Value Object
|
* ContainerType Value Object
|
||||||
*
|
*
|
||||||
* Encapsulates container type validation and behavior
|
* Encapsulates container type validation and behavior
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Container type must be valid (e.g., 20DRY, 40HC, 40REEFER)
|
* - Container type must be valid (e.g., 20DRY, 40HC, 40REEFER)
|
||||||
* - Container type is immutable
|
* - Container type is immutable
|
||||||
*
|
*
|
||||||
* Format: {SIZE}{HEIGHT_MODIFIER?}{CATEGORY}
|
* Format: {SIZE}{HEIGHT_MODIFIER?}{CATEGORY}
|
||||||
* Examples: 20DRY, 40HC, 40REEFER, 45HCREEFER
|
* Examples: 20DRY, 40HC, 40REEFER, 45HCREEFER
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ContainerType {
|
export class ContainerType {
|
||||||
private readonly value: string;
|
private readonly value: string;
|
||||||
|
|
||||||
// Valid container types
|
// Valid container types
|
||||||
private static readonly VALID_TYPES = [
|
private static readonly VALID_TYPES = [
|
||||||
'LCL', // Less than Container Load
|
'LCL', // Less than Container Load
|
||||||
'20DRY',
|
'20DRY',
|
||||||
'40DRY',
|
'40DRY',
|
||||||
'20HC',
|
'20HC',
|
||||||
'40HC',
|
'40HC',
|
||||||
'45HC',
|
'45HC',
|
||||||
'20REEFER',
|
'20REEFER',
|
||||||
'40REEFER',
|
'40REEFER',
|
||||||
'40HCREEFER',
|
'40HCREEFER',
|
||||||
'45HCREEFER',
|
'45HCREEFER',
|
||||||
'20OT', // Open Top
|
'20OT', // Open Top
|
||||||
'40OT',
|
'40OT',
|
||||||
'20FR', // Flat Rack
|
'20FR', // Flat Rack
|
||||||
'40FR',
|
'40FR',
|
||||||
'20TANK',
|
'20TANK',
|
||||||
'40TANK',
|
'40TANK',
|
||||||
];
|
];
|
||||||
|
|
||||||
private constructor(type: string) {
|
private constructor(type: string) {
|
||||||
this.value = type;
|
this.value = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(type: string): ContainerType {
|
static create(type: string): ContainerType {
|
||||||
if (!type || type.trim().length === 0) {
|
if (!type || type.trim().length === 0) {
|
||||||
throw new Error('Container type cannot be empty.');
|
throw new Error('Container type cannot be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = type.trim().toUpperCase();
|
const normalized = type.trim().toUpperCase();
|
||||||
|
|
||||||
if (!ContainerType.isValid(normalized)) {
|
if (!ContainerType.isValid(normalized)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid container type: ${type}. Valid types: ${ContainerType.VALID_TYPES.join(', ')}`
|
`Invalid container type: ${type}. Valid types: ${ContainerType.VALID_TYPES.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ContainerType(normalized);
|
return new ContainerType(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isValid(type: string): boolean {
|
private static isValid(type: string): boolean {
|
||||||
return ContainerType.VALID_TYPES.includes(type);
|
return ContainerType.VALID_TYPES.includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSize(): string {
|
getSize(): string {
|
||||||
// Extract size (first 2 digits)
|
// Extract size (first 2 digits)
|
||||||
return this.value.match(/^\d+/)?.[0] || '';
|
return this.value.match(/^\d+/)?.[0] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
getTEU(): number {
|
getTEU(): number {
|
||||||
const size = this.getSize();
|
const size = this.getSize();
|
||||||
if (size === '20') return 1;
|
if (size === '20') return 1;
|
||||||
if (size === '40' || size === '45') return 2;
|
if (size === '40' || size === '45') return 2;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
isDry(): boolean {
|
isDry(): boolean {
|
||||||
return this.value.includes('DRY');
|
return this.value.includes('DRY');
|
||||||
}
|
}
|
||||||
|
|
||||||
isReefer(): boolean {
|
isReefer(): boolean {
|
||||||
return this.value.includes('REEFER');
|
return this.value.includes('REEFER');
|
||||||
}
|
}
|
||||||
|
|
||||||
isHighCube(): boolean {
|
isHighCube(): boolean {
|
||||||
return this.value.includes('HC');
|
return this.value.includes('HC');
|
||||||
}
|
}
|
||||||
|
|
||||||
isOpenTop(): boolean {
|
isOpenTop(): boolean {
|
||||||
return this.value.includes('OT');
|
return this.value.includes('OT');
|
||||||
}
|
}
|
||||||
|
|
||||||
isFlatRack(): boolean {
|
isFlatRack(): boolean {
|
||||||
return this.value.includes('FR');
|
return this.value.includes('FR');
|
||||||
}
|
}
|
||||||
|
|
||||||
isTank(): boolean {
|
isTank(): boolean {
|
||||||
return this.value.includes('TANK');
|
return this.value.includes('TANK');
|
||||||
}
|
}
|
||||||
|
|
||||||
isLCL(): boolean {
|
isLCL(): boolean {
|
||||||
return this.value === 'LCL';
|
return this.value === 'LCL';
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: ContainerType): boolean {
|
equals(other: ContainerType): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,120 +1,118 @@
|
|||||||
/**
|
/**
|
||||||
* DateRange Value Object
|
* DateRange Value Object
|
||||||
*
|
*
|
||||||
* Encapsulates ETD/ETA date range with validation
|
* Encapsulates ETD/ETA date range with validation
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - End date must be after start date
|
* - End date must be after start date
|
||||||
* - Dates cannot be in the past (for new shipments)
|
* - Dates cannot be in the past (for new shipments)
|
||||||
* - Date range is immutable
|
* - Date range is immutable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class DateRange {
|
export class DateRange {
|
||||||
private readonly startDate: Date;
|
private readonly startDate: Date;
|
||||||
private readonly endDate: Date;
|
private readonly endDate: Date;
|
||||||
|
|
||||||
private constructor(startDate: Date, endDate: Date) {
|
private constructor(startDate: Date, endDate: Date) {
|
||||||
this.startDate = startDate;
|
this.startDate = startDate;
|
||||||
this.endDate = endDate;
|
this.endDate = endDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(startDate: Date, endDate: Date, allowPastDates = false): DateRange {
|
static create(startDate: Date, endDate: Date, allowPastDates = false): DateRange {
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
throw new Error('Start date and end date are required.');
|
throw new Error('Start date and end date are required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endDate <= startDate) {
|
if (endDate <= startDate) {
|
||||||
throw new Error('End date must be after start date.');
|
throw new Error('End date must be after start date.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowPastDates) {
|
if (!allowPastDates) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setHours(0, 0, 0, 0); // Reset time to start of day
|
now.setHours(0, 0, 0, 0); // Reset time to start of day
|
||||||
|
|
||||||
if (startDate < now) {
|
if (startDate < now) {
|
||||||
throw new Error('Start date cannot be in the past.');
|
throw new Error('Start date cannot be in the past.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DateRange(new Date(startDate), new Date(endDate));
|
return new DateRange(new Date(startDate), new Date(endDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create from ETD and transit days
|
* Create from ETD and transit days
|
||||||
*/
|
*/
|
||||||
static fromTransitDays(etd: Date, transitDays: number): DateRange {
|
static fromTransitDays(etd: Date, transitDays: number): DateRange {
|
||||||
if (transitDays <= 0) {
|
if (transitDays <= 0) {
|
||||||
throw new Error('Transit days must be positive.');
|
throw new Error('Transit days must be positive.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const eta = new Date(etd);
|
const eta = new Date(etd);
|
||||||
eta.setDate(eta.getDate() + transitDays);
|
eta.setDate(eta.getDate() + transitDays);
|
||||||
|
|
||||||
return DateRange.create(etd, eta, true);
|
return DateRange.create(etd, eta, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
getStartDate(): Date {
|
getStartDate(): Date {
|
||||||
return new Date(this.startDate);
|
return new Date(this.startDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEndDate(): Date {
|
getEndDate(): Date {
|
||||||
return new Date(this.endDate);
|
return new Date(this.endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDurationInDays(): number {
|
getDurationInDays(): number {
|
||||||
const diffTime = this.endDate.getTime() - this.startDate.getTime();
|
const diffTime = this.endDate.getTime() - this.startDate.getTime();
|
||||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
getDurationInHours(): number {
|
getDurationInHours(): number {
|
||||||
const diffTime = this.endDate.getTime() - this.startDate.getTime();
|
const diffTime = this.endDate.getTime() - this.startDate.getTime();
|
||||||
return Math.ceil(diffTime / (1000 * 60 * 60));
|
return Math.ceil(diffTime / (1000 * 60 * 60));
|
||||||
}
|
}
|
||||||
|
|
||||||
contains(date: Date): boolean {
|
contains(date: Date): boolean {
|
||||||
return date >= this.startDate && date <= this.endDate;
|
return date >= this.startDate && date <= this.endDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
overlaps(other: DateRange): boolean {
|
overlaps(other: DateRange): boolean {
|
||||||
return (
|
return this.startDate <= other.endDate && this.endDate >= other.startDate;
|
||||||
this.startDate <= other.endDate && this.endDate >= other.startDate
|
}
|
||||||
);
|
|
||||||
}
|
isFutureRange(): boolean {
|
||||||
|
const now = new Date();
|
||||||
isFutureRange(): boolean {
|
return this.startDate > now;
|
||||||
const now = new Date();
|
}
|
||||||
return this.startDate > now;
|
|
||||||
}
|
isPastRange(): boolean {
|
||||||
|
const now = new Date();
|
||||||
isPastRange(): boolean {
|
return this.endDate < now;
|
||||||
const now = new Date();
|
}
|
||||||
return this.endDate < now;
|
|
||||||
}
|
isCurrentRange(): boolean {
|
||||||
|
const now = new Date();
|
||||||
isCurrentRange(): boolean {
|
return this.contains(now);
|
||||||
const now = new Date();
|
}
|
||||||
return this.contains(now);
|
|
||||||
}
|
equals(other: DateRange): boolean {
|
||||||
|
return (
|
||||||
equals(other: DateRange): boolean {
|
this.startDate.getTime() === other.startDate.getTime() &&
|
||||||
return (
|
this.endDate.getTime() === other.endDate.getTime()
|
||||||
this.startDate.getTime() === other.startDate.getTime() &&
|
);
|
||||||
this.endDate.getTime() === other.endDate.getTime()
|
}
|
||||||
);
|
|
||||||
}
|
toString(): string {
|
||||||
|
return `${this.formatDate(this.startDate)} - ${this.formatDate(this.endDate)}`;
|
||||||
toString(): string {
|
}
|
||||||
return `${this.formatDate(this.startDate)} - ${this.formatDate(this.endDate)}`;
|
|
||||||
}
|
private formatDate(date: Date): string {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
private formatDate(date: Date): string {
|
}
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
toObject(): { startDate: Date; endDate: Date } {
|
||||||
|
return {
|
||||||
toObject(): { startDate: Date; endDate: Date } {
|
startDate: new Date(this.startDate),
|
||||||
return {
|
endDate: new Date(this.endDate),
|
||||||
startDate: new Date(this.startDate),
|
};
|
||||||
endDate: new Date(this.endDate),
|
}
|
||||||
};
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,70 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* Email Value Object Unit Tests
|
* Email Value Object Unit Tests
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Email } from './email.vo';
|
import { Email } from './email.vo';
|
||||||
|
|
||||||
describe('Email Value Object', () => {
|
describe('Email Value Object', () => {
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create email with valid format', () => {
|
it('should create email with valid format', () => {
|
||||||
const email = Email.create('user@example.com');
|
const email = Email.create('user@example.com');
|
||||||
expect(email.getValue()).toBe('user@example.com');
|
expect(email.getValue()).toBe('user@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize email to lowercase', () => {
|
it('should normalize email to lowercase', () => {
|
||||||
const email = Email.create('User@Example.COM');
|
const email = Email.create('User@Example.COM');
|
||||||
expect(email.getValue()).toBe('user@example.com');
|
expect(email.getValue()).toBe('user@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trim whitespace', () => {
|
it('should trim whitespace', () => {
|
||||||
const email = Email.create(' user@example.com ');
|
const email = Email.create(' user@example.com ');
|
||||||
expect(email.getValue()).toBe('user@example.com');
|
expect(email.getValue()).toBe('user@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for empty email', () => {
|
it('should throw error for empty email', () => {
|
||||||
expect(() => Email.create('')).toThrow('Email cannot be empty.');
|
expect(() => Email.create('')).toThrow('Email cannot be empty.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for invalid format', () => {
|
it('should throw error for invalid format', () => {
|
||||||
expect(() => Email.create('invalid-email')).toThrow('Invalid email format');
|
expect(() => Email.create('invalid-email')).toThrow('Invalid email format');
|
||||||
expect(() => Email.create('@example.com')).toThrow('Invalid email format');
|
expect(() => Email.create('@example.com')).toThrow('Invalid email format');
|
||||||
expect(() => Email.create('user@')).toThrow('Invalid email format');
|
expect(() => Email.create('user@')).toThrow('Invalid email format');
|
||||||
expect(() => Email.create('user@.com')).toThrow('Invalid email format');
|
expect(() => Email.create('user@.com')).toThrow('Invalid email format');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDomain', () => {
|
describe('getDomain', () => {
|
||||||
it('should return email domain', () => {
|
it('should return email domain', () => {
|
||||||
const email = Email.create('user@example.com');
|
const email = Email.create('user@example.com');
|
||||||
expect(email.getDomain()).toBe('example.com');
|
expect(email.getDomain()).toBe('example.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getLocalPart', () => {
|
describe('getLocalPart', () => {
|
||||||
it('should return email local part', () => {
|
it('should return email local part', () => {
|
||||||
const email = Email.create('user@example.com');
|
const email = Email.create('user@example.com');
|
||||||
expect(email.getLocalPart()).toBe('user');
|
expect(email.getLocalPart()).toBe('user');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('equals', () => {
|
describe('equals', () => {
|
||||||
it('should return true for same email', () => {
|
it('should return true for same email', () => {
|
||||||
const email1 = Email.create('user@example.com');
|
const email1 = Email.create('user@example.com');
|
||||||
const email2 = Email.create('user@example.com');
|
const email2 = Email.create('user@example.com');
|
||||||
expect(email1.equals(email2)).toBe(true);
|
expect(email1.equals(email2)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for different emails', () => {
|
it('should return false for different emails', () => {
|
||||||
const email1 = Email.create('user1@example.com');
|
const email1 = Email.create('user1@example.com');
|
||||||
const email2 = Email.create('user2@example.com');
|
const email2 = Email.create('user2@example.com');
|
||||||
expect(email1.equals(email2)).toBe(false);
|
expect(email1.equals(email2)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toString', () => {
|
describe('toString', () => {
|
||||||
it('should return email as string', () => {
|
it('should return email as string', () => {
|
||||||
const email = Email.create('user@example.com');
|
const email = Email.create('user@example.com');
|
||||||
expect(email.toString()).toBe('user@example.com');
|
expect(email.toString()).toBe('user@example.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,60 +1,60 @@
|
|||||||
/**
|
/**
|
||||||
* Email Value Object
|
* Email Value Object
|
||||||
*
|
*
|
||||||
* Encapsulates email address validation and behavior
|
* Encapsulates email address validation and behavior
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Email must be valid format
|
* - Email must be valid format
|
||||||
* - Email is case-insensitive (stored lowercase)
|
* - Email is case-insensitive (stored lowercase)
|
||||||
* - Email is immutable
|
* - Email is immutable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class Email {
|
export class Email {
|
||||||
private readonly value: string;
|
private readonly value: string;
|
||||||
|
|
||||||
private constructor(email: string) {
|
private constructor(email: string) {
|
||||||
this.value = email;
|
this.value = email;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(email: string): Email {
|
static create(email: string): Email {
|
||||||
if (!email || email.trim().length === 0) {
|
if (!email || email.trim().length === 0) {
|
||||||
throw new Error('Email cannot be empty.');
|
throw new Error('Email cannot be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = email.trim().toLowerCase();
|
const normalized = email.trim().toLowerCase();
|
||||||
|
|
||||||
if (!Email.isValid(normalized)) {
|
if (!Email.isValid(normalized)) {
|
||||||
throw new Error(`Invalid email format: ${email}`);
|
throw new Error(`Invalid email format: ${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Email(normalized);
|
return new Email(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isValid(email: string): boolean {
|
private static isValid(email: string): boolean {
|
||||||
// RFC 5322 simplified email regex
|
// RFC 5322 simplified email regex
|
||||||
const emailPattern =
|
const emailPattern =
|
||||||
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||||
|
|
||||||
return emailPattern.test(email);
|
return emailPattern.test(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDomain(): string {
|
getDomain(): string {
|
||||||
return this.value.split('@')[1];
|
return this.value.split('@')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalPart(): string {
|
getLocalPart(): string {
|
||||||
return this.value.split('@')[0];
|
return this.value.split('@')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Email): boolean {
|
equals(other: Email): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Domain Value Objects Barrel Export
|
* Domain Value Objects Barrel Export
|
||||||
*
|
*
|
||||||
* All value objects for the Xpeditis platform
|
* All value objects for the Xpeditis platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './email.vo';
|
export * from './email.vo';
|
||||||
export * from './port-code.vo';
|
export * from './port-code.vo';
|
||||||
export * from './money.vo';
|
export * from './money.vo';
|
||||||
export * from './container-type.vo';
|
export * from './container-type.vo';
|
||||||
export * from './date-range.vo';
|
export * from './date-range.vo';
|
||||||
export * from './booking-number.vo';
|
export * from './booking-number.vo';
|
||||||
export * from './booking-status.vo';
|
export * from './booking-status.vo';
|
||||||
|
|||||||
@ -1,133 +1,133 @@
|
|||||||
/**
|
/**
|
||||||
* Money Value Object Unit Tests
|
* Money Value Object Unit Tests
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Money } from './money.vo';
|
import { Money } from './money.vo';
|
||||||
|
|
||||||
describe('Money Value Object', () => {
|
describe('Money Value Object', () => {
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create money with valid amount and currency', () => {
|
it('should create money with valid amount and currency', () => {
|
||||||
const money = Money.create(100, 'USD');
|
const money = Money.create(100, 'USD');
|
||||||
expect(money.getAmount()).toBe(100);
|
expect(money.getAmount()).toBe(100);
|
||||||
expect(money.getCurrency()).toBe('USD');
|
expect(money.getCurrency()).toBe('USD');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should round to 2 decimal places', () => {
|
it('should round to 2 decimal places', () => {
|
||||||
const money = Money.create(100.999, 'USD');
|
const money = Money.create(100.999, 'USD');
|
||||||
expect(money.getAmount()).toBe(101);
|
expect(money.getAmount()).toBe(101);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for negative amount', () => {
|
it('should throw error for negative amount', () => {
|
||||||
expect(() => Money.create(-100, 'USD')).toThrow('Amount cannot be negative');
|
expect(() => Money.create(-100, 'USD')).toThrow('Amount cannot be negative');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for invalid currency', () => {
|
it('should throw error for invalid currency', () => {
|
||||||
expect(() => Money.create(100, 'XXX')).toThrow('Invalid currency code');
|
expect(() => Money.create(100, 'XXX')).toThrow('Invalid currency code');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize currency to uppercase', () => {
|
it('should normalize currency to uppercase', () => {
|
||||||
const money = Money.create(100, 'usd');
|
const money = Money.create(100, 'usd');
|
||||||
expect(money.getCurrency()).toBe('USD');
|
expect(money.getCurrency()).toBe('USD');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('zero', () => {
|
describe('zero', () => {
|
||||||
it('should create zero amount', () => {
|
it('should create zero amount', () => {
|
||||||
const money = Money.zero('USD');
|
const money = Money.zero('USD');
|
||||||
expect(money.getAmount()).toBe(0);
|
expect(money.getAmount()).toBe(0);
|
||||||
expect(money.isZero()).toBe(true);
|
expect(money.isZero()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('add', () => {
|
describe('add', () => {
|
||||||
it('should add two money amounts', () => {
|
it('should add two money amounts', () => {
|
||||||
const money1 = Money.create(100, 'USD');
|
const money1 = Money.create(100, 'USD');
|
||||||
const money2 = Money.create(50, 'USD');
|
const money2 = Money.create(50, 'USD');
|
||||||
const result = money1.add(money2);
|
const result = money1.add(money2);
|
||||||
expect(result.getAmount()).toBe(150);
|
expect(result.getAmount()).toBe(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for currency mismatch', () => {
|
it('should throw error for currency mismatch', () => {
|
||||||
const money1 = Money.create(100, 'USD');
|
const money1 = Money.create(100, 'USD');
|
||||||
const money2 = Money.create(50, 'EUR');
|
const money2 = Money.create(50, 'EUR');
|
||||||
expect(() => money1.add(money2)).toThrow('Currency mismatch');
|
expect(() => money1.add(money2)).toThrow('Currency mismatch');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('subtract', () => {
|
describe('subtract', () => {
|
||||||
it('should subtract two money amounts', () => {
|
it('should subtract two money amounts', () => {
|
||||||
const money1 = Money.create(100, 'USD');
|
const money1 = Money.create(100, 'USD');
|
||||||
const money2 = Money.create(30, 'USD');
|
const money2 = Money.create(30, 'USD');
|
||||||
const result = money1.subtract(money2);
|
const result = money1.subtract(money2);
|
||||||
expect(result.getAmount()).toBe(70);
|
expect(result.getAmount()).toBe(70);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for negative result', () => {
|
it('should throw error for negative result', () => {
|
||||||
const money1 = Money.create(50, 'USD');
|
const money1 = Money.create(50, 'USD');
|
||||||
const money2 = Money.create(100, 'USD');
|
const money2 = Money.create(100, 'USD');
|
||||||
expect(() => money1.subtract(money2)).toThrow('negative amount');
|
expect(() => money1.subtract(money2)).toThrow('negative amount');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('multiply', () => {
|
describe('multiply', () => {
|
||||||
it('should multiply money amount', () => {
|
it('should multiply money amount', () => {
|
||||||
const money = Money.create(100, 'USD');
|
const money = Money.create(100, 'USD');
|
||||||
const result = money.multiply(2);
|
const result = money.multiply(2);
|
||||||
expect(result.getAmount()).toBe(200);
|
expect(result.getAmount()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for negative multiplier', () => {
|
it('should throw error for negative multiplier', () => {
|
||||||
const money = Money.create(100, 'USD');
|
const money = Money.create(100, 'USD');
|
||||||
expect(() => money.multiply(-2)).toThrow('Multiplier cannot be negative');
|
expect(() => money.multiply(-2)).toThrow('Multiplier cannot be negative');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('divide', () => {
|
describe('divide', () => {
|
||||||
it('should divide money amount', () => {
|
it('should divide money amount', () => {
|
||||||
const money = Money.create(100, 'USD');
|
const money = Money.create(100, 'USD');
|
||||||
const result = money.divide(2);
|
const result = money.divide(2);
|
||||||
expect(result.getAmount()).toBe(50);
|
expect(result.getAmount()).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for zero divisor', () => {
|
it('should throw error for zero divisor', () => {
|
||||||
const money = Money.create(100, 'USD');
|
const money = Money.create(100, 'USD');
|
||||||
expect(() => money.divide(0)).toThrow('Divisor must be positive');
|
expect(() => money.divide(0)).toThrow('Divisor must be positive');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('comparisons', () => {
|
describe('comparisons', () => {
|
||||||
it('should compare greater than', () => {
|
it('should compare greater than', () => {
|
||||||
const money1 = Money.create(100, 'USD');
|
const money1 = Money.create(100, 'USD');
|
||||||
const money2 = Money.create(50, 'USD');
|
const money2 = Money.create(50, 'USD');
|
||||||
expect(money1.isGreaterThan(money2)).toBe(true);
|
expect(money1.isGreaterThan(money2)).toBe(true);
|
||||||
expect(money2.isGreaterThan(money1)).toBe(false);
|
expect(money2.isGreaterThan(money1)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should compare less than', () => {
|
it('should compare less than', () => {
|
||||||
const money1 = Money.create(50, 'USD');
|
const money1 = Money.create(50, 'USD');
|
||||||
const money2 = Money.create(100, 'USD');
|
const money2 = Money.create(100, 'USD');
|
||||||
expect(money1.isLessThan(money2)).toBe(true);
|
expect(money1.isLessThan(money2)).toBe(true);
|
||||||
expect(money2.isLessThan(money1)).toBe(false);
|
expect(money2.isLessThan(money1)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should compare equality', () => {
|
it('should compare equality', () => {
|
||||||
const money1 = Money.create(100, 'USD');
|
const money1 = Money.create(100, 'USD');
|
||||||
const money2 = Money.create(100, 'USD');
|
const money2 = Money.create(100, 'USD');
|
||||||
const money3 = Money.create(50, 'USD');
|
const money3 = Money.create(50, 'USD');
|
||||||
expect(money1.isEqualTo(money2)).toBe(true);
|
expect(money1.isEqualTo(money2)).toBe(true);
|
||||||
expect(money1.isEqualTo(money3)).toBe(false);
|
expect(money1.isEqualTo(money3)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('format', () => {
|
describe('format', () => {
|
||||||
it('should format USD with $ symbol', () => {
|
it('should format USD with $ symbol', () => {
|
||||||
const money = Money.create(100.5, 'USD');
|
const money = Money.create(100.5, 'USD');
|
||||||
expect(money.format()).toBe('$100.50');
|
expect(money.format()).toBe('$100.50');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format EUR with € symbol', () => {
|
it('should format EUR with € symbol', () => {
|
||||||
const money = Money.create(100.5, 'EUR');
|
const money = Money.create(100.5, 'EUR');
|
||||||
expect(money.format()).toBe('€100.50');
|
expect(money.format()).toBe('€100.50');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,137 +1,137 @@
|
|||||||
/**
|
/**
|
||||||
* Money Value Object
|
* Money Value Object
|
||||||
*
|
*
|
||||||
* Encapsulates currency and amount with proper validation
|
* Encapsulates currency and amount with proper validation
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Amount must be non-negative
|
* - Amount must be non-negative
|
||||||
* - Currency must be valid ISO 4217 code
|
* - Currency must be valid ISO 4217 code
|
||||||
* - Money is immutable
|
* - Money is immutable
|
||||||
* - Arithmetic operations return new Money instances
|
* - Arithmetic operations return new Money instances
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class Money {
|
export class Money {
|
||||||
private readonly amount: number;
|
private readonly amount: number;
|
||||||
private readonly currency: string;
|
private readonly currency: string;
|
||||||
|
|
||||||
private static readonly SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'CNY', 'JPY'];
|
private static readonly SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'CNY', 'JPY'];
|
||||||
|
|
||||||
private constructor(amount: number, currency: string) {
|
private constructor(amount: number, currency: string) {
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(amount: number, currency: string): Money {
|
static create(amount: number, currency: string): Money {
|
||||||
if (amount < 0) {
|
if (amount < 0) {
|
||||||
throw new Error('Amount cannot be negative.');
|
throw new Error('Amount cannot be negative.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedCurrency = currency.trim().toUpperCase();
|
const normalizedCurrency = currency.trim().toUpperCase();
|
||||||
|
|
||||||
if (!Money.isValidCurrency(normalizedCurrency)) {
|
if (!Money.isValidCurrency(normalizedCurrency)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid currency code: ${currency}. Supported currencies: ${Money.SUPPORTED_CURRENCIES.join(', ')}`
|
`Invalid currency code: ${currency}. Supported currencies: ${Money.SUPPORTED_CURRENCIES.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round to 2 decimal places to avoid floating point issues
|
// Round to 2 decimal places to avoid floating point issues
|
||||||
const roundedAmount = Math.round(amount * 100) / 100;
|
const roundedAmount = Math.round(amount * 100) / 100;
|
||||||
|
|
||||||
return new Money(roundedAmount, normalizedCurrency);
|
return new Money(roundedAmount, normalizedCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
static zero(currency: string): Money {
|
static zero(currency: string): Money {
|
||||||
return Money.create(0, currency);
|
return Money.create(0, currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isValidCurrency(currency: string): boolean {
|
private static isValidCurrency(currency: string): boolean {
|
||||||
return Money.SUPPORTED_CURRENCIES.includes(currency);
|
return Money.SUPPORTED_CURRENCIES.includes(currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAmount(): number {
|
getAmount(): number {
|
||||||
return this.amount;
|
return this.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrency(): string {
|
getCurrency(): string {
|
||||||
return this.currency;
|
return this.currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
add(other: Money): Money {
|
add(other: Money): Money {
|
||||||
this.ensureSameCurrency(other);
|
this.ensureSameCurrency(other);
|
||||||
return Money.create(this.amount + other.amount, this.currency);
|
return Money.create(this.amount + other.amount, this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
subtract(other: Money): Money {
|
subtract(other: Money): Money {
|
||||||
this.ensureSameCurrency(other);
|
this.ensureSameCurrency(other);
|
||||||
const result = this.amount - other.amount;
|
const result = this.amount - other.amount;
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
throw new Error('Subtraction would result in negative amount.');
|
throw new Error('Subtraction would result in negative amount.');
|
||||||
}
|
}
|
||||||
return Money.create(result, this.currency);
|
return Money.create(result, this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
multiply(multiplier: number): Money {
|
multiply(multiplier: number): Money {
|
||||||
if (multiplier < 0) {
|
if (multiplier < 0) {
|
||||||
throw new Error('Multiplier cannot be negative.');
|
throw new Error('Multiplier cannot be negative.');
|
||||||
}
|
}
|
||||||
return Money.create(this.amount * multiplier, this.currency);
|
return Money.create(this.amount * multiplier, this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
divide(divisor: number): Money {
|
divide(divisor: number): Money {
|
||||||
if (divisor <= 0) {
|
if (divisor <= 0) {
|
||||||
throw new Error('Divisor must be positive.');
|
throw new Error('Divisor must be positive.');
|
||||||
}
|
}
|
||||||
return Money.create(this.amount / divisor, this.currency);
|
return Money.create(this.amount / divisor, this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
isGreaterThan(other: Money): boolean {
|
isGreaterThan(other: Money): boolean {
|
||||||
this.ensureSameCurrency(other);
|
this.ensureSameCurrency(other);
|
||||||
return this.amount > other.amount;
|
return this.amount > other.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLessThan(other: Money): boolean {
|
isLessThan(other: Money): boolean {
|
||||||
this.ensureSameCurrency(other);
|
this.ensureSameCurrency(other);
|
||||||
return this.amount < other.amount;
|
return this.amount < other.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
isEqualTo(other: Money): boolean {
|
isEqualTo(other: Money): boolean {
|
||||||
return this.currency === other.currency && this.amount === other.amount;
|
return this.currency === other.currency && this.amount === other.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
isZero(): boolean {
|
isZero(): boolean {
|
||||||
return this.amount === 0;
|
return this.amount === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureSameCurrency(other: Money): void {
|
private ensureSameCurrency(other: Money): void {
|
||||||
if (this.currency !== other.currency) {
|
if (this.currency !== other.currency) {
|
||||||
throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
|
throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format as string with currency symbol
|
* Format as string with currency symbol
|
||||||
*/
|
*/
|
||||||
format(): string {
|
format(): string {
|
||||||
const symbols: { [key: string]: string } = {
|
const symbols: { [key: string]: string } = {
|
||||||
USD: '$',
|
USD: '$',
|
||||||
EUR: '€',
|
EUR: '€',
|
||||||
GBP: '£',
|
GBP: '£',
|
||||||
CNY: '¥',
|
CNY: '¥',
|
||||||
JPY: '¥',
|
JPY: '¥',
|
||||||
};
|
};
|
||||||
|
|
||||||
const symbol = symbols[this.currency] || this.currency;
|
const symbol = symbols[this.currency] || this.currency;
|
||||||
return `${symbol}${this.amount.toFixed(2)}`;
|
return `${symbol}${this.amount.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return this.format();
|
return this.format();
|
||||||
}
|
}
|
||||||
|
|
||||||
toObject(): { amount: number; currency: string } {
|
toObject(): { amount: number; currency: string } {
|
||||||
return {
|
return {
|
||||||
amount: this.amount,
|
amount: this.amount,
|
||||||
currency: this.currency,
|
currency: this.currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +1,66 @@
|
|||||||
/**
|
/**
|
||||||
* PortCode Value Object
|
* PortCode Value Object
|
||||||
*
|
*
|
||||||
* Encapsulates UN/LOCODE port code validation and behavior
|
* Encapsulates UN/LOCODE port code validation and behavior
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter/digit location)
|
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter/digit location)
|
||||||
* - Port code is always uppercase
|
* - Port code is always uppercase
|
||||||
* - Port code is immutable
|
* - Port code is immutable
|
||||||
*
|
*
|
||||||
* Format: CCLLL
|
* Format: CCLLL
|
||||||
* - CC: ISO 3166-1 alpha-2 country code
|
* - CC: ISO 3166-1 alpha-2 country code
|
||||||
* - LLL: 3-character location code (letters or digits)
|
* - LLL: 3-character location code (letters or digits)
|
||||||
*
|
*
|
||||||
* Examples: NLRTM (Rotterdam), USNYC (New York), SGSIN (Singapore)
|
* Examples: NLRTM (Rotterdam), USNYC (New York), SGSIN (Singapore)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class PortCode {
|
export class PortCode {
|
||||||
private readonly value: string;
|
private readonly value: string;
|
||||||
|
|
||||||
private constructor(code: string) {
|
private constructor(code: string) {
|
||||||
this.value = code;
|
this.value = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(code: string): PortCode {
|
static create(code: string): PortCode {
|
||||||
if (!code || code.trim().length === 0) {
|
if (!code || code.trim().length === 0) {
|
||||||
throw new Error('Port code cannot be empty.');
|
throw new Error('Port code cannot be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = code.trim().toUpperCase();
|
const normalized = code.trim().toUpperCase();
|
||||||
|
|
||||||
if (!PortCode.isValid(normalized)) {
|
if (!PortCode.isValid(normalized)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid port code format: ${code}. Must follow UN/LOCODE format (e.g., NLRTM, USNYC).`
|
`Invalid port code format: ${code}. Must follow UN/LOCODE format (e.g., NLRTM, USNYC).`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PortCode(normalized);
|
return new PortCode(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isValid(code: string): boolean {
|
private static isValid(code: string): boolean {
|
||||||
// UN/LOCODE format: 2-letter country code + 3-character location code
|
// UN/LOCODE format: 2-letter country code + 3-character location code
|
||||||
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
|
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
|
||||||
return unlocodePattern.test(code);
|
return unlocodePattern.test(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCountryCode(): string {
|
getCountryCode(): string {
|
||||||
return this.value.substring(0, 2);
|
return this.value.substring(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocationCode(): string {
|
getLocationCode(): string {
|
||||||
return this.value.substring(2);
|
return this.value.substring(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: PortCode): boolean {
|
equals(other: PortCode): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,107 +1,105 @@
|
|||||||
import { Money } from './money.vo';
|
import { Money } from './money.vo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Surcharge Type Enumeration
|
* Surcharge Type Enumeration
|
||||||
* Common maritime shipping surcharges
|
* Common maritime shipping surcharges
|
||||||
*/
|
*/
|
||||||
export enum SurchargeType {
|
export enum SurchargeType {
|
||||||
BAF = 'BAF', // Bunker Adjustment Factor
|
BAF = 'BAF', // Bunker Adjustment Factor
|
||||||
CAF = 'CAF', // Currency Adjustment Factor
|
CAF = 'CAF', // Currency Adjustment Factor
|
||||||
PSS = 'PSS', // Peak Season Surcharge
|
PSS = 'PSS', // Peak Season Surcharge
|
||||||
THC = 'THC', // Terminal Handling Charge
|
THC = 'THC', // Terminal Handling Charge
|
||||||
OTHER = 'OTHER',
|
OTHER = 'OTHER',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Surcharge Value Object
|
* Surcharge Value Object
|
||||||
* Represents additional fees applied to base freight rates
|
* Represents additional fees applied to base freight rates
|
||||||
*/
|
*/
|
||||||
export class Surcharge {
|
export class Surcharge {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly type: SurchargeType,
|
public readonly type: SurchargeType,
|
||||||
public readonly amount: Money,
|
public readonly amount: Money,
|
||||||
public readonly description?: string,
|
public readonly description?: string
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private validate(): void {
|
private validate(): void {
|
||||||
if (!Object.values(SurchargeType).includes(this.type)) {
|
if (!Object.values(SurchargeType).includes(this.type)) {
|
||||||
throw new Error(`Invalid surcharge type: ${this.type}`);
|
throw new Error(`Invalid surcharge type: ${this.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get human-readable surcharge label
|
* Get human-readable surcharge label
|
||||||
*/
|
*/
|
||||||
getLabel(): string {
|
getLabel(): string {
|
||||||
const labels: Record<SurchargeType, string> = {
|
const labels: Record<SurchargeType, string> = {
|
||||||
[SurchargeType.BAF]: 'Bunker Adjustment Factor',
|
[SurchargeType.BAF]: 'Bunker Adjustment Factor',
|
||||||
[SurchargeType.CAF]: 'Currency Adjustment Factor',
|
[SurchargeType.CAF]: 'Currency Adjustment Factor',
|
||||||
[SurchargeType.PSS]: 'Peak Season Surcharge',
|
[SurchargeType.PSS]: 'Peak Season Surcharge',
|
||||||
[SurchargeType.THC]: 'Terminal Handling Charge',
|
[SurchargeType.THC]: 'Terminal Handling Charge',
|
||||||
[SurchargeType.OTHER]: 'Other Surcharge',
|
[SurchargeType.OTHER]: 'Other Surcharge',
|
||||||
};
|
};
|
||||||
return labels[this.type];
|
return labels[this.type];
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Surcharge): boolean {
|
equals(other: Surcharge): boolean {
|
||||||
return (
|
return this.type === other.type && this.amount.isEqualTo(other.amount);
|
||||||
this.type === other.type &&
|
}
|
||||||
this.amount.isEqualTo(other.amount)
|
|
||||||
);
|
toString(): string {
|
||||||
}
|
const label = this.description || this.getLabel();
|
||||||
|
return `${label}: ${this.amount.toString()}`;
|
||||||
toString(): string {
|
}
|
||||||
const label = this.description || this.getLabel();
|
}
|
||||||
return `${label}: ${this.amount.toString()}`;
|
|
||||||
}
|
/**
|
||||||
}
|
* Collection of surcharges with utility methods
|
||||||
|
*/
|
||||||
/**
|
export class SurchargeCollection {
|
||||||
* Collection of surcharges with utility methods
|
constructor(public readonly surcharges: Surcharge[]) {}
|
||||||
*/
|
|
||||||
export class SurchargeCollection {
|
/**
|
||||||
constructor(public readonly surcharges: Surcharge[]) {}
|
* Calculate total surcharge amount in a specific currency
|
||||||
|
* Note: This assumes all surcharges are in the same currency
|
||||||
/**
|
* In production, currency conversion would be needed
|
||||||
* Calculate total surcharge amount in a specific currency
|
*/
|
||||||
* Note: This assumes all surcharges are in the same currency
|
getTotalAmount(currency: string): Money {
|
||||||
* In production, currency conversion would be needed
|
const relevantSurcharges = this.surcharges.filter(s => s.amount.getCurrency() === currency);
|
||||||
*/
|
|
||||||
getTotalAmount(currency: string): Money {
|
if (relevantSurcharges.length === 0) {
|
||||||
const relevantSurcharges = this.surcharges
|
return Money.zero(currency);
|
||||||
.filter((s) => s.amount.getCurrency() === currency);
|
}
|
||||||
|
|
||||||
if (relevantSurcharges.length === 0) {
|
return relevantSurcharges.reduce(
|
||||||
return Money.zero(currency);
|
(total, surcharge) => total.add(surcharge.amount),
|
||||||
}
|
Money.zero(currency)
|
||||||
|
);
|
||||||
return relevantSurcharges
|
}
|
||||||
.reduce((total, surcharge) => total.add(surcharge.amount), Money.zero(currency));
|
|
||||||
}
|
/**
|
||||||
|
* Check if collection has any surcharges
|
||||||
/**
|
*/
|
||||||
* Check if collection has any surcharges
|
isEmpty(): boolean {
|
||||||
*/
|
return this.surcharges.length === 0;
|
||||||
isEmpty(): boolean {
|
}
|
||||||
return this.surcharges.length === 0;
|
|
||||||
}
|
/**
|
||||||
|
* Get surcharges by type
|
||||||
/**
|
*/
|
||||||
* Get surcharges by type
|
getByType(type: SurchargeType): Surcharge[] {
|
||||||
*/
|
return this.surcharges.filter(s => s.type === type);
|
||||||
getByType(type: SurchargeType): Surcharge[] {
|
}
|
||||||
return this.surcharges.filter((s) => s.type === type);
|
|
||||||
}
|
/**
|
||||||
|
* Get formatted surcharge details for display
|
||||||
/**
|
*/
|
||||||
* Get formatted surcharge details for display
|
getDetails(): string {
|
||||||
*/
|
if (this.isEmpty()) {
|
||||||
getDetails(): string {
|
return 'All-in price (no separate surcharges)';
|
||||||
if (this.isEmpty()) {
|
}
|
||||||
return 'All-in price (no separate surcharges)';
|
return this.surcharges.map(s => s.toString()).join(', ');
|
||||||
}
|
}
|
||||||
return this.surcharges.map((s) => s.toString()).join(', ');
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,54 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* Volume Value Object
|
* Volume Value Object
|
||||||
* Represents shipping volume in CBM (Cubic Meters) and weight in KG
|
* Represents shipping volume in CBM (Cubic Meters) and weight in KG
|
||||||
*
|
*
|
||||||
* Business Rule: Price is calculated using freight class rule:
|
* Business Rule: Price is calculated using freight class rule:
|
||||||
* - Take the higher of: (volumeCBM * pricePerCBM) or (weightKG * pricePerKG)
|
* - Take the higher of: (volumeCBM * pricePerCBM) or (weightKG * pricePerKG)
|
||||||
*/
|
*/
|
||||||
export class Volume {
|
export class Volume {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly cbm: number,
|
public readonly cbm: number,
|
||||||
public readonly weightKG: number,
|
public readonly weightKG: number
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private validate(): void {
|
private validate(): void {
|
||||||
if (this.cbm < 0) {
|
if (this.cbm < 0) {
|
||||||
throw new Error('Volume in CBM cannot be negative');
|
throw new Error('Volume in CBM cannot be negative');
|
||||||
}
|
}
|
||||||
if (this.weightKG < 0) {
|
if (this.weightKG < 0) {
|
||||||
throw new Error('Weight in KG cannot be negative');
|
throw new Error('Weight in KG cannot be negative');
|
||||||
}
|
}
|
||||||
if (this.cbm === 0 && this.weightKG === 0) {
|
if (this.cbm === 0 && this.weightKG === 0) {
|
||||||
throw new Error('Either volume or weight must be greater than zero');
|
throw new Error('Either volume or weight must be greater than zero');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this volume is within the specified range
|
* Check if this volume is within the specified range
|
||||||
*/
|
*/
|
||||||
isWithinRange(minCBM: number, maxCBM: number, minKG: number, maxKG: number): boolean {
|
isWithinRange(minCBM: number, maxCBM: number, minKG: number, maxKG: number): boolean {
|
||||||
const cbmInRange = this.cbm >= minCBM && this.cbm <= maxCBM;
|
const cbmInRange = this.cbm >= minCBM && this.cbm <= maxCBM;
|
||||||
const weightInRange = this.weightKG >= minKG && this.weightKG <= maxKG;
|
const weightInRange = this.weightKG >= minKG && this.weightKG <= maxKG;
|
||||||
return cbmInRange && weightInRange;
|
return cbmInRange && weightInRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate freight price using the freight class rule
|
* Calculate freight price using the freight class rule
|
||||||
* Returns the higher value between volume-based and weight-based pricing
|
* Returns the higher value between volume-based and weight-based pricing
|
||||||
*/
|
*/
|
||||||
calculateFreightPrice(pricePerCBM: number, pricePerKG: number): number {
|
calculateFreightPrice(pricePerCBM: number, pricePerKG: number): number {
|
||||||
const volumePrice = this.cbm * pricePerCBM;
|
const volumePrice = this.cbm * pricePerCBM;
|
||||||
const weightPrice = this.weightKG * pricePerKG;
|
const weightPrice = this.weightKG * pricePerKG;
|
||||||
return Math.max(volumePrice, weightPrice);
|
return Math.max(volumePrice, weightPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Volume): boolean {
|
equals(other: Volume): boolean {
|
||||||
return this.cbm === other.cbm && this.weightKG === other.weightKG;
|
return this.cbm === other.cbm && this.weightKG === other.weightKG;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return `${this.cbm} CBM / ${this.weightKG} KG`;
|
return `${this.cbm} CBM / ${this.weightKG} KG`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Cache Module
|
* Cache Module
|
||||||
*
|
*
|
||||||
* Provides Redis cache adapter as CachePort implementation
|
* Provides Redis cache adapter as CachePort implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module, Global } from '@nestjs/common';
|
import { Module, Global } from '@nestjs/common';
|
||||||
import { RedisCacheAdapter } from './redis-cache.adapter';
|
import { RedisCacheAdapter } from './redis-cache.adapter';
|
||||||
import { CACHE_PORT } from '../../domain/ports/out/cache.port';
|
import { CACHE_PORT } from '../../domain/ports/out/cache.port';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: CACHE_PORT,
|
provide: CACHE_PORT,
|
||||||
useClass: RedisCacheAdapter,
|
useClass: RedisCacheAdapter,
|
||||||
},
|
},
|
||||||
RedisCacheAdapter,
|
RedisCacheAdapter,
|
||||||
],
|
],
|
||||||
exports: [CACHE_PORT, RedisCacheAdapter],
|
exports: [CACHE_PORT, RedisCacheAdapter],
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
|||||||
@ -1,181 +1,183 @@
|
|||||||
/**
|
/**
|
||||||
* Redis Cache Adapter
|
* Redis Cache Adapter
|
||||||
*
|
*
|
||||||
* Implements CachePort interface using Redis (ioredis)
|
* Implements CachePort interface using Redis (ioredis)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { CachePort } from '../../domain/ports/out/cache.port';
|
import { CachePort } from '../../domain/ports/out/cache.port';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestroy {
|
export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(RedisCacheAdapter.name);
|
private readonly logger = new Logger(RedisCacheAdapter.name);
|
||||||
private client: Redis;
|
private client: Redis;
|
||||||
private stats = {
|
private stats = {
|
||||||
hits: 0,
|
hits: 0,
|
||||||
misses: 0,
|
misses: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const host = this.configService.get<string>('REDIS_HOST', 'localhost');
|
const host = this.configService.get<string>('REDIS_HOST', 'localhost');
|
||||||
const port = this.configService.get<number>('REDIS_PORT', 6379);
|
const port = this.configService.get<number>('REDIS_PORT', 6379);
|
||||||
const password = this.configService.get<string>('REDIS_PASSWORD');
|
const password = this.configService.get<string>('REDIS_PASSWORD');
|
||||||
const db = this.configService.get<number>('REDIS_DB', 0);
|
const db = this.configService.get<number>('REDIS_DB', 0);
|
||||||
|
|
||||||
this.client = new Redis({
|
this.client = new Redis({
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
password,
|
password,
|
||||||
db,
|
db,
|
||||||
retryStrategy: (times) => {
|
retryStrategy: times => {
|
||||||
const delay = Math.min(times * 50, 2000);
|
const delay = Math.min(times * 50, 2000);
|
||||||
return delay;
|
return delay;
|
||||||
},
|
},
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
this.client.on('connect', () => {
|
||||||
this.logger.log(`Connected to Redis at ${host}:${port}`);
|
this.logger.log(`Connected to Redis at ${host}:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
this.client.on('error', err => {
|
||||||
this.logger.error(`Redis connection error: ${err.message}`);
|
this.logger.error(`Redis connection error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('ready', () => {
|
this.client.on('ready', () => {
|
||||||
this.logger.log('Redis client ready');
|
this.logger.log('Redis client ready');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
await this.client.quit();
|
await this.client.quit();
|
||||||
this.logger.log('Redis connection closed');
|
this.logger.log('Redis connection closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
async get<T>(key: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const value = await this.client.get(key);
|
const value = await this.client.get(key);
|
||||||
|
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
this.stats.misses++;
|
this.stats.misses++;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stats.hits++;
|
this.stats.hits++;
|
||||||
return JSON.parse(value) as T;
|
return JSON.parse(value) as T;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error getting key ${key}: ${error?.message || 'Unknown error'}`);
|
this.logger.error(`Error getting key ${key}: ${error?.message || 'Unknown error'}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const serialized = JSON.stringify(value);
|
const serialized = JSON.stringify(value);
|
||||||
if (ttlSeconds) {
|
if (ttlSeconds) {
|
||||||
await this.client.setex(key, ttlSeconds, serialized);
|
await this.client.setex(key, ttlSeconds, serialized);
|
||||||
} else {
|
} else {
|
||||||
await this.client.set(key, serialized);
|
await this.client.set(key, serialized);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error setting key ${key}: ${error?.message || 'Unknown error'}`);
|
this.logger.error(`Error setting key ${key}: ${error?.message || 'Unknown error'}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.client.del(key);
|
await this.client.del(key);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error deleting key ${key}: ${error?.message || 'Unknown error'}`);
|
this.logger.error(`Error deleting key ${key}: ${error?.message || 'Unknown error'}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMany(keys: string[]): Promise<void> {
|
async deleteMany(keys: string[]): Promise<void> {
|
||||||
if (keys.length === 0) return;
|
if (keys.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.client.del(...keys);
|
await this.client.del(...keys);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error deleting keys: ${error?.message || 'Unknown error'}`);
|
this.logger.error(`Error deleting keys: ${error?.message || 'Unknown error'}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(key: string): Promise<boolean> {
|
async exists(key: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await this.client.exists(key);
|
const result = await this.client.exists(key);
|
||||||
return result === 1;
|
return result === 1;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`);
|
this.logger.error(
|
||||||
return false;
|
`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`
|
||||||
}
|
);
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
async ttl(key: string): Promise<number> {
|
}
|
||||||
try {
|
|
||||||
return await this.client.ttl(key);
|
async ttl(key: string): Promise<number> {
|
||||||
} catch (error: any) {
|
try {
|
||||||
this.logger.error(`Error getting TTL for key ${key}: ${error?.message || 'Unknown error'}`);
|
return await this.client.ttl(key);
|
||||||
return -2;
|
} catch (error: any) {
|
||||||
}
|
this.logger.error(`Error getting TTL for key ${key}: ${error?.message || 'Unknown error'}`);
|
||||||
}
|
return -2;
|
||||||
|
}
|
||||||
async clear(): Promise<void> {
|
}
|
||||||
try {
|
|
||||||
await this.client.flushdb();
|
async clear(): Promise<void> {
|
||||||
this.logger.warn('Redis database cleared');
|
try {
|
||||||
} catch (error: any) {
|
await this.client.flushdb();
|
||||||
this.logger.error(`Error clearing cache: ${error?.message || 'Unknown error'}`);
|
this.logger.warn('Redis database cleared');
|
||||||
throw error;
|
} catch (error: any) {
|
||||||
}
|
this.logger.error(`Error clearing cache: ${error?.message || 'Unknown error'}`);
|
||||||
}
|
throw error;
|
||||||
|
}
|
||||||
async getStats(): Promise<{
|
}
|
||||||
hits: number;
|
|
||||||
misses: number;
|
async getStats(): Promise<{
|
||||||
hitRate: number;
|
hits: number;
|
||||||
keyCount: number;
|
misses: number;
|
||||||
}> {
|
hitRate: number;
|
||||||
try {
|
keyCount: number;
|
||||||
const keyCount = await this.client.dbsize();
|
}> {
|
||||||
const total = this.stats.hits + this.stats.misses;
|
try {
|
||||||
const hitRate = total > 0 ? this.stats.hits / total : 0;
|
const keyCount = await this.client.dbsize();
|
||||||
|
const total = this.stats.hits + this.stats.misses;
|
||||||
return {
|
const hitRate = total > 0 ? this.stats.hits / total : 0;
|
||||||
hits: this.stats.hits,
|
|
||||||
misses: this.stats.misses,
|
return {
|
||||||
hitRate: Math.round(hitRate * 10000) / 100, // Percentage with 2 decimals
|
hits: this.stats.hits,
|
||||||
keyCount,
|
misses: this.stats.misses,
|
||||||
};
|
hitRate: Math.round(hitRate * 10000) / 100, // Percentage with 2 decimals
|
||||||
} catch (error: any) {
|
keyCount,
|
||||||
this.logger.error(`Error getting stats: ${error?.message || 'Unknown error'}`);
|
};
|
||||||
return {
|
} catch (error: any) {
|
||||||
hits: this.stats.hits,
|
this.logger.error(`Error getting stats: ${error?.message || 'Unknown error'}`);
|
||||||
misses: this.stats.misses,
|
return {
|
||||||
hitRate: 0,
|
hits: this.stats.hits,
|
||||||
keyCount: 0,
|
misses: this.stats.misses,
|
||||||
};
|
hitRate: 0,
|
||||||
}
|
keyCount: 0,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
/**
|
}
|
||||||
* Reset statistics (useful for testing)
|
|
||||||
*/
|
/**
|
||||||
resetStats(): void {
|
* Reset statistics (useful for testing)
|
||||||
this.stats.hits = 0;
|
*/
|
||||||
this.stats.misses = 0;
|
resetStats(): void {
|
||||||
}
|
this.stats.hits = 0;
|
||||||
|
this.stats.misses = 0;
|
||||||
/**
|
}
|
||||||
* Get Redis client (for advanced usage)
|
|
||||||
*/
|
/**
|
||||||
getClient(): Redis {
|
* Get Redis client (for advanced usage)
|
||||||
return this.client;
|
*/
|
||||||
}
|
getClient(): Redis {
|
||||||
}
|
return this.client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,75 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
* Carrier Module
|
* Carrier Module
|
||||||
*
|
*
|
||||||
* Provides all carrier connector implementations
|
* Provides all carrier connector implementations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MaerskConnector } from './maersk/maersk.connector';
|
import { MaerskConnector } from './maersk/maersk.connector';
|
||||||
import { MSCConnectorAdapter } from './msc/msc.connector';
|
import { MSCConnectorAdapter } from './msc/msc.connector';
|
||||||
import { MSCRequestMapper } from './msc/msc.mapper';
|
import { MSCRequestMapper } from './msc/msc.mapper';
|
||||||
import { CMACGMConnectorAdapter } from './cma-cgm/cma-cgm.connector';
|
import { CMACGMConnectorAdapter } from './cma-cgm/cma-cgm.connector';
|
||||||
import { CMACGMRequestMapper } from './cma-cgm/cma-cgm.mapper';
|
import { CMACGMRequestMapper } from './cma-cgm/cma-cgm.mapper';
|
||||||
import { HapagLloydConnectorAdapter } from './hapag-lloyd/hapag-lloyd.connector';
|
import { HapagLloydConnectorAdapter } from './hapag-lloyd/hapag-lloyd.connector';
|
||||||
import { HapagLloydRequestMapper } from './hapag-lloyd/hapag-lloyd.mapper';
|
import { HapagLloydRequestMapper } from './hapag-lloyd/hapag-lloyd.mapper';
|
||||||
import { ONEConnectorAdapter } from './one/one.connector';
|
import { ONEConnectorAdapter } from './one/one.connector';
|
||||||
import { ONERequestMapper } from './one/one.mapper';
|
import { ONERequestMapper } from './one/one.mapper';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
// Maersk
|
// Maersk
|
||||||
MaerskConnector,
|
MaerskConnector,
|
||||||
|
|
||||||
// MSC
|
// MSC
|
||||||
MSCRequestMapper,
|
MSCRequestMapper,
|
||||||
MSCConnectorAdapter,
|
MSCConnectorAdapter,
|
||||||
|
|
||||||
// CMA CGM
|
// CMA CGM
|
||||||
CMACGMRequestMapper,
|
CMACGMRequestMapper,
|
||||||
CMACGMConnectorAdapter,
|
CMACGMConnectorAdapter,
|
||||||
|
|
||||||
// Hapag-Lloyd
|
// Hapag-Lloyd
|
||||||
HapagLloydRequestMapper,
|
HapagLloydRequestMapper,
|
||||||
HapagLloydConnectorAdapter,
|
HapagLloydConnectorAdapter,
|
||||||
|
|
||||||
// ONE
|
// ONE
|
||||||
ONERequestMapper,
|
ONERequestMapper,
|
||||||
ONEConnectorAdapter,
|
ONEConnectorAdapter,
|
||||||
|
|
||||||
// Factory that provides all connectors
|
// Factory that provides all connectors
|
||||||
{
|
{
|
||||||
provide: 'CarrierConnectors',
|
provide: 'CarrierConnectors',
|
||||||
useFactory: (
|
useFactory: (
|
||||||
maerskConnector: MaerskConnector,
|
maerskConnector: MaerskConnector,
|
||||||
mscConnector: MSCConnectorAdapter,
|
mscConnector: MSCConnectorAdapter,
|
||||||
cmacgmConnector: CMACGMConnectorAdapter,
|
cmacgmConnector: CMACGMConnectorAdapter,
|
||||||
hapagConnector: HapagLloydConnectorAdapter,
|
hapagConnector: HapagLloydConnectorAdapter,
|
||||||
oneConnector: ONEConnectorAdapter,
|
oneConnector: ONEConnectorAdapter
|
||||||
) => {
|
) => {
|
||||||
return [
|
return [maerskConnector, mscConnector, cmacgmConnector, hapagConnector, oneConnector];
|
||||||
maerskConnector,
|
},
|
||||||
mscConnector,
|
inject: [
|
||||||
cmacgmConnector,
|
MaerskConnector,
|
||||||
hapagConnector,
|
MSCConnectorAdapter,
|
||||||
oneConnector,
|
CMACGMConnectorAdapter,
|
||||||
];
|
HapagLloydConnectorAdapter,
|
||||||
},
|
ONEConnectorAdapter,
|
||||||
inject: [
|
],
|
||||||
MaerskConnector,
|
},
|
||||||
MSCConnectorAdapter,
|
],
|
||||||
CMACGMConnectorAdapter,
|
exports: [
|
||||||
HapagLloydConnectorAdapter,
|
'CarrierConnectors',
|
||||||
ONEConnectorAdapter,
|
MaerskConnector,
|
||||||
],
|
MSCConnectorAdapter,
|
||||||
},
|
CMACGMConnectorAdapter,
|
||||||
],
|
HapagLloydConnectorAdapter,
|
||||||
exports: [
|
ONEConnectorAdapter,
|
||||||
'CarrierConnectors',
|
],
|
||||||
MaerskConnector,
|
})
|
||||||
MSCConnectorAdapter,
|
export class CarrierModule {}
|
||||||
CMACGMConnectorAdapter,
|
|
||||||
HapagLloydConnectorAdapter,
|
|
||||||
ONEConnectorAdapter,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class CarrierModule {}
|
|
||||||
|
|||||||
@ -9,24 +9,21 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import {
|
import {
|
||||||
CarrierConnectorPort,
|
CarrierConnectorPort,
|
||||||
CarrierRateSearchInput,
|
CarrierRateSearchInput,
|
||||||
CarrierAvailabilityInput
|
CarrierAvailabilityInput,
|
||||||
} from '../../../domain/ports/out/carrier-connector.port';
|
} from '../../../domain/ports/out/carrier-connector.port';
|
||||||
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
||||||
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
||||||
import { CMACGMRequestMapper } from './cma-cgm.mapper';
|
import { CMACGMRequestMapper } from './cma-cgm.mapper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CMACGMConnectorAdapter
|
export class CMACGMConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort {
|
||||||
extends BaseCarrierConnector
|
|
||||||
implements CarrierConnectorPort
|
|
||||||
{
|
|
||||||
private readonly apiUrl: string;
|
private readonly apiUrl: string;
|
||||||
private readonly clientId: string;
|
private readonly clientId: string;
|
||||||
private readonly clientSecret: string;
|
private readonly clientSecret: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly requestMapper: CMACGMRequestMapper,
|
private readonly requestMapper: CMACGMRequestMapper
|
||||||
) {
|
) {
|
||||||
const config: CarrierConfig = {
|
const config: CarrierConfig = {
|
||||||
name: 'CMA CGM',
|
name: 'CMA CGM',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user