From d809feecef340d24f8015eca7652c0ad8d66f6f2 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 27 Oct 2025 20:54:01 +0100 Subject: [PATCH] format prettier --- apps/backend/src/app.module.ts | 244 ++- .../src/application/auth/auth.service.ts | 18 +- .../src/application/auth/jwt.strategy.ts | 2 +- .../controllers/admin/csv-rates.controller.ts | 689 ++++----- .../controllers/audit.controller.ts | 36 +- .../controllers/auth.controller.ts | 431 +++--- .../controllers/bookings.controller.ts | 1365 ++++++++--------- .../controllers/gdpr.controller.ts | 24 +- .../src/application/controllers/index.ts | 4 +- .../controllers/notifications.controller.ts | 28 +- .../controllers/organizations.controller.ts | 724 +++++---- .../controllers/rates.controller.ts | 529 ++++--- .../controllers/webhooks.controller.ts | 29 +- .../decorators/current-user.decorator.ts | 84 +- .../src/application/decorators/index.ts | 6 +- .../decorators/public.decorator.ts | 32 +- .../application/decorators/roles.decorator.ts | 46 +- .../src/application/dto/auth-login.dto.ts | 212 +-- .../application/dto/booking-response.dto.ts | 368 ++--- .../dto/create-booking-request.dto.ts | 254 +-- .../application/dto/csv-rate-search.dto.ts | 415 +++-- .../application/dto/csv-rate-upload.dto.ts | 402 ++--- apps/backend/src/application/dto/index.ts | 18 +- .../src/application/dto/organization.dto.ts | 602 ++++---- .../dto/rate-search-filters.dto.ts | 310 ++-- .../dto/rate-search-request.dto.ts | 207 +-- .../dto/rate-search-response.dto.ts | 296 ++-- apps/backend/src/application/dto/user.dto.ts | 473 +++--- .../gateways/notifications.gateway.ts | 8 +- apps/backend/src/application/guards/index.ts | 4 +- .../src/application/guards/jwt-auth.guard.ts | 90 +- .../src/application/guards/roles.guard.ts | 92 +- .../src/application/guards/throttle.guard.ts | 8 +- .../performance-monitoring.interceptor.ts | 24 +- .../src/application/mappers/booking.mapper.ts | 336 ++-- .../application/mappers/csv-rate.mapper.ts | 221 ++- apps/backend/src/application/mappers/index.ts | 4 +- .../mappers/organization.mapper.ts | 164 +- .../application/mappers/rate-quote.mapper.ts | 138 +- .../src/application/rates/rates.module.ts | 27 +- .../application/services/analytics.service.ts | 61 +- .../services/audit.service.spec.ts | 5 +- .../src/application/services/audit.service.ts | 28 +- .../services/booking-automation.service.ts | 27 +- .../brute-force-protection.service.ts | 20 +- .../application/services/export.service.ts | 54 +- .../services/file-validation.service.ts | 16 +- .../services/fuzzy-search.service.ts | 26 +- .../src/application/services/gdpr.service.ts | 13 +- .../services/notification.service.spec.ts | 11 +- .../services/notification.service.ts | 14 +- .../services/webhook.service.spec.ts | 16 +- .../application/services/webhook.service.ts | 54 +- .../src/domain/entities/booking.entity.ts | 596 ++++--- .../src/domain/entities/carrier.entity.ts | 366 ++--- .../src/domain/entities/container.entity.ts | 597 +++---- .../src/domain/entities/csv-rate.entity.ts | 484 +++--- apps/backend/src/domain/entities/index.ts | 26 +- .../domain/entities/notification.entity.ts | 2 +- .../domain/entities/organization.entity.ts | 402 ++--- .../src/domain/entities/port.entity.ts | 414 ++--- .../domain/entities/rate-quote.entity.spec.ts | 480 +++--- .../src/domain/entities/rate-quote.entity.ts | 554 +++---- .../src/domain/entities/user.entity.ts | 503 +++--- .../src/domain/entities/webhook.entity.ts | 5 +- .../exceptions/carrier-timeout.exception.ts | 32 +- .../carrier-unavailable.exception.ts | 32 +- apps/backend/src/domain/exceptions/index.ts | 24 +- .../invalid-booking-number.exception.ts | 12 +- .../invalid-booking-status.exception.ts | 16 +- .../exceptions/invalid-port-code.exception.ts | 26 +- .../invalid-rate-quote.exception.ts | 26 +- .../exceptions/port-not-found.exception.ts | 26 +- .../rate-quote-expired.exception.ts | 32 +- .../src/domain/ports/in/get-ports.port.ts | 90 +- apps/backend/src/domain/ports/in/index.ts | 18 +- .../domain/ports/in/search-csv-rates.port.ts | 218 +-- .../src/domain/ports/in/search-rates.port.ts | 88 +- .../ports/in/validate-availability.port.ts | 54 +- .../availability-validation.service.ts | 96 +- .../services/csv-rate-search.service.ts | 534 +++---- apps/backend/src/domain/services/index.ts | 20 +- .../domain/services/port-search.service.ts | 135 +- .../domain/services/rate-search.service.ts | 330 ++-- .../domain/value-objects/booking-number.vo.ts | 154 +- .../domain/value-objects/booking-status.vo.ts | 218 ++- .../domain/value-objects/container-type.vo.ts | 224 +-- .../src/domain/value-objects/date-range.vo.ts | 238 ++- .../src/domain/value-objects/email.vo.spec.ts | 140 +- .../src/domain/value-objects/email.vo.ts | 120 +- .../backend/src/domain/value-objects/index.ts | 26 +- .../src/domain/value-objects/money.vo.spec.ts | 266 ++-- .../src/domain/value-objects/money.vo.ts | 274 ++-- .../src/domain/value-objects/port-code.vo.ts | 132 +- .../src/domain/value-objects/surcharge.vo.ts | 212 ++- .../src/domain/value-objects/volume.vo.ts | 108 +- .../src/infrastructure/cache/cache.module.ts | 44 +- .../cache/redis-cache.adapter.ts | 364 ++--- .../infrastructure/carriers/carrier.module.ts | 144 +- .../carriers/cma-cgm/cma-cgm.connector.ts | 9 +- .../carriers/cma-cgm/cma-cgm.mapper.ts | 42 +- .../csv-loader/csv-rate-loader.adapter.ts | 659 ++++---- .../carriers/csv-loader/csv-rate.module.ts | 116 +- .../hapag-lloyd/hapag-lloyd.connector.ts | 8 +- .../hapag-lloyd/hapag-lloyd.mapper.ts | 7 +- .../carriers/maersk/maersk-request.mapper.ts | 108 +- .../carriers/maersk/maersk-response.mapper.ts | 220 ++- .../carriers/maersk/maersk.connector.ts | 220 +-- .../carriers/maersk/maersk.types.ts | 220 +-- .../carriers/msc/msc.connector.ts | 9 +- .../infrastructure/carriers/msc/msc.mapper.ts | 2 +- .../carriers/one/one.connector.ts | 9 +- .../infrastructure/carriers/one/one.mapper.ts | 5 +- .../src/infrastructure/email/email.adapter.ts | 14 +- .../email/templates/email-templates.ts | 5 +- .../monitoring/sentry.config.ts | 16 +- .../src/infrastructure/pdf/pdf.adapter.ts | 63 +- .../persistence/typeorm/data-source.ts | 54 +- .../typeorm/entities/booking.orm-entity.ts | 2 +- .../typeorm/entities/carrier.orm-entity.ts | 94 +- .../typeorm/entities/container.orm-entity.ts | 11 +- .../entities/csv-rate-config.orm-entity.ts | 140 +- .../persistence/typeorm/entities/index.ts | 24 +- .../entities/notification.orm-entity.ts | 8 +- .../entities/organization.orm-entity.ts | 110 +- .../typeorm/entities/port.orm-entity.ts | 104 +- .../typeorm/entities/rate-quote.orm-entity.ts | 224 +-- .../typeorm/entities/user.orm-entity.ts | 140 +- .../typeorm/entities/webhook.orm-entity.ts | 9 +- .../typeorm/mappers/booking-orm.mapper.ts | 15 +- .../typeorm/mappers/carrier-orm.mapper.ts | 120 +- .../persistence/typeorm/mappers/index.ts | 22 +- .../mappers/organization-orm.mapper.ts | 136 +- .../typeorm/mappers/port-orm.mapper.ts | 128 +- .../typeorm/mappers/rate-quote-orm.mapper.ts | 196 +-- .../typeorm/mappers/user-orm.mapper.ts | 132 +- .../1700000001000-CreateAuditLogsTable.ts | 12 +- .../1700000002000-CreateNotificationsTable.ts | 8 +- .../1700000003000-CreateWebhooksTable.ts | 4 +- ...000001-CreateExtensionsAndOrganizations.ts | 130 +- .../1730000000003-CreateCarriers.ts | 118 +- .../migrations/1730000000004-CreatePorts.ts | 138 +- .../1730000000005-CreateRateQuotes.ts | 182 +-- ...0000000006-SeedCarriersAndOrganizations.ts | 54 +- .../migrations/1730000000007-SeedTestUsers.ts | 4 +- .../1730000000011-CreateCsvRateConfigs.ts | 328 ++-- .../persistence/typeorm/repositories/index.ts | 22 +- .../typeorm-audit-log.repository.ts | 10 +- .../typeorm-carrier.repository.ts | 170 +- .../typeorm-csv-rate-config.repository.ts | 374 ++--- .../typeorm-notification.repository.ts | 10 +- .../repositories/typeorm-port.repository.ts | 231 ++- .../typeorm-rate-quote.repository.ts | 168 +- .../repositories/typeorm-user.repository.ts | 168 +- .../typeorm-webhook.repository.ts | 13 +- .../typeorm/seeds/carriers.seed.ts | 186 +-- .../typeorm/seeds/test-organizations.seed.ts | 172 +-- .../security/security.config.ts | 7 +- .../security/security.module.ts | 6 +- .../storage/s3-storage.adapter.ts | 27 +- apps/backend/src/main.ts | 9 +- apps/backend/test/app.e2e-spec.ts | 2 +- .../integration/booking.repository.spec.ts | 780 +++++----- .../test/integration/maersk.connector.spec.ts | 834 +++++----- .../integration/redis-cache.adapter.spec.ts | 536 +++---- apps/backend/test/setup-integration.ts | 70 +- 166 files changed, 13053 insertions(+), 13332 deletions(-) diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index fe44e35..ae7a57c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,123 +1,121 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { LoggerModule } from 'nestjs-pino'; -import { APP_GUARD } from '@nestjs/core'; -import * as Joi from 'joi'; - -// Import feature modules -import { AuthModule } from './application/auth/auth.module'; -import { RatesModule } from './application/rates/rates.module'; -import { BookingsModule } from './application/bookings/bookings.module'; -import { OrganizationsModule } from './application/organizations/organizations.module'; -import { UsersModule } from './application/users/users.module'; -import { DashboardModule } from './application/dashboard/dashboard.module'; -import { AuditModule } from './application/audit/audit.module'; -import { NotificationsModule } from './application/notifications/notifications.module'; -import { WebhooksModule } from './application/webhooks/webhooks.module'; -import { GDPRModule } from './application/gdpr/gdpr.module'; -import { CacheModule } from './infrastructure/cache/cache.module'; -import { CarrierModule } from './infrastructure/carriers/carrier.module'; -import { SecurityModule } from './infrastructure/security/security.module'; -import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; - -// Import global guards -import { JwtAuthGuard } from './application/guards/jwt-auth.guard'; -import { CustomThrottlerGuard } from './application/guards/throttle.guard'; - -@Module({ - imports: [ - // Configuration - ConfigModule.forRoot({ - isGlobal: true, - validationSchema: Joi.object({ - NODE_ENV: Joi.string() - .valid('development', 'production', 'test') - .default('development'), - PORT: Joi.number().default(4000), - DATABASE_HOST: Joi.string().required(), - DATABASE_PORT: Joi.number().default(5432), - DATABASE_USER: Joi.string().required(), - DATABASE_PASSWORD: Joi.string().required(), - DATABASE_NAME: Joi.string().required(), - REDIS_HOST: Joi.string().required(), - REDIS_PORT: Joi.number().default(6379), - REDIS_PASSWORD: Joi.string().required(), - JWT_SECRET: Joi.string().required(), - JWT_ACCESS_EXPIRATION: Joi.string().default('15m'), - JWT_REFRESH_EXPIRATION: Joi.string().default('7d'), - }), - }), - - // Logging - LoggerModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - pinoHttp: { - transport: - configService.get('NODE_ENV') === 'development' - ? { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'SYS:standard', - ignore: 'pid,hostname', - }, - } - : undefined, - level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug', - }, - }), - inject: [ConfigService], - }), - - // Database - TypeOrmModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - type: 'postgres', - host: configService.get('DATABASE_HOST'), - port: configService.get('DATABASE_PORT'), - username: configService.get('DATABASE_USER'), - password: configService.get('DATABASE_PASSWORD'), - database: configService.get('DATABASE_NAME'), - entities: [__dirname + '/**/*.orm-entity{.ts,.js}'], - synchronize: configService.get('DATABASE_SYNC', false), - logging: configService.get('DATABASE_LOGGING', false), - autoLoadEntities: true, // Auto-load entities from forFeature() - }), - inject: [ConfigService], - }), - - // Infrastructure modules - SecurityModule, - CacheModule, - CarrierModule, - CsvRateModule, - - // Feature modules - AuthModule, - RatesModule, - BookingsModule, - OrganizationsModule, - UsersModule, - DashboardModule, - AuditModule, - NotificationsModule, - WebhooksModule, - GDPRModule, - ], - controllers: [], - providers: [ - // Global JWT authentication guard - // All routes are protected by default, use @Public() to bypass - { - provide: APP_GUARD, - useClass: JwtAuthGuard, - }, - // Global rate limiting guard - { - provide: APP_GUARD, - useClass: CustomThrottlerGuard, - }, - ], -}) -export class AppModule {} +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LoggerModule } from 'nestjs-pino'; +import { APP_GUARD } from '@nestjs/core'; +import * as Joi from 'joi'; + +// Import feature modules +import { AuthModule } from './application/auth/auth.module'; +import { RatesModule } from './application/rates/rates.module'; +import { BookingsModule } from './application/bookings/bookings.module'; +import { OrganizationsModule } from './application/organizations/organizations.module'; +import { UsersModule } from './application/users/users.module'; +import { DashboardModule } from './application/dashboard/dashboard.module'; +import { AuditModule } from './application/audit/audit.module'; +import { NotificationsModule } from './application/notifications/notifications.module'; +import { WebhooksModule } from './application/webhooks/webhooks.module'; +import { GDPRModule } from './application/gdpr/gdpr.module'; +import { CacheModule } from './infrastructure/cache/cache.module'; +import { CarrierModule } from './infrastructure/carriers/carrier.module'; +import { SecurityModule } from './infrastructure/security/security.module'; +import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; + +// Import global guards +import { JwtAuthGuard } from './application/guards/jwt-auth.guard'; +import { CustomThrottlerGuard } from './application/guards/throttle.guard'; + +@Module({ + imports: [ + // Configuration + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: Joi.object({ + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), + PORT: Joi.number().default(4000), + DATABASE_HOST: Joi.string().required(), + DATABASE_PORT: Joi.number().default(5432), + DATABASE_USER: Joi.string().required(), + DATABASE_PASSWORD: Joi.string().required(), + DATABASE_NAME: Joi.string().required(), + REDIS_HOST: Joi.string().required(), + REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().required(), + JWT_SECRET: Joi.string().required(), + JWT_ACCESS_EXPIRATION: Joi.string().default('15m'), + JWT_REFRESH_EXPIRATION: Joi.string().default('7d'), + }), + }), + + // Logging + LoggerModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + pinoHttp: { + transport: + configService.get('NODE_ENV') === 'development' + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } + : undefined, + level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug', + }, + }), + inject: [ConfigService], + }), + + // Database + TypeOrmModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + type: 'postgres', + host: configService.get('DATABASE_HOST'), + port: configService.get('DATABASE_PORT'), + username: configService.get('DATABASE_USER'), + password: configService.get('DATABASE_PASSWORD'), + database: configService.get('DATABASE_NAME'), + entities: [__dirname + '/**/*.orm-entity{.ts,.js}'], + synchronize: configService.get('DATABASE_SYNC', false), + logging: configService.get('DATABASE_LOGGING', false), + autoLoadEntities: true, // Auto-load entities from forFeature() + }), + inject: [ConfigService], + }), + + // Infrastructure modules + SecurityModule, + CacheModule, + CarrierModule, + CsvRateModule, + + // Feature modules + AuthModule, + RatesModule, + BookingsModule, + OrganizationsModule, + UsersModule, + DashboardModule, + AuditModule, + NotificationsModule, + WebhooksModule, + GDPRModule, + ], + controllers: [], + providers: [ + // Global JWT authentication guard + // All routes are protected by default, use @Public() to bypass + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + // Global rate limiting guard + { + provide: APP_GUARD, + useClass: CustomThrottlerGuard, + }, + ], +}) +export class AppModule {} diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 8722b9b..8c6249b 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -1,4 +1,10 @@ -import { Injectable, UnauthorizedException, ConflictException, Logger, Inject } from '@nestjs/common'; +import { + Injectable, + UnauthorizedException, + ConflictException, + Logger, + Inject, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; @@ -22,7 +28,7 @@ export class AuthService { @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, // ✅ Correct injection private readonly jwtService: JwtService, - private readonly configService: ConfigService, + private readonly configService: ConfigService ) {} /** @@ -33,7 +39,7 @@ export class AuthService { password: string, firstName: string, lastName: string, - organizationId?: string, + organizationId?: string ): Promise<{ accessToken: string; refreshToken: string; user: any }> { this.logger.log(`Registering new user: ${email}`); @@ -87,7 +93,7 @@ export class AuthService { */ async login( email: string, - password: string, + password: string ): Promise<{ accessToken: string; refreshToken: string; user: any }> { this.logger.log(`Login attempt for: ${email}`); @@ -127,7 +133,9 @@ export class AuthService { /** * Refresh access token using refresh token */ - async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { + async refreshAccessToken( + refreshToken: string + ): Promise<{ accessToken: string; refreshToken: string }> { try { const payload = await this.jwtService.verifyAsync(refreshToken, { secret: this.configService.get('JWT_SECRET'), diff --git a/apps/backend/src/application/auth/jwt.strategy.ts b/apps/backend/src/application/auth/jwt.strategy.ts index 2eaf2a2..437d1a8 100644 --- a/apps/backend/src/application/auth/jwt.strategy.ts +++ b/apps/backend/src/application/auth/jwt.strategy.ts @@ -32,7 +32,7 @@ export interface JwtPayload { export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private readonly configService: ConfigService, - private readonly authService: AuthService, + private readonly authService: AuthService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), diff --git a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts index a0113af..ba149bc 100644 --- a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts +++ b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts @@ -1,358 +1,331 @@ -import { - Controller, - Post, - Get, - Delete, - Param, - Body, - UseGuards, - UseInterceptors, - UploadedFile, - HttpCode, - HttpStatus, - Logger, - BadRequestException, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiConsumes, - ApiBody, -} from '@nestjs/swagger'; -import { diskStorage } from 'multer'; -import { extname } from 'path'; -import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; -import { RolesGuard } from '../../guards/roles.guard'; -import { Roles } from '../../decorators/roles.decorator'; -import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator'; -import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter'; -import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; -import { - CsvRateUploadDto, - CsvRateUploadResponseDto, - CsvRateConfigDto, - CsvFileValidationDto, -} from '../../dto/csv-rate-upload.dto'; -import { CsvRateMapper } from '../../mappers/csv-rate.mapper'; - -/** - * CSV Rates Admin Controller - * - * ADMIN-ONLY endpoints for managing CSV rate files - * Protected by JWT + Roles guard - */ -@ApiTags('Admin - CSV Rates') -@Controller('admin/csv-rates') -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, RolesGuard) -@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints -export class CsvRatesAdminController { - private readonly logger = new Logger(CsvRatesAdminController.name); - - constructor( - private readonly csvLoader: CsvRateLoaderAdapter, - private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository, - private readonly csvRateMapper: CsvRateMapper, - ) {} - - /** - * Upload CSV rate file (ADMIN only) - */ - @Post('upload') - @HttpCode(HttpStatus.CREATED) - @UseInterceptors( - FileInterceptor('file', { - storage: diskStorage({ - destination: './apps/backend/src/infrastructure/storage/csv-storage/rates', - filename: (req, file, cb) => { - // Generate filename: company-name.csv - const companyName = req.body.companyName || 'unknown'; - const sanitized = companyName - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, ''); - const filename = `${sanitized}.csv`; - cb(null, filename); - }, - }), - fileFilter: (req, file, cb) => { - // Only allow CSV files - if (extname(file.originalname).toLowerCase() !== '.csv') { - return cb(new BadRequestException('Only CSV files are allowed'), false); - } - cb(null, true); - }, - limits: { - fileSize: 10 * 1024 * 1024, // 10MB max - }, - }), - ) - @ApiConsumes('multipart/form-data') - @ApiOperation({ - summary: 'Upload CSV rate file (ADMIN only)', - description: - 'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.', - }) - @ApiBody({ - schema: { - type: 'object', - required: ['companyName', 'file'], - properties: { - companyName: { - type: 'string', - description: 'Carrier company name', - example: 'SSC Consolidation', - }, - file: { - type: 'string', - format: 'binary', - description: 'CSV file to upload', - }, - }, - }, - }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'CSV file uploaded and validated successfully', - type: CsvRateUploadResponseDto, - }) - @ApiResponse({ - status: 400, - description: 'Invalid file format or validation failed', - }) - @ApiResponse({ - status: 403, - description: 'Forbidden - Admin role required', - }) - async uploadCsv( - @UploadedFile() file: Express.Multer.File, - @Body() dto: CsvRateUploadDto, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`, - ); - - if (!file) { - throw new BadRequestException('File is required'); - } - - try { - // Validate CSV file structure - const validation = await this.csvLoader.validateCsvFile(file.filename); - - if (!validation.valid) { - this.logger.error( - `CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`, - ); - throw new BadRequestException({ - message: 'CSV validation failed', - errors: validation.errors, - }); - } - - // Load rates to verify parsing - const rates = await this.csvLoader.loadRatesFromCsv(file.filename); - const ratesCount = rates.length; - - this.logger.log( - `Successfully parsed ${ratesCount} rates from ${file.filename}`, - ); - - // Check if config exists for this company - const existingConfig = await this.csvConfigRepository.findByCompanyName( - dto.companyName, - ); - - if (existingConfig) { - // Update existing configuration - await this.csvConfigRepository.update(existingConfig.id, { - csvFilePath: file.filename, - uploadedAt: new Date(), - uploadedBy: user.id, - rowCount: ratesCount, - lastValidatedAt: new Date(), - metadata: { - ...existingConfig.metadata, - lastUpload: { - timestamp: new Date().toISOString(), - by: user.email, - ratesCount, - }, - }, - }); - - this.logger.log( - `Updated CSV config for company: ${dto.companyName}`, - ); - } else { - // Create new configuration - await this.csvConfigRepository.create({ - companyName: dto.companyName, - csvFilePath: file.filename, - type: 'CSV_ONLY', - hasApi: false, - apiConnector: null, - isActive: true, - uploadedAt: new Date(), - uploadedBy: user.id, - rowCount: ratesCount, - lastValidatedAt: new Date(), - metadata: { - uploadedBy: user.email, - description: `${dto.companyName} shipping rates`, - }, - }); - - this.logger.log( - `Created new CSV config for company: ${dto.companyName}`, - ); - } - - return { - success: true, - ratesCount, - csvFilePath: file.filename, - companyName: dto.companyName, - uploadedAt: new Date(), - }; - } catch (error: any) { - this.logger.error( - `CSV upload failed: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - throw error; - } - } - - /** - * Get all CSV rate configurations - */ - @Get('config') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Get all CSV rate configurations (ADMIN only)', - description: 'Returns list of all CSV rate configurations with upload details.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'List of CSV rate configurations', - type: [CsvRateConfigDto], - }) - async getAllConfigs(): Promise { - this.logger.log('Fetching all CSV rate configs (admin)'); - - const configs = await this.csvConfigRepository.findAll(); - return this.csvRateMapper.mapConfigEntitiesToDtos(configs); - } - - /** - * Get configuration for specific company - */ - @Get('config/:companyName') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Get CSV configuration for specific company (ADMIN only)', - description: 'Returns CSV rate configuration details for a specific carrier.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'CSV rate configuration', - type: CsvRateConfigDto, - }) - @ApiResponse({ - status: 404, - description: 'Company configuration not found', - }) - async getConfigByCompany( - @Param('companyName') companyName: string, - ): Promise { - this.logger.log(`Fetching CSV config for company: ${companyName}`); - - const config = await this.csvConfigRepository.findByCompanyName(companyName); - - if (!config) { - throw new BadRequestException( - `No CSV configuration found for company: ${companyName}`, - ); - } - - return this.csvRateMapper.mapConfigEntityToDto(config); - } - - /** - * Validate CSV file - */ - @Post('validate/:companyName') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Validate CSV file for company (ADMIN only)', - description: - 'Validates the CSV file structure and data for a specific company without uploading.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Validation result', - type: CsvFileValidationDto, - }) - async validateCsvFile( - @Param('companyName') companyName: string, - ): Promise { - this.logger.log(`Validating CSV file for company: ${companyName}`); - - const config = await this.csvConfigRepository.findByCompanyName(companyName); - - if (!config) { - throw new BadRequestException( - `No CSV configuration found for company: ${companyName}`, - ); - } - - const result = await this.csvLoader.validateCsvFile(config.csvFilePath); - - // Update validation timestamp - if (result.valid && result.rowCount) { - await this.csvConfigRepository.updateValidationInfo( - companyName, - result.rowCount, - result, - ); - } - - return result; - } - - /** - * Delete CSV rate configuration - */ - @Delete('config/:companyName') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ - summary: 'Delete CSV rate configuration (ADMIN only)', - description: - 'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.', - }) - @ApiResponse({ - status: HttpStatus.NO_CONTENT, - description: 'Configuration deleted successfully', - }) - @ApiResponse({ - status: 404, - description: 'Company configuration not found', - }) - async deleteConfig( - @Param('companyName') companyName: string, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.warn( - `[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`, - ); - - await this.csvConfigRepository.delete(companyName); - - this.logger.log(`Deleted CSV config for company: ${companyName}`); - } -} +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + HttpCode, + HttpStatus, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiConsumes, + ApiBody, +} from '@nestjs/swagger'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; +import { RolesGuard } from '../../guards/roles.guard'; +import { Roles } from '../../decorators/roles.decorator'; +import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator'; +import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter'; +import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; +import { + CsvRateUploadDto, + CsvRateUploadResponseDto, + CsvRateConfigDto, + CsvFileValidationDto, +} from '../../dto/csv-rate-upload.dto'; +import { CsvRateMapper } from '../../mappers/csv-rate.mapper'; + +/** + * CSV Rates Admin Controller + * + * ADMIN-ONLY endpoints for managing CSV rate files + * Protected by JWT + Roles guard + */ +@ApiTags('Admin - CSV Rates') +@Controller('admin/csv-rates') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints +export class CsvRatesAdminController { + private readonly logger = new Logger(CsvRatesAdminController.name); + + constructor( + private readonly csvLoader: CsvRateLoaderAdapter, + private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository, + private readonly csvRateMapper: CsvRateMapper + ) {} + + /** + * Upload CSV rate file (ADMIN only) + */ + @Post('upload') + @HttpCode(HttpStatus.CREATED) + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: './apps/backend/src/infrastructure/storage/csv-storage/rates', + filename: (req, file, cb) => { + // Generate filename: company-name.csv + const companyName = req.body.companyName || 'unknown'; + const sanitized = companyName + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + const filename = `${sanitized}.csv`; + cb(null, filename); + }, + }), + fileFilter: (req, file, cb) => { + // Only allow CSV files + if (extname(file.originalname).toLowerCase() !== '.csv') { + return cb(new BadRequestException('Only CSV files are allowed'), false); + } + cb(null, true); + }, + limits: { + fileSize: 10 * 1024 * 1024, // 10MB max + }, + }) + ) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Upload CSV rate file (ADMIN only)', + description: + 'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['companyName', 'file'], + properties: { + companyName: { + type: 'string', + description: 'Carrier company name', + example: 'SSC Consolidation', + }, + file: { + type: 'string', + format: 'binary', + description: 'CSV file to upload', + }, + }, + }, + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'CSV file uploaded and validated successfully', + type: CsvRateUploadResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid file format or validation failed', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - Admin role required', + }) + async uploadCsv( + @UploadedFile() file: Express.Multer.File, + @Body() dto: CsvRateUploadDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`); + + if (!file) { + throw new BadRequestException('File is required'); + } + + try { + // Validate CSV file structure + const validation = await this.csvLoader.validateCsvFile(file.filename); + + if (!validation.valid) { + this.logger.error( + `CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}` + ); + throw new BadRequestException({ + message: 'CSV validation failed', + errors: validation.errors, + }); + } + + // Load rates to verify parsing + const rates = await this.csvLoader.loadRatesFromCsv(file.filename); + const ratesCount = rates.length; + + this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`); + + // Check if config exists for this company + const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName); + + if (existingConfig) { + // Update existing configuration + await this.csvConfigRepository.update(existingConfig.id, { + csvFilePath: file.filename, + uploadedAt: new Date(), + uploadedBy: user.id, + rowCount: ratesCount, + lastValidatedAt: new Date(), + metadata: { + ...existingConfig.metadata, + lastUpload: { + timestamp: new Date().toISOString(), + by: user.email, + ratesCount, + }, + }, + }); + + this.logger.log(`Updated CSV config for company: ${dto.companyName}`); + } else { + // Create new configuration + await this.csvConfigRepository.create({ + companyName: dto.companyName, + csvFilePath: file.filename, + type: 'CSV_ONLY', + hasApi: false, + apiConnector: null, + isActive: true, + uploadedAt: new Date(), + uploadedBy: user.id, + rowCount: ratesCount, + lastValidatedAt: new Date(), + metadata: { + uploadedBy: user.email, + description: `${dto.companyName} shipping rates`, + }, + }); + + this.logger.log(`Created new CSV config for company: ${dto.companyName}`); + } + + return { + success: true, + ratesCount, + csvFilePath: file.filename, + companyName: dto.companyName, + uploadedAt: new Date(), + }; + } catch (error: any) { + this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack); + throw error; + } + } + + /** + * Get all CSV rate configurations + */ + @Get('config') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get all CSV rate configurations (ADMIN only)', + description: 'Returns list of all CSV rate configurations with upload details.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'List of CSV rate configurations', + type: [CsvRateConfigDto], + }) + async getAllConfigs(): Promise { + this.logger.log('Fetching all CSV rate configs (admin)'); + + const configs = await this.csvConfigRepository.findAll(); + return this.csvRateMapper.mapConfigEntitiesToDtos(configs); + } + + /** + * Get configuration for specific company + */ + @Get('config/:companyName') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get CSV configuration for specific company (ADMIN only)', + description: 'Returns CSV rate configuration details for a specific carrier.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'CSV rate configuration', + type: CsvRateConfigDto, + }) + @ApiResponse({ + status: 404, + description: 'Company configuration not found', + }) + async getConfigByCompany(@Param('companyName') companyName: string): Promise { + this.logger.log(`Fetching CSV config for company: ${companyName}`); + + const config = await this.csvConfigRepository.findByCompanyName(companyName); + + if (!config) { + throw new BadRequestException(`No CSV configuration found for company: ${companyName}`); + } + + return this.csvRateMapper.mapConfigEntityToDto(config); + } + + /** + * Validate CSV file + */ + @Post('validate/:companyName') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Validate CSV file for company (ADMIN only)', + description: + 'Validates the CSV file structure and data for a specific company without uploading.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Validation result', + type: CsvFileValidationDto, + }) + async validateCsvFile(@Param('companyName') companyName: string): Promise { + this.logger.log(`Validating CSV file for company: ${companyName}`); + + const config = await this.csvConfigRepository.findByCompanyName(companyName); + + if (!config) { + throw new BadRequestException(`No CSV configuration found for company: ${companyName}`); + } + + const result = await this.csvLoader.validateCsvFile(config.csvFilePath); + + // Update validation timestamp + if (result.valid && result.rowCount) { + await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result); + } + + return result; + } + + /** + * Delete CSV rate configuration + */ + @Delete('config/:companyName') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete CSV rate configuration (ADMIN only)', + description: + 'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Configuration deleted successfully', + }) + @ApiResponse({ + status: 404, + description: 'Company configuration not found', + }) + async deleteConfig( + @Param('companyName') companyName: string, + @CurrentUser() user: UserPayload + ): Promise { + 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}`); + } +} diff --git a/apps/backend/src/application/controllers/audit.controller.ts b/apps/backend/src/application/controllers/audit.controller.ts index 4ea6e87..3248d4c 100644 --- a/apps/backend/src/application/controllers/audit.controller.ts +++ b/apps/backend/src/application/controllers/audit.controller.ts @@ -66,8 +66,18 @@ export class AuditController { @ApiOperation({ summary: 'Get audit logs with filters' }) @ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' }) @ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' }) - @ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true }) - @ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true }) + @ApiQuery({ + name: 'action', + required: false, + description: 'Filter by action (comma-separated)', + isArray: true, + }) + @ApiQuery({ + name: 'status', + required: false, + description: 'Filter by status (comma-separated)', + isArray: true, + }) @ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' }) @ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' }) @ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' }) @@ -84,7 +94,7 @@ export class AuditController { @Query('startDate') startDate?: string, @Query('endDate') endDate?: string, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number, - @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number ): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> { page = page || 1; limit = limit || 50; @@ -104,7 +114,7 @@ export class AuditController { const { logs, total } = await this.auditService.getAuditLogs(filters); return { - logs: logs.map((log) => this.mapToDto(log)), + logs: logs.map(log => this.mapToDto(log)), total, page, pageSize: limit, @@ -121,7 +131,7 @@ export class AuditController { @ApiResponse({ status: 404, description: 'Audit log not found' }) async getAuditLogById( @Param('id') id: string, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { const log = await this.auditService.getAuditLogs({ organizationId: user.organizationId, @@ -145,14 +155,14 @@ export class AuditController { async getResourceAuditTrail( @Param('type') resourceType: string, @Param('id') resourceId: string, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId); // Filter by organization for security - const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId); + const filteredLogs = logs.filter(log => log.organizationId === user.organizationId); - return filteredLogs.map((log) => this.mapToDto(log)); + return filteredLogs.map(log => this.mapToDto(log)); } /** @@ -165,11 +175,11 @@ export class AuditController { @ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' }) async getOrganizationActivity( @CurrentUser() user: UserPayload, - @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number ): Promise { limit = limit || 50; const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit); - return logs.map((log) => this.mapToDto(log)); + return logs.map(log => this.mapToDto(log)); } /** @@ -183,15 +193,15 @@ export class AuditController { async getUserActivity( @CurrentUser() user: UserPayload, @Param('userId') userId: string, - @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number ): Promise { limit = limit || 50; const logs = await this.auditService.getUserActivity(userId, limit); // Filter by organization for security - const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId); + const filteredLogs = logs.filter(log => log.organizationId === user.organizationId); - return filteredLogs.map((log) => this.mapToDto(log)); + return filteredLogs.map(log => this.mapToDto(log)); } /** diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index ae29155..00492eb 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -1,227 +1,204 @@ -import { - Controller, - Post, - Body, - HttpCode, - HttpStatus, - UseGuards, - Get, -} from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, -} from '@nestjs/swagger'; -import { AuthService } from '../auth/auth.service'; -import { - LoginDto, - RegisterDto, - AuthResponseDto, - RefreshTokenDto, -} from '../dto/auth-login.dto'; -import { Public } from '../decorators/public.decorator'; -import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; - -/** - * Authentication Controller - * - * Handles user authentication endpoints: - * - POST /auth/register - User registration - * - POST /auth/login - User login - * - POST /auth/refresh - Token refresh - * - POST /auth/logout - User logout (placeholder) - * - GET /auth/me - Get current user profile - */ -@ApiTags('Authentication') -@Controller('auth') -export class AuthController { - constructor(private readonly authService: AuthService) {} - - /** - * Register a new user - * - * Creates a new user account and returns access + refresh tokens. - * - * @param dto - Registration data (email, password, firstName, lastName, organizationId) - * @returns Access token, refresh token, and user info - */ - @Public() - @Post('register') - @HttpCode(HttpStatus.CREATED) - @ApiOperation({ - summary: 'Register new user', - description: - 'Create a new user account with email and password. Returns JWT tokens.', - }) - @ApiResponse({ - status: 201, - description: 'User successfully registered', - type: AuthResponseDto, - }) - @ApiResponse({ - status: 409, - description: 'User with this email already exists', - }) - @ApiResponse({ - status: 400, - description: 'Validation error (invalid email, weak password, etc.)', - }) - async register(@Body() dto: RegisterDto): Promise { - const result = await this.authService.register( - dto.email, - dto.password, - dto.firstName, - dto.lastName, - dto.organizationId, - ); - - return { - accessToken: result.accessToken, - refreshToken: result.refreshToken, - user: result.user, - }; - } - - /** - * Login with email and password - * - * Authenticates a user and returns access + refresh tokens. - * - * @param dto - Login credentials (email, password) - * @returns Access token, refresh token, and user info - */ - @Public() - @Post('login') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'User login', - description: 'Authenticate with email and password. Returns JWT tokens.', - }) - @ApiResponse({ - status: 200, - description: 'Login successful', - type: AuthResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Invalid credentials or inactive account', - }) - async login(@Body() dto: LoginDto): Promise { - const result = await this.authService.login(dto.email, dto.password); - - return { - accessToken: result.accessToken, - refreshToken: result.refreshToken, - user: result.user, - }; - } - - /** - * Refresh access token - * - * Obtains a new access token using a valid refresh token. - * - * @param dto - Refresh token - * @returns New access token - */ - @Public() - @Post('refresh') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Refresh access token', - description: - 'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).', - }) - @ApiResponse({ - status: 200, - description: 'Token refreshed successfully', - schema: { - properties: { - accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' }, - }, - }, - }) - @ApiResponse({ - status: 401, - description: 'Invalid or expired refresh token', - }) - async refresh( - @Body() dto: RefreshTokenDto, - ): Promise<{ accessToken: string }> { - const result = - await this.authService.refreshAccessToken(dto.refreshToken); - - return { accessToken: result.accessToken }; - } - - /** - * Logout (placeholder) - * - * Currently a no-op endpoint. With JWT, logout is typically handled client-side - * by removing tokens. For more security, implement token blacklisting with Redis. - * - * @returns Success message - */ - @UseGuards(JwtAuthGuard) - @Post('logout') - @HttpCode(HttpStatus.OK) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Logout', - description: - 'Logout the current user. Currently handled client-side by removing tokens.', - }) - @ApiResponse({ - status: 200, - description: 'Logout successful', - schema: { - properties: { - message: { type: 'string', example: 'Logout successful' }, - }, - }, - }) - async logout(): Promise<{ message: string }> { - // TODO: Implement token blacklisting with Redis for more security - // For now, logout is handled client-side by removing tokens - return { message: 'Logout successful' }; - } - - /** - * Get current user profile - * - * Returns the profile of the currently authenticated user. - * - * @param user - Current user from JWT token - * @returns User profile - */ - @UseGuards(JwtAuthGuard) - @Get('me') - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get current user profile', - description: 'Returns the profile of the authenticated user.', - }) - @ApiResponse({ - status: 200, - description: 'User profile retrieved successfully', - schema: { - properties: { - id: { type: 'string', format: 'uuid' }, - email: { type: 'string', format: 'email' }, - firstName: { type: 'string' }, - lastName: { type: 'string' }, - role: { type: 'string', enum: ['admin', 'manager', 'user', 'viewer'] }, - organizationId: { type: 'string', format: 'uuid' }, - }, - }, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - invalid or missing token', - }) - async getProfile(@CurrentUser() user: UserPayload) { - return user; - } -} +import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { AuthService } from '../auth/auth.service'; +import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto'; +import { Public } from '../decorators/public.decorator'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; + +/** + * Authentication Controller + * + * Handles user authentication endpoints: + * - POST /auth/register - User registration + * - POST /auth/login - User login + * - POST /auth/refresh - Token refresh + * - POST /auth/logout - User logout (placeholder) + * - GET /auth/me - Get current user profile + */ +@ApiTags('Authentication') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + /** + * Register a new user + * + * Creates a new user account and returns access + refresh tokens. + * + * @param dto - Registration data (email, password, firstName, lastName, organizationId) + * @returns Access token, refresh token, and user info + */ + @Public() + @Post('register') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Register new user', + description: 'Create a new user account with email and password. Returns JWT tokens.', + }) + @ApiResponse({ + status: 201, + description: 'User successfully registered', + type: AuthResponseDto, + }) + @ApiResponse({ + status: 409, + description: 'User with this email already exists', + }) + @ApiResponse({ + status: 400, + description: 'Validation error (invalid email, weak password, etc.)', + }) + async register(@Body() dto: RegisterDto): Promise { + const result = await this.authService.register( + dto.email, + dto.password, + dto.firstName, + dto.lastName, + dto.organizationId + ); + + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + }; + } + + /** + * Login with email and password + * + * Authenticates a user and returns access + refresh tokens. + * + * @param dto - Login credentials (email, password) + * @returns Access token, refresh token, and user info + */ + @Public() + @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'User login', + description: 'Authenticate with email and password. Returns JWT tokens.', + }) + @ApiResponse({ + status: 200, + description: 'Login successful', + type: AuthResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Invalid credentials or inactive account', + }) + async login(@Body() dto: LoginDto): Promise { + const result = await this.authService.login(dto.email, dto.password); + + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + }; + } + + /** + * Refresh access token + * + * Obtains a new access token using a valid refresh token. + * + * @param dto - Refresh token + * @returns New access token + */ + @Public() + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Refresh access token', + description: + 'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).', + }) + @ApiResponse({ + status: 200, + description: 'Token refreshed successfully', + schema: { + properties: { + accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: 'Invalid or expired refresh token', + }) + async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> { + const result = await this.authService.refreshAccessToken(dto.refreshToken); + + return { accessToken: result.accessToken }; + } + + /** + * Logout (placeholder) + * + * Currently a no-op endpoint. With JWT, logout is typically handled client-side + * by removing tokens. For more security, implement token blacklisting with Redis. + * + * @returns Success message + */ + @UseGuards(JwtAuthGuard) + @Post('logout') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Logout', + description: 'Logout the current user. Currently handled client-side by removing tokens.', + }) + @ApiResponse({ + status: 200, + description: 'Logout successful', + schema: { + properties: { + message: { type: 'string', example: 'Logout successful' }, + }, + }, + }) + async logout(): Promise<{ message: string }> { + // TODO: Implement token blacklisting with Redis for more security + // For now, logout is handled client-side by removing tokens + return { message: 'Logout successful' }; + } + + /** + * Get current user profile + * + * Returns the profile of the currently authenticated user. + * + * @param user - Current user from JWT token + * @returns User profile + */ + @UseGuards(JwtAuthGuard) + @Get('me') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get current user profile', + description: 'Returns the profile of the authenticated user.', + }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + schema: { + properties: { + id: { type: 'string', format: 'uuid' }, + email: { type: 'string', format: 'email' }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + role: { type: 'string', enum: ['admin', 'manager', 'user', 'viewer'] }, + organizationId: { type: 'string', format: 'uuid' }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid or missing token', + }) + async getProfile(@CurrentUser() user: UserPayload) { + return user; + } +} diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index 1fc91ef..1cde9d7 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -1,693 +1,672 @@ -import { - Controller, - Get, - Post, - Param, - Body, - Query, - HttpCode, - HttpStatus, - Logger, - UsePipes, - ValidationPipe, - NotFoundException, - ParseUUIDPipe, - ParseIntPipe, - DefaultValuePipe, - UseGuards, - Res, - StreamableFile, - Inject, -} from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBadRequestResponse, - ApiNotFoundResponse, - ApiInternalServerErrorResponse, - ApiQuery, - ApiParam, - ApiBearerAuth, - ApiProduces, -} from '@nestjs/swagger'; -import { Response } from 'express'; -import { - CreateBookingRequestDto, - BookingResponseDto, - BookingListResponseDto, -} from '../dto'; -import { BookingFilterDto } from '../dto/booking-filter.dto'; -import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto'; -import { BookingMapper } from '../mappers'; -import { BookingService } from '../../domain/services/booking.service'; -import { BookingRepository, BOOKING_REPOSITORY } from '../../domain/ports/out/booking.repository'; -import { RateQuoteRepository, RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository'; -import { BookingNumber } from '../../domain/value-objects/booking-number.vo'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; -import { ExportService } from '../services/export.service'; -import { FuzzySearchService } from '../services/fuzzy-search.service'; -import { AuditService } from '../services/audit.service'; -import { AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity'; -import { NotificationService } from '../services/notification.service'; -import { NotificationsGateway } from '../gateways/notifications.gateway'; -import { WebhookService } from '../services/webhook.service'; -import { WebhookEvent } from '../../domain/entities/webhook.entity'; - -@ApiTags('Bookings') -@Controller('bookings') -@UseGuards(JwtAuthGuard) -@ApiBearerAuth() -export class BookingsController { - private readonly logger = new Logger(BookingsController.name); - - constructor( - private readonly bookingService: BookingService, - @Inject(BOOKING_REPOSITORY) private readonly bookingRepository: BookingRepository, - @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository, - private readonly exportService: ExportService, - private readonly fuzzySearchService: FuzzySearchService, - private readonly auditService: AuditService, - private readonly notificationService: NotificationService, - private readonly notificationsGateway: NotificationsGateway, - private readonly webhookService: WebhookService, - ) {} - - @Post() - @HttpCode(HttpStatus.CREATED) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - @ApiOperation({ - summary: 'Create a new booking', - description: - 'Create a new booking based on a rate quote. The booking will be in "draft" status initially. Requires authentication.', - }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'Booking created successfully', - type: BookingResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiBadRequestResponse({ - description: 'Invalid request parameters', - }) - @ApiNotFoundResponse({ - description: 'Rate quote not found', - }) - @ApiInternalServerErrorResponse({ - description: 'Internal server error', - }) - async createBooking( - @Body() dto: CreateBookingRequestDto, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`, - ); - - try { - // Convert DTO to domain input, using authenticated user's data - const input = { - ...BookingMapper.toCreateBookingInput(dto), - userId: user.id, - organizationId: user.organizationId, - }; - - // Create booking via domain service - const booking = await this.bookingService.createBooking(input); - - // Fetch rate quote for response - const rateQuote = await this.rateQuoteRepository.findById(dto.rateQuoteId); - if (!rateQuote) { - throw new NotFoundException(`Rate quote ${dto.rateQuoteId} not found`); - } - - // Convert to DTO - const response = BookingMapper.toDto(booking, rateQuote); - - this.logger.log( - `Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`, - ); - - // Audit log: Booking created - await this.auditService.logSuccess( - AuditAction.BOOKING_CREATED, - user.id, - user.email, - user.organizationId, - { - resourceType: 'booking', - resourceId: booking.id, - resourceName: booking.bookingNumber.value, - metadata: { - rateQuoteId: dto.rateQuoteId, - status: booking.status.value, - carrier: rateQuote.carrierName, - }, - }, - ); - - // Send real-time notification - try { - const notification = await this.notificationService.notifyBookingCreated( - user.id, - user.organizationId, - booking.bookingNumber.value, - booking.id, - ); - await this.notificationsGateway.sendNotificationToUser(user.id, notification); - } catch (error: any) { - // Don't fail the booking creation if notification fails - this.logger.error(`Failed to send notification: ${error?.message}`); - } - - // Trigger webhooks - try { - await this.webhookService.triggerWebhooks( - WebhookEvent.BOOKING_CREATED, - user.organizationId, - { - bookingId: booking.id, - bookingNumber: booking.bookingNumber.value, - status: booking.status.value, - shipper: booking.shipper, - consignee: booking.consignee, - carrier: rateQuote.carrierName, - origin: rateQuote.origin, - destination: rateQuote.destination, - etd: rateQuote.etd?.toISOString(), - eta: rateQuote.eta?.toISOString(), - createdAt: booking.createdAt.toISOString(), - }, - ); - } catch (error: any) { - // Don't fail the booking creation if webhook fails - this.logger.error(`Failed to trigger webhooks: ${error?.message}`); - } - - return response; - } catch (error: any) { - this.logger.error( - `Booking creation failed: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - - // Audit log: Booking creation failed - await this.auditService.logFailure( - AuditAction.BOOKING_CREATED, - user.id, - user.email, - user.organizationId, - error?.message || 'Unknown error', - { - resourceType: 'booking', - metadata: { - rateQuoteId: dto.rateQuoteId, - }, - }, - ); - - throw error; - } - } - - @Get(':id') - @ApiOperation({ - summary: 'Get booking by ID', - description: - 'Retrieve detailed information about a specific booking. Requires authentication.', - }) - @ApiParam({ - name: 'id', - description: 'Booking ID (UUID)', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Booking details retrieved successfully', - type: BookingResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiNotFoundResponse({ - description: 'Booking not found', - }) - async getBooking( - @Param('id', ParseUUIDPipe) id: string, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`); - - const booking = await this.bookingRepository.findById(id); - if (!booking) { - throw new NotFoundException(`Booking ${id} not found`); - } - - // Verify booking belongs to user's organization - if (booking.organizationId !== user.organizationId) { - throw new NotFoundException(`Booking ${id} not found`); - } - - // Fetch rate quote - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - if (!rateQuote) { - throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`); - } - - return BookingMapper.toDto(booking, rateQuote); - } - - @Get('number/:bookingNumber') - @ApiOperation({ - summary: 'Get booking by booking number', - description: - 'Retrieve detailed information about a specific booking using its booking number. Requires authentication.', - }) - @ApiParam({ - name: 'bookingNumber', - description: 'Booking number', - example: 'WCM-2025-ABC123', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Booking details retrieved successfully', - type: BookingResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiNotFoundResponse({ - description: 'Booking not found', - }) - async getBookingByNumber( - @Param('bookingNumber') bookingNumber: string, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[User: ${user.email}] Fetching booking by number: ${bookingNumber}`, - ); - - const bookingNumberVo = BookingNumber.fromString(bookingNumber); - const booking = - await this.bookingRepository.findByBookingNumber(bookingNumberVo); - - if (!booking) { - throw new NotFoundException(`Booking ${bookingNumber} not found`); - } - - // Verify booking belongs to user's organization - if (booking.organizationId !== user.organizationId) { - throw new NotFoundException(`Booking ${bookingNumber} not found`); - } - - // Fetch rate quote - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - if (!rateQuote) { - throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`); - } - - return BookingMapper.toDto(booking, rateQuote); - } - - @Get() - @ApiOperation({ - summary: 'List bookings', - description: - "Retrieve a paginated list of bookings for the authenticated user's organization. Requires authentication.", - }) - @ApiQuery({ - name: 'page', - required: false, - description: 'Page number (1-based)', - example: 1, - }) - @ApiQuery({ - name: 'pageSize', - required: false, - description: 'Number of items per page', - example: 20, - }) - @ApiQuery({ - name: 'status', - required: false, - description: 'Filter by booking status', - enum: [ - 'draft', - 'pending_confirmation', - 'confirmed', - 'in_transit', - 'delivered', - 'cancelled', - ], - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Bookings list retrieved successfully', - type: BookingListResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - async listBookings( - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, - @Query('status') status: string | undefined, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}`, - ); - - // Use authenticated user's organization ID - const organizationId = user.organizationId; - - // Fetch bookings for the user's organization - const bookings = - await this.bookingRepository.findByOrganization(organizationId); - - // Filter by status if provided - const filteredBookings = status - ? bookings.filter((b: any) => b.status.value === status) - : bookings; - - // Paginate - const startIndex = (page - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedBookings = filteredBookings.slice(startIndex, endIndex); - - // Fetch rate quotes for all bookings - const bookingsWithQuotes = await Promise.all( - paginatedBookings.map(async (booking: any) => { - const rateQuote = await this.rateQuoteRepository.findById( - booking.rateQuoteId, - ); - return { booking, rateQuote: rateQuote! }; - }), - ); - - // Convert to DTOs - const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); - - const totalPages = Math.ceil(filteredBookings.length / pageSize); - - return { - bookings: bookingDtos, - total: filteredBookings.length, - page, - pageSize, - totalPages, - }; - } - - @Get('search/fuzzy') - @ApiOperation({ - summary: 'Fuzzy search bookings', - description: - 'Search bookings using fuzzy matching. Tolerant to typos and partial matches. Searches across booking number, shipper, and consignee names.', - }) - @ApiQuery({ - name: 'q', - required: true, - description: 'Search query (minimum 2 characters)', - example: 'WCM-2025', - }) - @ApiQuery({ - name: 'limit', - required: false, - description: 'Maximum number of results', - example: 20, - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Search results retrieved successfully', - type: [BookingResponseDto], - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - async fuzzySearch( - @Query('q') searchTerm: string, - @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`); - - if (!searchTerm || searchTerm.length < 2) { - return []; - } - - // Perform fuzzy search - const bookingOrms = await this.fuzzySearchService.search( - searchTerm, - user.organizationId, - limit, - ); - - // Map ORM entities to domain and fetch rate quotes - const bookingsWithQuotes = await Promise.all( - bookingOrms.map(async (bookingOrm) => { - const booking = await this.bookingRepository.findById(bookingOrm.id); - const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId); - return { booking: booking!, rateQuote: rateQuote! }; - }), - ); - - // Convert to DTOs - const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) => - BookingMapper.toDto(booking, rateQuote), - ); - - this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`); - - return bookingDtos; - } - - @Get('advanced/search') - @ApiOperation({ - summary: 'Advanced booking search with filtering', - description: - 'Search bookings with advanced filtering options including status, date ranges, carrier, ports, shipper/consignee. Supports sorting and pagination.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Filtered bookings retrieved successfully', - type: BookingListResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - async advancedSearch( - @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}`, - ); - - // Fetch all bookings for organization - let bookings = await this.bookingRepository.findByOrganization(user.organizationId); - - // Apply filters - bookings = this.applyFilters(bookings, filter); - - // Sort bookings - bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!); - - // Total count before pagination - const total = bookings.length; - - // Paginate - const startIndex = ((filter.page || 1) - 1) * (filter.pageSize || 20); - const endIndex = startIndex + (filter.pageSize || 20); - const paginatedBookings = bookings.slice(startIndex, endIndex); - - // Fetch rate quotes - const bookingsWithQuotes = await Promise.all( - paginatedBookings.map(async (booking) => { - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - return { booking, rateQuote: rateQuote! }; - }), - ); - - // Convert to DTOs - const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); - - const totalPages = Math.ceil(total / (filter.pageSize || 20)); - - return { - bookings: bookingDtos, - total, - page: filter.page || 1, - pageSize: filter.pageSize || 20, - totalPages, - }; - } - - @Post('export') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Export bookings to CSV/Excel/JSON', - description: - 'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.', - }) - @ApiProduces('text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/json') - @ApiResponse({ - status: HttpStatus.OK, - description: 'Export file generated successfully', - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - async exportBookings( - @Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto, - @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, - @CurrentUser() user: UserPayload, - @Res({ passthrough: true }) res: Response, - ): Promise { - this.logger.log( - `[User: ${user.email}] Exporting bookings to ${exportDto.format}`, - ); - - let bookings: any[]; - - // If specific booking IDs provided, use those - if (exportDto.bookingIds && exportDto.bookingIds.length > 0) { - bookings = await Promise.all( - exportDto.bookingIds.map((id) => this.bookingRepository.findById(id)), - ); - bookings = bookings.filter((b) => b !== null && b.organizationId === user.organizationId); - } else { - // Otherwise, use filter criteria - bookings = await this.bookingRepository.findByOrganization(user.organizationId); - bookings = this.applyFilters(bookings, filter); - } - - // Fetch rate quotes - const bookingsWithQuotes = await Promise.all( - bookings.map(async (booking) => { - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - return { booking, rateQuote: rateQuote! }; - }), - ); - - // Generate export file - const exportResult = await this.exportService.exportBookings( - bookingsWithQuotes, - exportDto.format, - exportDto.fields, - ); - - // Set response headers - res.set({ - 'Content-Type': exportResult.contentType, - 'Content-Disposition': `attachment; filename="${exportResult.filename}"`, - }); - - // Audit log: Data exported - await this.auditService.logSuccess( - AuditAction.DATA_EXPORTED, - user.id, - user.email, - user.organizationId, - { - resourceType: 'booking', - metadata: { - format: exportDto.format, - bookingCount: bookings.length, - fields: exportDto.fields?.join(', ') || 'all', - filename: exportResult.filename, - }, - }, - ); - - return new StreamableFile(exportResult.buffer); - } - - /** - * Apply filters to bookings array - */ - private applyFilters(bookings: any[], filter: BookingFilterDto): any[] { - let filtered = bookings; - - // Filter by status - if (filter.status && filter.status.length > 0) { - filtered = filtered.filter((b) => filter.status!.includes(b.status.value)); - } - - // Filter by search (booking number partial match) - if (filter.search) { - const searchLower = filter.search.toLowerCase(); - filtered = filtered.filter((b) => - b.bookingNumber.value.toLowerCase().includes(searchLower), - ); - } - - // Filter by shipper - if (filter.shipper) { - const shipperLower = filter.shipper.toLowerCase(); - filtered = filtered.filter((b) => - b.shipper.name.toLowerCase().includes(shipperLower), - ); - } - - // Filter by consignee - if (filter.consignee) { - const consigneeLower = filter.consignee.toLowerCase(); - filtered = filtered.filter((b) => - b.consignee.name.toLowerCase().includes(consigneeLower), - ); - } - - // Filter by creation date range - if (filter.createdFrom) { - const fromDate = new Date(filter.createdFrom); - filtered = filtered.filter((b) => b.createdAt >= fromDate); - } - if (filter.createdTo) { - const toDate = new Date(filter.createdTo); - filtered = filtered.filter((b) => b.createdAt <= toDate); - } - - return filtered; - } - - /** - * Sort bookings array - */ - private sortBookings(bookings: any[], sortBy: string, sortOrder: string): any[] { - return [...bookings].sort((a, b) => { - let aValue: any; - let bValue: any; - - switch (sortBy) { - case 'bookingNumber': - aValue = a.bookingNumber.value; - bValue = b.bookingNumber.value; - break; - case 'status': - aValue = a.status.value; - bValue = b.status.value; - break; - case 'createdAt': - default: - aValue = a.createdAt; - bValue = b.createdAt; - break; - } - - if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; - if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - } -} +import { + Controller, + Get, + Post, + Param, + Body, + Query, + HttpCode, + HttpStatus, + Logger, + UsePipes, + ValidationPipe, + NotFoundException, + ParseUUIDPipe, + ParseIntPipe, + DefaultValuePipe, + UseGuards, + Res, + StreamableFile, + Inject, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiInternalServerErrorResponse, + ApiQuery, + ApiParam, + ApiBearerAuth, + ApiProduces, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto'; +import { BookingFilterDto } from '../dto/booking-filter.dto'; +import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto'; +import { BookingMapper } from '../mappers'; +import { BookingService } from '../../domain/services/booking.service'; +import { BookingRepository, BOOKING_REPOSITORY } from '../../domain/ports/out/booking.repository'; +import { + RateQuoteRepository, + RATE_QUOTE_REPOSITORY, +} from '../../domain/ports/out/rate-quote.repository'; +import { BookingNumber } from '../../domain/value-objects/booking-number.vo'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { ExportService } from '../services/export.service'; +import { FuzzySearchService } from '../services/fuzzy-search.service'; +import { AuditService } from '../services/audit.service'; +import { AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity'; +import { NotificationService } from '../services/notification.service'; +import { NotificationsGateway } from '../gateways/notifications.gateway'; +import { WebhookService } from '../services/webhook.service'; +import { WebhookEvent } from '../../domain/entities/webhook.entity'; + +@ApiTags('Bookings') +@Controller('bookings') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class BookingsController { + private readonly logger = new Logger(BookingsController.name); + + constructor( + private readonly bookingService: BookingService, + @Inject(BOOKING_REPOSITORY) private readonly bookingRepository: BookingRepository, + @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository, + private readonly exportService: ExportService, + private readonly fuzzySearchService: FuzzySearchService, + private readonly auditService: AuditService, + private readonly notificationService: NotificationService, + private readonly notificationsGateway: NotificationsGateway, + private readonly webhookService: WebhookService + ) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Create a new booking', + description: + 'Create a new booking based on a rate quote. The booking will be in "draft" status initially. Requires authentication.', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Booking created successfully', + type: BookingResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiBadRequestResponse({ + description: 'Invalid request parameters', + }) + @ApiNotFoundResponse({ + description: 'Rate quote not found', + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + }) + async createBooking( + @Body() dto: CreateBookingRequestDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); + + try { + // Convert DTO to domain input, using authenticated user's data + const input = { + ...BookingMapper.toCreateBookingInput(dto), + userId: user.id, + organizationId: user.organizationId, + }; + + // Create booking via domain service + const booking = await this.bookingService.createBooking(input); + + // Fetch rate quote for response + const rateQuote = await this.rateQuoteRepository.findById(dto.rateQuoteId); + if (!rateQuote) { + throw new NotFoundException(`Rate quote ${dto.rateQuoteId} not found`); + } + + // Convert to DTO + const response = BookingMapper.toDto(booking, rateQuote); + + this.logger.log( + `Booking created successfully: ${booking.bookingNumber.value} (${booking.id})` + ); + + // Audit log: Booking created + await this.auditService.logSuccess( + AuditAction.BOOKING_CREATED, + user.id, + user.email, + user.organizationId, + { + resourceType: 'booking', + resourceId: booking.id, + resourceName: booking.bookingNumber.value, + metadata: { + rateQuoteId: dto.rateQuoteId, + status: booking.status.value, + carrier: rateQuote.carrierName, + }, + } + ); + + // Send real-time notification + try { + const notification = await this.notificationService.notifyBookingCreated( + user.id, + user.organizationId, + booking.bookingNumber.value, + booking.id + ); + await this.notificationsGateway.sendNotificationToUser(user.id, notification); + } catch (error: any) { + // Don't fail the booking creation if notification fails + this.logger.error(`Failed to send notification: ${error?.message}`); + } + + // Trigger webhooks + try { + await this.webhookService.triggerWebhooks( + WebhookEvent.BOOKING_CREATED, + user.organizationId, + { + bookingId: booking.id, + bookingNumber: booking.bookingNumber.value, + status: booking.status.value, + shipper: booking.shipper, + consignee: booking.consignee, + carrier: rateQuote.carrierName, + origin: rateQuote.origin, + destination: rateQuote.destination, + etd: rateQuote.etd?.toISOString(), + eta: rateQuote.eta?.toISOString(), + createdAt: booking.createdAt.toISOString(), + } + ); + } catch (error: any) { + // Don't fail the booking creation if webhook fails + this.logger.error(`Failed to trigger webhooks: ${error?.message}`); + } + + return response; + } catch (error: any) { + this.logger.error( + `Booking creation failed: ${error?.message || 'Unknown error'}`, + error?.stack + ); + + // Audit log: Booking creation failed + await this.auditService.logFailure( + AuditAction.BOOKING_CREATED, + user.id, + user.email, + user.organizationId, + error?.message || 'Unknown error', + { + resourceType: 'booking', + metadata: { + rateQuoteId: dto.rateQuoteId, + }, + } + ); + + throw error; + } + } + + @Get(':id') + @ApiOperation({ + summary: 'Get booking by ID', + description: 'Retrieve detailed information about a specific booking. Requires authentication.', + }) + @ApiParam({ + name: 'id', + description: 'Booking ID (UUID)', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Booking details retrieved successfully', + type: BookingResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiNotFoundResponse({ + description: 'Booking not found', + }) + async getBooking( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`); + + const booking = await this.bookingRepository.findById(id); + if (!booking) { + throw new NotFoundException(`Booking ${id} not found`); + } + + // Verify booking belongs to user's organization + if (booking.organizationId !== user.organizationId) { + throw new NotFoundException(`Booking ${id} not found`); + } + + // Fetch rate quote + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + if (!rateQuote) { + throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`); + } + + return BookingMapper.toDto(booking, rateQuote); + } + + @Get('number/:bookingNumber') + @ApiOperation({ + summary: 'Get booking by booking number', + description: + 'Retrieve detailed information about a specific booking using its booking number. Requires authentication.', + }) + @ApiParam({ + name: 'bookingNumber', + description: 'Booking number', + example: 'WCM-2025-ABC123', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Booking details retrieved successfully', + type: BookingResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiNotFoundResponse({ + description: 'Booking not found', + }) + async getBookingByNumber( + @Param('bookingNumber') bookingNumber: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`); + + const bookingNumberVo = BookingNumber.fromString(bookingNumber); + const booking = await this.bookingRepository.findByBookingNumber(bookingNumberVo); + + if (!booking) { + throw new NotFoundException(`Booking ${bookingNumber} not found`); + } + + // Verify booking belongs to user's organization + if (booking.organizationId !== user.organizationId) { + throw new NotFoundException(`Booking ${bookingNumber} not found`); + } + + // Fetch rate quote + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + if (!rateQuote) { + throw new NotFoundException(`Rate quote ${booking.rateQuoteId} not found`); + } + + return BookingMapper.toDto(booking, rateQuote); + } + + @Get() + @ApiOperation({ + summary: 'List bookings', + description: + "Retrieve a paginated list of bookings for the authenticated user's organization. Requires authentication.", + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number (1-based)', + example: 1, + }) + @ApiQuery({ + name: 'pageSize', + required: false, + description: 'Number of items per page', + example: 20, + }) + @ApiQuery({ + name: 'status', + required: false, + description: 'Filter by booking status', + enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'], + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Bookings list retrieved successfully', + type: BookingListResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async listBookings( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, + @Query('status') status: string | undefined, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log( + `[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}` + ); + + // Use authenticated user's organization ID + const organizationId = user.organizationId; + + // Fetch bookings for the user's organization + const bookings = await this.bookingRepository.findByOrganization(organizationId); + + // Filter by status if provided + const filteredBookings = status + ? bookings.filter((b: any) => b.status.value === status) + : bookings; + + // Paginate + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedBookings = filteredBookings.slice(startIndex, endIndex); + + // Fetch rate quotes for all bookings + const bookingsWithQuotes = await Promise.all( + paginatedBookings.map(async (booking: any) => { + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + return { booking, rateQuote: rateQuote! }; + }) + ); + + // Convert to DTOs + const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); + + const totalPages = Math.ceil(filteredBookings.length / pageSize); + + return { + bookings: bookingDtos, + total: filteredBookings.length, + page, + pageSize, + totalPages, + }; + } + + @Get('search/fuzzy') + @ApiOperation({ + summary: 'Fuzzy search bookings', + description: + 'Search bookings using fuzzy matching. Tolerant to typos and partial matches. Searches across booking number, shipper, and consignee names.', + }) + @ApiQuery({ + name: 'q', + required: true, + description: 'Search query (minimum 2 characters)', + example: 'WCM-2025', + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Maximum number of results', + example: 20, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Search results retrieved successfully', + type: [BookingResponseDto], + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async fuzzySearch( + @Query('q') searchTerm: string, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`); + + if (!searchTerm || searchTerm.length < 2) { + return []; + } + + // Perform fuzzy search + const bookingOrms = await this.fuzzySearchService.search( + searchTerm, + user.organizationId, + limit + ); + + // Map ORM entities to domain and fetch rate quotes + const bookingsWithQuotes = await Promise.all( + bookingOrms.map(async bookingOrm => { + const booking = await this.bookingRepository.findById(bookingOrm.id); + const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId); + return { booking: booking!, rateQuote: rateQuote! }; + }) + ); + + // Convert to DTOs + const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) => + BookingMapper.toDto(booking, rateQuote) + ); + + this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`); + + return bookingDtos; + } + + @Get('advanced/search') + @ApiOperation({ + summary: 'Advanced booking search with filtering', + description: + 'Search bookings with advanced filtering options including status, date ranges, carrier, ports, shipper/consignee. Supports sorting and pagination.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Filtered bookings retrieved successfully', + type: BookingListResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async advancedSearch( + @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log( + `[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}` + ); + + // Fetch all bookings for organization + let bookings = await this.bookingRepository.findByOrganization(user.organizationId); + + // Apply filters + bookings = this.applyFilters(bookings, filter); + + // Sort bookings + bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!); + + // Total count before pagination + const total = bookings.length; + + // Paginate + const startIndex = ((filter.page || 1) - 1) * (filter.pageSize || 20); + const endIndex = startIndex + (filter.pageSize || 20); + const paginatedBookings = bookings.slice(startIndex, endIndex); + + // Fetch rate quotes + const bookingsWithQuotes = await Promise.all( + paginatedBookings.map(async booking => { + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + return { booking, rateQuote: rateQuote! }; + }) + ); + + // Convert to DTOs + const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); + + const totalPages = Math.ceil(total / (filter.pageSize || 20)); + + return { + bookings: bookingDtos, + total, + page: filter.page || 1, + pageSize: filter.pageSize || 20, + totalPages, + }; + } + + @Post('export') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Export bookings to CSV/Excel/JSON', + description: + 'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.', + }) + @ApiProduces( + 'text/csv', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/json' + ) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Export file generated successfully', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async exportBookings( + @Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto, + @Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto, + @CurrentUser() user: UserPayload, + @Res({ passthrough: true }) res: Response + ): Promise { + this.logger.log(`[User: ${user.email}] Exporting bookings to ${exportDto.format}`); + + let bookings: any[]; + + // If specific booking IDs provided, use those + if (exportDto.bookingIds && exportDto.bookingIds.length > 0) { + bookings = await Promise.all( + exportDto.bookingIds.map(id => this.bookingRepository.findById(id)) + ); + bookings = bookings.filter(b => b !== null && b.organizationId === user.organizationId); + } else { + // Otherwise, use filter criteria + bookings = await this.bookingRepository.findByOrganization(user.organizationId); + bookings = this.applyFilters(bookings, filter); + } + + // Fetch rate quotes + const bookingsWithQuotes = await Promise.all( + bookings.map(async booking => { + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); + return { booking, rateQuote: rateQuote! }; + }) + ); + + // Generate export file + const exportResult = await this.exportService.exportBookings( + bookingsWithQuotes, + exportDto.format, + exportDto.fields + ); + + // Set response headers + res.set({ + 'Content-Type': exportResult.contentType, + 'Content-Disposition': `attachment; filename="${exportResult.filename}"`, + }); + + // Audit log: Data exported + await this.auditService.logSuccess( + AuditAction.DATA_EXPORTED, + user.id, + user.email, + user.organizationId, + { + resourceType: 'booking', + metadata: { + format: exportDto.format, + bookingCount: bookings.length, + fields: exportDto.fields?.join(', ') || 'all', + filename: exportResult.filename, + }, + } + ); + + return new StreamableFile(exportResult.buffer); + } + + /** + * Apply filters to bookings array + */ + private applyFilters(bookings: any[], filter: BookingFilterDto): any[] { + let filtered = bookings; + + // Filter by status + if (filter.status && filter.status.length > 0) { + filtered = filtered.filter(b => filter.status!.includes(b.status.value)); + } + + // Filter by search (booking number partial match) + if (filter.search) { + const searchLower = filter.search.toLowerCase(); + filtered = filtered.filter(b => b.bookingNumber.value.toLowerCase().includes(searchLower)); + } + + // Filter by shipper + if (filter.shipper) { + const shipperLower = filter.shipper.toLowerCase(); + filtered = filtered.filter(b => b.shipper.name.toLowerCase().includes(shipperLower)); + } + + // Filter by consignee + if (filter.consignee) { + const consigneeLower = filter.consignee.toLowerCase(); + filtered = filtered.filter(b => b.consignee.name.toLowerCase().includes(consigneeLower)); + } + + // Filter by creation date range + if (filter.createdFrom) { + const fromDate = new Date(filter.createdFrom); + filtered = filtered.filter(b => b.createdAt >= fromDate); + } + if (filter.createdTo) { + const toDate = new Date(filter.createdTo); + filtered = filtered.filter(b => b.createdAt <= toDate); + } + + return filtered; + } + + /** + * Sort bookings array + */ + private sortBookings(bookings: any[], sortBy: string, sortOrder: string): any[] { + return [...bookings].sort((a, b) => { + let aValue: any; + let bValue: any; + + switch (sortBy) { + case 'bookingNumber': + aValue = a.bookingNumber.value; + bValue = b.bookingNumber.value; + break; + case 'status': + aValue = a.status.value; + bValue = b.status.value; + break; + case 'createdAt': + default: + aValue = a.createdAt; + bValue = b.createdAt; + break; + } + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } +} diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index de10eac..b21f2fc 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -41,17 +41,14 @@ export class GDPRController { status: 200, description: 'Data export successful', }) - async exportData( - @CurrentUser() user: UserPayload, - @Res() res: Response, - ): Promise { + async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise { const exportData = await this.gdprService.exportUserData(user.id); // Set headers for file download res.setHeader('Content-Type', 'application/json'); res.setHeader( 'Content-Disposition', - `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`, + `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"` ); res.json(exportData); @@ -69,10 +66,7 @@ export class GDPRController { status: 200, description: 'CSV export successful', }) - async exportDataCSV( - @CurrentUser() user: UserPayload, - @Res() res: Response, - ): Promise { + async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise { const exportData = await this.gdprService.exportUserData(user.id); // Convert to CSV (simplified version) @@ -87,7 +81,7 @@ export class GDPRController { res.setHeader('Content-Type', 'text/csv'); res.setHeader( 'Content-Disposition', - `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`, + `attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"` ); res.send(csv); @@ -108,7 +102,7 @@ export class GDPRController { }) async deleteAccount( @CurrentUser() user: UserPayload, - @Body() body: { reason?: string; confirmEmail: string }, + @Body() body: { reason?: string; confirmEmail: string } ): Promise { // Verify email confirmation (security measure) if (body.confirmEmail !== user.email) { @@ -133,7 +127,7 @@ export class GDPRController { }) async recordConsent( @CurrentUser() user: UserPayload, - @Body() body: Omit, + @Body() body: Omit ): Promise<{ success: boolean }> { await this.gdprService.recordConsent({ ...body, @@ -158,7 +152,7 @@ export class GDPRController { }) async withdrawConsent( @CurrentUser() user: UserPayload, - @Body() body: { consentType: 'marketing' | 'analytics' }, + @Body() body: { consentType: 'marketing' | 'analytics' } ): Promise<{ success: boolean }> { await this.gdprService.withdrawConsent(user.id, body.consentType); @@ -177,9 +171,7 @@ export class GDPRController { status: 200, description: 'Consent status retrieved', }) - async getConsentStatus( - @CurrentUser() user: UserPayload, - ): Promise { + async getConsentStatus(@CurrentUser() user: UserPayload): Promise { return this.gdprService.getConsentStatus(user.id); } } diff --git a/apps/backend/src/application/controllers/index.ts b/apps/backend/src/application/controllers/index.ts index a76cdd7..70e2402 100644 --- a/apps/backend/src/application/controllers/index.ts +++ b/apps/backend/src/application/controllers/index.ts @@ -1,2 +1,2 @@ -export * from './rates.controller'; -export * from './bookings.controller'; +export * from './rates.controller'; +export * from './bookings.controller'; diff --git a/apps/backend/src/application/controllers/notifications.controller.ts b/apps/backend/src/application/controllers/notifications.controller.ts index 9861a20..3d1442a 100644 --- a/apps/backend/src/application/controllers/notifications.controller.ts +++ b/apps/backend/src/application/controllers/notifications.controller.ts @@ -17,13 +17,7 @@ import { DefaultValuePipe, NotFoundException, } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiQuery, -} from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { NotificationService } from '../services/notification.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; @@ -62,7 +56,7 @@ export class NotificationsController { @CurrentUser() user: UserPayload, @Query('read') read?: string, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number, - @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number ): Promise<{ notifications: NotificationResponseDto[]; total: number; @@ -82,7 +76,7 @@ export class NotificationsController { const { notifications, total } = await this.notificationService.getNotifications(filters); return { - notifications: notifications.map((n) => this.mapToDto(n)), + notifications: notifications.map(n => this.mapToDto(n)), total, page, pageSize: limit, @@ -95,14 +89,18 @@ export class NotificationsController { @Get('unread') @ApiOperation({ summary: 'Get unread notifications' }) @ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of notifications (default: 50)', + }) async getUnreadNotifications( @CurrentUser() user: UserPayload, - @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number ): Promise { limit = limit || 50; const notifications = await this.notificationService.getUnreadNotifications(user.id, limit); - return notifications.map((n) => this.mapToDto(n)); + return notifications.map(n => this.mapToDto(n)); } /** @@ -125,7 +123,7 @@ export class NotificationsController { @ApiResponse({ status: 404, description: 'Notification not found' }) async getNotificationById( @CurrentUser() user: UserPayload, - @Param('id') id: string, + @Param('id') id: string ): Promise { const notification = await this.notificationService.getNotificationById(id); @@ -145,7 +143,7 @@ export class NotificationsController { @ApiResponse({ status: 404, description: 'Notification not found' }) async markAsRead( @CurrentUser() user: UserPayload, - @Param('id') id: string, + @Param('id') id: string ): Promise<{ success: boolean }> { const notification = await this.notificationService.getNotificationById(id); @@ -177,7 +175,7 @@ export class NotificationsController { @ApiResponse({ status: 404, description: 'Notification not found' }) async deleteNotification( @CurrentUser() user: UserPayload, - @Param('id') id: string, + @Param('id') id: string ): Promise<{ success: boolean }> { const notification = await this.notificationService.getNotificationById(id); diff --git a/apps/backend/src/application/controllers/organizations.controller.ts b/apps/backend/src/application/controllers/organizations.controller.ts index 8826414..a196508 100644 --- a/apps/backend/src/application/controllers/organizations.controller.ts +++ b/apps/backend/src/application/controllers/organizations.controller.ts @@ -1,367 +1,357 @@ -import { - Controller, - Get, - Post, - Patch, - Param, - Body, - Query, - HttpCode, - HttpStatus, - Logger, - UsePipes, - ValidationPipe, - NotFoundException, - ParseUUIDPipe, - ParseIntPipe, - DefaultValuePipe, - UseGuards, - ForbiddenException, - Inject, -} from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBadRequestResponse, - ApiNotFoundResponse, - ApiQuery, - ApiParam, - ApiBearerAuth, -} from '@nestjs/swagger'; -import { - CreateOrganizationDto, - UpdateOrganizationDto, - OrganizationResponseDto, - OrganizationListResponseDto, -} from '../dto/organization.dto'; -import { OrganizationMapper } from '../mappers/organization.mapper'; -import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository'; -import { Organization, OrganizationType } from '../../domain/entities/organization.entity'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { RolesGuard } from '../guards/roles.guard'; -import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; -import { Roles } from '../decorators/roles.decorator'; -import { v4 as uuidv4 } from 'uuid'; - -/** - * Organizations Controller - * - * Manages organization CRUD operations: - * - Create organization (admin only) - * - Get organization details - * - Update organization (admin/manager) - * - List organizations - */ -@ApiTags('Organizations') -@Controller('organizations') -@UseGuards(JwtAuthGuard, RolesGuard) -@ApiBearerAuth() -export class OrganizationsController { - private readonly logger = new Logger(OrganizationsController.name); - - constructor( - @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, - ) {} - - /** - * Create a new organization - * - * Admin-only endpoint to create a new organization. - */ - @Post() - @HttpCode(HttpStatus.CREATED) - @Roles('admin') - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - @ApiOperation({ - summary: 'Create new organization', - description: - 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.', - }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'Organization created successfully', - type: OrganizationResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiResponse({ - status: 403, - description: 'Forbidden - requires admin role', - }) - @ApiBadRequestResponse({ - description: 'Invalid request parameters', - }) - async createOrganization( - @Body() dto: CreateOrganizationDto, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`, - ); - - try { - // Check for duplicate name - const existingByName = await this.organizationRepository.findByName(dto.name); - if (existingByName) { - throw new ForbiddenException( - `Organization with name "${dto.name}" already exists`, - ); - } - - // Check for duplicate SCAC if provided - if (dto.scac) { - const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac); - if (existingBySCAC) { - throw new ForbiddenException( - `Organization with SCAC "${dto.scac}" already exists`, - ); - } - } - - // Create organization entity - const organization = Organization.create({ - id: uuidv4(), - name: dto.name, - type: dto.type, - scac: dto.scac, - address: OrganizationMapper.mapDtoToAddress(dto.address), - logoUrl: dto.logoUrl, - documents: [], - isActive: true, - }); - - // Save to database - const savedOrg = await this.organizationRepository.save(organization); - - this.logger.log( - `Organization created successfully: ${savedOrg.name} (${savedOrg.id})`, - ); - - return OrganizationMapper.toDto(savedOrg); - } catch (error: any) { - this.logger.error( - `Organization creation failed: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - throw error; - } - } - - /** - * Get organization by ID - * - * Retrieve details of a specific organization. - * Users can only view their own organization unless they are admins. - */ - @Get(':id') - @ApiOperation({ - summary: 'Get organization by ID', - description: - 'Retrieve organization details. Users can view their own organization, admins can view any.', - }) - @ApiParam({ - name: 'id', - description: 'Organization ID (UUID)', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Organization details retrieved successfully', - type: OrganizationResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiNotFoundResponse({ - description: 'Organization not found', - }) - async getOrganization( - @Param('id', ParseUUIDPipe) id: string, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`); - - const organization = await this.organizationRepository.findById(id); - if (!organization) { - throw new NotFoundException(`Organization ${id} not found`); - } - - // Authorization: Users can only view their own organization (unless admin) - if (user.role !== 'admin' && organization.id !== user.organizationId) { - throw new ForbiddenException('You can only view your own organization'); - } - - return OrganizationMapper.toDto(organization); - } - - /** - * Update organization - * - * Update organization details (name, address, logo, status). - * Requires admin or manager role. - */ - @Patch(':id') - @Roles('admin', 'manager') - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - @ApiOperation({ - summary: 'Update organization', - description: - 'Update organization details (name, address, logo, status). Requires admin or manager role.', - }) - @ApiParam({ - name: 'id', - description: 'Organization ID (UUID)', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Organization updated successfully', - type: OrganizationResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiResponse({ - status: 403, - description: 'Forbidden - requires admin or manager role', - }) - @ApiNotFoundResponse({ - description: 'Organization not found', - }) - async updateOrganization( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateOrganizationDto, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[User: ${user.email}] Updating organization: ${id}`, - ); - - const organization = await this.organizationRepository.findById(id); - if (!organization) { - throw new NotFoundException(`Organization ${id} not found`); - } - - // Authorization: Managers can only update their own organization - if (user.role === 'manager' && organization.id !== user.organizationId) { - throw new ForbiddenException('You can only update your own organization'); - } - - // Update fields - if (dto.name) { - organization.updateName(dto.name); - } - - if (dto.address) { - organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address)); - } - - if (dto.logoUrl !== undefined) { - organization.updateLogoUrl(dto.logoUrl); - } - - if (dto.isActive !== undefined) { - if (dto.isActive) { - organization.activate(); - } else { - organization.deactivate(); - } - } - - // Save updated organization - const updatedOrg = await this.organizationRepository.save(organization); - - this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); - - return OrganizationMapper.toDto(updatedOrg); - } - - /** - * List organizations - * - * Retrieve a paginated list of organizations. - * Admins can see all, others see only their own. - */ - @Get() - @ApiOperation({ - summary: 'List organizations', - description: - 'Retrieve a paginated list of organizations. Admins see all, others see only their own.', - }) - @ApiQuery({ - name: 'page', - required: false, - description: 'Page number (1-based)', - example: 1, - }) - @ApiQuery({ - name: 'pageSize', - required: false, - description: 'Number of items per page', - example: 20, - }) - @ApiQuery({ - name: 'type', - required: false, - description: 'Filter by organization type', - enum: OrganizationType, - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Organizations list retrieved successfully', - type: OrganizationListResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - async listOrganizations( - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, - @Query('type') type: OrganizationType | undefined, - @CurrentUser() user: UserPayload, - ): Promise { - this.logger.log( - `[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`, - ); - - // Fetch organizations - let organizations: Organization[]; - - if (user.role === 'admin') { - // Admins can see all organizations - organizations = await this.organizationRepository.findAll(); - } else { - // Others see only their own organization - const userOrg = await this.organizationRepository.findById(user.organizationId); - organizations = userOrg ? [userOrg] : []; - } - - // Filter by type if provided - const filteredOrgs = type - ? organizations.filter(org => org.type === type) - : organizations; - - // Paginate - const startIndex = (page - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex); - - // Convert to DTOs - const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs); - - const totalPages = Math.ceil(filteredOrgs.length / pageSize); - - return { - organizations: orgDtos, - total: filteredOrgs.length, - page, - pageSize, - totalPages, - }; - } -} +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + HttpCode, + HttpStatus, + Logger, + UsePipes, + ValidationPipe, + NotFoundException, + ParseUUIDPipe, + ParseIntPipe, + DefaultValuePipe, + UseGuards, + ForbiddenException, + Inject, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiQuery, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { + CreateOrganizationDto, + UpdateOrganizationDto, + OrganizationResponseDto, + OrganizationListResponseDto, +} from '../dto/organization.dto'; +import { OrganizationMapper } from '../mappers/organization.mapper'; +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '../../domain/ports/out/organization.repository'; +import { Organization, OrganizationType } from '../../domain/entities/organization.entity'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { Roles } from '../decorators/roles.decorator'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Organizations Controller + * + * Manages organization CRUD operations: + * - Create organization (admin only) + * - Get organization details + * - Update organization (admin/manager) + * - List organizations + */ +@ApiTags('Organizations') +@Controller('organizations') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class OrganizationsController { + private readonly logger = new Logger(OrganizationsController.name); + + constructor( + @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository + ) {} + + /** + * Create a new organization + * + * Admin-only endpoint to create a new organization. + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @Roles('admin') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Create new organization', + description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Organization created successfully', + type: OrganizationResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin role', + }) + @ApiBadRequestResponse({ + description: 'Invalid request parameters', + }) + async createOrganization( + @Body() dto: CreateOrganizationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`); + + try { + // Check for duplicate name + const existingByName = await this.organizationRepository.findByName(dto.name); + if (existingByName) { + throw new ForbiddenException(`Organization with name "${dto.name}" already exists`); + } + + // Check for duplicate SCAC if provided + if (dto.scac) { + const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac); + if (existingBySCAC) { + throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`); + } + } + + // Create organization entity + const organization = Organization.create({ + id: uuidv4(), + name: dto.name, + type: dto.type, + scac: dto.scac, + address: OrganizationMapper.mapDtoToAddress(dto.address), + logoUrl: dto.logoUrl, + documents: [], + isActive: true, + }); + + // Save to database + const savedOrg = await this.organizationRepository.save(organization); + + this.logger.log(`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`); + + return OrganizationMapper.toDto(savedOrg); + } catch (error: any) { + this.logger.error( + `Organization creation failed: ${error?.message || 'Unknown error'}`, + error?.stack + ); + throw error; + } + } + + /** + * Get organization by ID + * + * Retrieve details of a specific organization. + * Users can only view their own organization unless they are admins. + */ + @Get(':id') + @ApiOperation({ + summary: 'Get organization by ID', + description: + 'Retrieve organization details. Users can view their own organization, admins can view any.', + }) + @ApiParam({ + name: 'id', + description: 'Organization ID (UUID)', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Organization details retrieved successfully', + type: OrganizationResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiNotFoundResponse({ + description: 'Organization not found', + }) + async getOrganization( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Authorization: Users can only view their own organization (unless admin) + if (user.role !== 'admin' && organization.id !== user.organizationId) { + throw new ForbiddenException('You can only view your own organization'); + } + + return OrganizationMapper.toDto(organization); + } + + /** + * Update organization + * + * Update organization details (name, address, logo, status). + * Requires admin or manager role. + */ + @Patch(':id') + @Roles('admin', 'manager') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Update organization', + description: + 'Update organization details (name, address, logo, status). Requires admin or manager role.', + }) + @ApiParam({ + name: 'id', + description: 'Organization ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Organization updated successfully', + type: OrganizationResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + @ApiNotFoundResponse({ + description: 'Organization not found', + }) + async updateOrganization( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOrganizationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Updating organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Authorization: Managers can only update their own organization + if (user.role === 'manager' && organization.id !== user.organizationId) { + throw new ForbiddenException('You can only update your own organization'); + } + + // Update fields + if (dto.name) { + organization.updateName(dto.name); + } + + if (dto.address) { + organization.updateAddress(OrganizationMapper.mapDtoToAddress(dto.address)); + } + + if (dto.logoUrl !== undefined) { + organization.updateLogoUrl(dto.logoUrl); + } + + if (dto.isActive !== undefined) { + if (dto.isActive) { + organization.activate(); + } else { + organization.deactivate(); + } + } + + // Save updated organization + const updatedOrg = await this.organizationRepository.save(organization); + + this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); + + return OrganizationMapper.toDto(updatedOrg); + } + + /** + * List organizations + * + * Retrieve a paginated list of organizations. + * Admins can see all, others see only their own. + */ + @Get() + @ApiOperation({ + summary: 'List organizations', + description: + 'Retrieve a paginated list of organizations. Admins see all, others see only their own.', + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number (1-based)', + example: 1, + }) + @ApiQuery({ + name: 'pageSize', + required: false, + description: 'Number of items per page', + example: 20, + }) + @ApiQuery({ + name: 'type', + required: false, + description: 'Filter by organization type', + enum: OrganizationType, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Organizations list retrieved successfully', + type: OrganizationListResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async listOrganizations( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number, + @Query('type') type: OrganizationType | undefined, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log( + `[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}` + ); + + // Fetch organizations + let organizations: Organization[]; + + if (user.role === 'admin') { + // Admins can see all organizations + organizations = await this.organizationRepository.findAll(); + } else { + // Others see only their own organization + const userOrg = await this.organizationRepository.findById(user.organizationId); + organizations = userOrg ? [userOrg] : []; + } + + // Filter by type if provided + const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations; + + // Paginate + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedOrgs = filteredOrgs.slice(startIndex, endIndex); + + // Convert to DTOs + const orgDtos = OrganizationMapper.toDtoArray(paginatedOrgs); + + const totalPages = Math.ceil(filteredOrgs.length / pageSize); + + return { + organizations: orgDtos, + total: filteredOrgs.length, + page, + pageSize, + totalPages, + }; + } +} diff --git a/apps/backend/src/application/controllers/rates.controller.ts b/apps/backend/src/application/controllers/rates.controller.ts index 6cf5145..9cd1c8d 100644 --- a/apps/backend/src/application/controllers/rates.controller.ts +++ b/apps/backend/src/application/controllers/rates.controller.ts @@ -1,267 +1,262 @@ -import { - Controller, - Post, - Get, - Body, - HttpCode, - HttpStatus, - Logger, - UsePipes, - ValidationPipe, - UseGuards, -} from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBadRequestResponse, - ApiInternalServerErrorResponse, - ApiBearerAuth, -} from '@nestjs/swagger'; -import { RateSearchRequestDto, RateSearchResponseDto } from '../dto'; -import { RateQuoteMapper } from '../mappers'; -import { RateSearchService } from '../../domain/services/rate-search.service'; -import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; -import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; -import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto'; -import { CsvRateMapper } from '../mappers/csv-rate.mapper'; - -@ApiTags('Rates') -@Controller('rates') -@ApiBearerAuth() -export class RatesController { - private readonly logger = new Logger(RatesController.name); - - constructor( - private readonly rateSearchService: RateSearchService, - private readonly csvRateSearchService: CsvRateSearchService, - private readonly csvRateMapper: CsvRateMapper, - ) {} - - @Post('search') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - @ApiOperation({ - summary: 'Search shipping rates', - description: - 'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Rate search completed successfully', - type: RateSearchResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiBadRequestResponse({ - description: 'Invalid request parameters', - schema: { - example: { - statusCode: 400, - message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'], - error: 'Bad Request', - }, - }, - }) - @ApiInternalServerErrorResponse({ - description: 'Internal server error', - }) - async searchRates( - @Body() dto: RateSearchRequestDto, - @CurrentUser() user: UserPayload, - ): Promise { - const startTime = Date.now(); - this.logger.log( - `[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`, - ); - - try { - // Convert DTO to domain input - const searchInput = { - origin: dto.origin, - destination: dto.destination, - containerType: dto.containerType, - mode: dto.mode, - departureDate: new Date(dto.departureDate), - quantity: dto.quantity, - weight: dto.weight, - volume: dto.volume, - isHazmat: dto.isHazmat, - imoClass: dto.imoClass, - }; - - // Execute search - const result = await this.rateSearchService.execute(searchInput); - - // Convert domain entities to DTOs - const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes); - - const responseTimeMs = Date.now() - startTime; - this.logger.log( - `Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`, - ); - - return { - quotes: quoteDtos, - count: quoteDtos.length, - origin: dto.origin, - destination: dto.destination, - departureDate: dto.departureDate, - containerType: dto.containerType, - mode: dto.mode, - fromCache: false, // TODO: Implement cache detection - responseTimeMs, - }; - } catch (error: any) { - this.logger.error( - `Rate search failed: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - throw error; - } - } - - /** - * Search CSV-based rates with advanced filters - */ - @Post('search-csv') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - @ApiOperation({ - summary: 'Search CSV-based rates with advanced filters', - description: - 'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'CSV rate search completed successfully', - type: CsvRateSearchResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid token', - }) - @ApiBadRequestResponse({ - description: 'Invalid request parameters', - }) - async searchCsvRates( - @Body() dto: CsvRateSearchDto, - @CurrentUser() user: UserPayload, - ): Promise { - const startTime = Date.now(); - this.logger.log( - `[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`, - ); - - try { - // Map DTO to domain input - const searchInput = { - origin: dto.origin, - destination: dto.destination, - volumeCBM: dto.volumeCBM, - weightKG: dto.weightKG, - palletCount: dto.palletCount ?? 0, - containerType: dto.containerType, - filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), - }; - - // Execute CSV rate search - const result = await this.csvRateSearchService.execute(searchInput); - - // Map domain output to response DTO - const response = this.csvRateMapper.mapSearchOutputToResponseDto(result); - - const responseTimeMs = Date.now() - startTime; - this.logger.log( - `CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`, - ); - - return response; - } catch (error: any) { - this.logger.error( - `CSV rate search failed: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - throw error; - } - } - - /** - * Get available companies - */ - @Get('companies') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Get available carrier companies', - description: 'Returns list of all available carrier companies in the CSV rate system.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'List of available companies', - type: AvailableCompaniesDto, - }) - async getCompanies(): Promise { - this.logger.log('Fetching available companies'); - - try { - const companies = await this.csvRateSearchService.getAvailableCompanies(); - - return { - companies, - total: companies.length, - }; - } catch (error: any) { - this.logger.error( - `Failed to fetch companies: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - throw error; - } - } - - /** - * Get filter options - */ - @Get('filters/options') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Get available filter options', - description: - 'Returns available options for all filters (companies, container types, currencies).', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Available filter options', - type: FilterOptionsDto, - }) - async getFilterOptions(): Promise { - this.logger.log('Fetching filter options'); - - try { - const [companies, containerTypes] = await Promise.all([ - this.csvRateSearchService.getAvailableCompanies(), - this.csvRateSearchService.getAvailableContainerTypes(), - ]); - - return { - companies, - containerTypes, - currencies: ['USD', 'EUR'], - }; - } catch (error: any) { - this.logger.error( - `Failed to fetch filter options: ${error?.message || 'Unknown error'}`, - error?.stack, - ); - throw error; - } - } -} +import { + Controller, + Post, + Get, + Body, + HttpCode, + HttpStatus, + Logger, + UsePipes, + ValidationPipe, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBadRequestResponse, + ApiInternalServerErrorResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { RateSearchRequestDto, RateSearchResponseDto } from '../dto'; +import { RateQuoteMapper } from '../mappers'; +import { RateSearchService } from '../../domain/services/rate-search.service'; +import { CsvRateSearchService } from '../../domain/services/csv-rate-search.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; +import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto'; +import { CsvRateMapper } from '../mappers/csv-rate.mapper'; + +@ApiTags('Rates') +@Controller('rates') +@ApiBearerAuth() +export class RatesController { + private readonly logger = new Logger(RatesController.name); + + constructor( + private readonly rateSearchService: RateSearchService, + private readonly csvRateSearchService: CsvRateSearchService, + private readonly csvRateMapper: CsvRateMapper + ) {} + + @Post('search') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Search shipping rates', + description: + 'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Rate search completed successfully', + type: RateSearchResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiBadRequestResponse({ + description: 'Invalid request parameters', + schema: { + example: { + statusCode: 400, + message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'], + error: 'Bad Request', + }, + }, + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + }) + async searchRates( + @Body() dto: RateSearchRequestDto, + @CurrentUser() user: UserPayload + ): Promise { + const startTime = Date.now(); + this.logger.log( + `[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}` + ); + + try { + // Convert DTO to domain input + const searchInput = { + origin: dto.origin, + destination: dto.destination, + containerType: dto.containerType, + mode: dto.mode, + departureDate: new Date(dto.departureDate), + quantity: dto.quantity, + weight: dto.weight, + volume: dto.volume, + isHazmat: dto.isHazmat, + imoClass: dto.imoClass, + }; + + // Execute search + const result = await this.rateSearchService.execute(searchInput); + + // Convert domain entities to DTOs + const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes); + + const responseTimeMs = Date.now() - startTime; + this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`); + + return { + quotes: quoteDtos, + count: quoteDtos.length, + origin: dto.origin, + destination: dto.destination, + departureDate: dto.departureDate, + containerType: dto.containerType, + mode: dto.mode, + fromCache: false, // TODO: Implement cache detection + responseTimeMs, + }; + } catch (error: any) { + this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack); + throw error; + } + } + + /** + * Search CSV-based rates with advanced filters + */ + @Post('search-csv') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Search CSV-based rates with advanced filters', + description: + 'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'CSV rate search completed successfully', + type: CsvRateSearchResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiBadRequestResponse({ + description: 'Invalid request parameters', + }) + async searchCsvRates( + @Body() dto: CsvRateSearchDto, + @CurrentUser() user: UserPayload + ): Promise { + const startTime = Date.now(); + this.logger.log( + `[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg` + ); + + try { + // Map DTO to domain input + const searchInput = { + origin: dto.origin, + destination: dto.destination, + volumeCBM: dto.volumeCBM, + weightKG: dto.weightKG, + palletCount: dto.palletCount ?? 0, + containerType: dto.containerType, + filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), + }; + + // Execute CSV rate search + const result = await this.csvRateSearchService.execute(searchInput); + + // Map domain output to response DTO + const response = this.csvRateMapper.mapSearchOutputToResponseDto(result); + + const responseTimeMs = Date.now() - startTime; + this.logger.log( + `CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms` + ); + + return response; + } catch (error: any) { + this.logger.error( + `CSV rate search failed: ${error?.message || 'Unknown error'}`, + error?.stack + ); + throw error; + } + } + + /** + * Get available companies + */ + @Get('companies') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get available carrier companies', + description: 'Returns list of all available carrier companies in the CSV rate system.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'List of available companies', + type: AvailableCompaniesDto, + }) + async getCompanies(): Promise { + this.logger.log('Fetching available companies'); + + try { + const companies = await this.csvRateSearchService.getAvailableCompanies(); + + return { + companies, + total: companies.length, + }; + } catch (error: any) { + this.logger.error( + `Failed to fetch companies: ${error?.message || 'Unknown error'}`, + error?.stack + ); + throw error; + } + } + + /** + * Get filter options + */ + @Get('filters/options') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get available filter options', + description: + 'Returns available options for all filters (companies, container types, currencies).', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Available filter options', + type: FilterOptionsDto, + }) + async getFilterOptions(): Promise { + this.logger.log('Fetching filter options'); + + try { + const [companies, containerTypes] = await Promise.all([ + this.csvRateSearchService.getAvailableCompanies(), + this.csvRateSearchService.getAvailableContainerTypes(), + ]); + + return { + companies, + containerTypes, + currencies: ['USD', 'EUR'], + }; + } catch (error: any) { + this.logger.error( + `Failed to fetch filter options: ${error?.message || 'Unknown error'}`, + error?.stack + ); + throw error; + } + } +} diff --git a/apps/backend/src/application/controllers/webhooks.controller.ts b/apps/backend/src/application/controllers/webhooks.controller.ts index c96a882..8e51de6 100644 --- a/apps/backend/src/application/controllers/webhooks.controller.ts +++ b/apps/backend/src/application/controllers/webhooks.controller.ts @@ -16,13 +16,12 @@ import { NotFoundException, ForbiddenException, } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, -} from '@nestjs/swagger'; -import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service'; + WebhookService, + CreateWebhookInput, + UpdateWebhookInput, +} from '../services/webhook.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; import { Roles } from '../decorators/roles.decorator'; @@ -74,7 +73,7 @@ export class WebhooksController { @ApiResponse({ status: 201, description: 'Webhook created successfully' }) async createWebhook( @Body() dto: CreateWebhookDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { const input: CreateWebhookInput = { organizationId: user.organizationId, @@ -96,10 +95,8 @@ export class WebhooksController { @ApiOperation({ summary: 'Get all webhooks for organization' }) @ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' }) async getWebhooks(@CurrentUser() user: UserPayload): Promise { - const webhooks = await this.webhookService.getWebhooksByOrganization( - user.organizationId, - ); - return webhooks.map((w) => this.mapToDto(w)); + const webhooks = await this.webhookService.getWebhooksByOrganization(user.organizationId); + return webhooks.map(w => this.mapToDto(w)); } /** @@ -112,7 +109,7 @@ export class WebhooksController { @ApiResponse({ status: 404, description: 'Webhook not found' }) async getWebhookById( @Param('id') id: string, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { const webhook = await this.webhookService.getWebhookById(id); @@ -139,7 +136,7 @@ export class WebhooksController { async updateWebhook( @Param('id') id: string, @Body() dto: UpdateWebhookDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { const webhook = await this.webhookService.getWebhookById(id); @@ -166,7 +163,7 @@ export class WebhooksController { @ApiResponse({ status: 404, description: 'Webhook not found' }) async activateWebhook( @Param('id') id: string, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise<{ success: boolean }> { const webhook = await this.webhookService.getWebhookById(id); @@ -193,7 +190,7 @@ export class WebhooksController { @ApiResponse({ status: 404, description: 'Webhook not found' }) async deactivateWebhook( @Param('id') id: string, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise<{ success: boolean }> { const webhook = await this.webhookService.getWebhookById(id); @@ -220,7 +217,7 @@ export class WebhooksController { @ApiResponse({ status: 404, description: 'Webhook not found' }) async deleteWebhook( @Param('id') id: string, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise<{ success: boolean }> { const webhook = await this.webhookService.getWebhookById(id); diff --git a/apps/backend/src/application/decorators/current-user.decorator.ts b/apps/backend/src/application/decorators/current-user.decorator.ts index 1df9c1a..713840c 100644 --- a/apps/backend/src/application/decorators/current-user.decorator.ts +++ b/apps/backend/src/application/decorators/current-user.decorator.ts @@ -1,42 +1,42 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; - -/** - * User payload interface extracted from JWT - */ -export interface UserPayload { - id: string; - email: string; - role: string; - organizationId: string; - firstName: string; - lastName: string; -} - -/** - * CurrentUser Decorator - * - * Extracts the authenticated user from the request object. - * Must be used with JwtAuthGuard. - * - * Usage: - * @UseGuards(JwtAuthGuard) - * @Get('me') - * getProfile(@CurrentUser() user: UserPayload) { - * return user; - * } - * - * You can also extract a specific property: - * @Get('my-bookings') - * getMyBookings(@CurrentUser('id') userId: string) { - * return this.bookingService.findByUserId(userId); - * } - */ -export const CurrentUser = createParamDecorator( - (data: keyof UserPayload | undefined, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - const user = request.user; - - // If a specific property is requested, return only that property - return data ? user?.[data] : user; - }, -); +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + * User payload interface extracted from JWT + */ +export interface UserPayload { + id: string; + email: string; + role: string; + organizationId: string; + firstName: string; + lastName: string; +} + +/** + * CurrentUser Decorator + * + * Extracts the authenticated user from the request object. + * Must be used with JwtAuthGuard. + * + * Usage: + * @UseGuards(JwtAuthGuard) + * @Get('me') + * getProfile(@CurrentUser() user: UserPayload) { + * return user; + * } + * + * You can also extract a specific property: + * @Get('my-bookings') + * getMyBookings(@CurrentUser('id') userId: string) { + * return this.bookingService.findByUserId(userId); + * } + */ +export const CurrentUser = createParamDecorator( + (data: keyof UserPayload | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + // If a specific property is requested, return only that property + return data ? user?.[data] : user; + } +); diff --git a/apps/backend/src/application/decorators/index.ts b/apps/backend/src/application/decorators/index.ts index 734f124..76ef1b6 100644 --- a/apps/backend/src/application/decorators/index.ts +++ b/apps/backend/src/application/decorators/index.ts @@ -1,3 +1,3 @@ -export * from './current-user.decorator'; -export * from './public.decorator'; -export * from './roles.decorator'; +export * from './current-user.decorator'; +export * from './public.decorator'; +export * from './roles.decorator'; diff --git a/apps/backend/src/application/decorators/public.decorator.ts b/apps/backend/src/application/decorators/public.decorator.ts index 2d769ea..2b95a3a 100644 --- a/apps/backend/src/application/decorators/public.decorator.ts +++ b/apps/backend/src/application/decorators/public.decorator.ts @@ -1,16 +1,16 @@ -import { SetMetadata } from '@nestjs/common'; - -/** - * Public Decorator - * - * Marks a route as public, bypassing JWT authentication. - * Use this for routes that should be accessible without a token. - * - * Usage: - * @Public() - * @Post('login') - * login(@Body() dto: LoginDto) { - * return this.authService.login(dto.email, dto.password); - * } - */ -export const Public = () => SetMetadata('isPublic', true); +import { SetMetadata } from '@nestjs/common'; + +/** + * Public Decorator + * + * Marks a route as public, bypassing JWT authentication. + * Use this for routes that should be accessible without a token. + * + * Usage: + * @Public() + * @Post('login') + * login(@Body() dto: LoginDto) { + * return this.authService.login(dto.email, dto.password); + * } + */ +export const Public = () => SetMetadata('isPublic', true); diff --git a/apps/backend/src/application/decorators/roles.decorator.ts b/apps/backend/src/application/decorators/roles.decorator.ts index 331570a..32795bf 100644 --- a/apps/backend/src/application/decorators/roles.decorator.ts +++ b/apps/backend/src/application/decorators/roles.decorator.ts @@ -1,23 +1,23 @@ -import { SetMetadata } from '@nestjs/common'; - -/** - * Roles Decorator - * - * Specifies which roles are allowed to access a route. - * Must be used with both JwtAuthGuard and RolesGuard. - * - * Available roles: - * - 'admin': Full system access - * - 'manager': Manage bookings and users within organization - * - 'user': Create and view bookings - * - 'viewer': Read-only access - * - * Usage: - * @UseGuards(JwtAuthGuard, RolesGuard) - * @Roles('admin', 'manager') - * @Delete('bookings/:id') - * deleteBooking(@Param('id') id: string) { - * return this.bookingService.delete(id); - * } - */ -export const Roles = (...roles: string[]) => SetMetadata('roles', roles); +import { SetMetadata } from '@nestjs/common'; + +/** + * Roles Decorator + * + * Specifies which roles are allowed to access a route. + * Must be used with both JwtAuthGuard and RolesGuard. + * + * Available roles: + * - 'admin': Full system access + * - 'manager': Manage bookings and users within organization + * - 'user': Create and view bookings + * - 'viewer': Read-only access + * + * Usage: + * @UseGuards(JwtAuthGuard, RolesGuard) + * @Roles('admin', 'manager') + * @Delete('bookings/:id') + * deleteBooking(@Param('id') id: string) { + * return this.bookingService.delete(id); + * } + */ +export const Roles = (...roles: string[]) => SetMetadata('roles', roles); diff --git a/apps/backend/src/application/dto/auth-login.dto.ts b/apps/backend/src/application/dto/auth-login.dto.ts index 039f017..4c0459c 100644 --- a/apps/backend/src/application/dto/auth-login.dto.ts +++ b/apps/backend/src/application/dto/auth-login.dto.ts @@ -1,106 +1,106 @@ -import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class LoginDto { - @ApiProperty({ - example: 'john.doe@acme.com', - description: 'Email address', - }) - @IsEmail({}, { message: 'Invalid email format' }) - email: string; - - @ApiProperty({ - example: 'SecurePassword123!', - description: 'Password (minimum 12 characters)', - minLength: 12, - }) - @IsString() - @MinLength(12, { message: 'Password must be at least 12 characters' }) - password: string; -} - -export class RegisterDto { - @ApiProperty({ - example: 'john.doe@acme.com', - description: 'Email address', - }) - @IsEmail({}, { message: 'Invalid email format' }) - email: string; - - @ApiProperty({ - example: 'SecurePassword123!', - description: 'Password (minimum 12 characters)', - minLength: 12, - }) - @IsString() - @MinLength(12, { message: 'Password must be at least 12 characters' }) - password: string; - - @ApiProperty({ - example: 'John', - description: 'First name', - }) - @IsString() - @MinLength(2, { message: 'First name must be at least 2 characters' }) - firstName: string; - - @ApiProperty({ - example: 'Doe', - description: 'Last name', - }) - @IsString() - @MinLength(2, { message: 'Last name must be at least 2 characters' }) - lastName: string; - - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'Organization ID (optional, will create default organization if not provided)', - required: false, - }) - @IsString() - @IsOptional() - organizationId?: string; -} - -export class AuthResponseDto { - @ApiProperty({ - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - description: 'JWT access token (valid 15 minutes)', - }) - accessToken: string; - - @ApiProperty({ - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - description: 'JWT refresh token (valid 7 days)', - }) - refreshToken: string; - - @ApiProperty({ - example: { - id: '550e8400-e29b-41d4-a716-446655440000', - email: 'john.doe@acme.com', - firstName: 'John', - lastName: 'Doe', - role: 'user', - organizationId: '550e8400-e29b-41d4-a716-446655440001', - }, - description: 'User information', - }) - user: { - id: string; - email: string; - firstName: string; - lastName: string; - role: string; - organizationId: string; - }; -} - -export class RefreshTokenDto { - @ApiProperty({ - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - description: 'Refresh token', - }) - @IsString() - refreshToken: string; -} +import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginDto { + @ApiProperty({ + example: 'john.doe@acme.com', + description: 'Email address', + }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiProperty({ + example: 'SecurePassword123!', + description: 'Password (minimum 12 characters)', + minLength: 12, + }) + @IsString() + @MinLength(12, { message: 'Password must be at least 12 characters' }) + password: string; +} + +export class RegisterDto { + @ApiProperty({ + example: 'john.doe@acme.com', + description: 'Email address', + }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiProperty({ + example: 'SecurePassword123!', + description: 'Password (minimum 12 characters)', + minLength: 12, + }) + @IsString() + @MinLength(12, { message: 'Password must be at least 12 characters' }) + password: string; + + @ApiProperty({ + example: 'John', + description: 'First name', + }) + @IsString() + @MinLength(2, { message: 'First name must be at least 2 characters' }) + firstName: string; + + @ApiProperty({ + example: 'Doe', + description: 'Last name', + }) + @IsString() + @MinLength(2, { message: 'Last name must be at least 2 characters' }) + lastName: string; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Organization ID (optional, will create default organization if not provided)', + required: false, + }) + @IsString() + @IsOptional() + organizationId?: string; +} + +export class AuthResponseDto { + @ApiProperty({ + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + description: 'JWT access token (valid 15 minutes)', + }) + accessToken: string; + + @ApiProperty({ + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + description: 'JWT refresh token (valid 7 days)', + }) + refreshToken: string; + + @ApiProperty({ + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + email: 'john.doe@acme.com', + firstName: 'John', + lastName: 'Doe', + role: 'user', + organizationId: '550e8400-e29b-41d4-a716-446655440001', + }, + description: 'User information', + }) + user: { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + organizationId: string; + }; +} + +export class RefreshTokenDto { + @ApiProperty({ + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + description: 'Refresh token', + }) + @IsString() + refreshToken: string; +} diff --git a/apps/backend/src/application/dto/booking-response.dto.ts b/apps/backend/src/application/dto/booking-response.dto.ts index 971ac9e..8001962 100644 --- a/apps/backend/src/application/dto/booking-response.dto.ts +++ b/apps/backend/src/application/dto/booking-response.dto.ts @@ -1,184 +1,184 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { PortDto, PricingDto } from './rate-search-response.dto'; - -export class BookingAddressDto { - @ApiProperty({ example: '123 Main Street' }) - street: string; - - @ApiProperty({ example: 'Rotterdam' }) - city: string; - - @ApiProperty({ example: '3000 AB' }) - postalCode: string; - - @ApiProperty({ example: 'NL' }) - country: string; -} - -export class BookingPartyDto { - @ApiProperty({ example: 'Acme Corporation' }) - name: string; - - @ApiProperty({ type: BookingAddressDto }) - address: BookingAddressDto; - - @ApiProperty({ example: 'John Doe' }) - contactName: string; - - @ApiProperty({ example: 'john.doe@acme.com' }) - contactEmail: string; - - @ApiProperty({ example: '+31612345678' }) - contactPhone: string; -} - -export class BookingContainerDto { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id: string; - - @ApiProperty({ example: '40HC' }) - type: string; - - @ApiPropertyOptional({ example: 'ABCU1234567' }) - containerNumber?: string; - - @ApiPropertyOptional({ example: 22000 }) - vgm?: number; - - @ApiPropertyOptional({ example: -18 }) - temperature?: number; - - @ApiPropertyOptional({ example: 'SEAL123456' }) - sealNumber?: string; -} - -export class BookingRateQuoteDto { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id: string; - - @ApiProperty({ example: 'Maersk Line' }) - carrierName: string; - - @ApiProperty({ example: 'MAERSK' }) - carrierCode: string; - - @ApiProperty({ type: PortDto }) - origin: PortDto; - - @ApiProperty({ type: PortDto }) - destination: PortDto; - - @ApiProperty({ type: PricingDto }) - pricing: PricingDto; - - @ApiProperty({ example: '40HC' }) - containerType: string; - - @ApiProperty({ example: 'FCL' }) - mode: string; - - @ApiProperty({ example: '2025-02-15T10:00:00Z' }) - etd: string; - - @ApiProperty({ example: '2025-03-17T14:00:00Z' }) - eta: string; - - @ApiProperty({ example: 30 }) - transitDays: number; -} - -export class BookingResponseDto { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id: string; - - @ApiProperty({ example: 'WCM-2025-ABC123', description: 'Unique booking number' }) - bookingNumber: string; - - @ApiProperty({ - example: 'draft', - enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'], - }) - status: string; - - @ApiProperty({ type: BookingPartyDto }) - shipper: BookingPartyDto; - - @ApiProperty({ type: BookingPartyDto }) - consignee: BookingPartyDto; - - @ApiProperty({ example: 'Electronics and consumer goods' }) - cargoDescription: string; - - @ApiProperty({ type: [BookingContainerDto] }) - containers: BookingContainerDto[]; - - @ApiPropertyOptional({ example: 'Please handle with care. Delivery before 5 PM.' }) - specialInstructions?: string; - - @ApiProperty({ type: BookingRateQuoteDto, description: 'Associated rate quote details' }) - rateQuote: BookingRateQuoteDto; - - @ApiProperty({ example: '2025-02-15T10:00:00Z' }) - createdAt: string; - - @ApiProperty({ example: '2025-02-15T10:00:00Z' }) - updatedAt: string; -} - -export class BookingListItemDto { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id: string; - - @ApiProperty({ example: 'WCM-2025-ABC123' }) - bookingNumber: string; - - @ApiProperty({ example: 'draft' }) - status: string; - - @ApiProperty({ example: 'Acme Corporation' }) - shipperName: string; - - @ApiProperty({ example: 'Shanghai Imports Ltd' }) - consigneeName: string; - - @ApiProperty({ example: 'NLRTM' }) - originPort: string; - - @ApiProperty({ example: 'CNSHA' }) - destinationPort: string; - - @ApiProperty({ example: 'Maersk Line' }) - carrierName: string; - - @ApiProperty({ example: '2025-02-15T10:00:00Z' }) - etd: string; - - @ApiProperty({ example: '2025-03-17T14:00:00Z' }) - eta: string; - - @ApiProperty({ example: 1700.0 }) - totalAmount: number; - - @ApiProperty({ example: 'USD' }) - currency: string; - - @ApiProperty({ example: '2025-02-15T10:00:00Z' }) - createdAt: string; -} - -export class BookingListResponseDto { - @ApiProperty({ type: [BookingListItemDto] }) - bookings: BookingListItemDto[]; - - @ApiProperty({ example: 25, description: 'Total number of bookings' }) - total: number; - - @ApiProperty({ example: 1, description: 'Current page number' }) - page: number; - - @ApiProperty({ example: 20, description: 'Items per page' }) - pageSize: number; - - @ApiProperty({ example: 2, description: 'Total number of pages' }) - totalPages: number; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PortDto, PricingDto } from './rate-search-response.dto'; + +export class BookingAddressDto { + @ApiProperty({ example: '123 Main Street' }) + street: string; + + @ApiProperty({ example: 'Rotterdam' }) + city: string; + + @ApiProperty({ example: '3000 AB' }) + postalCode: string; + + @ApiProperty({ example: 'NL' }) + country: string; +} + +export class BookingPartyDto { + @ApiProperty({ example: 'Acme Corporation' }) + name: string; + + @ApiProperty({ type: BookingAddressDto }) + address: BookingAddressDto; + + @ApiProperty({ example: 'John Doe' }) + contactName: string; + + @ApiProperty({ example: 'john.doe@acme.com' }) + contactEmail: string; + + @ApiProperty({ example: '+31612345678' }) + contactPhone: string; +} + +export class BookingContainerDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ example: '40HC' }) + type: string; + + @ApiPropertyOptional({ example: 'ABCU1234567' }) + containerNumber?: string; + + @ApiPropertyOptional({ example: 22000 }) + vgm?: number; + + @ApiPropertyOptional({ example: -18 }) + temperature?: number; + + @ApiPropertyOptional({ example: 'SEAL123456' }) + sealNumber?: string; +} + +export class BookingRateQuoteDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ example: 'Maersk Line' }) + carrierName: string; + + @ApiProperty({ example: 'MAERSK' }) + carrierCode: string; + + @ApiProperty({ type: PortDto }) + origin: PortDto; + + @ApiProperty({ type: PortDto }) + destination: PortDto; + + @ApiProperty({ type: PricingDto }) + pricing: PricingDto; + + @ApiProperty({ example: '40HC' }) + containerType: string; + + @ApiProperty({ example: 'FCL' }) + mode: string; + + @ApiProperty({ example: '2025-02-15T10:00:00Z' }) + etd: string; + + @ApiProperty({ example: '2025-03-17T14:00:00Z' }) + eta: string; + + @ApiProperty({ example: 30 }) + transitDays: number; +} + +export class BookingResponseDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ example: 'WCM-2025-ABC123', description: 'Unique booking number' }) + bookingNumber: string; + + @ApiProperty({ + example: 'draft', + enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'], + }) + status: string; + + @ApiProperty({ type: BookingPartyDto }) + shipper: BookingPartyDto; + + @ApiProperty({ type: BookingPartyDto }) + consignee: BookingPartyDto; + + @ApiProperty({ example: 'Electronics and consumer goods' }) + cargoDescription: string; + + @ApiProperty({ type: [BookingContainerDto] }) + containers: BookingContainerDto[]; + + @ApiPropertyOptional({ example: 'Please handle with care. Delivery before 5 PM.' }) + specialInstructions?: string; + + @ApiProperty({ type: BookingRateQuoteDto, description: 'Associated rate quote details' }) + rateQuote: BookingRateQuoteDto; + + @ApiProperty({ example: '2025-02-15T10:00:00Z' }) + createdAt: string; + + @ApiProperty({ example: '2025-02-15T10:00:00Z' }) + updatedAt: string; +} + +export class BookingListItemDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ example: 'WCM-2025-ABC123' }) + bookingNumber: string; + + @ApiProperty({ example: 'draft' }) + status: string; + + @ApiProperty({ example: 'Acme Corporation' }) + shipperName: string; + + @ApiProperty({ example: 'Shanghai Imports Ltd' }) + consigneeName: string; + + @ApiProperty({ example: 'NLRTM' }) + originPort: string; + + @ApiProperty({ example: 'CNSHA' }) + destinationPort: string; + + @ApiProperty({ example: 'Maersk Line' }) + carrierName: string; + + @ApiProperty({ example: '2025-02-15T10:00:00Z' }) + etd: string; + + @ApiProperty({ example: '2025-03-17T14:00:00Z' }) + eta: string; + + @ApiProperty({ example: 1700.0 }) + totalAmount: number; + + @ApiProperty({ example: 'USD' }) + currency: string; + + @ApiProperty({ example: '2025-02-15T10:00:00Z' }) + createdAt: string; +} + +export class BookingListResponseDto { + @ApiProperty({ type: [BookingListItemDto] }) + bookings: BookingListItemDto[]; + + @ApiProperty({ example: 25, description: 'Total number of bookings' }) + total: number; + + @ApiProperty({ example: 1, description: 'Current page number' }) + page: number; + + @ApiProperty({ example: 20, description: 'Items per page' }) + pageSize: number; + + @ApiProperty({ example: 2, description: 'Total number of pages' }) + totalPages: number; +} diff --git a/apps/backend/src/application/dto/create-booking-request.dto.ts b/apps/backend/src/application/dto/create-booking-request.dto.ts index 307b583..169c73b 100644 --- a/apps/backend/src/application/dto/create-booking-request.dto.ts +++ b/apps/backend/src/application/dto/create-booking-request.dto.ts @@ -1,119 +1,135 @@ -import { IsString, IsUUID, IsOptional, ValidateNested, IsArray, IsEmail, Matches, MinLength } from 'class-validator'; -import { Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class AddressDto { - @ApiProperty({ example: '123 Main Street' }) - @IsString() - @MinLength(5, { message: 'Street must be at least 5 characters' }) - street: string; - - @ApiProperty({ example: 'Rotterdam' }) - @IsString() - @MinLength(2, { message: 'City must be at least 2 characters' }) - city: string; - - @ApiProperty({ example: '3000 AB' }) - @IsString() - postalCode: string; - - @ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' }) - @IsString() - @Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' }) - country: string; -} - -export class PartyDto { - @ApiProperty({ example: 'Acme Corporation' }) - @IsString() - @MinLength(2, { message: 'Name must be at least 2 characters' }) - name: string; - - @ApiProperty({ type: AddressDto }) - @ValidateNested() - @Type(() => AddressDto) - address: AddressDto; - - @ApiProperty({ example: 'John Doe' }) - @IsString() - @MinLength(2, { message: 'Contact name must be at least 2 characters' }) - contactName: string; - - @ApiProperty({ example: 'john.doe@acme.com' }) - @IsEmail({}, { message: 'Contact email must be a valid email address' }) - contactEmail: string; - - @ApiProperty({ example: '+31612345678' }) - @IsString() - @Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Contact phone must be a valid international phone number' }) - contactPhone: string; -} - -export class ContainerDto { - @ApiProperty({ example: '40HC', description: 'Container type' }) - @IsString() - type: string; - - @ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' }) - @IsOptional() - @IsString() - @Matches(/^[A-Z]{4}\d{7}$/, { message: 'Container number must be 4 letters followed by 7 digits' }) - containerNumber?: string; - - @ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' }) - @IsOptional() - vgm?: number; - - @ApiPropertyOptional({ example: -18, description: 'Temperature in Celsius (for reefer containers)' }) - @IsOptional() - temperature?: number; - - @ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' }) - @IsOptional() - @IsString() - sealNumber?: string; -} - -export class CreateBookingRequestDto { - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'Rate quote ID from previous search' - }) - @IsUUID(4, { message: 'Rate quote ID must be a valid UUID' }) - rateQuoteId: string; - - @ApiProperty({ type: PartyDto, description: 'Shipper details' }) - @ValidateNested() - @Type(() => PartyDto) - shipper: PartyDto; - - @ApiProperty({ type: PartyDto, description: 'Consignee details' }) - @ValidateNested() - @Type(() => PartyDto) - consignee: PartyDto; - - @ApiProperty({ - example: 'Electronics and consumer goods', - description: 'Cargo description' - }) - @IsString() - @MinLength(10, { message: 'Cargo description must be at least 10 characters' }) - cargoDescription: string; - - @ApiProperty({ - type: [ContainerDto], - description: 'Container details (can be empty for initial booking)' - }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => ContainerDto) - containers: ContainerDto[]; - - @ApiPropertyOptional({ - example: 'Please handle with care. Delivery before 5 PM.', - description: 'Special instructions for the carrier' - }) - @IsOptional() - @IsString() - specialInstructions?: string; -} +import { + IsString, + IsUUID, + IsOptional, + ValidateNested, + IsArray, + IsEmail, + Matches, + MinLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AddressDto { + @ApiProperty({ example: '123 Main Street' }) + @IsString() + @MinLength(5, { message: 'Street must be at least 5 characters' }) + street: string; + + @ApiProperty({ example: 'Rotterdam' }) + @IsString() + @MinLength(2, { message: 'City must be at least 2 characters' }) + city: string; + + @ApiProperty({ example: '3000 AB' }) + @IsString() + postalCode: string; + + @ApiProperty({ example: 'NL', description: 'ISO 3166-1 alpha-2 country code' }) + @IsString() + @Matches(/^[A-Z]{2}$/, { message: 'Country must be a valid 2-letter ISO country code' }) + country: string; +} + +export class PartyDto { + @ApiProperty({ example: 'Acme Corporation' }) + @IsString() + @MinLength(2, { message: 'Name must be at least 2 characters' }) + name: string; + + @ApiProperty({ type: AddressDto }) + @ValidateNested() + @Type(() => AddressDto) + address: AddressDto; + + @ApiProperty({ example: 'John Doe' }) + @IsString() + @MinLength(2, { message: 'Contact name must be at least 2 characters' }) + contactName: string; + + @ApiProperty({ example: 'john.doe@acme.com' }) + @IsEmail({}, { message: 'Contact email must be a valid email address' }) + contactEmail: string; + + @ApiProperty({ example: '+31612345678' }) + @IsString() + @Matches(/^\+?[1-9]\d{1,14}$/, { + message: 'Contact phone must be a valid international phone number', + }) + contactPhone: string; +} + +export class ContainerDto { + @ApiProperty({ example: '40HC', description: 'Container type' }) + @IsString() + type: string; + + @ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' }) + @IsOptional() + @IsString() + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'Container number must be 4 letters followed by 7 digits', + }) + containerNumber?: string; + + @ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' }) + @IsOptional() + vgm?: number; + + @ApiPropertyOptional({ + example: -18, + description: 'Temperature in Celsius (for reefer containers)', + }) + @IsOptional() + temperature?: number; + + @ApiPropertyOptional({ example: 'SEAL123456', description: 'Seal number' }) + @IsOptional() + @IsString() + sealNumber?: string; +} + +export class CreateBookingRequestDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Rate quote ID from previous search', + }) + @IsUUID(4, { message: 'Rate quote ID must be a valid UUID' }) + rateQuoteId: string; + + @ApiProperty({ type: PartyDto, description: 'Shipper details' }) + @ValidateNested() + @Type(() => PartyDto) + shipper: PartyDto; + + @ApiProperty({ type: PartyDto, description: 'Consignee details' }) + @ValidateNested() + @Type(() => PartyDto) + consignee: PartyDto; + + @ApiProperty({ + example: 'Electronics and consumer goods', + description: 'Cargo description', + }) + @IsString() + @MinLength(10, { message: 'Cargo description must be at least 10 characters' }) + cargoDescription: string; + + @ApiProperty({ + type: [ContainerDto], + description: 'Container details (can be empty for initial booking)', + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContainerDto) + containers: ContainerDto[]; + + @ApiPropertyOptional({ + example: 'Please handle with care. Delivery before 5 PM.', + description: 'Special instructions for the carrier', + }) + @IsOptional() + @IsString() + specialInstructions?: string; +} diff --git a/apps/backend/src/application/dto/csv-rate-search.dto.ts b/apps/backend/src/application/dto/csv-rate-search.dto.ts index 5827662..a7b8a37 100644 --- a/apps/backend/src/application/dto/csv-rate-search.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-search.dto.ts @@ -1,211 +1,204 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsNotEmpty, - IsString, - IsNumber, - Min, - IsOptional, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { RateSearchFiltersDto } from './rate-search-filters.dto'; - -/** - * CSV Rate Search Request DTO - * - * Request body for searching rates in CSV-based system - * Includes basic search parameters + optional advanced filters - */ -export class CsvRateSearchDto { - @ApiProperty({ - description: 'Origin port code (UN/LOCODE format)', - example: 'NLRTM', - pattern: '^[A-Z]{2}[A-Z0-9]{3}$', - }) - @IsNotEmpty() - @IsString() - origin: string; - - @ApiProperty({ - description: 'Destination port code (UN/LOCODE format)', - example: 'USNYC', - pattern: '^[A-Z]{2}[A-Z0-9]{3}$', - }) - @IsNotEmpty() - @IsString() - destination: string; - - @ApiProperty({ - description: 'Volume in cubic meters (CBM)', - minimum: 0.01, - example: 25.5, - }) - @IsNotEmpty() - @IsNumber() - @Min(0.01) - volumeCBM: number; - - @ApiProperty({ - description: 'Weight in kilograms', - minimum: 1, - example: 3500, - }) - @IsNotEmpty() - @IsNumber() - @Min(1) - weightKG: number; - - @ApiPropertyOptional({ - description: 'Number of pallets (0 if no pallets)', - minimum: 0, - example: 10, - default: 0, - }) - @IsOptional() - @IsNumber() - @Min(0) - palletCount?: number; - - @ApiPropertyOptional({ - description: 'Container type filter (e.g., LCL, 20DRY, 40HC)', - example: 'LCL', - }) - @IsOptional() - @IsString() - containerType?: string; - - @ApiPropertyOptional({ - description: 'Advanced filters for narrowing results', - type: RateSearchFiltersDto, - }) - @IsOptional() - @ValidateNested() - @Type(() => RateSearchFiltersDto) - filters?: RateSearchFiltersDto; -} - -/** - * CSV Rate Search Response DTO - * - * Response containing matching rates with calculated prices - */ -export class CsvRateSearchResponseDto { - @ApiProperty({ - description: 'Array of matching rate results', - type: [Object], // Will be replaced with RateResultDto - }) - results: CsvRateResultDto[]; - - @ApiProperty({ - description: 'Total number of results found', - example: 15, - }) - totalResults: number; - - @ApiProperty({ - description: 'CSV files that were searched', - type: [String], - example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'], - }) - searchedFiles: string[]; - - @ApiProperty({ - description: 'Timestamp when search was executed', - example: '2025-10-23T10:30:00Z', - }) - searchedAt: Date; - - @ApiProperty({ - description: 'Filters that were applied to the search', - type: RateSearchFiltersDto, - }) - appliedFilters: RateSearchFiltersDto; -} - -/** - * Single CSV Rate Result DTO - */ -export class CsvRateResultDto { - @ApiProperty({ - description: 'Company name', - example: 'SSC Consolidation', - }) - companyName: string; - - @ApiProperty({ - description: 'Origin port code', - example: 'NLRTM', - }) - origin: string; - - @ApiProperty({ - description: 'Destination port code', - example: 'USNYC', - }) - destination: string; - - @ApiProperty({ - description: 'Container type', - example: 'LCL', - }) - containerType: string; - - @ApiProperty({ - description: 'Calculated price in USD', - example: 1850.50, - }) - priceUSD: number; - - @ApiProperty({ - description: 'Calculated price in EUR', - example: 1665.45, - }) - priceEUR: number; - - @ApiProperty({ - description: 'Primary currency of the rate', - enum: ['USD', 'EUR'], - example: 'USD', - }) - primaryCurrency: string; - - @ApiProperty({ - description: 'Whether this rate has separate surcharges', - example: true, - }) - hasSurcharges: boolean; - - @ApiProperty({ - description: 'Details of surcharges if any', - example: 'BAF+CAF included', - nullable: true, - }) - surchargeDetails: string | null; - - @ApiProperty({ - description: 'Transit time in days', - example: 28, - }) - transitDays: number; - - @ApiProperty({ - description: 'Rate validity end date', - example: '2025-12-31', - }) - validUntil: string; - - @ApiProperty({ - description: 'Source of the rate', - enum: ['CSV', 'API'], - example: 'CSV', - }) - source: 'CSV' | 'API'; - - @ApiProperty({ - description: 'Match score (0-100) indicating how well this rate matches the search', - minimum: 0, - maximum: 100, - example: 95, - }) - matchScore: number; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { RateSearchFiltersDto } from './rate-search-filters.dto'; + +/** + * CSV Rate Search Request DTO + * + * Request body for searching rates in CSV-based system + * Includes basic search parameters + optional advanced filters + */ +export class CsvRateSearchDto { + @ApiProperty({ + description: 'Origin port code (UN/LOCODE format)', + example: 'NLRTM', + pattern: '^[A-Z]{2}[A-Z0-9]{3}$', + }) + @IsNotEmpty() + @IsString() + origin: string; + + @ApiProperty({ + description: 'Destination port code (UN/LOCODE format)', + example: 'USNYC', + pattern: '^[A-Z]{2}[A-Z0-9]{3}$', + }) + @IsNotEmpty() + @IsString() + destination: string; + + @ApiProperty({ + description: 'Volume in cubic meters (CBM)', + minimum: 0.01, + example: 25.5, + }) + @IsNotEmpty() + @IsNumber() + @Min(0.01) + volumeCBM: number; + + @ApiProperty({ + description: 'Weight in kilograms', + minimum: 1, + example: 3500, + }) + @IsNotEmpty() + @IsNumber() + @Min(1) + weightKG: number; + + @ApiPropertyOptional({ + description: 'Number of pallets (0 if no pallets)', + minimum: 0, + example: 10, + default: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + palletCount?: number; + + @ApiPropertyOptional({ + description: 'Container type filter (e.g., LCL, 20DRY, 40HC)', + example: 'LCL', + }) + @IsOptional() + @IsString() + containerType?: string; + + @ApiPropertyOptional({ + description: 'Advanced filters for narrowing results', + type: RateSearchFiltersDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => RateSearchFiltersDto) + filters?: RateSearchFiltersDto; +} + +/** + * CSV Rate Search Response DTO + * + * Response containing matching rates with calculated prices + */ +export class CsvRateSearchResponseDto { + @ApiProperty({ + description: 'Array of matching rate results', + type: [Object], // Will be replaced with RateResultDto + }) + results: CsvRateResultDto[]; + + @ApiProperty({ + description: 'Total number of results found', + example: 15, + }) + totalResults: number; + + @ApiProperty({ + description: 'CSV files that were searched', + type: [String], + example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'], + }) + searchedFiles: string[]; + + @ApiProperty({ + description: 'Timestamp when search was executed', + example: '2025-10-23T10:30:00Z', + }) + searchedAt: Date; + + @ApiProperty({ + description: 'Filters that were applied to the search', + type: RateSearchFiltersDto, + }) + appliedFilters: RateSearchFiltersDto; +} + +/** + * Single CSV Rate Result DTO + */ +export class CsvRateResultDto { + @ApiProperty({ + description: 'Company name', + example: 'SSC Consolidation', + }) + companyName: string; + + @ApiProperty({ + description: 'Origin port code', + example: 'NLRTM', + }) + origin: string; + + @ApiProperty({ + description: 'Destination port code', + example: 'USNYC', + }) + destination: string; + + @ApiProperty({ + description: 'Container type', + example: 'LCL', + }) + containerType: string; + + @ApiProperty({ + description: 'Calculated price in USD', + example: 1850.5, + }) + priceUSD: number; + + @ApiProperty({ + description: 'Calculated price in EUR', + example: 1665.45, + }) + priceEUR: number; + + @ApiProperty({ + description: 'Primary currency of the rate', + enum: ['USD', 'EUR'], + example: 'USD', + }) + primaryCurrency: string; + + @ApiProperty({ + description: 'Whether this rate has separate surcharges', + example: true, + }) + hasSurcharges: boolean; + + @ApiProperty({ + description: 'Details of surcharges if any', + example: 'BAF+CAF included', + nullable: true, + }) + surchargeDetails: string | null; + + @ApiProperty({ + description: 'Transit time in days', + example: 28, + }) + transitDays: number; + + @ApiProperty({ + description: 'Rate validity end date', + example: '2025-12-31', + }) + validUntil: string; + + @ApiProperty({ + description: 'Source of the rate', + enum: ['CSV', 'API'], + example: 'CSV', + }) + source: 'CSV' | 'API'; + + @ApiProperty({ + description: 'Match score (0-100) indicating how well this rate matches the search', + minimum: 0, + maximum: 100, + example: 95, + }) + matchScore: number; +} diff --git a/apps/backend/src/application/dto/csv-rate-upload.dto.ts b/apps/backend/src/application/dto/csv-rate-upload.dto.ts index 242958b..cd38dba 100644 --- a/apps/backend/src/application/dto/csv-rate-upload.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-upload.dto.ts @@ -1,201 +1,201 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; - -/** - * CSV Rate Upload DTO - * - * Request DTO for uploading CSV rate files (ADMIN only) - */ -export class CsvRateUploadDto { - @ApiProperty({ - description: 'Name of the carrier company', - example: 'SSC Consolidation', - maxLength: 255, - }) - @IsNotEmpty() - @IsString() - @MaxLength(255) - companyName: string; - - @ApiProperty({ - description: 'CSV file containing shipping rates', - type: 'string', - format: 'binary', - }) - file: any; // Will be handled by multer -} - -/** - * CSV Rate Upload Response DTO - */ -export class CsvRateUploadResponseDto { - @ApiProperty({ - description: 'Upload success status', - example: true, - }) - success: boolean; - - @ApiProperty({ - description: 'Number of rate rows parsed from CSV', - example: 25, - }) - ratesCount: number; - - @ApiProperty({ - description: 'Path where CSV file was saved', - example: 'ssc-consolidation.csv', - }) - csvFilePath: string; - - @ApiProperty({ - description: 'Company name for which rates were uploaded', - example: 'SSC Consolidation', - }) - companyName: string; - - @ApiProperty({ - description: 'Upload timestamp', - example: '2025-10-23T10:30:00Z', - }) - uploadedAt: Date; -} - -/** - * CSV Rate Config Response DTO - * - * Configuration entry for a company's CSV rates - */ -export class CsvRateConfigDto { - @ApiProperty({ - description: 'Configuration ID', - example: '123e4567-e89b-12d3-a456-426614174000', - }) - id: string; - - @ApiProperty({ - description: 'Company name', - example: 'SSC Consolidation', - }) - companyName: string; - - @ApiProperty({ - description: 'CSV file path', - example: 'ssc-consolidation.csv', - }) - csvFilePath: string; - - @ApiProperty({ - description: 'Integration type', - enum: ['CSV_ONLY', 'CSV_AND_API'], - example: 'CSV_ONLY', - }) - type: 'CSV_ONLY' | 'CSV_AND_API'; - - @ApiProperty({ - description: 'Whether company has API connector', - example: false, - }) - hasApi: boolean; - - @ApiProperty({ - description: 'API connector name if hasApi is true', - example: null, - nullable: true, - }) - apiConnector: string | null; - - @ApiProperty({ - description: 'Whether configuration is active', - example: true, - }) - isActive: boolean; - - @ApiProperty({ - description: 'When CSV was last uploaded', - example: '2025-10-23T10:30:00Z', - }) - uploadedAt: Date; - - @ApiProperty({ - description: 'Number of rate rows in CSV', - example: 25, - nullable: true, - }) - rowCount: number | null; - - @ApiProperty({ - description: 'Additional metadata', - example: { description: 'LCL rates for Europe to US', coverage: 'Global' }, - nullable: true, - }) - metadata: Record | null; -} - -/** - * CSV File Validation Result DTO - */ -export class CsvFileValidationDto { - @ApiProperty({ - description: 'Whether CSV file is valid', - example: true, - }) - valid: boolean; - - @ApiProperty({ - description: 'Validation errors if any', - type: [String], - example: [], - }) - errors: string[]; - - @ApiProperty({ - description: 'Number of rows in CSV file', - example: 25, - required: false, - }) - rowCount?: number; -} - -/** - * Available Companies Response DTO - */ -export class AvailableCompaniesDto { - @ApiProperty({ - description: 'List of available company names', - type: [String], - example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'], - }) - companies: string[]; - - @ApiProperty({ - description: 'Total number of companies', - example: 4, - }) - total: number; -} - -/** - * Filter Options Response DTO - */ -export class FilterOptionsDto { - @ApiProperty({ - description: 'Available company names', - type: [String], - example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'], - }) - companies: string[]; - - @ApiProperty({ - description: 'Available container types', - type: [String], - example: ['LCL', '20DRY', '40HC', '40DRY'], - }) - containerTypes: string[]; - - @ApiProperty({ - description: 'Supported currencies', - type: [String], - example: ['USD', 'EUR'], - }) - currencies: string[]; -} +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** + * CSV Rate Upload DTO + * + * Request DTO for uploading CSV rate files (ADMIN only) + */ +export class CsvRateUploadDto { + @ApiProperty({ + description: 'Name of the carrier company', + example: 'SSC Consolidation', + maxLength: 255, + }) + @IsNotEmpty() + @IsString() + @MaxLength(255) + companyName: string; + + @ApiProperty({ + description: 'CSV file containing shipping rates', + type: 'string', + format: 'binary', + }) + file: any; // Will be handled by multer +} + +/** + * CSV Rate Upload Response DTO + */ +export class CsvRateUploadResponseDto { + @ApiProperty({ + description: 'Upload success status', + example: true, + }) + success: boolean; + + @ApiProperty({ + description: 'Number of rate rows parsed from CSV', + example: 25, + }) + ratesCount: number; + + @ApiProperty({ + description: 'Path where CSV file was saved', + example: 'ssc-consolidation.csv', + }) + csvFilePath: string; + + @ApiProperty({ + description: 'Company name for which rates were uploaded', + example: 'SSC Consolidation', + }) + companyName: string; + + @ApiProperty({ + description: 'Upload timestamp', + example: '2025-10-23T10:30:00Z', + }) + uploadedAt: Date; +} + +/** + * CSV Rate Config Response DTO + * + * Configuration entry for a company's CSV rates + */ +export class CsvRateConfigDto { + @ApiProperty({ + description: 'Configuration ID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + id: string; + + @ApiProperty({ + description: 'Company name', + example: 'SSC Consolidation', + }) + companyName: string; + + @ApiProperty({ + description: 'CSV file path', + example: 'ssc-consolidation.csv', + }) + csvFilePath: string; + + @ApiProperty({ + description: 'Integration type', + enum: ['CSV_ONLY', 'CSV_AND_API'], + example: 'CSV_ONLY', + }) + type: 'CSV_ONLY' | 'CSV_AND_API'; + + @ApiProperty({ + description: 'Whether company has API connector', + example: false, + }) + hasApi: boolean; + + @ApiProperty({ + description: 'API connector name if hasApi is true', + example: null, + nullable: true, + }) + apiConnector: string | null; + + @ApiProperty({ + description: 'Whether configuration is active', + example: true, + }) + isActive: boolean; + + @ApiProperty({ + description: 'When CSV was last uploaded', + example: '2025-10-23T10:30:00Z', + }) + uploadedAt: Date; + + @ApiProperty({ + description: 'Number of rate rows in CSV', + example: 25, + nullable: true, + }) + rowCount: number | null; + + @ApiProperty({ + description: 'Additional metadata', + example: { description: 'LCL rates for Europe to US', coverage: 'Global' }, + nullable: true, + }) + metadata: Record | null; +} + +/** + * CSV File Validation Result DTO + */ +export class CsvFileValidationDto { + @ApiProperty({ + description: 'Whether CSV file is valid', + example: true, + }) + valid: boolean; + + @ApiProperty({ + description: 'Validation errors if any', + type: [String], + example: [], + }) + errors: string[]; + + @ApiProperty({ + description: 'Number of rows in CSV file', + example: 25, + required: false, + }) + rowCount?: number; +} + +/** + * Available Companies Response DTO + */ +export class AvailableCompaniesDto { + @ApiProperty({ + description: 'List of available company names', + type: [String], + example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'], + }) + companies: string[]; + + @ApiProperty({ + description: 'Total number of companies', + example: 4, + }) + total: number; +} + +/** + * Filter Options Response DTO + */ +export class FilterOptionsDto { + @ApiProperty({ + description: 'Available company names', + type: [String], + example: ['SSC Consolidation', 'ECU Worldwide', 'TCC Logistics', 'NVO Consolidation'], + }) + companies: string[]; + + @ApiProperty({ + description: 'Available container types', + type: [String], + example: ['LCL', '20DRY', '40HC', '40DRY'], + }) + containerTypes: string[]; + + @ApiProperty({ + description: 'Supported currencies', + type: [String], + example: ['USD', 'EUR'], + }) + currencies: string[]; +} diff --git a/apps/backend/src/application/dto/index.ts b/apps/backend/src/application/dto/index.ts index c8b196a..5340fdf 100644 --- a/apps/backend/src/application/dto/index.ts +++ b/apps/backend/src/application/dto/index.ts @@ -1,9 +1,9 @@ -// Rate Search DTOs -export * from './rate-search-request.dto'; -export * from './rate-search-response.dto'; - -// Booking DTOs -export * from './create-booking-request.dto'; -export * from './booking-response.dto'; -export * from './booking-filter.dto'; -export * from './booking-export.dto'; +// Rate Search DTOs +export * from './rate-search-request.dto'; +export * from './rate-search-response.dto'; + +// Booking DTOs +export * from './create-booking-request.dto'; +export * from './booking-response.dto'; +export * from './booking-filter.dto'; +export * from './booking-export.dto'; diff --git a/apps/backend/src/application/dto/organization.dto.ts b/apps/backend/src/application/dto/organization.dto.ts index bc19618..5f5c450 100644 --- a/apps/backend/src/application/dto/organization.dto.ts +++ b/apps/backend/src/application/dto/organization.dto.ts @@ -1,301 +1,301 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsString, - IsEnum, - IsNotEmpty, - MinLength, - MaxLength, - IsOptional, - IsUrl, - IsBoolean, - ValidateNested, - Matches, - IsUUID, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { OrganizationType } from '../../domain/entities/organization.entity'; - -/** - * Address DTO - */ -export class AddressDto { - @ApiProperty({ - example: '123 Main Street', - description: 'Street address', - }) - @IsString() - @IsNotEmpty() - street: string; - - @ApiProperty({ - example: 'Rotterdam', - description: 'City', - }) - @IsString() - @IsNotEmpty() - city: string; - - @ApiPropertyOptional({ - example: 'South Holland', - description: 'State or province', - }) - @IsString() - @IsOptional() - state?: string; - - @ApiProperty({ - example: '3000 AB', - description: 'Postal code', - }) - @IsString() - @IsNotEmpty() - postalCode: string; - - @ApiProperty({ - example: 'NL', - description: 'Country code (ISO 3166-1 alpha-2)', - minLength: 2, - maxLength: 2, - }) - @IsString() - @MinLength(2) - @MaxLength(2) - @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' }) - country: string; -} - -/** - * Create Organization DTO - */ -export class CreateOrganizationDto { - @ApiProperty({ - example: 'Acme Freight Forwarding', - description: 'Organization name', - minLength: 2, - maxLength: 200, - }) - @IsString() - @IsNotEmpty() - @MinLength(2) - @MaxLength(200) - name: string; - - @ApiProperty({ - example: OrganizationType.FREIGHT_FORWARDER, - description: 'Organization type', - enum: OrganizationType, - }) - @IsEnum(OrganizationType) - type: OrganizationType; - - @ApiPropertyOptional({ - example: 'MAEU', - description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', - minLength: 4, - maxLength: 4, - }) - @IsString() - @IsOptional() - @MinLength(4) - @MaxLength(4) - @Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters (e.g., MAEU, MSCU)' }) - scac?: string; - - @ApiProperty({ - description: 'Organization address', - type: AddressDto, - }) - @ValidateNested() - @Type(() => AddressDto) - address: AddressDto; - - @ApiPropertyOptional({ - example: 'https://example.com/logo.png', - description: 'Logo URL', - }) - @IsUrl() - @IsOptional() - logoUrl?: string; -} - -/** - * Update Organization DTO - */ -export class UpdateOrganizationDto { - @ApiPropertyOptional({ - example: 'Acme Freight Forwarding Inc.', - description: 'Organization name', - minLength: 2, - maxLength: 200, - }) - @IsString() - @IsOptional() - @MinLength(2) - @MaxLength(200) - name?: string; - - @ApiPropertyOptional({ - description: 'Organization address', - type: AddressDto, - }) - @ValidateNested() - @Type(() => AddressDto) - @IsOptional() - address?: AddressDto; - - @ApiPropertyOptional({ - example: 'https://example.com/logo.png', - description: 'Logo URL', - }) - @IsUrl() - @IsOptional() - logoUrl?: string; - - @ApiPropertyOptional({ - example: true, - description: 'Active status', - }) - @IsBoolean() - @IsOptional() - isActive?: boolean; -} - -/** - * Organization Document DTO - */ -export class OrganizationDocumentDto { - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'Document ID', - }) - @IsUUID() - id: string; - - @ApiProperty({ - example: 'business_license', - description: 'Document type', - }) - @IsString() - type: string; - - @ApiProperty({ - example: 'Business License 2025', - description: 'Document name', - }) - @IsString() - name: string; - - @ApiProperty({ - example: 'https://s3.amazonaws.com/xpeditis/documents/doc123.pdf', - description: 'Document URL', - }) - @IsUrl() - url: string; - - @ApiProperty({ - example: '2025-01-15T10:00:00Z', - description: 'Upload timestamp', - }) - uploadedAt: Date; -} - -/** - * Organization Response DTO - */ -export class OrganizationResponseDto { - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'Organization ID', - }) - id: string; - - @ApiProperty({ - example: 'Acme Freight Forwarding', - description: 'Organization name', - }) - name: string; - - @ApiProperty({ - example: OrganizationType.FREIGHT_FORWARDER, - description: 'Organization type', - enum: OrganizationType, - }) - type: OrganizationType; - - @ApiPropertyOptional({ - example: 'MAEU', - description: 'Standard Carrier Alpha Code (carriers only)', - }) - scac?: string; - - @ApiProperty({ - description: 'Organization address', - type: AddressDto, - }) - address: AddressDto; - - @ApiPropertyOptional({ - example: 'https://example.com/logo.png', - description: 'Logo URL', - }) - logoUrl?: string; - - @ApiProperty({ - description: 'Organization documents', - type: [OrganizationDocumentDto], - }) - documents: OrganizationDocumentDto[]; - - @ApiProperty({ - example: true, - description: 'Active status', - }) - isActive: boolean; - - @ApiProperty({ - example: '2025-01-01T00:00:00Z', - description: 'Creation timestamp', - }) - createdAt: Date; - - @ApiProperty({ - example: '2025-01-15T10:00:00Z', - description: 'Last update timestamp', - }) - updatedAt: Date; -} - -/** - * Organization List Response DTO - */ -export class OrganizationListResponseDto { - @ApiProperty({ - description: 'List of organizations', - type: [OrganizationResponseDto], - }) - organizations: OrganizationResponseDto[]; - - @ApiProperty({ - example: 25, - description: 'Total number of organizations', - }) - total: number; - - @ApiProperty({ - example: 1, - description: 'Current page number', - }) - page: number; - - @ApiProperty({ - example: 20, - description: 'Page size', - }) - pageSize: number; - - @ApiProperty({ - example: 2, - description: 'Total number of pages', - }) - totalPages: number; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsEnum, + IsNotEmpty, + MinLength, + MaxLength, + IsOptional, + IsUrl, + IsBoolean, + ValidateNested, + Matches, + IsUUID, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { OrganizationType } from '../../domain/entities/organization.entity'; + +/** + * Address DTO + */ +export class AddressDto { + @ApiProperty({ + example: '123 Main Street', + description: 'Street address', + }) + @IsString() + @IsNotEmpty() + street: string; + + @ApiProperty({ + example: 'Rotterdam', + description: 'City', + }) + @IsString() + @IsNotEmpty() + city: string; + + @ApiPropertyOptional({ + example: 'South Holland', + description: 'State or province', + }) + @IsString() + @IsOptional() + state?: string; + + @ApiProperty({ + example: '3000 AB', + description: 'Postal code', + }) + @IsString() + @IsNotEmpty() + postalCode: string; + + @ApiProperty({ + example: 'NL', + description: 'Country code (ISO 3166-1 alpha-2)', + minLength: 2, + maxLength: 2, + }) + @IsString() + @MinLength(2) + @MaxLength(2) + @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' }) + country: string; +} + +/** + * Create Organization DTO + */ +export class CreateOrganizationDto { + @ApiProperty({ + example: 'Acme Freight Forwarding', + description: 'Organization name', + minLength: 2, + maxLength: 200, + }) + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(200) + name: string; + + @ApiProperty({ + example: OrganizationType.FREIGHT_FORWARDER, + description: 'Organization type', + enum: OrganizationType, + }) + @IsEnum(OrganizationType) + type: OrganizationType; + + @ApiPropertyOptional({ + example: 'MAEU', + description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', + minLength: 4, + maxLength: 4, + }) + @IsString() + @IsOptional() + @MinLength(4) + @MaxLength(4) + @Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters (e.g., MAEU, MSCU)' }) + scac?: string; + + @ApiProperty({ + description: 'Organization address', + type: AddressDto, + }) + @ValidateNested() + @Type(() => AddressDto) + address: AddressDto; + + @ApiPropertyOptional({ + example: 'https://example.com/logo.png', + description: 'Logo URL', + }) + @IsUrl() + @IsOptional() + logoUrl?: string; +} + +/** + * Update Organization DTO + */ +export class UpdateOrganizationDto { + @ApiPropertyOptional({ + example: 'Acme Freight Forwarding Inc.', + description: 'Organization name', + minLength: 2, + maxLength: 200, + }) + @IsString() + @IsOptional() + @MinLength(2) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ + description: 'Organization address', + type: AddressDto, + }) + @ValidateNested() + @Type(() => AddressDto) + @IsOptional() + address?: AddressDto; + + @ApiPropertyOptional({ + example: 'https://example.com/logo.png', + description: 'Logo URL', + }) + @IsUrl() + @IsOptional() + logoUrl?: string; + + @ApiPropertyOptional({ + example: true, + description: 'Active status', + }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +/** + * Organization Document DTO + */ +export class OrganizationDocumentDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Document ID', + }) + @IsUUID() + id: string; + + @ApiProperty({ + example: 'business_license', + description: 'Document type', + }) + @IsString() + type: string; + + @ApiProperty({ + example: 'Business License 2025', + description: 'Document name', + }) + @IsString() + name: string; + + @ApiProperty({ + example: 'https://s3.amazonaws.com/xpeditis/documents/doc123.pdf', + description: 'Document URL', + }) + @IsUrl() + url: string; + + @ApiProperty({ + example: '2025-01-15T10:00:00Z', + description: 'Upload timestamp', + }) + uploadedAt: Date; +} + +/** + * Organization Response DTO + */ +export class OrganizationResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Organization ID', + }) + id: string; + + @ApiProperty({ + example: 'Acme Freight Forwarding', + description: 'Organization name', + }) + name: string; + + @ApiProperty({ + example: OrganizationType.FREIGHT_FORWARDER, + description: 'Organization type', + enum: OrganizationType, + }) + type: OrganizationType; + + @ApiPropertyOptional({ + example: 'MAEU', + description: 'Standard Carrier Alpha Code (carriers only)', + }) + scac?: string; + + @ApiProperty({ + description: 'Organization address', + type: AddressDto, + }) + address: AddressDto; + + @ApiPropertyOptional({ + example: 'https://example.com/logo.png', + description: 'Logo URL', + }) + logoUrl?: string; + + @ApiProperty({ + description: 'Organization documents', + type: [OrganizationDocumentDto], + }) + documents: OrganizationDocumentDto[]; + + @ApiProperty({ + example: true, + description: 'Active status', + }) + isActive: boolean; + + @ApiProperty({ + example: '2025-01-01T00:00:00Z', + description: 'Creation timestamp', + }) + createdAt: Date; + + @ApiProperty({ + example: '2025-01-15T10:00:00Z', + description: 'Last update timestamp', + }) + updatedAt: Date; +} + +/** + * Organization List Response DTO + */ +export class OrganizationListResponseDto { + @ApiProperty({ + description: 'List of organizations', + type: [OrganizationResponseDto], + }) + organizations: OrganizationResponseDto[]; + + @ApiProperty({ + example: 25, + description: 'Total number of organizations', + }) + total: number; + + @ApiProperty({ + example: 1, + description: 'Current page number', + }) + page: number; + + @ApiProperty({ + example: 20, + description: 'Page size', + }) + pageSize: number; + + @ApiProperty({ + example: 2, + description: 'Total number of pages', + }) + totalPages: number; +} diff --git a/apps/backend/src/application/dto/rate-search-filters.dto.ts b/apps/backend/src/application/dto/rate-search-filters.dto.ts index c5e23e4..6b86758 100644 --- a/apps/backend/src/application/dto/rate-search-filters.dto.ts +++ b/apps/backend/src/application/dto/rate-search-filters.dto.ts @@ -1,155 +1,155 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsOptional, - IsArray, - IsNumber, - Min, - Max, - IsEnum, - IsBoolean, - IsDateString, - IsString, -} from 'class-validator'; - -/** - * Rate Search Filters DTO - * - * Advanced filters for narrowing down rate search results - * All filters are optional - */ -export class RateSearchFiltersDto { - @ApiPropertyOptional({ - description: 'List of company names to include in search', - type: [String], - example: ['SSC Consolidation', 'ECU Worldwide'], - }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - companies?: string[]; - - @ApiPropertyOptional({ - description: 'Minimum volume in CBM (cubic meters)', - minimum: 0, - example: 1, - }) - @IsOptional() - @IsNumber() - @Min(0) - minVolumeCBM?: number; - - @ApiPropertyOptional({ - description: 'Maximum volume in CBM (cubic meters)', - minimum: 0, - example: 100, - }) - @IsOptional() - @IsNumber() - @Min(0) - maxVolumeCBM?: number; - - @ApiPropertyOptional({ - description: 'Minimum weight in kilograms', - minimum: 0, - example: 100, - }) - @IsOptional() - @IsNumber() - @Min(0) - minWeightKG?: number; - - @ApiPropertyOptional({ - description: 'Maximum weight in kilograms', - minimum: 0, - example: 15000, - }) - @IsOptional() - @IsNumber() - @Min(0) - maxWeightKG?: number; - - @ApiPropertyOptional({ - description: 'Exact number of pallets (0 means any)', - minimum: 0, - example: 10, - }) - @IsOptional() - @IsNumber() - @Min(0) - palletCount?: number; - - @ApiPropertyOptional({ - description: 'Minimum price in selected currency', - minimum: 0, - example: 1000, - }) - @IsOptional() - @IsNumber() - @Min(0) - minPrice?: number; - - @ApiPropertyOptional({ - description: 'Maximum price in selected currency', - minimum: 0, - example: 5000, - }) - @IsOptional() - @IsNumber() - @Min(0) - maxPrice?: number; - - @ApiPropertyOptional({ - description: 'Minimum transit time in days', - minimum: 0, - example: 20, - }) - @IsOptional() - @IsNumber() - @Min(0) - minTransitDays?: number; - - @ApiPropertyOptional({ - description: 'Maximum transit time in days', - minimum: 0, - example: 40, - }) - @IsOptional() - @IsNumber() - @Min(0) - maxTransitDays?: number; - - @ApiPropertyOptional({ - description: 'Container types to filter by', - type: [String], - example: ['LCL', '20DRY', '40HC'], - }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - containerTypes?: string[]; - - @ApiPropertyOptional({ - description: 'Preferred currency for price filtering', - enum: ['USD', 'EUR'], - example: 'USD', - }) - @IsOptional() - @IsEnum(['USD', 'EUR']) - currency?: 'USD' | 'EUR'; - - @ApiPropertyOptional({ - description: 'Only show all-in prices (without separate surcharges)', - example: false, - }) - @IsOptional() - @IsBoolean() - onlyAllInPrices?: boolean; - - @ApiPropertyOptional({ - description: 'Departure date to check rate validity (ISO 8601)', - example: '2025-06-15', - }) - @IsOptional() - @IsDateString() - departureDate?: string; -} +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsArray, + IsNumber, + Min, + Max, + IsEnum, + IsBoolean, + IsDateString, + IsString, +} from 'class-validator'; + +/** + * Rate Search Filters DTO + * + * Advanced filters for narrowing down rate search results + * All filters are optional + */ +export class RateSearchFiltersDto { + @ApiPropertyOptional({ + description: 'List of company names to include in search', + type: [String], + example: ['SSC Consolidation', 'ECU Worldwide'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + companies?: string[]; + + @ApiPropertyOptional({ + description: 'Minimum volume in CBM (cubic meters)', + minimum: 0, + example: 1, + }) + @IsOptional() + @IsNumber() + @Min(0) + minVolumeCBM?: number; + + @ApiPropertyOptional({ + description: 'Maximum volume in CBM (cubic meters)', + minimum: 0, + example: 100, + }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolumeCBM?: number; + + @ApiPropertyOptional({ + description: 'Minimum weight in kilograms', + minimum: 0, + example: 100, + }) + @IsOptional() + @IsNumber() + @Min(0) + minWeightKG?: number; + + @ApiPropertyOptional({ + description: 'Maximum weight in kilograms', + minimum: 0, + example: 15000, + }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeightKG?: number; + + @ApiPropertyOptional({ + description: 'Exact number of pallets (0 means any)', + minimum: 0, + example: 10, + }) + @IsOptional() + @IsNumber() + @Min(0) + palletCount?: number; + + @ApiPropertyOptional({ + description: 'Minimum price in selected currency', + minimum: 0, + example: 1000, + }) + @IsOptional() + @IsNumber() + @Min(0) + minPrice?: number; + + @ApiPropertyOptional({ + description: 'Maximum price in selected currency', + minimum: 0, + example: 5000, + }) + @IsOptional() + @IsNumber() + @Min(0) + maxPrice?: number; + + @ApiPropertyOptional({ + description: 'Minimum transit time in days', + minimum: 0, + example: 20, + }) + @IsOptional() + @IsNumber() + @Min(0) + minTransitDays?: number; + + @ApiPropertyOptional({ + description: 'Maximum transit time in days', + minimum: 0, + example: 40, + }) + @IsOptional() + @IsNumber() + @Min(0) + maxTransitDays?: number; + + @ApiPropertyOptional({ + description: 'Container types to filter by', + type: [String], + example: ['LCL', '20DRY', '40HC'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + containerTypes?: string[]; + + @ApiPropertyOptional({ + description: 'Preferred currency for price filtering', + enum: ['USD', 'EUR'], + example: 'USD', + }) + @IsOptional() + @IsEnum(['USD', 'EUR']) + currency?: 'USD' | 'EUR'; + + @ApiPropertyOptional({ + description: 'Only show all-in prices (without separate surcharges)', + example: false, + }) + @IsOptional() + @IsBoolean() + onlyAllInPrices?: boolean; + + @ApiPropertyOptional({ + description: 'Departure date to check rate validity (ISO 8601)', + example: '2025-06-15', + }) + @IsOptional() + @IsDateString() + departureDate?: string; +} diff --git a/apps/backend/src/application/dto/rate-search-request.dto.ts b/apps/backend/src/application/dto/rate-search-request.dto.ts index c164597..2901216 100644 --- a/apps/backend/src/application/dto/rate-search-request.dto.ts +++ b/apps/backend/src/application/dto/rate-search-request.dto.ts @@ -1,97 +1,110 @@ -import { IsString, IsDateString, IsEnum, IsOptional, IsInt, Min, IsBoolean, Matches } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class RateSearchRequestDto { - @ApiProperty({ - description: 'Origin port code (UN/LOCODE)', - example: 'NLRTM', - pattern: '^[A-Z]{5}$', - }) - @IsString() - @Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' }) - origin: string; - - @ApiProperty({ - description: 'Destination port code (UN/LOCODE)', - example: 'CNSHA', - pattern: '^[A-Z]{5}$', - }) - @IsString() - @Matches(/^[A-Z]{5}$/, { message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)' }) - destination: string; - - @ApiProperty({ - description: 'Container type', - example: '40HC', - enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], - }) - @IsString() - @IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], { - message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC', - }) - containerType: string; - - @ApiProperty({ - description: 'Shipping mode', - example: 'FCL', - enum: ['FCL', 'LCL'], - }) - @IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' }) - mode: 'FCL' | 'LCL'; - - @ApiProperty({ - description: 'Desired departure date (ISO 8601 format)', - example: '2025-02-15', - }) - @IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' }) - departureDate: string; - - @ApiPropertyOptional({ - description: 'Number of containers', - example: 2, - minimum: 1, - default: 1, - }) - @IsOptional() - @IsInt() - @Min(1, { message: 'Quantity must be at least 1' }) - quantity?: number; - - @ApiPropertyOptional({ - description: 'Total cargo weight in kg', - example: 20000, - minimum: 0, - }) - @IsOptional() - @IsInt() - @Min(0, { message: 'Weight must be non-negative' }) - weight?: number; - - @ApiPropertyOptional({ - description: 'Total cargo volume in cubic meters', - example: 50.5, - minimum: 0, - }) - @IsOptional() - @Min(0, { message: 'Volume must be non-negative' }) - volume?: number; - - @ApiPropertyOptional({ - description: 'Whether cargo is hazardous material', - example: false, - default: false, - }) - @IsOptional() - @IsBoolean() - isHazmat?: boolean; - - @ApiPropertyOptional({ - description: 'IMO hazmat class (required if isHazmat is true)', - example: '3', - pattern: '^[1-9](\\.[1-9])?$', - }) - @IsOptional() - @IsString() - @Matches(/^[1-9](\.[1-9])?$/, { message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)' }) - imoClass?: string; -} +import { + IsString, + IsDateString, + IsEnum, + IsOptional, + IsInt, + Min, + IsBoolean, + Matches, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RateSearchRequestDto { + @ApiProperty({ + description: 'Origin port code (UN/LOCODE)', + example: 'NLRTM', + pattern: '^[A-Z]{5}$', + }) + @IsString() + @Matches(/^[A-Z]{5}$/, { message: 'Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)' }) + origin: string; + + @ApiProperty({ + description: 'Destination port code (UN/LOCODE)', + example: 'CNSHA', + pattern: '^[A-Z]{5}$', + }) + @IsString() + @Matches(/^[A-Z]{5}$/, { + message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)', + }) + destination: string; + + @ApiProperty({ + description: 'Container type', + example: '40HC', + enum: ['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], + }) + @IsString() + @IsEnum(['20DRY', '20HC', '40DRY', '40HC', '40REEFER', '45HC'], { + message: 'Container type must be one of: 20DRY, 20HC, 40DRY, 40HC, 40REEFER, 45HC', + }) + containerType: string; + + @ApiProperty({ + description: 'Shipping mode', + example: 'FCL', + enum: ['FCL', 'LCL'], + }) + @IsEnum(['FCL', 'LCL'], { message: 'Mode must be either FCL or LCL' }) + mode: 'FCL' | 'LCL'; + + @ApiProperty({ + description: 'Desired departure date (ISO 8601 format)', + example: '2025-02-15', + }) + @IsDateString({}, { message: 'Departure date must be a valid ISO 8601 date string' }) + departureDate: string; + + @ApiPropertyOptional({ + description: 'Number of containers', + example: 2, + minimum: 1, + default: 1, + }) + @IsOptional() + @IsInt() + @Min(1, { message: 'Quantity must be at least 1' }) + quantity?: number; + + @ApiPropertyOptional({ + description: 'Total cargo weight in kg', + example: 20000, + minimum: 0, + }) + @IsOptional() + @IsInt() + @Min(0, { message: 'Weight must be non-negative' }) + weight?: number; + + @ApiPropertyOptional({ + description: 'Total cargo volume in cubic meters', + example: 50.5, + minimum: 0, + }) + @IsOptional() + @Min(0, { message: 'Volume must be non-negative' }) + volume?: number; + + @ApiPropertyOptional({ + description: 'Whether cargo is hazardous material', + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + isHazmat?: boolean; + + @ApiPropertyOptional({ + description: 'IMO hazmat class (required if isHazmat is true)', + example: '3', + pattern: '^[1-9](\\.[1-9])?$', + }) + @IsOptional() + @IsString() + @Matches(/^[1-9](\.[1-9])?$/, { + message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)', + }) + imoClass?: string; +} diff --git a/apps/backend/src/application/dto/rate-search-response.dto.ts b/apps/backend/src/application/dto/rate-search-response.dto.ts index b86cbac..f57fb5a 100644 --- a/apps/backend/src/application/dto/rate-search-response.dto.ts +++ b/apps/backend/src/application/dto/rate-search-response.dto.ts @@ -1,148 +1,148 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class PortDto { - @ApiProperty({ example: 'NLRTM' }) - code: string; - - @ApiProperty({ example: 'Rotterdam' }) - name: string; - - @ApiProperty({ example: 'Netherlands' }) - country: string; -} - -export class SurchargeDto { - @ApiProperty({ example: 'BAF', description: 'Surcharge type code' }) - type: string; - - @ApiProperty({ example: 'Bunker Adjustment Factor' }) - description: string; - - @ApiProperty({ example: 150.0 }) - amount: number; - - @ApiProperty({ example: 'USD' }) - currency: string; -} - -export class PricingDto { - @ApiProperty({ example: 1500.0, description: 'Base ocean freight' }) - baseFreight: number; - - @ApiProperty({ type: [SurchargeDto] }) - surcharges: SurchargeDto[]; - - @ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' }) - totalAmount: number; - - @ApiProperty({ example: 'USD' }) - currency: string; -} - -export class RouteSegmentDto { - @ApiProperty({ example: 'NLRTM' }) - portCode: string; - - @ApiProperty({ example: 'Port of Rotterdam' }) - portName: string; - - @ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' }) - arrival?: string; - - @ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' }) - departure?: string; - - @ApiPropertyOptional({ example: 'MAERSK ESSEX' }) - vesselName?: string; - - @ApiPropertyOptional({ example: '025W' }) - voyageNumber?: string; -} - -export class RateQuoteDto { - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) - id: string; - - @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' }) - carrierId: string; - - @ApiProperty({ example: 'Maersk Line' }) - carrierName: string; - - @ApiProperty({ example: 'MAERSK' }) - carrierCode: string; - - @ApiProperty({ type: PortDto }) - origin: PortDto; - - @ApiProperty({ type: PortDto }) - destination: PortDto; - - @ApiProperty({ type: PricingDto }) - pricing: PricingDto; - - @ApiProperty({ example: '40HC' }) - containerType: string; - - @ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] }) - mode: 'FCL' | 'LCL'; - - @ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' }) - etd: string; - - @ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' }) - eta: string; - - @ApiProperty({ example: 30, description: 'Transit time in days' }) - transitDays: number; - - @ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' }) - route: RouteSegmentDto[]; - - @ApiProperty({ example: 85, description: 'Available container slots' }) - availability: number; - - @ApiProperty({ example: 'Weekly' }) - frequency: string; - - @ApiPropertyOptional({ example: 'Container Ship' }) - vesselType?: string; - - @ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' }) - co2EmissionsKg?: number; - - @ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' }) - validUntil: string; - - @ApiProperty({ example: '2025-02-15T10:00:00Z' }) - createdAt: string; -} - -export class RateSearchResponseDto { - @ApiProperty({ type: [RateQuoteDto] }) - quotes: RateQuoteDto[]; - - @ApiProperty({ example: 5, description: 'Total number of quotes returned' }) - count: number; - - @ApiProperty({ example: 'NLRTM' }) - origin: string; - - @ApiProperty({ example: 'CNSHA' }) - destination: string; - - @ApiProperty({ example: '2025-02-15' }) - departureDate: string; - - @ApiProperty({ example: '40HC' }) - containerType: string; - - @ApiProperty({ example: 'FCL' }) - mode: string; - - @ApiProperty({ example: true, description: 'Whether results were served from cache' }) - fromCache: boolean; - - @ApiProperty({ example: 234, description: 'Query response time in milliseconds' }) - responseTimeMs: number; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class PortDto { + @ApiProperty({ example: 'NLRTM' }) + code: string; + + @ApiProperty({ example: 'Rotterdam' }) + name: string; + + @ApiProperty({ example: 'Netherlands' }) + country: string; +} + +export class SurchargeDto { + @ApiProperty({ example: 'BAF', description: 'Surcharge type code' }) + type: string; + + @ApiProperty({ example: 'Bunker Adjustment Factor' }) + description: string; + + @ApiProperty({ example: 150.0 }) + amount: number; + + @ApiProperty({ example: 'USD' }) + currency: string; +} + +export class PricingDto { + @ApiProperty({ example: 1500.0, description: 'Base ocean freight' }) + baseFreight: number; + + @ApiProperty({ type: [SurchargeDto] }) + surcharges: SurchargeDto[]; + + @ApiProperty({ example: 1700.0, description: 'Total amount including all surcharges' }) + totalAmount: number; + + @ApiProperty({ example: 'USD' }) + currency: string; +} + +export class RouteSegmentDto { + @ApiProperty({ example: 'NLRTM' }) + portCode: string; + + @ApiProperty({ example: 'Port of Rotterdam' }) + portName: string; + + @ApiPropertyOptional({ example: '2025-02-15T10:00:00Z' }) + arrival?: string; + + @ApiPropertyOptional({ example: '2025-02-15T14:00:00Z' }) + departure?: string; + + @ApiPropertyOptional({ example: 'MAERSK ESSEX' }) + vesselName?: string; + + @ApiPropertyOptional({ example: '025W' }) + voyageNumber?: string; +} + +export class RateQuoteDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' }) + carrierId: string; + + @ApiProperty({ example: 'Maersk Line' }) + carrierName: string; + + @ApiProperty({ example: 'MAERSK' }) + carrierCode: string; + + @ApiProperty({ type: PortDto }) + origin: PortDto; + + @ApiProperty({ type: PortDto }) + destination: PortDto; + + @ApiProperty({ type: PricingDto }) + pricing: PricingDto; + + @ApiProperty({ example: '40HC' }) + containerType: string; + + @ApiProperty({ example: 'FCL', enum: ['FCL', 'LCL'] }) + mode: 'FCL' | 'LCL'; + + @ApiProperty({ example: '2025-02-15T10:00:00Z', description: 'Estimated Time of Departure' }) + etd: string; + + @ApiProperty({ example: '2025-03-17T14:00:00Z', description: 'Estimated Time of Arrival' }) + eta: string; + + @ApiProperty({ example: 30, description: 'Transit time in days' }) + transitDays: number; + + @ApiProperty({ type: [RouteSegmentDto], description: 'Route segments with port details' }) + route: RouteSegmentDto[]; + + @ApiProperty({ example: 85, description: 'Available container slots' }) + availability: number; + + @ApiProperty({ example: 'Weekly' }) + frequency: string; + + @ApiPropertyOptional({ example: 'Container Ship' }) + vesselType?: string; + + @ApiPropertyOptional({ example: 12500.5, description: 'CO2 emissions in kg' }) + co2EmissionsKg?: number; + + @ApiProperty({ example: '2025-02-15T10:15:00Z', description: 'Quote expiration timestamp' }) + validUntil: string; + + @ApiProperty({ example: '2025-02-15T10:00:00Z' }) + createdAt: string; +} + +export class RateSearchResponseDto { + @ApiProperty({ type: [RateQuoteDto] }) + quotes: RateQuoteDto[]; + + @ApiProperty({ example: 5, description: 'Total number of quotes returned' }) + count: number; + + @ApiProperty({ example: 'NLRTM' }) + origin: string; + + @ApiProperty({ example: 'CNSHA' }) + destination: string; + + @ApiProperty({ example: '2025-02-15' }) + departureDate: string; + + @ApiProperty({ example: '40HC' }) + containerType: string; + + @ApiProperty({ example: 'FCL' }) + mode: string; + + @ApiProperty({ example: true, description: 'Whether results were served from cache' }) + fromCache: boolean; + + @ApiProperty({ example: 234, description: 'Query response time in milliseconds' }) + responseTimeMs: number; +} diff --git a/apps/backend/src/application/dto/user.dto.ts b/apps/backend/src/application/dto/user.dto.ts index 73f5a80..d70d82d 100644 --- a/apps/backend/src/application/dto/user.dto.ts +++ b/apps/backend/src/application/dto/user.dto.ts @@ -1,236 +1,237 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsString, - IsEmail, - IsEnum, - IsNotEmpty, - MinLength, - MaxLength, - IsOptional, - IsBoolean, - IsUUID, -} from 'class-validator'; - -/** - * User roles enum - */ -export enum UserRole { - ADMIN = 'admin', - MANAGER = 'manager', - USER = 'user', - VIEWER = 'viewer', -} - -/** - * Create User DTO (for admin/manager inviting users) - */ -export class CreateUserDto { - @ApiProperty({ - example: 'jane.doe@acme.com', - description: 'User email address', - }) - @IsEmail({}, { message: 'Invalid email format' }) - email: string; - - @ApiProperty({ - example: 'Jane', - description: 'First name', - minLength: 2, - }) - @IsString() - @MinLength(2, { message: 'First name must be at least 2 characters' }) - firstName: string; - - @ApiProperty({ - example: 'Doe', - description: 'Last name', - minLength: 2, - }) - @IsString() - @MinLength(2, { message: 'Last name must be at least 2 characters' }) - lastName: string; - - @ApiProperty({ - example: UserRole.USER, - description: 'User role', - enum: UserRole, - }) - @IsEnum(UserRole) - role: UserRole; - - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'Organization ID', - }) - @IsUUID() - organizationId: string; - - @ApiPropertyOptional({ - example: 'TempPassword123!', - description: 'Temporary password (min 12 characters). If not provided, a random one will be generated.', - minLength: 12, - }) - @IsString() - @IsOptional() - @MinLength(12, { message: 'Password must be at least 12 characters' }) - password?: string; -} - -/** - * Update User DTO - */ -export class UpdateUserDto { - @ApiPropertyOptional({ - example: 'Jane', - description: 'First name', - minLength: 2, - }) - @IsString() - @IsOptional() - @MinLength(2) - firstName?: string; - - @ApiPropertyOptional({ - example: 'Doe', - description: 'Last name', - minLength: 2, - }) - @IsString() - @IsOptional() - @MinLength(2) - lastName?: string; - - @ApiPropertyOptional({ - example: UserRole.MANAGER, - description: 'User role', - enum: UserRole, - }) - @IsEnum(UserRole) - @IsOptional() - role?: UserRole; - - @ApiPropertyOptional({ - example: true, - description: 'Active status', - }) - @IsBoolean() - @IsOptional() - isActive?: boolean; -} - -/** - * Update Password DTO - */ -export class UpdatePasswordDto { - @ApiProperty({ - example: 'OldPassword123!', - description: 'Current password', - }) - @IsString() - @IsNotEmpty() - currentPassword: string; - - @ApiProperty({ - example: 'NewSecurePassword456!', - description: 'New password (min 12 characters)', - minLength: 12, - }) - @IsString() - @MinLength(12, { message: 'Password must be at least 12 characters' }) - newPassword: string; -} - -/** - * User Response DTO - */ -export class UserResponseDto { - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'User ID', - }) - id: string; - - @ApiProperty({ - example: 'john.doe@acme.com', - description: 'User email', - }) - email: string; - - @ApiProperty({ - example: 'John', - description: 'First name', - }) - firstName: string; - - @ApiProperty({ - example: 'Doe', - description: 'Last name', - }) - lastName: string; - - @ApiProperty({ - example: UserRole.USER, - description: 'User role', - enum: UserRole, - }) - role: UserRole; - - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'Organization ID', - }) - organizationId: string; - - @ApiProperty({ - example: true, - description: 'Active status', - }) - isActive: boolean; - - @ApiProperty({ - example: '2025-01-01T00:00:00Z', - description: 'Creation timestamp', - }) - createdAt: Date; - - @ApiProperty({ - example: '2025-01-15T10:00:00Z', - description: 'Last update timestamp', - }) - updatedAt: Date; -} - -/** - * User List Response DTO - */ -export class UserListResponseDto { - @ApiProperty({ - description: 'List of users', - type: [UserResponseDto], - }) - users: UserResponseDto[]; - - @ApiProperty({ - example: 15, - description: 'Total number of users', - }) - total: number; - - @ApiProperty({ - example: 1, - description: 'Current page number', - }) - page: number; - - @ApiProperty({ - example: 20, - description: 'Page size', - }) - pageSize: number; - - @ApiProperty({ - example: 1, - description: 'Total number of pages', - }) - totalPages: number; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsEmail, + IsEnum, + IsNotEmpty, + MinLength, + MaxLength, + IsOptional, + IsBoolean, + IsUUID, +} from 'class-validator'; + +/** + * User roles enum + */ +export enum UserRole { + ADMIN = 'admin', + MANAGER = 'manager', + USER = 'user', + VIEWER = 'viewer', +} + +/** + * Create User DTO (for admin/manager inviting users) + */ +export class CreateUserDto { + @ApiProperty({ + example: 'jane.doe@acme.com', + description: 'User email address', + }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiProperty({ + example: 'Jane', + description: 'First name', + minLength: 2, + }) + @IsString() + @MinLength(2, { message: 'First name must be at least 2 characters' }) + firstName: string; + + @ApiProperty({ + example: 'Doe', + description: 'Last name', + minLength: 2, + }) + @IsString() + @MinLength(2, { message: 'Last name must be at least 2 characters' }) + lastName: string; + + @ApiProperty({ + example: UserRole.USER, + description: 'User role', + enum: UserRole, + }) + @IsEnum(UserRole) + role: UserRole; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Organization ID', + }) + @IsUUID() + organizationId: string; + + @ApiPropertyOptional({ + example: 'TempPassword123!', + description: + 'Temporary password (min 12 characters). If not provided, a random one will be generated.', + minLength: 12, + }) + @IsString() + @IsOptional() + @MinLength(12, { message: 'Password must be at least 12 characters' }) + password?: string; +} + +/** + * Update User DTO + */ +export class UpdateUserDto { + @ApiPropertyOptional({ + example: 'Jane', + description: 'First name', + minLength: 2, + }) + @IsString() + @IsOptional() + @MinLength(2) + firstName?: string; + + @ApiPropertyOptional({ + example: 'Doe', + description: 'Last name', + minLength: 2, + }) + @IsString() + @IsOptional() + @MinLength(2) + lastName?: string; + + @ApiPropertyOptional({ + example: UserRole.MANAGER, + description: 'User role', + enum: UserRole, + }) + @IsEnum(UserRole) + @IsOptional() + role?: UserRole; + + @ApiPropertyOptional({ + example: true, + description: 'Active status', + }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +/** + * Update Password DTO + */ +export class UpdatePasswordDto { + @ApiProperty({ + example: 'OldPassword123!', + description: 'Current password', + }) + @IsString() + @IsNotEmpty() + currentPassword: string; + + @ApiProperty({ + example: 'NewSecurePassword456!', + description: 'New password (min 12 characters)', + minLength: 12, + }) + @IsString() + @MinLength(12, { message: 'Password must be at least 12 characters' }) + newPassword: string; +} + +/** + * User Response DTO + */ +export class UserResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'User ID', + }) + id: string; + + @ApiProperty({ + example: 'john.doe@acme.com', + description: 'User email', + }) + email: string; + + @ApiProperty({ + example: 'John', + description: 'First name', + }) + firstName: string; + + @ApiProperty({ + example: 'Doe', + description: 'Last name', + }) + lastName: string; + + @ApiProperty({ + example: UserRole.USER, + description: 'User role', + enum: UserRole, + }) + role: UserRole; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Organization ID', + }) + organizationId: string; + + @ApiProperty({ + example: true, + description: 'Active status', + }) + isActive: boolean; + + @ApiProperty({ + example: '2025-01-01T00:00:00Z', + description: 'Creation timestamp', + }) + createdAt: Date; + + @ApiProperty({ + example: '2025-01-15T10:00:00Z', + description: 'Last update timestamp', + }) + updatedAt: Date; +} + +/** + * User List Response DTO + */ +export class UserListResponseDto { + @ApiProperty({ + description: 'List of users', + type: [UserResponseDto], + }) + users: UserResponseDto[]; + + @ApiProperty({ + example: 15, + description: 'Total number of users', + }) + total: number; + + @ApiProperty({ + example: 1, + description: 'Current page number', + }) + page: number; + + @ApiProperty({ + example: 20, + description: 'Page size', + }) + pageSize: number; + + @ApiProperty({ + example: 1, + description: 'Total number of pages', + }) + totalPages: number; +} diff --git a/apps/backend/src/application/gateways/notifications.gateway.ts b/apps/backend/src/application/gateways/notifications.gateway.ts index d570372..597760b 100644 --- a/apps/backend/src/application/gateways/notifications.gateway.ts +++ b/apps/backend/src/application/gateways/notifications.gateway.ts @@ -39,7 +39,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco constructor( private readonly jwtService: JwtService, - private readonly notificationService: NotificationService, + private readonly notificationService: NotificationService ) {} /** @@ -81,12 +81,12 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco // Send recent notifications on connection const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10); client.emit('recent_notifications', { - notifications: recentNotifications.map((n) => this.mapNotificationToDto(n)), + notifications: recentNotifications.map(n => this.mapNotificationToDto(n)), }); } catch (error: any) { this.logger.error( `Error during client connection: ${error?.message || 'Unknown error'}`, - error?.stack, + error?.stack ); client.disconnect(); } @@ -112,7 +112,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco @SubscribeMessage('mark_as_read') async handleMarkAsRead( @ConnectedSocket() client: Socket, - @MessageBody() data: { notificationId: string }, + @MessageBody() data: { notificationId: string } ) { try { const userId = client.data.userId; diff --git a/apps/backend/src/application/guards/index.ts b/apps/backend/src/application/guards/index.ts index 23083ac..e174be2 100644 --- a/apps/backend/src/application/guards/index.ts +++ b/apps/backend/src/application/guards/index.ts @@ -1,2 +1,2 @@ -export * from './jwt-auth.guard'; -export * from './roles.guard'; +export * from './jwt-auth.guard'; +export * from './roles.guard'; diff --git a/apps/backend/src/application/guards/jwt-auth.guard.ts b/apps/backend/src/application/guards/jwt-auth.guard.ts index 27e75c9..7dfa8d2 100644 --- a/apps/backend/src/application/guards/jwt-auth.guard.ts +++ b/apps/backend/src/application/guards/jwt-auth.guard.ts @@ -1,45 +1,45 @@ -import { Injectable, ExecutionContext } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { Reflector } from '@nestjs/core'; - -/** - * JWT Authentication Guard - * - * This guard: - * - Uses the JWT strategy to authenticate requests - * - Checks for valid JWT token in Authorization header - * - Attaches user object to request if authentication succeeds - * - Can be bypassed with @Public() decorator - * - * Usage: - * @UseGuards(JwtAuthGuard) - * @Get('protected') - * protectedRoute(@CurrentUser() user: UserPayload) { - * return { user }; - * } - */ -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') { - constructor(private reflector: Reflector) { - super(); - } - - /** - * Determine if the route should be accessible without authentication - * Routes decorated with @Public() will bypass this guard - */ - canActivate(context: ExecutionContext) { - // Check if route is marked as public - const isPublic = this.reflector.getAllAndOverride('isPublic', [ - context.getHandler(), - context.getClass(), - ]); - - if (isPublic) { - return true; - } - - // Otherwise, perform JWT authentication - return super.canActivate(context); - } -} +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; + +/** + * JWT Authentication Guard + * + * This guard: + * - Uses the JWT strategy to authenticate requests + * - Checks for valid JWT token in Authorization header + * - Attaches user object to request if authentication succeeds + * - Can be bypassed with @Public() decorator + * + * Usage: + * @UseGuards(JwtAuthGuard) + * @Get('protected') + * protectedRoute(@CurrentUser() user: UserPayload) { + * return { user }; + * } + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + /** + * Determine if the route should be accessible without authentication + * Routes decorated with @Public() will bypass this guard + */ + canActivate(context: ExecutionContext) { + // Check if route is marked as public + const isPublic = this.reflector.getAllAndOverride('isPublic', [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + // Otherwise, perform JWT authentication + return super.canActivate(context); + } +} diff --git a/apps/backend/src/application/guards/roles.guard.ts b/apps/backend/src/application/guards/roles.guard.ts index 6e05dea..55987d3 100644 --- a/apps/backend/src/application/guards/roles.guard.ts +++ b/apps/backend/src/application/guards/roles.guard.ts @@ -1,46 +1,46 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -/** - * Roles Guard for Role-Based Access Control (RBAC) - * - * This guard: - * - Checks if the authenticated user has the required role(s) - * - Works in conjunction with JwtAuthGuard - * - Uses @Roles() decorator to specify required roles - * - * Usage: - * @UseGuards(JwtAuthGuard, RolesGuard) - * @Roles('admin', 'manager') - * @Get('admin-only') - * adminRoute(@CurrentUser() user: UserPayload) { - * return { message: 'Admin access granted' }; - * } - */ -@Injectable() -export class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - // Get required roles from @Roles() decorator - const requiredRoles = this.reflector.getAllAndOverride('roles', [ - context.getHandler(), - context.getClass(), - ]); - - // If no roles are required, allow access - if (!requiredRoles || requiredRoles.length === 0) { - return true; - } - - // Get user from request (should be set by JwtAuthGuard) - const { user } = context.switchToHttp().getRequest(); - - // Check if user has any of the required roles - if (!user || !user.role) { - return false; - } - - return requiredRoles.includes(user.role); - } -} +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +/** + * Roles Guard for Role-Based Access Control (RBAC) + * + * This guard: + * - Checks if the authenticated user has the required role(s) + * - Works in conjunction with JwtAuthGuard + * - Uses @Roles() decorator to specify required roles + * + * Usage: + * @UseGuards(JwtAuthGuard, RolesGuard) + * @Roles('admin', 'manager') + * @Get('admin-only') + * adminRoute(@CurrentUser() user: UserPayload) { + * return { message: 'Admin access granted' }; + * } + */ +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + // Get required roles from @Roles() decorator + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + + // If no roles are required, allow access + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + // Get user from request (should be set by JwtAuthGuard) + const { user } = context.switchToHttp().getRequest(); + + // Check if user has any of the required roles + if (!user || !user.role) { + return false; + } + + return requiredRoles.includes(user.role); + } +} diff --git a/apps/backend/src/application/guards/throttle.guard.ts b/apps/backend/src/application/guards/throttle.guard.ts index b5108e7..37fc1ef 100644 --- a/apps/backend/src/application/guards/throttle.guard.ts +++ b/apps/backend/src/application/guards/throttle.guard.ts @@ -23,11 +23,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard { /** * Custom error message (override for new API) */ - protected async throwThrottlingException( - context: ExecutionContext, - ): Promise { - throw new ThrottlerException( - 'Too many requests. Please try again later.', - ); + protected async throwThrottlingException(context: ExecutionContext): Promise { + throw new ThrottlerException('Too many requests. Please try again later.'); } } diff --git a/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts b/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts index df76066..4fdafa7 100644 --- a/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts +++ b/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts @@ -4,13 +4,7 @@ * Tracks request duration and logs metrics */ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import * as Sentry from '@sentry/node'; @@ -25,33 +19,31 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor { const startTime = Date.now(); return next.handle().pipe( - tap((data) => { + tap(data => { const duration = Date.now() - startTime; const response = context.switchToHttp().getResponse(); // Log performance if (duration > 1000) { this.logger.warn( - `Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`, + `Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})` ); } // Log successful request - this.logger.log( - `${method} ${url} - ${response.statusCode} - ${duration}ms`, - ); + this.logger.log(`${method} ${url} - ${response.statusCode} - ${duration}ms`); }), - catchError((error) => { + catchError(error => { const duration = Date.now() - startTime; // Log error this.logger.error( `Request error: ${method} ${url} (${duration}ms) - ${error.message}`, - error.stack, + error.stack ); // Capture exception in Sentry - Sentry.withScope((scope) => { + Sentry.withScope(scope => { scope.setContext('request', { method, url, @@ -62,7 +54,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor { }); throw error; - }), + }) ); } } diff --git a/apps/backend/src/application/mappers/booking.mapper.ts b/apps/backend/src/application/mappers/booking.mapper.ts index 9398627..b110592 100644 --- a/apps/backend/src/application/mappers/booking.mapper.ts +++ b/apps/backend/src/application/mappers/booking.mapper.ts @@ -1,168 +1,168 @@ -import { Booking } from '../../domain/entities/booking.entity'; -import { RateQuote } from '../../domain/entities/rate-quote.entity'; -import { - BookingResponseDto, - BookingAddressDto, - BookingPartyDto, - BookingContainerDto, - BookingRateQuoteDto, - BookingListItemDto, -} from '../dto/booking-response.dto'; -import { - CreateBookingRequestDto, - PartyDto, - AddressDto, - ContainerDto, -} from '../dto/create-booking-request.dto'; - -export class BookingMapper { - /** - * Map CreateBookingRequestDto to domain inputs - */ - static toCreateBookingInput(dto: CreateBookingRequestDto) { - return { - rateQuoteId: dto.rateQuoteId, - shipper: { - name: dto.shipper.name, - address: { - street: dto.shipper.address.street, - city: dto.shipper.address.city, - postalCode: dto.shipper.address.postalCode, - country: dto.shipper.address.country, - }, - contactName: dto.shipper.contactName, - contactEmail: dto.shipper.contactEmail, - contactPhone: dto.shipper.contactPhone, - }, - consignee: { - name: dto.consignee.name, - address: { - street: dto.consignee.address.street, - city: dto.consignee.address.city, - postalCode: dto.consignee.address.postalCode, - country: dto.consignee.address.country, - }, - contactName: dto.consignee.contactName, - contactEmail: dto.consignee.contactEmail, - contactPhone: dto.consignee.contactPhone, - }, - cargoDescription: dto.cargoDescription, - containers: dto.containers.map((c) => ({ - type: c.type, - containerNumber: c.containerNumber, - vgm: c.vgm, - temperature: c.temperature, - sealNumber: c.sealNumber, - })), - specialInstructions: dto.specialInstructions, - }; - } - - /** - * Map Booking entity and RateQuote to BookingResponseDto - */ - static toDto(booking: Booking, rateQuote: RateQuote): BookingResponseDto { - return { - id: booking.id, - bookingNumber: booking.bookingNumber.value, - status: booking.status.value, - shipper: { - name: booking.shipper.name, - address: { - street: booking.shipper.address.street, - city: booking.shipper.address.city, - postalCode: booking.shipper.address.postalCode, - country: booking.shipper.address.country, - }, - contactName: booking.shipper.contactName, - contactEmail: booking.shipper.contactEmail, - contactPhone: booking.shipper.contactPhone, - }, - consignee: { - name: booking.consignee.name, - address: { - street: booking.consignee.address.street, - city: booking.consignee.address.city, - postalCode: booking.consignee.address.postalCode, - country: booking.consignee.address.country, - }, - contactName: booking.consignee.contactName, - contactEmail: booking.consignee.contactEmail, - contactPhone: booking.consignee.contactPhone, - }, - cargoDescription: booking.cargoDescription, - containers: booking.containers.map((c) => ({ - id: c.id, - type: c.type, - containerNumber: c.containerNumber, - vgm: c.vgm, - temperature: c.temperature, - sealNumber: c.sealNumber, - })), - specialInstructions: booking.specialInstructions, - rateQuote: { - id: rateQuote.id, - carrierName: rateQuote.carrierName, - carrierCode: rateQuote.carrierCode, - origin: { - code: rateQuote.origin.code, - name: rateQuote.origin.name, - country: rateQuote.origin.country, - }, - destination: { - code: rateQuote.destination.code, - name: rateQuote.destination.name, - country: rateQuote.destination.country, - }, - pricing: { - baseFreight: rateQuote.pricing.baseFreight, - surcharges: rateQuote.pricing.surcharges.map((s) => ({ - type: s.type, - description: s.description, - amount: s.amount, - currency: s.currency, - })), - totalAmount: rateQuote.pricing.totalAmount, - currency: rateQuote.pricing.currency, - }, - containerType: rateQuote.containerType, - mode: rateQuote.mode, - etd: rateQuote.etd.toISOString(), - eta: rateQuote.eta.toISOString(), - transitDays: rateQuote.transitDays, - }, - createdAt: booking.createdAt.toISOString(), - updatedAt: booking.updatedAt.toISOString(), - }; - } - - /** - * Map Booking entity to list item DTO (simplified view) - */ - static toListItemDto(booking: Booking, rateQuote: RateQuote): BookingListItemDto { - return { - id: booking.id, - bookingNumber: booking.bookingNumber.value, - status: booking.status.value, - shipperName: booking.shipper.name, - consigneeName: booking.consignee.name, - originPort: rateQuote.origin.code, - destinationPort: rateQuote.destination.code, - carrierName: rateQuote.carrierName, - etd: rateQuote.etd.toISOString(), - eta: rateQuote.eta.toISOString(), - totalAmount: rateQuote.pricing.totalAmount, - currency: rateQuote.pricing.currency, - createdAt: booking.createdAt.toISOString(), - }; - } - - /** - * Map array of bookings to list item DTOs - */ - static toListItemDtoArray( - bookings: Array<{ booking: Booking; rateQuote: RateQuote }> - ): BookingListItemDto[] { - return bookings.map(({ booking, rateQuote }) => this.toListItemDto(booking, rateQuote)); - } -} +import { Booking } from '../../domain/entities/booking.entity'; +import { RateQuote } from '../../domain/entities/rate-quote.entity'; +import { + BookingResponseDto, + BookingAddressDto, + BookingPartyDto, + BookingContainerDto, + BookingRateQuoteDto, + BookingListItemDto, +} from '../dto/booking-response.dto'; +import { + CreateBookingRequestDto, + PartyDto, + AddressDto, + ContainerDto, +} from '../dto/create-booking-request.dto'; + +export class BookingMapper { + /** + * Map CreateBookingRequestDto to domain inputs + */ + static toCreateBookingInput(dto: CreateBookingRequestDto) { + return { + rateQuoteId: dto.rateQuoteId, + shipper: { + name: dto.shipper.name, + address: { + street: dto.shipper.address.street, + city: dto.shipper.address.city, + postalCode: dto.shipper.address.postalCode, + country: dto.shipper.address.country, + }, + contactName: dto.shipper.contactName, + contactEmail: dto.shipper.contactEmail, + contactPhone: dto.shipper.contactPhone, + }, + consignee: { + name: dto.consignee.name, + address: { + street: dto.consignee.address.street, + city: dto.consignee.address.city, + postalCode: dto.consignee.address.postalCode, + country: dto.consignee.address.country, + }, + contactName: dto.consignee.contactName, + contactEmail: dto.consignee.contactEmail, + contactPhone: dto.consignee.contactPhone, + }, + cargoDescription: dto.cargoDescription, + containers: dto.containers.map(c => ({ + type: c.type, + containerNumber: c.containerNumber, + vgm: c.vgm, + temperature: c.temperature, + sealNumber: c.sealNumber, + })), + specialInstructions: dto.specialInstructions, + }; + } + + /** + * Map Booking entity and RateQuote to BookingResponseDto + */ + static toDto(booking: Booking, rateQuote: RateQuote): BookingResponseDto { + return { + id: booking.id, + bookingNumber: booking.bookingNumber.value, + status: booking.status.value, + shipper: { + name: booking.shipper.name, + address: { + street: booking.shipper.address.street, + city: booking.shipper.address.city, + postalCode: booking.shipper.address.postalCode, + country: booking.shipper.address.country, + }, + contactName: booking.shipper.contactName, + contactEmail: booking.shipper.contactEmail, + contactPhone: booking.shipper.contactPhone, + }, + consignee: { + name: booking.consignee.name, + address: { + street: booking.consignee.address.street, + city: booking.consignee.address.city, + postalCode: booking.consignee.address.postalCode, + country: booking.consignee.address.country, + }, + contactName: booking.consignee.contactName, + contactEmail: booking.consignee.contactEmail, + contactPhone: booking.consignee.contactPhone, + }, + cargoDescription: booking.cargoDescription, + containers: booking.containers.map(c => ({ + id: c.id, + type: c.type, + containerNumber: c.containerNumber, + vgm: c.vgm, + temperature: c.temperature, + sealNumber: c.sealNumber, + })), + specialInstructions: booking.specialInstructions, + rateQuote: { + id: rateQuote.id, + carrierName: rateQuote.carrierName, + carrierCode: rateQuote.carrierCode, + origin: { + code: rateQuote.origin.code, + name: rateQuote.origin.name, + country: rateQuote.origin.country, + }, + destination: { + code: rateQuote.destination.code, + name: rateQuote.destination.name, + country: rateQuote.destination.country, + }, + pricing: { + baseFreight: rateQuote.pricing.baseFreight, + surcharges: rateQuote.pricing.surcharges.map(s => ({ + type: s.type, + description: s.description, + amount: s.amount, + currency: s.currency, + })), + totalAmount: rateQuote.pricing.totalAmount, + currency: rateQuote.pricing.currency, + }, + containerType: rateQuote.containerType, + mode: rateQuote.mode, + etd: rateQuote.etd.toISOString(), + eta: rateQuote.eta.toISOString(), + transitDays: rateQuote.transitDays, + }, + createdAt: booking.createdAt.toISOString(), + updatedAt: booking.updatedAt.toISOString(), + }; + } + + /** + * Map Booking entity to list item DTO (simplified view) + */ + static toListItemDto(booking: Booking, rateQuote: RateQuote): BookingListItemDto { + return { + id: booking.id, + bookingNumber: booking.bookingNumber.value, + status: booking.status.value, + shipperName: booking.shipper.name, + consigneeName: booking.consignee.name, + originPort: rateQuote.origin.code, + destinationPort: rateQuote.destination.code, + carrierName: rateQuote.carrierName, + etd: rateQuote.etd.toISOString(), + eta: rateQuote.eta.toISOString(), + totalAmount: rateQuote.pricing.totalAmount, + currency: rateQuote.pricing.currency, + createdAt: booking.createdAt.toISOString(), + }; + } + + /** + * Map array of bookings to list item DTOs + */ + static toListItemDtoArray( + bookings: Array<{ booking: Booking; rateQuote: RateQuote }> + ): BookingListItemDto[] { + return bookings.map(({ booking, rateQuote }) => this.toListItemDto(booking, rateQuote)); + } +} diff --git a/apps/backend/src/application/mappers/csv-rate.mapper.ts b/apps/backend/src/application/mappers/csv-rate.mapper.ts index 8a63823..e43996a 100644 --- a/apps/backend/src/application/mappers/csv-rate.mapper.ts +++ b/apps/backend/src/application/mappers/csv-rate.mapper.ts @@ -1,112 +1,109 @@ -import { Injectable } from '@nestjs/common'; -import { CsvRate } from '@domain/entities/csv-rate.entity'; -import { Volume } from '@domain/value-objects/volume.vo'; -import { - CsvRateResultDto, - CsvRateSearchResponseDto, -} from '../dto/csv-rate-search.dto'; -import { - CsvRateSearchInput, - CsvRateSearchOutput, - CsvRateSearchResult, - RateSearchFilters, -} from '@domain/ports/in/search-csv-rates.port'; -import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto'; -import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto'; -import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; - -/** - * CSV Rate Mapper - * - * Maps between domain entities and DTOs - * Follows hexagonal architecture principles - */ -@Injectable() -export class CsvRateMapper { - /** - * Map DTO filters to domain filters - */ - mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined { - if (!dto) { - return undefined; - } - - return { - companies: dto.companies, - minVolumeCBM: dto.minVolumeCBM, - maxVolumeCBM: dto.maxVolumeCBM, - minWeightKG: dto.minWeightKG, - maxWeightKG: dto.maxWeightKG, - palletCount: dto.palletCount, - minPrice: dto.minPrice, - maxPrice: dto.maxPrice, - currency: dto.currency, - minTransitDays: dto.minTransitDays, - maxTransitDays: dto.maxTransitDays, - containerTypes: dto.containerTypes, - onlyAllInPrices: dto.onlyAllInPrices, - departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined, - }; - } - - /** - * Map domain search result to DTO - */ - mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto { - const rate = result.rate; - - return { - companyName: rate.companyName, - origin: rate.origin.getValue(), - destination: rate.destination.getValue(), - containerType: rate.containerType.getValue(), - priceUSD: result.calculatedPrice.usd, - priceEUR: result.calculatedPrice.eur, - primaryCurrency: result.calculatedPrice.primaryCurrency, - hasSurcharges: rate.hasSurcharges(), - surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null, - transitDays: rate.transitDays, - validUntil: rate.validity.getEndDate().toISOString().split('T')[0], - source: result.source, - matchScore: result.matchScore, - }; - } - - /** - * Map domain search output to response DTO - */ - mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto { - return { - results: output.results.map((result) => this.mapSearchResultToDto(result)), - totalResults: output.totalResults, - searchedFiles: output.searchedFiles, - searchedAt: output.searchedAt, - appliedFilters: output.appliedFilters as any, // Already matches DTO structure - }; - } - - /** - * Map ORM entity to DTO - */ - mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto { - return { - id: entity.id, - companyName: entity.companyName, - csvFilePath: entity.csvFilePath, - type: entity.type, - hasApi: entity.hasApi, - apiConnector: entity.apiConnector, - isActive: entity.isActive, - uploadedAt: entity.uploadedAt, - rowCount: entity.rowCount, - metadata: entity.metadata, - }; - } - - /** - * Map multiple config entities to DTOs - */ - mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] { - return entities.map((entity) => this.mapConfigEntityToDto(entity)); - } -} +import { Injectable } from '@nestjs/common'; +import { CsvRate } from '@domain/entities/csv-rate.entity'; +import { Volume } from '@domain/value-objects/volume.vo'; +import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; +import { + CsvRateSearchInput, + CsvRateSearchOutput, + CsvRateSearchResult, + RateSearchFilters, +} from '@domain/ports/in/search-csv-rates.port'; +import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto'; +import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto'; +import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; + +/** + * CSV Rate Mapper + * + * Maps between domain entities and DTOs + * Follows hexagonal architecture principles + */ +@Injectable() +export class CsvRateMapper { + /** + * Map DTO filters to domain filters + */ + mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined { + if (!dto) { + return undefined; + } + + return { + companies: dto.companies, + minVolumeCBM: dto.minVolumeCBM, + maxVolumeCBM: dto.maxVolumeCBM, + minWeightKG: dto.minWeightKG, + maxWeightKG: dto.maxWeightKG, + palletCount: dto.palletCount, + minPrice: dto.minPrice, + maxPrice: dto.maxPrice, + currency: dto.currency, + minTransitDays: dto.minTransitDays, + maxTransitDays: dto.maxTransitDays, + containerTypes: dto.containerTypes, + onlyAllInPrices: dto.onlyAllInPrices, + departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined, + }; + } + + /** + * Map domain search result to DTO + */ + mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto { + const rate = result.rate; + + return { + companyName: rate.companyName, + origin: rate.origin.getValue(), + destination: rate.destination.getValue(), + containerType: rate.containerType.getValue(), + priceUSD: result.calculatedPrice.usd, + priceEUR: result.calculatedPrice.eur, + primaryCurrency: result.calculatedPrice.primaryCurrency, + hasSurcharges: rate.hasSurcharges(), + surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null, + transitDays: rate.transitDays, + validUntil: rate.validity.getEndDate().toISOString().split('T')[0], + source: result.source, + matchScore: result.matchScore, + }; + } + + /** + * Map domain search output to response DTO + */ + mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto { + return { + results: output.results.map(result => this.mapSearchResultToDto(result)), + totalResults: output.totalResults, + searchedFiles: output.searchedFiles, + searchedAt: output.searchedAt, + appliedFilters: output.appliedFilters as any, // Already matches DTO structure + }; + } + + /** + * Map ORM entity to DTO + */ + mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto { + return { + id: entity.id, + companyName: entity.companyName, + csvFilePath: entity.csvFilePath, + type: entity.type, + hasApi: entity.hasApi, + apiConnector: entity.apiConnector, + isActive: entity.isActive, + uploadedAt: entity.uploadedAt, + rowCount: entity.rowCount, + metadata: entity.metadata, + }; + } + + /** + * Map multiple config entities to DTOs + */ + mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] { + return entities.map(entity => this.mapConfigEntityToDto(entity)); + } +} diff --git a/apps/backend/src/application/mappers/index.ts b/apps/backend/src/application/mappers/index.ts index 5eb3980..1d63164 100644 --- a/apps/backend/src/application/mappers/index.ts +++ b/apps/backend/src/application/mappers/index.ts @@ -1,2 +1,2 @@ -export * from './rate-quote.mapper'; -export * from './booking.mapper'; +export * from './rate-quote.mapper'; +export * from './booking.mapper'; diff --git a/apps/backend/src/application/mappers/organization.mapper.ts b/apps/backend/src/application/mappers/organization.mapper.ts index b12ecb0..0f3ba05 100644 --- a/apps/backend/src/application/mappers/organization.mapper.ts +++ b/apps/backend/src/application/mappers/organization.mapper.ts @@ -1,83 +1,81 @@ -import { - Organization, - OrganizationAddress, - OrganizationDocument, -} from '../../domain/entities/organization.entity'; -import { - OrganizationResponseDto, - OrganizationDocumentDto, - AddressDto, -} from '../dto/organization.dto'; - -/** - * Organization Mapper - * - * Maps between Organization domain entities and DTOs - */ -export class OrganizationMapper { - /** - * Convert Organization entity to DTO - */ - static toDto(organization: Organization): OrganizationResponseDto { - return { - id: organization.id, - name: organization.name, - type: organization.type, - scac: organization.scac, - address: this.mapAddressToDto(organization.address), - logoUrl: organization.logoUrl, - documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), - isActive: organization.isActive, - createdAt: organization.createdAt, - updatedAt: organization.updatedAt, - }; - } - - /** - * Convert array of Organization entities to DTOs - */ - static toDtoArray(organizations: Organization[]): OrganizationResponseDto[] { - return organizations.map(org => this.toDto(org)); - } - - /** - * Map Address entity to DTO - */ - private static mapAddressToDto(address: OrganizationAddress): AddressDto { - return { - street: address.street, - city: address.city, - state: address.state, - postalCode: address.postalCode, - country: address.country, - }; - } - - /** - * Map Document entity to DTO - */ - private static mapDocumentToDto( - document: OrganizationDocument, - ): OrganizationDocumentDto { - return { - id: document.id, - type: document.type, - name: document.name, - url: document.url, - uploadedAt: document.uploadedAt, - }; - } - - /** - * Map DTO Address to domain Address - */ - static mapDtoToAddress(dto: AddressDto): OrganizationAddress { - return { - street: dto.street, - city: dto.city, - state: dto.state, - postalCode: dto.postalCode, - country: dto.country, - }; - } -} +import { + Organization, + OrganizationAddress, + OrganizationDocument, +} from '../../domain/entities/organization.entity'; +import { + OrganizationResponseDto, + OrganizationDocumentDto, + AddressDto, +} from '../dto/organization.dto'; + +/** + * Organization Mapper + * + * Maps between Organization domain entities and DTOs + */ +export class OrganizationMapper { + /** + * Convert Organization entity to DTO + */ + static toDto(organization: Organization): OrganizationResponseDto { + return { + id: organization.id, + name: organization.name, + type: organization.type, + scac: organization.scac, + address: this.mapAddressToDto(organization.address), + logoUrl: organization.logoUrl, + documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), + isActive: organization.isActive, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + }; + } + + /** + * Convert array of Organization entities to DTOs + */ + static toDtoArray(organizations: Organization[]): OrganizationResponseDto[] { + return organizations.map(org => this.toDto(org)); + } + + /** + * Map Address entity to DTO + */ + private static mapAddressToDto(address: OrganizationAddress): AddressDto { + return { + street: address.street, + city: address.city, + state: address.state, + postalCode: address.postalCode, + country: address.country, + }; + } + + /** + * Map Document entity to DTO + */ + private static mapDocumentToDto(document: OrganizationDocument): OrganizationDocumentDto { + return { + id: document.id, + type: document.type, + name: document.name, + url: document.url, + uploadedAt: document.uploadedAt, + }; + } + + /** + * Map DTO Address to domain Address + */ + static mapDtoToAddress(dto: AddressDto): OrganizationAddress { + return { + street: dto.street, + city: dto.city, + state: dto.state, + postalCode: dto.postalCode, + country: dto.country, + }; + } +} diff --git a/apps/backend/src/application/mappers/rate-quote.mapper.ts b/apps/backend/src/application/mappers/rate-quote.mapper.ts index 6bb3d36..a46c393 100644 --- a/apps/backend/src/application/mappers/rate-quote.mapper.ts +++ b/apps/backend/src/application/mappers/rate-quote.mapper.ts @@ -1,69 +1,69 @@ -import { RateQuote } from '../../domain/entities/rate-quote.entity'; -import { - RateQuoteDto, - PortDto, - SurchargeDto, - PricingDto, - RouteSegmentDto, -} from '../dto/rate-search-response.dto'; - -export class RateQuoteMapper { - /** - * Map domain RateQuote entity to DTO - */ - static toDto(entity: RateQuote): RateQuoteDto { - return { - id: entity.id, - carrierId: entity.carrierId, - carrierName: entity.carrierName, - carrierCode: entity.carrierCode, - origin: { - code: entity.origin.code, - name: entity.origin.name, - country: entity.origin.country, - }, - destination: { - code: entity.destination.code, - name: entity.destination.name, - country: entity.destination.country, - }, - pricing: { - baseFreight: entity.pricing.baseFreight, - surcharges: entity.pricing.surcharges.map((s) => ({ - type: s.type, - description: s.description, - amount: s.amount, - currency: s.currency, - })), - totalAmount: entity.pricing.totalAmount, - currency: entity.pricing.currency, - }, - containerType: entity.containerType, - mode: entity.mode, - etd: entity.etd.toISOString(), - eta: entity.eta.toISOString(), - transitDays: entity.transitDays, - route: entity.route.map((segment) => ({ - portCode: segment.portCode, - portName: segment.portName, - arrival: segment.arrival?.toISOString(), - departure: segment.departure?.toISOString(), - vesselName: segment.vesselName, - voyageNumber: segment.voyageNumber, - })), - availability: entity.availability, - frequency: entity.frequency, - vesselType: entity.vesselType, - co2EmissionsKg: entity.co2EmissionsKg, - validUntil: entity.validUntil.toISOString(), - createdAt: entity.createdAt.toISOString(), - }; - } - - /** - * Map array of RateQuote entities to DTOs - */ - static toDtoArray(entities: RateQuote[]): RateQuoteDto[] { - return entities.map((entity) => this.toDto(entity)); - } -} +import { RateQuote } from '../../domain/entities/rate-quote.entity'; +import { + RateQuoteDto, + PortDto, + SurchargeDto, + PricingDto, + RouteSegmentDto, +} from '../dto/rate-search-response.dto'; + +export class RateQuoteMapper { + /** + * Map domain RateQuote entity to DTO + */ + static toDto(entity: RateQuote): RateQuoteDto { + return { + id: entity.id, + carrierId: entity.carrierId, + carrierName: entity.carrierName, + carrierCode: entity.carrierCode, + origin: { + code: entity.origin.code, + name: entity.origin.name, + country: entity.origin.country, + }, + destination: { + code: entity.destination.code, + name: entity.destination.name, + country: entity.destination.country, + }, + pricing: { + baseFreight: entity.pricing.baseFreight, + surcharges: entity.pricing.surcharges.map(s => ({ + type: s.type, + description: s.description, + amount: s.amount, + currency: s.currency, + })), + totalAmount: entity.pricing.totalAmount, + currency: entity.pricing.currency, + }, + containerType: entity.containerType, + mode: entity.mode, + etd: entity.etd.toISOString(), + eta: entity.eta.toISOString(), + transitDays: entity.transitDays, + route: entity.route.map(segment => ({ + portCode: segment.portCode, + portName: segment.portName, + arrival: segment.arrival?.toISOString(), + departure: segment.departure?.toISOString(), + vesselName: segment.vesselName, + voyageNumber: segment.voyageNumber, + })), + availability: entity.availability, + frequency: entity.frequency, + vesselType: entity.vesselType, + co2EmissionsKg: entity.co2EmissionsKg, + validUntil: entity.validUntil.toISOString(), + createdAt: entity.createdAt.toISOString(), + }; + } + + /** + * Map array of RateQuote entities to DTOs + */ + static toDtoArray(entities: RateQuote[]): RateQuoteDto[] { + return entities.map(entity => this.toDto(entity)); + } +} diff --git a/apps/backend/src/application/rates/rates.module.ts b/apps/backend/src/application/rates/rates.module.ts index 1a360fa..173f859 100644 --- a/apps/backend/src/application/rates/rates.module.ts +++ b/apps/backend/src/application/rates/rates.module.ts @@ -45,33 +45,14 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit }, { provide: RateSearchService, - useFactory: ( - cache: any, - rateQuoteRepo: any, - portRepo: any, - carrierRepo: any, - ) => { + useFactory: (cache: any, rateQuoteRepo: any, portRepo: any, carrierRepo: any) => { // For now, create service with empty connectors array // TODO: Inject actual carrier connectors - return new RateSearchService( - [], - cache, - rateQuoteRepo, - portRepo, - carrierRepo, - ); + return new RateSearchService([], cache, rateQuoteRepo, portRepo, carrierRepo); }, - inject: [ - CACHE_PORT, - RATE_QUOTE_REPOSITORY, - PORT_REPOSITORY, - CARRIER_REPOSITORY, - ], + inject: [CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY], }, ], - exports: [ - RATE_QUOTE_REPOSITORY, - RateSearchService, - ], + exports: [RATE_QUOTE_REPOSITORY, RateSearchService], }) export class RatesModule {} diff --git a/apps/backend/src/application/services/analytics.service.ts b/apps/backend/src/application/services/analytics.service.ts index bb93e28..1f23605 100644 --- a/apps/backend/src/application/services/analytics.service.ts +++ b/apps/backend/src/application/services/analytics.service.ts @@ -53,7 +53,7 @@ export class AnalyticsService { @Inject(BOOKING_REPOSITORY) private readonly bookingRepository: BookingRepository, @Inject(RATE_QUOTE_REPOSITORY) - private readonly rateQuoteRepository: RateQuoteRepository, + private readonly rateQuoteRepository: RateQuoteRepository ) {} /** @@ -70,13 +70,11 @@ export class AnalyticsService { const allBookings = await this.bookingRepository.findByOrganization(organizationId); // This month bookings - const thisMonthBookings = allBookings.filter( - (b) => b.createdAt >= thisMonthStart - ); + const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart); // Last month bookings const lastMonthBookings = allBookings.filter( - (b) => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd + b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd ); // Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU) @@ -118,10 +116,10 @@ export class AnalyticsService { // Pending confirmations (status = pending_confirmation) const pendingThisMonth = thisMonthBookings.filter( - (b) => b.status.value === 'pending_confirmation' + b => b.status.value === 'pending_confirmation' ).length; const pendingLastMonth = lastMonthBookings.filter( - (b) => b.status.value === 'pending_confirmation' + b => b.status.value === 'pending_confirmation' ).length; // Calculate percentage changes @@ -135,15 +133,9 @@ export class AnalyticsService { totalTEUs: totalTEUsThisMonth, estimatedRevenue: estimatedRevenueThisMonth, pendingConfirmations: pendingThisMonth, - bookingsThisMonthChange: calculateChange( - thisMonthBookings.length, - lastMonthBookings.length - ), + bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length), totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth), - estimatedRevenueChange: calculateChange( - estimatedRevenueThisMonth, - estimatedRevenueLastMonth - ), + estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth), pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth), }; } @@ -172,7 +164,7 @@ export class AnalyticsService { // Count bookings in this month const count = allBookings.filter( - (b) => b.createdAt >= monthDate && b.createdAt <= monthEnd + b => b.createdAt >= monthDate && b.createdAt <= monthEnd ).length; data.push(count); } @@ -187,13 +179,16 @@ export class AnalyticsService { const allBookings = await this.bookingRepository.findByOrganization(organizationId); // Group by route (origin-destination) - const routeMap = new Map(); + const routeMap = new Map< + string, + { + originPort: string; + destinationPort: string; + bookingCount: number; + totalTEUs: number; + totalPrice: number; + } + >(); for (const booking of allBookings) { try { @@ -231,16 +226,14 @@ export class AnalyticsService { } // Convert to array and sort by booking count - const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map( - ([route, data]) => ({ - route, - originPort: data.originPort, - destinationPort: data.destinationPort, - bookingCount: data.bookingCount, - totalTEUs: data.totalTEUs, - avgPrice: data.totalPrice / data.bookingCount, - }) - ); + const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({ + route, + originPort: data.originPort, + destinationPort: data.destinationPort, + bookingCount: data.bookingCount, + totalTEUs: data.totalTEUs, + avgPrice: data.totalPrice / data.bookingCount, + })); // Sort by booking count and return top 5 return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5); @@ -256,7 +249,7 @@ export class AnalyticsService { // Check for pending confirmations (older than 24h) const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const oldPendingBookings = allBookings.filter( - (b) => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo + b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo ); for (const booking of oldPendingBookings) { diff --git a/apps/backend/src/application/services/audit.service.spec.ts b/apps/backend/src/application/services/audit.service.spec.ts index 20989fd..dbf55ca 100644 --- a/apps/backend/src/application/services/audit.service.spec.ts +++ b/apps/backend/src/application/services/audit.service.spec.ts @@ -4,7 +4,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuditService } from './audit.service'; -import { AUDIT_LOG_REPOSITORY, AuditLogRepository } from '../../domain/ports/out/audit-log.repository'; +import { + AUDIT_LOG_REPOSITORY, + AuditLogRepository, +} from '../../domain/ports/out/audit-log.repository'; import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity'; describe('AuditService', () => { diff --git a/apps/backend/src/application/services/audit.service.ts b/apps/backend/src/application/services/audit.service.ts index 33330e5..2726026 100644 --- a/apps/backend/src/application/services/audit.service.ts +++ b/apps/backend/src/application/services/audit.service.ts @@ -7,11 +7,7 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; -import { - AuditLog, - AuditAction, - AuditStatus, -} from '../../domain/entities/audit-log.entity'; +import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity'; import { AuditLogRepository, AUDIT_LOG_REPOSITORY, @@ -39,7 +35,7 @@ export class AuditService { constructor( @Inject(AUDIT_LOG_REPOSITORY) - private readonly auditLogRepository: AuditLogRepository, + private readonly auditLogRepository: AuditLogRepository ) {} /** @@ -54,14 +50,12 @@ export class AuditService { await this.auditLogRepository.save(auditLog); - this.logger.log( - `Audit log created: ${input.action} by ${input.userEmail} (${input.status})`, - ); + this.logger.log(`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`); } catch (error: any) { // Never throw on audit logging failure - log the error and continue this.logger.error( `Failed to create audit log: ${error?.message || 'Unknown error'}`, - error?.stack, + error?.stack ); } } @@ -81,7 +75,7 @@ export class AuditService { metadata?: Record; ipAddress?: string; userAgent?: string; - }, + } ): Promise { await this.log({ action, @@ -108,7 +102,7 @@ export class AuditService { metadata?: Record; ipAddress?: string; userAgent?: string; - }, + } ): Promise { await this.log({ action, @@ -139,20 +133,14 @@ export class AuditService { /** * Get audit trail for a specific resource */ - async getResourceAuditTrail( - resourceType: string, - resourceId: string, - ): Promise { + async getResourceAuditTrail(resourceType: string, resourceId: string): Promise { return this.auditLogRepository.findByResource(resourceType, resourceId); } /** * Get recent activity for an organization */ - async getOrganizationActivity( - organizationId: string, - limit: number = 50, - ): Promise { + async getOrganizationActivity(organizationId: string, limit: number = 50): Promise { return this.auditLogRepository.findRecentByOrganization(organizationId, limit); } diff --git a/apps/backend/src/application/services/booking-automation.service.ts b/apps/backend/src/application/services/booking-automation.service.ts index 1cfa291..8c6b985 100644 --- a/apps/backend/src/application/services/booking-automation.service.ts +++ b/apps/backend/src/application/services/booking-automation.service.ts @@ -8,12 +8,12 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { Booking } from '../../domain/entities/booking.entity'; import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port'; import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port'; -import { - StoragePort, - STORAGE_PORT, -} from '../../domain/ports/out/storage.port'; +import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port'; import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository'; -import { RateQuoteRepository, RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository'; +import { + RateQuoteRepository, + RATE_QUOTE_REPOSITORY, +} from '../../domain/ports/out/rate-quote.repository'; @Injectable() export class BookingAutomationService { @@ -24,16 +24,14 @@ export class BookingAutomationService { @Inject(PDF_PORT) private readonly pdfPort: PdfPort, @Inject(STORAGE_PORT) private readonly storagePort: StoragePort, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, - @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository, + @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository ) {} /** * Execute all post-booking automation tasks */ async executePostBookingTasks(booking: Booking): Promise { - this.logger.log( - `Starting post-booking automation for booking: ${booking.bookingNumber.value}` - ); + this.logger.log(`Starting post-booking automation for booking: ${booking.bookingNumber.value}`); try { // Get user and rate quote details @@ -42,9 +40,7 @@ export class BookingAutomationService { throw new Error(`User not found: ${booking.userId}`); } - const rateQuote = await this.rateQuoteRepository.findById( - booking.rateQuoteId - ); + const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); if (!rateQuote) { throw new Error(`Rate quote not found: ${booking.rateQuoteId}`); } @@ -79,7 +75,7 @@ export class BookingAutomationService { email: booking.consignee.contactEmail, phone: booking.consignee.contactPhone, }, - containers: booking.containers.map((c) => ({ + containers: booking.containers.map(c => ({ type: c.type, quantity: 1, containerNumber: c.containerNumber, @@ -173,10 +169,7 @@ export class BookingAutomationService { `Sent ${updateType} notification for booking: ${booking.bookingNumber.value}` ); } catch (error) { - this.logger.error( - `Failed to send booking update notification`, - error - ); + this.logger.error(`Failed to send booking update notification`, error); } } } diff --git a/apps/backend/src/application/services/brute-force-protection.service.ts b/apps/backend/src/application/services/brute-force-protection.service.ts index be73b1b..6f054a2 100644 --- a/apps/backend/src/application/services/brute-force-protection.service.ts +++ b/apps/backend/src/application/services/brute-force-protection.service.ts @@ -38,13 +38,11 @@ export class BruteForceProtectionService { // Calculate block time with exponential backoff if (existing.count > bruteForceConfig.freeRetries) { - const waitTime = this.calculateWaitTime( - existing.count - bruteForceConfig.freeRetries, - ); + const waitTime = this.calculateWaitTime(existing.count - bruteForceConfig.freeRetries); existing.blockedUntil = new Date(now.getTime() + waitTime); this.logger.warn( - `Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`, + `Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}` ); } @@ -99,7 +97,7 @@ export class BruteForceProtectionService { const now = new Date(); const remaining = Math.max( 0, - Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000), + Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000) ); return remaining; @@ -116,8 +114,7 @@ export class BruteForceProtectionService { * Calculate wait time with exponential backoff */ private calculateWaitTime(failedAttempts: number): number { - const waitTime = - bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1); + const waitTime = bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1); return Math.min(waitTime, bruteForceConfig.maxWait); } @@ -163,10 +160,7 @@ export class BruteForceProtectionService { return { totalAttempts, currentlyBlocked, - averageAttempts: - this.attempts.size > 0 - ? Math.round(totalAttempts / this.attempts.size) - : 0, + averageAttempts: this.attempts.size > 0 ? Math.round(totalAttempts / this.attempts.size) : 0, }; } @@ -190,9 +184,7 @@ export class BruteForceProtectionService { }); } - this.logger.warn( - `Manually blocked ${identifier} for ${durationMs / 1000} seconds`, - ); + this.logger.warn(`Manually blocked ${identifier} for ${durationMs / 1000} seconds`); } /** diff --git a/apps/backend/src/application/services/export.service.ts b/apps/backend/src/application/services/export.service.ts index 3d04cfe..c3d2bde 100644 --- a/apps/backend/src/application/services/export.service.ts +++ b/apps/backend/src/application/services/export.service.ts @@ -25,10 +25,10 @@ export class ExportService { async exportBookings( data: BookingExportData[], format: ExportFormat, - fields?: ExportField[], + fields?: ExportField[] ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { this.logger.log( - `Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`, + `Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields` ); switch (format) { @@ -48,17 +48,17 @@ export class ExportService { */ private async exportToCSV( data: BookingExportData[], - fields?: ExportField[], + fields?: ExportField[] ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { const selectedFields = fields || Object.values(ExportField); - const rows = data.map((item) => this.extractFields(item, selectedFields)); + const rows = data.map(item => this.extractFields(item, selectedFields)); // Build CSV header - const header = selectedFields.map((field) => this.getFieldLabel(field)).join(','); + const header = selectedFields.map(field => this.getFieldLabel(field)).join(','); // Build CSV rows - const csvRows = rows.map((row) => - selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','), + const csvRows = rows.map(row => + selectedFields.map(field => this.escapeCSVValue(row[field] || '')).join(',') ); const csv = [header, ...csvRows].join('\n'); @@ -79,10 +79,10 @@ export class ExportService { */ private async exportToExcel( data: BookingExportData[], - fields?: ExportField[], + fields?: ExportField[] ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { const selectedFields = fields || Object.values(ExportField); - const rows = data.map((item) => this.extractFields(item, selectedFields)); + const rows = data.map(item => this.extractFields(item, selectedFields)); const workbook = new ExcelJS.Workbook(); workbook.creator = 'Xpeditis'; @@ -91,9 +91,7 @@ export class ExportService { const worksheet = workbook.addWorksheet('Bookings'); // Add header row with styling - const headerRow = worksheet.addRow( - selectedFields.map((field) => this.getFieldLabel(field)), - ); + const headerRow = worksheet.addRow(selectedFields.map(field => this.getFieldLabel(field))); headerRow.font = { bold: true }; headerRow.fill = { type: 'pattern', @@ -102,15 +100,15 @@ export class ExportService { }; // Add data rows - rows.forEach((row) => { - const values = selectedFields.map((field) => row[field] || ''); + rows.forEach(row => { + const values = selectedFields.map(field => row[field] || ''); worksheet.addRow(values); }); // Auto-fit columns - worksheet.columns.forEach((column) => { + worksheet.columns.forEach(column => { let maxLength = 10; - column.eachCell?.({ includeEmpty: false }, (cell) => { + column.eachCell?.({ includeEmpty: false }, cell => { const columnLength = cell.value ? String(cell.value).length : 10; if (columnLength > maxLength) { maxLength = columnLength; @@ -136,10 +134,10 @@ export class ExportService { */ private async exportToJSON( data: BookingExportData[], - fields?: ExportField[], + fields?: ExportField[] ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { const selectedFields = fields || Object.values(ExportField); - const rows = data.map((item) => this.extractFields(item, selectedFields)); + const rows = data.map(item => this.extractFields(item, selectedFields)); const json = JSON.stringify( { @@ -148,7 +146,7 @@ export class ExportService { bookings: rows, }, null, - 2, + 2 ); const buffer = Buffer.from(json, 'utf-8'); @@ -166,14 +164,11 @@ export class ExportService { /** * Extract specified fields from booking data */ - private extractFields( - data: BookingExportData, - fields: ExportField[], - ): Record { + private extractFields(data: BookingExportData, fields: ExportField[]): Record { const { booking, rateQuote } = data; const result: Record = {}; - fields.forEach((field) => { + fields.forEach(field => { switch (field) { case ExportField.BOOKING_NUMBER: result[field] = booking.bookingNumber.value; @@ -206,7 +201,7 @@ export class ExportService { result[field] = booking.consignee.name; break; case ExportField.CONTAINER_TYPE: - result[field] = booking.containers.map((c) => c.type).join(', '); + result[field] = booking.containers.map(c => c.type).join(', '); break; case ExportField.CONTAINER_COUNT: result[field] = booking.containers.length; @@ -217,7 +212,8 @@ export class ExportService { }, 0); break; case ExportField.PRICE: - result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`; + result[field] = + `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`; break; } }); @@ -253,11 +249,7 @@ export class ExportService { */ private escapeCSVValue(value: string): string { const stringValue = String(value); - if ( - stringValue.includes(',') || - stringValue.includes('"') || - stringValue.includes('\n') - ) { + if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) { return `"${stringValue.replace(/"/g, '""')}"`; } return stringValue; diff --git a/apps/backend/src/application/services/file-validation.service.ts b/apps/backend/src/application/services/file-validation.service.ts index 3c3a90d..d60f92d 100644 --- a/apps/backend/src/application/services/file-validation.service.ts +++ b/apps/backend/src/application/services/file-validation.service.ts @@ -32,14 +32,14 @@ export class FileValidationService { // Validate file size if (file.size > fileUploadConfig.maxFileSize) { errors.push( - `File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`, + `File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB` ); } // Validate MIME type if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) { errors.push( - `File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`, + `File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}` ); } @@ -47,7 +47,7 @@ export class FileValidationService { const ext = path.extname(file.originalname).toLowerCase(); if (!fileUploadConfig.allowedExtensions.includes(ext)) { errors.push( - `File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`, + `File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}` ); } @@ -129,7 +129,7 @@ export class FileValidationService { ]; const lowerFilename = filename.toLowerCase(); - return dangerousExtensions.some((ext) => lowerFilename.includes(ext)); + return dangerousExtensions.some(ext => lowerFilename.includes(ext)); } /** @@ -180,9 +180,7 @@ export class FileValidationService { // TODO: Integrate with ClamAV or similar virus scanner // For now, just log - this.logger.log( - `Virus scan requested for file: ${file.originalname} (not implemented)`, - ); + this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`); return true; } @@ -190,9 +188,7 @@ export class FileValidationService { /** * Validate multiple files */ - async validateFiles( - files: Express.Multer.File[], - ): Promise { + async validateFiles(files: Express.Multer.File[]): Promise { const allErrors: string[] = []; for (const file of files) { diff --git a/apps/backend/src/application/services/fuzzy-search.service.ts b/apps/backend/src/application/services/fuzzy-search.service.ts index 962ec96..5d4baf4 100644 --- a/apps/backend/src/application/services/fuzzy-search.service.ts +++ b/apps/backend/src/application/services/fuzzy-search.service.ts @@ -16,7 +16,7 @@ export class FuzzySearchService { constructor( @InjectRepository(BookingOrmEntity) - private readonly bookingOrmRepository: Repository, + private readonly bookingOrmRepository: Repository ) {} /** @@ -26,15 +26,13 @@ export class FuzzySearchService { async fuzzySearchBookings( searchTerm: string, organizationId: string, - limit: number = 20, + limit: number = 20 ): Promise { if (!searchTerm || searchTerm.length < 2) { return []; } - this.logger.log( - `Fuzzy search for "${searchTerm}" in organization ${organizationId}`, - ); + this.logger.log(`Fuzzy search for "${searchTerm}" in organization ${organizationId}`); // Use PostgreSQL full-text search with similarity // This requires pg_trgm extension to be enabled @@ -54,7 +52,7 @@ export class FuzzySearchService { { searchTerm, likeTerm: `%${searchTerm}%`, - }, + } ) .orderBy( `GREATEST( @@ -62,7 +60,7 @@ export class FuzzySearchService { similarity(booking.shipper_name, :searchTerm), similarity(booking.consignee_name, :searchTerm) )`, - 'DESC', + 'DESC' ) .setParameter('searchTerm', searchTerm) .limit(limit) @@ -80,21 +78,19 @@ export class FuzzySearchService { async fullTextSearch( searchTerm: string, organizationId: string, - limit: number = 20, + limit: number = 20 ): Promise { if (!searchTerm || searchTerm.length < 2) { return []; } - this.logger.log( - `Full-text search for "${searchTerm}" in organization ${organizationId}`, - ); + this.logger.log(`Full-text search for "${searchTerm}" in organization ${organizationId}`); // Convert search term to tsquery format const tsquery = searchTerm .split(/\s+/) - .filter((term) => term.length > 0) - .map((term) => `${term}:*`) + .filter(term => term.length > 0) + .map(term => `${term}:*`) .join(' & '); const results = await this.bookingOrmRepository @@ -111,7 +107,7 @@ export class FuzzySearchService { { tsquery, likeTerm: `%${searchTerm}%`, - }, + } ) .orderBy('booking.created_at', 'DESC') .limit(limit) @@ -128,7 +124,7 @@ export class FuzzySearchService { async search( searchTerm: string, organizationId: string, - limit: number = 20, + limit: number = 20 ): Promise { // Try fuzzy search first (more tolerant to typos) let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit); diff --git a/apps/backend/src/application/services/gdpr.service.ts b/apps/backend/src/application/services/gdpr.service.ts index 7347745..3f637e8 100644 --- a/apps/backend/src/application/services/gdpr.service.ts +++ b/apps/backend/src/application/services/gdpr.service.ts @@ -31,7 +31,7 @@ export class GDPRService { constructor( @InjectRepository(UserOrmEntity) - private readonly userRepository: Repository, + private readonly userRepository: Repository ) {} /** @@ -63,7 +63,8 @@ export class GDPRService { exportDate: new Date().toISOString(), userId, userData: sanitizedUser, - message: 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', + message: + 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.', }; this.logger.log(`Data export completed for user ${userId}`); @@ -76,7 +77,9 @@ export class GDPRService { * Note: This is a simplified version. In production, implement full anonymization logic. */ async deleteUserData(userId: string, reason?: string): Promise { - this.logger.warn(`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`); + this.logger.warn( + `Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}` + ); // Verify user exists const user = await this.userRepository.findOne({ where: { id: userId } }); @@ -117,7 +120,9 @@ export class GDPRService { // In production, store in separate consent table // For now, just log the consent - this.logger.log(`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`); + this.logger.log( + `Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}` + ); } /** diff --git a/apps/backend/src/application/services/notification.service.spec.ts b/apps/backend/src/application/services/notification.service.spec.ts index a6ae894..6bb620b 100644 --- a/apps/backend/src/application/services/notification.service.spec.ts +++ b/apps/backend/src/application/services/notification.service.spec.ts @@ -4,8 +4,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotificationService } from './notification.service'; -import { NOTIFICATION_REPOSITORY, NotificationRepository } from '../../domain/ports/out/notification.repository'; -import { Notification, NotificationType, NotificationPriority } from '../../domain/entities/notification.entity'; +import { + NOTIFICATION_REPOSITORY, + NotificationRepository, +} from '../../domain/ports/out/notification.repository'; +import { + Notification, + NotificationType, + NotificationPriority, +} from '../../domain/entities/notification.entity'; describe('NotificationService', () => { let service: NotificationService; diff --git a/apps/backend/src/application/services/notification.service.ts b/apps/backend/src/application/services/notification.service.ts index 8b74c7a..30f0647 100644 --- a/apps/backend/src/application/services/notification.service.ts +++ b/apps/backend/src/application/services/notification.service.ts @@ -34,7 +34,7 @@ export class NotificationService { constructor( @Inject(NOTIFICATION_REPOSITORY) - private readonly notificationRepository: NotificationRepository, + private readonly notificationRepository: NotificationRepository ) {} /** @@ -50,14 +50,14 @@ export class NotificationService { await this.notificationRepository.save(notification); this.logger.log( - `Notification created: ${input.type} for user ${input.userId} - ${input.title}`, + `Notification created: ${input.type} for user ${input.userId} - ${input.title}` ); return notification; } catch (error: any) { this.logger.error( `Failed to create notification: ${error?.message || 'Unknown error'}`, - error?.stack, + error?.stack ); throw error; } @@ -147,7 +147,7 @@ export class NotificationService { userId: string, organizationId: string, bookingNumber: string, - bookingId: string, + bookingId: string ): Promise { return this.createNotification({ userId, @@ -166,7 +166,7 @@ export class NotificationService { organizationId: string, bookingNumber: string, bookingId: string, - status: string, + status: string ): Promise { return this.createNotification({ userId, @@ -184,7 +184,7 @@ export class NotificationService { userId: string, organizationId: string, bookingNumber: string, - bookingId: string, + bookingId: string ): Promise { return this.createNotification({ userId, @@ -202,7 +202,7 @@ export class NotificationService { userId: string, organizationId: string, documentName: string, - bookingId: string, + bookingId: string ): Promise { return this.createNotification({ userId, diff --git a/apps/backend/src/application/services/webhook.service.spec.ts b/apps/backend/src/application/services/webhook.service.spec.ts index 10a1d18..92aff0b 100644 --- a/apps/backend/src/application/services/webhook.service.spec.ts +++ b/apps/backend/src/application/services/webhook.service.spec.ts @@ -123,11 +123,9 @@ describe('WebhookService', () => { of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any }) ); - await service.triggerWebhooks( - WebhookEvent.BOOKING_CREATED, - 'org-123', - { bookingId: 'booking-123' } - ); + await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', { + bookingId: 'booking-123', + }); expect(httpService.post).toHaveBeenCalledWith( 'https://example.com/webhook', @@ -151,11 +149,9 @@ describe('WebhookService', () => { repository.findActiveByEvent.mockResolvedValue([webhook]); httpService.post.mockReturnValue(throwError(() => new Error('Network error'))); - await service.triggerWebhooks( - WebhookEvent.BOOKING_CREATED, - 'org-123', - { bookingId: 'booking-123' } - ); + await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', { + bookingId: 'booking-123', + }); // Should be saved as failed after retries expect(repository.save).toHaveBeenCalledWith( diff --git a/apps/backend/src/application/services/webhook.service.ts b/apps/backend/src/application/services/webhook.service.ts index b6a499b..83deb90 100644 --- a/apps/backend/src/application/services/webhook.service.ts +++ b/apps/backend/src/application/services/webhook.service.ts @@ -9,11 +9,7 @@ import { HttpService } from '@nestjs/axios'; import { v4 as uuidv4 } from 'uuid'; import * as crypto from 'crypto'; import { firstValueFrom } from 'rxjs'; -import { - Webhook, - WebhookEvent, - WebhookStatus, -} from '../../domain/entities/webhook.entity'; +import { Webhook, WebhookEvent, WebhookStatus } from '../../domain/entities/webhook.entity'; import { WebhookRepository, WEBHOOK_REPOSITORY, @@ -51,7 +47,7 @@ export class WebhookService { constructor( @Inject(WEBHOOK_REPOSITORY) private readonly webhookRepository: WebhookRepository, - private readonly httpService: HttpService, + private readonly httpService: HttpService ) {} /** @@ -72,9 +68,7 @@ export class WebhookService { await this.webhookRepository.save(webhook); - this.logger.log( - `Webhook created: ${webhook.id} for organization ${input.organizationId}`, - ); + this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`); return webhook; } @@ -158,11 +152,7 @@ export class WebhookService { /** * Trigger webhooks for an event */ - async triggerWebhooks( - event: WebhookEvent, - organizationId: string, - data: any, - ): Promise { + async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise { try { const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId); @@ -179,17 +169,13 @@ export class WebhookService { }; // Trigger all webhooks in parallel - await Promise.allSettled( - webhooks.map((webhook) => this.triggerWebhook(webhook, payload)), - ); + await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload))); - this.logger.log( - `Triggered ${webhooks.length} webhooks for event: ${event}`, - ); + this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`); } catch (error: any) { this.logger.error( `Error triggering webhooks: ${error?.message || 'Unknown error'}`, - error?.stack, + error?.stack ); } } @@ -197,10 +183,7 @@ export class WebhookService { /** * Trigger a single webhook with retries */ - private async triggerWebhook( - webhook: Webhook, - payload: WebhookPayload, - ): Promise { + private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) { @@ -226,7 +209,7 @@ export class WebhookService { this.httpService.post(webhook.url, payload, { headers, timeout: 10000, // 10 seconds - }), + }) ); if (response && response.status >= 200 && response.status < 300) { @@ -234,17 +217,17 @@ export class WebhookService { const updatedWebhook = webhook.recordTrigger(); await this.webhookRepository.save(updatedWebhook); - this.logger.log( - `Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`, - ); + this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`); return; } - lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`); + lastError = new Error( + `HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}` + ); } catch (error: any) { lastError = error; this.logger.warn( - `Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`, + `Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}` ); } } @@ -254,7 +237,7 @@ export class WebhookService { await this.webhookRepository.save(failedWebhook); this.logger.error( - `Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`, + `Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}` ); } @@ -279,16 +262,13 @@ export class WebhookService { */ verifySignature(payload: any, signature: string, secret: string): boolean { const expectedSignature = this.generateSignature(payload, secret); - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature), - ); + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); } /** * Delay helper for retries */ private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise(resolve => setTimeout(resolve, ms)); } } diff --git a/apps/backend/src/domain/entities/booking.entity.ts b/apps/backend/src/domain/entities/booking.entity.ts index a32a189..ac496c2 100644 --- a/apps/backend/src/domain/entities/booking.entity.ts +++ b/apps/backend/src/domain/entities/booking.entity.ts @@ -1,299 +1,297 @@ -/** - * Booking Entity - * - * Represents a freight booking - * - * Business Rules: - * - Must have valid rate quote - * - Shipper and consignee are required - * - Status transitions must follow allowed paths - * - Containers can be added/updated until confirmed - * - Cannot modify confirmed bookings (except status) - */ - -import { BookingNumber } from '../value-objects/booking-number.vo'; -import { BookingStatus } from '../value-objects/booking-status.vo'; - -export interface Address { - street: string; - city: string; - postalCode: string; - country: string; -} - -export interface Party { - name: string; - address: Address; - contactName: string; - contactEmail: string; - contactPhone: string; -} - -export interface BookingContainer { - id: string; - type: string; - containerNumber?: string; - vgm?: number; // Verified Gross Mass in kg - temperature?: number; // For reefer containers - sealNumber?: string; -} - -export interface BookingProps { - id: string; - bookingNumber: BookingNumber; - userId: string; - organizationId: string; - rateQuoteId: string; - status: BookingStatus; - shipper: Party; - consignee: Party; - cargoDescription: string; - containers: BookingContainer[]; - specialInstructions?: string; - createdAt: Date; - updatedAt: Date; -} - -export class Booking { - private readonly props: BookingProps; - - private constructor(props: BookingProps) { - this.props = props; - } - - /** - * Factory method to create a new Booking - */ - static create( - props: Omit & { - id: string; - bookingNumber?: BookingNumber; - status?: BookingStatus; - } - ): Booking { - const now = new Date(); - - const bookingProps: BookingProps = { - ...props, - bookingNumber: props.bookingNumber || BookingNumber.generate(), - status: props.status || BookingStatus.create('draft'), - createdAt: now, - updatedAt: now, - }; - - // Validate business rules - Booking.validate(bookingProps); - - return new Booking(bookingProps); - } - - /** - * Validate business rules - */ - private static validate(props: BookingProps): void { - if (!props.userId) { - throw new Error('User ID is required'); - } - - if (!props.organizationId) { - throw new Error('Organization ID is required'); - } - - if (!props.rateQuoteId) { - throw new Error('Rate quote ID is required'); - } - - if (!props.shipper || !props.shipper.name) { - throw new Error('Shipper information is required'); - } - - if (!props.consignee || !props.consignee.name) { - throw new Error('Consignee information is required'); - } - - if (!props.cargoDescription || props.cargoDescription.length < 10) { - throw new Error('Cargo description must be at least 10 characters'); - } - } - - // Getters - get id(): string { - return this.props.id; - } - - get bookingNumber(): BookingNumber { - return this.props.bookingNumber; - } - - get userId(): string { - return this.props.userId; - } - - get organizationId(): string { - return this.props.organizationId; - } - - get rateQuoteId(): string { - return this.props.rateQuoteId; - } - - get status(): BookingStatus { - return this.props.status; - } - - get shipper(): Party { - return { ...this.props.shipper }; - } - - get consignee(): Party { - return { ...this.props.consignee }; - } - - get cargoDescription(): string { - return this.props.cargoDescription; - } - - get containers(): BookingContainer[] { - return [...this.props.containers]; - } - - get specialInstructions(): string | undefined { - return this.props.specialInstructions; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - /** - * Update booking status - */ - updateStatus(newStatus: BookingStatus): Booking { - if (!this.status.canTransitionTo(newStatus)) { - throw new Error( - `Cannot transition from ${this.status.value} to ${newStatus.value}` - ); - } - - return new Booking({ - ...this.props, - status: newStatus, - updatedAt: new Date(), - }); - } - - /** - * Add container to booking - */ - addContainer(container: BookingContainer): Booking { - if (!this.status.canBeModified()) { - throw new Error('Cannot modify containers after booking is confirmed'); - } - - return new Booking({ - ...this.props, - containers: [...this.props.containers, container], - updatedAt: new Date(), - }); - } - - /** - * Update container information - */ - updateContainer(containerId: string, updates: Partial): Booking { - if (!this.status.canBeModified()) { - throw new Error('Cannot modify containers after booking is confirmed'); - } - - const containerIndex = this.props.containers.findIndex((c) => c.id === containerId); - if (containerIndex === -1) { - throw new Error(`Container ${containerId} not found`); - } - - const updatedContainers = [...this.props.containers]; - updatedContainers[containerIndex] = { - ...updatedContainers[containerIndex], - ...updates, - }; - - return new Booking({ - ...this.props, - containers: updatedContainers, - updatedAt: new Date(), - }); - } - - /** - * Remove container from booking - */ - removeContainer(containerId: string): Booking { - if (!this.status.canBeModified()) { - throw new Error('Cannot modify containers after booking is confirmed'); - } - - return new Booking({ - ...this.props, - containers: this.props.containers.filter((c) => c.id !== containerId), - updatedAt: new Date(), - }); - } - - /** - * Update cargo description - */ - updateCargoDescription(description: string): Booking { - if (!this.status.canBeModified()) { - throw new Error('Cannot modify cargo description after booking is confirmed'); - } - - if (description.length < 10) { - throw new Error('Cargo description must be at least 10 characters'); - } - - return new Booking({ - ...this.props, - cargoDescription: description, - updatedAt: new Date(), - }); - } - - /** - * Update special instructions - */ - updateSpecialInstructions(instructions: string): Booking { - return new Booking({ - ...this.props, - specialInstructions: instructions, - updatedAt: new Date(), - }); - } - - /** - * Check if booking can be cancelled - */ - canBeCancelled(): boolean { - return !this.status.isFinal(); - } - - /** - * Cancel booking - */ - cancel(): Booking { - if (!this.canBeCancelled()) { - throw new Error('Cannot cancel booking in final state'); - } - - return this.updateStatus(BookingStatus.create('cancelled')); - } - - /** - * Equality check - */ - equals(other: Booking): boolean { - return this.id === other.id; - } -} +/** + * Booking Entity + * + * Represents a freight booking + * + * Business Rules: + * - Must have valid rate quote + * - Shipper and consignee are required + * - Status transitions must follow allowed paths + * - Containers can be added/updated until confirmed + * - Cannot modify confirmed bookings (except status) + */ + +import { BookingNumber } from '../value-objects/booking-number.vo'; +import { BookingStatus } from '../value-objects/booking-status.vo'; + +export interface Address { + street: string; + city: string; + postalCode: string; + country: string; +} + +export interface Party { + name: string; + address: Address; + contactName: string; + contactEmail: string; + contactPhone: string; +} + +export interface BookingContainer { + id: string; + type: string; + containerNumber?: string; + vgm?: number; // Verified Gross Mass in kg + temperature?: number; // For reefer containers + sealNumber?: string; +} + +export interface BookingProps { + id: string; + bookingNumber: BookingNumber; + userId: string; + organizationId: string; + rateQuoteId: string; + status: BookingStatus; + shipper: Party; + consignee: Party; + cargoDescription: string; + containers: BookingContainer[]; + specialInstructions?: string; + createdAt: Date; + updatedAt: Date; +} + +export class Booking { + private readonly props: BookingProps; + + private constructor(props: BookingProps) { + this.props = props; + } + + /** + * Factory method to create a new Booking + */ + static create( + props: Omit & { + id: string; + bookingNumber?: BookingNumber; + status?: BookingStatus; + } + ): Booking { + const now = new Date(); + + const bookingProps: BookingProps = { + ...props, + bookingNumber: props.bookingNumber || BookingNumber.generate(), + status: props.status || BookingStatus.create('draft'), + createdAt: now, + updatedAt: now, + }; + + // Validate business rules + Booking.validate(bookingProps); + + return new Booking(bookingProps); + } + + /** + * Validate business rules + */ + private static validate(props: BookingProps): void { + if (!props.userId) { + throw new Error('User ID is required'); + } + + if (!props.organizationId) { + throw new Error('Organization ID is required'); + } + + if (!props.rateQuoteId) { + throw new Error('Rate quote ID is required'); + } + + if (!props.shipper || !props.shipper.name) { + throw new Error('Shipper information is required'); + } + + if (!props.consignee || !props.consignee.name) { + throw new Error('Consignee information is required'); + } + + if (!props.cargoDescription || props.cargoDescription.length < 10) { + throw new Error('Cargo description must be at least 10 characters'); + } + } + + // Getters + get id(): string { + return this.props.id; + } + + get bookingNumber(): BookingNumber { + return this.props.bookingNumber; + } + + get userId(): string { + return this.props.userId; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get rateQuoteId(): string { + return this.props.rateQuoteId; + } + + get status(): BookingStatus { + return this.props.status; + } + + get shipper(): Party { + return { ...this.props.shipper }; + } + + get consignee(): Party { + return { ...this.props.consignee }; + } + + get cargoDescription(): string { + return this.props.cargoDescription; + } + + get containers(): BookingContainer[] { + return [...this.props.containers]; + } + + get specialInstructions(): string | undefined { + return this.props.specialInstructions; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + /** + * Update booking status + */ + updateStatus(newStatus: BookingStatus): Booking { + if (!this.status.canTransitionTo(newStatus)) { + throw new Error(`Cannot transition from ${this.status.value} to ${newStatus.value}`); + } + + return new Booking({ + ...this.props, + status: newStatus, + updatedAt: new Date(), + }); + } + + /** + * Add container to booking + */ + addContainer(container: BookingContainer): Booking { + if (!this.status.canBeModified()) { + throw new Error('Cannot modify containers after booking is confirmed'); + } + + return new Booking({ + ...this.props, + containers: [...this.props.containers, container], + updatedAt: new Date(), + }); + } + + /** + * Update container information + */ + updateContainer(containerId: string, updates: Partial): Booking { + if (!this.status.canBeModified()) { + throw new Error('Cannot modify containers after booking is confirmed'); + } + + const containerIndex = this.props.containers.findIndex(c => c.id === containerId); + if (containerIndex === -1) { + throw new Error(`Container ${containerId} not found`); + } + + const updatedContainers = [...this.props.containers]; + updatedContainers[containerIndex] = { + ...updatedContainers[containerIndex], + ...updates, + }; + + return new Booking({ + ...this.props, + containers: updatedContainers, + updatedAt: new Date(), + }); + } + + /** + * Remove container from booking + */ + removeContainer(containerId: string): Booking { + if (!this.status.canBeModified()) { + throw new Error('Cannot modify containers after booking is confirmed'); + } + + return new Booking({ + ...this.props, + containers: this.props.containers.filter(c => c.id !== containerId), + updatedAt: new Date(), + }); + } + + /** + * Update cargo description + */ + updateCargoDescription(description: string): Booking { + if (!this.status.canBeModified()) { + throw new Error('Cannot modify cargo description after booking is confirmed'); + } + + if (description.length < 10) { + throw new Error('Cargo description must be at least 10 characters'); + } + + return new Booking({ + ...this.props, + cargoDescription: description, + updatedAt: new Date(), + }); + } + + /** + * Update special instructions + */ + updateSpecialInstructions(instructions: string): Booking { + return new Booking({ + ...this.props, + specialInstructions: instructions, + updatedAt: new Date(), + }); + } + + /** + * Check if booking can be cancelled + */ + canBeCancelled(): boolean { + return !this.status.isFinal(); + } + + /** + * Cancel booking + */ + cancel(): Booking { + if (!this.canBeCancelled()) { + throw new Error('Cannot cancel booking in final state'); + } + + return this.updateStatus(BookingStatus.create('cancelled')); + } + + /** + * Equality check + */ + equals(other: Booking): boolean { + return this.id === other.id; + } +} diff --git a/apps/backend/src/domain/entities/carrier.entity.ts b/apps/backend/src/domain/entities/carrier.entity.ts index beee678..fcd9b58 100644 --- a/apps/backend/src/domain/entities/carrier.entity.ts +++ b/apps/backend/src/domain/entities/carrier.entity.ts @@ -1,182 +1,184 @@ -/** - * Carrier Entity - * - * Represents a shipping carrier (e.g., Maersk, MSC, CMA CGM) - * - * Business Rules: - * - Carrier code must be unique - * - SCAC code must be valid (4 uppercase letters) - * - API configuration is optional (for carriers with API integration) - */ - -export interface CarrierApiConfig { - baseUrl: string; - apiKey?: string; - clientId?: string; - clientSecret?: string; - timeout: number; // in milliseconds - retryAttempts: number; - circuitBreakerThreshold: number; -} - -export interface CarrierProps { - id: string; - name: string; - code: string; // Unique carrier code (e.g., 'MAERSK', 'MSC') - scac: string; // Standard Carrier Alpha Code - logoUrl?: string; - website?: string; - apiConfig?: CarrierApiConfig; - isActive: boolean; - supportsApi: boolean; // True if carrier has API integration - createdAt: Date; - updatedAt: Date; -} - -export class Carrier { - private readonly props: CarrierProps; - - private constructor(props: CarrierProps) { - this.props = props; - } - - /** - * Factory method to create a new Carrier - */ - static create(props: Omit): Carrier { - const now = new Date(); - - // Validate SCAC code - if (!Carrier.isValidSCAC(props.scac)) { - throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); - } - - // Validate carrier code - if (!Carrier.isValidCarrierCode(props.code)) { - throw new Error('Invalid carrier code format. Must be uppercase letters and underscores only.'); - } - - // Validate API config if carrier supports API - if (props.supportsApi && !props.apiConfig) { - throw new Error('Carriers with API support must have API configuration.'); - } - - return new Carrier({ - ...props, - createdAt: now, - updatedAt: now, - }); - } - - /** - * Factory method to reconstitute from persistence - */ - static fromPersistence(props: CarrierProps): Carrier { - return new Carrier(props); - } - - /** - * Validate SCAC code format - */ - private static isValidSCAC(scac: string): boolean { - const scacPattern = /^[A-Z]{4}$/; - return scacPattern.test(scac); - } - - /** - * Validate carrier code format - */ - private static isValidCarrierCode(code: string): boolean { - const codePattern = /^[A-Z_]+$/; - return codePattern.test(code); - } - - // Getters - get id(): string { - return this.props.id; - } - - get name(): string { - return this.props.name; - } - - get code(): string { - return this.props.code; - } - - get scac(): string { - return this.props.scac; - } - - get logoUrl(): string | undefined { - return this.props.logoUrl; - } - - get website(): string | undefined { - return this.props.website; - } - - get apiConfig(): CarrierApiConfig | undefined { - return this.props.apiConfig ? { ...this.props.apiConfig } : undefined; - } - - get isActive(): boolean { - return this.props.isActive; - } - - get supportsApi(): boolean { - return this.props.supportsApi; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - // Business methods - hasApiIntegration(): boolean { - return this.props.supportsApi && !!this.props.apiConfig; - } - - updateApiConfig(apiConfig: CarrierApiConfig): void { - if (!this.props.supportsApi) { - throw new Error('Cannot update API config for carrier without API support.'); - } - - this.props.apiConfig = { ...apiConfig }; - this.props.updatedAt = new Date(); - } - - updateLogoUrl(logoUrl: string): void { - this.props.logoUrl = logoUrl; - this.props.updatedAt = new Date(); - } - - updateWebsite(website: string): void { - this.props.website = website; - this.props.updatedAt = new Date(); - } - - deactivate(): void { - this.props.isActive = false; - this.props.updatedAt = new Date(); - } - - activate(): void { - this.props.isActive = true; - this.props.updatedAt = new Date(); - } - - /** - * Convert to plain object for persistence - */ - toObject(): CarrierProps { - return { - ...this.props, - apiConfig: this.props.apiConfig ? { ...this.props.apiConfig } : undefined, - }; - } -} +/** + * Carrier Entity + * + * Represents a shipping carrier (e.g., Maersk, MSC, CMA CGM) + * + * Business Rules: + * - Carrier code must be unique + * - SCAC code must be valid (4 uppercase letters) + * - API configuration is optional (for carriers with API integration) + */ + +export interface CarrierApiConfig { + baseUrl: string; + apiKey?: string; + clientId?: string; + clientSecret?: string; + timeout: number; // in milliseconds + retryAttempts: number; + circuitBreakerThreshold: number; +} + +export interface CarrierProps { + id: string; + name: string; + code: string; // Unique carrier code (e.g., 'MAERSK', 'MSC') + scac: string; // Standard Carrier Alpha Code + logoUrl?: string; + website?: string; + apiConfig?: CarrierApiConfig; + isActive: boolean; + supportsApi: boolean; // True if carrier has API integration + createdAt: Date; + updatedAt: Date; +} + +export class Carrier { + private readonly props: CarrierProps; + + private constructor(props: CarrierProps) { + this.props = props; + } + + /** + * Factory method to create a new Carrier + */ + static create(props: Omit): Carrier { + const now = new Date(); + + // Validate SCAC code + if (!Carrier.isValidSCAC(props.scac)) { + throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); + } + + // Validate carrier code + if (!Carrier.isValidCarrierCode(props.code)) { + throw new Error( + 'Invalid carrier code format. Must be uppercase letters and underscores only.' + ); + } + + // Validate API config if carrier supports API + if (props.supportsApi && !props.apiConfig) { + throw new Error('Carriers with API support must have API configuration.'); + } + + return new Carrier({ + ...props, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: CarrierProps): Carrier { + return new Carrier(props); + } + + /** + * Validate SCAC code format + */ + private static isValidSCAC(scac: string): boolean { + const scacPattern = /^[A-Z]{4}$/; + return scacPattern.test(scac); + } + + /** + * Validate carrier code format + */ + private static isValidCarrierCode(code: string): boolean { + const codePattern = /^[A-Z_]+$/; + return codePattern.test(code); + } + + // Getters + get id(): string { + return this.props.id; + } + + get name(): string { + return this.props.name; + } + + get code(): string { + return this.props.code; + } + + get scac(): string { + return this.props.scac; + } + + get logoUrl(): string | undefined { + return this.props.logoUrl; + } + + get website(): string | undefined { + return this.props.website; + } + + get apiConfig(): CarrierApiConfig | undefined { + return this.props.apiConfig ? { ...this.props.apiConfig } : undefined; + } + + get isActive(): boolean { + return this.props.isActive; + } + + get supportsApi(): boolean { + return this.props.supportsApi; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + // Business methods + hasApiIntegration(): boolean { + return this.props.supportsApi && !!this.props.apiConfig; + } + + updateApiConfig(apiConfig: CarrierApiConfig): void { + if (!this.props.supportsApi) { + throw new Error('Cannot update API config for carrier without API support.'); + } + + this.props.apiConfig = { ...apiConfig }; + this.props.updatedAt = new Date(); + } + + updateLogoUrl(logoUrl: string): void { + this.props.logoUrl = logoUrl; + this.props.updatedAt = new Date(); + } + + updateWebsite(website: string): void { + this.props.website = website; + this.props.updatedAt = new Date(); + } + + deactivate(): void { + this.props.isActive = false; + this.props.updatedAt = new Date(); + } + + activate(): void { + this.props.isActive = true; + this.props.updatedAt = new Date(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): CarrierProps { + return { + ...this.props, + apiConfig: this.props.apiConfig ? { ...this.props.apiConfig } : undefined, + }; + } +} diff --git a/apps/backend/src/domain/entities/container.entity.ts b/apps/backend/src/domain/entities/container.entity.ts index f753824..d3bfa4e 100644 --- a/apps/backend/src/domain/entities/container.entity.ts +++ b/apps/backend/src/domain/entities/container.entity.ts @@ -1,297 +1,300 @@ -/** - * Container Entity - * - * Represents a shipping container in a booking - * - * Business Rules: - * - Container number must follow ISO 6346 format (when provided) - * - VGM (Verified Gross Mass) is required for export shipments - * - Temperature must be within valid range for reefer containers - */ - -export enum ContainerCategory { - DRY = 'DRY', - REEFER = 'REEFER', - OPEN_TOP = 'OPEN_TOP', - FLAT_RACK = 'FLAT_RACK', - TANK = 'TANK', -} - -export enum ContainerSize { - TWENTY = '20', - FORTY = '40', - FORTY_FIVE = '45', -} - -export enum ContainerHeight { - STANDARD = 'STANDARD', - HIGH_CUBE = 'HIGH_CUBE', -} - -export interface ContainerProps { - id: string; - bookingId?: string; // Optional until container is assigned to a booking - type: string; // e.g., '20DRY', '40HC', '40REEFER' - category: ContainerCategory; - size: ContainerSize; - height: ContainerHeight; - containerNumber?: string; // ISO 6346 format (assigned by carrier) - sealNumber?: string; - vgm?: number; // Verified Gross Mass in kg - tareWeight?: number; // Empty container weight in kg - maxGrossWeight?: number; // Maximum gross weight in kg - temperature?: number; // For reefer containers (°C) - humidity?: number; // For reefer containers (%) - ventilation?: string; // For reefer containers - isHazmat: boolean; - imoClass?: string; // IMO hazmat class (if hazmat) - cargoDescription?: string; - createdAt: Date; - updatedAt: Date; -} - -export class Container { - private readonly props: ContainerProps; - - private constructor(props: ContainerProps) { - this.props = props; - } - - /** - * Factory method to create a new Container - */ - static create(props: Omit): Container { - const now = new Date(); - - // Validate container number format if provided - if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) { - throw new Error('Invalid container number format. Must follow ISO 6346 standard.'); - } - - // Validate VGM if provided - if (props.vgm !== undefined && props.vgm <= 0) { - throw new Error('VGM must be positive.'); - } - - // Validate temperature for reefer containers - if (props.category === ContainerCategory.REEFER) { - if (props.temperature === undefined) { - throw new Error('Temperature is required for reefer containers.'); - } - if (props.temperature < -40 || props.temperature > 40) { - throw new Error('Temperature must be between -40°C and +40°C.'); - } - } - - // Validate hazmat - if (props.isHazmat && !props.imoClass) { - throw new Error('IMO class is required for hazmat containers.'); - } - - return new Container({ - ...props, - createdAt: now, - updatedAt: now, - }); - } - - /** - * Factory method to reconstitute from persistence - */ - static fromPersistence(props: ContainerProps): Container { - return new Container(props); - } - - /** - * Validate ISO 6346 container number format - * Format: 4 letters (owner code) + 6 digits + 1 check digit - * Example: MSCU1234567 - */ - private static isValidContainerNumber(containerNumber: string): boolean { - const pattern = /^[A-Z]{4}\d{7}$/; - if (!pattern.test(containerNumber)) { - return false; - } - - // Validate check digit (ISO 6346 algorithm) - const ownerCode = containerNumber.substring(0, 4); - const serialNumber = containerNumber.substring(4, 10); - const checkDigit = parseInt(containerNumber.substring(10, 11), 10); - - // Convert letters to numbers (A=10, B=12, C=13, ..., Z=38) - const letterValues: { [key: string]: number } = {}; - 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => { - letterValues[letter] = 10 + index + Math.floor(index / 2); - }); - - // Calculate sum - let sum = 0; - for (let i = 0; i < ownerCode.length; i++) { - sum += letterValues[ownerCode[i]] * Math.pow(2, i); - } - for (let i = 0; i < serialNumber.length; i++) { - sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4); - } - - // Check digit = sum % 11 (if 10, use 0) - const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11; - - return calculatedCheckDigit === checkDigit; - } - - // Getters - get id(): string { - return this.props.id; - } - - get bookingId(): string | undefined { - return this.props.bookingId; - } - - get type(): string { - return this.props.type; - } - - get category(): ContainerCategory { - return this.props.category; - } - - get size(): ContainerSize { - return this.props.size; - } - - get height(): ContainerHeight { - return this.props.height; - } - - get containerNumber(): string | undefined { - return this.props.containerNumber; - } - - get sealNumber(): string | undefined { - return this.props.sealNumber; - } - - get vgm(): number | undefined { - return this.props.vgm; - } - - get tareWeight(): number | undefined { - return this.props.tareWeight; - } - - get maxGrossWeight(): number | undefined { - return this.props.maxGrossWeight; - } - - get temperature(): number | undefined { - return this.props.temperature; - } - - get humidity(): number | undefined { - return this.props.humidity; - } - - get ventilation(): string | undefined { - return this.props.ventilation; - } - - get isHazmat(): boolean { - return this.props.isHazmat; - } - - get imoClass(): string | undefined { - return this.props.imoClass; - } - - get cargoDescription(): string | undefined { - return this.props.cargoDescription; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - // Business methods - isReefer(): boolean { - return this.props.category === ContainerCategory.REEFER; - } - - isDry(): boolean { - return this.props.category === ContainerCategory.DRY; - } - - isHighCube(): boolean { - return this.props.height === ContainerHeight.HIGH_CUBE; - } - - getTEU(): number { - // Twenty-foot Equivalent Unit - if (this.props.size === ContainerSize.TWENTY) { - return 1; - } else if (this.props.size === ContainerSize.FORTY || this.props.size === ContainerSize.FORTY_FIVE) { - return 2; - } - return 0; - } - - getPayload(): number | undefined { - if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) { - return this.props.vgm - this.props.tareWeight; - } - return undefined; - } - - assignContainerNumber(containerNumber: string): void { - if (!Container.isValidContainerNumber(containerNumber)) { - throw new Error('Invalid container number format.'); - } - this.props.containerNumber = containerNumber; - this.props.updatedAt = new Date(); - } - - assignSealNumber(sealNumber: string): void { - this.props.sealNumber = sealNumber; - this.props.updatedAt = new Date(); - } - - setVGM(vgm: number): void { - if (vgm <= 0) { - throw new Error('VGM must be positive.'); - } - this.props.vgm = vgm; - this.props.updatedAt = new Date(); - } - - setTemperature(temperature: number): void { - if (!this.isReefer()) { - throw new Error('Cannot set temperature for non-reefer container.'); - } - if (temperature < -40 || temperature > 40) { - throw new Error('Temperature must be between -40°C and +40°C.'); - } - this.props.temperature = temperature; - this.props.updatedAt = new Date(); - } - - setCargoDescription(description: string): void { - this.props.cargoDescription = description; - this.props.updatedAt = new Date(); - } - - assignToBooking(bookingId: string): void { - this.props.bookingId = bookingId; - this.props.updatedAt = new Date(); - } - - /** - * Convert to plain object for persistence - */ - toObject(): ContainerProps { - return { ...this.props }; - } -} +/** + * Container Entity + * + * Represents a shipping container in a booking + * + * Business Rules: + * - Container number must follow ISO 6346 format (when provided) + * - VGM (Verified Gross Mass) is required for export shipments + * - Temperature must be within valid range for reefer containers + */ + +export enum ContainerCategory { + DRY = 'DRY', + REEFER = 'REEFER', + OPEN_TOP = 'OPEN_TOP', + FLAT_RACK = 'FLAT_RACK', + TANK = 'TANK', +} + +export enum ContainerSize { + TWENTY = '20', + FORTY = '40', + FORTY_FIVE = '45', +} + +export enum ContainerHeight { + STANDARD = 'STANDARD', + HIGH_CUBE = 'HIGH_CUBE', +} + +export interface ContainerProps { + id: string; + bookingId?: string; // Optional until container is assigned to a booking + type: string; // e.g., '20DRY', '40HC', '40REEFER' + category: ContainerCategory; + size: ContainerSize; + height: ContainerHeight; + containerNumber?: string; // ISO 6346 format (assigned by carrier) + sealNumber?: string; + vgm?: number; // Verified Gross Mass in kg + tareWeight?: number; // Empty container weight in kg + maxGrossWeight?: number; // Maximum gross weight in kg + temperature?: number; // For reefer containers (°C) + humidity?: number; // For reefer containers (%) + ventilation?: string; // For reefer containers + isHazmat: boolean; + imoClass?: string; // IMO hazmat class (if hazmat) + cargoDescription?: string; + createdAt: Date; + updatedAt: Date; +} + +export class Container { + private readonly props: ContainerProps; + + private constructor(props: ContainerProps) { + this.props = props; + } + + /** + * Factory method to create a new Container + */ + static create(props: Omit): Container { + const now = new Date(); + + // Validate container number format if provided + if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) { + throw new Error('Invalid container number format. Must follow ISO 6346 standard.'); + } + + // Validate VGM if provided + if (props.vgm !== undefined && props.vgm <= 0) { + throw new Error('VGM must be positive.'); + } + + // Validate temperature for reefer containers + if (props.category === ContainerCategory.REEFER) { + if (props.temperature === undefined) { + throw new Error('Temperature is required for reefer containers.'); + } + if (props.temperature < -40 || props.temperature > 40) { + throw new Error('Temperature must be between -40°C and +40°C.'); + } + } + + // Validate hazmat + if (props.isHazmat && !props.imoClass) { + throw new Error('IMO class is required for hazmat containers.'); + } + + return new Container({ + ...props, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: ContainerProps): Container { + return new Container(props); + } + + /** + * Validate ISO 6346 container number format + * Format: 4 letters (owner code) + 6 digits + 1 check digit + * Example: MSCU1234567 + */ + private static isValidContainerNumber(containerNumber: string): boolean { + const pattern = /^[A-Z]{4}\d{7}$/; + if (!pattern.test(containerNumber)) { + return false; + } + + // Validate check digit (ISO 6346 algorithm) + const ownerCode = containerNumber.substring(0, 4); + const serialNumber = containerNumber.substring(4, 10); + const checkDigit = parseInt(containerNumber.substring(10, 11), 10); + + // Convert letters to numbers (A=10, B=12, C=13, ..., Z=38) + const letterValues: { [key: string]: number } = {}; + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => { + letterValues[letter] = 10 + index + Math.floor(index / 2); + }); + + // Calculate sum + let sum = 0; + for (let i = 0; i < ownerCode.length; i++) { + sum += letterValues[ownerCode[i]] * Math.pow(2, i); + } + for (let i = 0; i < serialNumber.length; i++) { + sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4); + } + + // Check digit = sum % 11 (if 10, use 0) + const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11; + + return calculatedCheckDigit === checkDigit; + } + + // Getters + get id(): string { + return this.props.id; + } + + get bookingId(): string | undefined { + return this.props.bookingId; + } + + get type(): string { + return this.props.type; + } + + get category(): ContainerCategory { + return this.props.category; + } + + get size(): ContainerSize { + return this.props.size; + } + + get height(): ContainerHeight { + return this.props.height; + } + + get containerNumber(): string | undefined { + return this.props.containerNumber; + } + + get sealNumber(): string | undefined { + return this.props.sealNumber; + } + + get vgm(): number | undefined { + return this.props.vgm; + } + + get tareWeight(): number | undefined { + return this.props.tareWeight; + } + + get maxGrossWeight(): number | undefined { + return this.props.maxGrossWeight; + } + + get temperature(): number | undefined { + return this.props.temperature; + } + + get humidity(): number | undefined { + return this.props.humidity; + } + + get ventilation(): string | undefined { + return this.props.ventilation; + } + + get isHazmat(): boolean { + return this.props.isHazmat; + } + + get imoClass(): string | undefined { + return this.props.imoClass; + } + + get cargoDescription(): string | undefined { + return this.props.cargoDescription; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + // Business methods + isReefer(): boolean { + return this.props.category === ContainerCategory.REEFER; + } + + isDry(): boolean { + return this.props.category === ContainerCategory.DRY; + } + + isHighCube(): boolean { + return this.props.height === ContainerHeight.HIGH_CUBE; + } + + getTEU(): number { + // Twenty-foot Equivalent Unit + if (this.props.size === ContainerSize.TWENTY) { + return 1; + } else if ( + this.props.size === ContainerSize.FORTY || + this.props.size === ContainerSize.FORTY_FIVE + ) { + return 2; + } + return 0; + } + + getPayload(): number | undefined { + if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) { + return this.props.vgm - this.props.tareWeight; + } + return undefined; + } + + assignContainerNumber(containerNumber: string): void { + if (!Container.isValidContainerNumber(containerNumber)) { + throw new Error('Invalid container number format.'); + } + this.props.containerNumber = containerNumber; + this.props.updatedAt = new Date(); + } + + assignSealNumber(sealNumber: string): void { + this.props.sealNumber = sealNumber; + this.props.updatedAt = new Date(); + } + + setVGM(vgm: number): void { + if (vgm <= 0) { + throw new Error('VGM must be positive.'); + } + this.props.vgm = vgm; + this.props.updatedAt = new Date(); + } + + setTemperature(temperature: number): void { + if (!this.isReefer()) { + throw new Error('Cannot set temperature for non-reefer container.'); + } + if (temperature < -40 || temperature > 40) { + throw new Error('Temperature must be between -40°C and +40°C.'); + } + this.props.temperature = temperature; + this.props.updatedAt = new Date(); + } + + setCargoDescription(description: string): void { + this.props.cargoDescription = description; + this.props.updatedAt = new Date(); + } + + assignToBooking(bookingId: string): void { + this.props.bookingId = bookingId; + this.props.updatedAt = new Date(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): ContainerProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/domain/entities/csv-rate.entity.ts b/apps/backend/src/domain/entities/csv-rate.entity.ts index d8b815a..7ab95ca 100644 --- a/apps/backend/src/domain/entities/csv-rate.entity.ts +++ b/apps/backend/src/domain/entities/csv-rate.entity.ts @@ -1,245 +1,239 @@ -import { PortCode } from '../value-objects/port-code.vo'; -import { ContainerType } from '../value-objects/container-type.vo'; -import { Money } from '../value-objects/money.vo'; -import { Volume } from '../value-objects/volume.vo'; -import { Surcharge, SurchargeCollection } from '../value-objects/surcharge.vo'; -import { DateRange } from '../value-objects/date-range.vo'; - -/** - * Volume Range - Valid range for CBM - */ -export interface VolumeRange { - minCBM: number; - maxCBM: number; -} - -/** - * Weight Range - Valid range for KG - */ -export interface WeightRange { - minKG: number; - maxKG: number; -} - -/** - * Rate Pricing - Pricing structure for CSV rates - */ -export interface RatePricing { - pricePerCBM: number; - pricePerKG: number; - basePriceUSD: Money; - basePriceEUR: Money; -} - -/** - * CSV Rate Entity - * - * Represents a shipping rate loaded from CSV file. - * Contains all information needed to calculate freight costs. - * - * Business Rules: - * - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges - * - Rate must be valid (within validity period) to be used - * - Volume and weight must be within specified ranges - */ -export class CsvRate { - constructor( - public readonly companyName: string, - public readonly origin: PortCode, - public readonly destination: PortCode, - public readonly containerType: ContainerType, - public readonly volumeRange: VolumeRange, - public readonly weightRange: WeightRange, - public readonly palletCount: number, - public readonly pricing: RatePricing, - public readonly currency: string, // Primary currency (USD or EUR) - public readonly surcharges: SurchargeCollection, - public readonly transitDays: number, - public readonly validity: DateRange, - ) { - this.validate(); - } - - private validate(): void { - if (!this.companyName || this.companyName.trim().length === 0) { - throw new Error('Company name is required'); - } - - if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) { - throw new Error('Volume range cannot be negative'); - } - - if (this.volumeRange.minCBM > this.volumeRange.maxCBM) { - throw new Error('Min volume cannot be greater than max volume'); - } - - if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) { - throw new Error('Weight range cannot be negative'); - } - - if (this.weightRange.minKG > this.weightRange.maxKG) { - throw new Error('Min weight cannot be greater than max weight'); - } - - if (this.palletCount < 0) { - throw new Error('Pallet count cannot be negative'); - } - - if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) { - throw new Error('Prices cannot be negative'); - } - - if (this.transitDays <= 0) { - throw new Error('Transit days must be positive'); - } - - if (this.currency !== 'USD' && this.currency !== 'EUR') { - throw new Error('Currency must be USD or EUR'); - } - } - - /** - * Calculate total price for given volume and weight - * - * Business Logic: - * 1. Calculate volume-based price: volumeCBM * pricePerCBM - * 2. Calculate weight-based price: weightKG * pricePerKG - * 3. Take the maximum (freight class rule) - * 4. Add surcharges - */ - calculatePrice(volume: Volume): Money { - // Freight class rule: max(volume price, weight price) - const freightPrice = volume.calculateFreightPrice( - this.pricing.pricePerCBM, - this.pricing.pricePerKG, - ); - - // Create Money object in the rate's currency - let totalPrice = Money.create(freightPrice, this.currency); - - // Add surcharges in the same currency - const surchargeTotal = this.surcharges.getTotalAmount(this.currency); - totalPrice = totalPrice.add(surchargeTotal); - - return totalPrice; - } - - /** - * Get price in specific currency (USD or EUR) - */ - getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money { - const price = this.calculatePrice(volume); - - // If already in target currency, return as-is - if (price.getCurrency() === targetCurrency) { - return price; - } - - // Otherwise, use the pre-calculated base price in target currency - // and recalculate proportionally - const basePriceInPrimaryCurrency = - this.currency === 'USD' - ? this.pricing.basePriceUSD - : this.pricing.basePriceEUR; - - const basePriceInTargetCurrency = - targetCurrency === 'USD' - ? this.pricing.basePriceUSD - : this.pricing.basePriceEUR; - - // Calculate conversion ratio - const ratio = - basePriceInTargetCurrency.getAmount() / - basePriceInPrimaryCurrency.getAmount(); - - // Apply ratio to calculated price - const convertedAmount = price.getAmount() * ratio; - return Money.create(convertedAmount, targetCurrency); - } - - /** - * Check if rate is valid for a specific date - */ - isValidForDate(date: Date): boolean { - return this.validity.contains(date); - } - - /** - * Check if rate is currently valid (today is within validity period) - */ - isCurrentlyValid(): boolean { - return this.validity.isCurrentRange(); - } - - /** - * Check if volume and weight match this rate's range - */ - matchesVolume(volume: Volume): boolean { - return volume.isWithinRange( - this.volumeRange.minCBM, - this.volumeRange.maxCBM, - this.weightRange.minKG, - this.weightRange.maxKG, - ); - } - - /** - * Check if pallet count matches - * 0 means "any pallet count" (flexible) - * Otherwise must match exactly or be within range - */ - matchesPalletCount(palletCount: number): boolean { - // If rate has 0 pallets, it's flexible - if (this.palletCount === 0) { - return true; - } - // Otherwise must match exactly - return this.palletCount === palletCount; - } - - /** - * Check if rate matches a specific route - */ - matchesRoute(origin: PortCode, destination: PortCode): boolean { - return this.origin.equals(origin) && this.destination.equals(destination); - } - - /** - * Check if rate has separate surcharges - */ - hasSurcharges(): boolean { - return !this.surcharges.isEmpty(); - } - - /** - * Get surcharge details as formatted string - */ - getSurchargeDetails(): string { - return this.surcharges.getDetails(); - } - - /** - * Check if this is an "all-in" rate (no separate surcharges) - */ - isAllInPrice(): boolean { - return this.surcharges.isEmpty(); - } - - /** - * Get route description - */ - getRouteDescription(): string { - return `${this.origin.getValue()} → ${this.destination.getValue()}`; - } - - /** - * Get company and route summary - */ - getSummary(): string { - return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`; - } - - toString(): string { - return this.getSummary(); - } -} +import { PortCode } from '../value-objects/port-code.vo'; +import { ContainerType } from '../value-objects/container-type.vo'; +import { Money } from '../value-objects/money.vo'; +import { Volume } from '../value-objects/volume.vo'; +import { Surcharge, SurchargeCollection } from '../value-objects/surcharge.vo'; +import { DateRange } from '../value-objects/date-range.vo'; + +/** + * Volume Range - Valid range for CBM + */ +export interface VolumeRange { + minCBM: number; + maxCBM: number; +} + +/** + * Weight Range - Valid range for KG + */ +export interface WeightRange { + minKG: number; + maxKG: number; +} + +/** + * Rate Pricing - Pricing structure for CSV rates + */ +export interface RatePricing { + pricePerCBM: number; + pricePerKG: number; + basePriceUSD: Money; + basePriceEUR: Money; +} + +/** + * CSV Rate Entity + * + * Represents a shipping rate loaded from CSV file. + * Contains all information needed to calculate freight costs. + * + * Business Rules: + * - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges + * - Rate must be valid (within validity period) to be used + * - Volume and weight must be within specified ranges + */ +export class CsvRate { + constructor( + public readonly companyName: string, + public readonly origin: PortCode, + public readonly destination: PortCode, + public readonly containerType: ContainerType, + public readonly volumeRange: VolumeRange, + public readonly weightRange: WeightRange, + public readonly palletCount: number, + public readonly pricing: RatePricing, + public readonly currency: string, // Primary currency (USD or EUR) + public readonly surcharges: SurchargeCollection, + public readonly transitDays: number, + public readonly validity: DateRange + ) { + this.validate(); + } + + private validate(): void { + if (!this.companyName || this.companyName.trim().length === 0) { + throw new Error('Company name is required'); + } + + if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) { + throw new Error('Volume range cannot be negative'); + } + + if (this.volumeRange.minCBM > this.volumeRange.maxCBM) { + throw new Error('Min volume cannot be greater than max volume'); + } + + if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) { + throw new Error('Weight range cannot be negative'); + } + + if (this.weightRange.minKG > this.weightRange.maxKG) { + throw new Error('Min weight cannot be greater than max weight'); + } + + if (this.palletCount < 0) { + throw new Error('Pallet count cannot be negative'); + } + + if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) { + throw new Error('Prices cannot be negative'); + } + + if (this.transitDays <= 0) { + throw new Error('Transit days must be positive'); + } + + if (this.currency !== 'USD' && this.currency !== 'EUR') { + throw new Error('Currency must be USD or EUR'); + } + } + + /** + * Calculate total price for given volume and weight + * + * Business Logic: + * 1. Calculate volume-based price: volumeCBM * pricePerCBM + * 2. Calculate weight-based price: weightKG * pricePerKG + * 3. Take the maximum (freight class rule) + * 4. Add surcharges + */ + calculatePrice(volume: Volume): Money { + // Freight class rule: max(volume price, weight price) + const freightPrice = volume.calculateFreightPrice( + this.pricing.pricePerCBM, + this.pricing.pricePerKG + ); + + // Create Money object in the rate's currency + let totalPrice = Money.create(freightPrice, this.currency); + + // Add surcharges in the same currency + const surchargeTotal = this.surcharges.getTotalAmount(this.currency); + totalPrice = totalPrice.add(surchargeTotal); + + return totalPrice; + } + + /** + * Get price in specific currency (USD or EUR) + */ + getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money { + const price = this.calculatePrice(volume); + + // If already in target currency, return as-is + if (price.getCurrency() === targetCurrency) { + return price; + } + + // Otherwise, use the pre-calculated base price in target currency + // and recalculate proportionally + const basePriceInPrimaryCurrency = + this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR; + + const basePriceInTargetCurrency = + targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR; + + // Calculate conversion ratio + const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount(); + + // Apply ratio to calculated price + const convertedAmount = price.getAmount() * ratio; + return Money.create(convertedAmount, targetCurrency); + } + + /** + * Check if rate is valid for a specific date + */ + isValidForDate(date: Date): boolean { + return this.validity.contains(date); + } + + /** + * Check if rate is currently valid (today is within validity period) + */ + isCurrentlyValid(): boolean { + return this.validity.isCurrentRange(); + } + + /** + * Check if volume and weight match this rate's range + */ + matchesVolume(volume: Volume): boolean { + return volume.isWithinRange( + this.volumeRange.minCBM, + this.volumeRange.maxCBM, + this.weightRange.minKG, + this.weightRange.maxKG + ); + } + + /** + * Check if pallet count matches + * 0 means "any pallet count" (flexible) + * Otherwise must match exactly or be within range + */ + matchesPalletCount(palletCount: number): boolean { + // If rate has 0 pallets, it's flexible + if (this.palletCount === 0) { + return true; + } + // Otherwise must match exactly + return this.palletCount === palletCount; + } + + /** + * Check if rate matches a specific route + */ + matchesRoute(origin: PortCode, destination: PortCode): boolean { + return this.origin.equals(origin) && this.destination.equals(destination); + } + + /** + * Check if rate has separate surcharges + */ + hasSurcharges(): boolean { + return !this.surcharges.isEmpty(); + } + + /** + * Get surcharge details as formatted string + */ + getSurchargeDetails(): string { + return this.surcharges.getDetails(); + } + + /** + * Check if this is an "all-in" rate (no separate surcharges) + */ + isAllInPrice(): boolean { + return this.surcharges.isEmpty(); + } + + /** + * Get route description + */ + getRouteDescription(): string { + return `${this.origin.getValue()} → ${this.destination.getValue()}`; + } + + /** + * Get company and route summary + */ + getSummary(): string { + return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`; + } + + toString(): string { + return this.getSummary(); + } +} diff --git a/apps/backend/src/domain/entities/index.ts b/apps/backend/src/domain/entities/index.ts index d253d47..862b4a7 100644 --- a/apps/backend/src/domain/entities/index.ts +++ b/apps/backend/src/domain/entities/index.ts @@ -1,13 +1,13 @@ -/** - * Domain Entities Barrel Export - * - * All core domain entities for the Xpeditis platform - */ - -export * from './organization.entity'; -export * from './user.entity'; -export * from './carrier.entity'; -export * from './port.entity'; -export * from './rate-quote.entity'; -export * from './container.entity'; -export * from './booking.entity'; +/** + * Domain Entities Barrel Export + * + * All core domain entities for the Xpeditis platform + */ + +export * from './organization.entity'; +export * from './user.entity'; +export * from './carrier.entity'; +export * from './port.entity'; +export * from './rate-quote.entity'; +export * from './container.entity'; +export * from './booking.entity'; diff --git a/apps/backend/src/domain/entities/notification.entity.ts b/apps/backend/src/domain/entities/notification.entity.ts index be47cb6..f92959b 100644 --- a/apps/backend/src/domain/entities/notification.entity.ts +++ b/apps/backend/src/domain/entities/notification.entity.ts @@ -42,7 +42,7 @@ export class Notification { private constructor(private readonly props: NotificationProps) {} static create( - props: Omit & { id: string }, + props: Omit & { id: string } ): Notification { return new Notification({ ...props, diff --git a/apps/backend/src/domain/entities/organization.entity.ts b/apps/backend/src/domain/entities/organization.entity.ts index fe95979..5907e35 100644 --- a/apps/backend/src/domain/entities/organization.entity.ts +++ b/apps/backend/src/domain/entities/organization.entity.ts @@ -1,201 +1,201 @@ -/** - * Organization Entity - * - * Represents a business organization (freight forwarder, carrier, or shipper) - * in the Xpeditis platform. - * - * Business Rules: - * - SCAC code must be unique across all carrier organizations - * - Name must be unique - * - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER) - */ - -export enum OrganizationType { - FREIGHT_FORWARDER = 'FREIGHT_FORWARDER', - CARRIER = 'CARRIER', - SHIPPER = 'SHIPPER', -} - -export interface OrganizationAddress { - street: string; - city: string; - state?: string; - postalCode: string; - country: string; -} - -export interface OrganizationDocument { - id: string; - type: string; - name: string; - url: string; - uploadedAt: Date; -} - -export interface OrganizationProps { - id: string; - name: string; - type: OrganizationType; - scac?: string; // Standard Carrier Alpha Code (for carriers only) - address: OrganizationAddress; - logoUrl?: string; - documents: OrganizationDocument[]; - createdAt: Date; - updatedAt: Date; - isActive: boolean; -} - -export class Organization { - private readonly props: OrganizationProps; - - private constructor(props: OrganizationProps) { - this.props = props; - } - - /** - * Factory method to create a new Organization - */ - static create(props: Omit): Organization { - const now = new Date(); - - // Validate SCAC code if provided - if (props.scac && !Organization.isValidSCAC(props.scac)) { - throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); - } - - // Validate that carriers have SCAC codes - if (props.type === OrganizationType.CARRIER && !props.scac) { - throw new Error('Carrier organizations must have a SCAC code.'); - } - - // Validate that non-carriers don't have SCAC codes - if (props.type !== OrganizationType.CARRIER && props.scac) { - throw new Error('Only carrier organizations can have SCAC codes.'); - } - - return new Organization({ - ...props, - createdAt: now, - updatedAt: now, - }); - } - - /** - * Factory method to reconstitute from persistence - */ - static fromPersistence(props: OrganizationProps): Organization { - return new Organization(props); - } - - /** - * Validate SCAC code format - * SCAC = Standard Carrier Alpha Code (4 uppercase letters) - */ - private static isValidSCAC(scac: string): boolean { - const scacPattern = /^[A-Z]{4}$/; - return scacPattern.test(scac); - } - - // Getters - get id(): string { - return this.props.id; - } - - get name(): string { - return this.props.name; - } - - get type(): OrganizationType { - return this.props.type; - } - - get scac(): string | undefined { - return this.props.scac; - } - - get address(): OrganizationAddress { - return { ...this.props.address }; - } - - get logoUrl(): string | undefined { - return this.props.logoUrl; - } - - get documents(): OrganizationDocument[] { - return [...this.props.documents]; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - get isActive(): boolean { - return this.props.isActive; - } - - // Business methods - isCarrier(): boolean { - return this.props.type === OrganizationType.CARRIER; - } - - isFreightForwarder(): boolean { - return this.props.type === OrganizationType.FREIGHT_FORWARDER; - } - - isShipper(): boolean { - return this.props.type === OrganizationType.SHIPPER; - } - - updateName(name: string): void { - if (!name || name.trim().length === 0) { - throw new Error('Organization name cannot be empty.'); - } - this.props.name = name.trim(); - this.props.updatedAt = new Date(); - } - - updateAddress(address: OrganizationAddress): void { - this.props.address = { ...address }; - this.props.updatedAt = new Date(); - } - - updateLogoUrl(logoUrl: string): void { - this.props.logoUrl = logoUrl; - this.props.updatedAt = new Date(); - } - - addDocument(document: OrganizationDocument): void { - this.props.documents.push(document); - this.props.updatedAt = new Date(); - } - - removeDocument(documentId: string): void { - this.props.documents = this.props.documents.filter(doc => doc.id !== documentId); - this.props.updatedAt = new Date(); - } - - deactivate(): void { - this.props.isActive = false; - this.props.updatedAt = new Date(); - } - - activate(): void { - this.props.isActive = true; - this.props.updatedAt = new Date(); - } - - /** - * Convert to plain object for persistence - */ - toObject(): OrganizationProps { - return { - ...this.props, - address: { ...this.props.address }, - documents: [...this.props.documents], - }; - } -} +/** + * Organization Entity + * + * Represents a business organization (freight forwarder, carrier, or shipper) + * in the Xpeditis platform. + * + * Business Rules: + * - SCAC code must be unique across all carrier organizations + * - Name must be unique + * - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER) + */ + +export enum OrganizationType { + FREIGHT_FORWARDER = 'FREIGHT_FORWARDER', + CARRIER = 'CARRIER', + SHIPPER = 'SHIPPER', +} + +export interface OrganizationAddress { + street: string; + city: string; + state?: string; + postalCode: string; + country: string; +} + +export interface OrganizationDocument { + id: string; + type: string; + name: string; + url: string; + uploadedAt: Date; +} + +export interface OrganizationProps { + id: string; + name: string; + type: OrganizationType; + scac?: string; // Standard Carrier Alpha Code (for carriers only) + address: OrganizationAddress; + logoUrl?: string; + documents: OrganizationDocument[]; + createdAt: Date; + updatedAt: Date; + isActive: boolean; +} + +export class Organization { + private readonly props: OrganizationProps; + + private constructor(props: OrganizationProps) { + this.props = props; + } + + /** + * Factory method to create a new Organization + */ + static create(props: Omit): Organization { + const now = new Date(); + + // Validate SCAC code if provided + if (props.scac && !Organization.isValidSCAC(props.scac)) { + throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); + } + + // Validate that carriers have SCAC codes + if (props.type === OrganizationType.CARRIER && !props.scac) { + throw new Error('Carrier organizations must have a SCAC code.'); + } + + // Validate that non-carriers don't have SCAC codes + if (props.type !== OrganizationType.CARRIER && props.scac) { + throw new Error('Only carrier organizations can have SCAC codes.'); + } + + return new Organization({ + ...props, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: OrganizationProps): Organization { + return new Organization(props); + } + + /** + * Validate SCAC code format + * SCAC = Standard Carrier Alpha Code (4 uppercase letters) + */ + private static isValidSCAC(scac: string): boolean { + const scacPattern = /^[A-Z]{4}$/; + return scacPattern.test(scac); + } + + // Getters + get id(): string { + return this.props.id; + } + + get name(): string { + return this.props.name; + } + + get type(): OrganizationType { + return this.props.type; + } + + get scac(): string | undefined { + return this.props.scac; + } + + get address(): OrganizationAddress { + return { ...this.props.address }; + } + + get logoUrl(): string | undefined { + return this.props.logoUrl; + } + + get documents(): OrganizationDocument[] { + return [...this.props.documents]; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + get isActive(): boolean { + return this.props.isActive; + } + + // Business methods + isCarrier(): boolean { + return this.props.type === OrganizationType.CARRIER; + } + + isFreightForwarder(): boolean { + return this.props.type === OrganizationType.FREIGHT_FORWARDER; + } + + isShipper(): boolean { + return this.props.type === OrganizationType.SHIPPER; + } + + updateName(name: string): void { + if (!name || name.trim().length === 0) { + throw new Error('Organization name cannot be empty.'); + } + this.props.name = name.trim(); + this.props.updatedAt = new Date(); + } + + updateAddress(address: OrganizationAddress): void { + this.props.address = { ...address }; + this.props.updatedAt = new Date(); + } + + updateLogoUrl(logoUrl: string): void { + this.props.logoUrl = logoUrl; + this.props.updatedAt = new Date(); + } + + addDocument(document: OrganizationDocument): void { + this.props.documents.push(document); + this.props.updatedAt = new Date(); + } + + removeDocument(documentId: string): void { + this.props.documents = this.props.documents.filter(doc => doc.id !== documentId); + this.props.updatedAt = new Date(); + } + + deactivate(): void { + this.props.isActive = false; + this.props.updatedAt = new Date(); + } + + activate(): void { + this.props.isActive = true; + this.props.updatedAt = new Date(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): OrganizationProps { + return { + ...this.props, + address: { ...this.props.address }, + documents: [...this.props.documents], + }; + } +} diff --git a/apps/backend/src/domain/entities/port.entity.ts b/apps/backend/src/domain/entities/port.entity.ts index 020016b..4c93138 100644 --- a/apps/backend/src/domain/entities/port.entity.ts +++ b/apps/backend/src/domain/entities/port.entity.ts @@ -1,205 +1,209 @@ -/** - * Port Entity - * - * Represents a maritime port (based on UN/LOCODE standard) - * - * Business Rules: - * - Port code must follow UN/LOCODE format (2-letter country + 3-letter location) - * - Coordinates must be valid latitude/longitude - */ - -export interface PortCoordinates { - latitude: number; - longitude: number; -} - -export interface PortProps { - id: string; - code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam) - name: string; // Port name - city: string; - country: string; // ISO 3166-1 alpha-2 country code - countryName: string; // Full country name - coordinates: PortCoordinates; - timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam') - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} - -export class Port { - private readonly props: PortProps; - - private constructor(props: PortProps) { - this.props = props; - } - - /** - * Factory method to create a new Port - */ - static create(props: Omit): Port { - const now = new Date(); - - // Validate UN/LOCODE format - if (!Port.isValidUNLOCODE(props.code)) { - throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).'); - } - - // Validate country code - if (!Port.isValidCountryCode(props.country)) { - throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).'); - } - - // Validate coordinates - if (!Port.isValidCoordinates(props.coordinates)) { - throw new Error('Invalid coordinates.'); - } - - return new Port({ - ...props, - createdAt: now, - updatedAt: now, - }); - } - - /** - * Factory method to reconstitute from persistence - */ - static fromPersistence(props: PortProps): Port { - return new Port(props); - } - - /** - * Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code) - */ - private static isValidUNLOCODE(code: string): boolean { - const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/; - return unlocodePattern.test(code); - } - - /** - * Validate ISO 3166-1 alpha-2 country code - */ - private static isValidCountryCode(code: string): boolean { - const countryCodePattern = /^[A-Z]{2}$/; - return countryCodePattern.test(code); - } - - /** - * Validate coordinates - */ - private static isValidCoordinates(coords: PortCoordinates): boolean { - const { latitude, longitude } = coords; - return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180; - } - - // Getters - get id(): string { - return this.props.id; - } - - get code(): string { - return this.props.code; - } - - get name(): string { - return this.props.name; - } - - get city(): string { - return this.props.city; - } - - get country(): string { - return this.props.country; - } - - get countryName(): string { - return this.props.countryName; - } - - get coordinates(): PortCoordinates { - return { ...this.props.coordinates }; - } - - get timezone(): string | undefined { - return this.props.timezone; - } - - get isActive(): boolean { - return this.props.isActive; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - // Business methods - /** - * Get display name (e.g., "Rotterdam, Netherlands (NLRTM)") - */ - getDisplayName(): string { - return `${this.props.name}, ${this.props.countryName} (${this.props.code})`; - } - - /** - * Calculate distance to another port (Haversine formula) - * Returns distance in kilometers - */ - distanceTo(otherPort: Port): number { - const R = 6371; // Earth's radius in kilometers - const lat1 = this.toRadians(this.props.coordinates.latitude); - const lat2 = this.toRadians(otherPort.coordinates.latitude); - const deltaLat = this.toRadians(otherPort.coordinates.latitude - this.props.coordinates.latitude); - const deltaLon = this.toRadians(otherPort.coordinates.longitude - this.props.coordinates.longitude); - - const a = - Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + - Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); - - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - return R * c; - } - - private toRadians(degrees: number): number { - return degrees * (Math.PI / 180); - } - - updateCoordinates(coordinates: PortCoordinates): void { - if (!Port.isValidCoordinates(coordinates)) { - throw new Error('Invalid coordinates.'); - } - this.props.coordinates = { ...coordinates }; - this.props.updatedAt = new Date(); - } - - updateTimezone(timezone: string): void { - this.props.timezone = timezone; - this.props.updatedAt = new Date(); - } - - deactivate(): void { - this.props.isActive = false; - this.props.updatedAt = new Date(); - } - - activate(): void { - this.props.isActive = true; - this.props.updatedAt = new Date(); - } - - /** - * Convert to plain object for persistence - */ - toObject(): PortProps { - return { - ...this.props, - coordinates: { ...this.props.coordinates }, - }; - } -} +/** + * Port Entity + * + * Represents a maritime port (based on UN/LOCODE standard) + * + * Business Rules: + * - Port code must follow UN/LOCODE format (2-letter country + 3-letter location) + * - Coordinates must be valid latitude/longitude + */ + +export interface PortCoordinates { + latitude: number; + longitude: number; +} + +export interface PortProps { + id: string; + code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam) + name: string; // Port name + city: string; + country: string; // ISO 3166-1 alpha-2 country code + countryName: string; // Full country name + coordinates: PortCoordinates; + timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam') + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class Port { + private readonly props: PortProps; + + private constructor(props: PortProps) { + this.props = props; + } + + /** + * Factory method to create a new Port + */ + static create(props: Omit): Port { + const now = new Date(); + + // Validate UN/LOCODE format + if (!Port.isValidUNLOCODE(props.code)) { + throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).'); + } + + // Validate country code + if (!Port.isValidCountryCode(props.country)) { + throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).'); + } + + // Validate coordinates + if (!Port.isValidCoordinates(props.coordinates)) { + throw new Error('Invalid coordinates.'); + } + + return new Port({ + ...props, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: PortProps): Port { + return new Port(props); + } + + /** + * Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code) + */ + private static isValidUNLOCODE(code: string): boolean { + const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/; + return unlocodePattern.test(code); + } + + /** + * Validate ISO 3166-1 alpha-2 country code + */ + private static isValidCountryCode(code: string): boolean { + const countryCodePattern = /^[A-Z]{2}$/; + return countryCodePattern.test(code); + } + + /** + * Validate coordinates + */ + private static isValidCoordinates(coords: PortCoordinates): boolean { + const { latitude, longitude } = coords; + return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180; + } + + // Getters + get id(): string { + return this.props.id; + } + + get code(): string { + return this.props.code; + } + + get name(): string { + return this.props.name; + } + + get city(): string { + return this.props.city; + } + + get country(): string { + return this.props.country; + } + + get countryName(): string { + return this.props.countryName; + } + + get coordinates(): PortCoordinates { + return { ...this.props.coordinates }; + } + + get timezone(): string | undefined { + return this.props.timezone; + } + + get isActive(): boolean { + return this.props.isActive; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + // Business methods + /** + * Get display name (e.g., "Rotterdam, Netherlands (NLRTM)") + */ + getDisplayName(): string { + return `${this.props.name}, ${this.props.countryName} (${this.props.code})`; + } + + /** + * Calculate distance to another port (Haversine formula) + * Returns distance in kilometers + */ + distanceTo(otherPort: Port): number { + const R = 6371; // Earth's radius in kilometers + const lat1 = this.toRadians(this.props.coordinates.latitude); + const lat2 = this.toRadians(otherPort.coordinates.latitude); + const deltaLat = this.toRadians( + otherPort.coordinates.latitude - this.props.coordinates.latitude + ); + const deltaLon = this.toRadians( + otherPort.coordinates.longitude - this.props.coordinates.longitude + ); + + const a = + Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; + } + + private toRadians(degrees: number): number { + return degrees * (Math.PI / 180); + } + + updateCoordinates(coordinates: PortCoordinates): void { + if (!Port.isValidCoordinates(coordinates)) { + throw new Error('Invalid coordinates.'); + } + this.props.coordinates = { ...coordinates }; + this.props.updatedAt = new Date(); + } + + updateTimezone(timezone: string): void { + this.props.timezone = timezone; + this.props.updatedAt = new Date(); + } + + deactivate(): void { + this.props.isActive = false; + this.props.updatedAt = new Date(); + } + + activate(): void { + this.props.isActive = true; + this.props.updatedAt = new Date(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): PortProps { + return { + ...this.props, + coordinates: { ...this.props.coordinates }, + }; + } +} diff --git a/apps/backend/src/domain/entities/rate-quote.entity.spec.ts b/apps/backend/src/domain/entities/rate-quote.entity.spec.ts index 9b69709..bc56517 100644 --- a/apps/backend/src/domain/entities/rate-quote.entity.spec.ts +++ b/apps/backend/src/domain/entities/rate-quote.entity.spec.ts @@ -1,240 +1,240 @@ -/** - * RateQuote Entity Unit Tests - */ - -import { RateQuote } from './rate-quote.entity'; - -describe('RateQuote Entity', () => { - const validProps = { - id: 'quote-1', - carrierId: 'carrier-1', - carrierName: 'Maersk', - carrierCode: 'MAERSK', - origin: { - code: 'NLRTM', - name: 'Rotterdam', - country: 'Netherlands', - }, - destination: { - code: 'USNYC', - name: 'New York', - country: 'United States', - }, - pricing: { - baseFreight: 1000, - surcharges: [ - { type: 'BAF', description: 'Bunker Adjustment Factor', amount: 100, currency: 'USD' }, - ], - totalAmount: 1100, - currency: 'USD', - }, - containerType: '40HC', - mode: 'FCL' as const, - etd: new Date('2025-11-01'), - eta: new Date('2025-11-20'), - transitDays: 19, - route: [ - { - portCode: 'NLRTM', - portName: 'Rotterdam', - departure: new Date('2025-11-01'), - }, - { - portCode: 'USNYC', - portName: 'New York', - arrival: new Date('2025-11-20'), - }, - ], - availability: 50, - frequency: 'Weekly', - vesselType: 'Container Ship', - co2EmissionsKg: 2500, - }; - - describe('create', () => { - it('should create rate quote with valid props', () => { - const rateQuote = RateQuote.create(validProps); - expect(rateQuote.id).toBe('quote-1'); - expect(rateQuote.carrierName).toBe('Maersk'); - expect(rateQuote.origin.code).toBe('NLRTM'); - expect(rateQuote.destination.code).toBe('USNYC'); - expect(rateQuote.pricing.totalAmount).toBe(1100); - }); - - it('should set validUntil to 15 minutes from now', () => { - const before = new Date(); - const rateQuote = RateQuote.create(validProps); - const after = new Date(); - - const expectedValidUntil = new Date(before.getTime() + 15 * 60 * 1000); - const diff = Math.abs(rateQuote.validUntil.getTime() - expectedValidUntil.getTime()); - - // Allow 1 second tolerance for test execution time - expect(diff).toBeLessThan(1000); - }); - - it('should throw error for non-positive total price', () => { - expect(() => - RateQuote.create({ - ...validProps, - pricing: { ...validProps.pricing, totalAmount: 0 }, - }) - ).toThrow('Total price must be positive'); - }); - - it('should throw error for non-positive base freight', () => { - expect(() => - RateQuote.create({ - ...validProps, - pricing: { ...validProps.pricing, baseFreight: 0 }, - }) - ).toThrow('Base freight must be positive'); - }); - - it('should throw error if ETA is not after ETD', () => { - expect(() => - RateQuote.create({ - ...validProps, - eta: new Date('2025-10-31'), - }) - ).toThrow('ETA must be after ETD'); - }); - - it('should throw error for non-positive transit days', () => { - expect(() => - RateQuote.create({ - ...validProps, - transitDays: 0, - }) - ).toThrow('Transit days must be positive'); - }); - - it('should throw error for negative availability', () => { - expect(() => - RateQuote.create({ - ...validProps, - availability: -1, - }) - ).toThrow('Availability cannot be negative'); - }); - - it('should throw error if route has less than 2 segments', () => { - expect(() => - RateQuote.create({ - ...validProps, - route: [{ portCode: 'NLRTM', portName: 'Rotterdam' }], - }) - ).toThrow('Route must have at least origin and destination'); - }); - }); - - describe('isValid', () => { - it('should return true for non-expired quote', () => { - const rateQuote = RateQuote.create(validProps); - expect(rateQuote.isValid()).toBe(true); - }); - - it('should return false for expired quote', () => { - const expiredQuote = RateQuote.fromPersistence({ - ...validProps, - validUntil: new Date(Date.now() - 1000), // 1 second ago - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(expiredQuote.isValid()).toBe(false); - }); - }); - - describe('isExpired', () => { - it('should return false for non-expired quote', () => { - const rateQuote = RateQuote.create(validProps); - expect(rateQuote.isExpired()).toBe(false); - }); - - it('should return true for expired quote', () => { - const expiredQuote = RateQuote.fromPersistence({ - ...validProps, - validUntil: new Date(Date.now() - 1000), - createdAt: new Date(), - updatedAt: new Date(), - }); - expect(expiredQuote.isExpired()).toBe(true); - }); - }); - - describe('hasAvailability', () => { - it('should return true when availability > 0', () => { - const rateQuote = RateQuote.create(validProps); - expect(rateQuote.hasAvailability()).toBe(true); - }); - - it('should return false when availability = 0', () => { - const rateQuote = RateQuote.create({ ...validProps, availability: 0 }); - expect(rateQuote.hasAvailability()).toBe(false); - }); - }); - - describe('getTotalSurcharges', () => { - it('should calculate total surcharges', () => { - const rateQuote = RateQuote.create({ - ...validProps, - pricing: { - baseFreight: 1000, - surcharges: [ - { type: 'BAF', description: 'BAF', amount: 100, currency: 'USD' }, - { type: 'CAF', description: 'CAF', amount: 50, currency: 'USD' }, - ], - totalAmount: 1150, - currency: 'USD', - }, - }); - expect(rateQuote.getTotalSurcharges()).toBe(150); - }); - }); - - describe('getTransshipmentCount', () => { - it('should return 0 for direct route', () => { - const rateQuote = RateQuote.create(validProps); - expect(rateQuote.getTransshipmentCount()).toBe(0); - }); - - it('should return correct count for route with transshipments', () => { - const rateQuote = RateQuote.create({ - ...validProps, - route: [ - { portCode: 'NLRTM', portName: 'Rotterdam' }, - { portCode: 'ESBCN', portName: 'Barcelona' }, - { portCode: 'USNYC', portName: 'New York' }, - ], - }); - expect(rateQuote.getTransshipmentCount()).toBe(1); - }); - }); - - describe('isDirectRoute', () => { - it('should return true for direct route', () => { - const rateQuote = RateQuote.create(validProps); - expect(rateQuote.isDirectRoute()).toBe(true); - }); - - it('should return false for route with transshipments', () => { - const rateQuote = RateQuote.create({ - ...validProps, - route: [ - { portCode: 'NLRTM', portName: 'Rotterdam' }, - { portCode: 'ESBCN', portName: 'Barcelona' }, - { portCode: 'USNYC', portName: 'New York' }, - ], - }); - expect(rateQuote.isDirectRoute()).toBe(false); - }); - }); - - describe('getPricePerDay', () => { - it('should calculate price per day', () => { - const rateQuote = RateQuote.create(validProps); - const pricePerDay = rateQuote.getPricePerDay(); - expect(pricePerDay).toBeCloseTo(1100 / 19, 2); - }); - }); -}); +/** + * RateQuote Entity Unit Tests + */ + +import { RateQuote } from './rate-quote.entity'; + +describe('RateQuote Entity', () => { + const validProps = { + id: 'quote-1', + carrierId: 'carrier-1', + carrierName: 'Maersk', + carrierCode: 'MAERSK', + origin: { + code: 'NLRTM', + name: 'Rotterdam', + country: 'Netherlands', + }, + destination: { + code: 'USNYC', + name: 'New York', + country: 'United States', + }, + pricing: { + baseFreight: 1000, + surcharges: [ + { type: 'BAF', description: 'Bunker Adjustment Factor', amount: 100, currency: 'USD' }, + ], + totalAmount: 1100, + currency: 'USD', + }, + containerType: '40HC', + mode: 'FCL' as const, + etd: new Date('2025-11-01'), + eta: new Date('2025-11-20'), + transitDays: 19, + route: [ + { + portCode: 'NLRTM', + portName: 'Rotterdam', + departure: new Date('2025-11-01'), + }, + { + portCode: 'USNYC', + portName: 'New York', + arrival: new Date('2025-11-20'), + }, + ], + availability: 50, + frequency: 'Weekly', + vesselType: 'Container Ship', + co2EmissionsKg: 2500, + }; + + describe('create', () => { + it('should create rate quote with valid props', () => { + const rateQuote = RateQuote.create(validProps); + expect(rateQuote.id).toBe('quote-1'); + expect(rateQuote.carrierName).toBe('Maersk'); + expect(rateQuote.origin.code).toBe('NLRTM'); + expect(rateQuote.destination.code).toBe('USNYC'); + expect(rateQuote.pricing.totalAmount).toBe(1100); + }); + + it('should set validUntil to 15 minutes from now', () => { + const before = new Date(); + const rateQuote = RateQuote.create(validProps); + const after = new Date(); + + const expectedValidUntil = new Date(before.getTime() + 15 * 60 * 1000); + const diff = Math.abs(rateQuote.validUntil.getTime() - expectedValidUntil.getTime()); + + // Allow 1 second tolerance for test execution time + expect(diff).toBeLessThan(1000); + }); + + it('should throw error for non-positive total price', () => { + expect(() => + RateQuote.create({ + ...validProps, + pricing: { ...validProps.pricing, totalAmount: 0 }, + }) + ).toThrow('Total price must be positive'); + }); + + it('should throw error for non-positive base freight', () => { + expect(() => + RateQuote.create({ + ...validProps, + pricing: { ...validProps.pricing, baseFreight: 0 }, + }) + ).toThrow('Base freight must be positive'); + }); + + it('should throw error if ETA is not after ETD', () => { + expect(() => + RateQuote.create({ + ...validProps, + eta: new Date('2025-10-31'), + }) + ).toThrow('ETA must be after ETD'); + }); + + it('should throw error for non-positive transit days', () => { + expect(() => + RateQuote.create({ + ...validProps, + transitDays: 0, + }) + ).toThrow('Transit days must be positive'); + }); + + it('should throw error for negative availability', () => { + expect(() => + RateQuote.create({ + ...validProps, + availability: -1, + }) + ).toThrow('Availability cannot be negative'); + }); + + it('should throw error if route has less than 2 segments', () => { + expect(() => + RateQuote.create({ + ...validProps, + route: [{ portCode: 'NLRTM', portName: 'Rotterdam' }], + }) + ).toThrow('Route must have at least origin and destination'); + }); + }); + + describe('isValid', () => { + it('should return true for non-expired quote', () => { + const rateQuote = RateQuote.create(validProps); + expect(rateQuote.isValid()).toBe(true); + }); + + it('should return false for expired quote', () => { + const expiredQuote = RateQuote.fromPersistence({ + ...validProps, + validUntil: new Date(Date.now() - 1000), // 1 second ago + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(expiredQuote.isValid()).toBe(false); + }); + }); + + describe('isExpired', () => { + it('should return false for non-expired quote', () => { + const rateQuote = RateQuote.create(validProps); + expect(rateQuote.isExpired()).toBe(false); + }); + + it('should return true for expired quote', () => { + const expiredQuote = RateQuote.fromPersistence({ + ...validProps, + validUntil: new Date(Date.now() - 1000), + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(expiredQuote.isExpired()).toBe(true); + }); + }); + + describe('hasAvailability', () => { + it('should return true when availability > 0', () => { + const rateQuote = RateQuote.create(validProps); + expect(rateQuote.hasAvailability()).toBe(true); + }); + + it('should return false when availability = 0', () => { + const rateQuote = RateQuote.create({ ...validProps, availability: 0 }); + expect(rateQuote.hasAvailability()).toBe(false); + }); + }); + + describe('getTotalSurcharges', () => { + it('should calculate total surcharges', () => { + const rateQuote = RateQuote.create({ + ...validProps, + pricing: { + baseFreight: 1000, + surcharges: [ + { type: 'BAF', description: 'BAF', amount: 100, currency: 'USD' }, + { type: 'CAF', description: 'CAF', amount: 50, currency: 'USD' }, + ], + totalAmount: 1150, + currency: 'USD', + }, + }); + expect(rateQuote.getTotalSurcharges()).toBe(150); + }); + }); + + describe('getTransshipmentCount', () => { + it('should return 0 for direct route', () => { + const rateQuote = RateQuote.create(validProps); + expect(rateQuote.getTransshipmentCount()).toBe(0); + }); + + it('should return correct count for route with transshipments', () => { + const rateQuote = RateQuote.create({ + ...validProps, + route: [ + { portCode: 'NLRTM', portName: 'Rotterdam' }, + { portCode: 'ESBCN', portName: 'Barcelona' }, + { portCode: 'USNYC', portName: 'New York' }, + ], + }); + expect(rateQuote.getTransshipmentCount()).toBe(1); + }); + }); + + describe('isDirectRoute', () => { + it('should return true for direct route', () => { + const rateQuote = RateQuote.create(validProps); + expect(rateQuote.isDirectRoute()).toBe(true); + }); + + it('should return false for route with transshipments', () => { + const rateQuote = RateQuote.create({ + ...validProps, + route: [ + { portCode: 'NLRTM', portName: 'Rotterdam' }, + { portCode: 'ESBCN', portName: 'Barcelona' }, + { portCode: 'USNYC', portName: 'New York' }, + ], + }); + expect(rateQuote.isDirectRoute()).toBe(false); + }); + }); + + describe('getPricePerDay', () => { + it('should calculate price per day', () => { + const rateQuote = RateQuote.create(validProps); + const pricePerDay = rateQuote.getPricePerDay(); + expect(pricePerDay).toBeCloseTo(1100 / 19, 2); + }); + }); +}); diff --git a/apps/backend/src/domain/entities/rate-quote.entity.ts b/apps/backend/src/domain/entities/rate-quote.entity.ts index 36ce14b..c1c0a48 100644 --- a/apps/backend/src/domain/entities/rate-quote.entity.ts +++ b/apps/backend/src/domain/entities/rate-quote.entity.ts @@ -1,277 +1,277 @@ -/** - * RateQuote Entity - * - * Represents a shipping rate quote from a carrier - * - * Business Rules: - * - Price must be positive - * - ETA must be after ETD - * - Transit days must be positive - * - Rate quotes expire after 15 minutes (cache TTL) - * - Availability must be between 0 and actual capacity - */ - -export interface RouteSegment { - portCode: string; - portName: string; - arrival?: Date; - departure?: Date; - vesselName?: string; - voyageNumber?: string; -} - -export interface Surcharge { - type: string; // e.g., 'BAF', 'CAF', 'THC', 'ISPS' - description: string; - amount: number; - currency: string; -} - -export interface PriceBreakdown { - baseFreight: number; - surcharges: Surcharge[]; - totalAmount: number; - currency: string; -} - -export interface RateQuoteProps { - id: string; - carrierId: string; - carrierName: string; - carrierCode: string; - origin: { - code: string; - name: string; - country: string; - }; - destination: { - code: string; - name: string; - country: string; - }; - pricing: PriceBreakdown; - containerType: string; // e.g., '20DRY', '40HC', '40REEFER' - mode: 'FCL' | 'LCL'; - etd: Date; // Estimated Time of Departure - eta: Date; // Estimated Time of Arrival - transitDays: number; - route: RouteSegment[]; - availability: number; // Available container slots - frequency: string; // e.g., 'Weekly', 'Bi-weekly' - vesselType?: string; // e.g., 'Container Ship', 'Ro-Ro' - co2EmissionsKg?: number; // CO2 emissions in kg - validUntil: Date; // When this quote expires (typically createdAt + 15 min) - createdAt: Date; - updatedAt: Date; -} - -export class RateQuote { - private readonly props: RateQuoteProps; - - private constructor(props: RateQuoteProps) { - this.props = props; - } - - /** - * Factory method to create a new RateQuote - */ - static create( - props: Omit & { id: string } - ): RateQuote { - const now = new Date(); - const validUntil = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes - - // Validate pricing - if (props.pricing.totalAmount <= 0) { - throw new Error('Total price must be positive.'); - } - - if (props.pricing.baseFreight <= 0) { - throw new Error('Base freight must be positive.'); - } - - // Validate dates - if (props.eta <= props.etd) { - throw new Error('ETA must be after ETD.'); - } - - // Validate transit days - if (props.transitDays <= 0) { - throw new Error('Transit days must be positive.'); - } - - // Validate availability - if (props.availability < 0) { - throw new Error('Availability cannot be negative.'); - } - - // Validate route has at least origin and destination - if (props.route.length < 2) { - throw new Error('Route must have at least origin and destination ports.'); - } - - return new RateQuote({ - ...props, - validUntil, - createdAt: now, - updatedAt: now, - }); - } - - /** - * Factory method to reconstitute from persistence - */ - static fromPersistence(props: RateQuoteProps): RateQuote { - return new RateQuote(props); - } - - // Getters - get id(): string { - return this.props.id; - } - - get carrierId(): string { - return this.props.carrierId; - } - - get carrierName(): string { - return this.props.carrierName; - } - - get carrierCode(): string { - return this.props.carrierCode; - } - - get origin(): { code: string; name: string; country: string } { - return { ...this.props.origin }; - } - - get destination(): { code: string; name: string; country: string } { - return { ...this.props.destination }; - } - - get pricing(): PriceBreakdown { - return { - ...this.props.pricing, - surcharges: [...this.props.pricing.surcharges], - }; - } - - get containerType(): string { - return this.props.containerType; - } - - get mode(): 'FCL' | 'LCL' { - return this.props.mode; - } - - get etd(): Date { - return this.props.etd; - } - - get eta(): Date { - return this.props.eta; - } - - get transitDays(): number { - return this.props.transitDays; - } - - get route(): RouteSegment[] { - return [...this.props.route]; - } - - get availability(): number { - return this.props.availability; - } - - get frequency(): string { - return this.props.frequency; - } - - get vesselType(): string | undefined { - return this.props.vesselType; - } - - get co2EmissionsKg(): number | undefined { - return this.props.co2EmissionsKg; - } - - get validUntil(): Date { - return this.props.validUntil; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - // Business methods - /** - * Check if the rate quote is still valid (not expired) - */ - isValid(): boolean { - return new Date() < this.props.validUntil; - } - - /** - * Check if the rate quote has expired - */ - isExpired(): boolean { - return new Date() >= this.props.validUntil; - } - - /** - * Check if containers are available - */ - hasAvailability(): boolean { - return this.props.availability > 0; - } - - /** - * Get total surcharges amount - */ - getTotalSurcharges(): number { - return this.props.pricing.surcharges.reduce((sum, surcharge) => sum + surcharge.amount, 0); - } - - /** - * Get number of transshipments (route segments minus 2 for origin and destination) - */ - getTransshipmentCount(): number { - return Math.max(0, this.props.route.length - 2); - } - - /** - * Check if this is a direct route (no transshipments) - */ - isDirectRoute(): boolean { - return this.getTransshipmentCount() === 0; - } - - /** - * Get price per day (for comparison) - */ - getPricePerDay(): number { - return this.props.pricing.totalAmount / this.props.transitDays; - } - - /** - * Convert to plain object for persistence - */ - toObject(): RateQuoteProps { - return { - ...this.props, - origin: { ...this.props.origin }, - destination: { ...this.props.destination }, - pricing: { - ...this.props.pricing, - surcharges: [...this.props.pricing.surcharges], - }, - route: [...this.props.route], - }; - } -} +/** + * RateQuote Entity + * + * Represents a shipping rate quote from a carrier + * + * Business Rules: + * - Price must be positive + * - ETA must be after ETD + * - Transit days must be positive + * - Rate quotes expire after 15 minutes (cache TTL) + * - Availability must be between 0 and actual capacity + */ + +export interface RouteSegment { + portCode: string; + portName: string; + arrival?: Date; + departure?: Date; + vesselName?: string; + voyageNumber?: string; +} + +export interface Surcharge { + type: string; // e.g., 'BAF', 'CAF', 'THC', 'ISPS' + description: string; + amount: number; + currency: string; +} + +export interface PriceBreakdown { + baseFreight: number; + surcharges: Surcharge[]; + totalAmount: number; + currency: string; +} + +export interface RateQuoteProps { + id: string; + carrierId: string; + carrierName: string; + carrierCode: string; + origin: { + code: string; + name: string; + country: string; + }; + destination: { + code: string; + name: string; + country: string; + }; + pricing: PriceBreakdown; + containerType: string; // e.g., '20DRY', '40HC', '40REEFER' + mode: 'FCL' | 'LCL'; + etd: Date; // Estimated Time of Departure + eta: Date; // Estimated Time of Arrival + transitDays: number; + route: RouteSegment[]; + availability: number; // Available container slots + frequency: string; // e.g., 'Weekly', 'Bi-weekly' + vesselType?: string; // e.g., 'Container Ship', 'Ro-Ro' + co2EmissionsKg?: number; // CO2 emissions in kg + validUntil: Date; // When this quote expires (typically createdAt + 15 min) + createdAt: Date; + updatedAt: Date; +} + +export class RateQuote { + private readonly props: RateQuoteProps; + + private constructor(props: RateQuoteProps) { + this.props = props; + } + + /** + * Factory method to create a new RateQuote + */ + static create( + props: Omit & { id: string } + ): RateQuote { + const now = new Date(); + const validUntil = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes + + // Validate pricing + if (props.pricing.totalAmount <= 0) { + throw new Error('Total price must be positive.'); + } + + if (props.pricing.baseFreight <= 0) { + throw new Error('Base freight must be positive.'); + } + + // Validate dates + if (props.eta <= props.etd) { + throw new Error('ETA must be after ETD.'); + } + + // Validate transit days + if (props.transitDays <= 0) { + throw new Error('Transit days must be positive.'); + } + + // Validate availability + if (props.availability < 0) { + throw new Error('Availability cannot be negative.'); + } + + // Validate route has at least origin and destination + if (props.route.length < 2) { + throw new Error('Route must have at least origin and destination ports.'); + } + + return new RateQuote({ + ...props, + validUntil, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: RateQuoteProps): RateQuote { + return new RateQuote(props); + } + + // Getters + get id(): string { + return this.props.id; + } + + get carrierId(): string { + return this.props.carrierId; + } + + get carrierName(): string { + return this.props.carrierName; + } + + get carrierCode(): string { + return this.props.carrierCode; + } + + get origin(): { code: string; name: string; country: string } { + return { ...this.props.origin }; + } + + get destination(): { code: string; name: string; country: string } { + return { ...this.props.destination }; + } + + get pricing(): PriceBreakdown { + return { + ...this.props.pricing, + surcharges: [...this.props.pricing.surcharges], + }; + } + + get containerType(): string { + return this.props.containerType; + } + + get mode(): 'FCL' | 'LCL' { + return this.props.mode; + } + + get etd(): Date { + return this.props.etd; + } + + get eta(): Date { + return this.props.eta; + } + + get transitDays(): number { + return this.props.transitDays; + } + + get route(): RouteSegment[] { + return [...this.props.route]; + } + + get availability(): number { + return this.props.availability; + } + + get frequency(): string { + return this.props.frequency; + } + + get vesselType(): string | undefined { + return this.props.vesselType; + } + + get co2EmissionsKg(): number | undefined { + return this.props.co2EmissionsKg; + } + + get validUntil(): Date { + return this.props.validUntil; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + // Business methods + /** + * Check if the rate quote is still valid (not expired) + */ + isValid(): boolean { + return new Date() < this.props.validUntil; + } + + /** + * Check if the rate quote has expired + */ + isExpired(): boolean { + return new Date() >= this.props.validUntil; + } + + /** + * Check if containers are available + */ + hasAvailability(): boolean { + return this.props.availability > 0; + } + + /** + * Get total surcharges amount + */ + getTotalSurcharges(): number { + return this.props.pricing.surcharges.reduce((sum, surcharge) => sum + surcharge.amount, 0); + } + + /** + * Get number of transshipments (route segments minus 2 for origin and destination) + */ + getTransshipmentCount(): number { + return Math.max(0, this.props.route.length - 2); + } + + /** + * Check if this is a direct route (no transshipments) + */ + isDirectRoute(): boolean { + return this.getTransshipmentCount() === 0; + } + + /** + * Get price per day (for comparison) + */ + getPricePerDay(): number { + return this.props.pricing.totalAmount / this.props.transitDays; + } + + /** + * Convert to plain object for persistence + */ + toObject(): RateQuoteProps { + return { + ...this.props, + origin: { ...this.props.origin }, + destination: { ...this.props.destination }, + pricing: { + ...this.props.pricing, + surcharges: [...this.props.pricing.surcharges], + }, + route: [...this.props.route], + }; + } +} diff --git a/apps/backend/src/domain/entities/user.entity.ts b/apps/backend/src/domain/entities/user.entity.ts index 5c645b4..5fa20a2 100644 --- a/apps/backend/src/domain/entities/user.entity.ts +++ b/apps/backend/src/domain/entities/user.entity.ts @@ -1,250 +1,253 @@ -/** - * User Entity - * - * Represents a user account in the Xpeditis platform. - * - * Business Rules: - * - Email must be valid and unique - * - Password must meet complexity requirements (enforced at application layer) - * - Users belong to an organization - * - Role-based access control (Admin, Manager, User, Viewer) - */ - -export enum UserRole { - ADMIN = 'admin', // Full system access - MANAGER = 'manager', // Manage bookings and users within organization - USER = 'user', // Create and view bookings - VIEWER = 'viewer', // Read-only access -} - -export interface UserProps { - id: string; - organizationId: string; - email: string; - passwordHash: string; - role: UserRole; - firstName: string; - lastName: string; - phoneNumber?: string; - totpSecret?: string; // For 2FA - isEmailVerified: boolean; - isActive: boolean; - lastLoginAt?: Date; - createdAt: Date; - updatedAt: Date; -} - -export class User { - private readonly props: UserProps; - - private constructor(props: UserProps) { - this.props = props; - } - - /** - * Factory method to create a new User - */ - static create( - props: Omit - ): User { - const now = new Date(); - - // Validate email format (basic validation) - if (!User.isValidEmail(props.email)) { - throw new Error('Invalid email format.'); - } - - return new User({ - ...props, - isEmailVerified: false, - isActive: true, - createdAt: now, - updatedAt: now, - }); - } - - /** - * Factory method to reconstitute from persistence - */ - static fromPersistence(props: UserProps): User { - return new User(props); - } - - /** - * Validate email format - */ - private static isValidEmail(email: string): boolean { - const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailPattern.test(email); - } - - // Getters - get id(): string { - return this.props.id; - } - - get organizationId(): string { - return this.props.organizationId; - } - - get email(): string { - return this.props.email; - } - - get passwordHash(): string { - return this.props.passwordHash; - } - - get role(): UserRole { - return this.props.role; - } - - get firstName(): string { - return this.props.firstName; - } - - get lastName(): string { - return this.props.lastName; - } - - get fullName(): string { - return `${this.props.firstName} ${this.props.lastName}`; - } - - get phoneNumber(): string | undefined { - return this.props.phoneNumber; - } - - get totpSecret(): string | undefined { - return this.props.totpSecret; - } - - get isEmailVerified(): boolean { - return this.props.isEmailVerified; - } - - get isActive(): boolean { - return this.props.isActive; - } - - get lastLoginAt(): Date | undefined { - return this.props.lastLoginAt; - } - - get createdAt(): Date { - return this.props.createdAt; - } - - get updatedAt(): Date { - return this.props.updatedAt; - } - - // Business methods - has2FAEnabled(): boolean { - return !!this.props.totpSecret; - } - - isAdmin(): boolean { - return this.props.role === UserRole.ADMIN; - } - - isManager(): boolean { - return this.props.role === UserRole.MANAGER; - } - - isRegularUser(): boolean { - return this.props.role === UserRole.USER; - } - - isViewer(): boolean { - return this.props.role === UserRole.VIEWER; - } - - canManageUsers(): boolean { - return this.props.role === UserRole.ADMIN || this.props.role === UserRole.MANAGER; - } - - canCreateBookings(): boolean { - return ( - this.props.role === UserRole.ADMIN || - this.props.role === UserRole.MANAGER || - this.props.role === UserRole.USER - ); - } - - updatePassword(newPasswordHash: string): void { - this.props.passwordHash = newPasswordHash; - this.props.updatedAt = new Date(); - } - - updateRole(newRole: UserRole): void { - this.props.role = newRole; - this.props.updatedAt = new Date(); - } - - updateFirstName(firstName: string): void { - if (!firstName || firstName.trim().length === 0) { - throw new Error('First name cannot be empty.'); - } - this.props.firstName = firstName.trim(); - this.props.updatedAt = new Date(); - } - - updateLastName(lastName: string): void { - if (!lastName || lastName.trim().length === 0) { - throw new Error('Last name cannot be empty.'); - } - this.props.lastName = lastName.trim(); - this.props.updatedAt = new Date(); - } - - updateProfile(firstName: string, lastName: string, phoneNumber?: string): void { - if (!firstName || firstName.trim().length === 0) { - throw new Error('First name cannot be empty.'); - } - if (!lastName || lastName.trim().length === 0) { - throw new Error('Last name cannot be empty.'); - } - - this.props.firstName = firstName.trim(); - this.props.lastName = lastName.trim(); - this.props.phoneNumber = phoneNumber; - this.props.updatedAt = new Date(); - } - - verifyEmail(): void { - this.props.isEmailVerified = true; - this.props.updatedAt = new Date(); - } - - enable2FA(totpSecret: string): void { - this.props.totpSecret = totpSecret; - this.props.updatedAt = new Date(); - } - - disable2FA(): void { - this.props.totpSecret = undefined; - this.props.updatedAt = new Date(); - } - - recordLogin(): void { - this.props.lastLoginAt = new Date(); - } - - deactivate(): void { - this.props.isActive = false; - this.props.updatedAt = new Date(); - } - - activate(): void { - this.props.isActive = true; - this.props.updatedAt = new Date(); - } - - /** - * Convert to plain object for persistence - */ - toObject(): UserProps { - return { ...this.props }; - } -} +/** + * User Entity + * + * Represents a user account in the Xpeditis platform. + * + * Business Rules: + * - Email must be valid and unique + * - Password must meet complexity requirements (enforced at application layer) + * - Users belong to an organization + * - Role-based access control (Admin, Manager, User, Viewer) + */ + +export enum UserRole { + ADMIN = 'admin', // Full system access + MANAGER = 'manager', // Manage bookings and users within organization + USER = 'user', // Create and view bookings + VIEWER = 'viewer', // Read-only access +} + +export interface UserProps { + id: string; + organizationId: string; + email: string; + passwordHash: string; + role: UserRole; + firstName: string; + lastName: string; + phoneNumber?: string; + totpSecret?: string; // For 2FA + isEmailVerified: boolean; + isActive: boolean; + lastLoginAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export class User { + private readonly props: UserProps; + + private constructor(props: UserProps) { + this.props = props; + } + + /** + * Factory method to create a new User + */ + static create( + props: Omit< + UserProps, + 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt' + > + ): User { + const now = new Date(); + + // Validate email format (basic validation) + if (!User.isValidEmail(props.email)) { + throw new Error('Invalid email format.'); + } + + return new User({ + ...props, + isEmailVerified: false, + isActive: true, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: UserProps): User { + return new User(props); + } + + /** + * Validate email format + */ + private static isValidEmail(email: string): boolean { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(email); + } + + // Getters + get id(): string { + return this.props.id; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get email(): string { + return this.props.email; + } + + get passwordHash(): string { + return this.props.passwordHash; + } + + get role(): UserRole { + return this.props.role; + } + + get firstName(): string { + return this.props.firstName; + } + + get lastName(): string { + return this.props.lastName; + } + + get fullName(): string { + return `${this.props.firstName} ${this.props.lastName}`; + } + + get phoneNumber(): string | undefined { + return this.props.phoneNumber; + } + + get totpSecret(): string | undefined { + return this.props.totpSecret; + } + + get isEmailVerified(): boolean { + return this.props.isEmailVerified; + } + + get isActive(): boolean { + return this.props.isActive; + } + + get lastLoginAt(): Date | undefined { + return this.props.lastLoginAt; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + // Business methods + has2FAEnabled(): boolean { + return !!this.props.totpSecret; + } + + isAdmin(): boolean { + return this.props.role === UserRole.ADMIN; + } + + isManager(): boolean { + return this.props.role === UserRole.MANAGER; + } + + isRegularUser(): boolean { + return this.props.role === UserRole.USER; + } + + isViewer(): boolean { + return this.props.role === UserRole.VIEWER; + } + + canManageUsers(): boolean { + return this.props.role === UserRole.ADMIN || this.props.role === UserRole.MANAGER; + } + + canCreateBookings(): boolean { + return ( + this.props.role === UserRole.ADMIN || + this.props.role === UserRole.MANAGER || + this.props.role === UserRole.USER + ); + } + + updatePassword(newPasswordHash: string): void { + this.props.passwordHash = newPasswordHash; + this.props.updatedAt = new Date(); + } + + updateRole(newRole: UserRole): void { + this.props.role = newRole; + this.props.updatedAt = new Date(); + } + + updateFirstName(firstName: string): void { + if (!firstName || firstName.trim().length === 0) { + throw new Error('First name cannot be empty.'); + } + this.props.firstName = firstName.trim(); + this.props.updatedAt = new Date(); + } + + updateLastName(lastName: string): void { + if (!lastName || lastName.trim().length === 0) { + throw new Error('Last name cannot be empty.'); + } + this.props.lastName = lastName.trim(); + this.props.updatedAt = new Date(); + } + + updateProfile(firstName: string, lastName: string, phoneNumber?: string): void { + if (!firstName || firstName.trim().length === 0) { + throw new Error('First name cannot be empty.'); + } + if (!lastName || lastName.trim().length === 0) { + throw new Error('Last name cannot be empty.'); + } + + this.props.firstName = firstName.trim(); + this.props.lastName = lastName.trim(); + this.props.phoneNumber = phoneNumber; + this.props.updatedAt = new Date(); + } + + verifyEmail(): void { + this.props.isEmailVerified = true; + this.props.updatedAt = new Date(); + } + + enable2FA(totpSecret: string): void { + this.props.totpSecret = totpSecret; + this.props.updatedAt = new Date(); + } + + disable2FA(): void { + this.props.totpSecret = undefined; + this.props.updatedAt = new Date(); + } + + recordLogin(): void { + this.props.lastLoginAt = new Date(); + } + + deactivate(): void { + this.props.isActive = false; + this.props.updatedAt = new Date(); + } + + activate(): void { + this.props.isActive = true; + this.props.updatedAt = new Date(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): UserProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/domain/entities/webhook.entity.ts b/apps/backend/src/domain/entities/webhook.entity.ts index 10ea7ed..584ecf7 100644 --- a/apps/backend/src/domain/entities/webhook.entity.ts +++ b/apps/backend/src/domain/entities/webhook.entity.ts @@ -41,7 +41,10 @@ export class Webhook { private constructor(private readonly props: WebhookProps) {} static create( - props: Omit & { id: string }, + props: Omit< + WebhookProps, + 'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt' + > & { id: string } ): Webhook { return new Webhook({ ...props, diff --git a/apps/backend/src/domain/exceptions/carrier-timeout.exception.ts b/apps/backend/src/domain/exceptions/carrier-timeout.exception.ts index 0bc2b65..c58d811 100644 --- a/apps/backend/src/domain/exceptions/carrier-timeout.exception.ts +++ b/apps/backend/src/domain/exceptions/carrier-timeout.exception.ts @@ -1,16 +1,16 @@ -/** - * CarrierTimeoutException - * - * Thrown when a carrier API call times out - */ - -export class CarrierTimeoutException extends Error { - constructor( - public readonly carrierName: string, - public readonly timeoutMs: number - ) { - super(`Carrier ${carrierName} timed out after ${timeoutMs}ms`); - this.name = 'CarrierTimeoutException'; - Object.setPrototypeOf(this, CarrierTimeoutException.prototype); - } -} +/** + * CarrierTimeoutException + * + * Thrown when a carrier API call times out + */ + +export class CarrierTimeoutException extends Error { + constructor( + public readonly carrierName: string, + public readonly timeoutMs: number + ) { + super(`Carrier ${carrierName} timed out after ${timeoutMs}ms`); + this.name = 'CarrierTimeoutException'; + Object.setPrototypeOf(this, CarrierTimeoutException.prototype); + } +} diff --git a/apps/backend/src/domain/exceptions/carrier-unavailable.exception.ts b/apps/backend/src/domain/exceptions/carrier-unavailable.exception.ts index 34773af..2a24cba 100644 --- a/apps/backend/src/domain/exceptions/carrier-unavailable.exception.ts +++ b/apps/backend/src/domain/exceptions/carrier-unavailable.exception.ts @@ -1,16 +1,16 @@ -/** - * CarrierUnavailableException - * - * Thrown when a carrier is unavailable or not responding - */ - -export class CarrierUnavailableException extends Error { - constructor( - public readonly carrierName: string, - public readonly reason?: string - ) { - super(`Carrier ${carrierName} is unavailable${reason ? `: ${reason}` : ''}`); - this.name = 'CarrierUnavailableException'; - Object.setPrototypeOf(this, CarrierUnavailableException.prototype); - } -} +/** + * CarrierUnavailableException + * + * Thrown when a carrier is unavailable or not responding + */ + +export class CarrierUnavailableException extends Error { + constructor( + public readonly carrierName: string, + public readonly reason?: string + ) { + super(`Carrier ${carrierName} is unavailable${reason ? `: ${reason}` : ''}`); + this.name = 'CarrierUnavailableException'; + Object.setPrototypeOf(this, CarrierUnavailableException.prototype); + } +} diff --git a/apps/backend/src/domain/exceptions/index.ts b/apps/backend/src/domain/exceptions/index.ts index a64cf5c..d8ae549 100644 --- a/apps/backend/src/domain/exceptions/index.ts +++ b/apps/backend/src/domain/exceptions/index.ts @@ -1,12 +1,12 @@ -/** - * Domain Exceptions Barrel Export - * - * All domain exceptions for the Xpeditis platform - */ - -export * from './invalid-port-code.exception'; -export * from './invalid-rate-quote.exception'; -export * from './carrier-timeout.exception'; -export * from './carrier-unavailable.exception'; -export * from './rate-quote-expired.exception'; -export * from './port-not-found.exception'; +/** + * Domain Exceptions Barrel Export + * + * All domain exceptions for the Xpeditis platform + */ + +export * from './invalid-port-code.exception'; +export * from './invalid-rate-quote.exception'; +export * from './carrier-timeout.exception'; +export * from './carrier-unavailable.exception'; +export * from './rate-quote-expired.exception'; +export * from './port-not-found.exception'; diff --git a/apps/backend/src/domain/exceptions/invalid-booking-number.exception.ts b/apps/backend/src/domain/exceptions/invalid-booking-number.exception.ts index 34cad0d..a530c74 100644 --- a/apps/backend/src/domain/exceptions/invalid-booking-number.exception.ts +++ b/apps/backend/src/domain/exceptions/invalid-booking-number.exception.ts @@ -1,6 +1,6 @@ -export class InvalidBookingNumberException extends Error { - constructor(value: string) { - super(`Invalid booking number format: ${value}. Expected format: WCM-YYYY-XXXXXX`); - this.name = 'InvalidBookingNumberException'; - } -} +export class InvalidBookingNumberException extends Error { + constructor(value: string) { + super(`Invalid booking number format: ${value}. Expected format: WCM-YYYY-XXXXXX`); + this.name = 'InvalidBookingNumberException'; + } +} diff --git a/apps/backend/src/domain/exceptions/invalid-booking-status.exception.ts b/apps/backend/src/domain/exceptions/invalid-booking-status.exception.ts index d3ad69f..894c120 100644 --- a/apps/backend/src/domain/exceptions/invalid-booking-status.exception.ts +++ b/apps/backend/src/domain/exceptions/invalid-booking-status.exception.ts @@ -1,8 +1,8 @@ -export class InvalidBookingStatusException extends Error { - constructor(value: string) { - super( - `Invalid booking status: ${value}. Valid statuses: draft, pending_confirmation, confirmed, in_transit, delivered, cancelled` - ); - this.name = 'InvalidBookingStatusException'; - } -} +export class InvalidBookingStatusException extends Error { + constructor(value: string) { + super( + `Invalid booking status: ${value}. Valid statuses: draft, pending_confirmation, confirmed, in_transit, delivered, cancelled` + ); + this.name = 'InvalidBookingStatusException'; + } +} diff --git a/apps/backend/src/domain/exceptions/invalid-port-code.exception.ts b/apps/backend/src/domain/exceptions/invalid-port-code.exception.ts index 7ad11bd..f4f8eb4 100644 --- a/apps/backend/src/domain/exceptions/invalid-port-code.exception.ts +++ b/apps/backend/src/domain/exceptions/invalid-port-code.exception.ts @@ -1,13 +1,13 @@ -/** - * InvalidPortCodeException - * - * Thrown when a port code is invalid or not found - */ - -export class InvalidPortCodeException extends Error { - constructor(portCode: string, message?: string) { - super(message || `Invalid port code: ${portCode}`); - this.name = 'InvalidPortCodeException'; - Object.setPrototypeOf(this, InvalidPortCodeException.prototype); - } -} +/** + * InvalidPortCodeException + * + * Thrown when a port code is invalid or not found + */ + +export class InvalidPortCodeException extends Error { + constructor(portCode: string, message?: string) { + super(message || `Invalid port code: ${portCode}`); + this.name = 'InvalidPortCodeException'; + Object.setPrototypeOf(this, InvalidPortCodeException.prototype); + } +} diff --git a/apps/backend/src/domain/exceptions/invalid-rate-quote.exception.ts b/apps/backend/src/domain/exceptions/invalid-rate-quote.exception.ts index ad7e3f9..4197bb1 100644 --- a/apps/backend/src/domain/exceptions/invalid-rate-quote.exception.ts +++ b/apps/backend/src/domain/exceptions/invalid-rate-quote.exception.ts @@ -1,13 +1,13 @@ -/** - * InvalidRateQuoteException - * - * Thrown when a rate quote is invalid or malformed - */ - -export class InvalidRateQuoteException extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidRateQuoteException'; - Object.setPrototypeOf(this, InvalidRateQuoteException.prototype); - } -} +/** + * InvalidRateQuoteException + * + * Thrown when a rate quote is invalid or malformed + */ + +export class InvalidRateQuoteException extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidRateQuoteException'; + Object.setPrototypeOf(this, InvalidRateQuoteException.prototype); + } +} diff --git a/apps/backend/src/domain/exceptions/port-not-found.exception.ts b/apps/backend/src/domain/exceptions/port-not-found.exception.ts index 73c5bd0..c886989 100644 --- a/apps/backend/src/domain/exceptions/port-not-found.exception.ts +++ b/apps/backend/src/domain/exceptions/port-not-found.exception.ts @@ -1,13 +1,13 @@ -/** - * PortNotFoundException - * - * Thrown when a port is not found in the database - */ - -export class PortNotFoundException extends Error { - constructor(public readonly portCode: string) { - super(`Port not found: ${portCode}`); - this.name = 'PortNotFoundException'; - Object.setPrototypeOf(this, PortNotFoundException.prototype); - } -} +/** + * PortNotFoundException + * + * Thrown when a port is not found in the database + */ + +export class PortNotFoundException extends Error { + constructor(public readonly portCode: string) { + super(`Port not found: ${portCode}`); + this.name = 'PortNotFoundException'; + Object.setPrototypeOf(this, PortNotFoundException.prototype); + } +} diff --git a/apps/backend/src/domain/exceptions/rate-quote-expired.exception.ts b/apps/backend/src/domain/exceptions/rate-quote-expired.exception.ts index f55b3de..2906c10 100644 --- a/apps/backend/src/domain/exceptions/rate-quote-expired.exception.ts +++ b/apps/backend/src/domain/exceptions/rate-quote-expired.exception.ts @@ -1,16 +1,16 @@ -/** - * RateQuoteExpiredException - * - * Thrown when attempting to use an expired rate quote - */ - -export class RateQuoteExpiredException extends Error { - constructor( - public readonly rateQuoteId: string, - public readonly expiredAt: Date - ) { - super(`Rate quote ${rateQuoteId} expired at ${expiredAt.toISOString()}`); - this.name = 'RateQuoteExpiredException'; - Object.setPrototypeOf(this, RateQuoteExpiredException.prototype); - } -} +/** + * RateQuoteExpiredException + * + * Thrown when attempting to use an expired rate quote + */ + +export class RateQuoteExpiredException extends Error { + constructor( + public readonly rateQuoteId: string, + public readonly expiredAt: Date + ) { + super(`Rate quote ${rateQuoteId} expired at ${expiredAt.toISOString()}`); + this.name = 'RateQuoteExpiredException'; + Object.setPrototypeOf(this, RateQuoteExpiredException.prototype); + } +} diff --git a/apps/backend/src/domain/ports/in/get-ports.port.ts b/apps/backend/src/domain/ports/in/get-ports.port.ts index 5236840..d45c601 100644 --- a/apps/backend/src/domain/ports/in/get-ports.port.ts +++ b/apps/backend/src/domain/ports/in/get-ports.port.ts @@ -1,45 +1,45 @@ -/** - * GetPortsPort (API Port - Input) - * - * Defines the interface for port autocomplete and retrieval - */ - -import { Port } from '../../entities/port.entity'; - -export interface PortSearchInput { - query: string; // Search query (port name, city, or code) - limit?: number; // Max results (default: 10) - countryFilter?: string; // ISO country code filter -} - -export interface PortSearchOutput { - ports: Port[]; - totalMatches: number; -} - -export interface GetPortInput { - portCode: string; // UN/LOCODE -} - -export interface GetPortsPort { - /** - * Search ports by query (autocomplete) - * @param input - Port search parameters - * @returns Matching ports - */ - search(input: PortSearchInput): Promise; - - /** - * Get port by code - * @param input - Port code - * @returns Port entity - */ - getByCode(input: GetPortInput): Promise; - - /** - * Get multiple ports by codes - * @param portCodes - Array of port codes - * @returns Array of ports - */ - getByCodes(portCodes: string[]): Promise; -} +/** + * GetPortsPort (API Port - Input) + * + * Defines the interface for port autocomplete and retrieval + */ + +import { Port } from '../../entities/port.entity'; + +export interface PortSearchInput { + query: string; // Search query (port name, city, or code) + limit?: number; // Max results (default: 10) + countryFilter?: string; // ISO country code filter +} + +export interface PortSearchOutput { + ports: Port[]; + totalMatches: number; +} + +export interface GetPortInput { + portCode: string; // UN/LOCODE +} + +export interface GetPortsPort { + /** + * Search ports by query (autocomplete) + * @param input - Port search parameters + * @returns Matching ports + */ + search(input: PortSearchInput): Promise; + + /** + * Get port by code + * @param input - Port code + * @returns Port entity + */ + getByCode(input: GetPortInput): Promise; + + /** + * Get multiple ports by codes + * @param portCodes - Array of port codes + * @returns Array of ports + */ + getByCodes(portCodes: string[]): Promise; +} diff --git a/apps/backend/src/domain/ports/in/index.ts b/apps/backend/src/domain/ports/in/index.ts index c8c81ae..f41feef 100644 --- a/apps/backend/src/domain/ports/in/index.ts +++ b/apps/backend/src/domain/ports/in/index.ts @@ -1,9 +1,9 @@ -/** - * API Ports (Input) Barrel Export - * - * All input ports (use case interfaces) for the Xpeditis platform - */ - -export * from './search-rates.port'; -export * from './get-ports.port'; -export * from './validate-availability.port'; +/** + * API Ports (Input) Barrel Export + * + * All input ports (use case interfaces) for the Xpeditis platform + */ + +export * from './search-rates.port'; +export * from './get-ports.port'; +export * from './validate-availability.port'; diff --git a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts index f10b9d2..88aa46c 100644 --- a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts +++ b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts @@ -1,109 +1,109 @@ -import { CsvRate } from '../../entities/csv-rate.entity'; -import { PortCode } from '../../value-objects/port-code.vo'; -import { Volume } from '../../value-objects/volume.vo'; - -/** - * Advanced Rate Search Filters - * - * Filters for narrowing down rate search results - */ -export interface RateSearchFilters { - // Company filters - companies?: string[]; // List of company names to include - - // Volume/Weight filters - minVolumeCBM?: number; - maxVolumeCBM?: number; - minWeightKG?: number; - maxWeightKG?: number; - palletCount?: number; // Exact pallet count (0 = any) - - // Price filters - minPrice?: number; - maxPrice?: number; - currency?: 'USD' | 'EUR'; // Preferred currency for filtering - - // Transit filters - minTransitDays?: number; - maxTransitDays?: number; - - // Container type filters - containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC'] - - // Surcharge filters - onlyAllInPrices?: boolean; // Only show rates without separate surcharges - - // Date filters - departureDate?: Date; // Filter by validity for specific date -} - -/** - * CSV Rate Search Input - * - * Parameters for searching rates in CSV system - */ -export interface CsvRateSearchInput { - origin: string; // Port code (UN/LOCODE) - destination: string; // Port code (UN/LOCODE) - volumeCBM: number; // Volume in cubic meters - weightKG: number; // Weight in kilograms - palletCount?: number; // Number of pallets (0 if none) - containerType?: string; // Optional container type filter - filters?: RateSearchFilters; // Advanced filters -} - -/** - * CSV Rate Search Result - * - * Single rate result with calculated price - */ -export interface CsvRateSearchResult { - rate: CsvRate; - calculatedPrice: { - usd: number; - eur: number; - primaryCurrency: string; - }; - source: 'CSV'; - matchScore: number; // 0-100, how well it matches filters -} - -/** - * CSV Rate Search Output - * - * Results from CSV rate search - */ -export interface CsvRateSearchOutput { - results: CsvRateSearchResult[]; - totalResults: number; - searchedFiles: string[]; // CSV files searched - searchedAt: Date; - appliedFilters: RateSearchFilters; -} - -/** - * Search CSV Rates Port (Input Port) - * - * Use case for searching rates in CSV-based system - * Supports advanced filters for precise rate matching - */ -export interface SearchCsvRatesPort { - /** - * Execute CSV rate search with filters - * @param input - Search parameters and filters - * @returns Matching rates with calculated prices - */ - execute(input: CsvRateSearchInput): Promise; - - /** - * Get available companies in CSV system - * @returns List of company names that have CSV rates - */ - getAvailableCompanies(): Promise; - - /** - * Get available container types in CSV system - * @returns List of container types available - */ - getAvailableContainerTypes(): Promise; -} +import { CsvRate } from '../../entities/csv-rate.entity'; +import { PortCode } from '../../value-objects/port-code.vo'; +import { Volume } from '../../value-objects/volume.vo'; + +/** + * Advanced Rate Search Filters + * + * Filters for narrowing down rate search results + */ +export interface RateSearchFilters { + // Company filters + companies?: string[]; // List of company names to include + + // Volume/Weight filters + minVolumeCBM?: number; + maxVolumeCBM?: number; + minWeightKG?: number; + maxWeightKG?: number; + palletCount?: number; // Exact pallet count (0 = any) + + // Price filters + minPrice?: number; + maxPrice?: number; + currency?: 'USD' | 'EUR'; // Preferred currency for filtering + + // Transit filters + minTransitDays?: number; + maxTransitDays?: number; + + // Container type filters + containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC'] + + // Surcharge filters + onlyAllInPrices?: boolean; // Only show rates without separate surcharges + + // Date filters + departureDate?: Date; // Filter by validity for specific date +} + +/** + * CSV Rate Search Input + * + * Parameters for searching rates in CSV system + */ +export interface CsvRateSearchInput { + origin: string; // Port code (UN/LOCODE) + destination: string; // Port code (UN/LOCODE) + volumeCBM: number; // Volume in cubic meters + weightKG: number; // Weight in kilograms + palletCount?: number; // Number of pallets (0 if none) + containerType?: string; // Optional container type filter + filters?: RateSearchFilters; // Advanced filters +} + +/** + * CSV Rate Search Result + * + * Single rate result with calculated price + */ +export interface CsvRateSearchResult { + rate: CsvRate; + calculatedPrice: { + usd: number; + eur: number; + primaryCurrency: string; + }; + source: 'CSV'; + matchScore: number; // 0-100, how well it matches filters +} + +/** + * CSV Rate Search Output + * + * Results from CSV rate search + */ +export interface CsvRateSearchOutput { + results: CsvRateSearchResult[]; + totalResults: number; + searchedFiles: string[]; // CSV files searched + searchedAt: Date; + appliedFilters: RateSearchFilters; +} + +/** + * Search CSV Rates Port (Input Port) + * + * Use case for searching rates in CSV-based system + * Supports advanced filters for precise rate matching + */ +export interface SearchCsvRatesPort { + /** + * Execute CSV rate search with filters + * @param input - Search parameters and filters + * @returns Matching rates with calculated prices + */ + execute(input: CsvRateSearchInput): Promise; + + /** + * Get available companies in CSV system + * @returns List of company names that have CSV rates + */ + getAvailableCompanies(): Promise; + + /** + * Get available container types in CSV system + * @returns List of container types available + */ + getAvailableContainerTypes(): Promise; +} diff --git a/apps/backend/src/domain/ports/in/search-rates.port.ts b/apps/backend/src/domain/ports/in/search-rates.port.ts index 660a1ce..c902cab 100644 --- a/apps/backend/src/domain/ports/in/search-rates.port.ts +++ b/apps/backend/src/domain/ports/in/search-rates.port.ts @@ -1,44 +1,44 @@ -/** - * SearchRatesPort (API Port - Input) - * - * Defines the interface for searching shipping rates - * This is the entry point for the rate search use case - */ - -import { RateQuote } from '../../entities/rate-quote.entity'; - -export interface RateSearchInput { - origin: string; // Port code (UN/LOCODE) - destination: string; // Port code (UN/LOCODE) - containerType: string; // e.g., '20DRY', '40HC' - mode: 'FCL' | 'LCL'; - departureDate: Date; - quantity?: number; // Number of containers (default: 1) - weight?: number; // For LCL (kg) - volume?: number; // For LCL (CBM) - isHazmat?: boolean; - imoClass?: string; // If hazmat - carrierPreferences?: string[]; // Specific carrier codes to query -} - -export interface RateSearchOutput { - quotes: RateQuote[]; - searchId: string; - searchedAt: Date; - totalResults: number; - carrierResults: { - carrierName: string; - status: 'success' | 'error' | 'timeout'; - resultCount: number; - errorMessage?: string; - }[]; -} - -export interface SearchRatesPort { - /** - * Execute rate search across multiple carriers - * @param input - Rate search parameters - * @returns Rate quotes from available carriers - */ - execute(input: RateSearchInput): Promise; -} +/** + * SearchRatesPort (API Port - Input) + * + * Defines the interface for searching shipping rates + * This is the entry point for the rate search use case + */ + +import { RateQuote } from '../../entities/rate-quote.entity'; + +export interface RateSearchInput { + origin: string; // Port code (UN/LOCODE) + destination: string; // Port code (UN/LOCODE) + containerType: string; // e.g., '20DRY', '40HC' + mode: 'FCL' | 'LCL'; + departureDate: Date; + quantity?: number; // Number of containers (default: 1) + weight?: number; // For LCL (kg) + volume?: number; // For LCL (CBM) + isHazmat?: boolean; + imoClass?: string; // If hazmat + carrierPreferences?: string[]; // Specific carrier codes to query +} + +export interface RateSearchOutput { + quotes: RateQuote[]; + searchId: string; + searchedAt: Date; + totalResults: number; + carrierResults: { + carrierName: string; + status: 'success' | 'error' | 'timeout'; + resultCount: number; + errorMessage?: string; + }[]; +} + +export interface SearchRatesPort { + /** + * Execute rate search across multiple carriers + * @param input - Rate search parameters + * @returns Rate quotes from available carriers + */ + execute(input: RateSearchInput): Promise; +} diff --git a/apps/backend/src/domain/ports/in/validate-availability.port.ts b/apps/backend/src/domain/ports/in/validate-availability.port.ts index f5920c0..bfd15f6 100644 --- a/apps/backend/src/domain/ports/in/validate-availability.port.ts +++ b/apps/backend/src/domain/ports/in/validate-availability.port.ts @@ -1,27 +1,27 @@ -/** - * ValidateAvailabilityPort (API Port - Input) - * - * Defines the interface for validating container availability - */ - -export interface AvailabilityInput { - rateQuoteId: string; - quantity: number; // Number of containers requested -} - -export interface AvailabilityOutput { - isAvailable: boolean; - availableQuantity: number; - requestedQuantity: number; - rateQuoteId: string; - validUntil: Date; -} - -export interface ValidateAvailabilityPort { - /** - * Validate if containers are available for a rate quote - * @param input - Availability check parameters - * @returns Availability status - */ - execute(input: AvailabilityInput): Promise; -} +/** + * ValidateAvailabilityPort (API Port - Input) + * + * Defines the interface for validating container availability + */ + +export interface AvailabilityInput { + rateQuoteId: string; + quantity: number; // Number of containers requested +} + +export interface AvailabilityOutput { + isAvailable: boolean; + availableQuantity: number; + requestedQuantity: number; + rateQuoteId: string; + validUntil: Date; +} + +export interface ValidateAvailabilityPort { + /** + * Validate if containers are available for a rate quote + * @param input - Availability check parameters + * @returns Availability status + */ + execute(input: AvailabilityInput): Promise; +} diff --git a/apps/backend/src/domain/services/availability-validation.service.ts b/apps/backend/src/domain/services/availability-validation.service.ts index e9ddae4..fbe44d7 100644 --- a/apps/backend/src/domain/services/availability-validation.service.ts +++ b/apps/backend/src/domain/services/availability-validation.service.ts @@ -1,48 +1,48 @@ -/** - * AvailabilityValidationService - * - * Domain service for validating container availability - * - * Business Rules: - * - Check if rate quote is still valid (not expired) - * - Verify requested quantity is available - */ - -import { - ValidateAvailabilityPort, - AvailabilityInput, - AvailabilityOutput, -} from '../ports/in/validate-availability.port'; -import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; -import { InvalidRateQuoteException } from '../exceptions/invalid-rate-quote.exception'; -import { RateQuoteExpiredException } from '../exceptions/rate-quote-expired.exception'; - -export class AvailabilityValidationService implements ValidateAvailabilityPort { - constructor(private readonly rateQuoteRepository: RateQuoteRepository) {} - - async execute(input: AvailabilityInput): Promise { - // Find rate quote - const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId); - - if (!rateQuote) { - throw new InvalidRateQuoteException(`Rate quote not found: ${input.rateQuoteId}`); - } - - // Check if rate quote has expired - if (rateQuote.isExpired()) { - throw new RateQuoteExpiredException(rateQuote.id, rateQuote.validUntil); - } - - // Check availability - const availableQuantity = rateQuote.availability; - const isAvailable = availableQuantity >= input.quantity; - - return { - isAvailable, - availableQuantity, - requestedQuantity: input.quantity, - rateQuoteId: rateQuote.id, - validUntil: rateQuote.validUntil, - }; - } -} +/** + * AvailabilityValidationService + * + * Domain service for validating container availability + * + * Business Rules: + * - Check if rate quote is still valid (not expired) + * - Verify requested quantity is available + */ + +import { + ValidateAvailabilityPort, + AvailabilityInput, + AvailabilityOutput, +} from '../ports/in/validate-availability.port'; +import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; +import { InvalidRateQuoteException } from '../exceptions/invalid-rate-quote.exception'; +import { RateQuoteExpiredException } from '../exceptions/rate-quote-expired.exception'; + +export class AvailabilityValidationService implements ValidateAvailabilityPort { + constructor(private readonly rateQuoteRepository: RateQuoteRepository) {} + + async execute(input: AvailabilityInput): Promise { + // Find rate quote + const rateQuote = await this.rateQuoteRepository.findById(input.rateQuoteId); + + if (!rateQuote) { + throw new InvalidRateQuoteException(`Rate quote not found: ${input.rateQuoteId}`); + } + + // Check if rate quote has expired + if (rateQuote.isExpired()) { + throw new RateQuoteExpiredException(rateQuote.id, rateQuote.validUntil); + } + + // Check availability + const availableQuantity = rateQuote.availability; + const isAvailable = availableQuantity >= input.quantity; + + return { + isAvailable, + availableQuantity, + requestedQuantity: input.quantity, + rateQuoteId: rateQuote.id, + validUntil: rateQuote.validUntil, + }; + } +} diff --git a/apps/backend/src/domain/services/csv-rate-search.service.ts b/apps/backend/src/domain/services/csv-rate-search.service.ts index ab1d29f..98e19d0 100644 --- a/apps/backend/src/domain/services/csv-rate-search.service.ts +++ b/apps/backend/src/domain/services/csv-rate-search.service.ts @@ -1,284 +1,250 @@ -import { CsvRate } from '../entities/csv-rate.entity'; -import { PortCode } from '../value-objects/port-code.vo'; -import { ContainerType } from '../value-objects/container-type.vo'; -import { Volume } from '../value-objects/volume.vo'; -import { Money } from '../value-objects/money.vo'; -import { - SearchCsvRatesPort, - CsvRateSearchInput, - CsvRateSearchOutput, - CsvRateSearchResult, - RateSearchFilters, -} from '../ports/in/search-csv-rates.port'; -import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port'; - -/** - * CSV Rate Search Service - * - * Domain service implementing CSV rate search use case. - * Applies business rules for matching rates and filtering. - * - * Pure domain logic - no framework dependencies. - */ -export class CsvRateSearchService implements SearchCsvRatesPort { - constructor(private readonly csvRateLoader: CsvRateLoaderPort) {} - - async execute(input: CsvRateSearchInput): Promise { - const searchStartTime = new Date(); - - // Parse and validate input - const origin = PortCode.create(input.origin); - const destination = PortCode.create(input.destination); - const volume = new Volume(input.volumeCBM, input.weightKG); - const palletCount = input.palletCount ?? 0; - - // Load all CSV rates - const allRates = await this.loadAllRates(); - - // Apply route and volume matching - let matchingRates = this.filterByRoute(allRates, origin, destination); - matchingRates = this.filterByVolume(matchingRates, volume); - matchingRates = this.filterByPalletCount(matchingRates, palletCount); - - // Apply container type filter if specified - if (input.containerType) { - const containerType = ContainerType.create(input.containerType); - matchingRates = matchingRates.filter((rate) => - rate.containerType.equals(containerType), - ); - } - - // Apply advanced filters - if (input.filters) { - matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume); - } - - // Calculate prices and create results - const results: CsvRateSearchResult[] = matchingRates.map((rate) => { - const priceUSD = rate.getPriceInCurrency(volume, 'USD'); - const priceEUR = rate.getPriceInCurrency(volume, 'EUR'); - - return { - rate, - calculatedPrice: { - usd: priceUSD.getAmount(), - eur: priceEUR.getAmount(), - primaryCurrency: rate.currency, - }, - source: 'CSV' as const, - matchScore: this.calculateMatchScore(rate, input), - }; - }); - - // Sort by price (ascending) in primary currency - results.sort((a, b) => { - const priceA = - a.calculatedPrice.primaryCurrency === 'USD' - ? a.calculatedPrice.usd - : a.calculatedPrice.eur; - const priceB = - b.calculatedPrice.primaryCurrency === 'USD' - ? b.calculatedPrice.usd - : b.calculatedPrice.eur; - return priceA - priceB; - }); - - return { - results, - totalResults: results.length, - searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(), - searchedAt: searchStartTime, - appliedFilters: input.filters || {}, - }; - } - - async getAvailableCompanies(): Promise { - const allRates = await this.loadAllRates(); - const companies = new Set(allRates.map((rate) => rate.companyName)); - return Array.from(companies).sort(); - } - - async getAvailableContainerTypes(): Promise { - const allRates = await this.loadAllRates(); - const types = new Set(allRates.map((rate) => rate.containerType.getValue())); - return Array.from(types).sort(); - } - - /** - * Load all rates from all CSV files - */ - private async loadAllRates(): Promise { - const files = await this.csvRateLoader.getAvailableCsvFiles(); - const ratePromises = files.map((file) => - this.csvRateLoader.loadRatesFromCsv(file), - ); - const rateArrays = await Promise.all(ratePromises); - return rateArrays.flat(); - } - - /** - * Filter rates by route (origin/destination) - */ - private filterByRoute( - rates: CsvRate[], - origin: PortCode, - destination: PortCode, - ): CsvRate[] { - return rates.filter((rate) => rate.matchesRoute(origin, destination)); - } - - /** - * Filter rates by volume/weight range - */ - private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] { - return rates.filter((rate) => rate.matchesVolume(volume)); - } - - /** - * Filter rates by pallet count - */ - private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] { - return rates.filter((rate) => rate.matchesPalletCount(palletCount)); - } - - /** - * Apply advanced filters to rate list - */ - private applyAdvancedFilters( - rates: CsvRate[], - filters: RateSearchFilters, - volume: Volume, - ): CsvRate[] { - let filtered = rates; - - // Company filter - if (filters.companies && filters.companies.length > 0) { - filtered = filtered.filter((rate) => - filters.companies!.includes(rate.companyName), - ); - } - - // Volume CBM filter - if (filters.minVolumeCBM !== undefined) { - filtered = filtered.filter( - (rate) => rate.volumeRange.maxCBM >= filters.minVolumeCBM!, - ); - } - if (filters.maxVolumeCBM !== undefined) { - filtered = filtered.filter( - (rate) => rate.volumeRange.minCBM <= filters.maxVolumeCBM!, - ); - } - - // Weight KG filter - if (filters.minWeightKG !== undefined) { - filtered = filtered.filter( - (rate) => rate.weightRange.maxKG >= filters.minWeightKG!, - ); - } - if (filters.maxWeightKG !== undefined) { - filtered = filtered.filter( - (rate) => rate.weightRange.minKG <= filters.maxWeightKG!, - ); - } - - // Pallet count filter - if (filters.palletCount !== undefined) { - filtered = filtered.filter((rate) => - rate.matchesPalletCount(filters.palletCount!), - ); - } - - // Price filter (calculate price first) - if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { - const currency = filters.currency || 'USD'; - filtered = filtered.filter((rate) => { - const price = rate.getPriceInCurrency(volume, currency); - const amount = price.getAmount(); - - if (filters.minPrice !== undefined && amount < filters.minPrice) { - return false; - } - if (filters.maxPrice !== undefined && amount > filters.maxPrice) { - return false; - } - return true; - }); - } - - // Transit days filter - if (filters.minTransitDays !== undefined) { - filtered = filtered.filter( - (rate) => rate.transitDays >= filters.minTransitDays!, - ); - } - if (filters.maxTransitDays !== undefined) { - filtered = filtered.filter( - (rate) => rate.transitDays <= filters.maxTransitDays!, - ); - } - - // Container type filter - if (filters.containerTypes && filters.containerTypes.length > 0) { - filtered = filtered.filter((rate) => - filters.containerTypes!.includes(rate.containerType.getValue()), - ); - } - - // All-in prices only filter - if (filters.onlyAllInPrices) { - filtered = filtered.filter((rate) => rate.isAllInPrice()); - } - - // Departure date / validity filter - if (filters.departureDate) { - filtered = filtered.filter((rate) => - rate.isValidForDate(filters.departureDate!), - ); - } - - return filtered; - } - - /** - * Calculate match score (0-100) based on how well rate matches input - * Higher score = better match - */ - private calculateMatchScore( - rate: CsvRate, - input: CsvRateSearchInput, - ): number { - let score = 100; - - // Reduce score if volume/weight is near boundaries - const volumeUtilization = - (input.volumeCBM - rate.volumeRange.minCBM) / - (rate.volumeRange.maxCBM - rate.volumeRange.minCBM); - if (volumeUtilization < 0.2 || volumeUtilization > 0.8) { - score -= 10; // Near boundaries - } - - // Reduce score if pallet count doesn't match exactly - if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) { - score -= 5; - } - - // Increase score for all-in prices (simpler for customers) - if (rate.isAllInPrice()) { - score += 5; - } - - // Reduce score for rates expiring soon - const daysUntilExpiry = Math.floor( - (rate.validity.getEndDate().getTime() - Date.now()) / - (1000 * 60 * 60 * 24), - ); - if (daysUntilExpiry < 7) { - score -= 10; - } else if (daysUntilExpiry < 30) { - score -= 5; - } - - return Math.max(0, Math.min(100, score)); - } -} +import { CsvRate } from '../entities/csv-rate.entity'; +import { PortCode } from '../value-objects/port-code.vo'; +import { ContainerType } from '../value-objects/container-type.vo'; +import { Volume } from '../value-objects/volume.vo'; +import { Money } from '../value-objects/money.vo'; +import { + SearchCsvRatesPort, + CsvRateSearchInput, + CsvRateSearchOutput, + CsvRateSearchResult, + RateSearchFilters, +} from '../ports/in/search-csv-rates.port'; +import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port'; + +/** + * CSV Rate Search Service + * + * Domain service implementing CSV rate search use case. + * Applies business rules for matching rates and filtering. + * + * Pure domain logic - no framework dependencies. + */ +export class CsvRateSearchService implements SearchCsvRatesPort { + constructor(private readonly csvRateLoader: CsvRateLoaderPort) {} + + async execute(input: CsvRateSearchInput): Promise { + const searchStartTime = new Date(); + + // Parse and validate input + const origin = PortCode.create(input.origin); + const destination = PortCode.create(input.destination); + const volume = new Volume(input.volumeCBM, input.weightKG); + const palletCount = input.palletCount ?? 0; + + // Load all CSV rates + const allRates = await this.loadAllRates(); + + // Apply route and volume matching + let matchingRates = this.filterByRoute(allRates, origin, destination); + matchingRates = this.filterByVolume(matchingRates, volume); + matchingRates = this.filterByPalletCount(matchingRates, palletCount); + + // Apply container type filter if specified + if (input.containerType) { + const containerType = ContainerType.create(input.containerType); + matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); + } + + // Apply advanced filters + if (input.filters) { + matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume); + } + + // Calculate prices and create results + const results: CsvRateSearchResult[] = matchingRates.map(rate => { + const priceUSD = rate.getPriceInCurrency(volume, 'USD'); + const priceEUR = rate.getPriceInCurrency(volume, 'EUR'); + + return { + rate, + calculatedPrice: { + usd: priceUSD.getAmount(), + eur: priceEUR.getAmount(), + primaryCurrency: rate.currency, + }, + source: 'CSV' as const, + matchScore: this.calculateMatchScore(rate, input), + }; + }); + + // Sort by price (ascending) in primary currency + results.sort((a, b) => { + const priceA = + a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur; + const priceB = + b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur; + return priceA - priceB; + }); + + return { + results, + totalResults: results.length, + searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(), + searchedAt: searchStartTime, + appliedFilters: input.filters || {}, + }; + } + + async getAvailableCompanies(): Promise { + const allRates = await this.loadAllRates(); + const companies = new Set(allRates.map(rate => rate.companyName)); + return Array.from(companies).sort(); + } + + async getAvailableContainerTypes(): Promise { + const allRates = await this.loadAllRates(); + const types = new Set(allRates.map(rate => rate.containerType.getValue())); + return Array.from(types).sort(); + } + + /** + * Load all rates from all CSV files + */ + private async loadAllRates(): Promise { + const files = await this.csvRateLoader.getAvailableCsvFiles(); + const ratePromises = files.map(file => this.csvRateLoader.loadRatesFromCsv(file)); + const rateArrays = await Promise.all(ratePromises); + return rateArrays.flat(); + } + + /** + * Filter rates by route (origin/destination) + */ + private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] { + return rates.filter(rate => rate.matchesRoute(origin, destination)); + } + + /** + * Filter rates by volume/weight range + */ + private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] { + return rates.filter(rate => rate.matchesVolume(volume)); + } + + /** + * Filter rates by pallet count + */ + private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] { + return rates.filter(rate => rate.matchesPalletCount(palletCount)); + } + + /** + * Apply advanced filters to rate list + */ + private applyAdvancedFilters( + rates: CsvRate[], + filters: RateSearchFilters, + volume: Volume + ): CsvRate[] { + let filtered = rates; + + // Company filter + if (filters.companies && filters.companies.length > 0) { + filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName)); + } + + // Volume CBM filter + if (filters.minVolumeCBM !== undefined) { + filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!); + } + if (filters.maxVolumeCBM !== undefined) { + filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!); + } + + // Weight KG filter + if (filters.minWeightKG !== undefined) { + filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!); + } + if (filters.maxWeightKG !== undefined) { + filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!); + } + + // Pallet count filter + if (filters.palletCount !== undefined) { + filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!)); + } + + // Price filter (calculate price first) + if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { + const currency = filters.currency || 'USD'; + filtered = filtered.filter(rate => { + const price = rate.getPriceInCurrency(volume, currency); + const amount = price.getAmount(); + + if (filters.minPrice !== undefined && amount < filters.minPrice) { + return false; + } + if (filters.maxPrice !== undefined && amount > filters.maxPrice) { + return false; + } + return true; + }); + } + + // Transit days filter + if (filters.minTransitDays !== undefined) { + filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!); + } + if (filters.maxTransitDays !== undefined) { + filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!); + } + + // Container type filter + if (filters.containerTypes && filters.containerTypes.length > 0) { + filtered = filtered.filter(rate => + filters.containerTypes!.includes(rate.containerType.getValue()) + ); + } + + // All-in prices only filter + if (filters.onlyAllInPrices) { + filtered = filtered.filter(rate => rate.isAllInPrice()); + } + + // Departure date / validity filter + if (filters.departureDate) { + filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!)); + } + + return filtered; + } + + /** + * Calculate match score (0-100) based on how well rate matches input + * Higher score = better match + */ + private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number { + let score = 100; + + // Reduce score if volume/weight is near boundaries + const volumeUtilization = + (input.volumeCBM - rate.volumeRange.minCBM) / + (rate.volumeRange.maxCBM - rate.volumeRange.minCBM); + if (volumeUtilization < 0.2 || volumeUtilization > 0.8) { + score -= 10; // Near boundaries + } + + // Reduce score if pallet count doesn't match exactly + if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) { + score -= 5; + } + + // Increase score for all-in prices (simpler for customers) + if (rate.isAllInPrice()) { + score += 5; + } + + // Reduce score for rates expiring soon + const daysUntilExpiry = Math.floor( + (rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + if (daysUntilExpiry < 7) { + score -= 10; + } else if (daysUntilExpiry < 30) { + score -= 5; + } + + return Math.max(0, Math.min(100, score)); + } +} diff --git a/apps/backend/src/domain/services/index.ts b/apps/backend/src/domain/services/index.ts index e533471..1d514e6 100644 --- a/apps/backend/src/domain/services/index.ts +++ b/apps/backend/src/domain/services/index.ts @@ -1,10 +1,10 @@ -/** - * Domain Services Barrel Export - * - * All domain services for the Xpeditis platform - */ - -export * from './rate-search.service'; -export * from './port-search.service'; -export * from './availability-validation.service'; -export * from './booking.service'; +/** + * Domain Services Barrel Export + * + * All domain services for the Xpeditis platform + */ + +export * from './rate-search.service'; +export * from './port-search.service'; +export * from './availability-validation.service'; +export * from './booking.service'; diff --git a/apps/backend/src/domain/services/port-search.service.ts b/apps/backend/src/domain/services/port-search.service.ts index 380844c..fdaae78 100644 --- a/apps/backend/src/domain/services/port-search.service.ts +++ b/apps/backend/src/domain/services/port-search.service.ts @@ -1,65 +1,70 @@ -/** - * PortSearchService - * - * Domain service for port search and autocomplete - * - * Business Rules: - * - Fuzzy search on port name, city, and code - * - Return top 10 results by default - * - Support country filtering - */ - -import { Port } from '../entities/port.entity'; -import { GetPortsPort, PortSearchInput, PortSearchOutput, GetPortInput } from '../ports/in/get-ports.port'; -import { PortRepository } from '../ports/out/port.repository'; -import { PortNotFoundException } from '../exceptions/port-not-found.exception'; - -export class PortSearchService implements GetPortsPort { - private static readonly DEFAULT_LIMIT = 10; - - constructor(private readonly portRepository: PortRepository) {} - - async search(input: PortSearchInput): Promise { - const limit = input.limit || PortSearchService.DEFAULT_LIMIT; - const query = input.query.trim(); - - if (query.length === 0) { - return { - ports: [], - totalMatches: 0, - }; - } - - // Search using repository fuzzy search - const ports = await this.portRepository.search(query, limit, input.countryFilter); - - return { - ports, - totalMatches: ports.length, - }; - } - - async getByCode(input: GetPortInput): Promise { - const port = await this.portRepository.findByCode(input.portCode); - - if (!port) { - throw new PortNotFoundException(input.portCode); - } - - return port; - } - - async getByCodes(portCodes: string[]): Promise { - const ports = await this.portRepository.findByCodes(portCodes); - - // Check if all ports were found - const foundCodes = ports.map((p) => p.code); - const missingCodes = portCodes.filter((code) => !foundCodes.includes(code)); - - if (missingCodes.length > 0) { - throw new PortNotFoundException(missingCodes[0]); - } - - return ports; - } -} +/** + * PortSearchService + * + * Domain service for port search and autocomplete + * + * Business Rules: + * - Fuzzy search on port name, city, and code + * - Return top 10 results by default + * - Support country filtering + */ + +import { Port } from '../entities/port.entity'; +import { + GetPortsPort, + PortSearchInput, + PortSearchOutput, + GetPortInput, +} from '../ports/in/get-ports.port'; +import { PortRepository } from '../ports/out/port.repository'; +import { PortNotFoundException } from '../exceptions/port-not-found.exception'; + +export class PortSearchService implements GetPortsPort { + private static readonly DEFAULT_LIMIT = 10; + + constructor(private readonly portRepository: PortRepository) {} + + async search(input: PortSearchInput): Promise { + const limit = input.limit || PortSearchService.DEFAULT_LIMIT; + const query = input.query.trim(); + + if (query.length === 0) { + return { + ports: [], + totalMatches: 0, + }; + } + + // Search using repository fuzzy search + const ports = await this.portRepository.search(query, limit, input.countryFilter); + + return { + ports, + totalMatches: ports.length, + }; + } + + async getByCode(input: GetPortInput): Promise { + const port = await this.portRepository.findByCode(input.portCode); + + if (!port) { + throw new PortNotFoundException(input.portCode); + } + + return port; + } + + async getByCodes(portCodes: string[]): Promise { + const ports = await this.portRepository.findByCodes(portCodes); + + // Check if all ports were found + const foundCodes = ports.map(p => p.code); + const missingCodes = portCodes.filter(code => !foundCodes.includes(code)); + + if (missingCodes.length > 0) { + throw new PortNotFoundException(missingCodes[0]); + } + + return ports; + } +} diff --git a/apps/backend/src/domain/services/rate-search.service.ts b/apps/backend/src/domain/services/rate-search.service.ts index d255d2b..7dc759a 100644 --- a/apps/backend/src/domain/services/rate-search.service.ts +++ b/apps/backend/src/domain/services/rate-search.service.ts @@ -1,165 +1,165 @@ -/** - * RateSearchService - * - * Domain service implementing the rate search business logic - * - * Business Rules: - * - Query multiple carriers in parallel - * - Cache results for 15 minutes - * - Handle carrier timeouts gracefully (5s max) - * - Return results even if some carriers fail - */ - -import { RateQuote } from '../entities/rate-quote.entity'; -import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port'; -import { CarrierConnectorPort } from '../ports/out/carrier-connector.port'; -import { CachePort } from '../ports/out/cache.port'; -import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; -import { PortRepository } from '../ports/out/port.repository'; -import { CarrierRepository } from '../ports/out/carrier.repository'; -import { PortNotFoundException } from '../exceptions/port-not-found.exception'; -import { v4 as uuidv4 } from 'uuid'; - -export class RateSearchService implements SearchRatesPort { - private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes - - constructor( - private readonly carrierConnectors: CarrierConnectorPort[], - private readonly cache: CachePort, - private readonly rateQuoteRepository: RateQuoteRepository, - private readonly portRepository: PortRepository, - private readonly carrierRepository: CarrierRepository - ) {} - - async execute(input: RateSearchInput): Promise { - const searchId = uuidv4(); - const searchedAt = new Date(); - - // Validate ports exist - await this.validatePorts(input.origin, input.destination); - - // Generate cache key - const cacheKey = this.generateCacheKey(input); - - // Check cache first - const cachedResults = await this.cache.get(cacheKey); - if (cachedResults) { - return cachedResults; - } - - // Filter carriers if preferences specified - const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences); - - // Query all carriers in parallel with Promise.allSettled - const carrierResults = await Promise.allSettled( - connectorsToQuery.map((connector) => this.queryCarrier(connector, input)) - ); - - // Process results - const quotes: RateQuote[] = []; - const carrierResultsSummary: RateSearchOutput['carrierResults'] = []; - - for (let i = 0; i < carrierResults.length; i++) { - const result = carrierResults[i]; - const connector = connectorsToQuery[i]; - const carrierName = connector.getCarrierName(); - - if (result.status === 'fulfilled') { - const carrierQuotes = result.value; - quotes.push(...carrierQuotes); - - carrierResultsSummary.push({ - carrierName, - status: 'success', - resultCount: carrierQuotes.length, - }); - } else { - // Handle error - const error = result.reason; - carrierResultsSummary.push({ - carrierName, - status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error', - resultCount: 0, - errorMessage: error.message, - }); - } - } - - // Save rate quotes to database - if (quotes.length > 0) { - await this.rateQuoteRepository.saveMany(quotes); - } - - // Build output - const output: RateSearchOutput = { - quotes, - searchId, - searchedAt, - totalResults: quotes.length, - carrierResults: carrierResultsSummary, - }; - - // Cache results - await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS); - - return output; - } - - private async validatePorts(originCode: string, destinationCode: string): Promise { - const [origin, destination] = await Promise.all([ - this.portRepository.findByCode(originCode), - this.portRepository.findByCode(destinationCode), - ]); - - if (!origin) { - throw new PortNotFoundException(originCode); - } - - if (!destination) { - throw new PortNotFoundException(destinationCode); - } - } - - private generateCacheKey(input: RateSearchInput): string { - const parts = [ - 'rate-search', - input.origin, - input.destination, - input.containerType, - input.mode, - input.departureDate.toISOString().split('T')[0], - input.quantity || 1, - input.isHazmat ? 'hazmat' : 'standard', - ]; - - return parts.join(':'); - } - - private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] { - if (!carrierPreferences || carrierPreferences.length === 0) { - return this.carrierConnectors; - } - - return this.carrierConnectors.filter((connector) => - carrierPreferences.includes(connector.getCarrierCode()) - ); - } - - private async queryCarrier( - connector: CarrierConnectorPort, - input: RateSearchInput - ): Promise { - return connector.searchRates({ - origin: input.origin, - destination: input.destination, - containerType: input.containerType, - mode: input.mode, - departureDate: input.departureDate, - quantity: input.quantity, - weight: input.weight, - volume: input.volume, - isHazmat: input.isHazmat, - imoClass: input.imoClass, - }); - } -} +/** + * RateSearchService + * + * Domain service implementing the rate search business logic + * + * Business Rules: + * - Query multiple carriers in parallel + * - Cache results for 15 minutes + * - Handle carrier timeouts gracefully (5s max) + * - Return results even if some carriers fail + */ + +import { RateQuote } from '../entities/rate-quote.entity'; +import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port'; +import { CarrierConnectorPort } from '../ports/out/carrier-connector.port'; +import { CachePort } from '../ports/out/cache.port'; +import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; +import { PortRepository } from '../ports/out/port.repository'; +import { CarrierRepository } from '../ports/out/carrier.repository'; +import { PortNotFoundException } from '../exceptions/port-not-found.exception'; +import { v4 as uuidv4 } from 'uuid'; + +export class RateSearchService implements SearchRatesPort { + private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes + + constructor( + private readonly carrierConnectors: CarrierConnectorPort[], + private readonly cache: CachePort, + private readonly rateQuoteRepository: RateQuoteRepository, + private readonly portRepository: PortRepository, + private readonly carrierRepository: CarrierRepository + ) {} + + async execute(input: RateSearchInput): Promise { + const searchId = uuidv4(); + const searchedAt = new Date(); + + // Validate ports exist + await this.validatePorts(input.origin, input.destination); + + // Generate cache key + const cacheKey = this.generateCacheKey(input); + + // Check cache first + const cachedResults = await this.cache.get(cacheKey); + if (cachedResults) { + return cachedResults; + } + + // Filter carriers if preferences specified + const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences); + + // Query all carriers in parallel with Promise.allSettled + const carrierResults = await Promise.allSettled( + connectorsToQuery.map(connector => this.queryCarrier(connector, input)) + ); + + // Process results + const quotes: RateQuote[] = []; + const carrierResultsSummary: RateSearchOutput['carrierResults'] = []; + + for (let i = 0; i < carrierResults.length; i++) { + const result = carrierResults[i]; + const connector = connectorsToQuery[i]; + const carrierName = connector.getCarrierName(); + + if (result.status === 'fulfilled') { + const carrierQuotes = result.value; + quotes.push(...carrierQuotes); + + carrierResultsSummary.push({ + carrierName, + status: 'success', + resultCount: carrierQuotes.length, + }); + } else { + // Handle error + const error = result.reason; + carrierResultsSummary.push({ + carrierName, + status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error', + resultCount: 0, + errorMessage: error.message, + }); + } + } + + // Save rate quotes to database + if (quotes.length > 0) { + await this.rateQuoteRepository.saveMany(quotes); + } + + // Build output + const output: RateSearchOutput = { + quotes, + searchId, + searchedAt, + totalResults: quotes.length, + carrierResults: carrierResultsSummary, + }; + + // Cache results + await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS); + + return output; + } + + private async validatePorts(originCode: string, destinationCode: string): Promise { + const [origin, destination] = await Promise.all([ + this.portRepository.findByCode(originCode), + this.portRepository.findByCode(destinationCode), + ]); + + if (!origin) { + throw new PortNotFoundException(originCode); + } + + if (!destination) { + throw new PortNotFoundException(destinationCode); + } + } + + private generateCacheKey(input: RateSearchInput): string { + const parts = [ + 'rate-search', + input.origin, + input.destination, + input.containerType, + input.mode, + input.departureDate.toISOString().split('T')[0], + input.quantity || 1, + input.isHazmat ? 'hazmat' : 'standard', + ]; + + return parts.join(':'); + } + + private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] { + if (!carrierPreferences || carrierPreferences.length === 0) { + return this.carrierConnectors; + } + + return this.carrierConnectors.filter(connector => + carrierPreferences.includes(connector.getCarrierCode()) + ); + } + + private async queryCarrier( + connector: CarrierConnectorPort, + input: RateSearchInput + ): Promise { + return connector.searchRates({ + origin: input.origin, + destination: input.destination, + containerType: input.containerType, + mode: input.mode, + departureDate: input.departureDate, + quantity: input.quantity, + weight: input.weight, + volume: input.volume, + isHazmat: input.isHazmat, + imoClass: input.imoClass, + }); + } +} diff --git a/apps/backend/src/domain/value-objects/booking-number.vo.ts b/apps/backend/src/domain/value-objects/booking-number.vo.ts index e756018..0a36c0c 100644 --- a/apps/backend/src/domain/value-objects/booking-number.vo.ts +++ b/apps/backend/src/domain/value-objects/booking-number.vo.ts @@ -1,77 +1,77 @@ -/** - * BookingNumber Value Object - * - * Represents a unique booking reference number - * Format: WCM-YYYY-XXXXXX (e.g., WCM-2025-ABC123) - * - WCM: WebCargo Maritime prefix - * - YYYY: Current year - * - XXXXXX: 6 alphanumeric characters - */ - -import { InvalidBookingNumberException } from '../exceptions/invalid-booking-number.exception'; - -export class BookingNumber { - private readonly _value: string; - - private constructor(value: string) { - this._value = value; - } - - get value(): string { - return this._value; - } - - /** - * Generate a new booking number - */ - static generate(): BookingNumber { - const year = new Date().getFullYear(); - const random = BookingNumber.generateRandomString(6); - const value = `WCM-${year}-${random}`; - return new BookingNumber(value); - } - - /** - * Create BookingNumber from string - */ - static fromString(value: string): BookingNumber { - if (!BookingNumber.isValid(value)) { - throw new InvalidBookingNumberException(value); - } - return new BookingNumber(value); - } - - /** - * Validate booking number format - */ - static isValid(value: string): boolean { - const pattern = /^WCM-\d{4}-[A-Z0-9]{6}$/; - return pattern.test(value); - } - - /** - * Generate random alphanumeric string - */ - private static generateRandomString(length: number): string { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous chars: 0,O,1,I - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; - } - - /** - * Equality check - */ - equals(other: BookingNumber): boolean { - return this._value === other._value; - } - - /** - * String representation - */ - toString(): string { - return this._value; - } -} +/** + * BookingNumber Value Object + * + * Represents a unique booking reference number + * Format: WCM-YYYY-XXXXXX (e.g., WCM-2025-ABC123) + * - WCM: WebCargo Maritime prefix + * - YYYY: Current year + * - XXXXXX: 6 alphanumeric characters + */ + +import { InvalidBookingNumberException } from '../exceptions/invalid-booking-number.exception'; + +export class BookingNumber { + private readonly _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + /** + * Generate a new booking number + */ + static generate(): BookingNumber { + const year = new Date().getFullYear(); + const random = BookingNumber.generateRandomString(6); + const value = `WCM-${year}-${random}`; + return new BookingNumber(value); + } + + /** + * Create BookingNumber from string + */ + static fromString(value: string): BookingNumber { + if (!BookingNumber.isValid(value)) { + throw new InvalidBookingNumberException(value); + } + return new BookingNumber(value); + } + + /** + * Validate booking number format + */ + static isValid(value: string): boolean { + const pattern = /^WCM-\d{4}-[A-Z0-9]{6}$/; + return pattern.test(value); + } + + /** + * Generate random alphanumeric string + */ + private static generateRandomString(length: number): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude ambiguous chars: 0,O,1,I + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + /** + * Equality check + */ + equals(other: BookingNumber): boolean { + return this._value === other._value; + } + + /** + * String representation + */ + toString(): string { + return this._value; + } +} diff --git a/apps/backend/src/domain/value-objects/booking-status.vo.ts b/apps/backend/src/domain/value-objects/booking-status.vo.ts index 5ff5fb4..72b7241 100644 --- a/apps/backend/src/domain/value-objects/booking-status.vo.ts +++ b/apps/backend/src/domain/value-objects/booking-status.vo.ts @@ -1,110 +1,108 @@ -/** - * BookingStatus Value Object - * - * Represents the current status of a booking - */ - -import { InvalidBookingStatusException } from '../exceptions/invalid-booking-status.exception'; - -export type BookingStatusValue = - | 'draft' - | 'pending_confirmation' - | 'confirmed' - | 'in_transit' - | 'delivered' - | 'cancelled'; - -export class BookingStatus { - private static readonly VALID_STATUSES: BookingStatusValue[] = [ - 'draft', - 'pending_confirmation', - 'confirmed', - 'in_transit', - 'delivered', - 'cancelled', - ]; - - private static readonly STATUS_TRANSITIONS: Record = { - draft: ['pending_confirmation', 'cancelled'], - pending_confirmation: ['confirmed', 'cancelled'], - confirmed: ['in_transit', 'cancelled'], - in_transit: ['delivered', 'cancelled'], - delivered: [], - cancelled: [], - }; - - private readonly _value: BookingStatusValue; - - private constructor(value: BookingStatusValue) { - this._value = value; - } - - get value(): BookingStatusValue { - return this._value; - } - - /** - * Create BookingStatus from string - */ - static create(value: string): BookingStatus { - if (!BookingStatus.isValid(value)) { - throw new InvalidBookingStatusException(value); - } - return new BookingStatus(value as BookingStatusValue); - } - - /** - * Validate status value - */ - static isValid(value: string): boolean { - return BookingStatus.VALID_STATUSES.includes(value as BookingStatusValue); - } - - /** - * Check if transition to another status is allowed - */ - canTransitionTo(newStatus: BookingStatus): boolean { - const allowedTransitions = BookingStatus.STATUS_TRANSITIONS[this._value]; - return allowedTransitions.includes(newStatus._value); - } - - /** - * Transition to new status - */ - transitionTo(newStatus: BookingStatus): BookingStatus { - if (!this.canTransitionTo(newStatus)) { - throw new Error( - `Invalid status transition from ${this._value} to ${newStatus._value}` - ); - } - return newStatus; - } - - /** - * Check if booking is in a final state - */ - isFinal(): boolean { - return this._value === 'delivered' || this._value === 'cancelled'; - } - - /** - * Check if booking can be modified - */ - canBeModified(): boolean { - return this._value === 'draft' || this._value === 'pending_confirmation'; - } - - /** - * Equality check - */ - equals(other: BookingStatus): boolean { - return this._value === other._value; - } - - /** - * String representation - */ - toString(): string { - return this._value; - } -} +/** + * BookingStatus Value Object + * + * Represents the current status of a booking + */ + +import { InvalidBookingStatusException } from '../exceptions/invalid-booking-status.exception'; + +export type BookingStatusValue = + | 'draft' + | 'pending_confirmation' + | 'confirmed' + | 'in_transit' + | 'delivered' + | 'cancelled'; + +export class BookingStatus { + private static readonly VALID_STATUSES: BookingStatusValue[] = [ + 'draft', + 'pending_confirmation', + 'confirmed', + 'in_transit', + 'delivered', + 'cancelled', + ]; + + private static readonly STATUS_TRANSITIONS: Record = { + draft: ['pending_confirmation', 'cancelled'], + pending_confirmation: ['confirmed', 'cancelled'], + confirmed: ['in_transit', 'cancelled'], + in_transit: ['delivered', 'cancelled'], + delivered: [], + cancelled: [], + }; + + private readonly _value: BookingStatusValue; + + private constructor(value: BookingStatusValue) { + this._value = value; + } + + get value(): BookingStatusValue { + return this._value; + } + + /** + * Create BookingStatus from string + */ + static create(value: string): BookingStatus { + if (!BookingStatus.isValid(value)) { + throw new InvalidBookingStatusException(value); + } + return new BookingStatus(value as BookingStatusValue); + } + + /** + * Validate status value + */ + static isValid(value: string): boolean { + return BookingStatus.VALID_STATUSES.includes(value as BookingStatusValue); + } + + /** + * Check if transition to another status is allowed + */ + canTransitionTo(newStatus: BookingStatus): boolean { + const allowedTransitions = BookingStatus.STATUS_TRANSITIONS[this._value]; + return allowedTransitions.includes(newStatus._value); + } + + /** + * Transition to new status + */ + transitionTo(newStatus: BookingStatus): BookingStatus { + if (!this.canTransitionTo(newStatus)) { + throw new Error(`Invalid status transition from ${this._value} to ${newStatus._value}`); + } + return newStatus; + } + + /** + * Check if booking is in a final state + */ + isFinal(): boolean { + return this._value === 'delivered' || this._value === 'cancelled'; + } + + /** + * Check if booking can be modified + */ + canBeModified(): boolean { + return this._value === 'draft' || this._value === 'pending_confirmation'; + } + + /** + * Equality check + */ + equals(other: BookingStatus): boolean { + return this._value === other._value; + } + + /** + * String representation + */ + toString(): string { + return this._value; + } +} diff --git a/apps/backend/src/domain/value-objects/container-type.vo.ts b/apps/backend/src/domain/value-objects/container-type.vo.ts index 4886db3..8b1cce6 100644 --- a/apps/backend/src/domain/value-objects/container-type.vo.ts +++ b/apps/backend/src/domain/value-objects/container-type.vo.ts @@ -1,112 +1,112 @@ -/** - * ContainerType Value Object - * - * Encapsulates container type validation and behavior - * - * Business Rules: - * - Container type must be valid (e.g., 20DRY, 40HC, 40REEFER) - * - Container type is immutable - * - * Format: {SIZE}{HEIGHT_MODIFIER?}{CATEGORY} - * Examples: 20DRY, 40HC, 40REEFER, 45HCREEFER - */ - -export class ContainerType { - private readonly value: string; - - // Valid container types - private static readonly VALID_TYPES = [ - 'LCL', // Less than Container Load - '20DRY', - '40DRY', - '20HC', - '40HC', - '45HC', - '20REEFER', - '40REEFER', - '40HCREEFER', - '45HCREEFER', - '20OT', // Open Top - '40OT', - '20FR', // Flat Rack - '40FR', - '20TANK', - '40TANK', - ]; - - private constructor(type: string) { - this.value = type; - } - - static create(type: string): ContainerType { - if (!type || type.trim().length === 0) { - throw new Error('Container type cannot be empty.'); - } - - const normalized = type.trim().toUpperCase(); - - if (!ContainerType.isValid(normalized)) { - throw new Error( - `Invalid container type: ${type}. Valid types: ${ContainerType.VALID_TYPES.join(', ')}` - ); - } - - return new ContainerType(normalized); - } - - private static isValid(type: string): boolean { - return ContainerType.VALID_TYPES.includes(type); - } - - getValue(): string { - return this.value; - } - - getSize(): string { - // Extract size (first 2 digits) - return this.value.match(/^\d+/)?.[0] || ''; - } - - getTEU(): number { - const size = this.getSize(); - if (size === '20') return 1; - if (size === '40' || size === '45') return 2; - return 0; - } - - isDry(): boolean { - return this.value.includes('DRY'); - } - - isReefer(): boolean { - return this.value.includes('REEFER'); - } - - isHighCube(): boolean { - return this.value.includes('HC'); - } - - isOpenTop(): boolean { - return this.value.includes('OT'); - } - - isFlatRack(): boolean { - return this.value.includes('FR'); - } - - isTank(): boolean { - return this.value.includes('TANK'); - } - - isLCL(): boolean { - return this.value === 'LCL'; - } - - equals(other: ContainerType): boolean { - return this.value === other.value; - } - - toString(): string { - return this.value; - } -} +/** + * ContainerType Value Object + * + * Encapsulates container type validation and behavior + * + * Business Rules: + * - Container type must be valid (e.g., 20DRY, 40HC, 40REEFER) + * - Container type is immutable + * + * Format: {SIZE}{HEIGHT_MODIFIER?}{CATEGORY} + * Examples: 20DRY, 40HC, 40REEFER, 45HCREEFER + */ + +export class ContainerType { + private readonly value: string; + + // Valid container types + private static readonly VALID_TYPES = [ + 'LCL', // Less than Container Load + '20DRY', + '40DRY', + '20HC', + '40HC', + '45HC', + '20REEFER', + '40REEFER', + '40HCREEFER', + '45HCREEFER', + '20OT', // Open Top + '40OT', + '20FR', // Flat Rack + '40FR', + '20TANK', + '40TANK', + ]; + + private constructor(type: string) { + this.value = type; + } + + static create(type: string): ContainerType { + if (!type || type.trim().length === 0) { + throw new Error('Container type cannot be empty.'); + } + + const normalized = type.trim().toUpperCase(); + + if (!ContainerType.isValid(normalized)) { + throw new Error( + `Invalid container type: ${type}. Valid types: ${ContainerType.VALID_TYPES.join(', ')}` + ); + } + + return new ContainerType(normalized); + } + + private static isValid(type: string): boolean { + return ContainerType.VALID_TYPES.includes(type); + } + + getValue(): string { + return this.value; + } + + getSize(): string { + // Extract size (first 2 digits) + return this.value.match(/^\d+/)?.[0] || ''; + } + + getTEU(): number { + const size = this.getSize(); + if (size === '20') return 1; + if (size === '40' || size === '45') return 2; + return 0; + } + + isDry(): boolean { + return this.value.includes('DRY'); + } + + isReefer(): boolean { + return this.value.includes('REEFER'); + } + + isHighCube(): boolean { + return this.value.includes('HC'); + } + + isOpenTop(): boolean { + return this.value.includes('OT'); + } + + isFlatRack(): boolean { + return this.value.includes('FR'); + } + + isTank(): boolean { + return this.value.includes('TANK'); + } + + isLCL(): boolean { + return this.value === 'LCL'; + } + + equals(other: ContainerType): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/apps/backend/src/domain/value-objects/date-range.vo.ts b/apps/backend/src/domain/value-objects/date-range.vo.ts index 92a81ee..0221cb0 100644 --- a/apps/backend/src/domain/value-objects/date-range.vo.ts +++ b/apps/backend/src/domain/value-objects/date-range.vo.ts @@ -1,120 +1,118 @@ -/** - * DateRange Value Object - * - * Encapsulates ETD/ETA date range with validation - * - * Business Rules: - * - End date must be after start date - * - Dates cannot be in the past (for new shipments) - * - Date range is immutable - */ - -export class DateRange { - private readonly startDate: Date; - private readonly endDate: Date; - - private constructor(startDate: Date, endDate: Date) { - this.startDate = startDate; - this.endDate = endDate; - } - - static create(startDate: Date, endDate: Date, allowPastDates = false): DateRange { - if (!startDate || !endDate) { - throw new Error('Start date and end date are required.'); - } - - if (endDate <= startDate) { - throw new Error('End date must be after start date.'); - } - - if (!allowPastDates) { - const now = new Date(); - now.setHours(0, 0, 0, 0); // Reset time to start of day - - if (startDate < now) { - throw new Error('Start date cannot be in the past.'); - } - } - - return new DateRange(new Date(startDate), new Date(endDate)); - } - - /** - * Create from ETD and transit days - */ - static fromTransitDays(etd: Date, transitDays: number): DateRange { - if (transitDays <= 0) { - throw new Error('Transit days must be positive.'); - } - - const eta = new Date(etd); - eta.setDate(eta.getDate() + transitDays); - - return DateRange.create(etd, eta, true); - } - - getStartDate(): Date { - return new Date(this.startDate); - } - - getEndDate(): Date { - return new Date(this.endDate); - } - - getDurationInDays(): number { - const diffTime = this.endDate.getTime() - this.startDate.getTime(); - return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } - - getDurationInHours(): number { - const diffTime = this.endDate.getTime() - this.startDate.getTime(); - return Math.ceil(diffTime / (1000 * 60 * 60)); - } - - contains(date: Date): boolean { - return date >= this.startDate && date <= this.endDate; - } - - overlaps(other: DateRange): boolean { - return ( - this.startDate <= other.endDate && this.endDate >= other.startDate - ); - } - - isFutureRange(): boolean { - const now = new Date(); - return this.startDate > now; - } - - isPastRange(): boolean { - const now = new Date(); - return this.endDate < now; - } - - isCurrentRange(): boolean { - const now = new Date(); - return this.contains(now); - } - - equals(other: DateRange): boolean { - return ( - this.startDate.getTime() === other.startDate.getTime() && - this.endDate.getTime() === other.endDate.getTime() - ); - } - - toString(): string { - return `${this.formatDate(this.startDate)} - ${this.formatDate(this.endDate)}`; - } - - private formatDate(date: Date): string { - return date.toISOString().split('T')[0]; - } - - toObject(): { startDate: Date; endDate: Date } { - return { - startDate: new Date(this.startDate), - endDate: new Date(this.endDate), - }; - } -} +/** + * DateRange Value Object + * + * Encapsulates ETD/ETA date range with validation + * + * Business Rules: + * - End date must be after start date + * - Dates cannot be in the past (for new shipments) + * - Date range is immutable + */ + +export class DateRange { + private readonly startDate: Date; + private readonly endDate: Date; + + private constructor(startDate: Date, endDate: Date) { + this.startDate = startDate; + this.endDate = endDate; + } + + static create(startDate: Date, endDate: Date, allowPastDates = false): DateRange { + if (!startDate || !endDate) { + throw new Error('Start date and end date are required.'); + } + + if (endDate <= startDate) { + throw new Error('End date must be after start date.'); + } + + if (!allowPastDates) { + const now = new Date(); + now.setHours(0, 0, 0, 0); // Reset time to start of day + + if (startDate < now) { + throw new Error('Start date cannot be in the past.'); + } + } + + return new DateRange(new Date(startDate), new Date(endDate)); + } + + /** + * Create from ETD and transit days + */ + static fromTransitDays(etd: Date, transitDays: number): DateRange { + if (transitDays <= 0) { + throw new Error('Transit days must be positive.'); + } + + const eta = new Date(etd); + eta.setDate(eta.getDate() + transitDays); + + return DateRange.create(etd, eta, true); + } + + getStartDate(): Date { + return new Date(this.startDate); + } + + getEndDate(): Date { + return new Date(this.endDate); + } + + getDurationInDays(): number { + const diffTime = this.endDate.getTime() - this.startDate.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } + + getDurationInHours(): number { + const diffTime = this.endDate.getTime() - this.startDate.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60)); + } + + contains(date: Date): boolean { + return date >= this.startDate && date <= this.endDate; + } + + overlaps(other: DateRange): boolean { + return this.startDate <= other.endDate && this.endDate >= other.startDate; + } + + isFutureRange(): boolean { + const now = new Date(); + return this.startDate > now; + } + + isPastRange(): boolean { + const now = new Date(); + return this.endDate < now; + } + + isCurrentRange(): boolean { + const now = new Date(); + return this.contains(now); + } + + equals(other: DateRange): boolean { + return ( + this.startDate.getTime() === other.startDate.getTime() && + this.endDate.getTime() === other.endDate.getTime() + ); + } + + toString(): string { + return `${this.formatDate(this.startDate)} - ${this.formatDate(this.endDate)}`; + } + + private formatDate(date: Date): string { + return date.toISOString().split('T')[0]; + } + + toObject(): { startDate: Date; endDate: Date } { + return { + startDate: new Date(this.startDate), + endDate: new Date(this.endDate), + }; + } +} diff --git a/apps/backend/src/domain/value-objects/email.vo.spec.ts b/apps/backend/src/domain/value-objects/email.vo.spec.ts index 19e9861..7bd6e78 100644 --- a/apps/backend/src/domain/value-objects/email.vo.spec.ts +++ b/apps/backend/src/domain/value-objects/email.vo.spec.ts @@ -1,70 +1,70 @@ -/** - * Email Value Object Unit Tests - */ - -import { Email } from './email.vo'; - -describe('Email Value Object', () => { - describe('create', () => { - it('should create email with valid format', () => { - const email = Email.create('user@example.com'); - expect(email.getValue()).toBe('user@example.com'); - }); - - it('should normalize email to lowercase', () => { - const email = Email.create('User@Example.COM'); - expect(email.getValue()).toBe('user@example.com'); - }); - - it('should trim whitespace', () => { - const email = Email.create(' user@example.com '); - expect(email.getValue()).toBe('user@example.com'); - }); - - it('should throw error for empty email', () => { - expect(() => Email.create('')).toThrow('Email cannot be empty.'); - }); - - it('should throw error for invalid format', () => { - expect(() => Email.create('invalid-email')).toThrow('Invalid email format'); - expect(() => Email.create('@example.com')).toThrow('Invalid email format'); - expect(() => Email.create('user@')).toThrow('Invalid email format'); - expect(() => Email.create('user@.com')).toThrow('Invalid email format'); - }); - }); - - describe('getDomain', () => { - it('should return email domain', () => { - const email = Email.create('user@example.com'); - expect(email.getDomain()).toBe('example.com'); - }); - }); - - describe('getLocalPart', () => { - it('should return email local part', () => { - const email = Email.create('user@example.com'); - expect(email.getLocalPart()).toBe('user'); - }); - }); - - describe('equals', () => { - it('should return true for same email', () => { - const email1 = Email.create('user@example.com'); - const email2 = Email.create('user@example.com'); - expect(email1.equals(email2)).toBe(true); - }); - - it('should return false for different emails', () => { - const email1 = Email.create('user1@example.com'); - const email2 = Email.create('user2@example.com'); - expect(email1.equals(email2)).toBe(false); - }); - }); - - describe('toString', () => { - it('should return email as string', () => { - const email = Email.create('user@example.com'); - expect(email.toString()).toBe('user@example.com'); - }); - }); -}); +/** + * Email Value Object Unit Tests + */ + +import { Email } from './email.vo'; + +describe('Email Value Object', () => { + describe('create', () => { + it('should create email with valid format', () => { + const email = Email.create('user@example.com'); + expect(email.getValue()).toBe('user@example.com'); + }); + + it('should normalize email to lowercase', () => { + const email = Email.create('User@Example.COM'); + expect(email.getValue()).toBe('user@example.com'); + }); + + it('should trim whitespace', () => { + const email = Email.create(' user@example.com '); + expect(email.getValue()).toBe('user@example.com'); + }); + + it('should throw error for empty email', () => { + expect(() => Email.create('')).toThrow('Email cannot be empty.'); + }); + + it('should throw error for invalid format', () => { + expect(() => Email.create('invalid-email')).toThrow('Invalid email format'); + expect(() => Email.create('@example.com')).toThrow('Invalid email format'); + expect(() => Email.create('user@')).toThrow('Invalid email format'); + expect(() => Email.create('user@.com')).toThrow('Invalid email format'); + }); + }); + + describe('getDomain', () => { + it('should return email domain', () => { + const email = Email.create('user@example.com'); + expect(email.getDomain()).toBe('example.com'); + }); + }); + + describe('getLocalPart', () => { + it('should return email local part', () => { + const email = Email.create('user@example.com'); + expect(email.getLocalPart()).toBe('user'); + }); + }); + + describe('equals', () => { + it('should return true for same email', () => { + const email1 = Email.create('user@example.com'); + const email2 = Email.create('user@example.com'); + expect(email1.equals(email2)).toBe(true); + }); + + it('should return false for different emails', () => { + const email1 = Email.create('user1@example.com'); + const email2 = Email.create('user2@example.com'); + expect(email1.equals(email2)).toBe(false); + }); + }); + + describe('toString', () => { + it('should return email as string', () => { + const email = Email.create('user@example.com'); + expect(email.toString()).toBe('user@example.com'); + }); + }); +}); diff --git a/apps/backend/src/domain/value-objects/email.vo.ts b/apps/backend/src/domain/value-objects/email.vo.ts index c081a4e..8214aed 100644 --- a/apps/backend/src/domain/value-objects/email.vo.ts +++ b/apps/backend/src/domain/value-objects/email.vo.ts @@ -1,60 +1,60 @@ -/** - * Email Value Object - * - * Encapsulates email address validation and behavior - * - * Business Rules: - * - Email must be valid format - * - Email is case-insensitive (stored lowercase) - * - Email is immutable - */ - -export class Email { - private readonly value: string; - - private constructor(email: string) { - this.value = email; - } - - static create(email: string): Email { - if (!email || email.trim().length === 0) { - throw new Error('Email cannot be empty.'); - } - - const normalized = email.trim().toLowerCase(); - - if (!Email.isValid(normalized)) { - throw new Error(`Invalid email format: ${email}`); - } - - return new Email(normalized); - } - - private static isValid(email: string): boolean { - // RFC 5322 simplified email regex - const emailPattern = - /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; - - return emailPattern.test(email); - } - - getValue(): string { - return this.value; - } - - getDomain(): string { - return this.value.split('@')[1]; - } - - getLocalPart(): string { - return this.value.split('@')[0]; - } - - equals(other: Email): boolean { - return this.value === other.value; - } - - toString(): string { - return this.value; - } -} +/** + * Email Value Object + * + * Encapsulates email address validation and behavior + * + * Business Rules: + * - Email must be valid format + * - Email is case-insensitive (stored lowercase) + * - Email is immutable + */ + +export class Email { + private readonly value: string; + + private constructor(email: string) { + this.value = email; + } + + static create(email: string): Email { + if (!email || email.trim().length === 0) { + throw new Error('Email cannot be empty.'); + } + + const normalized = email.trim().toLowerCase(); + + if (!Email.isValid(normalized)) { + throw new Error(`Invalid email format: ${email}`); + } + + return new Email(normalized); + } + + private static isValid(email: string): boolean { + // RFC 5322 simplified email regex + const emailPattern = + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + + return emailPattern.test(email); + } + + getValue(): string { + return this.value; + } + + getDomain(): string { + return this.value.split('@')[1]; + } + + getLocalPart(): string { + return this.value.split('@')[0]; + } + + equals(other: Email): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/apps/backend/src/domain/value-objects/index.ts b/apps/backend/src/domain/value-objects/index.ts index fe77d6a..13d1f43 100644 --- a/apps/backend/src/domain/value-objects/index.ts +++ b/apps/backend/src/domain/value-objects/index.ts @@ -1,13 +1,13 @@ -/** - * Domain Value Objects Barrel Export - * - * All value objects for the Xpeditis platform - */ - -export * from './email.vo'; -export * from './port-code.vo'; -export * from './money.vo'; -export * from './container-type.vo'; -export * from './date-range.vo'; -export * from './booking-number.vo'; -export * from './booking-status.vo'; +/** + * Domain Value Objects Barrel Export + * + * All value objects for the Xpeditis platform + */ + +export * from './email.vo'; +export * from './port-code.vo'; +export * from './money.vo'; +export * from './container-type.vo'; +export * from './date-range.vo'; +export * from './booking-number.vo'; +export * from './booking-status.vo'; diff --git a/apps/backend/src/domain/value-objects/money.vo.spec.ts b/apps/backend/src/domain/value-objects/money.vo.spec.ts index 87941cb..be097f8 100644 --- a/apps/backend/src/domain/value-objects/money.vo.spec.ts +++ b/apps/backend/src/domain/value-objects/money.vo.spec.ts @@ -1,133 +1,133 @@ -/** - * Money Value Object Unit Tests - */ - -import { Money } from './money.vo'; - -describe('Money Value Object', () => { - describe('create', () => { - it('should create money with valid amount and currency', () => { - const money = Money.create(100, 'USD'); - expect(money.getAmount()).toBe(100); - expect(money.getCurrency()).toBe('USD'); - }); - - it('should round to 2 decimal places', () => { - const money = Money.create(100.999, 'USD'); - expect(money.getAmount()).toBe(101); - }); - - it('should throw error for negative amount', () => { - expect(() => Money.create(-100, 'USD')).toThrow('Amount cannot be negative'); - }); - - it('should throw error for invalid currency', () => { - expect(() => Money.create(100, 'XXX')).toThrow('Invalid currency code'); - }); - - it('should normalize currency to uppercase', () => { - const money = Money.create(100, 'usd'); - expect(money.getCurrency()).toBe('USD'); - }); - }); - - describe('zero', () => { - it('should create zero amount', () => { - const money = Money.zero('USD'); - expect(money.getAmount()).toBe(0); - expect(money.isZero()).toBe(true); - }); - }); - - describe('add', () => { - it('should add two money amounts', () => { - const money1 = Money.create(100, 'USD'); - const money2 = Money.create(50, 'USD'); - const result = money1.add(money2); - expect(result.getAmount()).toBe(150); - }); - - it('should throw error for currency mismatch', () => { - const money1 = Money.create(100, 'USD'); - const money2 = Money.create(50, 'EUR'); - expect(() => money1.add(money2)).toThrow('Currency mismatch'); - }); - }); - - describe('subtract', () => { - it('should subtract two money amounts', () => { - const money1 = Money.create(100, 'USD'); - const money2 = Money.create(30, 'USD'); - const result = money1.subtract(money2); - expect(result.getAmount()).toBe(70); - }); - - it('should throw error for negative result', () => { - const money1 = Money.create(50, 'USD'); - const money2 = Money.create(100, 'USD'); - expect(() => money1.subtract(money2)).toThrow('negative amount'); - }); - }); - - describe('multiply', () => { - it('should multiply money amount', () => { - const money = Money.create(100, 'USD'); - const result = money.multiply(2); - expect(result.getAmount()).toBe(200); - }); - - it('should throw error for negative multiplier', () => { - const money = Money.create(100, 'USD'); - expect(() => money.multiply(-2)).toThrow('Multiplier cannot be negative'); - }); - }); - - describe('divide', () => { - it('should divide money amount', () => { - const money = Money.create(100, 'USD'); - const result = money.divide(2); - expect(result.getAmount()).toBe(50); - }); - - it('should throw error for zero divisor', () => { - const money = Money.create(100, 'USD'); - expect(() => money.divide(0)).toThrow('Divisor must be positive'); - }); - }); - - describe('comparisons', () => { - it('should compare greater than', () => { - const money1 = Money.create(100, 'USD'); - const money2 = Money.create(50, 'USD'); - expect(money1.isGreaterThan(money2)).toBe(true); - expect(money2.isGreaterThan(money1)).toBe(false); - }); - - it('should compare less than', () => { - const money1 = Money.create(50, 'USD'); - const money2 = Money.create(100, 'USD'); - expect(money1.isLessThan(money2)).toBe(true); - expect(money2.isLessThan(money1)).toBe(false); - }); - - it('should compare equality', () => { - const money1 = Money.create(100, 'USD'); - const money2 = Money.create(100, 'USD'); - const money3 = Money.create(50, 'USD'); - expect(money1.isEqualTo(money2)).toBe(true); - expect(money1.isEqualTo(money3)).toBe(false); - }); - }); - - describe('format', () => { - it('should format USD with $ symbol', () => { - const money = Money.create(100.5, 'USD'); - expect(money.format()).toBe('$100.50'); - }); - - it('should format EUR with € symbol', () => { - const money = Money.create(100.5, 'EUR'); - expect(money.format()).toBe('€100.50'); - }); - }); -}); +/** + * Money Value Object Unit Tests + */ + +import { Money } from './money.vo'; + +describe('Money Value Object', () => { + describe('create', () => { + it('should create money with valid amount and currency', () => { + const money = Money.create(100, 'USD'); + expect(money.getAmount()).toBe(100); + expect(money.getCurrency()).toBe('USD'); + }); + + it('should round to 2 decimal places', () => { + const money = Money.create(100.999, 'USD'); + expect(money.getAmount()).toBe(101); + }); + + it('should throw error for negative amount', () => { + expect(() => Money.create(-100, 'USD')).toThrow('Amount cannot be negative'); + }); + + it('should throw error for invalid currency', () => { + expect(() => Money.create(100, 'XXX')).toThrow('Invalid currency code'); + }); + + it('should normalize currency to uppercase', () => { + const money = Money.create(100, 'usd'); + expect(money.getCurrency()).toBe('USD'); + }); + }); + + describe('zero', () => { + it('should create zero amount', () => { + const money = Money.zero('USD'); + expect(money.getAmount()).toBe(0); + expect(money.isZero()).toBe(true); + }); + }); + + describe('add', () => { + it('should add two money amounts', () => { + const money1 = Money.create(100, 'USD'); + const money2 = Money.create(50, 'USD'); + const result = money1.add(money2); + expect(result.getAmount()).toBe(150); + }); + + it('should throw error for currency mismatch', () => { + const money1 = Money.create(100, 'USD'); + const money2 = Money.create(50, 'EUR'); + expect(() => money1.add(money2)).toThrow('Currency mismatch'); + }); + }); + + describe('subtract', () => { + it('should subtract two money amounts', () => { + const money1 = Money.create(100, 'USD'); + const money2 = Money.create(30, 'USD'); + const result = money1.subtract(money2); + expect(result.getAmount()).toBe(70); + }); + + it('should throw error for negative result', () => { + const money1 = Money.create(50, 'USD'); + const money2 = Money.create(100, 'USD'); + expect(() => money1.subtract(money2)).toThrow('negative amount'); + }); + }); + + describe('multiply', () => { + it('should multiply money amount', () => { + const money = Money.create(100, 'USD'); + const result = money.multiply(2); + expect(result.getAmount()).toBe(200); + }); + + it('should throw error for negative multiplier', () => { + const money = Money.create(100, 'USD'); + expect(() => money.multiply(-2)).toThrow('Multiplier cannot be negative'); + }); + }); + + describe('divide', () => { + it('should divide money amount', () => { + const money = Money.create(100, 'USD'); + const result = money.divide(2); + expect(result.getAmount()).toBe(50); + }); + + it('should throw error for zero divisor', () => { + const money = Money.create(100, 'USD'); + expect(() => money.divide(0)).toThrow('Divisor must be positive'); + }); + }); + + describe('comparisons', () => { + it('should compare greater than', () => { + const money1 = Money.create(100, 'USD'); + const money2 = Money.create(50, 'USD'); + expect(money1.isGreaterThan(money2)).toBe(true); + expect(money2.isGreaterThan(money1)).toBe(false); + }); + + it('should compare less than', () => { + const money1 = Money.create(50, 'USD'); + const money2 = Money.create(100, 'USD'); + expect(money1.isLessThan(money2)).toBe(true); + expect(money2.isLessThan(money1)).toBe(false); + }); + + it('should compare equality', () => { + const money1 = Money.create(100, 'USD'); + const money2 = Money.create(100, 'USD'); + const money3 = Money.create(50, 'USD'); + expect(money1.isEqualTo(money2)).toBe(true); + expect(money1.isEqualTo(money3)).toBe(false); + }); + }); + + describe('format', () => { + it('should format USD with $ symbol', () => { + const money = Money.create(100.5, 'USD'); + expect(money.format()).toBe('$100.50'); + }); + + it('should format EUR with € symbol', () => { + const money = Money.create(100.5, 'EUR'); + expect(money.format()).toBe('€100.50'); + }); + }); +}); diff --git a/apps/backend/src/domain/value-objects/money.vo.ts b/apps/backend/src/domain/value-objects/money.vo.ts index 949aee0..8db01f1 100644 --- a/apps/backend/src/domain/value-objects/money.vo.ts +++ b/apps/backend/src/domain/value-objects/money.vo.ts @@ -1,137 +1,137 @@ -/** - * Money Value Object - * - * Encapsulates currency and amount with proper validation - * - * Business Rules: - * - Amount must be non-negative - * - Currency must be valid ISO 4217 code - * - Money is immutable - * - Arithmetic operations return new Money instances - */ - -export class Money { - private readonly amount: number; - private readonly currency: string; - - private static readonly SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'CNY', 'JPY']; - - private constructor(amount: number, currency: string) { - this.amount = amount; - this.currency = currency; - } - - static create(amount: number, currency: string): Money { - if (amount < 0) { - throw new Error('Amount cannot be negative.'); - } - - const normalizedCurrency = currency.trim().toUpperCase(); - - if (!Money.isValidCurrency(normalizedCurrency)) { - throw new Error( - `Invalid currency code: ${currency}. Supported currencies: ${Money.SUPPORTED_CURRENCIES.join(', ')}` - ); - } - - // Round to 2 decimal places to avoid floating point issues - const roundedAmount = Math.round(amount * 100) / 100; - - return new Money(roundedAmount, normalizedCurrency); - } - - static zero(currency: string): Money { - return Money.create(0, currency); - } - - private static isValidCurrency(currency: string): boolean { - return Money.SUPPORTED_CURRENCIES.includes(currency); - } - - getAmount(): number { - return this.amount; - } - - getCurrency(): string { - return this.currency; - } - - add(other: Money): Money { - this.ensureSameCurrency(other); - return Money.create(this.amount + other.amount, this.currency); - } - - subtract(other: Money): Money { - this.ensureSameCurrency(other); - const result = this.amount - other.amount; - if (result < 0) { - throw new Error('Subtraction would result in negative amount.'); - } - return Money.create(result, this.currency); - } - - multiply(multiplier: number): Money { - if (multiplier < 0) { - throw new Error('Multiplier cannot be negative.'); - } - return Money.create(this.amount * multiplier, this.currency); - } - - divide(divisor: number): Money { - if (divisor <= 0) { - throw new Error('Divisor must be positive.'); - } - return Money.create(this.amount / divisor, this.currency); - } - - isGreaterThan(other: Money): boolean { - this.ensureSameCurrency(other); - return this.amount > other.amount; - } - - isLessThan(other: Money): boolean { - this.ensureSameCurrency(other); - return this.amount < other.amount; - } - - isEqualTo(other: Money): boolean { - return this.currency === other.currency && this.amount === other.amount; - } - - isZero(): boolean { - return this.amount === 0; - } - - private ensureSameCurrency(other: Money): void { - if (this.currency !== other.currency) { - throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`); - } - } - - /** - * Format as string with currency symbol - */ - format(): string { - const symbols: { [key: string]: string } = { - USD: '$', - EUR: '€', - GBP: '£', - CNY: '¥', - JPY: '¥', - }; - - const symbol = symbols[this.currency] || this.currency; - return `${symbol}${this.amount.toFixed(2)}`; - } - - toString(): string { - return this.format(); - } - - toObject(): { amount: number; currency: string } { - return { - amount: this.amount, - currency: this.currency, - }; - } -} +/** + * Money Value Object + * + * Encapsulates currency and amount with proper validation + * + * Business Rules: + * - Amount must be non-negative + * - Currency must be valid ISO 4217 code + * - Money is immutable + * - Arithmetic operations return new Money instances + */ + +export class Money { + private readonly amount: number; + private readonly currency: string; + + private static readonly SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'CNY', 'JPY']; + + private constructor(amount: number, currency: string) { + this.amount = amount; + this.currency = currency; + } + + static create(amount: number, currency: string): Money { + if (amount < 0) { + throw new Error('Amount cannot be negative.'); + } + + const normalizedCurrency = currency.trim().toUpperCase(); + + if (!Money.isValidCurrency(normalizedCurrency)) { + throw new Error( + `Invalid currency code: ${currency}. Supported currencies: ${Money.SUPPORTED_CURRENCIES.join(', ')}` + ); + } + + // Round to 2 decimal places to avoid floating point issues + const roundedAmount = Math.round(amount * 100) / 100; + + return new Money(roundedAmount, normalizedCurrency); + } + + static zero(currency: string): Money { + return Money.create(0, currency); + } + + private static isValidCurrency(currency: string): boolean { + return Money.SUPPORTED_CURRENCIES.includes(currency); + } + + getAmount(): number { + return this.amount; + } + + getCurrency(): string { + return this.currency; + } + + add(other: Money): Money { + this.ensureSameCurrency(other); + return Money.create(this.amount + other.amount, this.currency); + } + + subtract(other: Money): Money { + this.ensureSameCurrency(other); + const result = this.amount - other.amount; + if (result < 0) { + throw new Error('Subtraction would result in negative amount.'); + } + return Money.create(result, this.currency); + } + + multiply(multiplier: number): Money { + if (multiplier < 0) { + throw new Error('Multiplier cannot be negative.'); + } + return Money.create(this.amount * multiplier, this.currency); + } + + divide(divisor: number): Money { + if (divisor <= 0) { + throw new Error('Divisor must be positive.'); + } + return Money.create(this.amount / divisor, this.currency); + } + + isGreaterThan(other: Money): boolean { + this.ensureSameCurrency(other); + return this.amount > other.amount; + } + + isLessThan(other: Money): boolean { + this.ensureSameCurrency(other); + return this.amount < other.amount; + } + + isEqualTo(other: Money): boolean { + return this.currency === other.currency && this.amount === other.amount; + } + + isZero(): boolean { + return this.amount === 0; + } + + private ensureSameCurrency(other: Money): void { + if (this.currency !== other.currency) { + throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`); + } + } + + /** + * Format as string with currency symbol + */ + format(): string { + const symbols: { [key: string]: string } = { + USD: '$', + EUR: '€', + GBP: '£', + CNY: '¥', + JPY: '¥', + }; + + const symbol = symbols[this.currency] || this.currency; + return `${symbol}${this.amount.toFixed(2)}`; + } + + toString(): string { + return this.format(); + } + + toObject(): { amount: number; currency: string } { + return { + amount: this.amount, + currency: this.currency, + }; + } +} diff --git a/apps/backend/src/domain/value-objects/port-code.vo.ts b/apps/backend/src/domain/value-objects/port-code.vo.ts index 019eaa2..4f1fd2e 100644 --- a/apps/backend/src/domain/value-objects/port-code.vo.ts +++ b/apps/backend/src/domain/value-objects/port-code.vo.ts @@ -1,66 +1,66 @@ -/** - * PortCode Value Object - * - * Encapsulates UN/LOCODE port code validation and behavior - * - * Business Rules: - * - Port code must follow UN/LOCODE format (2-letter country + 3-letter/digit location) - * - Port code is always uppercase - * - Port code is immutable - * - * Format: CCLLL - * - CC: ISO 3166-1 alpha-2 country code - * - LLL: 3-character location code (letters or digits) - * - * Examples: NLRTM (Rotterdam), USNYC (New York), SGSIN (Singapore) - */ - -export class PortCode { - private readonly value: string; - - private constructor(code: string) { - this.value = code; - } - - static create(code: string): PortCode { - if (!code || code.trim().length === 0) { - throw new Error('Port code cannot be empty.'); - } - - const normalized = code.trim().toUpperCase(); - - if (!PortCode.isValid(normalized)) { - throw new Error( - `Invalid port code format: ${code}. Must follow UN/LOCODE format (e.g., NLRTM, USNYC).` - ); - } - - return new PortCode(normalized); - } - - private static isValid(code: string): boolean { - // UN/LOCODE format: 2-letter country code + 3-character location code - const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/; - return unlocodePattern.test(code); - } - - getValue(): string { - return this.value; - } - - getCountryCode(): string { - return this.value.substring(0, 2); - } - - getLocationCode(): string { - return this.value.substring(2); - } - - equals(other: PortCode): boolean { - return this.value === other.value; - } - - toString(): string { - return this.value; - } -} +/** + * PortCode Value Object + * + * Encapsulates UN/LOCODE port code validation and behavior + * + * Business Rules: + * - Port code must follow UN/LOCODE format (2-letter country + 3-letter/digit location) + * - Port code is always uppercase + * - Port code is immutable + * + * Format: CCLLL + * - CC: ISO 3166-1 alpha-2 country code + * - LLL: 3-character location code (letters or digits) + * + * Examples: NLRTM (Rotterdam), USNYC (New York), SGSIN (Singapore) + */ + +export class PortCode { + private readonly value: string; + + private constructor(code: string) { + this.value = code; + } + + static create(code: string): PortCode { + if (!code || code.trim().length === 0) { + throw new Error('Port code cannot be empty.'); + } + + const normalized = code.trim().toUpperCase(); + + if (!PortCode.isValid(normalized)) { + throw new Error( + `Invalid port code format: ${code}. Must follow UN/LOCODE format (e.g., NLRTM, USNYC).` + ); + } + + return new PortCode(normalized); + } + + private static isValid(code: string): boolean { + // UN/LOCODE format: 2-letter country code + 3-character location code + const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/; + return unlocodePattern.test(code); + } + + getValue(): string { + return this.value; + } + + getCountryCode(): string { + return this.value.substring(0, 2); + } + + getLocationCode(): string { + return this.value.substring(2); + } + + equals(other: PortCode): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/apps/backend/src/domain/value-objects/surcharge.vo.ts b/apps/backend/src/domain/value-objects/surcharge.vo.ts index d3f4af3..c5c2bd3 100644 --- a/apps/backend/src/domain/value-objects/surcharge.vo.ts +++ b/apps/backend/src/domain/value-objects/surcharge.vo.ts @@ -1,107 +1,105 @@ -import { Money } from './money.vo'; - -/** - * Surcharge Type Enumeration - * Common maritime shipping surcharges - */ -export enum SurchargeType { - BAF = 'BAF', // Bunker Adjustment Factor - CAF = 'CAF', // Currency Adjustment Factor - PSS = 'PSS', // Peak Season Surcharge - THC = 'THC', // Terminal Handling Charge - OTHER = 'OTHER', -} - -/** - * Surcharge Value Object - * Represents additional fees applied to base freight rates - */ -export class Surcharge { - constructor( - public readonly type: SurchargeType, - public readonly amount: Money, - public readonly description?: string, - ) { - this.validate(); - } - - private validate(): void { - if (!Object.values(SurchargeType).includes(this.type)) { - throw new Error(`Invalid surcharge type: ${this.type}`); - } - } - - /** - * Get human-readable surcharge label - */ - getLabel(): string { - const labels: Record = { - [SurchargeType.BAF]: 'Bunker Adjustment Factor', - [SurchargeType.CAF]: 'Currency Adjustment Factor', - [SurchargeType.PSS]: 'Peak Season Surcharge', - [SurchargeType.THC]: 'Terminal Handling Charge', - [SurchargeType.OTHER]: 'Other Surcharge', - }; - return labels[this.type]; - } - - equals(other: Surcharge): boolean { - return ( - this.type === other.type && - this.amount.isEqualTo(other.amount) - ); - } - - toString(): string { - const label = this.description || this.getLabel(); - return `${label}: ${this.amount.toString()}`; - } -} - -/** - * Collection of surcharges with utility methods - */ -export class SurchargeCollection { - constructor(public readonly surcharges: Surcharge[]) {} - - /** - * Calculate total surcharge amount in a specific currency - * Note: This assumes all surcharges are in the same currency - * In production, currency conversion would be needed - */ - getTotalAmount(currency: string): Money { - const relevantSurcharges = this.surcharges - .filter((s) => s.amount.getCurrency() === currency); - - if (relevantSurcharges.length === 0) { - return Money.zero(currency); - } - - return relevantSurcharges - .reduce((total, surcharge) => total.add(surcharge.amount), Money.zero(currency)); - } - - /** - * Check if collection has any surcharges - */ - isEmpty(): boolean { - return this.surcharges.length === 0; - } - - /** - * Get surcharges by type - */ - getByType(type: SurchargeType): Surcharge[] { - return this.surcharges.filter((s) => s.type === type); - } - - /** - * Get formatted surcharge details for display - */ - getDetails(): string { - if (this.isEmpty()) { - return 'All-in price (no separate surcharges)'; - } - return this.surcharges.map((s) => s.toString()).join(', '); - } -} +import { Money } from './money.vo'; + +/** + * Surcharge Type Enumeration + * Common maritime shipping surcharges + */ +export enum SurchargeType { + BAF = 'BAF', // Bunker Adjustment Factor + CAF = 'CAF', // Currency Adjustment Factor + PSS = 'PSS', // Peak Season Surcharge + THC = 'THC', // Terminal Handling Charge + OTHER = 'OTHER', +} + +/** + * Surcharge Value Object + * Represents additional fees applied to base freight rates + */ +export class Surcharge { + constructor( + public readonly type: SurchargeType, + public readonly amount: Money, + public readonly description?: string + ) { + this.validate(); + } + + private validate(): void { + if (!Object.values(SurchargeType).includes(this.type)) { + throw new Error(`Invalid surcharge type: ${this.type}`); + } + } + + /** + * Get human-readable surcharge label + */ + getLabel(): string { + const labels: Record = { + [SurchargeType.BAF]: 'Bunker Adjustment Factor', + [SurchargeType.CAF]: 'Currency Adjustment Factor', + [SurchargeType.PSS]: 'Peak Season Surcharge', + [SurchargeType.THC]: 'Terminal Handling Charge', + [SurchargeType.OTHER]: 'Other Surcharge', + }; + return labels[this.type]; + } + + equals(other: Surcharge): boolean { + return this.type === other.type && this.amount.isEqualTo(other.amount); + } + + toString(): string { + const label = this.description || this.getLabel(); + return `${label}: ${this.amount.toString()}`; + } +} + +/** + * Collection of surcharges with utility methods + */ +export class SurchargeCollection { + constructor(public readonly surcharges: Surcharge[]) {} + + /** + * Calculate total surcharge amount in a specific currency + * Note: This assumes all surcharges are in the same currency + * In production, currency conversion would be needed + */ + getTotalAmount(currency: string): Money { + const relevantSurcharges = this.surcharges.filter(s => s.amount.getCurrency() === currency); + + if (relevantSurcharges.length === 0) { + return Money.zero(currency); + } + + return relevantSurcharges.reduce( + (total, surcharge) => total.add(surcharge.amount), + Money.zero(currency) + ); + } + + /** + * Check if collection has any surcharges + */ + isEmpty(): boolean { + return this.surcharges.length === 0; + } + + /** + * Get surcharges by type + */ + getByType(type: SurchargeType): Surcharge[] { + return this.surcharges.filter(s => s.type === type); + } + + /** + * Get formatted surcharge details for display + */ + getDetails(): string { + if (this.isEmpty()) { + return 'All-in price (no separate surcharges)'; + } + return this.surcharges.map(s => s.toString()).join(', '); + } +} diff --git a/apps/backend/src/domain/value-objects/volume.vo.ts b/apps/backend/src/domain/value-objects/volume.vo.ts index 6bdc56b..a16b367 100644 --- a/apps/backend/src/domain/value-objects/volume.vo.ts +++ b/apps/backend/src/domain/value-objects/volume.vo.ts @@ -1,54 +1,54 @@ -/** - * Volume Value Object - * Represents shipping volume in CBM (Cubic Meters) and weight in KG - * - * Business Rule: Price is calculated using freight class rule: - * - Take the higher of: (volumeCBM * pricePerCBM) or (weightKG * pricePerKG) - */ -export class Volume { - constructor( - public readonly cbm: number, - public readonly weightKG: number, - ) { - this.validate(); - } - - private validate(): void { - if (this.cbm < 0) { - throw new Error('Volume in CBM cannot be negative'); - } - if (this.weightKG < 0) { - throw new Error('Weight in KG cannot be negative'); - } - if (this.cbm === 0 && this.weightKG === 0) { - throw new Error('Either volume or weight must be greater than zero'); - } - } - - /** - * Check if this volume is within the specified range - */ - isWithinRange(minCBM: number, maxCBM: number, minKG: number, maxKG: number): boolean { - const cbmInRange = this.cbm >= minCBM && this.cbm <= maxCBM; - const weightInRange = this.weightKG >= minKG && this.weightKG <= maxKG; - return cbmInRange && weightInRange; - } - - /** - * Calculate freight price using the freight class rule - * Returns the higher value between volume-based and weight-based pricing - */ - calculateFreightPrice(pricePerCBM: number, pricePerKG: number): number { - const volumePrice = this.cbm * pricePerCBM; - const weightPrice = this.weightKG * pricePerKG; - return Math.max(volumePrice, weightPrice); - } - - equals(other: Volume): boolean { - return this.cbm === other.cbm && this.weightKG === other.weightKG; - } - - toString(): string { - return `${this.cbm} CBM / ${this.weightKG} KG`; - } -} +/** + * Volume Value Object + * Represents shipping volume in CBM (Cubic Meters) and weight in KG + * + * Business Rule: Price is calculated using freight class rule: + * - Take the higher of: (volumeCBM * pricePerCBM) or (weightKG * pricePerKG) + */ +export class Volume { + constructor( + public readonly cbm: number, + public readonly weightKG: number + ) { + this.validate(); + } + + private validate(): void { + if (this.cbm < 0) { + throw new Error('Volume in CBM cannot be negative'); + } + if (this.weightKG < 0) { + throw new Error('Weight in KG cannot be negative'); + } + if (this.cbm === 0 && this.weightKG === 0) { + throw new Error('Either volume or weight must be greater than zero'); + } + } + + /** + * Check if this volume is within the specified range + */ + isWithinRange(minCBM: number, maxCBM: number, minKG: number, maxKG: number): boolean { + const cbmInRange = this.cbm >= minCBM && this.cbm <= maxCBM; + const weightInRange = this.weightKG >= minKG && this.weightKG <= maxKG; + return cbmInRange && weightInRange; + } + + /** + * Calculate freight price using the freight class rule + * Returns the higher value between volume-based and weight-based pricing + */ + calculateFreightPrice(pricePerCBM: number, pricePerKG: number): number { + const volumePrice = this.cbm * pricePerCBM; + const weightPrice = this.weightKG * pricePerKG; + return Math.max(volumePrice, weightPrice); + } + + equals(other: Volume): boolean { + return this.cbm === other.cbm && this.weightKG === other.weightKG; + } + + toString(): string { + return `${this.cbm} CBM / ${this.weightKG} KG`; + } +} diff --git a/apps/backend/src/infrastructure/cache/cache.module.ts b/apps/backend/src/infrastructure/cache/cache.module.ts index fc826fd..ca1fe2e 100644 --- a/apps/backend/src/infrastructure/cache/cache.module.ts +++ b/apps/backend/src/infrastructure/cache/cache.module.ts @@ -1,22 +1,22 @@ -/** - * Cache Module - * - * Provides Redis cache adapter as CachePort implementation - */ - -import { Module, Global } from '@nestjs/common'; -import { RedisCacheAdapter } from './redis-cache.adapter'; -import { CACHE_PORT } from '../../domain/ports/out/cache.port'; - -@Global() -@Module({ - providers: [ - { - provide: CACHE_PORT, - useClass: RedisCacheAdapter, - }, - RedisCacheAdapter, - ], - exports: [CACHE_PORT, RedisCacheAdapter], -}) -export class CacheModule {} +/** + * Cache Module + * + * Provides Redis cache adapter as CachePort implementation + */ + +import { Module, Global } from '@nestjs/common'; +import { RedisCacheAdapter } from './redis-cache.adapter'; +import { CACHE_PORT } from '../../domain/ports/out/cache.port'; + +@Global() +@Module({ + providers: [ + { + provide: CACHE_PORT, + useClass: RedisCacheAdapter, + }, + RedisCacheAdapter, + ], + exports: [CACHE_PORT, RedisCacheAdapter], +}) +export class CacheModule {} diff --git a/apps/backend/src/infrastructure/cache/redis-cache.adapter.ts b/apps/backend/src/infrastructure/cache/redis-cache.adapter.ts index 410a773..1612efc 100644 --- a/apps/backend/src/infrastructure/cache/redis-cache.adapter.ts +++ b/apps/backend/src/infrastructure/cache/redis-cache.adapter.ts @@ -1,181 +1,183 @@ -/** - * Redis Cache Adapter - * - * Implements CachePort interface using Redis (ioredis) - */ - -import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; -import { CachePort } from '../../domain/ports/out/cache.port'; - -@Injectable() -export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(RedisCacheAdapter.name); - private client: Redis; - private stats = { - hits: 0, - misses: 0, - }; - - constructor(private readonly configService: ConfigService) {} - - async onModuleInit(): Promise { - const host = this.configService.get('REDIS_HOST', 'localhost'); - const port = this.configService.get('REDIS_PORT', 6379); - const password = this.configService.get('REDIS_PASSWORD'); - const db = this.configService.get('REDIS_DB', 0); - - this.client = new Redis({ - host, - port, - password, - db, - retryStrategy: (times) => { - const delay = Math.min(times * 50, 2000); - return delay; - }, - maxRetriesPerRequest: 3, - }); - - this.client.on('connect', () => { - this.logger.log(`Connected to Redis at ${host}:${port}`); - }); - - this.client.on('error', (err) => { - this.logger.error(`Redis connection error: ${err.message}`); - }); - - this.client.on('ready', () => { - this.logger.log('Redis client ready'); - }); - } - - async onModuleDestroy(): Promise { - await this.client.quit(); - this.logger.log('Redis connection closed'); - } - - async get(key: string): Promise { - try { - const value = await this.client.get(key); - - if (value === null) { - this.stats.misses++; - return null; - } - - this.stats.hits++; - return JSON.parse(value) as T; - } catch (error: any) { - this.logger.error(`Error getting key ${key}: ${error?.message || 'Unknown error'}`); - return null; - } - } - - async set(key: string, value: T, ttlSeconds?: number): Promise { - try { - const serialized = JSON.stringify(value); - if (ttlSeconds) { - await this.client.setex(key, ttlSeconds, serialized); - } else { - await this.client.set(key, serialized); - } - } catch (error: any) { - this.logger.error(`Error setting key ${key}: ${error?.message || 'Unknown error'}`); - throw error; - } - } - - async delete(key: string): Promise { - try { - await this.client.del(key); - } catch (error: any) { - this.logger.error(`Error deleting key ${key}: ${error?.message || 'Unknown error'}`); - throw error; - } - } - - async deleteMany(keys: string[]): Promise { - if (keys.length === 0) return; - - try { - await this.client.del(...keys); - } catch (error: any) { - this.logger.error(`Error deleting keys: ${error?.message || 'Unknown error'}`); - throw error; - } - } - - async exists(key: string): Promise { - try { - const result = await this.client.exists(key); - return result === 1; - } catch (error: any) { - this.logger.error(`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`); - return false; - } - } - - async ttl(key: string): Promise { - try { - return await this.client.ttl(key); - } catch (error: any) { - this.logger.error(`Error getting TTL for key ${key}: ${error?.message || 'Unknown error'}`); - return -2; - } - } - - async clear(): Promise { - try { - await this.client.flushdb(); - this.logger.warn('Redis database cleared'); - } catch (error: any) { - this.logger.error(`Error clearing cache: ${error?.message || 'Unknown error'}`); - throw error; - } - } - - async getStats(): Promise<{ - hits: number; - misses: number; - hitRate: number; - keyCount: number; - }> { - try { - const keyCount = await this.client.dbsize(); - const total = this.stats.hits + this.stats.misses; - const hitRate = total > 0 ? this.stats.hits / total : 0; - - return { - hits: this.stats.hits, - misses: this.stats.misses, - hitRate: Math.round(hitRate * 10000) / 100, // Percentage with 2 decimals - keyCount, - }; - } catch (error: any) { - this.logger.error(`Error getting stats: ${error?.message || 'Unknown error'}`); - return { - hits: this.stats.hits, - misses: this.stats.misses, - hitRate: 0, - keyCount: 0, - }; - } - } - - /** - * Reset statistics (useful for testing) - */ - resetStats(): void { - this.stats.hits = 0; - this.stats.misses = 0; - } - - /** - * Get Redis client (for advanced usage) - */ - getClient(): Redis { - return this.client; - } -} +/** + * Redis Cache Adapter + * + * Implements CachePort interface using Redis (ioredis) + */ + +import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { CachePort } from '../../domain/ports/out/cache.port'; + +@Injectable() +export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RedisCacheAdapter.name); + private client: Redis; + private stats = { + hits: 0, + misses: 0, + }; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit(): Promise { + const host = this.configService.get('REDIS_HOST', 'localhost'); + const port = this.configService.get('REDIS_PORT', 6379); + const password = this.configService.get('REDIS_PASSWORD'); + const db = this.configService.get('REDIS_DB', 0); + + this.client = new Redis({ + host, + port, + password, + db, + retryStrategy: times => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + }); + + this.client.on('connect', () => { + this.logger.log(`Connected to Redis at ${host}:${port}`); + }); + + this.client.on('error', err => { + this.logger.error(`Redis connection error: ${err.message}`); + }); + + this.client.on('ready', () => { + this.logger.log('Redis client ready'); + }); + } + + async onModuleDestroy(): Promise { + await this.client.quit(); + this.logger.log('Redis connection closed'); + } + + async get(key: string): Promise { + try { + const value = await this.client.get(key); + + if (value === null) { + this.stats.misses++; + return null; + } + + this.stats.hits++; + return JSON.parse(value) as T; + } catch (error: any) { + this.logger.error(`Error getting key ${key}: ${error?.message || 'Unknown error'}`); + return null; + } + } + + async set(key: string, value: T, ttlSeconds?: number): Promise { + try { + const serialized = JSON.stringify(value); + if (ttlSeconds) { + await this.client.setex(key, ttlSeconds, serialized); + } else { + await this.client.set(key, serialized); + } + } catch (error: any) { + this.logger.error(`Error setting key ${key}: ${error?.message || 'Unknown error'}`); + throw error; + } + } + + async delete(key: string): Promise { + try { + await this.client.del(key); + } catch (error: any) { + this.logger.error(`Error deleting key ${key}: ${error?.message || 'Unknown error'}`); + throw error; + } + } + + async deleteMany(keys: string[]): Promise { + if (keys.length === 0) return; + + try { + await this.client.del(...keys); + } catch (error: any) { + this.logger.error(`Error deleting keys: ${error?.message || 'Unknown error'}`); + throw error; + } + } + + async exists(key: string): Promise { + try { + const result = await this.client.exists(key); + return result === 1; + } catch (error: any) { + this.logger.error( + `Error checking key existence ${key}: ${error?.message || 'Unknown error'}` + ); + return false; + } + } + + async ttl(key: string): Promise { + try { + return await this.client.ttl(key); + } catch (error: any) { + this.logger.error(`Error getting TTL for key ${key}: ${error?.message || 'Unknown error'}`); + return -2; + } + } + + async clear(): Promise { + try { + await this.client.flushdb(); + this.logger.warn('Redis database cleared'); + } catch (error: any) { + this.logger.error(`Error clearing cache: ${error?.message || 'Unknown error'}`); + throw error; + } + } + + async getStats(): Promise<{ + hits: number; + misses: number; + hitRate: number; + keyCount: number; + }> { + try { + const keyCount = await this.client.dbsize(); + const total = this.stats.hits + this.stats.misses; + const hitRate = total > 0 ? this.stats.hits / total : 0; + + return { + hits: this.stats.hits, + misses: this.stats.misses, + hitRate: Math.round(hitRate * 10000) / 100, // Percentage with 2 decimals + keyCount, + }; + } catch (error: any) { + this.logger.error(`Error getting stats: ${error?.message || 'Unknown error'}`); + return { + hits: this.stats.hits, + misses: this.stats.misses, + hitRate: 0, + keyCount: 0, + }; + } + } + + /** + * Reset statistics (useful for testing) + */ + resetStats(): void { + this.stats.hits = 0; + this.stats.misses = 0; + } + + /** + * Get Redis client (for advanced usage) + */ + getClient(): Redis { + return this.client; + } +} diff --git a/apps/backend/src/infrastructure/carriers/carrier.module.ts b/apps/backend/src/infrastructure/carriers/carrier.module.ts index eca4cd0..6057381 100644 --- a/apps/backend/src/infrastructure/carriers/carrier.module.ts +++ b/apps/backend/src/infrastructure/carriers/carrier.module.ts @@ -1,75 +1,69 @@ -/** - * Carrier Module - * - * Provides all carrier connector implementations - */ - -import { Module } from '@nestjs/common'; -import { MaerskConnector } from './maersk/maersk.connector'; -import { MSCConnectorAdapter } from './msc/msc.connector'; -import { MSCRequestMapper } from './msc/msc.mapper'; -import { CMACGMConnectorAdapter } from './cma-cgm/cma-cgm.connector'; -import { CMACGMRequestMapper } from './cma-cgm/cma-cgm.mapper'; -import { HapagLloydConnectorAdapter } from './hapag-lloyd/hapag-lloyd.connector'; -import { HapagLloydRequestMapper } from './hapag-lloyd/hapag-lloyd.mapper'; -import { ONEConnectorAdapter } from './one/one.connector'; -import { ONERequestMapper } from './one/one.mapper'; - -@Module({ - providers: [ - // Maersk - MaerskConnector, - - // MSC - MSCRequestMapper, - MSCConnectorAdapter, - - // CMA CGM - CMACGMRequestMapper, - CMACGMConnectorAdapter, - - // Hapag-Lloyd - HapagLloydRequestMapper, - HapagLloydConnectorAdapter, - - // ONE - ONERequestMapper, - ONEConnectorAdapter, - - // Factory that provides all connectors - { - provide: 'CarrierConnectors', - useFactory: ( - maerskConnector: MaerskConnector, - mscConnector: MSCConnectorAdapter, - cmacgmConnector: CMACGMConnectorAdapter, - hapagConnector: HapagLloydConnectorAdapter, - oneConnector: ONEConnectorAdapter, - ) => { - return [ - maerskConnector, - mscConnector, - cmacgmConnector, - hapagConnector, - oneConnector, - ]; - }, - inject: [ - MaerskConnector, - MSCConnectorAdapter, - CMACGMConnectorAdapter, - HapagLloydConnectorAdapter, - ONEConnectorAdapter, - ], - }, - ], - exports: [ - 'CarrierConnectors', - MaerskConnector, - MSCConnectorAdapter, - CMACGMConnectorAdapter, - HapagLloydConnectorAdapter, - ONEConnectorAdapter, - ], -}) -export class CarrierModule {} +/** + * Carrier Module + * + * Provides all carrier connector implementations + */ + +import { Module } from '@nestjs/common'; +import { MaerskConnector } from './maersk/maersk.connector'; +import { MSCConnectorAdapter } from './msc/msc.connector'; +import { MSCRequestMapper } from './msc/msc.mapper'; +import { CMACGMConnectorAdapter } from './cma-cgm/cma-cgm.connector'; +import { CMACGMRequestMapper } from './cma-cgm/cma-cgm.mapper'; +import { HapagLloydConnectorAdapter } from './hapag-lloyd/hapag-lloyd.connector'; +import { HapagLloydRequestMapper } from './hapag-lloyd/hapag-lloyd.mapper'; +import { ONEConnectorAdapter } from './one/one.connector'; +import { ONERequestMapper } from './one/one.mapper'; + +@Module({ + providers: [ + // Maersk + MaerskConnector, + + // MSC + MSCRequestMapper, + MSCConnectorAdapter, + + // CMA CGM + CMACGMRequestMapper, + CMACGMConnectorAdapter, + + // Hapag-Lloyd + HapagLloydRequestMapper, + HapagLloydConnectorAdapter, + + // ONE + ONERequestMapper, + ONEConnectorAdapter, + + // Factory that provides all connectors + { + provide: 'CarrierConnectors', + useFactory: ( + maerskConnector: MaerskConnector, + mscConnector: MSCConnectorAdapter, + cmacgmConnector: CMACGMConnectorAdapter, + hapagConnector: HapagLloydConnectorAdapter, + oneConnector: ONEConnectorAdapter + ) => { + return [maerskConnector, mscConnector, cmacgmConnector, hapagConnector, oneConnector]; + }, + inject: [ + MaerskConnector, + MSCConnectorAdapter, + CMACGMConnectorAdapter, + HapagLloydConnectorAdapter, + ONEConnectorAdapter, + ], + }, + ], + exports: [ + 'CarrierConnectors', + MaerskConnector, + MSCConnectorAdapter, + CMACGMConnectorAdapter, + HapagLloydConnectorAdapter, + ONEConnectorAdapter, + ], +}) +export class CarrierModule {} diff --git a/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.connector.ts b/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.connector.ts index d88797c..f09299b 100644 --- a/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.connector.ts +++ b/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.connector.ts @@ -9,24 +9,21 @@ import { ConfigService } from '@nestjs/config'; import { CarrierConnectorPort, CarrierRateSearchInput, - CarrierAvailabilityInput + CarrierAvailabilityInput, } from '../../../domain/ports/out/carrier-connector.port'; import { RateQuote } from '../../../domain/entities/rate-quote.entity'; import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; import { CMACGMRequestMapper } from './cma-cgm.mapper'; @Injectable() -export class CMACGMConnectorAdapter - extends BaseCarrierConnector - implements CarrierConnectorPort -{ +export class CMACGMConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort { private readonly apiUrl: string; private readonly clientId: string; private readonly clientSecret: string; constructor( private readonly configService: ConfigService, - private readonly requestMapper: CMACGMRequestMapper, + private readonly requestMapper: CMACGMRequestMapper ) { const config: CarrierConfig = { name: 'CMA CGM', diff --git a/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.mapper.ts b/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.mapper.ts index b3f2d59..57a4fd0 100644 --- a/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.mapper.ts +++ b/apps/backend/src/infrastructure/carriers/cma-cgm/cma-cgm.mapper.ts @@ -30,11 +30,31 @@ export class CMACGMRequestMapper { return cgmResponse.quotations.map((quotation: any) => { const surcharges: Surcharge[] = [ - { type: 'BAF', description: 'Bunker Surcharge', amount: quotation.charges?.bunker_surcharge || 0, currency: quotation.charges?.currency || 'USD' }, - { type: 'CAF', description: 'Currency Surcharge', amount: quotation.charges?.currency_surcharge || 0, currency: quotation.charges?.currency || 'USD' }, - { type: 'PSS', description: 'Peak Season', amount: quotation.charges?.peak_season || 0, currency: quotation.charges?.currency || 'USD' }, - { type: 'THC', description: 'Terminal Handling', amount: quotation.charges?.thc || 0, currency: quotation.charges?.currency || 'USD' }, - ].filter((s) => s.amount > 0); + { + type: 'BAF', + description: 'Bunker Surcharge', + amount: quotation.charges?.bunker_surcharge || 0, + currency: quotation.charges?.currency || 'USD', + }, + { + type: 'CAF', + description: 'Currency Surcharge', + amount: quotation.charges?.currency_surcharge || 0, + currency: quotation.charges?.currency || 'USD', + }, + { + type: 'PSS', + description: 'Peak Season', + amount: quotation.charges?.peak_season || 0, + currency: quotation.charges?.currency || 'USD', + }, + { + type: 'THC', + description: 'Terminal Handling', + amount: quotation.charges?.thc || 0, + currency: quotation.charges?.currency || 'USD', + }, + ].filter(s => s.amount > 0); const baseFreight = quotation.charges?.ocean_freight || 0; const totalSurcharges = surcharges.reduce((sum, s) => sum + s.amount, 0); @@ -53,7 +73,10 @@ export class CMACGMRequestMapper { }); // Transshipment ports - if (quotation.routing?.transshipment_ports && Array.isArray(quotation.routing.transshipment_ports)) { + if ( + quotation.routing?.transshipment_ports && + Array.isArray(quotation.routing.transshipment_ports) + ) { quotation.routing.transshipment_ports.forEach((port: any) => { route.push({ portCode: port.code || port, @@ -69,7 +92,12 @@ export class CMACGMRequestMapper { arrival: new Date(quotation.schedule?.arrival_date), }); - const transitDays = quotation.schedule?.transit_time_days || this.calculateTransitDays(quotation.schedule?.departure_date, quotation.schedule?.arrival_date); + const transitDays = + quotation.schedule?.transit_time_days || + this.calculateTransitDays( + quotation.schedule?.departure_date, + quotation.schedule?.arrival_date + ); return RateQuote.create({ id: uuidv4(), diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts index 614ca9d..a10192d 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts @@ -1,340 +1,319 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { parse } from 'csv-parse/sync'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; -import { CsvRate } from '@domain/entities/csv-rate.entity'; -import { PortCode } from '@domain/value-objects/port-code.vo'; -import { ContainerType } from '@domain/value-objects/container-type.vo'; -import { Money } from '@domain/value-objects/money.vo'; -import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo'; -import { DateRange } from '@domain/value-objects/date-range.vo'; - -/** - * CSV Row Interface - * Maps to CSV file structure - */ -interface CsvRow { - companyName: string; - origin: string; - destination: string; - containerType: string; - minVolumeCBM: string; - maxVolumeCBM: string; - minWeightKG: string; - maxWeightKG: string; - palletCount: string; - pricePerCBM: string; - pricePerKG: string; - basePriceUSD: string; - basePriceEUR: string; - currency: string; - hasSurcharges: string; - surchargeBAF?: string; - surchargeCAF?: string; - surchargeDetails?: string; - transitDays: string; - validFrom: string; - validUntil: string; -} - -/** - * CSV Rate Loader Adapter - * - * Infrastructure adapter for loading shipping rates from CSV files. - * Implements CsvRateLoaderPort interface. - * - * Features: - * - CSV parsing with validation - * - Mapping CSV rows to domain entities - * - Error handling and logging - * - File system operations - */ -@Injectable() -export class CsvRateLoaderAdapter implements CsvRateLoaderPort { - private readonly logger = new Logger(CsvRateLoaderAdapter.name); - private readonly csvDirectory: string; - - // Company name to CSV file mapping - private readonly companyFileMapping: Map = new Map([ - ['SSC Consolidation', 'ssc-consolidation.csv'], - ['ECU Worldwide', 'ecu-worldwide.csv'], - ['TCC Logistics', 'tcc-logistics.csv'], - ['NVO Consolidation', 'nvo-consolidation.csv'], - ]); - - constructor() { - // CSV files are stored in infrastructure/storage/csv-storage/rates/ - this.csvDirectory = path.join( - __dirname, - '..', - '..', - 'storage', - 'csv-storage', - 'rates', - ); - } - - async loadRatesFromCsv(filePath: string): Promise { - this.logger.log(`Loading rates from CSV: ${filePath}`); - - try { - // Read CSV file - const fullPath = path.isAbsolute(filePath) - ? filePath - : path.join(this.csvDirectory, filePath); - - const fileContent = await fs.readFile(fullPath, 'utf-8'); - - // Parse CSV - const records: CsvRow[] = parse(fileContent, { - columns: true, - skip_empty_lines: true, - trim: true, - }); - - this.logger.log(`Parsed ${records.length} rows from ${filePath}`); - - // Validate structure - this.validateCsvStructure(records); - - // Map to domain entities - const rates = records.map((record, index) => { - try { - return this.mapToCsvRate(record); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error( - `Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`, - ); - throw new Error( - `Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`, - ); - } - }); - - this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`); - return rates; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`); - throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`); - } - } - - async loadRatesByCompany(companyName: string): Promise { - const fileName = this.companyFileMapping.get(companyName); - - if (!fileName) { - this.logger.warn(`No CSV file configured for company: ${companyName}`); - return []; - } - - return this.loadRatesFromCsv(fileName); - } - - async validateCsvFile( - filePath: string, - ): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> { - const errors: string[] = []; - - try { - const fullPath = path.isAbsolute(filePath) - ? filePath - : path.join(this.csvDirectory, filePath); - - // Check if file exists - try { - await fs.access(fullPath); - } catch { - errors.push(`File not found: ${filePath}`); - return { valid: false, errors }; - } - - // Read and parse - const fileContent = await fs.readFile(fullPath, 'utf-8'); - const records: CsvRow[] = parse(fileContent, { - columns: true, - skip_empty_lines: true, - trim: true, - }); - - if (records.length === 0) { - errors.push('CSV file is empty'); - return { valid: false, errors, rowCount: 0 }; - } - - // Validate structure - try { - this.validateCsvStructure(records); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - errors.push(errorMessage); - } - - // Validate each row - records.forEach((record, index) => { - try { - this.mapToCsvRate(record); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - errors.push(`Row ${index + 1}: ${errorMessage}`); - } - }); - - return { - valid: errors.length === 0, - errors, - rowCount: records.length, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - errors.push(`Validation failed: ${errorMessage}`); - return { valid: false, errors }; - } - } - - async getAvailableCsvFiles(): Promise { - try { - // Ensure directory exists - try { - await fs.access(this.csvDirectory); - } catch { - this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`); - return []; - } - - const files = await fs.readdir(this.csvDirectory); - return files.filter((file) => file.endsWith('.csv')); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to list CSV files: ${errorMessage}`); - return []; - } - } - - /** - * Validate that CSV has all required columns - */ - private validateCsvStructure(records: CsvRow[]): void { - const requiredColumns = [ - 'companyName', - 'origin', - 'destination', - 'containerType', - 'minVolumeCBM', - 'maxVolumeCBM', - 'minWeightKG', - 'maxWeightKG', - 'palletCount', - 'pricePerCBM', - 'pricePerKG', - 'basePriceUSD', - 'basePriceEUR', - 'currency', - 'hasSurcharges', - 'transitDays', - 'validFrom', - 'validUntil', - ]; - - if (records.length === 0) { - throw new Error('CSV file is empty'); - } - - const firstRecord = records[0]; - const missingColumns = requiredColumns.filter( - (col) => !(col in firstRecord), - ); - - if (missingColumns.length > 0) { - throw new Error( - `Missing required columns: ${missingColumns.join(', ')}`, - ); - } - } - - /** - * Map CSV row to CsvRate domain entity - */ - private mapToCsvRate(record: CsvRow): CsvRate { - // Parse surcharges - const surcharges = this.parseSurcharges(record); - - // Create DateRange - const validFrom = new Date(record.validFrom); - const validUntil = new Date(record.validUntil); - const validity = DateRange.create(validFrom, validUntil, true); - - // Create CsvRate - return new CsvRate( - record.companyName.trim(), - PortCode.create(record.origin), - PortCode.create(record.destination), - ContainerType.create(record.containerType), - { - minCBM: parseFloat(record.minVolumeCBM), - maxCBM: parseFloat(record.maxVolumeCBM), - }, - { - minKG: parseFloat(record.minWeightKG), - maxKG: parseFloat(record.maxWeightKG), - }, - parseInt(record.palletCount, 10), - { - pricePerCBM: parseFloat(record.pricePerCBM), - pricePerKG: parseFloat(record.pricePerKG), - basePriceUSD: Money.create( - parseFloat(record.basePriceUSD), - 'USD', - ), - basePriceEUR: Money.create( - parseFloat(record.basePriceEUR), - 'EUR', - ), - }, - record.currency.toUpperCase(), - new SurchargeCollection(surcharges), - parseInt(record.transitDays, 10), - validity, - ); - } - - /** - * Parse surcharges from CSV row - */ - private parseSurcharges(record: CsvRow): Surcharge[] { - const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true'; - - if (!hasSurcharges) { - return []; - } - - const surcharges: Surcharge[] = []; - const currency = record.currency.toUpperCase(); - - // BAF (Bunker Adjustment Factor) - if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) { - surcharges.push( - new Surcharge( - SurchargeType.BAF, - Money.create(parseFloat(record.surchargeBAF), currency), - 'Bunker Adjustment Factor', - ), - ); - } - - // CAF (Currency Adjustment Factor) - if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) { - surcharges.push( - new Surcharge( - SurchargeType.CAF, - Money.create(parseFloat(record.surchargeCAF), currency), - 'Currency Adjustment Factor', - ), - ); - } - - return surcharges; - } -} +import { Injectable, Logger } from '@nestjs/common'; +import { parse } from 'csv-parse/sync'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; +import { CsvRate } from '@domain/entities/csv-rate.entity'; +import { PortCode } from '@domain/value-objects/port-code.vo'; +import { ContainerType } from '@domain/value-objects/container-type.vo'; +import { Money } from '@domain/value-objects/money.vo'; +import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo'; +import { DateRange } from '@domain/value-objects/date-range.vo'; + +/** + * CSV Row Interface + * Maps to CSV file structure + */ +interface CsvRow { + companyName: string; + origin: string; + destination: string; + containerType: string; + minVolumeCBM: string; + maxVolumeCBM: string; + minWeightKG: string; + maxWeightKG: string; + palletCount: string; + pricePerCBM: string; + pricePerKG: string; + basePriceUSD: string; + basePriceEUR: string; + currency: string; + hasSurcharges: string; + surchargeBAF?: string; + surchargeCAF?: string; + surchargeDetails?: string; + transitDays: string; + validFrom: string; + validUntil: string; +} + +/** + * CSV Rate Loader Adapter + * + * Infrastructure adapter for loading shipping rates from CSV files. + * Implements CsvRateLoaderPort interface. + * + * Features: + * - CSV parsing with validation + * - Mapping CSV rows to domain entities + * - Error handling and logging + * - File system operations + */ +@Injectable() +export class CsvRateLoaderAdapter implements CsvRateLoaderPort { + private readonly logger = new Logger(CsvRateLoaderAdapter.name); + private readonly csvDirectory: string; + + // Company name to CSV file mapping + private readonly companyFileMapping: Map = new Map([ + ['SSC Consolidation', 'ssc-consolidation.csv'], + ['ECU Worldwide', 'ecu-worldwide.csv'], + ['TCC Logistics', 'tcc-logistics.csv'], + ['NVO Consolidation', 'nvo-consolidation.csv'], + ]); + + constructor() { + // CSV files are stored in infrastructure/storage/csv-storage/rates/ + this.csvDirectory = path.join(__dirname, '..', '..', 'storage', 'csv-storage', 'rates'); + } + + async loadRatesFromCsv(filePath: string): Promise { + this.logger.log(`Loading rates from CSV: ${filePath}`); + + try { + // Read CSV file + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.csvDirectory, filePath); + + const fileContent = await fs.readFile(fullPath, 'utf-8'); + + // Parse CSV + const records: CsvRow[] = parse(fileContent, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + + this.logger.log(`Parsed ${records.length} rows from ${filePath}`); + + // Validate structure + this.validateCsvStructure(records); + + // Map to domain entities + const rates = records.map((record, index) => { + try { + return this.mapToCsvRate(record); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`); + throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`); + } + }); + + this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`); + return rates; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`); + throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`); + } + } + + async loadRatesByCompany(companyName: string): Promise { + const fileName = this.companyFileMapping.get(companyName); + + if (!fileName) { + this.logger.warn(`No CSV file configured for company: ${companyName}`); + return []; + } + + return this.loadRatesFromCsv(fileName); + } + + async validateCsvFile( + filePath: string + ): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> { + const errors: string[] = []; + + try { + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.csvDirectory, filePath); + + // Check if file exists + try { + await fs.access(fullPath); + } catch { + errors.push(`File not found: ${filePath}`); + return { valid: false, errors }; + } + + // Read and parse + const fileContent = await fs.readFile(fullPath, 'utf-8'); + const records: CsvRow[] = parse(fileContent, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + + if (records.length === 0) { + errors.push('CSV file is empty'); + return { valid: false, errors, rowCount: 0 }; + } + + // Validate structure + try { + this.validateCsvStructure(records); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + errors.push(errorMessage); + } + + // Validate each row + records.forEach((record, index) => { + try { + this.mapToCsvRate(record); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + errors.push(`Row ${index + 1}: ${errorMessage}`); + } + }); + + return { + valid: errors.length === 0, + errors, + rowCount: records.length, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + errors.push(`Validation failed: ${errorMessage}`); + return { valid: false, errors }; + } + } + + async getAvailableCsvFiles(): Promise { + try { + // Ensure directory exists + try { + await fs.access(this.csvDirectory); + } catch { + this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`); + return []; + } + + const files = await fs.readdir(this.csvDirectory); + return files.filter(file => file.endsWith('.csv')); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to list CSV files: ${errorMessage}`); + return []; + } + } + + /** + * Validate that CSV has all required columns + */ + private validateCsvStructure(records: CsvRow[]): void { + const requiredColumns = [ + 'companyName', + 'origin', + 'destination', + 'containerType', + 'minVolumeCBM', + 'maxVolumeCBM', + 'minWeightKG', + 'maxWeightKG', + 'palletCount', + 'pricePerCBM', + 'pricePerKG', + 'basePriceUSD', + 'basePriceEUR', + 'currency', + 'hasSurcharges', + 'transitDays', + 'validFrom', + 'validUntil', + ]; + + if (records.length === 0) { + throw new Error('CSV file is empty'); + } + + const firstRecord = records[0]; + const missingColumns = requiredColumns.filter(col => !(col in firstRecord)); + + if (missingColumns.length > 0) { + throw new Error(`Missing required columns: ${missingColumns.join(', ')}`); + } + } + + /** + * Map CSV row to CsvRate domain entity + */ + private mapToCsvRate(record: CsvRow): CsvRate { + // Parse surcharges + const surcharges = this.parseSurcharges(record); + + // Create DateRange + const validFrom = new Date(record.validFrom); + const validUntil = new Date(record.validUntil); + const validity = DateRange.create(validFrom, validUntil, true); + + // Create CsvRate + return new CsvRate( + record.companyName.trim(), + PortCode.create(record.origin), + PortCode.create(record.destination), + ContainerType.create(record.containerType), + { + minCBM: parseFloat(record.minVolumeCBM), + maxCBM: parseFloat(record.maxVolumeCBM), + }, + { + minKG: parseFloat(record.minWeightKG), + maxKG: parseFloat(record.maxWeightKG), + }, + parseInt(record.palletCount, 10), + { + pricePerCBM: parseFloat(record.pricePerCBM), + pricePerKG: parseFloat(record.pricePerKG), + basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'), + basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'), + }, + record.currency.toUpperCase(), + new SurchargeCollection(surcharges), + parseInt(record.transitDays, 10), + validity + ); + } + + /** + * Parse surcharges from CSV row + */ + private parseSurcharges(record: CsvRow): Surcharge[] { + const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true'; + + if (!hasSurcharges) { + return []; + } + + const surcharges: Surcharge[] = []; + const currency = record.currency.toUpperCase(); + + // BAF (Bunker Adjustment Factor) + if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) { + surcharges.push( + new Surcharge( + SurchargeType.BAF, + Money.create(parseFloat(record.surchargeBAF), currency), + 'Bunker Adjustment Factor' + ) + ); + } + + // CAF (Currency Adjustment Factor) + if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) { + surcharges.push( + new Surcharge( + SurchargeType.CAF, + Money.create(parseFloat(record.surchargeCAF), currency), + 'Currency Adjustment Factor' + ) + ); + } + + return surcharges; + } +} diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts index c7a29df..8da33ff 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts @@ -1,58 +1,58 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -// Domain Services -import { CsvRateSearchService } from '@domain/services/csv-rate-search.service'; - -// Infrastructure Adapters -import { CsvRateLoaderAdapter } from './csv-rate-loader.adapter'; -import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; - -// Application Layer -import { CsvRateMapper } from '@application/mappers/csv-rate.mapper'; -import { CsvRatesAdminController } from '@application/controllers/admin/csv-rates.controller'; - -// ORM Entities -import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; - -/** - * CSV Rate Module - * - * Module for CSV-based rate search system - * Registers all providers, repositories, and controllers - * - * Features: - * - CSV file loading and parsing - * - Rate search with advanced filters - * - Admin CSV upload (ADMIN role only) - * - Configuration management - */ -@Module({ - imports: [ - // TypeORM entities - TypeOrmModule.forFeature([CsvRateConfigOrmEntity]), - ], - providers: [ - // Domain Services - CsvRateSearchService, - - // Infrastructure Adapters - CsvRateLoaderAdapter, - TypeOrmCsvRateConfigRepository, - - // Application Mappers - CsvRateMapper, - ], - controllers: [ - // Admin Controllers - CsvRatesAdminController, - ], - exports: [ - // Export services for use in other modules - CsvRateSearchService, - CsvRateLoaderAdapter, - TypeOrmCsvRateConfigRepository, - CsvRateMapper, - ], -}) -export class CsvRateModule {} +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Domain Services +import { CsvRateSearchService } from '@domain/services/csv-rate-search.service'; + +// Infrastructure Adapters +import { CsvRateLoaderAdapter } from './csv-rate-loader.adapter'; +import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; + +// Application Layer +import { CsvRateMapper } from '@application/mappers/csv-rate.mapper'; +import { CsvRatesAdminController } from '@application/controllers/admin/csv-rates.controller'; + +// ORM Entities +import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; + +/** + * CSV Rate Module + * + * Module for CSV-based rate search system + * Registers all providers, repositories, and controllers + * + * Features: + * - CSV file loading and parsing + * - Rate search with advanced filters + * - Admin CSV upload (ADMIN role only) + * - Configuration management + */ +@Module({ + imports: [ + // TypeORM entities + TypeOrmModule.forFeature([CsvRateConfigOrmEntity]), + ], + providers: [ + // Domain Services + CsvRateSearchService, + + // Infrastructure Adapters + CsvRateLoaderAdapter, + TypeOrmCsvRateConfigRepository, + + // Application Mappers + CsvRateMapper, + ], + controllers: [ + // Admin Controllers + CsvRatesAdminController, + ], + exports: [ + // Export services for use in other modules + CsvRateSearchService, + CsvRateLoaderAdapter, + TypeOrmCsvRateConfigRepository, + CsvRateMapper, + ], +}) +export class CsvRateModule {} diff --git a/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.connector.ts b/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.connector.ts index dc20997..1039ce5 100644 --- a/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.connector.ts +++ b/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.connector.ts @@ -9,7 +9,7 @@ import { ConfigService } from '@nestjs/config'; import { CarrierConnectorPort, CarrierRateSearchInput, - CarrierAvailabilityInput + CarrierAvailabilityInput, } from '../../../domain/ports/out/carrier-connector.port'; import { RateQuote } from '../../../domain/entities/rate-quote.entity'; import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; @@ -25,7 +25,7 @@ export class HapagLloydConnectorAdapter constructor( private readonly configService: ConfigService, - private readonly requestMapper: HapagLloydRequestMapper, + private readonly requestMapper: HapagLloydRequestMapper ) { const config: CarrierConfig = { name: 'Hapag-Lloyd', @@ -91,7 +91,9 @@ export class HapagLloydConnectorAdapter return (response.data as any).available_capacity || 0; } catch (error: any) { - this.logger.error(`Hapag-Lloyd availability check error: ${error?.message || 'Unknown error'}`); + this.logger.error( + `Hapag-Lloyd availability check error: ${error?.message || 'Unknown error'}` + ); return 0; } } diff --git a/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.mapper.ts b/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.mapper.ts index 8bd3260..3ed9d5b 100644 --- a/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.mapper.ts +++ b/apps/backend/src/infrastructure/carriers/hapag-lloyd/hapag-lloyd.mapper.ts @@ -91,7 +91,12 @@ export class HapagLloydRequestMapper { arrival: new Date(quote.estimated_time_of_arrival), }); - const transitDays = quote.transit_time_days || this.calculateTransitDays(quote.estimated_time_of_departure, quote.estimated_time_of_arrival); + const transitDays = + quote.transit_time_days || + this.calculateTransitDays( + quote.estimated_time_of_departure, + quote.estimated_time_of_arrival + ); return RateQuote.create({ id: uuidv4(), diff --git a/apps/backend/src/infrastructure/carriers/maersk/maersk-request.mapper.ts b/apps/backend/src/infrastructure/carriers/maersk/maersk-request.mapper.ts index ab2c901..fbd3519 100644 --- a/apps/backend/src/infrastructure/carriers/maersk/maersk-request.mapper.ts +++ b/apps/backend/src/infrastructure/carriers/maersk/maersk-request.mapper.ts @@ -1,54 +1,54 @@ -/** - * Maersk Request Mapper - * - * Maps internal domain format to Maersk API format - */ - -import { CarrierRateSearchInput } from '../../../domain/ports/out/carrier-connector.port'; -import { MaerskRateSearchRequest } from './maersk.types'; - -export class MaerskRequestMapper { - /** - * Map domain rate search input to Maersk API request - */ - static toMaerskRateSearchRequest(input: CarrierRateSearchInput): MaerskRateSearchRequest { - const { size, type } = this.parseContainerType(input.containerType); - - return { - originPortCode: input.origin, - destinationPortCode: input.destination, - containerSize: size, - containerType: type, - cargoMode: (input.mode as 'FCL' | 'LCL') || 'FCL', - estimatedDepartureDate: input.departureDate.toISOString(), - numberOfContainers: input.quantity || 1, - cargoWeight: input.weight, - cargoVolume: input.volume, - isDangerousGoods: input.isHazmat || false, - imoClass: input.imoClass, - }; - } - - /** - * Parse container type (e.g., '40HC' -> { size: '40', type: 'DRY' }) - */ - private static parseContainerType(containerType: string): { size: string; type: string } { - // Extract size (first 2 digits) - const sizeMatch = containerType.match(/^(\d{2})/); - const size = sizeMatch ? sizeMatch[1] : '40'; - - // Determine type - let type = 'DRY'; - if (containerType.includes('REEFER')) { - type = 'REEFER'; - } else if (containerType.includes('OT')) { - type = 'OPEN_TOP'; - } else if (containerType.includes('FR')) { - type = 'FLAT_RACK'; - } else if (containerType.includes('TANK')) { - type = 'TANK'; - } - - return { size, type }; - } -} +/** + * Maersk Request Mapper + * + * Maps internal domain format to Maersk API format + */ + +import { CarrierRateSearchInput } from '../../../domain/ports/out/carrier-connector.port'; +import { MaerskRateSearchRequest } from './maersk.types'; + +export class MaerskRequestMapper { + /** + * Map domain rate search input to Maersk API request + */ + static toMaerskRateSearchRequest(input: CarrierRateSearchInput): MaerskRateSearchRequest { + const { size, type } = this.parseContainerType(input.containerType); + + return { + originPortCode: input.origin, + destinationPortCode: input.destination, + containerSize: size, + containerType: type, + cargoMode: (input.mode as 'FCL' | 'LCL') || 'FCL', + estimatedDepartureDate: input.departureDate.toISOString(), + numberOfContainers: input.quantity || 1, + cargoWeight: input.weight, + cargoVolume: input.volume, + isDangerousGoods: input.isHazmat || false, + imoClass: input.imoClass, + }; + } + + /** + * Parse container type (e.g., '40HC' -> { size: '40', type: 'DRY' }) + */ + private static parseContainerType(containerType: string): { size: string; type: string } { + // Extract size (first 2 digits) + const sizeMatch = containerType.match(/^(\d{2})/); + const size = sizeMatch ? sizeMatch[1] : '40'; + + // Determine type + let type = 'DRY'; + if (containerType.includes('REEFER')) { + type = 'REEFER'; + } else if (containerType.includes('OT')) { + type = 'OPEN_TOP'; + } else if (containerType.includes('FR')) { + type = 'FLAT_RACK'; + } else if (containerType.includes('TANK')) { + type = 'TANK'; + } + + return { size, type }; + } +} diff --git a/apps/backend/src/infrastructure/carriers/maersk/maersk-response.mapper.ts b/apps/backend/src/infrastructure/carriers/maersk/maersk-response.mapper.ts index 6095a49..753cc4f 100644 --- a/apps/backend/src/infrastructure/carriers/maersk/maersk-response.mapper.ts +++ b/apps/backend/src/infrastructure/carriers/maersk/maersk-response.mapper.ts @@ -1,111 +1,109 @@ -/** - * Maersk Response Mapper - * - * Maps Maersk API response to domain entities - */ - -import { v4 as uuidv4 } from 'uuid'; -import { RateQuote } from '../../../domain/entities/rate-quote.entity'; -import { MaerskRateSearchResponse, MaerskRateResult, MaerskRouteSegment } from './maersk.types'; - -export class MaerskResponseMapper { - /** - * Map Maersk API response to domain RateQuote entities - */ - static toRateQuotes( - response: MaerskRateSearchResponse, - originCode: string, - destinationCode: string - ): RateQuote[] { - return response.results.map((result) => this.toRateQuote(result, originCode, destinationCode)); - } - - /** - * Map single Maersk rate result to RateQuote domain entity - */ - private static toRateQuote( - result: MaerskRateResult, - originCode: string, - destinationCode: string - ): RateQuote { - const surcharges = result.pricing.charges.map((charge) => ({ - type: charge.chargeCode, - description: charge.chargeName, - amount: charge.amount, - currency: charge.currency, - })); - - const route = result.schedule.routeSchedule.map((segment) => - this.mapRouteSegment(segment) - ); - - return RateQuote.create({ - id: uuidv4(), - carrierId: 'maersk-carrier-id', // TODO: Get from carrier repository - carrierName: 'Maersk Line', - carrierCode: 'MAERSK', - origin: { - code: result.routeDetails.origin.unlocCode, - name: result.routeDetails.origin.cityName, - country: result.routeDetails.origin.countryName, - }, - destination: { - code: result.routeDetails.destination.unlocCode, - name: result.routeDetails.destination.cityName, - country: result.routeDetails.destination.countryName, - }, - pricing: { - baseFreight: result.pricing.oceanFreight, - surcharges, - totalAmount: result.pricing.totalAmount, - currency: result.pricing.currency, - }, - containerType: this.mapContainerType(result.equipment.type), - mode: 'FCL', // Maersk typically handles FCL - etd: new Date(result.routeDetails.departureDate), - eta: new Date(result.routeDetails.arrivalDate), - transitDays: result.routeDetails.transitTime, - route, - availability: result.bookingDetails.equipmentAvailability, - frequency: result.schedule.frequency, - vesselType: result.vesselInfo?.type, - co2EmissionsKg: result.sustainability?.co2Emissions, - }); - } - - /** - * Map Maersk route segment to domain format - */ - private static mapRouteSegment(segment: MaerskRouteSegment): any { - return { - portCode: segment.portCode, - portName: segment.portName, - arrival: segment.arrivalDate ? new Date(segment.arrivalDate) : undefined, - departure: segment.departureDate ? new Date(segment.departureDate) : undefined, - vesselName: segment.vesselName, - voyageNumber: segment.voyageNumber, - }; - } - - /** - * Map Maersk container type to internal format - */ - private static mapContainerType(maerskType: string): string { - // Map Maersk container types to standard format - const typeMap: { [key: string]: string } = { - '20DRY': '20DRY', - '40DRY': '40DRY', - '40HC': '40HC', - '45HC': '45HC', - '20REEFER': '20REEFER', - '40REEFER': '40REEFER', - '40HCREEFER': '40HCREEFER', - '20OT': '20OT', - '40OT': '40OT', - '20FR': '20FR', - '40FR': '40FR', - }; - - return typeMap[maerskType] || maerskType; - } -} +/** + * Maersk Response Mapper + * + * Maps Maersk API response to domain entities + */ + +import { v4 as uuidv4 } from 'uuid'; +import { RateQuote } from '../../../domain/entities/rate-quote.entity'; +import { MaerskRateSearchResponse, MaerskRateResult, MaerskRouteSegment } from './maersk.types'; + +export class MaerskResponseMapper { + /** + * Map Maersk API response to domain RateQuote entities + */ + static toRateQuotes( + response: MaerskRateSearchResponse, + originCode: string, + destinationCode: string + ): RateQuote[] { + return response.results.map(result => this.toRateQuote(result, originCode, destinationCode)); + } + + /** + * Map single Maersk rate result to RateQuote domain entity + */ + private static toRateQuote( + result: MaerskRateResult, + originCode: string, + destinationCode: string + ): RateQuote { + const surcharges = result.pricing.charges.map(charge => ({ + type: charge.chargeCode, + description: charge.chargeName, + amount: charge.amount, + currency: charge.currency, + })); + + const route = result.schedule.routeSchedule.map(segment => this.mapRouteSegment(segment)); + + return RateQuote.create({ + id: uuidv4(), + carrierId: 'maersk-carrier-id', // TODO: Get from carrier repository + carrierName: 'Maersk Line', + carrierCode: 'MAERSK', + origin: { + code: result.routeDetails.origin.unlocCode, + name: result.routeDetails.origin.cityName, + country: result.routeDetails.origin.countryName, + }, + destination: { + code: result.routeDetails.destination.unlocCode, + name: result.routeDetails.destination.cityName, + country: result.routeDetails.destination.countryName, + }, + pricing: { + baseFreight: result.pricing.oceanFreight, + surcharges, + totalAmount: result.pricing.totalAmount, + currency: result.pricing.currency, + }, + containerType: this.mapContainerType(result.equipment.type), + mode: 'FCL', // Maersk typically handles FCL + etd: new Date(result.routeDetails.departureDate), + eta: new Date(result.routeDetails.arrivalDate), + transitDays: result.routeDetails.transitTime, + route, + availability: result.bookingDetails.equipmentAvailability, + frequency: result.schedule.frequency, + vesselType: result.vesselInfo?.type, + co2EmissionsKg: result.sustainability?.co2Emissions, + }); + } + + /** + * Map Maersk route segment to domain format + */ + private static mapRouteSegment(segment: MaerskRouteSegment): any { + return { + portCode: segment.portCode, + portName: segment.portName, + arrival: segment.arrivalDate ? new Date(segment.arrivalDate) : undefined, + departure: segment.departureDate ? new Date(segment.departureDate) : undefined, + vesselName: segment.vesselName, + voyageNumber: segment.voyageNumber, + }; + } + + /** + * Map Maersk container type to internal format + */ + private static mapContainerType(maerskType: string): string { + // Map Maersk container types to standard format + const typeMap: { [key: string]: string } = { + '20DRY': '20DRY', + '40DRY': '40DRY', + '40HC': '40HC', + '45HC': '45HC', + '20REEFER': '20REEFER', + '40REEFER': '40REEFER', + '40HCREEFER': '40HCREEFER', + '20OT': '20OT', + '40OT': '40OT', + '20FR': '20FR', + '40FR': '40FR', + }; + + return typeMap[maerskType] || maerskType; + } +} diff --git a/apps/backend/src/infrastructure/carriers/maersk/maersk.connector.ts b/apps/backend/src/infrastructure/carriers/maersk/maersk.connector.ts index 2b11477..e323d3e 100644 --- a/apps/backend/src/infrastructure/carriers/maersk/maersk.connector.ts +++ b/apps/backend/src/infrastructure/carriers/maersk/maersk.connector.ts @@ -1,110 +1,110 @@ -/** - * Maersk Connector - * - * Implementation of CarrierConnectorPort for Maersk API - */ - -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { v4 as uuidv4 } from 'uuid'; -import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; -import { - CarrierRateSearchInput, - CarrierAvailabilityInput, -} from '../../../domain/ports/out/carrier-connector.port'; -import { RateQuote } from '../../../domain/entities/rate-quote.entity'; -import { MaerskRequestMapper } from './maersk-request.mapper'; -import { MaerskResponseMapper } from './maersk-response.mapper'; -import { MaerskRateSearchRequest, MaerskRateSearchResponse } from './maersk.types'; - -@Injectable() -export class MaerskConnector extends BaseCarrierConnector { - constructor(private readonly configService: ConfigService) { - const config: CarrierConfig = { - name: 'Maersk', - code: 'MAERSK', - baseUrl: configService.get('MAERSK_API_BASE_URL', 'https://api.maersk.com/v1'), - timeout: 5000, // 5 seconds - maxRetries: 2, - circuitBreakerThreshold: 50, // Open circuit after 50% failures - circuitBreakerTimeout: 30000, // Wait 30s before half-open - }; - - super(config); - } - - async searchRates(input: CarrierRateSearchInput): Promise { - try { - // Map domain input to Maersk API format - const maerskRequest = MaerskRequestMapper.toMaerskRateSearchRequest(input); - - // Make API request with circuit breaker - const response = await this.requestWithCircuitBreaker({ - method: 'POST', - url: '/rates/search', - data: maerskRequest, - headers: { - 'API-Key': this.configService.get('MAERSK_API_KEY'), - }, - }); - - // Map Maersk API response to domain entities - const rateQuotes = MaerskResponseMapper.toRateQuotes( - response.data, - input.origin, - input.destination - ); - - this.logger.log(`Found ${rateQuotes.length} rate quotes from Maersk`); - return rateQuotes; - } catch (error: any) { - this.logger.error(`Error searching Maersk rates: ${error?.message || 'Unknown error'}`); - // Return empty array instead of throwing - allows other carriers to succeed - return []; - } - } - - async checkAvailability(input: CarrierAvailabilityInput): Promise { - try { - const response = await this.requestWithCircuitBreaker<{ availability: number }>({ - method: 'POST', - url: '/availability/check', - data: { - origin: input.origin, - destination: input.destination, - containerType: input.containerType, - departureDate: input.departureDate?.toISOString() || input.startDate.toISOString(), - quantity: input.quantity, - }, - headers: { - 'API-Key': this.configService.get('MAERSK_API_KEY'), - }, - }); - - return response.data.availability; - } catch (error: any) { - this.logger.error(`Error checking Maersk availability: ${error?.message || 'Unknown error'}`); - return 0; - } - } - - /** - * Override health check to use Maersk-specific endpoint - */ - async healthCheck(): Promise { - try { - await this.requestWithCircuitBreaker({ - method: 'GET', - url: '/status', - timeout: 3000, - headers: { - 'API-Key': this.configService.get('MAERSK_API_KEY'), - }, - }); - return true; - } catch (error: any) { - this.logger.warn(`Maersk health check failed: ${error?.message || 'Unknown error'}`); - return false; - } - } -} +/** + * Maersk Connector + * + * Implementation of CarrierConnectorPort for Maersk API + */ + +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { v4 as uuidv4 } from 'uuid'; +import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; +import { + CarrierRateSearchInput, + CarrierAvailabilityInput, +} from '../../../domain/ports/out/carrier-connector.port'; +import { RateQuote } from '../../../domain/entities/rate-quote.entity'; +import { MaerskRequestMapper } from './maersk-request.mapper'; +import { MaerskResponseMapper } from './maersk-response.mapper'; +import { MaerskRateSearchRequest, MaerskRateSearchResponse } from './maersk.types'; + +@Injectable() +export class MaerskConnector extends BaseCarrierConnector { + constructor(private readonly configService: ConfigService) { + const config: CarrierConfig = { + name: 'Maersk', + code: 'MAERSK', + baseUrl: configService.get('MAERSK_API_BASE_URL', 'https://api.maersk.com/v1'), + timeout: 5000, // 5 seconds + maxRetries: 2, + circuitBreakerThreshold: 50, // Open circuit after 50% failures + circuitBreakerTimeout: 30000, // Wait 30s before half-open + }; + + super(config); + } + + async searchRates(input: CarrierRateSearchInput): Promise { + try { + // Map domain input to Maersk API format + const maerskRequest = MaerskRequestMapper.toMaerskRateSearchRequest(input); + + // Make API request with circuit breaker + const response = await this.requestWithCircuitBreaker({ + method: 'POST', + url: '/rates/search', + data: maerskRequest, + headers: { + 'API-Key': this.configService.get('MAERSK_API_KEY'), + }, + }); + + // Map Maersk API response to domain entities + const rateQuotes = MaerskResponseMapper.toRateQuotes( + response.data, + input.origin, + input.destination + ); + + this.logger.log(`Found ${rateQuotes.length} rate quotes from Maersk`); + return rateQuotes; + } catch (error: any) { + this.logger.error(`Error searching Maersk rates: ${error?.message || 'Unknown error'}`); + // Return empty array instead of throwing - allows other carriers to succeed + return []; + } + } + + async checkAvailability(input: CarrierAvailabilityInput): Promise { + try { + const response = await this.requestWithCircuitBreaker<{ availability: number }>({ + method: 'POST', + url: '/availability/check', + data: { + origin: input.origin, + destination: input.destination, + containerType: input.containerType, + departureDate: input.departureDate?.toISOString() || input.startDate.toISOString(), + quantity: input.quantity, + }, + headers: { + 'API-Key': this.configService.get('MAERSK_API_KEY'), + }, + }); + + return response.data.availability; + } catch (error: any) { + this.logger.error(`Error checking Maersk availability: ${error?.message || 'Unknown error'}`); + return 0; + } + } + + /** + * Override health check to use Maersk-specific endpoint + */ + async healthCheck(): Promise { + try { + await this.requestWithCircuitBreaker({ + method: 'GET', + url: '/status', + timeout: 3000, + headers: { + 'API-Key': this.configService.get('MAERSK_API_KEY'), + }, + }); + return true; + } catch (error: any) { + this.logger.warn(`Maersk health check failed: ${error?.message || 'Unknown error'}`); + return false; + } + } +} diff --git a/apps/backend/src/infrastructure/carriers/maersk/maersk.types.ts b/apps/backend/src/infrastructure/carriers/maersk/maersk.types.ts index 3965e66..b9ad3fd 100644 --- a/apps/backend/src/infrastructure/carriers/maersk/maersk.types.ts +++ b/apps/backend/src/infrastructure/carriers/maersk/maersk.types.ts @@ -1,110 +1,110 @@ -/** - * Maersk API Types - * - * Type definitions for Maersk API requests and responses - */ - -export interface MaerskRateSearchRequest { - originPortCode: string; - destinationPortCode: string; - containerSize: string; // '20', '40', '45' - containerType: string; // 'DRY', 'REEFER', etc. - cargoMode: 'FCL' | 'LCL'; - estimatedDepartureDate: string; // ISO 8601 - numberOfContainers?: number; - cargoWeight?: number; // kg - cargoVolume?: number; // CBM - isDangerousGoods?: boolean; - imoClass?: string; -} - -export interface MaerskRateSearchResponse { - searchId: string; - searchDate: string; - results: MaerskRateResult[]; -} - -export interface MaerskRateResult { - quoteId: string; - routeDetails: { - origin: MaerskPort; - destination: MaerskPort; - transitTime: number; // days - departureDate: string; // ISO 8601 - arrivalDate: string; // ISO 8601 - }; - pricing: { - oceanFreight: number; - currency: string; - charges: MaerskCharge[]; - totalAmount: number; - }; - equipment: { - type: string; - quantity: number; - }; - schedule: { - routeSchedule: MaerskRouteSegment[]; - frequency: string; - serviceString: string; - }; - vesselInfo?: { - name: string; - type: string; - operator: string; - }; - bookingDetails: { - validUntil: string; // ISO 8601 - equipmentAvailability: number; - }; - sustainability?: { - co2Emissions: number; // kg - co2PerTEU: number; - }; -} - -export interface MaerskPort { - unlocCode: string; - cityName: string; - countryName: string; - countryCode: string; -} - -export interface MaerskCharge { - chargeCode: string; - chargeName: string; - amount: number; - currency: string; -} - -export interface MaerskRouteSegment { - sequenceNumber: number; - portCode: string; - portName: string; - countryCode: string; - arrivalDate?: string; - departureDate?: string; - vesselName?: string; - voyageNumber?: string; - transportMode: 'VESSEL' | 'TRUCK' | 'RAIL'; -} - -export interface MaerskAvailabilityRequest { - origin: string; - destination: string; - containerType: string; - departureDate: string; - quantity: number; -} - -export interface MaerskAvailabilityResponse { - availability: number; - validUntil: string; -} - -export interface MaerskErrorResponse { - errorCode: string; - errorMessage: string; - timestamp: string; - path: string; -} +/** + * Maersk API Types + * + * Type definitions for Maersk API requests and responses + */ + +export interface MaerskRateSearchRequest { + originPortCode: string; + destinationPortCode: string; + containerSize: string; // '20', '40', '45' + containerType: string; // 'DRY', 'REEFER', etc. + cargoMode: 'FCL' | 'LCL'; + estimatedDepartureDate: string; // ISO 8601 + numberOfContainers?: number; + cargoWeight?: number; // kg + cargoVolume?: number; // CBM + isDangerousGoods?: boolean; + imoClass?: string; +} + +export interface MaerskRateSearchResponse { + searchId: string; + searchDate: string; + results: MaerskRateResult[]; +} + +export interface MaerskRateResult { + quoteId: string; + routeDetails: { + origin: MaerskPort; + destination: MaerskPort; + transitTime: number; // days + departureDate: string; // ISO 8601 + arrivalDate: string; // ISO 8601 + }; + pricing: { + oceanFreight: number; + currency: string; + charges: MaerskCharge[]; + totalAmount: number; + }; + equipment: { + type: string; + quantity: number; + }; + schedule: { + routeSchedule: MaerskRouteSegment[]; + frequency: string; + serviceString: string; + }; + vesselInfo?: { + name: string; + type: string; + operator: string; + }; + bookingDetails: { + validUntil: string; // ISO 8601 + equipmentAvailability: number; + }; + sustainability?: { + co2Emissions: number; // kg + co2PerTEU: number; + }; +} + +export interface MaerskPort { + unlocCode: string; + cityName: string; + countryName: string; + countryCode: string; +} + +export interface MaerskCharge { + chargeCode: string; + chargeName: string; + amount: number; + currency: string; +} + +export interface MaerskRouteSegment { + sequenceNumber: number; + portCode: string; + portName: string; + countryCode: string; + arrivalDate?: string; + departureDate?: string; + vesselName?: string; + voyageNumber?: string; + transportMode: 'VESSEL' | 'TRUCK' | 'RAIL'; +} + +export interface MaerskAvailabilityRequest { + origin: string; + destination: string; + containerType: string; + departureDate: string; + quantity: number; +} + +export interface MaerskAvailabilityResponse { + availability: number; + validUntil: string; +} + +export interface MaerskErrorResponse { + errorCode: string; + errorMessage: string; + timestamp: string; + path: string; +} diff --git a/apps/backend/src/infrastructure/carriers/msc/msc.connector.ts b/apps/backend/src/infrastructure/carriers/msc/msc.connector.ts index 1020a62..348919c 100644 --- a/apps/backend/src/infrastructure/carriers/msc/msc.connector.ts +++ b/apps/backend/src/infrastructure/carriers/msc/msc.connector.ts @@ -9,23 +9,20 @@ import { ConfigService } from '@nestjs/config'; import { CarrierConnectorPort, CarrierRateSearchInput, - CarrierAvailabilityInput + CarrierAvailabilityInput, } from '../../../domain/ports/out/carrier-connector.port'; import { RateQuote } from '../../../domain/entities/rate-quote.entity'; import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; import { MSCRequestMapper } from './msc.mapper'; @Injectable() -export class MSCConnectorAdapter - extends BaseCarrierConnector - implements CarrierConnectorPort -{ +export class MSCConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort { private readonly apiUrl: string; private readonly apiKey: string; constructor( private readonly configService: ConfigService, - private readonly requestMapper: MSCRequestMapper, + private readonly requestMapper: MSCRequestMapper ) { const config: CarrierConfig = { name: 'MSC', diff --git a/apps/backend/src/infrastructure/carriers/msc/msc.mapper.ts b/apps/backend/src/infrastructure/carriers/msc/msc.mapper.ts index 6f70ea6..c1c4420 100644 --- a/apps/backend/src/infrastructure/carriers/msc/msc.mapper.ts +++ b/apps/backend/src/infrastructure/carriers/msc/msc.mapper.ts @@ -58,7 +58,7 @@ export class MSCRequestMapper { amount: quote.surcharges?.pss || 0, currency: quote.currency || 'USD', }, - ].filter((s) => s.amount > 0); + ].filter(s => s.amount > 0); const totalSurcharges = surcharges.reduce((sum, s) => sum + s.amount, 0); const baseFreight = quote.ocean_freight || 0; diff --git a/apps/backend/src/infrastructure/carriers/one/one.connector.ts b/apps/backend/src/infrastructure/carriers/one/one.connector.ts index fb8df43..389a86b 100644 --- a/apps/backend/src/infrastructure/carriers/one/one.connector.ts +++ b/apps/backend/src/infrastructure/carriers/one/one.connector.ts @@ -9,24 +9,21 @@ import { ConfigService } from '@nestjs/config'; import { CarrierConnectorPort, CarrierRateSearchInput, - CarrierAvailabilityInput + CarrierAvailabilityInput, } from '../../../domain/ports/out/carrier-connector.port'; import { RateQuote } from '../../../domain/entities/rate-quote.entity'; import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector'; import { ONERequestMapper } from './one.mapper'; @Injectable() -export class ONEConnectorAdapter - extends BaseCarrierConnector - implements CarrierConnectorPort -{ +export class ONEConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort { private readonly apiUrl: string; private readonly username: string; private readonly password: string; constructor( private readonly configService: ConfigService, - private readonly requestMapper: ONERequestMapper, + private readonly requestMapper: ONERequestMapper ) { const config: CarrierConfig = { name: 'ONE', diff --git a/apps/backend/src/infrastructure/carriers/one/one.mapper.ts b/apps/backend/src/infrastructure/carriers/one/one.mapper.ts index 75a24c1..07e0046 100644 --- a/apps/backend/src/infrastructure/carriers/one/one.mapper.ts +++ b/apps/backend/src/infrastructure/carriers/one/one.mapper.ts @@ -78,7 +78,8 @@ export class ONERequestMapper { arrival: new Date(quote.arrival_date), }); - const transitDays = quote.transit_days || this.calculateTransitDays(quote.departure_date, quote.arrival_date); + const transitDays = + quote.transit_days || this.calculateTransitDays(quote.departure_date, quote.arrival_date); return RateQuote.create({ id: uuidv4(), @@ -130,7 +131,7 @@ export class ONERequestMapper { private formatChargeName(key: string): string { return key .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index bc5f576..404f446 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -7,10 +7,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; -import { - EmailPort, - EmailOptions, -} from '../../domain/ports/out/email.port'; +import { EmailPort, EmailOptions } from '../../domain/ports/out/email.port'; import { EmailTemplates } from './templates/email-templates'; @Injectable() @@ -39,17 +36,12 @@ export class EmailAdapter implements EmailPort { auth: user && pass ? { user, pass } : undefined, }); - this.logger.log( - `Email adapter initialized with SMTP host: ${host}:${port}` - ); + this.logger.log(`Email adapter initialized with SMTP host: ${host}:${port}`); } async send(options: EmailOptions): Promise { try { - const from = this.configService.get( - 'SMTP_FROM', - 'noreply@xpeditis.com' - ); + const from = this.configService.get('SMTP_FROM', 'noreply@xpeditis.com'); await this.transporter.sendMail({ from, diff --git a/apps/backend/src/infrastructure/email/templates/email-templates.ts b/apps/backend/src/infrastructure/email/templates/email-templates.ts index 25646e8..2492f4d 100644 --- a/apps/backend/src/infrastructure/email/templates/email-templates.ts +++ b/apps/backend/src/infrastructure/email/templates/email-templates.ts @@ -155,10 +155,7 @@ export class EmailTemplates { /** * Render welcome email */ - async renderWelcomeEmail(data: { - firstName: string; - dashboardUrl: string; - }): Promise { + async renderWelcomeEmail(data: { firstName: string; dashboardUrl: string }): Promise { const mjmlTemplate = ` diff --git a/apps/backend/src/infrastructure/monitoring/sentry.config.ts b/apps/backend/src/infrastructure/monitoring/sentry.config.ts index 5f96e1c..2d84c6f 100644 --- a/apps/backend/src/infrastructure/monitoring/sentry.config.ts +++ b/apps/backend/src/infrastructure/monitoring/sentry.config.ts @@ -23,9 +23,7 @@ export function initializeSentry(config: SentryConfig): void { Sentry.init({ dsn: config.dsn, environment: config.environment, - integrations: [ - nodeProfilingIntegration(), - ], + integrations: [nodeProfilingIntegration()], // Performance Monitoring tracesSampleRate: config.tracesSampleRate, // Profiling @@ -58,9 +56,7 @@ export function initializeSentry(config: SentryConfig): void { maxBreadcrumbs: 50, }); - console.log( - `✅ Sentry monitoring initialized for ${config.environment} environment`, - ); + console.log(`✅ Sentry monitoring initialized for ${config.environment} environment`); } /** @@ -68,7 +64,7 @@ export function initializeSentry(config: SentryConfig): void { */ export function captureException(error: Error, context?: Record) { if (context) { - Sentry.withScope((scope) => { + Sentry.withScope(scope => { Object.entries(context).forEach(([key, value]) => { scope.setExtra(key, value); }); @@ -85,10 +81,10 @@ export function captureException(error: Error, context?: Record) { export function captureMessage( message: string, level: Sentry.SeverityLevel = 'info', - context?: Record, + context?: Record ) { if (context) { - Sentry.withScope((scope) => { + Sentry.withScope(scope => { Object.entries(context).forEach(([key, value]) => { scope.setExtra(key, value); }); @@ -106,7 +102,7 @@ export function addBreadcrumb( category: string, message: string, data?: Record, - level: Sentry.SeverityLevel = 'info', + level: Sentry.SeverityLevel = 'info' ) { Sentry.addBreadcrumb({ category, diff --git a/apps/backend/src/infrastructure/pdf/pdf.adapter.ts b/apps/backend/src/infrastructure/pdf/pdf.adapter.ts index cfe8c68..fe17d3c 100644 --- a/apps/backend/src/infrastructure/pdf/pdf.adapter.ts +++ b/apps/backend/src/infrastructure/pdf/pdf.adapter.ts @@ -24,17 +24,12 @@ export class PdfAdapter implements PdfPort { doc.on('data', buffers.push.bind(buffers)); doc.on('end', () => { const pdfBuffer = Buffer.concat(buffers); - this.logger.log( - `Generated booking confirmation PDF for ${data.bookingNumber}` - ); + this.logger.log(`Generated booking confirmation PDF for ${data.bookingNumber}`); resolve(pdfBuffer); }); // Header - doc - .fontSize(24) - .fillColor('#0066cc') - .text('BOOKING CONFIRMATION', { align: 'center' }); + doc.fontSize(24).fillColor('#0066cc').text('BOOKING CONFIRMATION', { align: 'center' }); doc.moveDown(); @@ -60,9 +55,7 @@ export class PdfAdapter implements PdfPort { doc.fontSize(12).fillColor('#333333'); doc.text(`Origin: ${data.origin.name} (${data.origin.code})`); - doc.text( - `Destination: ${data.destination.name} (${data.destination.code})` - ); + doc.text(`Destination: ${data.destination.name} (${data.destination.code})`); doc.text(`Carrier: ${data.carrier.name}`); doc.text(`ETD: ${data.etd.toLocaleDateString()}`); doc.text(`ETA: ${data.eta.toLocaleDateString()}`); @@ -105,9 +98,7 @@ export class PdfAdapter implements PdfPort { doc.fontSize(12).fillColor('#333333'); data.containers.forEach((container, index) => { - doc.text( - `${index + 1}. Type: ${container.type} | Quantity: ${container.quantity}` - ); + doc.text(`${index + 1}. Type: ${container.type} | Quantity: ${container.quantity}`); if (container.containerNumber) { doc.text(` Container #: ${container.containerNumber}`); } @@ -127,16 +118,10 @@ export class PdfAdapter implements PdfPort { if (data.specialInstructions) { doc.moveDown(); - doc - .fontSize(14) - .fillColor('#0066cc') - .text('Special Instructions'); + doc.fontSize(14).fillColor('#0066cc').text('Special Instructions'); doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke(); doc.moveDown(); - doc - .fontSize(12) - .fillColor('#333333') - .text(data.specialInstructions); + doc.fontSize(12).fillColor('#333333').text(data.specialInstructions); } doc.moveDown(2); @@ -149,10 +134,9 @@ export class PdfAdapter implements PdfPort { doc .fontSize(16) .fillColor('#333333') - .text( - `${data.price.currency} ${data.price.amount.toLocaleString()}`, - { align: 'center' } - ); + .text(`${data.price.currency} ${data.price.amount.toLocaleString()}`, { + align: 'center', + }); doc.moveDown(3); @@ -160,10 +144,7 @@ export class PdfAdapter implements PdfPort { doc .fontSize(10) .fillColor('#666666') - .text( - 'This is a system-generated document. No signature required.', - { align: 'center' } - ); + .text('This is a system-generated document. No signature required.', { align: 'center' }); doc.text('© 2025 Xpeditis. All rights reserved.', { align: 'center' }); @@ -193,10 +174,7 @@ export class PdfAdapter implements PdfPort { }); // Header - doc - .fontSize(20) - .fillColor('#0066cc') - .text('RATE QUOTE COMPARISON', { align: 'center' }); + doc.fontSize(20).fillColor('#0066cc').text('RATE QUOTE COMPARISON', { align: 'center' }); doc.moveDown(2); @@ -210,20 +188,18 @@ export class PdfAdapter implements PdfPort { doc.text('ETA', 430, startY, { width: 80 }); doc.text('Route', 520, startY, { width: 200 }); - doc.moveTo(50, doc.y + 5).lineTo(750, doc.y + 5).stroke(); + doc + .moveTo(50, doc.y + 5) + .lineTo(750, doc.y + 5) + .stroke(); doc.moveDown(); // Table Rows doc.fontSize(9).fillColor('#333333'); - quotes.forEach((quote) => { + quotes.forEach(quote => { const rowY = doc.y; doc.text(quote.carrier.name, 50, rowY, { width: 100 }); - doc.text( - `${quote.price.currency} ${quote.price.amount}`, - 160, - rowY, - { width: 80 } - ); + doc.text(`${quote.price.currency} ${quote.price.amount}`, 160, rowY, { width: 80 }); doc.text(quote.transitDays.toString(), 250, rowY, { width: 80 }); doc.text(new Date(quote.etd).toLocaleDateString(), 340, rowY, { width: 80, @@ -240,10 +216,7 @@ export class PdfAdapter implements PdfPort { doc.moveDown(2); // Footer - doc - .fontSize(10) - .fillColor('#666666') - .text('Generated by Xpeditis', { align: 'center' }); + doc.fontSize(10).fillColor('#666666').text('Generated by Xpeditis', { align: 'center' }); doc.end(); } catch (error) { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts b/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts index 0a51df0..9b0f5a6 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts @@ -1,27 +1,27 @@ -/** - * TypeORM Data Source Configuration - * - * Used for migrations and CLI commands - */ - -import { DataSource } from 'typeorm'; -import { config } from 'dotenv'; -import { join } from 'path'; - -// Load environment variables -config(); - -export const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432', 10), - username: process.env.DATABASE_USER || 'xpeditis', - password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', - database: process.env.DATABASE_NAME || 'xpeditis_dev', - entities: [join(__dirname, 'entities', '*.orm-entity.{ts,js}')], - migrations: [join(__dirname, 'migrations', '*.{ts,js}')], - subscribers: [], - synchronize: false, // Never use in production - logging: process.env.NODE_ENV === 'development', - ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false, -}); +/** + * TypeORM Data Source Configuration + * + * Used for migrations and CLI commands + */ + +import { DataSource } from 'typeorm'; +import { config } from 'dotenv'; +import { join } from 'path'; + +// Load environment variables +config(); + +export const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5432', 10), + username: process.env.DATABASE_USER || 'xpeditis', + password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password', + database: process.env.DATABASE_NAME || 'xpeditis_dev', + entities: [join(__dirname, 'entities', '*.orm-entity.{ts,js}')], + migrations: [join(__dirname, 'migrations', '*.{ts,js}')], + subscribers: [], + synchronize: false, // Never use in production + logging: process.env.NODE_ENV === 'development', + ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false, +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts index 686da48..d78c7d7 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts @@ -83,7 +83,7 @@ export class BookingOrmEntity { @Column({ name: 'cargo_description', type: 'text' }) cargoDescription: string; - @OneToMany(() => ContainerOrmEntity, (container) => container.booking, { + @OneToMany(() => ContainerOrmEntity, container => container.booking, { cascade: true, eager: true, }) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier.orm-entity.ts index 44ce1a8..e85ccbf 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier.orm-entity.ts @@ -1,47 +1,47 @@ -/** - * Carrier ORM Entity (Infrastructure Layer) - * - * TypeORM entity for carrier persistence - */ - -import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; - -@Entity('carriers') -@Index('idx_carriers_code', ['code']) -@Index('idx_carriers_scac', ['scac']) -@Index('idx_carriers_active', ['isActive']) -@Index('idx_carriers_supports_api', ['supportsApi']) -export class CarrierOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ type: 'varchar', length: 255 }) - name: string; - - @Column({ type: 'varchar', length: 50, unique: true }) - code: string; - - @Column({ type: 'char', length: 4, unique: true }) - scac: string; - - @Column({ name: 'logo_url', type: 'text', nullable: true }) - logoUrl: string | null; - - @Column({ type: 'text', nullable: true }) - website: string | null; - - @Column({ name: 'api_config', type: 'jsonb', nullable: true }) - apiConfig: any | null; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @Column({ name: 'supports_api', type: 'boolean', default: false }) - supportsApi: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * Carrier ORM Entity (Infrastructure Layer) + * + * TypeORM entity for carrier persistence + */ + +import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity('carriers') +@Index('idx_carriers_code', ['code']) +@Index('idx_carriers_scac', ['scac']) +@Index('idx_carriers_active', ['isActive']) +@Index('idx_carriers_supports_api', ['supportsApi']) +export class CarrierOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + code: string; + + @Column({ type: 'char', length: 4, unique: true }) + scac: string; + + @Column({ name: 'logo_url', type: 'text', nullable: true }) + logoUrl: string | null; + + @Column({ type: 'text', nullable: true }) + website: string | null; + + @Column({ name: 'api_config', type: 'jsonb', nullable: true }) + apiConfig: any | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'supports_api', type: 'boolean', default: false }) + supportsApi: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts index 51efa76..630e502 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/container.orm-entity.ts @@ -4,14 +4,7 @@ * TypeORM entity for container persistence */ -import { - Entity, - Column, - PrimaryColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; import { BookingOrmEntity } from './booking.orm-entity'; @Entity('containers') @@ -24,7 +17,7 @@ export class ContainerOrmEntity { @Column({ name: 'booking_id', type: 'uuid' }) bookingId: string; - @ManyToOne(() => BookingOrmEntity, (booking) => booking.containers, { + @ManyToOne(() => BookingOrmEntity, booking => booking.containers, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'booking_id' }) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts index 97b266a..a40b036 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts @@ -1,70 +1,70 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { UserOrmEntity } from './user.orm-entity'; - -/** - * CSV Rate Config ORM Entity - * - * Stores configuration for CSV-based shipping rates - * Maps company names to their CSV files - */ -@Entity('csv_rate_configs') -export class CsvRateConfigOrmEntity { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'company_name', type: 'varchar', length: 255, unique: true }) - companyName: string; - - @Column({ name: 'csv_file_path', type: 'varchar', length: 500 }) - csvFilePath: string; - - @Column({ - name: 'type', - type: 'varchar', - length: 50, - default: 'CSV_ONLY', - }) - type: 'CSV_ONLY' | 'CSV_AND_API'; - - @Column({ name: 'has_api', type: 'boolean', default: false }) - hasApi: boolean; - - @Column({ name: 'api_connector', type: 'varchar', length: 100, nullable: true }) - apiConnector: string | null; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @Column({ name: 'uploaded_at', type: 'timestamp', default: () => 'NOW()' }) - uploadedAt: Date; - - @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) - uploadedBy: string | null; - - @ManyToOne(() => UserOrmEntity, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'uploaded_by' }) - uploader: UserOrmEntity | null; - - @Column({ name: 'last_validated_at', type: 'timestamp', nullable: true }) - lastValidatedAt: Date | null; - - @Column({ name: 'row_count', type: 'integer', nullable: true }) - rowCount: number | null; - - @Column({ name: 'metadata', type: 'jsonb', nullable: true }) - metadata: Record | null; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UserOrmEntity } from './user.orm-entity'; + +/** + * CSV Rate Config ORM Entity + * + * Stores configuration for CSV-based shipping rates + * Maps company names to their CSV files + */ +@Entity('csv_rate_configs') +export class CsvRateConfigOrmEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'company_name', type: 'varchar', length: 255, unique: true }) + companyName: string; + + @Column({ name: 'csv_file_path', type: 'varchar', length: 500 }) + csvFilePath: string; + + @Column({ + name: 'type', + type: 'varchar', + length: 50, + default: 'CSV_ONLY', + }) + type: 'CSV_ONLY' | 'CSV_AND_API'; + + @Column({ name: 'has_api', type: 'boolean', default: false }) + hasApi: boolean; + + @Column({ name: 'api_connector', type: 'varchar', length: 100, nullable: true }) + apiConnector: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'uploaded_at', type: 'timestamp', default: () => 'NOW()' }) + uploadedAt: Date; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy: string | null; + + @ManyToOne(() => UserOrmEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'uploaded_by' }) + uploader: UserOrmEntity | null; + + @Column({ name: 'last_validated_at', type: 'timestamp', nullable: true }) + lastValidatedAt: Date | null; + + @Column({ name: 'row_count', type: 'integer', nullable: true }) + rowCount: number | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts index 32bbbc0..31d7f2c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts @@ -1,12 +1,12 @@ -/** - * TypeORM Entities Barrel Export - * - * All ORM entities for persistence layer - */ - -export * from './organization.orm-entity'; -export * from './user.orm-entity'; -export * from './carrier.orm-entity'; -export * from './port.orm-entity'; -export * from './rate-quote.orm-entity'; -export * from './csv-rate-config.orm-entity'; +/** + * TypeORM Entities Barrel Export + * + * All ORM entities for persistence layer + */ + +export * from './organization.orm-entity'; +export * from './user.orm-entity'; +export * from './carrier.orm-entity'; +export * from './port.orm-entity'; +export * from './rate-quote.orm-entity'; +export * from './csv-rate-config.orm-entity'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts index 2c6fea7..e114838 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts @@ -2,13 +2,7 @@ * Notification ORM Entity */ -import { - Entity, - PrimaryColumn, - Column, - CreateDateColumn, - Index, -} from 'typeorm'; +import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm'; @Entity('notifications') @Index(['user_id', 'read', 'created_at']) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts index 3e82dda..2a75d7e 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts @@ -1,55 +1,55 @@ -/** - * Organization ORM Entity (Infrastructure Layer) - * - * TypeORM entity for organization persistence - */ - -import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; - -@Entity('organizations') -@Index('idx_organizations_type', ['type']) -@Index('idx_organizations_scac', ['scac']) -@Index('idx_organizations_active', ['isActive']) -export class OrganizationOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ type: 'varchar', length: 255, unique: true }) - name: string; - - @Column({ type: 'varchar', length: 50 }) - type: string; - - @Column({ type: 'char', length: 4, nullable: true, unique: true }) - scac: string | null; - - @Column({ name: 'address_street', type: 'varchar', length: 255 }) - addressStreet: string; - - @Column({ name: 'address_city', type: 'varchar', length: 100 }) - addressCity: string; - - @Column({ name: 'address_state', type: 'varchar', length: 100, nullable: true }) - addressState: string | null; - - @Column({ name: 'address_postal_code', type: 'varchar', length: 20 }) - addressPostalCode: string; - - @Column({ name: 'address_country', type: 'char', length: 2 }) - addressCountry: string; - - @Column({ name: 'logo_url', type: 'text', nullable: true }) - logoUrl: string | null; - - @Column({ type: 'jsonb', default: '[]' }) - documents: any[]; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * Organization ORM Entity (Infrastructure Layer) + * + * TypeORM entity for organization persistence + */ + +import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity('organizations') +@Index('idx_organizations_type', ['type']) +@Index('idx_organizations_scac', ['scac']) +@Index('idx_organizations_active', ['isActive']) +export class OrganizationOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255, unique: true }) + name: string; + + @Column({ type: 'varchar', length: 50 }) + type: string; + + @Column({ type: 'char', length: 4, nullable: true, unique: true }) + scac: string | null; + + @Column({ name: 'address_street', type: 'varchar', length: 255 }) + addressStreet: string; + + @Column({ name: 'address_city', type: 'varchar', length: 100 }) + addressCity: string; + + @Column({ name: 'address_state', type: 'varchar', length: 100, nullable: true }) + addressState: string | null; + + @Column({ name: 'address_postal_code', type: 'varchar', length: 20 }) + addressPostalCode: string; + + @Column({ name: 'address_country', type: 'char', length: 2 }) + addressCountry: string; + + @Column({ name: 'logo_url', type: 'text', nullable: true }) + logoUrl: string | null; + + @Column({ type: 'jsonb', default: '[]' }) + documents: any[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/port.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/port.orm-entity.ts index a0e9644..2968e81 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/port.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/port.orm-entity.ts @@ -1,52 +1,52 @@ -/** - * Port ORM Entity (Infrastructure Layer) - * - * TypeORM entity for port persistence - */ - -import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; - -@Entity('ports') -@Index('idx_ports_code', ['code']) -@Index('idx_ports_country', ['country']) -@Index('idx_ports_active', ['isActive']) -@Index('idx_ports_name_trgm', ['name']) -@Index('idx_ports_city_trgm', ['city']) -@Index('idx_ports_coordinates', ['latitude', 'longitude']) -export class PortOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ type: 'char', length: 5, unique: true }) - code: string; - - @Column({ type: 'varchar', length: 255 }) - name: string; - - @Column({ type: 'varchar', length: 255 }) - city: string; - - @Column({ type: 'char', length: 2 }) - country: string; - - @Column({ name: 'country_name', type: 'varchar', length: 100 }) - countryName: string; - - @Column({ type: 'decimal', precision: 9, scale: 6 }) - latitude: number; - - @Column({ type: 'decimal', precision: 9, scale: 6 }) - longitude: number; - - @Column({ type: 'varchar', length: 50, nullable: true }) - timezone: string | null; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * Port ORM Entity (Infrastructure Layer) + * + * TypeORM entity for port persistence + */ + +import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity('ports') +@Index('idx_ports_code', ['code']) +@Index('idx_ports_country', ['country']) +@Index('idx_ports_active', ['isActive']) +@Index('idx_ports_name_trgm', ['name']) +@Index('idx_ports_city_trgm', ['city']) +@Index('idx_ports_coordinates', ['latitude', 'longitude']) +export class PortOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ type: 'char', length: 5, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'varchar', length: 255 }) + city: string; + + @Column({ type: 'char', length: 2 }) + country: string; + + @Column({ name: 'country_name', type: 'varchar', length: 100 }) + countryName: string; + + @Column({ type: 'decimal', precision: 9, scale: 6 }) + latitude: number; + + @Column({ type: 'decimal', precision: 9, scale: 6 }) + longitude: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + timezone: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/rate-quote.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/rate-quote.orm-entity.ts index 973f6b8..69b8fe6 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/rate-quote.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/rate-quote.orm-entity.ts @@ -1,112 +1,112 @@ -/** - * RateQuote ORM Entity (Infrastructure Layer) - * - * TypeORM entity for rate quote persistence - */ - -import { - Entity, - Column, - PrimaryColumn, - CreateDateColumn, - UpdateDateColumn, - Index, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { CarrierOrmEntity } from './carrier.orm-entity'; - -@Entity('rate_quotes') -@Index('idx_rate_quotes_carrier', ['carrierId']) -@Index('idx_rate_quotes_origin_dest', ['originCode', 'destinationCode']) -@Index('idx_rate_quotes_container_type', ['containerType']) -@Index('idx_rate_quotes_etd', ['etd']) -@Index('idx_rate_quotes_valid_until', ['validUntil']) -@Index('idx_rate_quotes_created_at', ['createdAt']) -@Index('idx_rate_quotes_search', ['originCode', 'destinationCode', 'containerType', 'etd']) -export class RateQuoteOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ name: 'carrier_id', type: 'uuid' }) - carrierId: string; - - @ManyToOne(() => CarrierOrmEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'carrier_id' }) - carrier: CarrierOrmEntity; - - @Column({ name: 'carrier_name', type: 'varchar', length: 255 }) - carrierName: string; - - @Column({ name: 'carrier_code', type: 'varchar', length: 50 }) - carrierCode: string; - - @Column({ name: 'origin_code', type: 'char', length: 5 }) - originCode: string; - - @Column({ name: 'origin_name', type: 'varchar', length: 255 }) - originName: string; - - @Column({ name: 'origin_country', type: 'varchar', length: 100 }) - originCountry: string; - - @Column({ name: 'destination_code', type: 'char', length: 5 }) - destinationCode: string; - - @Column({ name: 'destination_name', type: 'varchar', length: 255 }) - destinationName: string; - - @Column({ name: 'destination_country', type: 'varchar', length: 100 }) - destinationCountry: string; - - @Column({ name: 'base_freight', type: 'decimal', precision: 10, scale: 2 }) - baseFreight: number; - - @Column({ type: 'jsonb', default: '[]' }) - surcharges: any[]; - - @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2 }) - totalAmount: number; - - @Column({ type: 'char', length: 3 }) - currency: string; - - @Column({ name: 'container_type', type: 'varchar', length: 20 }) - containerType: string; - - @Column({ type: 'varchar', length: 10 }) - mode: string; - - @Column({ type: 'timestamp' }) - etd: Date; - - @Column({ type: 'timestamp' }) - eta: Date; - - @Column({ name: 'transit_days', type: 'integer' }) - transitDays: number; - - @Column({ type: 'jsonb' }) - route: any[]; - - @Column({ type: 'integer' }) - availability: number; - - @Column({ type: 'varchar', length: 50 }) - frequency: string; - - @Column({ name: 'vessel_type', type: 'varchar', length: 100, nullable: true }) - vesselType: string | null; - - @Column({ name: 'co2_emissions_kg', type: 'integer', nullable: true }) - co2EmissionsKg: number | null; - - @Column({ name: 'valid_until', type: 'timestamp' }) - validUntil: Date; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * RateQuote ORM Entity (Infrastructure Layer) + * + * TypeORM entity for rate quote persistence + */ + +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { CarrierOrmEntity } from './carrier.orm-entity'; + +@Entity('rate_quotes') +@Index('idx_rate_quotes_carrier', ['carrierId']) +@Index('idx_rate_quotes_origin_dest', ['originCode', 'destinationCode']) +@Index('idx_rate_quotes_container_type', ['containerType']) +@Index('idx_rate_quotes_etd', ['etd']) +@Index('idx_rate_quotes_valid_until', ['validUntil']) +@Index('idx_rate_quotes_created_at', ['createdAt']) +@Index('idx_rate_quotes_search', ['originCode', 'destinationCode', 'containerType', 'etd']) +export class RateQuoteOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'carrier_id', type: 'uuid' }) + carrierId: string; + + @ManyToOne(() => CarrierOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'carrier_id' }) + carrier: CarrierOrmEntity; + + @Column({ name: 'carrier_name', type: 'varchar', length: 255 }) + carrierName: string; + + @Column({ name: 'carrier_code', type: 'varchar', length: 50 }) + carrierCode: string; + + @Column({ name: 'origin_code', type: 'char', length: 5 }) + originCode: string; + + @Column({ name: 'origin_name', type: 'varchar', length: 255 }) + originName: string; + + @Column({ name: 'origin_country', type: 'varchar', length: 100 }) + originCountry: string; + + @Column({ name: 'destination_code', type: 'char', length: 5 }) + destinationCode: string; + + @Column({ name: 'destination_name', type: 'varchar', length: 255 }) + destinationName: string; + + @Column({ name: 'destination_country', type: 'varchar', length: 100 }) + destinationCountry: string; + + @Column({ name: 'base_freight', type: 'decimal', precision: 10, scale: 2 }) + baseFreight: number; + + @Column({ type: 'jsonb', default: '[]' }) + surcharges: any[]; + + @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2 }) + totalAmount: number; + + @Column({ type: 'char', length: 3 }) + currency: string; + + @Column({ name: 'container_type', type: 'varchar', length: 20 }) + containerType: string; + + @Column({ type: 'varchar', length: 10 }) + mode: string; + + @Column({ type: 'timestamp' }) + etd: Date; + + @Column({ type: 'timestamp' }) + eta: Date; + + @Column({ name: 'transit_days', type: 'integer' }) + transitDays: number; + + @Column({ type: 'jsonb' }) + route: any[]; + + @Column({ type: 'integer' }) + availability: number; + + @Column({ type: 'varchar', length: 50 }) + frequency: string; + + @Column({ name: 'vessel_type', type: 'varchar', length: 100, nullable: true }) + vesselType: string | null; + + @Column({ name: 'co2_emissions_kg', type: 'integer', nullable: true }) + co2EmissionsKg: number | null; + + @Column({ name: 'valid_until', type: 'timestamp' }) + validUntil: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts index e77c62c..7946aba 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts @@ -1,70 +1,70 @@ -/** - * User ORM Entity (Infrastructure Layer) - * - * TypeORM entity for user persistence - */ - -import { - Entity, - Column, - PrimaryColumn, - CreateDateColumn, - UpdateDateColumn, - Index, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { OrganizationOrmEntity } from './organization.orm-entity'; - -@Entity('users') -@Index('idx_users_email', ['email']) -@Index('idx_users_organization', ['organizationId']) -@Index('idx_users_role', ['role']) -@Index('idx_users_active', ['isActive']) -export class UserOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ name: 'organization_id', type: 'uuid' }) - organizationId: string; - - @ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'organization_id' }) - organization: OrganizationOrmEntity; - - @Column({ type: 'varchar', length: 255, unique: true }) - email: string; - - @Column({ name: 'password_hash', type: 'varchar', length: 255 }) - passwordHash: string; - - @Column({ type: 'varchar', length: 50 }) - role: string; - - @Column({ name: 'first_name', type: 'varchar', length: 100 }) - firstName: string; - - @Column({ name: 'last_name', type: 'varchar', length: 100 }) - lastName: string; - - @Column({ name: 'phone_number', type: 'varchar', length: 20, nullable: true }) - phoneNumber: string | null; - - @Column({ name: 'totp_secret', type: 'varchar', length: 255, nullable: true }) - totpSecret: string | null; - - @Column({ name: 'is_email_verified', type: 'boolean', default: false }) - isEmailVerified: boolean; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @Column({ name: 'last_login_at', type: 'timestamp', nullable: true }) - lastLoginAt: Date | null; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * User ORM Entity (Infrastructure Layer) + * + * TypeORM entity for user persistence + */ + +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { OrganizationOrmEntity } from './organization.orm-entity'; + +@Entity('users') +@Index('idx_users_email', ['email']) +@Index('idx_users_organization', ['organizationId']) +@Index('idx_users_role', ['role']) +@Index('idx_users_active', ['isActive']) +export class UserOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'organization_id', type: 'uuid' }) + organizationId: string; + + @ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization: OrganizationOrmEntity; + + @Column({ type: 'varchar', length: 255, unique: true }) + email: string; + + @Column({ name: 'password_hash', type: 'varchar', length: 255 }) + passwordHash: string; + + @Column({ type: 'varchar', length: 50 }) + role: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName: string; + + @Column({ name: 'phone_number', type: 'varchar', length: 20, nullable: true }) + phoneNumber: string | null; + + @Column({ name: 'totp_secret', type: 'varchar', length: 255, nullable: true }) + totpSecret: string | null; + + @Column({ name: 'is_email_verified', type: 'boolean', default: false }) + isEmailVerified: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_login_at', type: 'timestamp', nullable: true }) + lastLoginAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts index 476cae8..ffb7779 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/webhook.orm-entity.ts @@ -2,14 +2,7 @@ * Webhook ORM Entity */ -import { - Entity, - PrimaryColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, -} from 'typeorm'; +import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; @Entity('webhooks') @Index(['organization_id', 'status']) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts index 8b6e2f3..c85cef0 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts @@ -12,10 +12,7 @@ import { } from '../../../../domain/entities/booking.entity'; import { BookingNumber } from '../../../../domain/value-objects/booking-number.vo'; import { BookingStatus } from '../../../../domain/value-objects/booking-status.vo'; -import { - BookingOrmEntity, - PartyJson, -} from '../entities/booking.orm-entity'; +import { BookingOrmEntity, PartyJson } from '../entities/booking.orm-entity'; import { ContainerOrmEntity } from '../entities/container.orm-entity'; export class BookingOrmMapper { @@ -39,9 +36,7 @@ export class BookingOrmMapper { orm.updatedAt = domain.updatedAt; // Map containers - orm.containers = domain.containers.map((container) => - this.containerToOrm(container, domain.id) - ); + orm.containers = domain.containers.map(container => this.containerToOrm(container, domain.id)); return orm; } @@ -60,9 +55,7 @@ export class BookingOrmMapper { shipper: this.jsonToParty(orm.shipper), consignee: this.jsonToParty(orm.consignee), cargoDescription: orm.cargoDescription, - containers: orm.containers - ? orm.containers.map((c) => this.ormToContainer(c)) - : [], + containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [], specialInstructions: orm.specialInstructions || undefined, createdAt: orm.createdAt, updatedAt: orm.updatedAt, @@ -79,7 +72,7 @@ export class BookingOrmMapper { * Map array of ORM entities to domain entities */ static toDomainMany(orms: BookingOrmEntity[]): Booking[] { - return orms.map((orm) => this.toDomain(orm)); + return orms.map(orm => this.toDomain(orm)); } /** diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/carrier-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/carrier-orm.mapper.ts index 4c5e5a9..bfa29d3 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/carrier-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/carrier-orm.mapper.ts @@ -1,60 +1,60 @@ -/** - * Carrier ORM Mapper - * - * Maps between Carrier domain entity and CarrierOrmEntity - */ - -import { Carrier, CarrierProps } from '../../../../domain/entities/carrier.entity'; -import { CarrierOrmEntity } from '../entities/carrier.orm-entity'; - -export class CarrierOrmMapper { - /** - * Map domain entity to ORM entity - */ - static toOrm(domain: Carrier): CarrierOrmEntity { - const orm = new CarrierOrmEntity(); - const props = domain.toObject(); - - orm.id = props.id; - orm.name = props.name; - orm.code = props.code; - orm.scac = props.scac; - orm.logoUrl = props.logoUrl || null; - orm.website = props.website || null; - orm.apiConfig = props.apiConfig || null; - orm.isActive = props.isActive; - orm.supportsApi = props.supportsApi; - orm.createdAt = props.createdAt; - orm.updatedAt = props.updatedAt; - - return orm; - } - - /** - * Map ORM entity to domain entity - */ - static toDomain(orm: CarrierOrmEntity): Carrier { - const props: CarrierProps = { - id: orm.id, - name: orm.name, - code: orm.code, - scac: orm.scac, - logoUrl: orm.logoUrl || undefined, - website: orm.website || undefined, - apiConfig: orm.apiConfig || undefined, - isActive: orm.isActive, - supportsApi: orm.supportsApi, - createdAt: orm.createdAt, - updatedAt: orm.updatedAt, - }; - - return Carrier.fromPersistence(props); - } - - /** - * Map array of ORM entities to domain entities - */ - static toDomainMany(orms: CarrierOrmEntity[]): Carrier[] { - return orms.map((orm) => this.toDomain(orm)); - } -} +/** + * Carrier ORM Mapper + * + * Maps between Carrier domain entity and CarrierOrmEntity + */ + +import { Carrier, CarrierProps } from '../../../../domain/entities/carrier.entity'; +import { CarrierOrmEntity } from '../entities/carrier.orm-entity'; + +export class CarrierOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: Carrier): CarrierOrmEntity { + const orm = new CarrierOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.name = props.name; + orm.code = props.code; + orm.scac = props.scac; + orm.logoUrl = props.logoUrl || null; + orm.website = props.website || null; + orm.apiConfig = props.apiConfig || null; + orm.isActive = props.isActive; + orm.supportsApi = props.supportsApi; + orm.createdAt = props.createdAt; + orm.updatedAt = props.updatedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: CarrierOrmEntity): Carrier { + const props: CarrierProps = { + id: orm.id, + name: orm.name, + code: orm.code, + scac: orm.scac, + logoUrl: orm.logoUrl || undefined, + website: orm.website || undefined, + apiConfig: orm.apiConfig || undefined, + isActive: orm.isActive, + supportsApi: orm.supportsApi, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }; + + return Carrier.fromPersistence(props); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: CarrierOrmEntity[]): Carrier[] { + return orms.map(orm => this.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts index 49ccc6c..7521113 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts @@ -1,11 +1,11 @@ -/** - * ORM Mappers Barrel Export - * - * All mappers for converting between domain and ORM entities - */ - -export * from './organization-orm.mapper'; -export * from './user-orm.mapper'; -export * from './carrier-orm.mapper'; -export * from './port-orm.mapper'; -export * from './rate-quote-orm.mapper'; +/** + * ORM Mappers Barrel Export + * + * All mappers for converting between domain and ORM entities + */ + +export * from './organization-orm.mapper'; +export * from './user-orm.mapper'; +export * from './carrier-orm.mapper'; +export * from './port-orm.mapper'; +export * from './rate-quote-orm.mapper'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts index 9573bd2..40d9d03 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts @@ -1,68 +1,68 @@ -/** - * Organization ORM Mapper - * - * Maps between Organization domain entity and OrganizationOrmEntity - */ - -import { Organization, OrganizationProps } from '../../../../domain/entities/organization.entity'; -import { OrganizationOrmEntity } from '../entities/organization.orm-entity'; - -export class OrganizationOrmMapper { - /** - * Map domain entity to ORM entity - */ - static toOrm(domain: Organization): OrganizationOrmEntity { - const orm = new OrganizationOrmEntity(); - const props = domain.toObject(); - - orm.id = props.id; - orm.name = props.name; - orm.type = props.type; - orm.scac = props.scac || null; - orm.addressStreet = props.address.street; - orm.addressCity = props.address.city; - orm.addressState = props.address.state || null; - orm.addressPostalCode = props.address.postalCode; - orm.addressCountry = props.address.country; - orm.logoUrl = props.logoUrl || null; - orm.documents = props.documents; - orm.isActive = props.isActive; - orm.createdAt = props.createdAt; - orm.updatedAt = props.updatedAt; - - return orm; - } - - /** - * Map ORM entity to domain entity - */ - static toDomain(orm: OrganizationOrmEntity): Organization { - const props: OrganizationProps = { - id: orm.id, - name: orm.name, - type: orm.type as any, - scac: orm.scac || undefined, - address: { - street: orm.addressStreet, - city: orm.addressCity, - state: orm.addressState || undefined, - postalCode: orm.addressPostalCode, - country: orm.addressCountry, - }, - logoUrl: orm.logoUrl || undefined, - documents: orm.documents || [], - isActive: orm.isActive, - createdAt: orm.createdAt, - updatedAt: orm.updatedAt, - }; - - return Organization.fromPersistence(props); - } - - /** - * Map array of ORM entities to domain entities - */ - static toDomainMany(orms: OrganizationOrmEntity[]): Organization[] { - return orms.map((orm) => this.toDomain(orm)); - } -} +/** + * Organization ORM Mapper + * + * Maps between Organization domain entity and OrganizationOrmEntity + */ + +import { Organization, OrganizationProps } from '../../../../domain/entities/organization.entity'; +import { OrganizationOrmEntity } from '../entities/organization.orm-entity'; + +export class OrganizationOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: Organization): OrganizationOrmEntity { + const orm = new OrganizationOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.name = props.name; + orm.type = props.type; + orm.scac = props.scac || null; + orm.addressStreet = props.address.street; + orm.addressCity = props.address.city; + orm.addressState = props.address.state || null; + orm.addressPostalCode = props.address.postalCode; + orm.addressCountry = props.address.country; + orm.logoUrl = props.logoUrl || null; + orm.documents = props.documents; + orm.isActive = props.isActive; + orm.createdAt = props.createdAt; + orm.updatedAt = props.updatedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: OrganizationOrmEntity): Organization { + const props: OrganizationProps = { + id: orm.id, + name: orm.name, + type: orm.type as any, + scac: orm.scac || undefined, + address: { + street: orm.addressStreet, + city: orm.addressCity, + state: orm.addressState || undefined, + postalCode: orm.addressPostalCode, + country: orm.addressCountry, + }, + logoUrl: orm.logoUrl || undefined, + documents: orm.documents || [], + isActive: orm.isActive, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }; + + return Organization.fromPersistence(props); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: OrganizationOrmEntity[]): Organization[] { + return orms.map(orm => this.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/port-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/port-orm.mapper.ts index e9ff29e..6b458a8 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/port-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/port-orm.mapper.ts @@ -1,64 +1,64 @@ -/** - * Port ORM Mapper - * - * Maps between Port domain entity and PortOrmEntity - */ - -import { Port, PortProps } from '../../../../domain/entities/port.entity'; -import { PortOrmEntity } from '../entities/port.orm-entity'; - -export class PortOrmMapper { - /** - * Map domain entity to ORM entity - */ - static toOrm(domain: Port): PortOrmEntity { - const orm = new PortOrmEntity(); - const props = domain.toObject(); - - orm.id = props.id; - orm.code = props.code; - orm.name = props.name; - orm.city = props.city; - orm.country = props.country; - orm.countryName = props.countryName; - orm.latitude = props.coordinates.latitude; - orm.longitude = props.coordinates.longitude; - orm.timezone = props.timezone || null; - orm.isActive = props.isActive; - orm.createdAt = props.createdAt; - orm.updatedAt = props.updatedAt; - - return orm; - } - - /** - * Map ORM entity to domain entity - */ - static toDomain(orm: PortOrmEntity): Port { - const props: PortProps = { - id: orm.id, - code: orm.code, - name: orm.name, - city: orm.city, - country: orm.country, - countryName: orm.countryName, - coordinates: { - latitude: Number(orm.latitude), - longitude: Number(orm.longitude), - }, - timezone: orm.timezone || undefined, - isActive: orm.isActive, - createdAt: orm.createdAt, - updatedAt: orm.updatedAt, - }; - - return Port.fromPersistence(props); - } - - /** - * Map array of ORM entities to domain entities - */ - static toDomainMany(orms: PortOrmEntity[]): Port[] { - return orms.map((orm) => this.toDomain(orm)); - } -} +/** + * Port ORM Mapper + * + * Maps between Port domain entity and PortOrmEntity + */ + +import { Port, PortProps } from '../../../../domain/entities/port.entity'; +import { PortOrmEntity } from '../entities/port.orm-entity'; + +export class PortOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: Port): PortOrmEntity { + const orm = new PortOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.code = props.code; + orm.name = props.name; + orm.city = props.city; + orm.country = props.country; + orm.countryName = props.countryName; + orm.latitude = props.coordinates.latitude; + orm.longitude = props.coordinates.longitude; + orm.timezone = props.timezone || null; + orm.isActive = props.isActive; + orm.createdAt = props.createdAt; + orm.updatedAt = props.updatedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: PortOrmEntity): Port { + const props: PortProps = { + id: orm.id, + code: orm.code, + name: orm.name, + city: orm.city, + country: orm.country, + countryName: orm.countryName, + coordinates: { + latitude: Number(orm.latitude), + longitude: Number(orm.longitude), + }, + timezone: orm.timezone || undefined, + isActive: orm.isActive, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }; + + return Port.fromPersistence(props); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: PortOrmEntity[]): Port[] { + return orms.map(orm => this.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/rate-quote-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/rate-quote-orm.mapper.ts index c5705c8..eb700be 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/rate-quote-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/rate-quote-orm.mapper.ts @@ -1,98 +1,98 @@ -/** - * RateQuote ORM Mapper - * - * Maps between RateQuote domain entity and RateQuoteOrmEntity - */ - -import { RateQuote, RateQuoteProps } from '../../../../domain/entities/rate-quote.entity'; -import { RateQuoteOrmEntity } from '../entities/rate-quote.orm-entity'; - -export class RateQuoteOrmMapper { - /** - * Map domain entity to ORM entity - */ - static toOrm(domain: RateQuote): RateQuoteOrmEntity { - const orm = new RateQuoteOrmEntity(); - const props = domain.toObject(); - - orm.id = props.id; - orm.carrierId = props.carrierId; - orm.carrierName = props.carrierName; - orm.carrierCode = props.carrierCode; - orm.originCode = props.origin.code; - orm.originName = props.origin.name; - orm.originCountry = props.origin.country; - orm.destinationCode = props.destination.code; - orm.destinationName = props.destination.name; - orm.destinationCountry = props.destination.country; - orm.baseFreight = props.pricing.baseFreight; - orm.surcharges = props.pricing.surcharges; - orm.totalAmount = props.pricing.totalAmount; - orm.currency = props.pricing.currency; - orm.containerType = props.containerType; - orm.mode = props.mode; - orm.etd = props.etd; - orm.eta = props.eta; - orm.transitDays = props.transitDays; - orm.route = props.route; - orm.availability = props.availability; - orm.frequency = props.frequency; - orm.vesselType = props.vesselType || null; - orm.co2EmissionsKg = props.co2EmissionsKg || null; - orm.validUntil = props.validUntil; - orm.createdAt = props.createdAt; - orm.updatedAt = props.updatedAt; - - return orm; - } - - /** - * Map ORM entity to domain entity - */ - static toDomain(orm: RateQuoteOrmEntity): RateQuote { - const props: RateQuoteProps = { - id: orm.id, - carrierId: orm.carrierId, - carrierName: orm.carrierName, - carrierCode: orm.carrierCode, - origin: { - code: orm.originCode, - name: orm.originName, - country: orm.originCountry, - }, - destination: { - code: orm.destinationCode, - name: orm.destinationName, - country: orm.destinationCountry, - }, - pricing: { - baseFreight: Number(orm.baseFreight), - surcharges: orm.surcharges || [], - totalAmount: Number(orm.totalAmount), - currency: orm.currency, - }, - containerType: orm.containerType, - mode: orm.mode as any, - etd: orm.etd, - eta: orm.eta, - transitDays: orm.transitDays, - route: orm.route || [], - availability: orm.availability, - frequency: orm.frequency, - vesselType: orm.vesselType || undefined, - co2EmissionsKg: orm.co2EmissionsKg || undefined, - validUntil: orm.validUntil, - createdAt: orm.createdAt, - updatedAt: orm.updatedAt, - }; - - return RateQuote.fromPersistence(props); - } - - /** - * Map array of ORM entities to domain entities - */ - static toDomainMany(orms: RateQuoteOrmEntity[]): RateQuote[] { - return orms.map((orm) => this.toDomain(orm)); - } -} +/** + * RateQuote ORM Mapper + * + * Maps between RateQuote domain entity and RateQuoteOrmEntity + */ + +import { RateQuote, RateQuoteProps } from '../../../../domain/entities/rate-quote.entity'; +import { RateQuoteOrmEntity } from '../entities/rate-quote.orm-entity'; + +export class RateQuoteOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: RateQuote): RateQuoteOrmEntity { + const orm = new RateQuoteOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.carrierId = props.carrierId; + orm.carrierName = props.carrierName; + orm.carrierCode = props.carrierCode; + orm.originCode = props.origin.code; + orm.originName = props.origin.name; + orm.originCountry = props.origin.country; + orm.destinationCode = props.destination.code; + orm.destinationName = props.destination.name; + orm.destinationCountry = props.destination.country; + orm.baseFreight = props.pricing.baseFreight; + orm.surcharges = props.pricing.surcharges; + orm.totalAmount = props.pricing.totalAmount; + orm.currency = props.pricing.currency; + orm.containerType = props.containerType; + orm.mode = props.mode; + orm.etd = props.etd; + orm.eta = props.eta; + orm.transitDays = props.transitDays; + orm.route = props.route; + orm.availability = props.availability; + orm.frequency = props.frequency; + orm.vesselType = props.vesselType || null; + orm.co2EmissionsKg = props.co2EmissionsKg || null; + orm.validUntil = props.validUntil; + orm.createdAt = props.createdAt; + orm.updatedAt = props.updatedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: RateQuoteOrmEntity): RateQuote { + const props: RateQuoteProps = { + id: orm.id, + carrierId: orm.carrierId, + carrierName: orm.carrierName, + carrierCode: orm.carrierCode, + origin: { + code: orm.originCode, + name: orm.originName, + country: orm.originCountry, + }, + destination: { + code: orm.destinationCode, + name: orm.destinationName, + country: orm.destinationCountry, + }, + pricing: { + baseFreight: Number(orm.baseFreight), + surcharges: orm.surcharges || [], + totalAmount: Number(orm.totalAmount), + currency: orm.currency, + }, + containerType: orm.containerType, + mode: orm.mode as any, + etd: orm.etd, + eta: orm.eta, + transitDays: orm.transitDays, + route: orm.route || [], + availability: orm.availability, + frequency: orm.frequency, + vesselType: orm.vesselType || undefined, + co2EmissionsKg: orm.co2EmissionsKg || undefined, + validUntil: orm.validUntil, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }; + + return RateQuote.fromPersistence(props); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: RateQuoteOrmEntity[]): RateQuote[] { + return orms.map(orm => this.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts index b22508d..bb14966 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts @@ -1,66 +1,66 @@ -/** - * User ORM Mapper - * - * Maps between User domain entity and UserOrmEntity - */ - -import { User, UserProps } from '../../../../domain/entities/user.entity'; -import { UserOrmEntity } from '../entities/user.orm-entity'; - -export class UserOrmMapper { - /** - * Map domain entity to ORM entity - */ - static toOrm(domain: User): UserOrmEntity { - const orm = new UserOrmEntity(); - const props = domain.toObject(); - - orm.id = props.id; - orm.organizationId = props.organizationId; - orm.email = props.email; - orm.passwordHash = props.passwordHash; - orm.role = props.role; - orm.firstName = props.firstName; - orm.lastName = props.lastName; - orm.phoneNumber = props.phoneNumber || null; - orm.totpSecret = props.totpSecret || null; - orm.isEmailVerified = props.isEmailVerified; - orm.isActive = props.isActive; - orm.lastLoginAt = props.lastLoginAt || null; - orm.createdAt = props.createdAt; - orm.updatedAt = props.updatedAt; - - return orm; - } - - /** - * Map ORM entity to domain entity - */ - static toDomain(orm: UserOrmEntity): User { - const props: UserProps = { - id: orm.id, - organizationId: orm.organizationId, - email: orm.email, - passwordHash: orm.passwordHash, - role: orm.role as any, - firstName: orm.firstName, - lastName: orm.lastName, - phoneNumber: orm.phoneNumber || undefined, - totpSecret: orm.totpSecret || undefined, - isEmailVerified: orm.isEmailVerified, - isActive: orm.isActive, - lastLoginAt: orm.lastLoginAt || undefined, - createdAt: orm.createdAt, - updatedAt: orm.updatedAt, - }; - - return User.fromPersistence(props); - } - - /** - * Map array of ORM entities to domain entities - */ - static toDomainMany(orms: UserOrmEntity[]): User[] { - return orms.map((orm) => this.toDomain(orm)); - } -} +/** + * User ORM Mapper + * + * Maps between User domain entity and UserOrmEntity + */ + +import { User, UserProps } from '../../../../domain/entities/user.entity'; +import { UserOrmEntity } from '../entities/user.orm-entity'; + +export class UserOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: User): UserOrmEntity { + const orm = new UserOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.organizationId = props.organizationId; + orm.email = props.email; + orm.passwordHash = props.passwordHash; + orm.role = props.role; + orm.firstName = props.firstName; + orm.lastName = props.lastName; + orm.phoneNumber = props.phoneNumber || null; + orm.totpSecret = props.totpSecret || null; + orm.isEmailVerified = props.isEmailVerified; + orm.isActive = props.isActive; + orm.lastLoginAt = props.lastLoginAt || null; + orm.createdAt = props.createdAt; + orm.updatedAt = props.updatedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: UserOrmEntity): User { + const props: UserProps = { + id: orm.id, + organizationId: orm.organizationId, + email: orm.email, + passwordHash: orm.passwordHash, + role: orm.role as any, + firstName: orm.firstName, + lastName: orm.lastName, + phoneNumber: orm.phoneNumber || undefined, + totpSecret: orm.totpSecret || undefined, + isEmailVerified: orm.isEmailVerified, + isActive: orm.isActive, + lastLoginAt: orm.lastLoginAt || undefined, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }; + + return User.fromPersistence(props); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: UserOrmEntity[]): User[] { + return orms.map(orm => this.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts index 024e235..6b1e5e2 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000001000-CreateAuditLogsTable.ts @@ -86,7 +86,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface { }, ], }), - true, + true ); // Create indexes for efficient querying @@ -95,7 +95,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface { new TableIndex({ name: 'idx_audit_logs_organization_timestamp', columnNames: ['organization_id', 'timestamp'], - }), + }) ); await queryRunner.createIndex( @@ -103,7 +103,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface { new TableIndex({ name: 'idx_audit_logs_user_timestamp', columnNames: ['user_id', 'timestamp'], - }), + }) ); await queryRunner.createIndex( @@ -111,7 +111,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface { new TableIndex({ name: 'idx_audit_logs_resource', columnNames: ['resource_type', 'resource_id'], - }), + }) ); await queryRunner.createIndex( @@ -119,7 +119,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface { new TableIndex({ name: 'idx_audit_logs_action', columnNames: ['action'], - }), + }) ); await queryRunner.createIndex( @@ -127,7 +127,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface { new TableIndex({ name: 'idx_audit_logs_timestamp', columnNames: ['timestamp'], - }), + }) ); } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts index 0f278c0..df51322 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000002000-CreateNotificationsTable.ts @@ -74,7 +74,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface }, ], }), - true, + true ); // Create indexes for efficient querying @@ -83,7 +83,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface new TableIndex({ name: 'idx_notifications_user_read_created', columnNames: ['user_id', 'read', 'created_at'], - }), + }) ); await queryRunner.createIndex( @@ -91,7 +91,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface new TableIndex({ name: 'idx_notifications_organization_created', columnNames: ['organization_id', 'created_at'], - }), + }) ); await queryRunner.createIndex( @@ -99,7 +99,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface new TableIndex({ name: 'idx_notifications_user_created', columnNames: ['user_id', 'created_at'], - }), + }) ); } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts index 0df0396..18e0454 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1700000003000-CreateWebhooksTable.ts @@ -80,7 +80,7 @@ export class CreateWebhooksTable1700000003000 implements MigrationInterface { }, ], }), - true, + true ); // Create index for efficient querying @@ -89,7 +89,7 @@ export class CreateWebhooksTable1700000003000 implements MigrationInterface { new TableIndex({ name: 'idx_webhooks_organization_status', columnNames: ['organization_id', 'status'], - }), + }) ); } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000001-CreateExtensionsAndOrganizations.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000001-CreateExtensionsAndOrganizations.ts index 25b81e1..ffe3f63 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000001-CreateExtensionsAndOrganizations.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000001-CreateExtensionsAndOrganizations.ts @@ -1,65 +1,65 @@ -/** - * Migration: Create PostgreSQL Extensions and Organizations Table - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateExtensionsAndOrganizations1730000000001 implements MigrationInterface { - name = 'CreateExtensionsAndOrganizations1730000000001'; - - public async up(queryRunner: QueryRunner): Promise { - // Create extensions - await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); - await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pg_trgm"`); - - // Create organizations table - await queryRunner.query(` - CREATE TABLE "organizations" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "name" VARCHAR(255) NOT NULL, - "type" VARCHAR(50) NOT NULL, - "scac" CHAR(4) NULL, - "address_street" VARCHAR(255) NOT NULL, - "address_city" VARCHAR(100) NOT NULL, - "address_state" VARCHAR(100) NULL, - "address_postal_code" VARCHAR(20) NOT NULL, - "address_country" CHAR(2) NOT NULL, - "logo_url" TEXT NULL, - "documents" JSONB NOT NULL DEFAULT '[]', - "is_active" BOOLEAN NOT NULL DEFAULT TRUE, - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_organizations" PRIMARY KEY ("id"), - CONSTRAINT "uq_organizations_name" UNIQUE ("name"), - CONSTRAINT "uq_organizations_scac" UNIQUE ("scac"), - CONSTRAINT "chk_organizations_scac_format" CHECK ("scac" IS NULL OR "scac" ~ '^[A-Z]{4}$'), - CONSTRAINT "chk_organizations_country" CHECK ("address_country" ~ '^[A-Z]{2}$') - ) - `); - - // Create indexes - await queryRunner.query(` - CREATE INDEX "idx_organizations_type" ON "organizations" ("type") - `); - await queryRunner.query(` - CREATE INDEX "idx_organizations_scac" ON "organizations" ("scac") - `); - await queryRunner.query(` - CREATE INDEX "idx_organizations_active" ON "organizations" ("is_active") - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "organizations" IS 'Business organizations (freight forwarders, carriers, shippers)' - `); - await queryRunner.query(` - COMMENT ON COLUMN "organizations"."scac" IS 'Standard Carrier Alpha Code (4 uppercase letters, carriers only)' - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "organizations"`); - await queryRunner.query(`DROP EXTENSION IF EXISTS "pg_trgm"`); - await queryRunner.query(`DROP EXTENSION IF EXISTS "uuid-ossp"`); - } -} +/** + * Migration: Create PostgreSQL Extensions and Organizations Table + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateExtensionsAndOrganizations1730000000001 implements MigrationInterface { + name = 'CreateExtensionsAndOrganizations1730000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // Create extensions + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pg_trgm"`); + + // Create organizations table + await queryRunner.query(` + CREATE TABLE "organizations" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "name" VARCHAR(255) NOT NULL, + "type" VARCHAR(50) NOT NULL, + "scac" CHAR(4) NULL, + "address_street" VARCHAR(255) NOT NULL, + "address_city" VARCHAR(100) NOT NULL, + "address_state" VARCHAR(100) NULL, + "address_postal_code" VARCHAR(20) NOT NULL, + "address_country" CHAR(2) NOT NULL, + "logo_url" TEXT NULL, + "documents" JSONB NOT NULL DEFAULT '[]', + "is_active" BOOLEAN NOT NULL DEFAULT TRUE, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_organizations" PRIMARY KEY ("id"), + CONSTRAINT "uq_organizations_name" UNIQUE ("name"), + CONSTRAINT "uq_organizations_scac" UNIQUE ("scac"), + CONSTRAINT "chk_organizations_scac_format" CHECK ("scac" IS NULL OR "scac" ~ '^[A-Z]{4}$'), + CONSTRAINT "chk_organizations_country" CHECK ("address_country" ~ '^[A-Z]{2}$') + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_organizations_type" ON "organizations" ("type") + `); + await queryRunner.query(` + CREATE INDEX "idx_organizations_scac" ON "organizations" ("scac") + `); + await queryRunner.query(` + CREATE INDEX "idx_organizations_active" ON "organizations" ("is_active") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "organizations" IS 'Business organizations (freight forwarders, carriers, shippers)' + `); + await queryRunner.query(` + COMMENT ON COLUMN "organizations"."scac" IS 'Standard Carrier Alpha Code (4 uppercase letters, carriers only)' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "organizations"`); + await queryRunner.query(`DROP EXTENSION IF EXISTS "pg_trgm"`); + await queryRunner.query(`DROP EXTENSION IF EXISTS "uuid-ossp"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000003-CreateCarriers.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000003-CreateCarriers.ts index eb5438c..abb28bc 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000003-CreateCarriers.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000003-CreateCarriers.ts @@ -1,59 +1,59 @@ -/** - * Migration: Create Carriers Table - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateCarriers1730000000003 implements MigrationInterface { - name = 'CreateCarriers1730000000003'; - - public async up(queryRunner: QueryRunner): Promise { - // Create carriers table - await queryRunner.query(` - CREATE TABLE "carriers" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "name" VARCHAR(255) NOT NULL, - "code" VARCHAR(50) NOT NULL, - "scac" CHAR(4) NOT NULL, - "logo_url" TEXT NULL, - "website" TEXT NULL, - "api_config" JSONB NULL, - "is_active" BOOLEAN NOT NULL DEFAULT TRUE, - "supports_api" BOOLEAN NOT NULL DEFAULT FALSE, - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_carriers" PRIMARY KEY ("id"), - CONSTRAINT "uq_carriers_code" UNIQUE ("code"), - CONSTRAINT "uq_carriers_scac" UNIQUE ("scac"), - CONSTRAINT "chk_carriers_code" CHECK ("code" ~ '^[A-Z_]+$'), - CONSTRAINT "chk_carriers_scac" CHECK ("scac" ~ '^[A-Z]{4}$') - ) - `); - - // Create indexes - await queryRunner.query(` - CREATE INDEX "idx_carriers_code" ON "carriers" ("code") - `); - await queryRunner.query(` - CREATE INDEX "idx_carriers_scac" ON "carriers" ("scac") - `); - await queryRunner.query(` - CREATE INDEX "idx_carriers_active" ON "carriers" ("is_active") - `); - await queryRunner.query(` - CREATE INDEX "idx_carriers_supports_api" ON "carriers" ("supports_api") - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "carriers" IS 'Shipping carriers with API configuration' - `); - await queryRunner.query(` - COMMENT ON COLUMN "carriers"."api_config" IS 'API configuration (baseUrl, credentials, timeout, etc.)' - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "carriers"`); - } -} +/** + * Migration: Create Carriers Table + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCarriers1730000000003 implements MigrationInterface { + name = 'CreateCarriers1730000000003'; + + public async up(queryRunner: QueryRunner): Promise { + // Create carriers table + await queryRunner.query(` + CREATE TABLE "carriers" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "name" VARCHAR(255) NOT NULL, + "code" VARCHAR(50) NOT NULL, + "scac" CHAR(4) NOT NULL, + "logo_url" TEXT NULL, + "website" TEXT NULL, + "api_config" JSONB NULL, + "is_active" BOOLEAN NOT NULL DEFAULT TRUE, + "supports_api" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_carriers" PRIMARY KEY ("id"), + CONSTRAINT "uq_carriers_code" UNIQUE ("code"), + CONSTRAINT "uq_carriers_scac" UNIQUE ("scac"), + CONSTRAINT "chk_carriers_code" CHECK ("code" ~ '^[A-Z_]+$'), + CONSTRAINT "chk_carriers_scac" CHECK ("scac" ~ '^[A-Z]{4}$') + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_carriers_code" ON "carriers" ("code") + `); + await queryRunner.query(` + CREATE INDEX "idx_carriers_scac" ON "carriers" ("scac") + `); + await queryRunner.query(` + CREATE INDEX "idx_carriers_active" ON "carriers" ("is_active") + `); + await queryRunner.query(` + CREATE INDEX "idx_carriers_supports_api" ON "carriers" ("supports_api") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "carriers" IS 'Shipping carriers with API configuration' + `); + await queryRunner.query(` + COMMENT ON COLUMN "carriers"."api_config" IS 'API configuration (baseUrl, credentials, timeout, etc.)' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "carriers"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000004-CreatePorts.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000004-CreatePorts.ts index 9c979f1..4bec4bb 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000004-CreatePorts.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000004-CreatePorts.ts @@ -1,69 +1,69 @@ -/** - * Migration: Create Ports Table - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreatePorts1730000000004 implements MigrationInterface { - name = 'CreatePorts1730000000004'; - - public async up(queryRunner: QueryRunner): Promise { - // Create ports table - await queryRunner.query(` - CREATE TABLE "ports" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "code" CHAR(5) NOT NULL, - "name" VARCHAR(255) NOT NULL, - "city" VARCHAR(255) NOT NULL, - "country" CHAR(2) NOT NULL, - "country_name" VARCHAR(100) NOT NULL, - "latitude" DECIMAL(9,6) NOT NULL, - "longitude" DECIMAL(9,6) NOT NULL, - "timezone" VARCHAR(50) NULL, - "is_active" BOOLEAN NOT NULL DEFAULT TRUE, - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_ports" PRIMARY KEY ("id"), - CONSTRAINT "uq_ports_code" UNIQUE ("code"), - CONSTRAINT "chk_ports_code" CHECK ("code" ~ '^[A-Z0-9]{5}$'), - CONSTRAINT "chk_ports_country" CHECK ("country" ~ '^[A-Z]{2}$'), - CONSTRAINT "chk_ports_latitude" CHECK ("latitude" >= -90 AND "latitude" <= 90), - CONSTRAINT "chk_ports_longitude" CHECK ("longitude" >= -180 AND "longitude" <= 180) - ) - `); - - // Create indexes - await queryRunner.query(` - CREATE INDEX "idx_ports_code" ON "ports" ("code") - `); - await queryRunner.query(` - CREATE INDEX "idx_ports_country" ON "ports" ("country") - `); - await queryRunner.query(` - CREATE INDEX "idx_ports_active" ON "ports" ("is_active") - `); - await queryRunner.query(` - CREATE INDEX "idx_ports_coordinates" ON "ports" ("latitude", "longitude") - `); - - // Create GIN indexes for fuzzy search using pg_trgm - await queryRunner.query(` - CREATE INDEX "idx_ports_name_trgm" ON "ports" USING GIN ("name" gin_trgm_ops) - `); - await queryRunner.query(` - CREATE INDEX "idx_ports_city_trgm" ON "ports" USING GIN ("city" gin_trgm_ops) - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "ports" IS 'Maritime ports (UN/LOCODE standard)' - `); - await queryRunner.query(` - COMMENT ON COLUMN "ports"."code" IS 'UN/LOCODE (5 characters: CC + LLL)' - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "ports"`); - } -} +/** + * Migration: Create Ports Table + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePorts1730000000004 implements MigrationInterface { + name = 'CreatePorts1730000000004'; + + public async up(queryRunner: QueryRunner): Promise { + // Create ports table + await queryRunner.query(` + CREATE TABLE "ports" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "code" CHAR(5) NOT NULL, + "name" VARCHAR(255) NOT NULL, + "city" VARCHAR(255) NOT NULL, + "country" CHAR(2) NOT NULL, + "country_name" VARCHAR(100) NOT NULL, + "latitude" DECIMAL(9,6) NOT NULL, + "longitude" DECIMAL(9,6) NOT NULL, + "timezone" VARCHAR(50) NULL, + "is_active" BOOLEAN NOT NULL DEFAULT TRUE, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_ports" PRIMARY KEY ("id"), + CONSTRAINT "uq_ports_code" UNIQUE ("code"), + CONSTRAINT "chk_ports_code" CHECK ("code" ~ '^[A-Z0-9]{5}$'), + CONSTRAINT "chk_ports_country" CHECK ("country" ~ '^[A-Z]{2}$'), + CONSTRAINT "chk_ports_latitude" CHECK ("latitude" >= -90 AND "latitude" <= 90), + CONSTRAINT "chk_ports_longitude" CHECK ("longitude" >= -180 AND "longitude" <= 180) + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_ports_code" ON "ports" ("code") + `); + await queryRunner.query(` + CREATE INDEX "idx_ports_country" ON "ports" ("country") + `); + await queryRunner.query(` + CREATE INDEX "idx_ports_active" ON "ports" ("is_active") + `); + await queryRunner.query(` + CREATE INDEX "idx_ports_coordinates" ON "ports" ("latitude", "longitude") + `); + + // Create GIN indexes for fuzzy search using pg_trgm + await queryRunner.query(` + CREATE INDEX "idx_ports_name_trgm" ON "ports" USING GIN ("name" gin_trgm_ops) + `); + await queryRunner.query(` + CREATE INDEX "idx_ports_city_trgm" ON "ports" USING GIN ("city" gin_trgm_ops) + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "ports" IS 'Maritime ports (UN/LOCODE standard)' + `); + await queryRunner.query(` + COMMENT ON COLUMN "ports"."code" IS 'UN/LOCODE (5 characters: CC + LLL)' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "ports"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000005-CreateRateQuotes.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000005-CreateRateQuotes.ts index 5991581..cce1e7e 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000005-CreateRateQuotes.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000005-CreateRateQuotes.ts @@ -1,91 +1,91 @@ -/** - * Migration: Create RateQuotes Table - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateRateQuotes1730000000005 implements MigrationInterface { - name = 'CreateRateQuotes1730000000005'; - - public async up(queryRunner: QueryRunner): Promise { - // Create rate_quotes table - await queryRunner.query(` - CREATE TABLE "rate_quotes" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "carrier_id" UUID NOT NULL, - "carrier_name" VARCHAR(255) NOT NULL, - "carrier_code" VARCHAR(50) NOT NULL, - "origin_code" CHAR(5) NOT NULL, - "origin_name" VARCHAR(255) NOT NULL, - "origin_country" VARCHAR(100) NOT NULL, - "destination_code" CHAR(5) NOT NULL, - "destination_name" VARCHAR(255) NOT NULL, - "destination_country" VARCHAR(100) NOT NULL, - "base_freight" DECIMAL(10,2) NOT NULL, - "surcharges" JSONB NOT NULL DEFAULT '[]', - "total_amount" DECIMAL(10,2) NOT NULL, - "currency" CHAR(3) NOT NULL, - "container_type" VARCHAR(20) NOT NULL, - "mode" VARCHAR(10) NOT NULL, - "etd" TIMESTAMP NOT NULL, - "eta" TIMESTAMP NOT NULL, - "transit_days" INTEGER NOT NULL, - "route" JSONB NOT NULL, - "availability" INTEGER NOT NULL, - "frequency" VARCHAR(50) NOT NULL, - "vessel_type" VARCHAR(100) NULL, - "co2_emissions_kg" INTEGER NULL, - "valid_until" TIMESTAMP NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_rate_quotes" PRIMARY KEY ("id"), - CONSTRAINT "fk_rate_quotes_carrier" FOREIGN KEY ("carrier_id") - REFERENCES "carriers"("id") ON DELETE CASCADE, - CONSTRAINT "chk_rate_quotes_base_freight" CHECK ("base_freight" > 0), - CONSTRAINT "chk_rate_quotes_total_amount" CHECK ("total_amount" > 0), - CONSTRAINT "chk_rate_quotes_transit_days" CHECK ("transit_days" > 0), - CONSTRAINT "chk_rate_quotes_availability" CHECK ("availability" >= 0), - CONSTRAINT "chk_rate_quotes_eta" CHECK ("eta" > "etd"), - CONSTRAINT "chk_rate_quotes_mode" CHECK ("mode" IN ('FCL', 'LCL')) - ) - `); - - // Create indexes - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_carrier" ON "rate_quotes" ("carrier_id") - `); - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_origin_dest" ON "rate_quotes" ("origin_code", "destination_code") - `); - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_container_type" ON "rate_quotes" ("container_type") - `); - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_etd" ON "rate_quotes" ("etd") - `); - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_valid_until" ON "rate_quotes" ("valid_until") - `); - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_created_at" ON "rate_quotes" ("created_at") - `); - - // Composite index for rate search - await queryRunner.query(` - CREATE INDEX "idx_rate_quotes_search" ON "rate_quotes" - ("origin_code", "destination_code", "container_type", "etd") - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "rate_quotes" IS 'Shipping rate quotes from carriers (15-min cache)' - `); - await queryRunner.query(` - COMMENT ON COLUMN "rate_quotes"."valid_until" IS 'Quote expiry time (created_at + 15 minutes)' - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "rate_quotes"`); - } -} +/** + * Migration: Create RateQuotes Table + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateRateQuotes1730000000005 implements MigrationInterface { + name = 'CreateRateQuotes1730000000005'; + + public async up(queryRunner: QueryRunner): Promise { + // Create rate_quotes table + await queryRunner.query(` + CREATE TABLE "rate_quotes" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "carrier_id" UUID NOT NULL, + "carrier_name" VARCHAR(255) NOT NULL, + "carrier_code" VARCHAR(50) NOT NULL, + "origin_code" CHAR(5) NOT NULL, + "origin_name" VARCHAR(255) NOT NULL, + "origin_country" VARCHAR(100) NOT NULL, + "destination_code" CHAR(5) NOT NULL, + "destination_name" VARCHAR(255) NOT NULL, + "destination_country" VARCHAR(100) NOT NULL, + "base_freight" DECIMAL(10,2) NOT NULL, + "surcharges" JSONB NOT NULL DEFAULT '[]', + "total_amount" DECIMAL(10,2) NOT NULL, + "currency" CHAR(3) NOT NULL, + "container_type" VARCHAR(20) NOT NULL, + "mode" VARCHAR(10) NOT NULL, + "etd" TIMESTAMP NOT NULL, + "eta" TIMESTAMP NOT NULL, + "transit_days" INTEGER NOT NULL, + "route" JSONB NOT NULL, + "availability" INTEGER NOT NULL, + "frequency" VARCHAR(50) NOT NULL, + "vessel_type" VARCHAR(100) NULL, + "co2_emissions_kg" INTEGER NULL, + "valid_until" TIMESTAMP NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_rate_quotes" PRIMARY KEY ("id"), + CONSTRAINT "fk_rate_quotes_carrier" FOREIGN KEY ("carrier_id") + REFERENCES "carriers"("id") ON DELETE CASCADE, + CONSTRAINT "chk_rate_quotes_base_freight" CHECK ("base_freight" > 0), + CONSTRAINT "chk_rate_quotes_total_amount" CHECK ("total_amount" > 0), + CONSTRAINT "chk_rate_quotes_transit_days" CHECK ("transit_days" > 0), + CONSTRAINT "chk_rate_quotes_availability" CHECK ("availability" >= 0), + CONSTRAINT "chk_rate_quotes_eta" CHECK ("eta" > "etd"), + CONSTRAINT "chk_rate_quotes_mode" CHECK ("mode" IN ('FCL', 'LCL')) + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_carrier" ON "rate_quotes" ("carrier_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_origin_dest" ON "rate_quotes" ("origin_code", "destination_code") + `); + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_container_type" ON "rate_quotes" ("container_type") + `); + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_etd" ON "rate_quotes" ("etd") + `); + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_valid_until" ON "rate_quotes" ("valid_until") + `); + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_created_at" ON "rate_quotes" ("created_at") + `); + + // Composite index for rate search + await queryRunner.query(` + CREATE INDEX "idx_rate_quotes_search" ON "rate_quotes" + ("origin_code", "destination_code", "container_type", "etd") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "rate_quotes" IS 'Shipping rate quotes from carriers (15-min cache)' + `); + await queryRunner.query(` + COMMENT ON COLUMN "rate_quotes"."valid_until" IS 'Quote expiry time (created_at + 15 minutes)' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "rate_quotes"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000006-SeedCarriersAndOrganizations.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000006-SeedCarriersAndOrganizations.ts index e39797e..a345b10 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000006-SeedCarriersAndOrganizations.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000006-SeedCarriersAndOrganizations.ts @@ -1,25 +1,29 @@ -/** - * Migration: Seed Carriers and Test Organizations - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; -import { getCarriersInsertSQL } from '../seeds/carriers.seed'; -import { getOrganizationsInsertSQL } from '../seeds/test-organizations.seed'; - -export class SeedCarriersAndOrganizations1730000000006 implements MigrationInterface { - name = 'SeedCarriersAndOrganizations1730000000006'; - - public async up(queryRunner: QueryRunner): Promise { - // Seed test organizations - await queryRunner.query(getOrganizationsInsertSQL()); - - // Seed carriers - await queryRunner.query(getCarriersInsertSQL()); - } - - public async down(queryRunner: QueryRunner): Promise { - // Delete seeded data - await queryRunner.query(`DELETE FROM "carriers" WHERE "code" IN ('MAERSK', 'MSC', 'CMA_CGM', 'HAPAG_LLOYD', 'ONE')`); - await queryRunner.query(`DELETE FROM "organizations" WHERE "name" IN ('Test Freight Forwarder Inc.', 'Demo Shipping Company', 'Sample Shipper Ltd.')`); - } -} +/** + * Migration: Seed Carriers and Test Organizations + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { getCarriersInsertSQL } from '../seeds/carriers.seed'; +import { getOrganizationsInsertSQL } from '../seeds/test-organizations.seed'; + +export class SeedCarriersAndOrganizations1730000000006 implements MigrationInterface { + name = 'SeedCarriersAndOrganizations1730000000006'; + + public async up(queryRunner: QueryRunner): Promise { + // Seed test organizations + await queryRunner.query(getOrganizationsInsertSQL()); + + // Seed carriers + await queryRunner.query(getCarriersInsertSQL()); + } + + public async down(queryRunner: QueryRunner): Promise { + // Delete seeded data + await queryRunner.query( + `DELETE FROM "carriers" WHERE "code" IN ('MAERSK', 'MSC', 'CMA_CGM', 'HAPAG_LLOYD', 'ONE')` + ); + await queryRunner.query( + `DELETE FROM "organizations" WHERE "name" IN ('Test Freight Forwarder Inc.', 'Demo Shipping Company', 'Sample Shipper Ltd.')` + ); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts index 4ffa08e..8e7ec46 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts @@ -17,7 +17,9 @@ export class SeedTestUsers1730000000007 implements MigrationInterface { `); if (result.length === 0) { - throw new Error('No organization found to seed users. Please run organization seed migration first.'); + throw new Error( + 'No organization found to seed users. Please run organization seed migration first.' + ); } const organizationId = result[0].id; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000011-CreateCsvRateConfigs.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000011-CreateCsvRateConfigs.ts index b0d488d..0b3e290 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000011-CreateCsvRateConfigs.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000011-CreateCsvRateConfigs.ts @@ -1,164 +1,164 @@ -/** - * Migration: Create CSV Rate Configs Table - * - * Stores configuration mapping company names to CSV rate files - * Used by CSV-based rate search system - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateCsvRateConfigs1730000000011 implements MigrationInterface { - name = 'CreateCsvRateConfigs1730000000011'; - - public async up(queryRunner: QueryRunner): Promise { - // Create csv_rate_configs table - await queryRunner.query(` - CREATE TABLE "csv_rate_configs" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "company_name" VARCHAR(255) NOT NULL, - "csv_file_path" VARCHAR(500) NOT NULL, - "type" VARCHAR(50) NOT NULL DEFAULT 'CSV_ONLY', - "has_api" BOOLEAN NOT NULL DEFAULT FALSE, - "api_connector" VARCHAR(100) NULL, - "is_active" BOOLEAN NOT NULL DEFAULT TRUE, - "uploaded_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "uploaded_by" UUID NULL, - "last_validated_at" TIMESTAMP NULL, - "row_count" INTEGER NULL, - "metadata" JSONB NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_csv_rate_configs" PRIMARY KEY ("id"), - CONSTRAINT "uq_csv_rate_configs_company" UNIQUE ("company_name"), - CONSTRAINT "chk_csv_rate_configs_type" CHECK ("type" IN ('CSV_ONLY', 'CSV_AND_API')) - ) - `); - - // Create indexes - await queryRunner.query(` - CREATE INDEX "idx_csv_rate_configs_company" ON "csv_rate_configs" ("company_name") - `); - await queryRunner.query(` - CREATE INDEX "idx_csv_rate_configs_active" ON "csv_rate_configs" ("is_active") - `); - await queryRunner.query(` - CREATE INDEX "idx_csv_rate_configs_has_api" ON "csv_rate_configs" ("has_api") - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "csv_rate_configs" IS 'Configuration for CSV-based shipping rate files' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."company_name" IS 'Carrier company name (must be unique)' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."csv_file_path" IS 'Relative path to CSV file from rates directory' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."type" IS 'Integration type: CSV_ONLY or CSV_AND_API' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."has_api" IS 'Whether company has API connector available' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."api_connector" IS 'Name of API connector class if has_api=true' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."row_count" IS 'Number of rate rows in CSV file' - `); - await queryRunner.query(` - COMMENT ON COLUMN "csv_rate_configs"."metadata" IS 'Additional metadata (validation results, etc.)' - `); - - // Add foreign key to users table for uploaded_by - await queryRunner.query(` - ALTER TABLE "csv_rate_configs" - ADD CONSTRAINT "fk_csv_rate_configs_user" - FOREIGN KEY ("uploaded_by") - REFERENCES "users"("id") - ON DELETE SET NULL - `); - - // Seed initial CSV rate configurations - await queryRunner.query(` - INSERT INTO "csv_rate_configs" ( - "company_name", - "csv_file_path", - "type", - "has_api", - "api_connector", - "is_active", - "metadata" - ) VALUES - ( - 'SSC Consolidation', - 'ssc-consolidation.csv', - 'CSV_ONLY', - FALSE, - NULL, - TRUE, - '{"description": "SSC Consolidation LCL rates", "coverage": "Europe to US/Asia"}'::jsonb - ), - ( - 'ECU Worldwide', - 'ecu-worldwide.csv', - 'CSV_AND_API', - TRUE, - 'ecu-worldwide', - TRUE, - '{"description": "ECU Worldwide LCL rates with API fallback", "coverage": "Europe to US/Asia", "api_portal": "https://api-portal.ecuworldwide.com"}'::jsonb - ), - ( - 'TCC Logistics', - 'tcc-logistics.csv', - 'CSV_ONLY', - FALSE, - NULL, - TRUE, - '{"description": "TCC Logistics LCL rates", "coverage": "Europe to US/Asia"}'::jsonb - ), - ( - 'NVO Consolidation', - 'nvo-consolidation.csv', - 'CSV_ONLY', - FALSE, - NULL, - TRUE, - '{"description": "NVO Consolidation LCL rates", "coverage": "Europe to US/Asia", "note": "Netherlands-based NVOCC"}'::jsonb - ), - ( - 'Test Maritime Express', - 'test-maritime-express.csv', - 'CSV_ONLY', - FALSE, - NULL, - TRUE, - '{"description": "Fictional carrier for testing comparator", "coverage": "Europe to US/Asia", "note": "10-20% cheaper pricing for testing purposes"}'::jsonb - ) - `); - } - - public async down(queryRunner: QueryRunner): Promise { - // Drop foreign key - await queryRunner.query(` - ALTER TABLE "csv_rate_configs" DROP CONSTRAINT "fk_csv_rate_configs_user" - `); - - // Drop indexes - await queryRunner.query(` - DROP INDEX "idx_csv_rate_configs_has_api" - `); - await queryRunner.query(` - DROP INDEX "idx_csv_rate_configs_active" - `); - await queryRunner.query(` - DROP INDEX "idx_csv_rate_configs_company" - `); - - // Drop table - await queryRunner.query(` - DROP TABLE "csv_rate_configs" - `); - } -} +/** + * Migration: Create CSV Rate Configs Table + * + * Stores configuration mapping company names to CSV rate files + * Used by CSV-based rate search system + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCsvRateConfigs1730000000011 implements MigrationInterface { + name = 'CreateCsvRateConfigs1730000000011'; + + public async up(queryRunner: QueryRunner): Promise { + // Create csv_rate_configs table + await queryRunner.query(` + CREATE TABLE "csv_rate_configs" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "company_name" VARCHAR(255) NOT NULL, + "csv_file_path" VARCHAR(500) NOT NULL, + "type" VARCHAR(50) NOT NULL DEFAULT 'CSV_ONLY', + "has_api" BOOLEAN NOT NULL DEFAULT FALSE, + "api_connector" VARCHAR(100) NULL, + "is_active" BOOLEAN NOT NULL DEFAULT TRUE, + "uploaded_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "uploaded_by" UUID NULL, + "last_validated_at" TIMESTAMP NULL, + "row_count" INTEGER NULL, + "metadata" JSONB NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_csv_rate_configs" PRIMARY KEY ("id"), + CONSTRAINT "uq_csv_rate_configs_company" UNIQUE ("company_name"), + CONSTRAINT "chk_csv_rate_configs_type" CHECK ("type" IN ('CSV_ONLY', 'CSV_AND_API')) + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_csv_rate_configs_company" ON "csv_rate_configs" ("company_name") + `); + await queryRunner.query(` + CREATE INDEX "idx_csv_rate_configs_active" ON "csv_rate_configs" ("is_active") + `); + await queryRunner.query(` + CREATE INDEX "idx_csv_rate_configs_has_api" ON "csv_rate_configs" ("has_api") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "csv_rate_configs" IS 'Configuration for CSV-based shipping rate files' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."company_name" IS 'Carrier company name (must be unique)' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."csv_file_path" IS 'Relative path to CSV file from rates directory' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."type" IS 'Integration type: CSV_ONLY or CSV_AND_API' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."has_api" IS 'Whether company has API connector available' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."api_connector" IS 'Name of API connector class if has_api=true' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."row_count" IS 'Number of rate rows in CSV file' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."metadata" IS 'Additional metadata (validation results, etc.)' + `); + + // Add foreign key to users table for uploaded_by + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + ADD CONSTRAINT "fk_csv_rate_configs_user" + FOREIGN KEY ("uploaded_by") + REFERENCES "users"("id") + ON DELETE SET NULL + `); + + // Seed initial CSV rate configurations + await queryRunner.query(` + INSERT INTO "csv_rate_configs" ( + "company_name", + "csv_file_path", + "type", + "has_api", + "api_connector", + "is_active", + "metadata" + ) VALUES + ( + 'SSC Consolidation', + 'ssc-consolidation.csv', + 'CSV_ONLY', + FALSE, + NULL, + TRUE, + '{"description": "SSC Consolidation LCL rates", "coverage": "Europe to US/Asia"}'::jsonb + ), + ( + 'ECU Worldwide', + 'ecu-worldwide.csv', + 'CSV_AND_API', + TRUE, + 'ecu-worldwide', + TRUE, + '{"description": "ECU Worldwide LCL rates with API fallback", "coverage": "Europe to US/Asia", "api_portal": "https://api-portal.ecuworldwide.com"}'::jsonb + ), + ( + 'TCC Logistics', + 'tcc-logistics.csv', + 'CSV_ONLY', + FALSE, + NULL, + TRUE, + '{"description": "TCC Logistics LCL rates", "coverage": "Europe to US/Asia"}'::jsonb + ), + ( + 'NVO Consolidation', + 'nvo-consolidation.csv', + 'CSV_ONLY', + FALSE, + NULL, + TRUE, + '{"description": "NVO Consolidation LCL rates", "coverage": "Europe to US/Asia", "note": "Netherlands-based NVOCC"}'::jsonb + ), + ( + 'Test Maritime Express', + 'test-maritime-express.csv', + 'CSV_ONLY', + FALSE, + NULL, + TRUE, + '{"description": "Fictional carrier for testing comparator", "coverage": "Europe to US/Asia", "note": "10-20% cheaper pricing for testing purposes"}'::jsonb + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign key + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" DROP CONSTRAINT "fk_csv_rate_configs_user" + `); + + // Drop indexes + await queryRunner.query(` + DROP INDEX "idx_csv_rate_configs_has_api" + `); + await queryRunner.query(` + DROP INDEX "idx_csv_rate_configs_active" + `); + await queryRunner.query(` + DROP INDEX "idx_csv_rate_configs_company" + `); + + // Drop table + await queryRunner.query(` + DROP TABLE "csv_rate_configs" + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts index 32b95a5..cefb832 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts @@ -1,11 +1,11 @@ -/** - * TypeORM Repositories Barrel Export - * - * All repository implementations - */ - -export * from './typeorm-organization.repository'; -export * from './typeorm-user.repository'; -export * from './typeorm-carrier.repository'; -export * from './typeorm-port.repository'; -export * from './typeorm-rate-quote.repository'; +/** + * TypeORM Repositories Barrel Export + * + * All repository implementations + */ + +export * from './typeorm-organization.repository'; +export * from './typeorm-user.repository'; +export * from './typeorm-carrier.repository'; +export * from './typeorm-port.repository'; +export * from './typeorm-rate-quote.repository'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts index dabf25d..f018bef 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository.ts @@ -16,7 +16,7 @@ import { AuditLogOrmEntity } from '../entities/audit-log.orm-entity'; export class TypeOrmAuditLogRepository implements AuditLogRepository { constructor( @InjectRepository(AuditLogOrmEntity) - private readonly ormRepository: Repository, + private readonly ormRepository: Repository ) {} async save(auditLog: AuditLog): Promise { @@ -77,7 +77,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository { } const ormEntities = await query.getMany(); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async count(filters: AuditLogFilters): Promise { @@ -131,7 +131,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository { }, }); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async findRecentByOrganization(organizationId: string, limit: number): Promise { @@ -145,7 +145,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository { take: limit, }); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async findByUser(userId: string, limit: number): Promise { @@ -159,7 +159,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository { take: limit, }); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } /** diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-carrier.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-carrier.repository.ts index 3230542..99868ac 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-carrier.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-carrier.repository.ts @@ -1,85 +1,85 @@ -/** - * TypeORM Carrier Repository - * - * Implements CarrierRepository interface using TypeORM - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Carrier } from '../../../../domain/entities/carrier.entity'; -import { CarrierRepository } from '../../../../domain/ports/out/carrier.repository'; -import { CarrierOrmEntity } from '../entities/carrier.orm-entity'; -import { CarrierOrmMapper } from '../mappers/carrier-orm.mapper'; - -@Injectable() -export class TypeOrmCarrierRepository implements CarrierRepository { - constructor( - @InjectRepository(CarrierOrmEntity) - private readonly repository: Repository - ) {} - - async save(carrier: Carrier): Promise { - const orm = CarrierOrmMapper.toOrm(carrier); - const saved = await this.repository.save(orm); - return CarrierOrmMapper.toDomain(saved); - } - - async saveMany(carriers: Carrier[]): Promise { - const orms = carriers.map((carrier) => CarrierOrmMapper.toOrm(carrier)); - const saved = await this.repository.save(orms); - return CarrierOrmMapper.toDomainMany(saved); - } - - async findById(id: string): Promise { - const orm = await this.repository.findOne({ where: { id } }); - return orm ? CarrierOrmMapper.toDomain(orm) : null; - } - - async findByCode(code: string): Promise { - const orm = await this.repository.findOne({ - where: { code: code.toUpperCase() }, - }); - return orm ? CarrierOrmMapper.toDomain(orm) : null; - } - - async findByScac(scac: string): Promise { - const orm = await this.repository.findOne({ - where: { scac: scac.toUpperCase() }, - }); - return orm ? CarrierOrmMapper.toDomain(orm) : null; - } - - async findAllActive(): Promise { - const orms = await this.repository.find({ - where: { isActive: true }, - order: { name: 'ASC' }, - }); - return CarrierOrmMapper.toDomainMany(orms); - } - - async findWithApiSupport(): Promise { - const orms = await this.repository.find({ - where: { supportsApi: true, isActive: true }, - order: { name: 'ASC' }, - }); - return CarrierOrmMapper.toDomainMany(orms); - } - - async findAll(): Promise { - const orms = await this.repository.find({ - order: { name: 'ASC' }, - }); - return CarrierOrmMapper.toDomainMany(orms); - } - - async update(carrier: Carrier): Promise { - const orm = CarrierOrmMapper.toOrm(carrier); - const updated = await this.repository.save(orm); - return CarrierOrmMapper.toDomain(updated); - } - - async deleteById(id: string): Promise { - await this.repository.delete({ id }); - } -} +/** + * TypeORM Carrier Repository + * + * Implements CarrierRepository interface using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Carrier } from '../../../../domain/entities/carrier.entity'; +import { CarrierRepository } from '../../../../domain/ports/out/carrier.repository'; +import { CarrierOrmEntity } from '../entities/carrier.orm-entity'; +import { CarrierOrmMapper } from '../mappers/carrier-orm.mapper'; + +@Injectable() +export class TypeOrmCarrierRepository implements CarrierRepository { + constructor( + @InjectRepository(CarrierOrmEntity) + private readonly repository: Repository + ) {} + + async save(carrier: Carrier): Promise { + const orm = CarrierOrmMapper.toOrm(carrier); + const saved = await this.repository.save(orm); + return CarrierOrmMapper.toDomain(saved); + } + + async saveMany(carriers: Carrier[]): Promise { + const orms = carriers.map(carrier => CarrierOrmMapper.toOrm(carrier)); + const saved = await this.repository.save(orms); + return CarrierOrmMapper.toDomainMany(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? CarrierOrmMapper.toDomain(orm) : null; + } + + async findByCode(code: string): Promise { + const orm = await this.repository.findOne({ + where: { code: code.toUpperCase() }, + }); + return orm ? CarrierOrmMapper.toDomain(orm) : null; + } + + async findByScac(scac: string): Promise { + const orm = await this.repository.findOne({ + where: { scac: scac.toUpperCase() }, + }); + return orm ? CarrierOrmMapper.toDomain(orm) : null; + } + + async findAllActive(): Promise { + const orms = await this.repository.find({ + where: { isActive: true }, + order: { name: 'ASC' }, + }); + return CarrierOrmMapper.toDomainMany(orms); + } + + async findWithApiSupport(): Promise { + const orms = await this.repository.find({ + where: { supportsApi: true, isActive: true }, + order: { name: 'ASC' }, + }); + return CarrierOrmMapper.toDomainMany(orms); + } + + async findAll(): Promise { + const orms = await this.repository.find({ + order: { name: 'ASC' }, + }); + return CarrierOrmMapper.toDomainMany(orms); + } + + async update(carrier: Carrier): Promise { + const orm = CarrierOrmMapper.toOrm(carrier); + const updated = await this.repository.save(orm); + return CarrierOrmMapper.toDomain(updated); + } + + async deleteById(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts index 845d8ce..59acf4c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts @@ -1,187 +1,187 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CsvRateConfigOrmEntity } from '../entities/csv-rate-config.orm-entity'; - -/** - * CSV Rate Config Repository Port - * - * Interface for CSV rate configuration operations - */ -export interface CsvRateConfigRepositoryPort { - findAll(): Promise; - findByCompanyName(companyName: string): Promise; - findActiveConfigs(): Promise; - create(config: Partial): Promise; - update(id: string, config: Partial): Promise; - delete(companyName: string): Promise; - exists(companyName: string): Promise; -} - -/** - * TypeORM CSV Rate Config Repository - * - * Implementation of CSV rate configuration repository using TypeORM - */ -@Injectable() -export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPort { - private readonly logger = new Logger(TypeOrmCsvRateConfigRepository.name); - - constructor( - @InjectRepository(CsvRateConfigOrmEntity) - private readonly repository: Repository, - ) {} - - /** - * Find all CSV rate configurations - */ - async findAll(): Promise { - this.logger.log('Finding all CSV rate configs'); - return this.repository.find({ - order: { companyName: 'ASC' }, - }); - } - - /** - * Find configuration by company name - */ - async findByCompanyName(companyName: string): Promise { - this.logger.log(`Finding CSV rate config for company: ${companyName}`); - return this.repository.findOne({ - where: { companyName }, - }); - } - - /** - * Find only active configurations - */ - async findActiveConfigs(): Promise { - this.logger.log('Finding active CSV rate configs'); - return this.repository.find({ - where: { isActive: true }, - order: { companyName: 'ASC' }, - }); - } - - /** - * Create new CSV rate configuration - */ - async create(config: Partial): Promise { - this.logger.log(`Creating CSV rate config for company: ${config.companyName}`); - - // Check if company already exists - const existing = await this.findByCompanyName(config.companyName!); - if (existing) { - throw new Error(`CSV rate config already exists for company: ${config.companyName}`); - } - - const entity = this.repository.create({ - ...config, - uploadedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }); - - return this.repository.save(entity); - } - - /** - * Update existing CSV rate configuration - */ - async update( - id: string, - config: Partial, - ): Promise { - this.logger.log(`Updating CSV rate config: ${id}`); - - const existing = await this.repository.findOne({ where: { id } }); - if (!existing) { - throw new Error(`CSV rate config not found: ${id}`); - } - - // Update entity - Object.assign(existing, config); - existing.updatedAt = new Date(); - - return this.repository.save(existing); - } - - /** - * Delete CSV rate configuration by company name - */ - async delete(companyName: string): Promise { - this.logger.log(`Deleting CSV rate config for company: ${companyName}`); - - const result = await this.repository.delete({ companyName }); - - if (result.affected === 0) { - throw new Error(`CSV rate config not found for company: ${companyName}`); - } - - this.logger.log(`Deleted CSV rate config for company: ${companyName}`); - } - - /** - * Check if configuration exists for company - */ - async exists(companyName: string): Promise { - const count = await this.repository.count({ - where: { companyName }, - }); - return count > 0; - } - - /** - * Update row count and validation timestamp - */ - async updateValidationInfo( - companyName: string, - rowCount: number, - validationResult: { valid: boolean; errors: string[] }, - ): Promise { - this.logger.log(`Updating validation info for company: ${companyName}`); - - const config = await this.findByCompanyName(companyName); - if (!config) { - throw new Error(`CSV rate config not found for company: ${companyName}`); - } - - await this.repository.update( - { companyName }, - { - rowCount, - lastValidatedAt: new Date(), - metadata: { - ...(config.metadata || {}), - lastValidation: { - valid: validationResult.valid, - errors: validationResult.errors, - timestamp: new Date().toISOString(), - }, - } as any, - }, - ); - } - - /** - * Get all companies with API support - */ - async findWithApiSupport(): Promise { - this.logger.log('Finding CSV rate configs with API support'); - return this.repository.find({ - where: { hasApi: true, isActive: true }, - order: { companyName: 'ASC' }, - }); - } - - /** - * Get all companies without API (CSV only) - */ - async findCsvOnly(): Promise { - this.logger.log('Finding CSV-only rate configs'); - return this.repository.find({ - where: { hasApi: false, isActive: true }, - order: { companyName: 'ASC' }, - }); - } -} +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CsvRateConfigOrmEntity } from '../entities/csv-rate-config.orm-entity'; + +/** + * CSV Rate Config Repository Port + * + * Interface for CSV rate configuration operations + */ +export interface CsvRateConfigRepositoryPort { + findAll(): Promise; + findByCompanyName(companyName: string): Promise; + findActiveConfigs(): Promise; + create(config: Partial): Promise; + update(id: string, config: Partial): Promise; + delete(companyName: string): Promise; + exists(companyName: string): Promise; +} + +/** + * TypeORM CSV Rate Config Repository + * + * Implementation of CSV rate configuration repository using TypeORM + */ +@Injectable() +export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPort { + private readonly logger = new Logger(TypeOrmCsvRateConfigRepository.name); + + constructor( + @InjectRepository(CsvRateConfigOrmEntity) + private readonly repository: Repository + ) {} + + /** + * Find all CSV rate configurations + */ + async findAll(): Promise { + this.logger.log('Finding all CSV rate configs'); + return this.repository.find({ + order: { companyName: 'ASC' }, + }); + } + + /** + * Find configuration by company name + */ + async findByCompanyName(companyName: string): Promise { + this.logger.log(`Finding CSV rate config for company: ${companyName}`); + return this.repository.findOne({ + where: { companyName }, + }); + } + + /** + * Find only active configurations + */ + async findActiveConfigs(): Promise { + this.logger.log('Finding active CSV rate configs'); + return this.repository.find({ + where: { isActive: true }, + order: { companyName: 'ASC' }, + }); + } + + /** + * Create new CSV rate configuration + */ + async create(config: Partial): Promise { + this.logger.log(`Creating CSV rate config for company: ${config.companyName}`); + + // Check if company already exists + const existing = await this.findByCompanyName(config.companyName!); + if (existing) { + throw new Error(`CSV rate config already exists for company: ${config.companyName}`); + } + + const entity = this.repository.create({ + ...config, + uploadedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + + return this.repository.save(entity); + } + + /** + * Update existing CSV rate configuration + */ + async update( + id: string, + config: Partial + ): Promise { + this.logger.log(`Updating CSV rate config: ${id}`); + + const existing = await this.repository.findOne({ where: { id } }); + if (!existing) { + throw new Error(`CSV rate config not found: ${id}`); + } + + // Update entity + Object.assign(existing, config); + existing.updatedAt = new Date(); + + return this.repository.save(existing); + } + + /** + * Delete CSV rate configuration by company name + */ + async delete(companyName: string): Promise { + this.logger.log(`Deleting CSV rate config for company: ${companyName}`); + + const result = await this.repository.delete({ companyName }); + + if (result.affected === 0) { + throw new Error(`CSV rate config not found for company: ${companyName}`); + } + + this.logger.log(`Deleted CSV rate config for company: ${companyName}`); + } + + /** + * Check if configuration exists for company + */ + async exists(companyName: string): Promise { + const count = await this.repository.count({ + where: { companyName }, + }); + return count > 0; + } + + /** + * Update row count and validation timestamp + */ + async updateValidationInfo( + companyName: string, + rowCount: number, + validationResult: { valid: boolean; errors: string[] } + ): Promise { + this.logger.log(`Updating validation info for company: ${companyName}`); + + const config = await this.findByCompanyName(companyName); + if (!config) { + throw new Error(`CSV rate config not found for company: ${companyName}`); + } + + await this.repository.update( + { companyName }, + { + rowCount, + lastValidatedAt: new Date(), + metadata: { + ...(config.metadata || {}), + lastValidation: { + valid: validationResult.valid, + errors: validationResult.errors, + timestamp: new Date().toISOString(), + }, + } as any, + } + ); + } + + /** + * Get all companies with API support + */ + async findWithApiSupport(): Promise { + this.logger.log('Finding CSV rate configs with API support'); + return this.repository.find({ + where: { hasApi: true, isActive: true }, + order: { companyName: 'ASC' }, + }); + } + + /** + * Get all companies without API (CSV only) + */ + async findCsvOnly(): Promise { + this.logger.log('Finding CSV-only rate configs'); + return this.repository.find({ + where: { hasApi: false, isActive: true }, + order: { companyName: 'ASC' }, + }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts index 95ea7b5..ad384a8 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts @@ -16,7 +16,7 @@ import { NotificationOrmEntity } from '../entities/notification.orm-entity'; export class TypeOrmNotificationRepository implements NotificationRepository { constructor( @InjectRepository(NotificationOrmEntity) - private readonly ormRepository: Repository, + private readonly ormRepository: Repository ) {} async save(notification: Notification): Promise { @@ -79,7 +79,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository { } const ormEntities = await query.getMany(); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async count(filters: NotificationFilters): Promise { @@ -131,7 +131,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository { take: limit, }); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async countUnreadByUser(userId: string): Promise { @@ -147,7 +147,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository { take: limit, }); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async markAsRead(id: string): Promise { @@ -163,7 +163,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository { { read: true, read_at: new Date(), - }, + } ); } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts index 21f6e43..d115f4c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts @@ -1,117 +1,114 @@ -/** - * TypeORM Port Repository - * - * Implements PortRepository interface using TypeORM - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, ILike } from 'typeorm'; -import { Port } from '../../../../domain/entities/port.entity'; -import { PortRepository } from '../../../../domain/ports/out/port.repository'; -import { PortOrmEntity } from '../entities/port.orm-entity'; -import { PortOrmMapper } from '../mappers/port-orm.mapper'; - -@Injectable() -export class TypeOrmPortRepository implements PortRepository { - constructor( - @InjectRepository(PortOrmEntity) - private readonly repository: Repository - ) {} - - async save(port: Port): Promise { - const orm = PortOrmMapper.toOrm(port); - const saved = await this.repository.save(orm); - return PortOrmMapper.toDomain(saved); - } - - async saveMany(ports: Port[]): Promise { - const orms = ports.map((port) => PortOrmMapper.toOrm(port)); - const saved = await this.repository.save(orms); - return PortOrmMapper.toDomainMany(saved); - } - - async findByCode(code: string): Promise { - const orm = await this.repository.findOne({ - where: { code: code.toUpperCase() }, - }); - return orm ? PortOrmMapper.toDomain(orm) : null; - } - - async findByCodes(codes: string[]): Promise { - const upperCodes = codes.map((c) => c.toUpperCase()); - const orms = await this.repository - .createQueryBuilder('port') - .where('port.code IN (:...codes)', { codes: upperCodes }) - .getMany(); - return PortOrmMapper.toDomainMany(orms); - } - - async search(query: string, limit = 10, countryFilter?: string): Promise { - const qb = this.repository - .createQueryBuilder('port') - .where('port.is_active = :isActive', { isActive: true }); - - // Fuzzy search using pg_trgm (trigram similarity) - // First try exact match on code - qb.andWhere( - '(port.code ILIKE :code OR port.name ILIKE :name OR port.city ILIKE :city)', - { - code: `${query}%`, - name: `%${query}%`, - city: `%${query}%`, - } - ); - - if (countryFilter) { - qb.andWhere('port.country = :country', { country: countryFilter.toUpperCase() }); - } - - // Order by relevance: exact code match first, then name, then city - qb.orderBy( - `CASE - WHEN port.code ILIKE :exactCode THEN 1 - WHEN port.name ILIKE :exactName THEN 2 - WHEN port.code ILIKE :startCode THEN 3 - WHEN port.name ILIKE :startName THEN 4 - ELSE 5 - END`, - 'ASC' - ); - qb.setParameters({ - exactCode: query.toUpperCase(), - exactName: query, - startCode: `${query.toUpperCase()}%`, - startName: `${query}%`, - }); - - qb.limit(limit); - - const orms = await qb.getMany(); - return PortOrmMapper.toDomainMany(orms); - } - - async findAllActive(): Promise { - const orms = await this.repository.find({ - where: { isActive: true }, - order: { name: 'ASC' }, - }); - return PortOrmMapper.toDomainMany(orms); - } - - async findByCountry(countryCode: string): Promise { - const orms = await this.repository.find({ - where: { country: countryCode.toUpperCase(), isActive: true }, - order: { name: 'ASC' }, - }); - return PortOrmMapper.toDomainMany(orms); - } - - async count(): Promise { - return this.repository.count(); - } - - async deleteByCode(code: string): Promise { - await this.repository.delete({ code: code.toUpperCase() }); - } -} +/** + * TypeORM Port Repository + * + * Implements PortRepository interface using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike } from 'typeorm'; +import { Port } from '../../../../domain/entities/port.entity'; +import { PortRepository } from '../../../../domain/ports/out/port.repository'; +import { PortOrmEntity } from '../entities/port.orm-entity'; +import { PortOrmMapper } from '../mappers/port-orm.mapper'; + +@Injectable() +export class TypeOrmPortRepository implements PortRepository { + constructor( + @InjectRepository(PortOrmEntity) + private readonly repository: Repository + ) {} + + async save(port: Port): Promise { + const orm = PortOrmMapper.toOrm(port); + const saved = await this.repository.save(orm); + return PortOrmMapper.toDomain(saved); + } + + async saveMany(ports: Port[]): Promise { + const orms = ports.map(port => PortOrmMapper.toOrm(port)); + const saved = await this.repository.save(orms); + return PortOrmMapper.toDomainMany(saved); + } + + async findByCode(code: string): Promise { + const orm = await this.repository.findOne({ + where: { code: code.toUpperCase() }, + }); + return orm ? PortOrmMapper.toDomain(orm) : null; + } + + async findByCodes(codes: string[]): Promise { + const upperCodes = codes.map(c => c.toUpperCase()); + const orms = await this.repository + .createQueryBuilder('port') + .where('port.code IN (:...codes)', { codes: upperCodes }) + .getMany(); + return PortOrmMapper.toDomainMany(orms); + } + + async search(query: string, limit = 10, countryFilter?: string): Promise { + const qb = this.repository + .createQueryBuilder('port') + .where('port.is_active = :isActive', { isActive: true }); + + // Fuzzy search using pg_trgm (trigram similarity) + // First try exact match on code + qb.andWhere('(port.code ILIKE :code OR port.name ILIKE :name OR port.city ILIKE :city)', { + code: `${query}%`, + name: `%${query}%`, + city: `%${query}%`, + }); + + if (countryFilter) { + qb.andWhere('port.country = :country', { country: countryFilter.toUpperCase() }); + } + + // Order by relevance: exact code match first, then name, then city + qb.orderBy( + `CASE + WHEN port.code ILIKE :exactCode THEN 1 + WHEN port.name ILIKE :exactName THEN 2 + WHEN port.code ILIKE :startCode THEN 3 + WHEN port.name ILIKE :startName THEN 4 + ELSE 5 + END`, + 'ASC' + ); + qb.setParameters({ + exactCode: query.toUpperCase(), + exactName: query, + startCode: `${query.toUpperCase()}%`, + startName: `${query}%`, + }); + + qb.limit(limit); + + const orms = await qb.getMany(); + return PortOrmMapper.toDomainMany(orms); + } + + async findAllActive(): Promise { + const orms = await this.repository.find({ + where: { isActive: true }, + order: { name: 'ASC' }, + }); + return PortOrmMapper.toDomainMany(orms); + } + + async findByCountry(countryCode: string): Promise { + const orms = await this.repository.find({ + where: { country: countryCode.toUpperCase(), isActive: true }, + order: { name: 'ASC' }, + }); + return PortOrmMapper.toDomainMany(orms); + } + + async count(): Promise { + return this.repository.count(); + } + + async deleteByCode(code: string): Promise { + await this.repository.delete({ code: code.toUpperCase() }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository.ts index a86cc5e..ff19c43 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository.ts @@ -1,84 +1,84 @@ -/** - * TypeORM RateQuote Repository - * - * Implements RateQuoteRepository interface using TypeORM - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan } from 'typeorm'; -import { RateQuote } from '../../../../domain/entities/rate-quote.entity'; -import { RateQuoteRepository } from '../../../../domain/ports/out/rate-quote.repository'; -import { RateQuoteOrmEntity } from '../entities/rate-quote.orm-entity'; -import { RateQuoteOrmMapper } from '../mappers/rate-quote-orm.mapper'; - -@Injectable() -export class TypeOrmRateQuoteRepository implements RateQuoteRepository { - constructor( - @InjectRepository(RateQuoteOrmEntity) - private readonly repository: Repository - ) {} - - async save(rateQuote: RateQuote): Promise { - const orm = RateQuoteOrmMapper.toOrm(rateQuote); - const saved = await this.repository.save(orm); - return RateQuoteOrmMapper.toDomain(saved); - } - - async saveMany(rateQuotes: RateQuote[]): Promise { - const orms = rateQuotes.map((rq) => RateQuoteOrmMapper.toOrm(rq)); - const saved = await this.repository.save(orms); - return RateQuoteOrmMapper.toDomainMany(saved); - } - - async findById(id: string): Promise { - const orm = await this.repository.findOne({ where: { id } }); - return orm ? RateQuoteOrmMapper.toDomain(orm) : null; - } - - async findBySearchCriteria(criteria: { - origin: string; - destination: string; - containerType: string; - departureDate: Date; - }): Promise { - const startOfDay = new Date(criteria.departureDate); - startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(criteria.departureDate); - endOfDay.setHours(23, 59, 59, 999); - - const orms = await this.repository - .createQueryBuilder('rq') - .where('rq.origin_code = :origin', { origin: criteria.origin.toUpperCase() }) - .andWhere('rq.destination_code = :destination', { - destination: criteria.destination.toUpperCase(), - }) - .andWhere('rq.container_type = :containerType', { containerType: criteria.containerType }) - .andWhere('rq.etd >= :startOfDay', { startOfDay }) - .andWhere('rq.etd <= :endOfDay', { endOfDay }) - .andWhere('rq.valid_until > :now', { now: new Date() }) - .orderBy('rq.total_amount', 'ASC') - .getMany(); - - return RateQuoteOrmMapper.toDomainMany(orms); - } - - async findByCarrier(carrierId: string): Promise { - const orms = await this.repository.find({ - where: { carrierId }, - order: { createdAt: 'DESC' }, - }); - return RateQuoteOrmMapper.toDomainMany(orms); - } - - async deleteExpired(): Promise { - const result = await this.repository.delete({ - validUntil: LessThan(new Date()), - }); - return result.affected || 0; - } - - async deleteById(id: string): Promise { - await this.repository.delete({ id }); - } -} +/** + * TypeORM RateQuote Repository + * + * Implements RateQuoteRepository interface using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { RateQuote } from '../../../../domain/entities/rate-quote.entity'; +import { RateQuoteRepository } from '../../../../domain/ports/out/rate-quote.repository'; +import { RateQuoteOrmEntity } from '../entities/rate-quote.orm-entity'; +import { RateQuoteOrmMapper } from '../mappers/rate-quote-orm.mapper'; + +@Injectable() +export class TypeOrmRateQuoteRepository implements RateQuoteRepository { + constructor( + @InjectRepository(RateQuoteOrmEntity) + private readonly repository: Repository + ) {} + + async save(rateQuote: RateQuote): Promise { + const orm = RateQuoteOrmMapper.toOrm(rateQuote); + const saved = await this.repository.save(orm); + return RateQuoteOrmMapper.toDomain(saved); + } + + async saveMany(rateQuotes: RateQuote[]): Promise { + const orms = rateQuotes.map(rq => RateQuoteOrmMapper.toOrm(rq)); + const saved = await this.repository.save(orms); + return RateQuoteOrmMapper.toDomainMany(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? RateQuoteOrmMapper.toDomain(orm) : null; + } + + async findBySearchCriteria(criteria: { + origin: string; + destination: string; + containerType: string; + departureDate: Date; + }): Promise { + const startOfDay = new Date(criteria.departureDate); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(criteria.departureDate); + endOfDay.setHours(23, 59, 59, 999); + + const orms = await this.repository + .createQueryBuilder('rq') + .where('rq.origin_code = :origin', { origin: criteria.origin.toUpperCase() }) + .andWhere('rq.destination_code = :destination', { + destination: criteria.destination.toUpperCase(), + }) + .andWhere('rq.container_type = :containerType', { containerType: criteria.containerType }) + .andWhere('rq.etd >= :startOfDay', { startOfDay }) + .andWhere('rq.etd <= :endOfDay', { endOfDay }) + .andWhere('rq.valid_until > :now', { now: new Date() }) + .orderBy('rq.total_amount', 'ASC') + .getMany(); + + return RateQuoteOrmMapper.toDomainMany(orms); + } + + async findByCarrier(carrierId: string): Promise { + const orms = await this.repository.find({ + where: { carrierId }, + order: { createdAt: 'DESC' }, + }); + return RateQuoteOrmMapper.toDomainMany(orms); + } + + async deleteExpired(): Promise { + const result = await this.repository.delete({ + validUntil: LessThan(new Date()), + }); + return result.affected || 0; + } + + async deleteById(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts index db315a2..c825dc6 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-user.repository.ts @@ -1,84 +1,84 @@ -/** - * TypeORM User Repository - * - * Implements UserRepository interface using TypeORM - */ - -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from '../../../../domain/entities/user.entity'; -import { UserRepository } from '../../../../domain/ports/out/user.repository'; -import { UserOrmEntity } from '../entities/user.orm-entity'; -import { UserOrmMapper } from '../mappers/user-orm.mapper'; - -@Injectable() -export class TypeOrmUserRepository implements UserRepository { - constructor( - @InjectRepository(UserOrmEntity) - private readonly repository: Repository - ) {} - - async save(user: User): Promise { - const orm = UserOrmMapper.toOrm(user); - const saved = await this.repository.save(orm); - return UserOrmMapper.toDomain(saved); - } - - async findById(id: string): Promise { - const orm = await this.repository.findOne({ where: { id } }); - return orm ? UserOrmMapper.toDomain(orm) : null; - } - - async findByEmail(email: string): Promise { - const orm = await this.repository.findOne({ - where: { email: email.toLowerCase() }, - }); - return orm ? UserOrmMapper.toDomain(orm) : null; - } - - async findByOrganization(organizationId: string): Promise { - const orms = await this.repository.find({ - where: { organizationId }, - order: { lastName: 'ASC', firstName: 'ASC' }, - }); - return UserOrmMapper.toDomainMany(orms); - } - - async findByRole(role: string): Promise { - const orms = await this.repository.find({ - where: { role }, - order: { lastName: 'ASC', firstName: 'ASC' }, - }); - return UserOrmMapper.toDomainMany(orms); - } - - async findAllActive(): Promise { - const orms = await this.repository.find({ - where: { isActive: true }, - order: { lastName: 'ASC', firstName: 'ASC' }, - }); - return UserOrmMapper.toDomainMany(orms); - } - - async update(user: User): Promise { - const orm = UserOrmMapper.toOrm(user); - const updated = await this.repository.save(orm); - return UserOrmMapper.toDomain(updated); - } - - async deleteById(id: string): Promise { - await this.repository.delete({ id }); - } - - async countByOrganization(organizationId: string): Promise { - return this.repository.count({ where: { organizationId } }); - } - - async emailExists(email: string): Promise { - const count = await this.repository.count({ - where: { email: email.toLowerCase() }, - }); - return count > 0; - } -} +/** + * TypeORM User Repository + * + * Implements UserRepository interface using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../../../domain/entities/user.entity'; +import { UserRepository } from '../../../../domain/ports/out/user.repository'; +import { UserOrmEntity } from '../entities/user.orm-entity'; +import { UserOrmMapper } from '../mappers/user-orm.mapper'; + +@Injectable() +export class TypeOrmUserRepository implements UserRepository { + constructor( + @InjectRepository(UserOrmEntity) + private readonly repository: Repository + ) {} + + async save(user: User): Promise { + const orm = UserOrmMapper.toOrm(user); + const saved = await this.repository.save(orm); + return UserOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? UserOrmMapper.toDomain(orm) : null; + } + + async findByEmail(email: string): Promise { + const orm = await this.repository.findOne({ + where: { email: email.toLowerCase() }, + }); + return orm ? UserOrmMapper.toDomain(orm) : null; + } + + async findByOrganization(organizationId: string): Promise { + const orms = await this.repository.find({ + where: { organizationId }, + order: { lastName: 'ASC', firstName: 'ASC' }, + }); + return UserOrmMapper.toDomainMany(orms); + } + + async findByRole(role: string): Promise { + const orms = await this.repository.find({ + where: { role }, + order: { lastName: 'ASC', firstName: 'ASC' }, + }); + return UserOrmMapper.toDomainMany(orms); + } + + async findAllActive(): Promise { + const orms = await this.repository.find({ + where: { isActive: true }, + order: { lastName: 'ASC', firstName: 'ASC' }, + }); + return UserOrmMapper.toDomainMany(orms); + } + + async update(user: User): Promise { + const orm = UserOrmMapper.toOrm(user); + const updated = await this.repository.save(orm); + return UserOrmMapper.toDomain(updated); + } + + async deleteById(id: string): Promise { + await this.repository.delete({ id }); + } + + async countByOrganization(organizationId: string): Promise { + return this.repository.count({ where: { organizationId } }); + } + + async emailExists(email: string): Promise { + const count = await this.repository.count({ + where: { email: email.toLowerCase() }, + }); + return count > 0; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts index 6082acc..5c6531b 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository.ts @@ -5,10 +5,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { - WebhookRepository, - WebhookFilters, -} from '../../../../domain/ports/out/webhook.repository'; +import { WebhookRepository, WebhookFilters } from '../../../../domain/ports/out/webhook.repository'; import { Webhook, WebhookEvent, WebhookStatus } from '../../../../domain/entities/webhook.entity'; import { WebhookOrmEntity } from '../entities/webhook.orm-entity'; @@ -16,7 +13,7 @@ import { WebhookOrmEntity } from '../entities/webhook.orm-entity'; export class TypeOrmWebhookRepository implements WebhookRepository { constructor( @InjectRepository(WebhookOrmEntity) - private readonly ormRepository: Repository, + private readonly ormRepository: Repository ) {} async save(webhook: Webhook): Promise { @@ -35,7 +32,7 @@ export class TypeOrmWebhookRepository implements WebhookRepository { order: { created_at: 'DESC' }, }); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async findActiveByEvent(event: WebhookEvent, organizationId: string): Promise { @@ -46,7 +43,7 @@ export class TypeOrmWebhookRepository implements WebhookRepository { .andWhere(':event = ANY(webhook.events)', { event }) .getMany(); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async findByFilters(filters: WebhookFilters): Promise { @@ -69,7 +66,7 @@ export class TypeOrmWebhookRepository implements WebhookRepository { query.orderBy('webhook.created_at', 'DESC'); const ormEntities = await query.getMany(); - return ormEntities.map((e) => this.toDomain(e)); + return ormEntities.map(e => this.toDomain(e)); } async delete(id: string): Promise { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/seeds/carriers.seed.ts b/apps/backend/src/infrastructure/persistence/typeorm/seeds/carriers.seed.ts index 62cf864..9a49200 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/seeds/carriers.seed.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/seeds/carriers.seed.ts @@ -1,93 +1,93 @@ -/** - * Carriers Seed Data - * - * Seeds the 5 major shipping carriers - */ - -import { v4 as uuidv4 } from 'uuid'; - -export interface CarrierSeed { - id: string; - name: string; - code: string; - scac: string; - logoUrl: string; - website: string; - supportsApi: boolean; - isActive: boolean; -} - -export const carrierSeeds: CarrierSeed[] = [ - { - id: uuidv4(), - name: 'Maersk Line', - code: 'MAERSK', - scac: 'MAEU', - logoUrl: 'https://www.maersk.com/~/media/maersk/logos/maersk-logo.svg', - website: 'https://www.maersk.com', - supportsApi: true, - isActive: true, - }, - { - id: uuidv4(), - name: 'Mediterranean Shipping Company (MSC)', - code: 'MSC', - scac: 'MSCU', - logoUrl: 'https://www.msc.com/themes/custom/msc_theme/logo.svg', - website: 'https://www.msc.com', - supportsApi: false, - isActive: true, - }, - { - id: uuidv4(), - name: 'CMA CGM', - code: 'CMA_CGM', - scac: 'CMDU', - logoUrl: 'https://www.cma-cgm.com/static/img/logo.svg', - website: 'https://www.cma-cgm.com', - supportsApi: false, - isActive: true, - }, - { - id: uuidv4(), - name: 'Hapag-Lloyd', - code: 'HAPAG_LLOYD', - scac: 'HLCU', - logoUrl: 'https://www.hapag-lloyd.com/etc/designs/hlag/images/logo.svg', - website: 'https://www.hapag-lloyd.com', - supportsApi: false, - isActive: true, - }, - { - id: uuidv4(), - name: 'Ocean Network Express (ONE)', - code: 'ONE', - scac: 'ONEY', - logoUrl: 'https://www.one-line.com/themes/custom/one/logo.svg', - website: 'https://www.one-line.com', - supportsApi: false, - isActive: true, - }, -]; - -/** - * Get SQL INSERT statement for carriers - */ -export function getCarriersInsertSQL(): string { - const values = carrierSeeds - .map( - (carrier) => - `('${carrier.id}', '${carrier.name}', '${carrier.code}', '${carrier.scac}', ` + - `'${carrier.logoUrl}', '${carrier.website}', NULL, ${carrier.isActive}, ${carrier.supportsApi}, NOW(), NOW())` - ) - .join(',\n '); - - return ` - INSERT INTO "carriers" ( - "id", "name", "code", "scac", "logo_url", "website", - "api_config", "is_active", "supports_api", "created_at", "updated_at" - ) VALUES - ${values} - ON CONFLICT ("code") DO NOTHING; - `; -} +/** + * Carriers Seed Data + * + * Seeds the 5 major shipping carriers + */ + +import { v4 as uuidv4 } from 'uuid'; + +export interface CarrierSeed { + id: string; + name: string; + code: string; + scac: string; + logoUrl: string; + website: string; + supportsApi: boolean; + isActive: boolean; +} + +export const carrierSeeds: CarrierSeed[] = [ + { + id: uuidv4(), + name: 'Maersk Line', + code: 'MAERSK', + scac: 'MAEU', + logoUrl: 'https://www.maersk.com/~/media/maersk/logos/maersk-logo.svg', + website: 'https://www.maersk.com', + supportsApi: true, + isActive: true, + }, + { + id: uuidv4(), + name: 'Mediterranean Shipping Company (MSC)', + code: 'MSC', + scac: 'MSCU', + logoUrl: 'https://www.msc.com/themes/custom/msc_theme/logo.svg', + website: 'https://www.msc.com', + supportsApi: false, + isActive: true, + }, + { + id: uuidv4(), + name: 'CMA CGM', + code: 'CMA_CGM', + scac: 'CMDU', + logoUrl: 'https://www.cma-cgm.com/static/img/logo.svg', + website: 'https://www.cma-cgm.com', + supportsApi: false, + isActive: true, + }, + { + id: uuidv4(), + name: 'Hapag-Lloyd', + code: 'HAPAG_LLOYD', + scac: 'HLCU', + logoUrl: 'https://www.hapag-lloyd.com/etc/designs/hlag/images/logo.svg', + website: 'https://www.hapag-lloyd.com', + supportsApi: false, + isActive: true, + }, + { + id: uuidv4(), + name: 'Ocean Network Express (ONE)', + code: 'ONE', + scac: 'ONEY', + logoUrl: 'https://www.one-line.com/themes/custom/one/logo.svg', + website: 'https://www.one-line.com', + supportsApi: false, + isActive: true, + }, +]; + +/** + * Get SQL INSERT statement for carriers + */ +export function getCarriersInsertSQL(): string { + const values = carrierSeeds + .map( + carrier => + `('${carrier.id}', '${carrier.name}', '${carrier.code}', '${carrier.scac}', ` + + `'${carrier.logoUrl}', '${carrier.website}', NULL, ${carrier.isActive}, ${carrier.supportsApi}, NOW(), NOW())` + ) + .join(',\n '); + + return ` + INSERT INTO "carriers" ( + "id", "name", "code", "scac", "logo_url", "website", + "api_config", "is_active", "supports_api", "created_at", "updated_at" + ) VALUES + ${values} + ON CONFLICT ("code") DO NOTHING; + `; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/seeds/test-organizations.seed.ts b/apps/backend/src/infrastructure/persistence/typeorm/seeds/test-organizations.seed.ts index 62fa7f8..8085d05 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/seeds/test-organizations.seed.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/seeds/test-organizations.seed.ts @@ -1,86 +1,86 @@ -/** - * Test Organizations Seed Data - * - * Seeds test organizations for development - */ - -import { v4 as uuidv4 } from 'uuid'; - -export interface OrganizationSeed { - id: string; - name: string; - type: string; - scac: string | null; - addressStreet: string; - addressCity: string; - addressState: string | null; - addressPostalCode: string; - addressCountry: string; - isActive: boolean; -} - -export const organizationSeeds: OrganizationSeed[] = [ - { - id: uuidv4(), - name: 'Test Freight Forwarder Inc.', - type: 'FREIGHT_FORWARDER', - scac: null, - addressStreet: '123 Logistics Avenue', - addressCity: 'Rotterdam', - addressState: null, - addressPostalCode: '3011 AA', - addressCountry: 'NL', - isActive: true, - }, - { - id: uuidv4(), - name: 'Demo Shipping Company', - type: 'CARRIER', - scac: 'DEMO', - addressStreet: '456 Maritime Boulevard', - addressCity: 'Singapore', - addressState: null, - addressPostalCode: '018956', - addressCountry: 'SG', - isActive: true, - }, - { - id: uuidv4(), - name: 'Sample Shipper Ltd.', - type: 'SHIPPER', - scac: null, - addressStreet: '789 Commerce Street', - addressCity: 'New York', - addressState: 'NY', - addressPostalCode: '10004', - addressCountry: 'US', - isActive: true, - }, -]; - -/** - * Get SQL INSERT statement for organizations - */ -export function getOrganizationsInsertSQL(): string { - const values = organizationSeeds - .map( - (org) => - `('${org.id}', '${org.name}', '${org.type}', ` + - `${org.scac ? `'${org.scac}'` : 'NULL'}, ` + - `'${org.addressStreet}', '${org.addressCity}', ` + - `${org.addressState ? `'${org.addressState}'` : 'NULL'}, ` + - `'${org.addressPostalCode}', '${org.addressCountry}', ` + - `NULL, '[]', ${org.isActive}, NOW(), NOW())` - ) - .join(',\n '); - - return ` - INSERT INTO "organizations" ( - "id", "name", "type", "scac", - "address_street", "address_city", "address_state", "address_postal_code", "address_country", - "logo_url", "documents", "is_active", "created_at", "updated_at" - ) VALUES - ${values} - ON CONFLICT ("name") DO NOTHING; - `; -} +/** + * Test Organizations Seed Data + * + * Seeds test organizations for development + */ + +import { v4 as uuidv4 } from 'uuid'; + +export interface OrganizationSeed { + id: string; + name: string; + type: string; + scac: string | null; + addressStreet: string; + addressCity: string; + addressState: string | null; + addressPostalCode: string; + addressCountry: string; + isActive: boolean; +} + +export const organizationSeeds: OrganizationSeed[] = [ + { + id: uuidv4(), + name: 'Test Freight Forwarder Inc.', + type: 'FREIGHT_FORWARDER', + scac: null, + addressStreet: '123 Logistics Avenue', + addressCity: 'Rotterdam', + addressState: null, + addressPostalCode: '3011 AA', + addressCountry: 'NL', + isActive: true, + }, + { + id: uuidv4(), + name: 'Demo Shipping Company', + type: 'CARRIER', + scac: 'DEMO', + addressStreet: '456 Maritime Boulevard', + addressCity: 'Singapore', + addressState: null, + addressPostalCode: '018956', + addressCountry: 'SG', + isActive: true, + }, + { + id: uuidv4(), + name: 'Sample Shipper Ltd.', + type: 'SHIPPER', + scac: null, + addressStreet: '789 Commerce Street', + addressCity: 'New York', + addressState: 'NY', + addressPostalCode: '10004', + addressCountry: 'US', + isActive: true, + }, +]; + +/** + * Get SQL INSERT statement for organizations + */ +export function getOrganizationsInsertSQL(): string { + const values = organizationSeeds + .map( + org => + `('${org.id}', '${org.name}', '${org.type}', ` + + `${org.scac ? `'${org.scac}'` : 'NULL'}, ` + + `'${org.addressStreet}', '${org.addressCity}', ` + + `${org.addressState ? `'${org.addressState}'` : 'NULL'}, ` + + `'${org.addressPostalCode}', '${org.addressCountry}', ` + + `NULL, '[]', ${org.isActive}, NOW(), NOW())` + ) + .join(',\n '); + + return ` + INSERT INTO "organizations" ( + "id", "name", "type", "scac", + "address_street", "address_city", "address_state", "address_postal_code", "address_country", + "logo_url", "documents", "is_active", "created_at", "updated_at" + ) VALUES + ${values} + ON CONFLICT ("name") DO NOTHING; + `; +} diff --git a/apps/backend/src/infrastructure/security/security.config.ts b/apps/backend/src/infrastructure/security/security.config.ts index 695e2f3..97f4de1 100644 --- a/apps/backend/src/infrastructure/security/security.config.ts +++ b/apps/backend/src/infrastructure/security/security.config.ts @@ -102,12 +102,7 @@ export const corsConfig = { origin: process.env.FRONTEND_URL || ['http://localhost:3000', 'http://localhost:3001'], credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: [ - 'Content-Type', - 'Authorization', - 'X-Requested-With', - 'X-CSRF-Token', - ], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'], exposedHeaders: ['X-Total-Count', 'X-Page-Count'], maxAge: 86400, // 24 hours }; diff --git a/apps/backend/src/infrastructure/security/security.module.ts b/apps/backend/src/infrastructure/security/security.module.ts index d26ffef..562ee9e 100644 --- a/apps/backend/src/infrastructure/security/security.module.ts +++ b/apps/backend/src/infrastructure/security/security.module.ts @@ -22,11 +22,7 @@ import { rateLimitConfig } from './security.config'; }, ]), ], - providers: [ - FileValidationService, - BruteForceProtectionService, - CustomThrottlerGuard, - ], + providers: [FileValidationService, BruteForceProtectionService, CustomThrottlerGuard], exports: [ FileValidationService, BruteForceProtectionService, diff --git a/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts b/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts index aac2f29..84cb877 100644 --- a/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts +++ b/apps/backend/src/infrastructure/storage/s3-storage.adapter.ts @@ -36,9 +36,7 @@ export class S3StorageAdapter implements StoragePort { const region = this.configService.get('AWS_REGION', 'us-east-1'); const endpoint = this.configService.get('AWS_S3_ENDPOINT'); const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID'); - const secretAccessKey = this.configService.get( - 'AWS_SECRET_ACCESS_KEY' - ); + const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY'); this.s3Client = new S3Client({ region, @@ -73,9 +71,7 @@ export class S3StorageAdapter implements StoragePort { const url = this.buildUrl(options.bucket, options.key); const size = - typeof options.body === 'string' - ? Buffer.byteLength(options.body) - : options.body.length; + typeof options.body === 'string' ? Buffer.byteLength(options.body) : options.body.length; this.logger.log(`Uploaded file to S3: ${options.key}`); @@ -109,10 +105,7 @@ export class S3StorageAdapter implements StoragePort { this.logger.log(`Downloaded file from S3: ${options.key}`); return Buffer.concat(chunks); } catch (error) { - this.logger.error( - `Failed to download file from S3: ${options.key}`, - error - ); + this.logger.error(`Failed to download file from S3: ${options.key}`, error); throw error; } } @@ -132,10 +125,7 @@ export class S3StorageAdapter implements StoragePort { } } - async getSignedUrl( - options: DownloadOptions, - expiresIn: number = 3600 - ): Promise { + async getSignedUrl(options: DownloadOptions, expiresIn: number = 3600): Promise { try { const command = new GetObjectCommand({ Bucket: options.bucket, @@ -143,15 +133,10 @@ export class S3StorageAdapter implements StoragePort { }); const url = await getSignedUrl(this.s3Client, command, { expiresIn }); - this.logger.log( - `Generated signed URL for: ${options.key} (expires in ${expiresIn}s)` - ); + this.logger.log(`Generated signed URL for: ${options.key} (expires in ${expiresIn}s)`); return url; } catch (error) { - this.logger.error( - `Failed to generate signed URL for: ${options.key}`, - error - ); + this.logger.error(`Failed to generate signed URL for: ${options.key}`, error); throw error; } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 4453797..331fdd0 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -6,10 +6,7 @@ import helmet from 'helmet'; import * as compression from 'compression'; import { AppModule } from './app.module'; import { Logger } from 'nestjs-pino'; -import { - helmetConfig, - corsConfig, -} from './infrastructure/security/security.config'; +import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -50,14 +47,14 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true, }, - }), + }) ); // Swagger documentation const config = new DocumentBuilder() .setTitle('Xpeditis API') .setDescription( - 'Maritime Freight Booking Platform - API for searching rates and managing bookings', + 'Maritime Freight Booking Platform - API for searching rates and managing bookings' ) .setVersion('1.0') .addBearerAuth() diff --git a/apps/backend/test/app.e2e-spec.ts b/apps/backend/test/app.e2e-spec.ts index 3b5cf0f..569012b 100644 --- a/apps/backend/test/app.e2e-spec.ts +++ b/apps/backend/test/app.e2e-spec.ts @@ -19,7 +19,7 @@ describe('AppController (e2e)', () => { return request(app.getHttpServer()) .get('/api/v1/health') .expect(200) - .expect((res) => { + .expect(res => { expect(res.body).toHaveProperty('status', 'ok'); expect(res.body).toHaveProperty('timestamp'); }); diff --git a/apps/backend/test/integration/booking.repository.spec.ts b/apps/backend/test/integration/booking.repository.spec.ts index b84699c..b103042 100644 --- a/apps/backend/test/integration/booking.repository.spec.ts +++ b/apps/backend/test/integration/booking.repository.spec.ts @@ -1,390 +1,390 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; -import { faker } from '@faker-js/faker'; -import { TypeOrmBookingRepository } from '../../src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; -import { BookingOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/booking.orm-entity'; -import { ContainerOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/container.orm-entity'; -import { OrganizationOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/organization.orm-entity'; -import { UserOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/user.orm-entity'; -import { RateQuoteOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; -import { PortOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/port.orm-entity'; -import { CarrierOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/carrier.orm-entity'; -import { Booking } from '../../src/domain/entities/booking.entity'; -import { BookingStatus } from '../../src/domain/value-objects/booking-status.vo'; -import { BookingNumber } from '../../src/domain/value-objects/booking-number.vo'; - -describe('TypeOrmBookingRepository (Integration)', () => { - let module: TestingModule; - let repository: TypeOrmBookingRepository; - let dataSource: DataSource; - let testOrganization: OrganizationOrmEntity; - let testUser: UserOrmEntity; - let testCarrier: CarrierOrmEntity; - let testOriginPort: PortOrmEntity; - let testDestinationPort: PortOrmEntity; - let testRateQuote: RateQuoteOrmEntity; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'postgres', - host: process.env.TEST_DB_HOST || 'localhost', - port: parseInt(process.env.TEST_DB_PORT || '5432'), - username: process.env.TEST_DB_USER || 'postgres', - password: process.env.TEST_DB_PASSWORD || 'postgres', - database: process.env.TEST_DB_NAME || 'xpeditis_test', - entities: [ - BookingOrmEntity, - ContainerOrmEntity, - OrganizationOrmEntity, - UserOrmEntity, - RateQuoteOrmEntity, - PortOrmEntity, - CarrierOrmEntity, - ], - synchronize: true, // Auto-create schema for tests - dropSchema: true, // Clean slate for each test run - logging: false, - }), - TypeOrmModule.forFeature([ - BookingOrmEntity, - ContainerOrmEntity, - OrganizationOrmEntity, - UserOrmEntity, - RateQuoteOrmEntity, - PortOrmEntity, - CarrierOrmEntity, - ]), - ], - providers: [TypeOrmBookingRepository], - }).compile(); - - repository = module.get(TypeOrmBookingRepository); - dataSource = module.get(DataSource); - - // Create test data fixtures - await createTestFixtures(); - }); - - afterAll(async () => { - await dataSource.destroy(); - await module.close(); - }); - - afterEach(async () => { - // Clean up bookings after each test - await dataSource.getRepository(ContainerOrmEntity).delete({}); - await dataSource.getRepository(BookingOrmEntity).delete({}); - }); - - async function createTestFixtures() { - const orgRepo = dataSource.getRepository(OrganizationOrmEntity); - const userRepo = dataSource.getRepository(UserOrmEntity); - const carrierRepo = dataSource.getRepository(CarrierOrmEntity); - const portRepo = dataSource.getRepository(PortOrmEntity); - const rateQuoteRepo = dataSource.getRepository(RateQuoteOrmEntity); - - // Create organization - testOrganization = orgRepo.create({ - id: faker.string.uuid(), - name: 'Test Freight Forwarder', - type: 'freight_forwarder', - scac: 'TEFF', - address: { - street: '123 Test St', - city: 'Rotterdam', - postalCode: '3000', - country: 'NL', - }, - contactEmail: 'test@example.com', - contactPhone: '+31123456789', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }); - await orgRepo.save(testOrganization); - - // Create user - testUser = userRepo.create({ - id: faker.string.uuid(), - organizationId: testOrganization.id, - email: 'testuser@example.com', - passwordHash: 'hashed_password', - firstName: 'Test', - lastName: 'User', - role: 'user', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }); - await userRepo.save(testUser); - - // Create carrier - testCarrier = carrierRepo.create({ - id: faker.string.uuid(), - name: 'Test Carrier Line', - code: 'TESTCARRIER', - scac: 'TSTC', - supportsApi: true, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }); - await carrierRepo.save(testCarrier); - - // Create ports - testOriginPort = portRepo.create({ - id: faker.string.uuid(), - name: 'Port of Rotterdam', - code: 'NLRTM', - city: 'Rotterdam', - country: 'Netherlands', - countryCode: 'NL', - timezone: 'Europe/Amsterdam', - latitude: 51.9225, - longitude: 4.47917, - createdAt: new Date(), - updatedAt: new Date(), - }); - await portRepo.save(testOriginPort); - - testDestinationPort = portRepo.create({ - id: faker.string.uuid(), - name: 'Port of Shanghai', - code: 'CNSHA', - city: 'Shanghai', - country: 'China', - countryCode: 'CN', - timezone: 'Asia/Shanghai', - latitude: 31.2304, - longitude: 121.4737, - createdAt: new Date(), - updatedAt: new Date(), - }); - await portRepo.save(testDestinationPort); - - // Create rate quote - testRateQuote = rateQuoteRepo.create({ - id: faker.string.uuid(), - carrierId: testCarrier.id, - originPortId: testOriginPort.id, - destinationPortId: testDestinationPort.id, - baseFreight: 1500.0, - currency: 'USD', - surcharges: [], - totalAmount: 1500.0, - containerType: '40HC', - validFrom: new Date(), - validUntil: new Date(Date.now() + 86400000 * 30), // 30 days - etd: new Date(Date.now() + 86400000 * 7), // 7 days from now - eta: new Date(Date.now() + 86400000 * 37), // 37 days from now - transitDays: 30, - createdAt: new Date(), - updatedAt: new Date(), - }); - await rateQuoteRepo.save(testRateQuote); - } - - function createTestBookingEntity(): Booking { - return Booking.create({ - id: faker.string.uuid(), - bookingNumber: BookingNumber.generate(), - userId: testUser.id, - organizationId: testOrganization.id, - rateQuoteId: testRateQuote.id, - status: BookingStatus.create('draft'), - shipper: { - name: 'Shipper Company Ltd', - address: { - street: '456 Shipper Ave', - city: 'Rotterdam', - postalCode: '3001', - country: 'NL', - }, - contactName: 'John Shipper', - contactEmail: 'shipper@example.com', - contactPhone: '+31987654321', - }, - consignee: { - name: 'Consignee Corp', - address: { - street: '789 Consignee Rd', - city: 'Shanghai', - postalCode: '200000', - country: 'CN', - }, - contactName: 'Jane Consignee', - contactEmail: 'consignee@example.com', - contactPhone: '+86123456789', - }, - cargoDescription: 'General cargo - electronics', - containers: [], - specialInstructions: 'Handle with care', - createdAt: new Date(), - updatedAt: new Date(), - }); - } - - describe('save', () => { - it('should save a new booking', async () => { - const booking = createTestBookingEntity(); - - const savedBooking = await repository.save(booking); - - expect(savedBooking.id).toBe(booking.id); - expect(savedBooking.bookingNumber.value).toBe(booking.bookingNumber.value); - expect(savedBooking.status.value).toBe('draft'); - }); - - it('should update an existing booking', async () => { - const booking = createTestBookingEntity(); - await repository.save(booking); - - // Update the booking - const updatedBooking = Booking.create({ - ...booking, - status: BookingStatus.create('pending_confirmation'), - cargoDescription: 'Updated cargo description', - }); - - const result = await repository.save(updatedBooking); - - expect(result.status.value).toBe('pending_confirmation'); - expect(result.cargoDescription).toBe('Updated cargo description'); - }); - }); - - describe('findById', () => { - it('should find a booking by ID', async () => { - const booking = createTestBookingEntity(); - await repository.save(booking); - - const found = await repository.findById(booking.id); - - expect(found).toBeDefined(); - expect(found?.id).toBe(booking.id); - expect(found?.bookingNumber.value).toBe(booking.bookingNumber.value); - }); - - it('should return null for non-existent ID', async () => { - const nonExistentId = faker.string.uuid(); - const found = await repository.findById(nonExistentId); - - expect(found).toBeNull(); - }); - }); - - describe('findByBookingNumber', () => { - it('should find a booking by booking number', async () => { - const booking = createTestBookingEntity(); - await repository.save(booking); - - const found = await repository.findByBookingNumber(booking.bookingNumber); - - expect(found).toBeDefined(); - expect(found?.id).toBe(booking.id); - expect(found?.bookingNumber.value).toBe(booking.bookingNumber.value); - }); - - it('should return null for non-existent booking number', async () => { - const nonExistentNumber = BookingNumber.generate(); - const found = await repository.findByBookingNumber(nonExistentNumber); - - expect(found).toBeNull(); - }); - }); - - describe('findByOrganization', () => { - it('should find all bookings for an organization', async () => { - const booking1 = createTestBookingEntity(); - const booking2 = createTestBookingEntity(); - const booking3 = createTestBookingEntity(); - - await repository.save(booking1); - await repository.save(booking2); - await repository.save(booking3); - - const bookings = await repository.findByOrganization(testOrganization.id); - - expect(bookings).toHaveLength(3); - expect(bookings.every((b) => b.organizationId === testOrganization.id)).toBe(true); - }); - - it('should return empty array for organization with no bookings', async () => { - const nonExistentOrgId = faker.string.uuid(); - const bookings = await repository.findByOrganization(nonExistentOrgId); - - expect(bookings).toEqual([]); - }); - }); - - describe('findByStatus', () => { - it('should find bookings by status', async () => { - const draftBooking1 = createTestBookingEntity(); - const draftBooking2 = createTestBookingEntity(); - const confirmedBooking = Booking.create({ - ...createTestBookingEntity(), - status: BookingStatus.create('confirmed'), - }); - - await repository.save(draftBooking1); - await repository.save(draftBooking2); - await repository.save(confirmedBooking); - - const draftBookings = await repository.findByStatus(BookingStatus.create('draft')); - const confirmedBookings = await repository.findByStatus(BookingStatus.create('confirmed')); - - expect(draftBookings).toHaveLength(2); - expect(confirmedBookings).toHaveLength(1); - expect(draftBookings.every((b) => b.status.value === 'draft')).toBe(true); - expect(confirmedBookings.every((b) => b.status.value === 'confirmed')).toBe(true); - }); - }); - - describe('delete', () => { - it('should delete a booking', async () => { - const booking = createTestBookingEntity(); - await repository.save(booking); - - const found = await repository.findById(booking.id); - expect(found).toBeDefined(); - - await repository.delete(booking.id); - - const deletedBooking = await repository.findById(booking.id); - expect(deletedBooking).toBeNull(); - }); - - it('should not throw error when deleting non-existent booking', async () => { - const nonExistentId = faker.string.uuid(); - await expect(repository.delete(nonExistentId)).resolves.not.toThrow(); - }); - }); - - describe('complex scenarios', () => { - it('should handle bookings with multiple containers', async () => { - const booking = createTestBookingEntity(); - - // Note: Container handling would be tested separately - // This test ensures the booking can be saved without containers first - await repository.save(booking); - - const found = await repository.findById(booking.id); - expect(found).toBeDefined(); - }); - - it('should maintain data integrity for nested shipper and consignee', async () => { - const booking = createTestBookingEntity(); - await repository.save(booking); - - const found = await repository.findById(booking.id); - - expect(found?.shipper.name).toBe(booking.shipper.name); - expect(found?.shipper.contactEmail).toBe(booking.shipper.contactEmail); - expect(found?.consignee.name).toBe(booking.consignee.name); - expect(found?.consignee.contactEmail).toBe(booking.consignee.contactEmail); - }); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { faker } from '@faker-js/faker'; +import { TypeOrmBookingRepository } from '../../src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; +import { BookingOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/booking.orm-entity'; +import { ContainerOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/container.orm-entity'; +import { OrganizationOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { UserOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { RateQuoteOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; +import { PortOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/port.orm-entity'; +import { CarrierOrmEntity } from '../../src/infrastructure/persistence/typeorm/entities/carrier.orm-entity'; +import { Booking } from '../../src/domain/entities/booking.entity'; +import { BookingStatus } from '../../src/domain/value-objects/booking-status.vo'; +import { BookingNumber } from '../../src/domain/value-objects/booking-number.vo'; + +describe('TypeOrmBookingRepository (Integration)', () => { + let module: TestingModule; + let repository: TypeOrmBookingRepository; + let dataSource: DataSource; + let testOrganization: OrganizationOrmEntity; + let testUser: UserOrmEntity; + let testCarrier: CarrierOrmEntity; + let testOriginPort: PortOrmEntity; + let testDestinationPort: PortOrmEntity; + let testRateQuote: RateQuoteOrmEntity; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: 'postgres', + host: process.env.TEST_DB_HOST || 'localhost', + port: parseInt(process.env.TEST_DB_PORT || '5432'), + username: process.env.TEST_DB_USER || 'postgres', + password: process.env.TEST_DB_PASSWORD || 'postgres', + database: process.env.TEST_DB_NAME || 'xpeditis_test', + entities: [ + BookingOrmEntity, + ContainerOrmEntity, + OrganizationOrmEntity, + UserOrmEntity, + RateQuoteOrmEntity, + PortOrmEntity, + CarrierOrmEntity, + ], + synchronize: true, // Auto-create schema for tests + dropSchema: true, // Clean slate for each test run + logging: false, + }), + TypeOrmModule.forFeature([ + BookingOrmEntity, + ContainerOrmEntity, + OrganizationOrmEntity, + UserOrmEntity, + RateQuoteOrmEntity, + PortOrmEntity, + CarrierOrmEntity, + ]), + ], + providers: [TypeOrmBookingRepository], + }).compile(); + + repository = module.get(TypeOrmBookingRepository); + dataSource = module.get(DataSource); + + // Create test data fixtures + await createTestFixtures(); + }); + + afterAll(async () => { + await dataSource.destroy(); + await module.close(); + }); + + afterEach(async () => { + // Clean up bookings after each test + await dataSource.getRepository(ContainerOrmEntity).delete({}); + await dataSource.getRepository(BookingOrmEntity).delete({}); + }); + + async function createTestFixtures() { + const orgRepo = dataSource.getRepository(OrganizationOrmEntity); + const userRepo = dataSource.getRepository(UserOrmEntity); + const carrierRepo = dataSource.getRepository(CarrierOrmEntity); + const portRepo = dataSource.getRepository(PortOrmEntity); + const rateQuoteRepo = dataSource.getRepository(RateQuoteOrmEntity); + + // Create organization + testOrganization = orgRepo.create({ + id: faker.string.uuid(), + name: 'Test Freight Forwarder', + type: 'freight_forwarder', + scac: 'TEFF', + address: { + street: '123 Test St', + city: 'Rotterdam', + postalCode: '3000', + country: 'NL', + }, + contactEmail: 'test@example.com', + contactPhone: '+31123456789', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await orgRepo.save(testOrganization); + + // Create user + testUser = userRepo.create({ + id: faker.string.uuid(), + organizationId: testOrganization.id, + email: 'testuser@example.com', + passwordHash: 'hashed_password', + firstName: 'Test', + lastName: 'User', + role: 'user', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await userRepo.save(testUser); + + // Create carrier + testCarrier = carrierRepo.create({ + id: faker.string.uuid(), + name: 'Test Carrier Line', + code: 'TESTCARRIER', + scac: 'TSTC', + supportsApi: true, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await carrierRepo.save(testCarrier); + + // Create ports + testOriginPort = portRepo.create({ + id: faker.string.uuid(), + name: 'Port of Rotterdam', + code: 'NLRTM', + city: 'Rotterdam', + country: 'Netherlands', + countryCode: 'NL', + timezone: 'Europe/Amsterdam', + latitude: 51.9225, + longitude: 4.47917, + createdAt: new Date(), + updatedAt: new Date(), + }); + await portRepo.save(testOriginPort); + + testDestinationPort = portRepo.create({ + id: faker.string.uuid(), + name: 'Port of Shanghai', + code: 'CNSHA', + city: 'Shanghai', + country: 'China', + countryCode: 'CN', + timezone: 'Asia/Shanghai', + latitude: 31.2304, + longitude: 121.4737, + createdAt: new Date(), + updatedAt: new Date(), + }); + await portRepo.save(testDestinationPort); + + // Create rate quote + testRateQuote = rateQuoteRepo.create({ + id: faker.string.uuid(), + carrierId: testCarrier.id, + originPortId: testOriginPort.id, + destinationPortId: testDestinationPort.id, + baseFreight: 1500.0, + currency: 'USD', + surcharges: [], + totalAmount: 1500.0, + containerType: '40HC', + validFrom: new Date(), + validUntil: new Date(Date.now() + 86400000 * 30), // 30 days + etd: new Date(Date.now() + 86400000 * 7), // 7 days from now + eta: new Date(Date.now() + 86400000 * 37), // 37 days from now + transitDays: 30, + createdAt: new Date(), + updatedAt: new Date(), + }); + await rateQuoteRepo.save(testRateQuote); + } + + function createTestBookingEntity(): Booking { + return Booking.create({ + id: faker.string.uuid(), + bookingNumber: BookingNumber.generate(), + userId: testUser.id, + organizationId: testOrganization.id, + rateQuoteId: testRateQuote.id, + status: BookingStatus.create('draft'), + shipper: { + name: 'Shipper Company Ltd', + address: { + street: '456 Shipper Ave', + city: 'Rotterdam', + postalCode: '3001', + country: 'NL', + }, + contactName: 'John Shipper', + contactEmail: 'shipper@example.com', + contactPhone: '+31987654321', + }, + consignee: { + name: 'Consignee Corp', + address: { + street: '789 Consignee Rd', + city: 'Shanghai', + postalCode: '200000', + country: 'CN', + }, + contactName: 'Jane Consignee', + contactEmail: 'consignee@example.com', + contactPhone: '+86123456789', + }, + cargoDescription: 'General cargo - electronics', + containers: [], + specialInstructions: 'Handle with care', + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + describe('save', () => { + it('should save a new booking', async () => { + const booking = createTestBookingEntity(); + + const savedBooking = await repository.save(booking); + + expect(savedBooking.id).toBe(booking.id); + expect(savedBooking.bookingNumber.value).toBe(booking.bookingNumber.value); + expect(savedBooking.status.value).toBe('draft'); + }); + + it('should update an existing booking', async () => { + const booking = createTestBookingEntity(); + await repository.save(booking); + + // Update the booking + const updatedBooking = Booking.create({ + ...booking, + status: BookingStatus.create('pending_confirmation'), + cargoDescription: 'Updated cargo description', + }); + + const result = await repository.save(updatedBooking); + + expect(result.status.value).toBe('pending_confirmation'); + expect(result.cargoDescription).toBe('Updated cargo description'); + }); + }); + + describe('findById', () => { + it('should find a booking by ID', async () => { + const booking = createTestBookingEntity(); + await repository.save(booking); + + const found = await repository.findById(booking.id); + + expect(found).toBeDefined(); + expect(found?.id).toBe(booking.id); + expect(found?.bookingNumber.value).toBe(booking.bookingNumber.value); + }); + + it('should return null for non-existent ID', async () => { + const nonExistentId = faker.string.uuid(); + const found = await repository.findById(nonExistentId); + + expect(found).toBeNull(); + }); + }); + + describe('findByBookingNumber', () => { + it('should find a booking by booking number', async () => { + const booking = createTestBookingEntity(); + await repository.save(booking); + + const found = await repository.findByBookingNumber(booking.bookingNumber); + + expect(found).toBeDefined(); + expect(found?.id).toBe(booking.id); + expect(found?.bookingNumber.value).toBe(booking.bookingNumber.value); + }); + + it('should return null for non-existent booking number', async () => { + const nonExistentNumber = BookingNumber.generate(); + const found = await repository.findByBookingNumber(nonExistentNumber); + + expect(found).toBeNull(); + }); + }); + + describe('findByOrganization', () => { + it('should find all bookings for an organization', async () => { + const booking1 = createTestBookingEntity(); + const booking2 = createTestBookingEntity(); + const booking3 = createTestBookingEntity(); + + await repository.save(booking1); + await repository.save(booking2); + await repository.save(booking3); + + const bookings = await repository.findByOrganization(testOrganization.id); + + expect(bookings).toHaveLength(3); + expect(bookings.every(b => b.organizationId === testOrganization.id)).toBe(true); + }); + + it('should return empty array for organization with no bookings', async () => { + const nonExistentOrgId = faker.string.uuid(); + const bookings = await repository.findByOrganization(nonExistentOrgId); + + expect(bookings).toEqual([]); + }); + }); + + describe('findByStatus', () => { + it('should find bookings by status', async () => { + const draftBooking1 = createTestBookingEntity(); + const draftBooking2 = createTestBookingEntity(); + const confirmedBooking = Booking.create({ + ...createTestBookingEntity(), + status: BookingStatus.create('confirmed'), + }); + + await repository.save(draftBooking1); + await repository.save(draftBooking2); + await repository.save(confirmedBooking); + + const draftBookings = await repository.findByStatus(BookingStatus.create('draft')); + const confirmedBookings = await repository.findByStatus(BookingStatus.create('confirmed')); + + expect(draftBookings).toHaveLength(2); + expect(confirmedBookings).toHaveLength(1); + expect(draftBookings.every(b => b.status.value === 'draft')).toBe(true); + expect(confirmedBookings.every(b => b.status.value === 'confirmed')).toBe(true); + }); + }); + + describe('delete', () => { + it('should delete a booking', async () => { + const booking = createTestBookingEntity(); + await repository.save(booking); + + const found = await repository.findById(booking.id); + expect(found).toBeDefined(); + + await repository.delete(booking.id); + + const deletedBooking = await repository.findById(booking.id); + expect(deletedBooking).toBeNull(); + }); + + it('should not throw error when deleting non-existent booking', async () => { + const nonExistentId = faker.string.uuid(); + await expect(repository.delete(nonExistentId)).resolves.not.toThrow(); + }); + }); + + describe('complex scenarios', () => { + it('should handle bookings with multiple containers', async () => { + const booking = createTestBookingEntity(); + + // Note: Container handling would be tested separately + // This test ensures the booking can be saved without containers first + await repository.save(booking); + + const found = await repository.findById(booking.id); + expect(found).toBeDefined(); + }); + + it('should maintain data integrity for nested shipper and consignee', async () => { + const booking = createTestBookingEntity(); + await repository.save(booking); + + const found = await repository.findById(booking.id); + + expect(found?.shipper.name).toBe(booking.shipper.name); + expect(found?.shipper.contactEmail).toBe(booking.shipper.contactEmail); + expect(found?.consignee.name).toBe(booking.consignee.name); + expect(found?.consignee.contactEmail).toBe(booking.consignee.contactEmail); + }); + }); +}); diff --git a/apps/backend/test/integration/maersk.connector.spec.ts b/apps/backend/test/integration/maersk.connector.spec.ts index e5b40c8..e0802dd 100644 --- a/apps/backend/test/integration/maersk.connector.spec.ts +++ b/apps/backend/test/integration/maersk.connector.spec.ts @@ -1,417 +1,417 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; -import { MaerskConnector } from '../../src/infrastructure/carriers/maersk/maersk.connector'; -import { CarrierRateSearchInput } from '../../src/domain/ports/out/carrier-connector.port'; -import { CarrierTimeoutException } from '../../src/domain/exceptions/carrier-timeout.exception'; -import { CarrierUnavailableException } from '../../src/domain/exceptions/carrier-unavailable.exception'; - -// Simple UUID generator for tests -const generateUuid = () => 'test-uuid-' + Math.random().toString(36).substring(2, 15); - -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -describe('MaerskConnector (Integration)', () => { - let connector: MaerskConnector; - let configService: ConfigService; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MaerskConnector, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - const config: Record = { - MAERSK_API_BASE_URL: 'https://api.maersk.com', - MAERSK_API_KEY: 'test-api-key-12345', - MAERSK_TIMEOUT: 5000, - }; - return config[key]; - }), - }, - }, - ], - }).compile(); - - connector = module.get(MaerskConnector); - configService = module.get(ConfigService); - - // Mock axios.create to return a mocked instance - mockedAxios.create = jest.fn().mockReturnValue({ - request: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() }, - }, - } as any); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - function createTestSearchInput(): CarrierRateSearchInput { - return { - origin: 'NLRTM', - destination: 'CNSHA', - departureDate: new Date('2025-02-01'), - containerType: '40HC', - mode: 'FCL', - quantity: 2, - weight: 20000, - isHazmat: false, - }; - } - - function createMaerskApiSuccessResponse() { - return { - status: 200, - statusText: 'OK', - data: { - results: [ - { - id: generateUuid(), - pricing: { - currency: 'USD', - oceanFreight: 1500.0, - charges: [ - { - name: 'BAF', - description: 'Bunker Adjustment Factor', - amount: 150.0, - }, - { - name: 'CAF', - description: 'Currency Adjustment Factor', - amount: 50.0, - }, - ], - totalAmount: 1700.0, - }, - routeDetails: { - origin: { - unlocCode: 'NLRTM', - cityName: 'Rotterdam', - countryName: 'Netherlands', - }, - destination: { - unlocCode: 'CNSHA', - cityName: 'Shanghai', - countryName: 'China', - }, - departureDate: '2025-02-01T10:00:00Z', - arrivalDate: '2025-03-03T14:00:00Z', - transitTime: 30, - }, - serviceDetails: { - serviceName: 'AE1/Shoex', - vesselName: 'MAERSK ESSEX', - vesselImo: '9632179', - }, - availability: { - available: true, - totalSlots: 100, - availableSlots: 85, - }, - }, - { - id: generateUuid(), - pricing: { - currency: 'USD', - oceanFreight: 1650.0, - charges: [ - { - name: 'BAF', - description: 'Bunker Adjustment Factor', - amount: 165.0, - }, - ], - totalAmount: 1815.0, - }, - routeDetails: { - origin: { - unlocCode: 'NLRTM', - cityName: 'Rotterdam', - countryName: 'Netherlands', - }, - destination: { - unlocCode: 'CNSHA', - cityName: 'Shanghai', - countryName: 'China', - }, - departureDate: '2025-02-08T12:00:00Z', - arrivalDate: '2025-03-08T16:00:00Z', - transitTime: 28, - }, - serviceDetails: { - serviceName: 'AE7/Condor', - vesselName: 'MAERSK SENTOSA', - vesselImo: '9778844', - }, - availability: { - available: true, - totalSlots: 120, - availableSlots: 95, - }, - }, - ], - }, - }; - } - - describe('searchRates', () => { - it('should successfully search rates and return mapped quotes', async () => { - const input = createTestSearchInput(); - const mockResponse = createMaerskApiSuccessResponse(); - - // Mock the HTTP client request method - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); - - const quotes = await connector.searchRates(input); - - expect(quotes).toBeDefined(); - expect(quotes.length).toBe(2); - - // Verify first quote - const quote1 = quotes[0]; - expect(quote1.carrierName).toBe('Maersk Line'); - expect(quote1.carrierCode).toBe('MAERSK'); - expect(quote1.origin.code).toBe('NLRTM'); - expect(quote1.destination.code).toBe('CNSHA'); - expect(quote1.pricing.baseFreight).toBe(1500.0); - expect(quote1.pricing.totalAmount).toBe(1700.0); - expect(quote1.pricing.currency).toBe('USD'); - expect(quote1.pricing.surcharges).toHaveLength(2); - expect(quote1.transitDays).toBe(30); - - // Verify second quote - const quote2 = quotes[1]; - expect(quote2.pricing.baseFreight).toBe(1650.0); - expect(quote2.pricing.totalAmount).toBe(1815.0); - expect(quote2.transitDays).toBe(28); - }); - - it('should map surcharges correctly', async () => { - const input = createTestSearchInput(); - const mockResponse = createMaerskApiSuccessResponse(); - - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); - - const quotes = await connector.searchRates(input); - const surcharges = quotes[0].pricing.surcharges; - - expect(surcharges).toHaveLength(2); - expect(surcharges[0]).toEqual({ - code: 'BAF', - name: 'Bunker Adjustment Factor', - amount: 150.0, - }); - expect(surcharges[1]).toEqual({ - code: 'CAF', - name: 'Currency Adjustment Factor', - amount: 50.0, - }); - }); - - it('should include vessel information in route segments', async () => { - const input = createTestSearchInput(); - const mockResponse = createMaerskApiSuccessResponse(); - - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); - - const quotes = await connector.searchRates(input); - - expect(quotes[0].route).toBeDefined(); - expect(Array.isArray(quotes[0].route)).toBe(true); - // Vessel name should be in route segments - const hasVesselInfo = quotes[0].route.some((seg) => seg.vesselName); - expect(hasVesselInfo).toBe(true); - }); - - it('should handle empty results gracefully', async () => { - const input = createTestSearchInput(); - const mockResponse = { - status: 200, - data: { results: [] }, - }; - - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); - - const quotes = await connector.searchRates(input); - - expect(quotes).toEqual([]); - }); - - it('should return empty array on API error', async () => { - const input = createTestSearchInput(); - - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockRejectedValue(new Error('API Error')); - - const quotes = await connector.searchRates(input); - - expect(quotes).toEqual([]); - }); - - it('should handle timeout errors', async () => { - const input = createTestSearchInput(); - - const mockHttpClient = (connector as any).httpClient; - const timeoutError = new Error('Timeout'); - (timeoutError as any).code = 'ECONNABORTED'; - mockHttpClient.request = jest.fn().mockRejectedValue(timeoutError); - - const quotes = await connector.searchRates(input); - - // Should return empty array instead of throwing - expect(quotes).toEqual([]); - }); - }); - - describe('healthCheck', () => { - it('should return true when API is reachable', async () => { - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockResolvedValue({ - status: 200, - data: { status: 'ok' }, - }); - - const health = await connector.healthCheck(); - - expect(health).toBe(true); - }); - - it('should return false when API is unreachable', async () => { - const mockHttpClient = (connector as any).httpClient; - mockHttpClient.request = jest.fn().mockRejectedValue(new Error('Connection failed')); - - const health = await connector.healthCheck(); - - expect(health).toBe(false); - }); - }); - - describe('circuit breaker', () => { - it('should open circuit breaker after consecutive failures', async () => { - const input = createTestSearchInput(); - const mockHttpClient = (connector as any).httpClient; - - // Simulate multiple failures - mockHttpClient.request = jest.fn().mockRejectedValue(new Error('Service unavailable')); - - // Make multiple requests to trigger circuit breaker - for (let i = 0; i < 5; i++) { - await connector.searchRates(input); - } - - // Circuit breaker should now be open - const circuitBreaker = (connector as any).circuitBreaker; - expect(circuitBreaker.opened).toBe(true); - }); - }); - - describe('request mapping', () => { - it('should send correctly formatted request to Maersk API', async () => { - const input = createTestSearchInput(); - const mockResponse = createMaerskApiSuccessResponse(); - - const mockHttpClient = (connector as any).httpClient; - const requestSpy = jest.fn().mockResolvedValue(mockResponse); - mockHttpClient.request = requestSpy; - - await connector.searchRates(input); - - expect(requestSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - url: '/rates/search', - headers: expect.objectContaining({ - 'API-Key': 'test-api-key-12345', - }), - data: expect.objectContaining({ - origin: 'NLRTM', - destination: 'CNSHA', - containerType: '40HC', - quantity: 2, - }), - }) - ); - }); - - it('should include departure date in request', async () => { - const input = createTestSearchInput(); - const mockResponse = createMaerskApiSuccessResponse(); - - const mockHttpClient = (connector as any).httpClient; - const requestSpy = jest.fn().mockResolvedValue(mockResponse); - mockHttpClient.request = requestSpy; - - await connector.searchRates(input); - - const requestData = requestSpy.mock.calls[0][0].data; - expect(requestData.departureDate).toBeDefined(); - expect(new Date(requestData.departureDate)).toEqual(input.departureDate); - }); - }); - - describe('error scenarios', () => { - it('should handle 401 unauthorized gracefully', async () => { - const input = createTestSearchInput(); - const mockHttpClient = (connector as any).httpClient; - - const error: any = new Error('Unauthorized'); - error.response = { status: 401 }; - mockHttpClient.request = jest.fn().mockRejectedValue(error); - - const quotes = await connector.searchRates(input); - expect(quotes).toEqual([]); - }); - - it('should handle 429 rate limit gracefully', async () => { - const input = createTestSearchInput(); - const mockHttpClient = (connector as any).httpClient; - - const error: any = new Error('Too Many Requests'); - error.response = { status: 429 }; - mockHttpClient.request = jest.fn().mockRejectedValue(error); - - const quotes = await connector.searchRates(input); - expect(quotes).toEqual([]); - }); - - it('should handle 500 server error gracefully', async () => { - const input = createTestSearchInput(); - const mockHttpClient = (connector as any).httpClient; - - const error: any = new Error('Internal Server Error'); - error.response = { status: 500 }; - mockHttpClient.request = jest.fn().mockRejectedValue(error); - - const quotes = await connector.searchRates(input); - expect(quotes).toEqual([]); - }); - - it('should handle malformed response data', async () => { - const input = createTestSearchInput(); - const mockHttpClient = (connector as any).httpClient; - - // Response missing required fields - mockHttpClient.request = jest.fn().mockResolvedValue({ - status: 200, - data: { results: [{ invalidStructure: true }] }, - }); - - const quotes = await connector.searchRates(input); - - // Should handle gracefully, possibly returning empty array or partial results - expect(Array.isArray(quotes)).toBe(true); - }); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { MaerskConnector } from '../../src/infrastructure/carriers/maersk/maersk.connector'; +import { CarrierRateSearchInput } from '../../src/domain/ports/out/carrier-connector.port'; +import { CarrierTimeoutException } from '../../src/domain/exceptions/carrier-timeout.exception'; +import { CarrierUnavailableException } from '../../src/domain/exceptions/carrier-unavailable.exception'; + +// Simple UUID generator for tests +const generateUuid = () => 'test-uuid-' + Math.random().toString(36).substring(2, 15); + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('MaerskConnector (Integration)', () => { + let connector: MaerskConnector; + let configService: ConfigService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MaerskConnector, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + const config: Record = { + MAERSK_API_BASE_URL: 'https://api.maersk.com', + MAERSK_API_KEY: 'test-api-key-12345', + MAERSK_TIMEOUT: 5000, + }; + return config[key]; + }), + }, + }, + ], + }).compile(); + + connector = module.get(MaerskConnector); + configService = module.get(ConfigService); + + // Mock axios.create to return a mocked instance + mockedAxios.create = jest.fn().mockReturnValue({ + request: jest.fn(), + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + } as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + function createTestSearchInput(): CarrierRateSearchInput { + return { + origin: 'NLRTM', + destination: 'CNSHA', + departureDate: new Date('2025-02-01'), + containerType: '40HC', + mode: 'FCL', + quantity: 2, + weight: 20000, + isHazmat: false, + }; + } + + function createMaerskApiSuccessResponse() { + return { + status: 200, + statusText: 'OK', + data: { + results: [ + { + id: generateUuid(), + pricing: { + currency: 'USD', + oceanFreight: 1500.0, + charges: [ + { + name: 'BAF', + description: 'Bunker Adjustment Factor', + amount: 150.0, + }, + { + name: 'CAF', + description: 'Currency Adjustment Factor', + amount: 50.0, + }, + ], + totalAmount: 1700.0, + }, + routeDetails: { + origin: { + unlocCode: 'NLRTM', + cityName: 'Rotterdam', + countryName: 'Netherlands', + }, + destination: { + unlocCode: 'CNSHA', + cityName: 'Shanghai', + countryName: 'China', + }, + departureDate: '2025-02-01T10:00:00Z', + arrivalDate: '2025-03-03T14:00:00Z', + transitTime: 30, + }, + serviceDetails: { + serviceName: 'AE1/Shoex', + vesselName: 'MAERSK ESSEX', + vesselImo: '9632179', + }, + availability: { + available: true, + totalSlots: 100, + availableSlots: 85, + }, + }, + { + id: generateUuid(), + pricing: { + currency: 'USD', + oceanFreight: 1650.0, + charges: [ + { + name: 'BAF', + description: 'Bunker Adjustment Factor', + amount: 165.0, + }, + ], + totalAmount: 1815.0, + }, + routeDetails: { + origin: { + unlocCode: 'NLRTM', + cityName: 'Rotterdam', + countryName: 'Netherlands', + }, + destination: { + unlocCode: 'CNSHA', + cityName: 'Shanghai', + countryName: 'China', + }, + departureDate: '2025-02-08T12:00:00Z', + arrivalDate: '2025-03-08T16:00:00Z', + transitTime: 28, + }, + serviceDetails: { + serviceName: 'AE7/Condor', + vesselName: 'MAERSK SENTOSA', + vesselImo: '9778844', + }, + availability: { + available: true, + totalSlots: 120, + availableSlots: 95, + }, + }, + ], + }, + }; + } + + describe('searchRates', () => { + it('should successfully search rates and return mapped quotes', async () => { + const input = createTestSearchInput(); + const mockResponse = createMaerskApiSuccessResponse(); + + // Mock the HTTP client request method + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); + + const quotes = await connector.searchRates(input); + + expect(quotes).toBeDefined(); + expect(quotes.length).toBe(2); + + // Verify first quote + const quote1 = quotes[0]; + expect(quote1.carrierName).toBe('Maersk Line'); + expect(quote1.carrierCode).toBe('MAERSK'); + expect(quote1.origin.code).toBe('NLRTM'); + expect(quote1.destination.code).toBe('CNSHA'); + expect(quote1.pricing.baseFreight).toBe(1500.0); + expect(quote1.pricing.totalAmount).toBe(1700.0); + expect(quote1.pricing.currency).toBe('USD'); + expect(quote1.pricing.surcharges).toHaveLength(2); + expect(quote1.transitDays).toBe(30); + + // Verify second quote + const quote2 = quotes[1]; + expect(quote2.pricing.baseFreight).toBe(1650.0); + expect(quote2.pricing.totalAmount).toBe(1815.0); + expect(quote2.transitDays).toBe(28); + }); + + it('should map surcharges correctly', async () => { + const input = createTestSearchInput(); + const mockResponse = createMaerskApiSuccessResponse(); + + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); + + const quotes = await connector.searchRates(input); + const surcharges = quotes[0].pricing.surcharges; + + expect(surcharges).toHaveLength(2); + expect(surcharges[0]).toEqual({ + code: 'BAF', + name: 'Bunker Adjustment Factor', + amount: 150.0, + }); + expect(surcharges[1]).toEqual({ + code: 'CAF', + name: 'Currency Adjustment Factor', + amount: 50.0, + }); + }); + + it('should include vessel information in route segments', async () => { + const input = createTestSearchInput(); + const mockResponse = createMaerskApiSuccessResponse(); + + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); + + const quotes = await connector.searchRates(input); + + expect(quotes[0].route).toBeDefined(); + expect(Array.isArray(quotes[0].route)).toBe(true); + // Vessel name should be in route segments + const hasVesselInfo = quotes[0].route.some(seg => seg.vesselName); + expect(hasVesselInfo).toBe(true); + }); + + it('should handle empty results gracefully', async () => { + const input = createTestSearchInput(); + const mockResponse = { + status: 200, + data: { results: [] }, + }; + + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse); + + const quotes = await connector.searchRates(input); + + expect(quotes).toEqual([]); + }); + + it('should return empty array on API error', async () => { + const input = createTestSearchInput(); + + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockRejectedValue(new Error('API Error')); + + const quotes = await connector.searchRates(input); + + expect(quotes).toEqual([]); + }); + + it('should handle timeout errors', async () => { + const input = createTestSearchInput(); + + const mockHttpClient = (connector as any).httpClient; + const timeoutError = new Error('Timeout'); + (timeoutError as any).code = 'ECONNABORTED'; + mockHttpClient.request = jest.fn().mockRejectedValue(timeoutError); + + const quotes = await connector.searchRates(input); + + // Should return empty array instead of throwing + expect(quotes).toEqual([]); + }); + }); + + describe('healthCheck', () => { + it('should return true when API is reachable', async () => { + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockResolvedValue({ + status: 200, + data: { status: 'ok' }, + }); + + const health = await connector.healthCheck(); + + expect(health).toBe(true); + }); + + it('should return false when API is unreachable', async () => { + const mockHttpClient = (connector as any).httpClient; + mockHttpClient.request = jest.fn().mockRejectedValue(new Error('Connection failed')); + + const health = await connector.healthCheck(); + + expect(health).toBe(false); + }); + }); + + describe('circuit breaker', () => { + it('should open circuit breaker after consecutive failures', async () => { + const input = createTestSearchInput(); + const mockHttpClient = (connector as any).httpClient; + + // Simulate multiple failures + mockHttpClient.request = jest.fn().mockRejectedValue(new Error('Service unavailable')); + + // Make multiple requests to trigger circuit breaker + for (let i = 0; i < 5; i++) { + await connector.searchRates(input); + } + + // Circuit breaker should now be open + const circuitBreaker = (connector as any).circuitBreaker; + expect(circuitBreaker.opened).toBe(true); + }); + }); + + describe('request mapping', () => { + it('should send correctly formatted request to Maersk API', async () => { + const input = createTestSearchInput(); + const mockResponse = createMaerskApiSuccessResponse(); + + const mockHttpClient = (connector as any).httpClient; + const requestSpy = jest.fn().mockResolvedValue(mockResponse); + mockHttpClient.request = requestSpy; + + await connector.searchRates(input); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: '/rates/search', + headers: expect.objectContaining({ + 'API-Key': 'test-api-key-12345', + }), + data: expect.objectContaining({ + origin: 'NLRTM', + destination: 'CNSHA', + containerType: '40HC', + quantity: 2, + }), + }) + ); + }); + + it('should include departure date in request', async () => { + const input = createTestSearchInput(); + const mockResponse = createMaerskApiSuccessResponse(); + + const mockHttpClient = (connector as any).httpClient; + const requestSpy = jest.fn().mockResolvedValue(mockResponse); + mockHttpClient.request = requestSpy; + + await connector.searchRates(input); + + const requestData = requestSpy.mock.calls[0][0].data; + expect(requestData.departureDate).toBeDefined(); + expect(new Date(requestData.departureDate)).toEqual(input.departureDate); + }); + }); + + describe('error scenarios', () => { + it('should handle 401 unauthorized gracefully', async () => { + const input = createTestSearchInput(); + const mockHttpClient = (connector as any).httpClient; + + const error: any = new Error('Unauthorized'); + error.response = { status: 401 }; + mockHttpClient.request = jest.fn().mockRejectedValue(error); + + const quotes = await connector.searchRates(input); + expect(quotes).toEqual([]); + }); + + it('should handle 429 rate limit gracefully', async () => { + const input = createTestSearchInput(); + const mockHttpClient = (connector as any).httpClient; + + const error: any = new Error('Too Many Requests'); + error.response = { status: 429 }; + mockHttpClient.request = jest.fn().mockRejectedValue(error); + + const quotes = await connector.searchRates(input); + expect(quotes).toEqual([]); + }); + + it('should handle 500 server error gracefully', async () => { + const input = createTestSearchInput(); + const mockHttpClient = (connector as any).httpClient; + + const error: any = new Error('Internal Server Error'); + error.response = { status: 500 }; + mockHttpClient.request = jest.fn().mockRejectedValue(error); + + const quotes = await connector.searchRates(input); + expect(quotes).toEqual([]); + }); + + it('should handle malformed response data', async () => { + const input = createTestSearchInput(); + const mockHttpClient = (connector as any).httpClient; + + // Response missing required fields + mockHttpClient.request = jest.fn().mockResolvedValue({ + status: 200, + data: { results: [{ invalidStructure: true }] }, + }); + + const quotes = await connector.searchRates(input); + + // Should handle gracefully, possibly returning empty array or partial results + expect(Array.isArray(quotes)).toBe(true); + }); + }); +}); diff --git a/apps/backend/test/integration/redis-cache.adapter.spec.ts b/apps/backend/test/integration/redis-cache.adapter.spec.ts index 72d92ce..002a04a 100644 --- a/apps/backend/test/integration/redis-cache.adapter.spec.ts +++ b/apps/backend/test/integration/redis-cache.adapter.spec.ts @@ -1,268 +1,268 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import RedisMock from 'ioredis-mock'; -import { RedisCacheAdapter } from '../../src/infrastructure/cache/redis-cache.adapter'; - -describe('RedisCacheAdapter (Integration)', () => { - let adapter: RedisCacheAdapter; - let redisMock: InstanceType; - - beforeAll(async () => { - // Create a mock Redis instance - redisMock = new RedisMock(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RedisCacheAdapter, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - const config: Record = { - REDIS_HOST: 'localhost', - REDIS_PORT: 6379, - REDIS_PASSWORD: '', - REDIS_DB: 0, - }; - return config[key]; - }), - }, - }, - ], - }).compile(); - - adapter = module.get(RedisCacheAdapter); - - // Replace the real Redis client with the mock - (adapter as any).client = redisMock; - }); - - afterEach(async () => { - // Clear all keys between tests - await redisMock.flushall(); - // Reset statistics - adapter.resetStats(); - }); - - afterAll(async () => { - await adapter.onModuleDestroy(); - }); - - describe('get and set operations', () => { - it('should set and get a string value', async () => { - const key = 'test-key'; - const value = 'test-value'; - - await adapter.set(key, value); - const result = await adapter.get(key); - - expect(result).toBe(value); - }); - - it('should set and get an object value', async () => { - const key = 'test-object'; - const value = { name: 'Test', count: 42, active: true }; - - await adapter.set(key, value); - const result = await adapter.get(key); - - expect(result).toEqual(value); - }); - - it('should return null for non-existent key', async () => { - const result = await adapter.get('non-existent-key'); - expect(result).toBeNull(); - }); - - it('should set value with TTL', async () => { - const key = 'ttl-key'; - const value = 'ttl-value'; - const ttl = 60; // 60 seconds - - await adapter.set(key, value, ttl); - const result = await adapter.get(key); - - expect(result).toBe(value); - - // Verify TTL was set - const remainingTtl = await redisMock.ttl(key); - expect(remainingTtl).toBeGreaterThan(0); - expect(remainingTtl).toBeLessThanOrEqual(ttl); - }); - }); - - describe('delete operations', () => { - it('should delete an existing key', async () => { - const key = 'delete-key'; - const value = 'delete-value'; - - await adapter.set(key, value); - expect(await adapter.get(key)).toBe(value); - - await adapter.delete(key); - expect(await adapter.get(key)).toBeNull(); - }); - - it('should delete multiple keys', async () => { - await adapter.set('key1', 'value1'); - await adapter.set('key2', 'value2'); - await adapter.set('key3', 'value3'); - - await adapter.deleteMany(['key1', 'key2']); - - expect(await adapter.get('key1')).toBeNull(); - expect(await adapter.get('key2')).toBeNull(); - expect(await adapter.get('key3')).toBe('value3'); - }); - - it('should clear all keys', async () => { - await adapter.set('key1', 'value1'); - await adapter.set('key2', 'value2'); - await adapter.set('key3', 'value3'); - - await adapter.clear(); - - expect(await adapter.get('key1')).toBeNull(); - expect(await adapter.get('key2')).toBeNull(); - expect(await adapter.get('key3')).toBeNull(); - }); - }); - - describe('statistics tracking', () => { - it('should track cache hits and misses', async () => { - // Initial stats - const initialStats = await adapter.getStats(); - expect(initialStats.hits).toBe(0); - expect(initialStats.misses).toBe(0); - - // Set a value - await adapter.set('stats-key', 'stats-value'); - - // Cache hit - await adapter.get('stats-key'); - let stats = await adapter.getStats(); - expect(stats.hits).toBe(1); - expect(stats.misses).toBe(0); - - // Cache miss - await adapter.get('non-existent'); - stats = await adapter.getStats(); - expect(stats.hits).toBe(1); - expect(stats.misses).toBe(1); - - // Another cache hit - await adapter.get('stats-key'); - stats = await adapter.getStats(); - expect(stats.hits).toBe(2); - expect(stats.misses).toBe(1); - }); - - it('should calculate hit rate correctly', async () => { - await adapter.set('key1', 'value1'); - await adapter.set('key2', 'value2'); - - // 2 hits - await adapter.get('key1'); - await adapter.get('key2'); - - // 1 miss - await adapter.get('non-existent'); - - const stats = await adapter.getStats(); - expect(stats.hitRate).toBeCloseTo(66.67, 1); // 66.67% as percentage - }); - - it('should report key count', async () => { - await adapter.set('key1', 'value1'); - await adapter.set('key2', 'value2'); - await adapter.set('key3', 'value3'); - - const stats = await adapter.getStats(); - expect(stats.keyCount).toBe(3); - }); - }); - - describe('error handling', () => { - it('should handle JSON parse errors gracefully', async () => { - // Manually set an invalid JSON value - await redisMock.set('invalid-json', '{invalid-json}'); - - const result = await adapter.get('invalid-json'); - expect(result).toBeNull(); - }); - - it('should return null on Redis errors during get', async () => { - // Mock a Redis error - const getSpy = jest.spyOn(redisMock, 'get').mockRejectedValueOnce(new Error('Redis error')); - - const result = await adapter.get('error-key'); - expect(result).toBeNull(); - - getSpy.mockRestore(); - }); - }); - - describe('complex data structures', () => { - it('should handle nested objects', async () => { - const complexObject = { - user: { - id: '123', - name: 'John Doe', - preferences: { - theme: 'dark', - notifications: true, - }, - }, - metadata: { - created: new Date('2025-01-01').toISOString(), - tags: ['test', 'integration'], - }, - }; - - await adapter.set('complex-key', complexObject); - const result = await adapter.get('complex-key'); - - expect(result).toEqual(complexObject); - }); - - it('should handle arrays', async () => { - const array = [ - { id: 1, name: 'Item 1' }, - { id: 2, name: 'Item 2' }, - { id: 3, name: 'Item 3' }, - ]; - - await adapter.set('array-key', array); - const result = await adapter.get('array-key'); - - expect(result).toEqual(array); - }); - }); - - describe('key patterns', () => { - it('should work with namespace-prefixed keys', async () => { - const namespace = 'rate-quotes'; - const key = `${namespace}:NLRTM:CNSHA:2025-01-15`; - const value = { price: 1500, carrier: 'MAERSK' }; - - await adapter.set(key, value); - const result = await adapter.get(key); - - expect(result).toEqual(value); - }); - - it('should handle colon-separated hierarchical keys', async () => { - await adapter.set('bookings:2025:01:booking-1', { id: 'booking-1' }); - await adapter.set('bookings:2025:01:booking-2', { id: 'booking-2' }); - await adapter.set('bookings:2025:02:booking-3', { id: 'booking-3' }); - - const jan1 = await adapter.get('bookings:2025:01:booking-1'); - const jan2 = await adapter.get('bookings:2025:01:booking-2'); - const feb3 = await adapter.get('bookings:2025:02:booking-3'); - - expect(jan1?.id).toBe('booking-1'); - expect(jan2?.id).toBe('booking-2'); - expect(feb3?.id).toBe('booking-3'); - }); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import RedisMock from 'ioredis-mock'; +import { RedisCacheAdapter } from '../../src/infrastructure/cache/redis-cache.adapter'; + +describe('RedisCacheAdapter (Integration)', () => { + let adapter: RedisCacheAdapter; + let redisMock: InstanceType; + + beforeAll(async () => { + // Create a mock Redis instance + redisMock = new RedisMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisCacheAdapter, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + const config: Record = { + REDIS_HOST: 'localhost', + REDIS_PORT: 6379, + REDIS_PASSWORD: '', + REDIS_DB: 0, + }; + return config[key]; + }), + }, + }, + ], + }).compile(); + + adapter = module.get(RedisCacheAdapter); + + // Replace the real Redis client with the mock + (adapter as any).client = redisMock; + }); + + afterEach(async () => { + // Clear all keys between tests + await redisMock.flushall(); + // Reset statistics + adapter.resetStats(); + }); + + afterAll(async () => { + await adapter.onModuleDestroy(); + }); + + describe('get and set operations', () => { + it('should set and get a string value', async () => { + const key = 'test-key'; + const value = 'test-value'; + + await adapter.set(key, value); + const result = await adapter.get(key); + + expect(result).toBe(value); + }); + + it('should set and get an object value', async () => { + const key = 'test-object'; + const value = { name: 'Test', count: 42, active: true }; + + await adapter.set(key, value); + const result = await adapter.get(key); + + expect(result).toEqual(value); + }); + + it('should return null for non-existent key', async () => { + const result = await adapter.get('non-existent-key'); + expect(result).toBeNull(); + }); + + it('should set value with TTL', async () => { + const key = 'ttl-key'; + const value = 'ttl-value'; + const ttl = 60; // 60 seconds + + await adapter.set(key, value, ttl); + const result = await adapter.get(key); + + expect(result).toBe(value); + + // Verify TTL was set + const remainingTtl = await redisMock.ttl(key); + expect(remainingTtl).toBeGreaterThan(0); + expect(remainingTtl).toBeLessThanOrEqual(ttl); + }); + }); + + describe('delete operations', () => { + it('should delete an existing key', async () => { + const key = 'delete-key'; + const value = 'delete-value'; + + await adapter.set(key, value); + expect(await adapter.get(key)).toBe(value); + + await adapter.delete(key); + expect(await adapter.get(key)).toBeNull(); + }); + + it('should delete multiple keys', async () => { + await adapter.set('key1', 'value1'); + await adapter.set('key2', 'value2'); + await adapter.set('key3', 'value3'); + + await adapter.deleteMany(['key1', 'key2']); + + expect(await adapter.get('key1')).toBeNull(); + expect(await adapter.get('key2')).toBeNull(); + expect(await adapter.get('key3')).toBe('value3'); + }); + + it('should clear all keys', async () => { + await adapter.set('key1', 'value1'); + await adapter.set('key2', 'value2'); + await adapter.set('key3', 'value3'); + + await adapter.clear(); + + expect(await adapter.get('key1')).toBeNull(); + expect(await adapter.get('key2')).toBeNull(); + expect(await adapter.get('key3')).toBeNull(); + }); + }); + + describe('statistics tracking', () => { + it('should track cache hits and misses', async () => { + // Initial stats + const initialStats = await adapter.getStats(); + expect(initialStats.hits).toBe(0); + expect(initialStats.misses).toBe(0); + + // Set a value + await adapter.set('stats-key', 'stats-value'); + + // Cache hit + await adapter.get('stats-key'); + let stats = await adapter.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(0); + + // Cache miss + await adapter.get('non-existent'); + stats = await adapter.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + + // Another cache hit + await adapter.get('stats-key'); + stats = await adapter.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(1); + }); + + it('should calculate hit rate correctly', async () => { + await adapter.set('key1', 'value1'); + await adapter.set('key2', 'value2'); + + // 2 hits + await adapter.get('key1'); + await adapter.get('key2'); + + // 1 miss + await adapter.get('non-existent'); + + const stats = await adapter.getStats(); + expect(stats.hitRate).toBeCloseTo(66.67, 1); // 66.67% as percentage + }); + + it('should report key count', async () => { + await adapter.set('key1', 'value1'); + await adapter.set('key2', 'value2'); + await adapter.set('key3', 'value3'); + + const stats = await adapter.getStats(); + expect(stats.keyCount).toBe(3); + }); + }); + + describe('error handling', () => { + it('should handle JSON parse errors gracefully', async () => { + // Manually set an invalid JSON value + await redisMock.set('invalid-json', '{invalid-json}'); + + const result = await adapter.get('invalid-json'); + expect(result).toBeNull(); + }); + + it('should return null on Redis errors during get', async () => { + // Mock a Redis error + const getSpy = jest.spyOn(redisMock, 'get').mockRejectedValueOnce(new Error('Redis error')); + + const result = await adapter.get('error-key'); + expect(result).toBeNull(); + + getSpy.mockRestore(); + }); + }); + + describe('complex data structures', () => { + it('should handle nested objects', async () => { + const complexObject = { + user: { + id: '123', + name: 'John Doe', + preferences: { + theme: 'dark', + notifications: true, + }, + }, + metadata: { + created: new Date('2025-01-01').toISOString(), + tags: ['test', 'integration'], + }, + }; + + await adapter.set('complex-key', complexObject); + const result = await adapter.get('complex-key'); + + expect(result).toEqual(complexObject); + }); + + it('should handle arrays', async () => { + const array = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + ]; + + await adapter.set('array-key', array); + const result = await adapter.get('array-key'); + + expect(result).toEqual(array); + }); + }); + + describe('key patterns', () => { + it('should work with namespace-prefixed keys', async () => { + const namespace = 'rate-quotes'; + const key = `${namespace}:NLRTM:CNSHA:2025-01-15`; + const value = { price: 1500, carrier: 'MAERSK' }; + + await adapter.set(key, value); + const result = await adapter.get(key); + + expect(result).toEqual(value); + }); + + it('should handle colon-separated hierarchical keys', async () => { + await adapter.set('bookings:2025:01:booking-1', { id: 'booking-1' }); + await adapter.set('bookings:2025:01:booking-2', { id: 'booking-2' }); + await adapter.set('bookings:2025:02:booking-3', { id: 'booking-3' }); + + const jan1 = await adapter.get('bookings:2025:01:booking-1'); + const jan2 = await adapter.get('bookings:2025:01:booking-2'); + const feb3 = await adapter.get('bookings:2025:02:booking-3'); + + expect(jan1?.id).toBe('booking-1'); + expect(jan2?.id).toBe('booking-2'); + expect(feb3?.id).toBe('booking-3'); + }); + }); +}); diff --git a/apps/backend/test/setup-integration.ts b/apps/backend/test/setup-integration.ts index 9cdedf0..ffc57aa 100644 --- a/apps/backend/test/setup-integration.ts +++ b/apps/backend/test/setup-integration.ts @@ -1,35 +1,35 @@ -/** - * Integration test setup - * Runs before all integration tests - */ - -// Set test environment variables -process.env.NODE_ENV = 'test'; -process.env.TEST_DB_HOST = process.env.TEST_DB_HOST || 'localhost'; -process.env.TEST_DB_PORT = process.env.TEST_DB_PORT || '5432'; -process.env.TEST_DB_USER = process.env.TEST_DB_USER || 'postgres'; -process.env.TEST_DB_PASSWORD = process.env.TEST_DB_PASSWORD || 'postgres'; -process.env.TEST_DB_NAME = process.env.TEST_DB_NAME || 'xpeditis_test'; - -// Redis test configuration -process.env.REDIS_HOST = process.env.REDIS_HOST || 'localhost'; -process.env.REDIS_PORT = process.env.REDIS_PORT || '6379'; -process.env.REDIS_DB = '1'; // Use DB 1 for tests - -// Carrier API test configuration -process.env.MAERSK_API_BASE_URL = 'https://api.maersk.com'; -process.env.MAERSK_API_KEY = 'test-api-key'; - -// Increase test timeout for integration tests -jest.setTimeout(30000); - -// Global test helpers -global.console = { - ...console, - // Suppress console logs during tests (optional) - // log: jest.fn(), - // debug: jest.fn(), - // info: jest.fn(), - // warn: jest.fn(), - error: console.error, // Keep error logs -}; +/** + * Integration test setup + * Runs before all integration tests + */ + +// Set test environment variables +process.env.NODE_ENV = 'test'; +process.env.TEST_DB_HOST = process.env.TEST_DB_HOST || 'localhost'; +process.env.TEST_DB_PORT = process.env.TEST_DB_PORT || '5432'; +process.env.TEST_DB_USER = process.env.TEST_DB_USER || 'postgres'; +process.env.TEST_DB_PASSWORD = process.env.TEST_DB_PASSWORD || 'postgres'; +process.env.TEST_DB_NAME = process.env.TEST_DB_NAME || 'xpeditis_test'; + +// Redis test configuration +process.env.REDIS_HOST = process.env.REDIS_HOST || 'localhost'; +process.env.REDIS_PORT = process.env.REDIS_PORT || '6379'; +process.env.REDIS_DB = '1'; // Use DB 1 for tests + +// Carrier API test configuration +process.env.MAERSK_API_BASE_URL = 'https://api.maersk.com'; +process.env.MAERSK_API_KEY = 'test-api-key'; + +// Increase test timeout for integration tests +jest.setTimeout(30000); + +// Global test helpers +global.console = { + ...console, + // Suppress console logs during tests (optional) + // log: jest.fn(), + // debug: jest.fn(), + // info: jest.fn(), + // warn: jest.fn(), + error: console.error, // Keep error logs +};