format prettier
This commit is contained in:
parent
07b08e3014
commit
d809feecef
@ -31,9 +31,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema: Joi.object({
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test')
|
||||
.default('development'),
|
||||
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),
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException, Logger, Inject } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as argon2 from 'argon2';
|
||||
@ -22,7 +28,7 @@ export class AuthService {
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository, // ✅ Correct injection
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -33,7 +39,7 @@ export class AuthService {
|
||||
password: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
organizationId?: string,
|
||||
organizationId?: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||
this.logger.log(`Registering new user: ${email}`);
|
||||
|
||||
@ -87,7 +93,7 @@ export class AuthService {
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
password: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||
this.logger.log(`Login attempt for: ${email}`);
|
||||
|
||||
@ -127,7 +133,9 @@ export class AuthService {
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
async refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||
secret: this.configService.get('JWT_SECRET'),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -55,7 +55,7 @@ export class CsvRatesAdminController {
|
||||
constructor(
|
||||
private readonly csvLoader: CsvRateLoaderAdapter,
|
||||
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
||||
private readonly csvRateMapper: CsvRateMapper,
|
||||
private readonly csvRateMapper: CsvRateMapper
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -88,7 +88,7 @@ export class CsvRatesAdminController {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
@ -130,11 +130,9 @@ export class CsvRatesAdminController {
|
||||
async uploadCsv(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CsvRateUploadDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<CsvRateUploadResponseDto> {
|
||||
this.logger.log(
|
||||
`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`,
|
||||
);
|
||||
this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`);
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('File is required');
|
||||
@ -146,7 +144,7 @@ export class CsvRatesAdminController {
|
||||
|
||||
if (!validation.valid) {
|
||||
this.logger.error(
|
||||
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`,
|
||||
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`
|
||||
);
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
@ -158,14 +156,10 @@ export class CsvRatesAdminController {
|
||||
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
||||
const ratesCount = rates.length;
|
||||
|
||||
this.logger.log(
|
||||
`Successfully parsed ${ratesCount} rates from ${file.filename}`,
|
||||
);
|
||||
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||
|
||||
// Check if config exists for this company
|
||||
const existingConfig = await this.csvConfigRepository.findByCompanyName(
|
||||
dto.companyName,
|
||||
);
|
||||
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
|
||||
|
||||
if (existingConfig) {
|
||||
// Update existing configuration
|
||||
@ -185,9 +179,7 @@ export class CsvRatesAdminController {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Updated CSV config for company: ${dto.companyName}`,
|
||||
);
|
||||
this.logger.log(`Updated CSV config for company: ${dto.companyName}`);
|
||||
} else {
|
||||
// Create new configuration
|
||||
await this.csvConfigRepository.create({
|
||||
@ -207,9 +199,7 @@ export class CsvRatesAdminController {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Created new CSV config for company: ${dto.companyName}`,
|
||||
);
|
||||
this.logger.log(`Created new CSV config for company: ${dto.companyName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -220,10 +210,7 @@ export class CsvRatesAdminController {
|
||||
uploadedAt: new Date(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`CSV upload failed: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
);
|
||||
this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -267,17 +254,13 @@ export class CsvRatesAdminController {
|
||||
status: 404,
|
||||
description: 'Company configuration not found',
|
||||
})
|
||||
async getConfigByCompany(
|
||||
@Param('companyName') companyName: string,
|
||||
): Promise<CsvRateConfigDto> {
|
||||
async getConfigByCompany(@Param('companyName') companyName: string): Promise<CsvRateConfigDto> {
|
||||
this.logger.log(`Fetching CSV config for company: ${companyName}`);
|
||||
|
||||
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
||||
|
||||
if (!config) {
|
||||
throw new BadRequestException(
|
||||
`No CSV configuration found for company: ${companyName}`,
|
||||
);
|
||||
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
|
||||
}
|
||||
|
||||
return this.csvRateMapper.mapConfigEntityToDto(config);
|
||||
@ -298,28 +281,20 @@ export class CsvRatesAdminController {
|
||||
description: 'Validation result',
|
||||
type: CsvFileValidationDto,
|
||||
})
|
||||
async validateCsvFile(
|
||||
@Param('companyName') companyName: string,
|
||||
): Promise<CsvFileValidationDto> {
|
||||
async validateCsvFile(@Param('companyName') companyName: string): Promise<CsvFileValidationDto> {
|
||||
this.logger.log(`Validating CSV file for company: ${companyName}`);
|
||||
|
||||
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
||||
|
||||
if (!config) {
|
||||
throw new BadRequestException(
|
||||
`No CSV configuration found for company: ${companyName}`,
|
||||
);
|
||||
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,
|
||||
);
|
||||
await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -345,11 +320,9 @@ export class CsvRatesAdminController {
|
||||
})
|
||||
async deleteConfig(
|
||||
@Param('companyName') companyName: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<void> {
|
||||
this.logger.warn(
|
||||
`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`,
|
||||
);
|
||||
this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`);
|
||||
|
||||
await this.csvConfigRepository.delete(companyName);
|
||||
|
||||
|
||||
@ -66,8 +66,18 @@ export class AuditController {
|
||||
@ApiOperation({ summary: 'Get audit logs with filters' })
|
||||
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
||||
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true })
|
||||
@ApiQuery({
|
||||
name: 'action',
|
||||
required: false,
|
||||
description: 'Filter by action (comma-separated)',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'status',
|
||||
required: false,
|
||||
description: 'Filter by status (comma-separated)',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
|
||||
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
|
||||
@ -84,7 +94,7 @@ export class AuditController {
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
|
||||
page = page || 1;
|
||||
limit = limit || 50;
|
||||
@ -104,7 +114,7 @@ export class AuditController {
|
||||
const { logs, total } = await this.auditService.getAuditLogs(filters);
|
||||
|
||||
return {
|
||||
logs: logs.map((log) => this.mapToDto(log)),
|
||||
logs: logs.map(log => this.mapToDto(log)),
|
||||
total,
|
||||
page,
|
||||
pageSize: limit,
|
||||
@ -121,7 +131,7 @@ export class AuditController {
|
||||
@ApiResponse({ status: 404, description: 'Audit log not found' })
|
||||
async getAuditLogById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<AuditLogResponseDto> {
|
||||
const log = await this.auditService.getAuditLogs({
|
||||
organizationId: user.organizationId,
|
||||
@ -145,14 +155,14 @@ export class AuditController {
|
||||
async getResourceAuditTrail(
|
||||
@Param('type') resourceType: string,
|
||||
@Param('id') resourceId: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<AuditLogResponseDto[]> {
|
||||
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
|
||||
|
||||
// Filter by organization for security
|
||||
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
||||
const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
|
||||
|
||||
return filteredLogs.map((log) => this.mapToDto(log));
|
||||
return filteredLogs.map(log => this.mapToDto(log));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -165,11 +175,11 @@ export class AuditController {
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
||||
async getOrganizationActivity(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||
): Promise<AuditLogResponseDto[]> {
|
||||
limit = limit || 50;
|
||||
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
|
||||
return logs.map((log) => this.mapToDto(log));
|
||||
return logs.map(log => this.mapToDto(log));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -183,15 +193,15 @@ export class AuditController {
|
||||
async getUserActivity(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Param('userId') userId: string,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||
): Promise<AuditLogResponseDto[]> {
|
||||
limit = limit || 50;
|
||||
const logs = await this.auditService.getUserActivity(userId, limit);
|
||||
|
||||
// Filter by organization for security
|
||||
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
||||
const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
|
||||
|
||||
return filteredLogs.map((log) => this.mapToDto(log));
|
||||
return filteredLogs.map(log => this.mapToDto(log));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,25 +1,7 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Get,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
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 { 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';
|
||||
@ -52,8 +34,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Register new user',
|
||||
description:
|
||||
'Create a new user account with email and password. Returns JWT tokens.',
|
||||
description: 'Create a new user account with email and password. Returns JWT tokens.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
@ -74,7 +55,7 @@ export class AuthController {
|
||||
dto.password,
|
||||
dto.firstName,
|
||||
dto.lastName,
|
||||
dto.organizationId,
|
||||
dto.organizationId
|
||||
);
|
||||
|
||||
return {
|
||||
@ -147,11 +128,8 @@ export class AuthController {
|
||||
status: 401,
|
||||
description: 'Invalid or expired refresh token',
|
||||
})
|
||||
async refresh(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
): Promise<{ accessToken: string }> {
|
||||
const result =
|
||||
await this.authService.refreshAccessToken(dto.refreshToken);
|
||||
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
|
||||
const result = await this.authService.refreshAccessToken(dto.refreshToken);
|
||||
|
||||
return { accessToken: result.accessToken };
|
||||
}
|
||||
@ -170,8 +148,7 @@ export class AuthController {
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Logout',
|
||||
description:
|
||||
'Logout the current user. Currently handled client-side by removing tokens.',
|
||||
description: 'Logout the current user. Currently handled client-side by removing tokens.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
|
||||
@ -32,17 +32,16 @@ import {
|
||||
ApiProduces,
|
||||
} from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import {
|
||||
CreateBookingRequestDto,
|
||||
BookingResponseDto,
|
||||
BookingListResponseDto,
|
||||
} from '../dto';
|
||||
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 {
|
||||
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';
|
||||
@ -71,7 +70,7 @@ export class BookingsController {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly notificationsGateway: NotificationsGateway,
|
||||
private readonly webhookService: WebhookService,
|
||||
private readonly webhookService: WebhookService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ -102,11 +101,9 @@ export class BookingsController {
|
||||
})
|
||||
async createBooking(
|
||||
@Body() dto: CreateBookingRequestDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<BookingResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`,
|
||||
);
|
||||
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
|
||||
|
||||
try {
|
||||
// Convert DTO to domain input, using authenticated user's data
|
||||
@ -129,7 +126,7 @@ export class BookingsController {
|
||||
const response = BookingMapper.toDto(booking, rateQuote);
|
||||
|
||||
this.logger.log(
|
||||
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`,
|
||||
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`
|
||||
);
|
||||
|
||||
// Audit log: Booking created
|
||||
@ -147,7 +144,7 @@ export class BookingsController {
|
||||
status: booking.status.value,
|
||||
carrier: rateQuote.carrierName,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Send real-time notification
|
||||
@ -156,7 +153,7 @@ export class BookingsController {
|
||||
user.id,
|
||||
user.organizationId,
|
||||
booking.bookingNumber.value,
|
||||
booking.id,
|
||||
booking.id
|
||||
);
|
||||
await this.notificationsGateway.sendNotificationToUser(user.id, notification);
|
||||
} catch (error: any) {
|
||||
@ -181,7 +178,7 @@ export class BookingsController {
|
||||
etd: rateQuote.etd?.toISOString(),
|
||||
eta: rateQuote.eta?.toISOString(),
|
||||
createdAt: booking.createdAt.toISOString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Don't fail the booking creation if webhook fails
|
||||
@ -192,7 +189,7 @@ export class BookingsController {
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Booking creation failed: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
error?.stack
|
||||
);
|
||||
|
||||
// Audit log: Booking creation failed
|
||||
@ -207,7 +204,7 @@ export class BookingsController {
|
||||
metadata: {
|
||||
rateQuoteId: dto.rateQuoteId,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
throw error;
|
||||
@ -217,8 +214,7 @@ export class BookingsController {
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Get booking by ID',
|
||||
description:
|
||||
'Retrieve detailed information about a specific booking. Requires authentication.',
|
||||
description: 'Retrieve detailed information about a specific booking. Requires authentication.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
@ -239,7 +235,7 @@ export class BookingsController {
|
||||
})
|
||||
async getBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<BookingResponseDto> {
|
||||
this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`);
|
||||
|
||||
@ -287,15 +283,12 @@ export class BookingsController {
|
||||
})
|
||||
async getBookingByNumber(
|
||||
@Param('bookingNumber') bookingNumber: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<BookingResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`,
|
||||
);
|
||||
this.logger.log(`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`);
|
||||
|
||||
const bookingNumberVo = BookingNumber.fromString(bookingNumber);
|
||||
const booking =
|
||||
await this.bookingRepository.findByBookingNumber(bookingNumberVo);
|
||||
const booking = await this.bookingRepository.findByBookingNumber(bookingNumberVo);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingNumber} not found`);
|
||||
@ -337,14 +330,7 @@ export class BookingsController {
|
||||
name: 'status',
|
||||
required: false,
|
||||
description: 'Filter by booking status',
|
||||
enum: [
|
||||
'draft',
|
||||
'pending_confirmation',
|
||||
'confirmed',
|
||||
'in_transit',
|
||||
'delivered',
|
||||
'cancelled',
|
||||
],
|
||||
enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'],
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
@ -359,18 +345,17 @@ export class BookingsController {
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||
@Query('status') status: string | undefined,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<BookingListResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}`,
|
||||
`[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);
|
||||
const bookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||
|
||||
// Filter by status if provided
|
||||
const filteredBookings = status
|
||||
@ -385,11 +370,9 @@ export class BookingsController {
|
||||
// Fetch rate quotes for all bookings
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
paginatedBookings.map(async (booking: any) => {
|
||||
const rateQuote = await this.rateQuoteRepository.findById(
|
||||
booking.rateQuoteId,
|
||||
);
|
||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||
return { booking, rateQuote: rateQuote! };
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Convert to DTOs
|
||||
@ -436,7 +419,7 @@ export class BookingsController {
|
||||
async fuzzySearch(
|
||||
@Query('q') searchTerm: string,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<BookingResponseDto[]> {
|
||||
this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`);
|
||||
|
||||
@ -448,21 +431,21 @@ export class BookingsController {
|
||||
const bookingOrms = await this.fuzzySearchService.search(
|
||||
searchTerm,
|
||||
user.organizationId,
|
||||
limit,
|
||||
limit
|
||||
);
|
||||
|
||||
// Map ORM entities to domain and fetch rate quotes
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
bookingOrms.map(async (bookingOrm) => {
|
||||
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),
|
||||
BookingMapper.toDto(booking, rateQuote)
|
||||
);
|
||||
|
||||
this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`);
|
||||
@ -487,10 +470,10 @@ export class BookingsController {
|
||||
})
|
||||
async advancedSearch(
|
||||
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<BookingListResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}`,
|
||||
`[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}`
|
||||
);
|
||||
|
||||
// Fetch all bookings for organization
|
||||
@ -512,10 +495,10 @@ export class BookingsController {
|
||||
|
||||
// Fetch rate quotes
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
paginatedBookings.map(async (booking) => {
|
||||
paginatedBookings.map(async booking => {
|
||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||
return { booking, rateQuote: rateQuote! };
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Convert to DTOs
|
||||
@ -539,7 +522,11 @@ export class BookingsController {
|
||||
description:
|
||||
'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.',
|
||||
})
|
||||
@ApiProduces('text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/json')
|
||||
@ApiProduces(
|
||||
'text/csv',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/json'
|
||||
)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'Export file generated successfully',
|
||||
@ -552,20 +539,18 @@ export class BookingsController {
|
||||
@Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto,
|
||||
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<StreamableFile> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Exporting bookings to ${exportDto.format}`,
|
||||
);
|
||||
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)),
|
||||
exportDto.bookingIds.map(id => this.bookingRepository.findById(id))
|
||||
);
|
||||
bookings = bookings.filter((b) => b !== null && b.organizationId === user.organizationId);
|
||||
bookings = bookings.filter(b => b !== null && b.organizationId === user.organizationId);
|
||||
} else {
|
||||
// Otherwise, use filter criteria
|
||||
bookings = await this.bookingRepository.findByOrganization(user.organizationId);
|
||||
@ -574,17 +559,17 @@ export class BookingsController {
|
||||
|
||||
// Fetch rate quotes
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
bookings.map(async (booking) => {
|
||||
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,
|
||||
exportDto.fields
|
||||
);
|
||||
|
||||
// Set response headers
|
||||
@ -607,7 +592,7 @@ export class BookingsController {
|
||||
fields: exportDto.fields?.join(', ') || 'all',
|
||||
filename: exportResult.filename,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return new StreamableFile(exportResult.buffer);
|
||||
@ -621,41 +606,35 @@ export class BookingsController {
|
||||
|
||||
// Filter by status
|
||||
if (filter.status && filter.status.length > 0) {
|
||||
filtered = filtered.filter((b) => filter.status!.includes(b.status.value));
|
||||
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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
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);
|
||||
filtered = filtered.filter(b => b.createdAt >= fromDate);
|
||||
}
|
||||
if (filter.createdTo) {
|
||||
const toDate = new Date(filter.createdTo);
|
||||
filtered = filtered.filter((b) => b.createdAt <= toDate);
|
||||
filtered = filtered.filter(b => b.createdAt <= toDate);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
|
||||
@ -41,17 +41,14 @@ export class GDPRController {
|
||||
status: 200,
|
||||
description: 'Data export successful',
|
||||
})
|
||||
async exportData(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||
const exportData = await this.gdprService.exportUserData(user.id);
|
||||
|
||||
// Set headers for file download
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`,
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
|
||||
);
|
||||
|
||||
res.json(exportData);
|
||||
@ -69,10 +66,7 @@ export class GDPRController {
|
||||
status: 200,
|
||||
description: 'CSV export successful',
|
||||
})
|
||||
async exportDataCSV(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||
const exportData = await this.gdprService.exportUserData(user.id);
|
||||
|
||||
// Convert to CSV (simplified version)
|
||||
@ -87,7 +81,7 @@ export class GDPRController {
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`,
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
|
||||
);
|
||||
|
||||
res.send(csv);
|
||||
@ -108,7 +102,7 @@ export class GDPRController {
|
||||
})
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { reason?: string; confirmEmail: string },
|
||||
@Body() body: { reason?: string; confirmEmail: string }
|
||||
): Promise<void> {
|
||||
// Verify email confirmation (security measure)
|
||||
if (body.confirmEmail !== user.email) {
|
||||
@ -133,7 +127,7 @@ export class GDPRController {
|
||||
})
|
||||
async recordConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: Omit<ConsentData, 'userId'>,
|
||||
@Body() body: Omit<ConsentData, 'userId'>
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.gdprService.recordConsent({
|
||||
...body,
|
||||
@ -158,7 +152,7 @@ export class GDPRController {
|
||||
})
|
||||
async withdrawConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { consentType: 'marketing' | 'analytics' },
|
||||
@Body() body: { consentType: 'marketing' | 'analytics' }
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
|
||||
@ -177,9 +171,7 @@ export class GDPRController {
|
||||
status: 200,
|
||||
description: 'Consent status retrieved',
|
||||
})
|
||||
async getConsentStatus(
|
||||
@CurrentUser() user: UserPayload,
|
||||
): Promise<any> {
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
|
||||
return this.gdprService.getConsentStatus(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,13 +17,7 @@ import {
|
||||
DefaultValuePipe,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
@ -62,7 +56,7 @@ export class NotificationsController {
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Query('read') read?: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number
|
||||
): Promise<{
|
||||
notifications: NotificationResponseDto[];
|
||||
total: number;
|
||||
@ -82,7 +76,7 @@ export class NotificationsController {
|
||||
const { notifications, total } = await this.notificationService.getNotifications(filters);
|
||||
|
||||
return {
|
||||
notifications: notifications.map((n) => this.mapToDto(n)),
|
||||
notifications: notifications.map(n => this.mapToDto(n)),
|
||||
total,
|
||||
page,
|
||||
pageSize: limit,
|
||||
@ -95,14 +89,18 @@ export class NotificationsController {
|
||||
@Get('unread')
|
||||
@ApiOperation({ summary: 'Get unread notifications' })
|
||||
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' })
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: 'Number of notifications (default: 50)',
|
||||
})
|
||||
async getUnreadNotifications(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||
): Promise<NotificationResponseDto[]> {
|
||||
limit = limit || 50;
|
||||
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
|
||||
return notifications.map((n) => this.mapToDto(n));
|
||||
return notifications.map(n => this.mapToDto(n));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -125,7 +123,7 @@ export class NotificationsController {
|
||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||
async getNotificationById(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Param('id') id: string,
|
||||
@Param('id') id: string
|
||||
): Promise<NotificationResponseDto> {
|
||||
const notification = await this.notificationService.getNotificationById(id);
|
||||
|
||||
@ -145,7 +143,7 @@ export class NotificationsController {
|
||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||
async markAsRead(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Param('id') id: string,
|
||||
@Param('id') id: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const notification = await this.notificationService.getNotificationById(id);
|
||||
|
||||
@ -177,7 +175,7 @@ export class NotificationsController {
|
||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||
async deleteNotification(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Param('id') id: string,
|
||||
@Param('id') id: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const notification = await this.notificationService.getNotificationById(id);
|
||||
|
||||
|
||||
@ -36,7 +36,10 @@ import {
|
||||
OrganizationListResponseDto,
|
||||
} from '../dto/organization.dto';
|
||||
import { OrganizationMapper } from '../mappers/organization.mapper';
|
||||
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
|
||||
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';
|
||||
@ -61,7 +64,7 @@ export class OrganizationsController {
|
||||
private readonly logger = new Logger(OrganizationsController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -75,8 +78,7 @@ export class OrganizationsController {
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@ApiOperation({
|
||||
summary: 'Create new organization',
|
||||
description:
|
||||
'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
|
||||
description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.CREATED,
|
||||
@ -96,28 +98,22 @@ export class OrganizationsController {
|
||||
})
|
||||
async createOrganization(
|
||||
@Body() dto: CreateOrganizationDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<OrganizationResponseDto> {
|
||||
this.logger.log(
|
||||
`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`,
|
||||
);
|
||||
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`,
|
||||
);
|
||||
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`,
|
||||
);
|
||||
throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,15 +132,13 @@ export class OrganizationsController {
|
||||
// Save to database
|
||||
const savedOrg = await this.organizationRepository.save(organization);
|
||||
|
||||
this.logger.log(
|
||||
`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`,
|
||||
);
|
||||
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,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@ -181,7 +175,7 @@ export class OrganizationsController {
|
||||
})
|
||||
async getOrganization(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<OrganizationResponseDto> {
|
||||
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
|
||||
|
||||
@ -235,11 +229,9 @@ export class OrganizationsController {
|
||||
async updateOrganization(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateOrganizationDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<OrganizationResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Updating organization: ${id}`,
|
||||
);
|
||||
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
|
||||
|
||||
const organization = await this.organizationRepository.findById(id);
|
||||
if (!organization) {
|
||||
@ -323,10 +315,10 @@ export class OrganizationsController {
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||
@Query('type') type: OrganizationType | undefined,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<OrganizationListResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`,
|
||||
`[User: ${user.email}] Listing organizations: page=${page}, pageSize=${pageSize}, type=${type}`
|
||||
);
|
||||
|
||||
// Fetch organizations
|
||||
@ -342,9 +334,7 @@ export class OrganizationsController {
|
||||
}
|
||||
|
||||
// Filter by type if provided
|
||||
const filteredOrgs = type
|
||||
? organizations.filter(org => org.type === type)
|
||||
: organizations;
|
||||
const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations;
|
||||
|
||||
// Paginate
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
|
||||
@ -37,7 +37,7 @@ export class RatesController {
|
||||
constructor(
|
||||
private readonly rateSearchService: RateSearchService,
|
||||
private readonly csvRateSearchService: CsvRateSearchService,
|
||||
private readonly csvRateMapper: CsvRateMapper,
|
||||
private readonly csvRateMapper: CsvRateMapper
|
||||
) {}
|
||||
|
||||
@Post('search')
|
||||
@ -73,11 +73,11 @@ export class RatesController {
|
||||
})
|
||||
async searchRates(
|
||||
@Body() dto: RateSearchRequestDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<RateSearchResponseDto> {
|
||||
const startTime = Date.now();
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`,
|
||||
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`
|
||||
);
|
||||
|
||||
try {
|
||||
@ -102,9 +102,7 @@ export class RatesController {
|
||||
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
|
||||
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
this.logger.log(
|
||||
`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`,
|
||||
);
|
||||
this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`);
|
||||
|
||||
return {
|
||||
quotes: quoteDtos,
|
||||
@ -118,10 +116,7 @@ export class RatesController {
|
||||
responseTimeMs,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Rate search failed: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
);
|
||||
this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -152,11 +147,11 @@ export class RatesController {
|
||||
})
|
||||
async searchCsvRates(
|
||||
@Body() dto: CsvRateSearchDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<CsvRateSearchResponseDto> {
|
||||
const startTime = Date.now();
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`,
|
||||
`[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
|
||||
);
|
||||
|
||||
try {
|
||||
@ -179,14 +174,14 @@ export class RatesController {
|
||||
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
this.logger.log(
|
||||
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`,
|
||||
`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,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@ -220,7 +215,7 @@ export class RatesController {
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Failed to fetch companies: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@ -259,7 +254,7 @@ export class RatesController {
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -16,13 +16,12 @@ import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service';
|
||||
WebhookService,
|
||||
CreateWebhookInput,
|
||||
UpdateWebhookInput,
|
||||
} from '../services/webhook.service';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
@ -74,7 +73,7 @@ export class WebhooksController {
|
||||
@ApiResponse({ status: 201, description: 'Webhook created successfully' })
|
||||
async createWebhook(
|
||||
@Body() dto: CreateWebhookDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<WebhookResponseDto> {
|
||||
const input: CreateWebhookInput = {
|
||||
organizationId: user.organizationId,
|
||||
@ -96,10 +95,8 @@ export class WebhooksController {
|
||||
@ApiOperation({ summary: 'Get all webhooks for organization' })
|
||||
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
|
||||
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
|
||||
const webhooks = await this.webhookService.getWebhooksByOrganization(
|
||||
user.organizationId,
|
||||
);
|
||||
return webhooks.map((w) => this.mapToDto(w));
|
||||
const webhooks = await this.webhookService.getWebhooksByOrganization(user.organizationId);
|
||||
return webhooks.map(w => this.mapToDto(w));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -112,7 +109,7 @@ export class WebhooksController {
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||
async getWebhookById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<WebhookResponseDto> {
|
||||
const webhook = await this.webhookService.getWebhookById(id);
|
||||
|
||||
@ -139,7 +136,7 @@ export class WebhooksController {
|
||||
async updateWebhook(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWebhookDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<WebhookResponseDto> {
|
||||
const webhook = await this.webhookService.getWebhookById(id);
|
||||
|
||||
@ -166,7 +163,7 @@ export class WebhooksController {
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||
async activateWebhook(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ success: boolean }> {
|
||||
const webhook = await this.webhookService.getWebhookById(id);
|
||||
|
||||
@ -193,7 +190,7 @@ export class WebhooksController {
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||
async deactivateWebhook(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ success: boolean }> {
|
||||
const webhook = await this.webhookService.getWebhookById(id);
|
||||
|
||||
@ -220,7 +217,7 @@ export class WebhooksController {
|
||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||
async deleteWebhook(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ success: boolean }> {
|
||||
const webhook = await this.webhookService.getWebhookById(id);
|
||||
|
||||
|
||||
@ -38,5 +38,5 @@ export const CurrentUser = createParamDecorator(
|
||||
|
||||
// If a specific property is requested, return only that property
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -1,4 +1,13 @@
|
||||
import { IsString, IsUUID, IsOptional, ValidateNested, IsArray, IsEmail, Matches, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
Matches,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
@ -45,7 +54,9 @@ export class PartyDto {
|
||||
|
||||
@ApiProperty({ example: '+31612345678' })
|
||||
@IsString()
|
||||
@Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Contact phone must be a valid international phone number' })
|
||||
@Matches(/^\+?[1-9]\d{1,14}$/, {
|
||||
message: 'Contact phone must be a valid international phone number',
|
||||
})
|
||||
contactPhone: string;
|
||||
}
|
||||
|
||||
@ -57,14 +68,19 @@ export class ContainerDto {
|
||||
@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' })
|
||||
@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)' })
|
||||
@ApiPropertyOptional({
|
||||
example: -18,
|
||||
description: 'Temperature in Celsius (for reefer containers)',
|
||||
})
|
||||
@IsOptional()
|
||||
temperature?: number;
|
||||
|
||||
@ -77,7 +93,7 @@ export class ContainerDto {
|
||||
export class CreateBookingRequestDto {
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
description: 'Rate quote ID from previous search'
|
||||
description: 'Rate quote ID from previous search',
|
||||
})
|
||||
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
|
||||
rateQuoteId: string;
|
||||
@ -94,7 +110,7 @@ export class CreateBookingRequestDto {
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Electronics and consumer goods',
|
||||
description: 'Cargo description'
|
||||
description: 'Cargo description',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
|
||||
@ -102,7 +118,7 @@ export class CreateBookingRequestDto {
|
||||
|
||||
@ApiProperty({
|
||||
type: [ContainerDto],
|
||||
description: 'Container details (can be empty for initial booking)'
|
||||
description: 'Container details (can be empty for initial booking)',
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@ -111,7 +127,7 @@ export class CreateBookingRequestDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Please handle with care. Delivery before 5 PM.',
|
||||
description: 'Special instructions for the carrier'
|
||||
description: 'Special instructions for the carrier',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
||||
|
||||
@ -152,7 +145,7 @@ export class CsvRateResultDto {
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Calculated price in USD',
|
||||
example: 1850.50,
|
||||
example: 1850.5,
|
||||
})
|
||||
priceUSD: number;
|
||||
|
||||
|
||||
@ -1,4 +1,13 @@
|
||||
import { IsString, IsDateString, IsEnum, IsOptional, IsInt, Min, IsBoolean, Matches } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
Min,
|
||||
IsBoolean,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RateSearchRequestDto {
|
||||
@ -17,7 +26,9 @@ export class RateSearchRequestDto {
|
||||
pattern: '^[A-Z]{5}$',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(/^[A-Z]{5}$/, { message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)' })
|
||||
@Matches(/^[A-Z]{5}$/, {
|
||||
message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)',
|
||||
})
|
||||
destination: string;
|
||||
|
||||
@ApiProperty({
|
||||
@ -92,6 +103,8 @@ export class RateSearchRequestDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[1-9](\.[1-9])?$/, { message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)' })
|
||||
@Matches(/^[1-9](\.[1-9])?$/, {
|
||||
message: 'IMO class must be in format X or X.Y (e.g., 3 or 3.1)',
|
||||
})
|
||||
imoClass?: string;
|
||||
}
|
||||
|
||||
@ -67,7 +67,8 @@ export class CreateUserDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'TempPassword123!',
|
||||
description: 'Temporary password (min 12 characters). If not provided, a random one will be generated.',
|
||||
description:
|
||||
'Temporary password (min 12 characters). If not provided, a random one will be generated.',
|
||||
minLength: 12,
|
||||
})
|
||||
@IsString()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -23,11 +23,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
/**
|
||||
* Custom error message (override for new API)
|
||||
*/
|
||||
protected async throwThrottlingException(
|
||||
context: ExecutionContext,
|
||||
): Promise<void> {
|
||||
throw new ThrottlerException(
|
||||
'Too many requests. Please try again later.',
|
||||
);
|
||||
protected async throwThrottlingException(context: ExecutionContext): Promise<void> {
|
||||
throw new ThrottlerException('Too many requests. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export class BookingMapper {
|
||||
contactPhone: dto.consignee.contactPhone,
|
||||
},
|
||||
cargoDescription: dto.cargoDescription,
|
||||
containers: dto.containers.map((c) => ({
|
||||
containers: dto.containers.map(c => ({
|
||||
type: c.type,
|
||||
containerNumber: c.containerNumber,
|
||||
vgm: c.vgm,
|
||||
@ -91,7 +91,7 @@ export class BookingMapper {
|
||||
contactPhone: booking.consignee.contactPhone,
|
||||
},
|
||||
cargoDescription: booking.cargoDescription,
|
||||
containers: booking.containers.map((c) => ({
|
||||
containers: booking.containers.map(c => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
containerNumber: c.containerNumber,
|
||||
@ -116,7 +116,7 @@ export class BookingMapper {
|
||||
},
|
||||
pricing: {
|
||||
baseFreight: rateQuote.pricing.baseFreight,
|
||||
surcharges: rateQuote.pricing.surcharges.map((s) => ({
|
||||
surcharges: rateQuote.pricing.surcharges.map(s => ({
|
||||
type: s.type,
|
||||
description: s.description,
|
||||
amount: s.amount,
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
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 { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||
import {
|
||||
CsvRateSearchInput,
|
||||
CsvRateSearchOutput,
|
||||
@ -77,7 +74,7 @@ export class CsvRateMapper {
|
||||
*/
|
||||
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
||||
return {
|
||||
results: output.results.map((result) => this.mapSearchResultToDto(result)),
|
||||
results: output.results.map(result => this.mapSearchResultToDto(result)),
|
||||
totalResults: output.totalResults,
|
||||
searchedFiles: output.searchedFiles,
|
||||
searchedAt: output.searchedAt,
|
||||
@ -107,6 +104,6 @@ export class CsvRateMapper {
|
||||
* Map multiple config entities to DTOs
|
||||
*/
|
||||
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
||||
return entities.map((entity) => this.mapConfigEntityToDto(entity));
|
||||
return entities.map(entity => this.mapConfigEntityToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,9 +56,7 @@ export class OrganizationMapper {
|
||||
/**
|
||||
* Map Document entity to DTO
|
||||
*/
|
||||
private static mapDocumentToDto(
|
||||
document: OrganizationDocument,
|
||||
): OrganizationDocumentDto {
|
||||
private static mapDocumentToDto(document: OrganizationDocument): OrganizationDocumentDto {
|
||||
return {
|
||||
id: document.id,
|
||||
type: document.type,
|
||||
|
||||
@ -29,7 +29,7 @@ export class RateQuoteMapper {
|
||||
},
|
||||
pricing: {
|
||||
baseFreight: entity.pricing.baseFreight,
|
||||
surcharges: entity.pricing.surcharges.map((s) => ({
|
||||
surcharges: entity.pricing.surcharges.map(s => ({
|
||||
type: s.type,
|
||||
description: s.description,
|
||||
amount: s.amount,
|
||||
@ -43,7 +43,7 @@ export class RateQuoteMapper {
|
||||
etd: entity.etd.toISOString(),
|
||||
eta: entity.eta.toISOString(),
|
||||
transitDays: entity.transitDays,
|
||||
route: entity.route.map((segment) => ({
|
||||
route: entity.route.map(segment => ({
|
||||
portCode: segment.portCode,
|
||||
portName: segment.portName,
|
||||
arrival: segment.arrival?.toISOString(),
|
||||
@ -64,6 +64,6 @@ export class RateQuoteMapper {
|
||||
* Map array of RateQuote entities to DTOs
|
||||
*/
|
||||
static toDtoArray(entities: RateQuote[]): RateQuoteDto[] {
|
||||
return entities.map((entity) => this.toDto(entity));
|
||||
return entities.map(entity => this.toDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,33 +45,14 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
|
||||
},
|
||||
{
|
||||
provide: RateSearchService,
|
||||
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 {}
|
||||
|
||||
@ -53,7 +53,7 @@ export class AnalyticsService {
|
||||
@Inject(BOOKING_REPOSITORY)
|
||||
private readonly bookingRepository: BookingRepository,
|
||||
@Inject(RATE_QUOTE_REPOSITORY)
|
||||
private readonly rateQuoteRepository: RateQuoteRepository,
|
||||
private readonly rateQuoteRepository: RateQuoteRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -70,13 +70,11 @@ export class AnalyticsService {
|
||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||
|
||||
// This month bookings
|
||||
const thisMonthBookings = allBookings.filter(
|
||||
(b) => b.createdAt >= thisMonthStart
|
||||
);
|
||||
const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart);
|
||||
|
||||
// Last month bookings
|
||||
const lastMonthBookings = allBookings.filter(
|
||||
(b) => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
|
||||
b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
|
||||
);
|
||||
|
||||
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
|
||||
@ -118,10 +116,10 @@ export class AnalyticsService {
|
||||
|
||||
// Pending confirmations (status = pending_confirmation)
|
||||
const pendingThisMonth = thisMonthBookings.filter(
|
||||
(b) => b.status.value === 'pending_confirmation'
|
||||
b => b.status.value === 'pending_confirmation'
|
||||
).length;
|
||||
const pendingLastMonth = lastMonthBookings.filter(
|
||||
(b) => b.status.value === 'pending_confirmation'
|
||||
b => b.status.value === 'pending_confirmation'
|
||||
).length;
|
||||
|
||||
// Calculate percentage changes
|
||||
@ -135,15 +133,9 @@ export class AnalyticsService {
|
||||
totalTEUs: totalTEUsThisMonth,
|
||||
estimatedRevenue: estimatedRevenueThisMonth,
|
||||
pendingConfirmations: pendingThisMonth,
|
||||
bookingsThisMonthChange: calculateChange(
|
||||
thisMonthBookings.length,
|
||||
lastMonthBookings.length
|
||||
),
|
||||
bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length),
|
||||
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
|
||||
estimatedRevenueChange: calculateChange(
|
||||
estimatedRevenueThisMonth,
|
||||
estimatedRevenueLastMonth
|
||||
),
|
||||
estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth),
|
||||
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
|
||||
};
|
||||
}
|
||||
@ -172,7 +164,7 @@ export class AnalyticsService {
|
||||
|
||||
// Count bookings in this month
|
||||
const count = allBookings.filter(
|
||||
(b) => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
||||
b => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
||||
).length;
|
||||
data.push(count);
|
||||
}
|
||||
@ -187,13 +179,16 @@ export class AnalyticsService {
|
||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||
|
||||
// Group by route (origin-destination)
|
||||
const routeMap = new Map<string, {
|
||||
originPort: string;
|
||||
destinationPort: string;
|
||||
bookingCount: number;
|
||||
totalTEUs: number;
|
||||
totalPrice: number;
|
||||
}>();
|
||||
const routeMap = new Map<
|
||||
string,
|
||||
{
|
||||
originPort: string;
|
||||
destinationPort: string;
|
||||
bookingCount: number;
|
||||
totalTEUs: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const booking of allBookings) {
|
||||
try {
|
||||
@ -231,16 +226,14 @@ export class AnalyticsService {
|
||||
}
|
||||
|
||||
// Convert to array and sort by booking count
|
||||
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(
|
||||
([route, data]) => ({
|
||||
route,
|
||||
originPort: data.originPort,
|
||||
destinationPort: data.destinationPort,
|
||||
bookingCount: data.bookingCount,
|
||||
totalTEUs: data.totalTEUs,
|
||||
avgPrice: data.totalPrice / data.bookingCount,
|
||||
})
|
||||
);
|
||||
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
|
||||
route,
|
||||
originPort: data.originPort,
|
||||
destinationPort: data.destinationPort,
|
||||
bookingCount: data.bookingCount,
|
||||
totalTEUs: data.totalTEUs,
|
||||
avgPrice: data.totalPrice / data.bookingCount,
|
||||
}));
|
||||
|
||||
// Sort by booking count and return top 5
|
||||
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
|
||||
@ -256,7 +249,7 @@ export class AnalyticsService {
|
||||
// Check for pending confirmations (older than 24h)
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const oldPendingBookings = allBookings.filter(
|
||||
(b) => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
||||
b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
||||
);
|
||||
|
||||
for (const booking of oldPendingBookings) {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -7,11 +7,7 @@
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
AuditLog,
|
||||
AuditAction,
|
||||
AuditStatus,
|
||||
} from '../../domain/entities/audit-log.entity';
|
||||
import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
|
||||
import {
|
||||
AuditLogRepository,
|
||||
AUDIT_LOG_REPOSITORY,
|
||||
@ -39,7 +35,7 @@ export class AuditService {
|
||||
|
||||
constructor(
|
||||
@Inject(AUDIT_LOG_REPOSITORY)
|
||||
private readonly auditLogRepository: AuditLogRepository,
|
||||
private readonly auditLogRepository: AuditLogRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -54,14 +50,12 @@ export class AuditService {
|
||||
|
||||
await this.auditLogRepository.save(auditLog);
|
||||
|
||||
this.logger.log(
|
||||
`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`,
|
||||
);
|
||||
this.logger.log(`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`);
|
||||
} catch (error: any) {
|
||||
// Never throw on audit logging failure - log the error and continue
|
||||
this.logger.error(
|
||||
`Failed to create audit log: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
error?.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -81,7 +75,7 @@ export class AuditService {
|
||||
metadata?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
},
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.log({
|
||||
action,
|
||||
@ -108,7 +102,7 @@ export class AuditService {
|
||||
metadata?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
},
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.log({
|
||||
action,
|
||||
@ -139,20 +133,14 @@ export class AuditService {
|
||||
/**
|
||||
* Get audit trail for a specific resource
|
||||
*/
|
||||
async getResourceAuditTrail(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
): Promise<AuditLog[]> {
|
||||
async getResourceAuditTrail(resourceType: string, resourceId: string): Promise<AuditLog[]> {
|
||||
return this.auditLogRepository.findByResource(resourceType, resourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity for an organization
|
||||
*/
|
||||
async getOrganizationActivity(
|
||||
organizationId: string,
|
||||
limit: number = 50,
|
||||
): Promise<AuditLog[]> {
|
||||
async getOrganizationActivity(organizationId: string, limit: number = 50): Promise<AuditLog[]> {
|
||||
return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
|
||||
}
|
||||
|
||||
|
||||
@ -8,12 +8,12 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { Booking } from '../../domain/entities/booking.entity';
|
||||
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
|
||||
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
|
||||
import {
|
||||
StoragePort,
|
||||
STORAGE_PORT,
|
||||
} from '../../domain/ports/out/storage.port';
|
||||
import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port';
|
||||
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
||||
import { RateQuoteRepository, RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository';
|
||||
import {
|
||||
RateQuoteRepository,
|
||||
RATE_QUOTE_REPOSITORY,
|
||||
} from '../../domain/ports/out/rate-quote.repository';
|
||||
|
||||
@Injectable()
|
||||
export class BookingAutomationService {
|
||||
@ -24,16 +24,14 @@ export class BookingAutomationService {
|
||||
@Inject(PDF_PORT) private readonly pdfPort: PdfPort,
|
||||
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository,
|
||||
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute all post-booking automation tasks
|
||||
*/
|
||||
async executePostBookingTasks(booking: Booking): Promise<void> {
|
||||
this.logger.log(
|
||||
`Starting post-booking automation for booking: ${booking.bookingNumber.value}`
|
||||
);
|
||||
this.logger.log(`Starting post-booking automation for booking: ${booking.bookingNumber.value}`);
|
||||
|
||||
try {
|
||||
// Get user and rate quote details
|
||||
@ -42,9 +40,7 @@ export class BookingAutomationService {
|
||||
throw new Error(`User not found: ${booking.userId}`);
|
||||
}
|
||||
|
||||
const rateQuote = await this.rateQuoteRepository.findById(
|
||||
booking.rateQuoteId
|
||||
);
|
||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||
if (!rateQuote) {
|
||||
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
|
||||
}
|
||||
@ -79,7 +75,7 @@ export class BookingAutomationService {
|
||||
email: booking.consignee.contactEmail,
|
||||
phone: booking.consignee.contactPhone,
|
||||
},
|
||||
containers: booking.containers.map((c) => ({
|
||||
containers: booking.containers.map(c => ({
|
||||
type: c.type,
|
||||
quantity: 1,
|
||||
containerNumber: c.containerNumber,
|
||||
@ -173,10 +169,7 @@ export class BookingAutomationService {
|
||||
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send booking update notification`,
|
||||
error
|
||||
);
|
||||
this.logger.error(`Failed to send booking update notification`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -25,10 +25,10 @@ export class ExportService {
|
||||
async exportBookings(
|
||||
data: BookingExportData[],
|
||||
format: ExportFormat,
|
||||
fields?: ExportField[],
|
||||
fields?: ExportField[]
|
||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||
this.logger.log(
|
||||
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`,
|
||||
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`
|
||||
);
|
||||
|
||||
switch (format) {
|
||||
@ -48,17 +48,17 @@ export class ExportService {
|
||||
*/
|
||||
private async exportToCSV(
|
||||
data: BookingExportData[],
|
||||
fields?: ExportField[],
|
||||
fields?: ExportField[]
|
||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||
const selectedFields = fields || Object.values(ExportField);
|
||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
||||
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||
|
||||
// Build CSV header
|
||||
const header = selectedFields.map((field) => this.getFieldLabel(field)).join(',');
|
||||
const header = selectedFields.map(field => this.getFieldLabel(field)).join(',');
|
||||
|
||||
// Build CSV rows
|
||||
const csvRows = rows.map((row) =>
|
||||
selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','),
|
||||
const csvRows = rows.map(row =>
|
||||
selectedFields.map(field => this.escapeCSVValue(row[field] || '')).join(',')
|
||||
);
|
||||
|
||||
const csv = [header, ...csvRows].join('\n');
|
||||
@ -79,10 +79,10 @@ export class ExportService {
|
||||
*/
|
||||
private async exportToExcel(
|
||||
data: BookingExportData[],
|
||||
fields?: ExportField[],
|
||||
fields?: ExportField[]
|
||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||
const selectedFields = fields || Object.values(ExportField);
|
||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
||||
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Xpeditis';
|
||||
@ -91,9 +91,7 @@ export class ExportService {
|
||||
const worksheet = workbook.addWorksheet('Bookings');
|
||||
|
||||
// Add header row with styling
|
||||
const headerRow = worksheet.addRow(
|
||||
selectedFields.map((field) => this.getFieldLabel(field)),
|
||||
);
|
||||
const headerRow = worksheet.addRow(selectedFields.map(field => this.getFieldLabel(field)));
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
@ -102,15 +100,15 @@ export class ExportService {
|
||||
};
|
||||
|
||||
// Add data rows
|
||||
rows.forEach((row) => {
|
||||
const values = selectedFields.map((field) => row[field] || '');
|
||||
rows.forEach(row => {
|
||||
const values = selectedFields.map(field => row[field] || '');
|
||||
worksheet.addRow(values);
|
||||
});
|
||||
|
||||
// Auto-fit columns
|
||||
worksheet.columns.forEach((column) => {
|
||||
worksheet.columns.forEach(column => {
|
||||
let maxLength = 10;
|
||||
column.eachCell?.({ includeEmpty: false }, (cell) => {
|
||||
column.eachCell?.({ includeEmpty: false }, cell => {
|
||||
const columnLength = cell.value ? String(cell.value).length : 10;
|
||||
if (columnLength > maxLength) {
|
||||
maxLength = columnLength;
|
||||
@ -136,10 +134,10 @@ export class ExportService {
|
||||
*/
|
||||
private async exportToJSON(
|
||||
data: BookingExportData[],
|
||||
fields?: ExportField[],
|
||||
fields?: ExportField[]
|
||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||
const selectedFields = fields || Object.values(ExportField);
|
||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
||||
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||
|
||||
const json = JSON.stringify(
|
||||
{
|
||||
@ -148,7 +146,7 @@ export class ExportService {
|
||||
bookings: rows,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
2
|
||||
);
|
||||
|
||||
const buffer = Buffer.from(json, 'utf-8');
|
||||
@ -166,14 +164,11 @@ export class ExportService {
|
||||
/**
|
||||
* Extract specified fields from booking data
|
||||
*/
|
||||
private extractFields(
|
||||
data: BookingExportData,
|
||||
fields: ExportField[],
|
||||
): Record<string, any> {
|
||||
private extractFields(data: BookingExportData, fields: ExportField[]): Record<string, any> {
|
||||
const { booking, rateQuote } = data;
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
fields.forEach(field => {
|
||||
switch (field) {
|
||||
case ExportField.BOOKING_NUMBER:
|
||||
result[field] = booking.bookingNumber.value;
|
||||
@ -206,7 +201,7 @@ export class ExportService {
|
||||
result[field] = booking.consignee.name;
|
||||
break;
|
||||
case ExportField.CONTAINER_TYPE:
|
||||
result[field] = booking.containers.map((c) => c.type).join(', ');
|
||||
result[field] = booking.containers.map(c => c.type).join(', ');
|
||||
break;
|
||||
case ExportField.CONTAINER_COUNT:
|
||||
result[field] = booking.containers.length;
|
||||
@ -217,7 +212,8 @@ export class ExportService {
|
||||
}, 0);
|
||||
break;
|
||||
case ExportField.PRICE:
|
||||
result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
||||
result[field] =
|
||||
`${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
@ -253,11 +249,7 @@ export class ExportService {
|
||||
*/
|
||||
private escapeCSVValue(value: string): string {
|
||||
const stringValue = String(value);
|
||||
if (
|
||||
stringValue.includes(',') ||
|
||||
stringValue.includes('"') ||
|
||||
stringValue.includes('\n')
|
||||
) {
|
||||
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
|
||||
@ -32,14 +32,14 @@ export class FileValidationService {
|
||||
// Validate file size
|
||||
if (file.size > fileUploadConfig.maxFileSize) {
|
||||
errors.push(
|
||||
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`,
|
||||
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
|
||||
errors.push(
|
||||
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`,
|
||||
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export class FileValidationService {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
|
||||
errors.push(
|
||||
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`,
|
||||
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -129,7 +129,7 @@ export class FileValidationService {
|
||||
];
|
||||
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
return dangerousExtensions.some((ext) => lowerFilename.includes(ext));
|
||||
return dangerousExtensions.some(ext => lowerFilename.includes(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -180,9 +180,7 @@ export class FileValidationService {
|
||||
|
||||
// TODO: Integrate with ClamAV or similar virus scanner
|
||||
// For now, just log
|
||||
this.logger.log(
|
||||
`Virus scan requested for file: ${file.originalname} (not implemented)`,
|
||||
);
|
||||
this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`);
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -190,9 +188,7 @@ export class FileValidationService {
|
||||
/**
|
||||
* Validate multiple files
|
||||
*/
|
||||
async validateFiles(
|
||||
files: Express.Multer.File[],
|
||||
): Promise<FileValidationResult> {
|
||||
async validateFiles(files: Express.Multer.File[]): Promise<FileValidationResult> {
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
|
||||
@ -16,7 +16,7 @@ export class FuzzySearchService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BookingOrmEntity)
|
||||
private readonly bookingOrmRepository: Repository<BookingOrmEntity>,
|
||||
private readonly bookingOrmRepository: Repository<BookingOrmEntity>
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -26,15 +26,13 @@ export class FuzzySearchService {
|
||||
async fuzzySearchBookings(
|
||||
searchTerm: string,
|
||||
organizationId: string,
|
||||
limit: number = 20,
|
||||
limit: number = 20
|
||||
): Promise<BookingOrmEntity[]> {
|
||||
if (!searchTerm || searchTerm.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Fuzzy search for "${searchTerm}" in organization ${organizationId}`,
|
||||
);
|
||||
this.logger.log(`Fuzzy search for "${searchTerm}" in organization ${organizationId}`);
|
||||
|
||||
// Use PostgreSQL full-text search with similarity
|
||||
// This requires pg_trgm extension to be enabled
|
||||
@ -54,7 +52,7 @@ export class FuzzySearchService {
|
||||
{
|
||||
searchTerm,
|
||||
likeTerm: `%${searchTerm}%`,
|
||||
},
|
||||
}
|
||||
)
|
||||
.orderBy(
|
||||
`GREATEST(
|
||||
@ -62,7 +60,7 @@ export class FuzzySearchService {
|
||||
similarity(booking.shipper_name, :searchTerm),
|
||||
similarity(booking.consignee_name, :searchTerm)
|
||||
)`,
|
||||
'DESC',
|
||||
'DESC'
|
||||
)
|
||||
.setParameter('searchTerm', searchTerm)
|
||||
.limit(limit)
|
||||
@ -80,21 +78,19 @@ export class FuzzySearchService {
|
||||
async fullTextSearch(
|
||||
searchTerm: string,
|
||||
organizationId: string,
|
||||
limit: number = 20,
|
||||
limit: number = 20
|
||||
): Promise<BookingOrmEntity[]> {
|
||||
if (!searchTerm || searchTerm.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Full-text search for "${searchTerm}" in organization ${organizationId}`,
|
||||
);
|
||||
this.logger.log(`Full-text search for "${searchTerm}" in organization ${organizationId}`);
|
||||
|
||||
// Convert search term to tsquery format
|
||||
const tsquery = searchTerm
|
||||
.split(/\s+/)
|
||||
.filter((term) => term.length > 0)
|
||||
.map((term) => `${term}:*`)
|
||||
.filter(term => term.length > 0)
|
||||
.map(term => `${term}:*`)
|
||||
.join(' & ');
|
||||
|
||||
const results = await this.bookingOrmRepository
|
||||
@ -111,7 +107,7 @@ export class FuzzySearchService {
|
||||
{
|
||||
tsquery,
|
||||
likeTerm: `%${searchTerm}%`,
|
||||
},
|
||||
}
|
||||
)
|
||||
.orderBy('booking.created_at', 'DESC')
|
||||
.limit(limit)
|
||||
@ -128,7 +124,7 @@ export class FuzzySearchService {
|
||||
async search(
|
||||
searchTerm: string,
|
||||
organizationId: string,
|
||||
limit: number = 20,
|
||||
limit: number = 20
|
||||
): Promise<BookingOrmEntity[]> {
|
||||
// Try fuzzy search first (more tolerant to typos)
|
||||
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
|
||||
|
||||
@ -31,7 +31,7 @@ export class GDPRService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
private readonly userRepository: Repository<UserOrmEntity>
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -63,7 +63,8 @@ export class GDPRService {
|
||||
exportDate: new Date().toISOString(),
|
||||
userId,
|
||||
userData: sanitizedUser,
|
||||
message: 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||
message:
|
||||
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||
};
|
||||
|
||||
this.logger.log(`Data export completed for user ${userId}`);
|
||||
@ -76,7 +77,9 @@ export class GDPRService {
|
||||
* Note: This is a simplified version. In production, implement full anonymization logic.
|
||||
*/
|
||||
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||
this.logger.warn(`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`);
|
||||
this.logger.warn(
|
||||
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||
);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
@ -117,7 +120,9 @@ export class GDPRService {
|
||||
|
||||
// In production, store in separate consent table
|
||||
// For now, just log the consent
|
||||
this.logger.log(`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`);
|
||||
this.logger.log(
|
||||
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -34,7 +34,7 @@ export class NotificationService {
|
||||
|
||||
constructor(
|
||||
@Inject(NOTIFICATION_REPOSITORY)
|
||||
private readonly notificationRepository: NotificationRepository,
|
||||
private readonly notificationRepository: NotificationRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -50,14 +50,14 @@ export class NotificationService {
|
||||
await this.notificationRepository.save(notification);
|
||||
|
||||
this.logger.log(
|
||||
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`,
|
||||
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`
|
||||
);
|
||||
|
||||
return notification;
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Failed to create notification: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@ -147,7 +147,7 @@ export class NotificationService {
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
bookingNumber: string,
|
||||
bookingId: string,
|
||||
bookingId: string
|
||||
): Promise<Notification> {
|
||||
return this.createNotification({
|
||||
userId,
|
||||
@ -166,7 +166,7 @@ export class NotificationService {
|
||||
organizationId: string,
|
||||
bookingNumber: string,
|
||||
bookingId: string,
|
||||
status: string,
|
||||
status: string
|
||||
): Promise<Notification> {
|
||||
return this.createNotification({
|
||||
userId,
|
||||
@ -184,7 +184,7 @@ export class NotificationService {
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
bookingNumber: string,
|
||||
bookingId: string,
|
||||
bookingId: string
|
||||
): Promise<Notification> {
|
||||
return this.createNotification({
|
||||
userId,
|
||||
@ -202,7 +202,7 @@ export class NotificationService {
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
documentName: string,
|
||||
bookingId: string,
|
||||
bookingId: string
|
||||
): Promise<Notification> {
|
||||
return this.createNotification({
|
||||
userId,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -9,11 +9,7 @@ import { HttpService } from '@nestjs/axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as crypto from 'crypto';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
Webhook,
|
||||
WebhookEvent,
|
||||
WebhookStatus,
|
||||
} from '../../domain/entities/webhook.entity';
|
||||
import { Webhook, WebhookEvent, WebhookStatus } from '../../domain/entities/webhook.entity';
|
||||
import {
|
||||
WebhookRepository,
|
||||
WEBHOOK_REPOSITORY,
|
||||
@ -51,7 +47,7 @@ export class WebhookService {
|
||||
constructor(
|
||||
@Inject(WEBHOOK_REPOSITORY)
|
||||
private readonly webhookRepository: WebhookRepository,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly httpService: HttpService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -72,9 +68,7 @@ export class WebhookService {
|
||||
|
||||
await this.webhookRepository.save(webhook);
|
||||
|
||||
this.logger.log(
|
||||
`Webhook created: ${webhook.id} for organization ${input.organizationId}`,
|
||||
);
|
||||
this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`);
|
||||
|
||||
return webhook;
|
||||
}
|
||||
@ -158,11 +152,7 @@ export class WebhookService {
|
||||
/**
|
||||
* Trigger webhooks for an event
|
||||
*/
|
||||
async triggerWebhooks(
|
||||
event: WebhookEvent,
|
||||
organizationId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise<void> {
|
||||
try {
|
||||
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
|
||||
|
||||
@ -179,17 +169,13 @@ export class WebhookService {
|
||||
};
|
||||
|
||||
// Trigger all webhooks in parallel
|
||||
await Promise.allSettled(
|
||||
webhooks.map((webhook) => this.triggerWebhook(webhook, payload)),
|
||||
);
|
||||
await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload)));
|
||||
|
||||
this.logger.log(
|
||||
`Triggered ${webhooks.length} webhooks for event: ${event}`,
|
||||
);
|
||||
this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Error triggering webhooks: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack,
|
||||
error?.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -197,10 +183,7 @@ export class WebhookService {
|
||||
/**
|
||||
* Trigger a single webhook with retries
|
||||
*/
|
||||
private async triggerWebhook(
|
||||
webhook: Webhook,
|
||||
payload: WebhookPayload,
|
||||
): Promise<void> {
|
||||
private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
|
||||
@ -226,7 +209,7 @@ export class WebhookService {
|
||||
this.httpService.post(webhook.url, payload, {
|
||||
headers,
|
||||
timeout: 10000, // 10 seconds
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
if (response && response.status >= 200 && response.status < 300) {
|
||||
@ -234,17 +217,17 @@ export class WebhookService {
|
||||
const updatedWebhook = webhook.recordTrigger();
|
||||
await this.webhookRepository.save(updatedWebhook);
|
||||
|
||||
this.logger.log(
|
||||
`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`,
|
||||
);
|
||||
this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`);
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`);
|
||||
lastError = new Error(
|
||||
`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
this.logger.warn(
|
||||
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`,
|
||||
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -254,7 +237,7 @@ export class WebhookService {
|
||||
await this.webhookRepository.save(failedWebhook);
|
||||
|
||||
this.logger.error(
|
||||
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`,
|
||||
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -279,16 +262,13 @@ export class WebhookService {
|
||||
*/
|
||||
verifySignature(payload: any, signature: string, secret: string): boolean {
|
||||
const expectedSignature = this.generateSignature(payload, secret);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for retries
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,9 +174,7 @@ export class Booking {
|
||||
*/
|
||||
updateStatus(newStatus: BookingStatus): Booking {
|
||||
if (!this.status.canTransitionTo(newStatus)) {
|
||||
throw new Error(
|
||||
`Cannot transition from ${this.status.value} to ${newStatus.value}`
|
||||
);
|
||||
throw new Error(`Cannot transition from ${this.status.value} to ${newStatus.value}`);
|
||||
}
|
||||
|
||||
return new Booking({
|
||||
@ -209,7 +207,7 @@ export class Booking {
|
||||
throw new Error('Cannot modify containers after booking is confirmed');
|
||||
}
|
||||
|
||||
const containerIndex = this.props.containers.findIndex((c) => c.id === containerId);
|
||||
const containerIndex = this.props.containers.findIndex(c => c.id === containerId);
|
||||
if (containerIndex === -1) {
|
||||
throw new Error(`Container ${containerId} not found`);
|
||||
}
|
||||
@ -237,7 +235,7 @@ export class Booking {
|
||||
|
||||
return new Booking({
|
||||
...this.props,
|
||||
containers: this.props.containers.filter((c) => c.id !== containerId),
|
||||
containers: this.props.containers.filter(c => c.id !== containerId),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -53,7 +53,9 @@ export class Carrier {
|
||||
|
||||
// Validate carrier code
|
||||
if (!Carrier.isValidCarrierCode(props.code)) {
|
||||
throw new Error('Invalid carrier code format. Must be uppercase letters and underscores only.');
|
||||
throw new Error(
|
||||
'Invalid carrier code format. Must be uppercase letters and underscores only.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate API config if carrier supports API
|
||||
|
||||
@ -233,7 +233,10 @@ export class Container {
|
||||
// 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) {
|
||||
} else if (
|
||||
this.props.size === ContainerSize.FORTY ||
|
||||
this.props.size === ContainerSize.FORTY_FIVE
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
|
||||
@ -55,7 +55,7 @@ export class CsvRate {
|
||||
public readonly currency: string, // Primary currency (USD or EUR)
|
||||
public readonly surcharges: SurchargeCollection,
|
||||
public readonly transitDays: number,
|
||||
public readonly validity: DateRange,
|
||||
public readonly validity: DateRange
|
||||
) {
|
||||
this.validate();
|
||||
}
|
||||
@ -111,7 +111,7 @@ export class CsvRate {
|
||||
// Freight class rule: max(volume price, weight price)
|
||||
const freightPrice = volume.calculateFreightPrice(
|
||||
this.pricing.pricePerCBM,
|
||||
this.pricing.pricePerKG,
|
||||
this.pricing.pricePerKG
|
||||
);
|
||||
|
||||
// Create Money object in the rate's currency
|
||||
@ -138,19 +138,13 @@ export class CsvRate {
|
||||
// Otherwise, use the pre-calculated base price in target currency
|
||||
// and recalculate proportionally
|
||||
const basePriceInPrimaryCurrency =
|
||||
this.currency === 'USD'
|
||||
? this.pricing.basePriceUSD
|
||||
: this.pricing.basePriceEUR;
|
||||
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||
|
||||
const basePriceInTargetCurrency =
|
||||
targetCurrency === 'USD'
|
||||
? this.pricing.basePriceUSD
|
||||
: this.pricing.basePriceEUR;
|
||||
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||
|
||||
// Calculate conversion ratio
|
||||
const ratio =
|
||||
basePriceInTargetCurrency.getAmount() /
|
||||
basePriceInPrimaryCurrency.getAmount();
|
||||
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
|
||||
|
||||
// Apply ratio to calculated price
|
||||
const convertedAmount = price.getAmount() * ratio;
|
||||
@ -179,7 +173,7 @@ export class CsvRate {
|
||||
this.volumeRange.minCBM,
|
||||
this.volumeRange.maxCBM,
|
||||
this.weightRange.minKG,
|
||||
this.weightRange.maxKG,
|
||||
this.weightRange.maxKG
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@ export class Notification {
|
||||
private constructor(private readonly props: NotificationProps) {}
|
||||
|
||||
static create(
|
||||
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string },
|
||||
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string }
|
||||
): Notification {
|
||||
return new Notification({
|
||||
...props,
|
||||
|
||||
@ -154,8 +154,12 @@ export class Port {
|
||||
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 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) +
|
||||
|
||||
@ -11,10 +11,10 @@
|
||||
*/
|
||||
|
||||
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
|
||||
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 {
|
||||
@ -45,7 +45,10 @@ export class User {
|
||||
* Factory method to create a new User
|
||||
*/
|
||||
static create(
|
||||
props: Omit<UserProps, 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'>
|
||||
props: Omit<
|
||||
UserProps,
|
||||
'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
|
||||
>
|
||||
): User {
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@ -41,7 +41,10 @@ export class Webhook {
|
||||
private constructor(private readonly props: WebhookProps) {}
|
||||
|
||||
static create(
|
||||
props: Omit<WebhookProps, 'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'> & { id: string },
|
||||
props: Omit<
|
||||
WebhookProps,
|
||||
'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'
|
||||
> & { id: string }
|
||||
): Webhook {
|
||||
return new Webhook({
|
||||
...props,
|
||||
|
||||
@ -43,9 +43,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
// Apply container type filter if specified
|
||||
if (input.containerType) {
|
||||
const containerType = ContainerType.create(input.containerType);
|
||||
matchingRates = matchingRates.filter((rate) =>
|
||||
rate.containerType.equals(containerType),
|
||||
);
|
||||
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||
}
|
||||
|
||||
// Apply advanced filters
|
||||
@ -54,7 +52,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
}
|
||||
|
||||
// Calculate prices and create results
|
||||
const results: CsvRateSearchResult[] = matchingRates.map((rate) => {
|
||||
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
||||
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
||||
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
||||
|
||||
@ -73,13 +71,9 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
// Sort by price (ascending) in primary currency
|
||||
results.sort((a, b) => {
|
||||
const priceA =
|
||||
a.calculatedPrice.primaryCurrency === 'USD'
|
||||
? a.calculatedPrice.usd
|
||||
: a.calculatedPrice.eur;
|
||||
a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur;
|
||||
const priceB =
|
||||
b.calculatedPrice.primaryCurrency === 'USD'
|
||||
? b.calculatedPrice.usd
|
||||
: b.calculatedPrice.eur;
|
||||
b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur;
|
||||
return priceA - priceB;
|
||||
});
|
||||
|
||||
@ -94,13 +88,13 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
|
||||
async getAvailableCompanies(): Promise<string[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const companies = new Set(allRates.map((rate) => rate.companyName));
|
||||
const companies = new Set(allRates.map(rate => rate.companyName));
|
||||
return Array.from(companies).sort();
|
||||
}
|
||||
|
||||
async getAvailableContainerTypes(): Promise<string[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const types = new Set(allRates.map((rate) => rate.containerType.getValue()));
|
||||
const types = new Set(allRates.map(rate => rate.containerType.getValue()));
|
||||
return Array.from(types).sort();
|
||||
}
|
||||
|
||||
@ -109,9 +103,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
*/
|
||||
private async loadAllRates(): Promise<CsvRate[]> {
|
||||
const files = await this.csvRateLoader.getAvailableCsvFiles();
|
||||
const ratePromises = files.map((file) =>
|
||||
this.csvRateLoader.loadRatesFromCsv(file),
|
||||
);
|
||||
const ratePromises = files.map(file => this.csvRateLoader.loadRatesFromCsv(file));
|
||||
const rateArrays = await Promise.all(ratePromises);
|
||||
return rateArrays.flat();
|
||||
}
|
||||
@ -119,26 +111,22 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
/**
|
||||
* Filter rates by route (origin/destination)
|
||||
*/
|
||||
private filterByRoute(
|
||||
rates: CsvRate[],
|
||||
origin: PortCode,
|
||||
destination: PortCode,
|
||||
): CsvRate[] {
|
||||
return rates.filter((rate) => rate.matchesRoute(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));
|
||||
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));
|
||||
return rates.filter(rate => rate.matchesPalletCount(palletCount));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -147,52 +135,40 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
private applyAdvancedFilters(
|
||||
rates: CsvRate[],
|
||||
filters: RateSearchFilters,
|
||||
volume: Volume,
|
||||
volume: Volume
|
||||
): CsvRate[] {
|
||||
let filtered = rates;
|
||||
|
||||
// Company filter
|
||||
if (filters.companies && filters.companies.length > 0) {
|
||||
filtered = filtered.filter((rate) =>
|
||||
filters.companies!.includes(rate.companyName),
|
||||
);
|
||||
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!,
|
||||
);
|
||||
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
|
||||
}
|
||||
if (filters.maxVolumeCBM !== undefined) {
|
||||
filtered = filtered.filter(
|
||||
(rate) => rate.volumeRange.minCBM <= filters.maxVolumeCBM!,
|
||||
);
|
||||
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!,
|
||||
);
|
||||
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
|
||||
}
|
||||
if (filters.maxWeightKG !== undefined) {
|
||||
filtered = filtered.filter(
|
||||
(rate) => rate.weightRange.minKG <= filters.maxWeightKG!,
|
||||
);
|
||||
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
|
||||
}
|
||||
|
||||
// Pallet count filter
|
||||
if (filters.palletCount !== undefined) {
|
||||
filtered = filtered.filter((rate) =>
|
||||
rate.matchesPalletCount(filters.palletCount!),
|
||||
);
|
||||
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) => {
|
||||
filtered = filtered.filter(rate => {
|
||||
const price = rate.getPriceInCurrency(volume, currency);
|
||||
const amount = price.getAmount();
|
||||
|
||||
@ -208,33 +184,27 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
|
||||
// Transit days filter
|
||||
if (filters.minTransitDays !== undefined) {
|
||||
filtered = filtered.filter(
|
||||
(rate) => rate.transitDays >= filters.minTransitDays!,
|
||||
);
|
||||
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
|
||||
}
|
||||
if (filters.maxTransitDays !== undefined) {
|
||||
filtered = filtered.filter(
|
||||
(rate) => rate.transitDays <= filters.maxTransitDays!,
|
||||
);
|
||||
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()),
|
||||
filtered = filtered.filter(rate =>
|
||||
filters.containerTypes!.includes(rate.containerType.getValue())
|
||||
);
|
||||
}
|
||||
|
||||
// All-in prices only filter
|
||||
if (filters.onlyAllInPrices) {
|
||||
filtered = filtered.filter((rate) => rate.isAllInPrice());
|
||||
filtered = filtered.filter(rate => rate.isAllInPrice());
|
||||
}
|
||||
|
||||
// Departure date / validity filter
|
||||
if (filters.departureDate) {
|
||||
filtered = filtered.filter((rate) =>
|
||||
rate.isValidForDate(filters.departureDate!),
|
||||
);
|
||||
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
@ -244,10 +214,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
* Calculate match score (0-100) based on how well rate matches input
|
||||
* Higher score = better match
|
||||
*/
|
||||
private calculateMatchScore(
|
||||
rate: CsvRate,
|
||||
input: CsvRateSearchInput,
|
||||
): number {
|
||||
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
|
||||
let score = 100;
|
||||
|
||||
// Reduce score if volume/weight is near boundaries
|
||||
@ -270,8 +237,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
|
||||
// Reduce score for rates expiring soon
|
||||
const daysUntilExpiry = Math.floor(
|
||||
(rate.validity.getEndDate().getTime() - Date.now()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (daysUntilExpiry < 7) {
|
||||
score -= 10;
|
||||
|
||||
@ -10,7 +10,12 @@
|
||||
*/
|
||||
|
||||
import { Port } from '../entities/port.entity';
|
||||
import { GetPortsPort, PortSearchInput, PortSearchOutput, GetPortInput } from '../ports/in/get-ports.port';
|
||||
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';
|
||||
|
||||
@ -53,8 +58,8 @@ export class PortSearchService implements GetPortsPort {
|
||||
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));
|
||||
const foundCodes = ports.map(p => p.code);
|
||||
const missingCodes = portCodes.filter(code => !foundCodes.includes(code));
|
||||
|
||||
if (missingCodes.length > 0) {
|
||||
throw new PortNotFoundException(missingCodes[0]);
|
||||
|
||||
@ -52,7 +52,7 @@ export class RateSearchService implements SearchRatesPort {
|
||||
|
||||
// Query all carriers in parallel with Promise.allSettled
|
||||
const carrierResults = await Promise.allSettled(
|
||||
connectorsToQuery.map((connector) => this.queryCarrier(connector, input))
|
||||
connectorsToQuery.map(connector => this.queryCarrier(connector, input))
|
||||
);
|
||||
|
||||
// Process results
|
||||
@ -140,7 +140,7 @@ export class RateSearchService implements SearchRatesPort {
|
||||
return this.carrierConnectors;
|
||||
}
|
||||
|
||||
return this.carrierConnectors.filter((connector) =>
|
||||
return this.carrierConnectors.filter(connector =>
|
||||
carrierPreferences.includes(connector.getCarrierCode())
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,9 +73,7 @@ export class BookingStatus {
|
||||
*/
|
||||
transitionTo(newStatus: BookingStatus): BookingStatus {
|
||||
if (!this.canTransitionTo(newStatus)) {
|
||||
throw new Error(
|
||||
`Invalid status transition from ${this._value} to ${newStatus._value}`
|
||||
);
|
||||
throw new Error(`Invalid status transition from ${this._value} to ${newStatus._value}`);
|
||||
}
|
||||
return newStatus;
|
||||
}
|
||||
|
||||
@ -76,9 +76,7 @@ export class DateRange {
|
||||
}
|
||||
|
||||
overlaps(other: DateRange): boolean {
|
||||
return (
|
||||
this.startDate <= other.endDate && this.endDate >= other.startDate
|
||||
);
|
||||
return this.startDate <= other.endDate && this.endDate >= other.startDate;
|
||||
}
|
||||
|
||||
isFutureRange(): boolean {
|
||||
|
||||
@ -20,7 +20,7 @@ export class Surcharge {
|
||||
constructor(
|
||||
public readonly type: SurchargeType,
|
||||
public readonly amount: Money,
|
||||
public readonly description?: string,
|
||||
public readonly description?: string
|
||||
) {
|
||||
this.validate();
|
||||
}
|
||||
@ -46,10 +46,7 @@ export class Surcharge {
|
||||
}
|
||||
|
||||
equals(other: Surcharge): boolean {
|
||||
return (
|
||||
this.type === other.type &&
|
||||
this.amount.isEqualTo(other.amount)
|
||||
);
|
||||
return this.type === other.type && this.amount.isEqualTo(other.amount);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
@ -70,15 +67,16 @@ export class SurchargeCollection {
|
||||
* In production, currency conversion would be needed
|
||||
*/
|
||||
getTotalAmount(currency: string): Money {
|
||||
const relevantSurcharges = this.surcharges
|
||||
.filter((s) => s.amount.getCurrency() === currency);
|
||||
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));
|
||||
return relevantSurcharges.reduce(
|
||||
(total, surcharge) => total.add(surcharge.amount),
|
||||
Money.zero(currency)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,7 +90,7 @@ export class SurchargeCollection {
|
||||
* Get surcharges by type
|
||||
*/
|
||||
getByType(type: SurchargeType): Surcharge[] {
|
||||
return this.surcharges.filter((s) => s.type === type);
|
||||
return this.surcharges.filter(s => s.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -102,6 +100,6 @@ export class SurchargeCollection {
|
||||
if (this.isEmpty()) {
|
||||
return 'All-in price (no separate surcharges)';
|
||||
}
|
||||
return this.surcharges.map((s) => s.toString()).join(', ');
|
||||
return this.surcharges.map(s => s.toString()).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
export class Volume {
|
||||
constructor(
|
||||
public readonly cbm: number,
|
||||
public readonly weightKG: number,
|
||||
public readonly weightKG: number
|
||||
) {
|
||||
this.validate();
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestr
|
||||
port,
|
||||
password,
|
||||
db,
|
||||
retryStrategy: (times) => {
|
||||
retryStrategy: times => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
@ -42,7 +42,7 @@ export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestr
|
||||
this.logger.log(`Connected to Redis at ${host}:${port}`);
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
this.client.on('error', err => {
|
||||
this.logger.error(`Redis connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
@ -112,7 +112,9 @@ export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestr
|
||||
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'}`);
|
||||
this.logger.error(
|
||||
`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,15 +44,9 @@ import { ONERequestMapper } from './one/one.mapper';
|
||||
mscConnector: MSCConnectorAdapter,
|
||||
cmacgmConnector: CMACGMConnectorAdapter,
|
||||
hapagConnector: HapagLloydConnectorAdapter,
|
||||
oneConnector: ONEConnectorAdapter,
|
||||
oneConnector: ONEConnectorAdapter
|
||||
) => {
|
||||
return [
|
||||
maerskConnector,
|
||||
mscConnector,
|
||||
cmacgmConnector,
|
||||
hapagConnector,
|
||||
oneConnector,
|
||||
];
|
||||
return [maerskConnector, mscConnector, cmacgmConnector, hapagConnector, oneConnector];
|
||||
},
|
||||
inject: [
|
||||
MaerskConnector,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -65,14 +65,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
|
||||
constructor() {
|
||||
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
||||
this.csvDirectory = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'storage',
|
||||
'csv-storage',
|
||||
'rates',
|
||||
);
|
||||
this.csvDirectory = path.join(__dirname, '..', '..', 'storage', 'csv-storage', 'rates');
|
||||
}
|
||||
|
||||
async loadRatesFromCsv(filePath: string): Promise<CsvRate[]> {
|
||||
@ -104,12 +97,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
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.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
|
||||
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -134,7 +123,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
}
|
||||
|
||||
async validateCsvFile(
|
||||
filePath: string,
|
||||
filePath: string
|
||||
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
@ -205,7 +194,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
}
|
||||
|
||||
const files = await fs.readdir(this.csvDirectory);
|
||||
return files.filter((file) => file.endsWith('.csv'));
|
||||
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}`);
|
||||
@ -243,14 +232,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
}
|
||||
|
||||
const firstRecord = records[0];
|
||||
const missingColumns = requiredColumns.filter(
|
||||
(col) => !(col in firstRecord),
|
||||
);
|
||||
const missingColumns = requiredColumns.filter(col => !(col in firstRecord));
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required columns: ${missingColumns.join(', ')}`,
|
||||
);
|
||||
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,19 +269,13 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
{
|
||||
pricePerCBM: parseFloat(record.pricePerCBM),
|
||||
pricePerKG: parseFloat(record.pricePerKG),
|
||||
basePriceUSD: Money.create(
|
||||
parseFloat(record.basePriceUSD),
|
||||
'USD',
|
||||
),
|
||||
basePriceEUR: Money.create(
|
||||
parseFloat(record.basePriceEUR),
|
||||
'EUR',
|
||||
),
|
||||
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,
|
||||
validity
|
||||
);
|
||||
}
|
||||
|
||||
@ -319,8 +298,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
new Surcharge(
|
||||
SurchargeType.BAF,
|
||||
Money.create(parseFloat(record.surchargeBAF), currency),
|
||||
'Bunker Adjustment Factor',
|
||||
),
|
||||
'Bunker Adjustment Factor'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -330,8 +309,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
new Surcharge(
|
||||
SurchargeType.CAF,
|
||||
Money.create(parseFloat(record.surchargeCAF), currency),
|
||||
'Currency Adjustment Factor',
|
||||
),
|
||||
'Currency Adjustment Factor'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -17,7 +17,7 @@ export class MaerskResponseMapper {
|
||||
originCode: string,
|
||||
destinationCode: string
|
||||
): RateQuote[] {
|
||||
return response.results.map((result) => this.toRateQuote(result, originCode, destinationCode));
|
||||
return response.results.map(result => this.toRateQuote(result, originCode, destinationCode));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,16 +28,14 @@ export class MaerskResponseMapper {
|
||||
originCode: string,
|
||||
destinationCode: string
|
||||
): RateQuote {
|
||||
const surcharges = result.pricing.charges.map((charge) => ({
|
||||
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)
|
||||
);
|
||||
const route = result.schedule.routeSchedule.map(segment => this.mapRouteSegment(segment));
|
||||
|
||||
return RateQuote.create({
|
||||
id: uuidv4(),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
@ -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<void> {
|
||||
try {
|
||||
const from = this.configService.get<string>(
|
||||
'SMTP_FROM',
|
||||
'noreply@xpeditis.com'
|
||||
);
|
||||
const from = this.configService.get<string>('SMTP_FROM', 'noreply@xpeditis.com');
|
||||
|
||||
await this.transporter.sendMail({
|
||||
from,
|
||||
|
||||
@ -155,10 +155,7 @@ export class EmailTemplates {
|
||||
/**
|
||||
* Render welcome email
|
||||
*/
|
||||
async renderWelcomeEmail(data: {
|
||||
firstName: string;
|
||||
dashboardUrl: string;
|
||||
}): Promise<string> {
|
||||
async renderWelcomeEmail(data: { firstName: string; dashboardUrl: string }): Promise<string> {
|
||||
const mjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-head>
|
||||
|
||||
@ -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<string, any>) {
|
||||
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<string, any>) {
|
||||
export function captureMessage(
|
||||
message: string,
|
||||
level: Sentry.SeverityLevel = 'info',
|
||||
context?: Record<string, any>,
|
||||
context?: Record<string, any>
|
||||
) {
|
||||
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<string, any>,
|
||||
level: Sentry.SeverityLevel = 'info',
|
||||
level: Sentry.SeverityLevel = 'info'
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
category,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -55,6 +55,6 @@ export class CarrierOrmMapper {
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: CarrierOrmEntity[]): Carrier[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
return orms.map(orm => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +63,6 @@ export class OrganizationOrmMapper {
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: OrganizationOrmEntity[]): Organization[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
return orms.map(orm => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,6 +59,6 @@ export class PortOrmMapper {
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: PortOrmEntity[]): Port[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
return orms.map(orm => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +93,6 @@ export class RateQuoteOrmMapper {
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: RateQuoteOrmEntity[]): RateQuote[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
return orms.map(orm => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +61,6 @@ export class UserOrmMapper {
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: UserOrmEntity[]): User[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
return orms.map(orm => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,11 @@ export class SeedCarriersAndOrganizations1730000000006 implements MigrationInter
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 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.')`);
|
||||
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.')`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -16,7 +16,7 @@ import { AuditLogOrmEntity } from '../entities/audit-log.orm-entity';
|
||||
export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
||||
constructor(
|
||||
@InjectRepository(AuditLogOrmEntity)
|
||||
private readonly ormRepository: Repository<AuditLogOrmEntity>,
|
||||
private readonly ormRepository: Repository<AuditLogOrmEntity>
|
||||
) {}
|
||||
|
||||
async save(auditLog: AuditLog): Promise<void> {
|
||||
@ -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<number> {
|
||||
@ -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<AuditLog[]> {
|
||||
@ -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<AuditLog[]> {
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -26,7 +26,7 @@ export class TypeOrmCarrierRepository implements CarrierRepository {
|
||||
}
|
||||
|
||||
async saveMany(carriers: Carrier[]): Promise<Carrier[]> {
|
||||
const orms = carriers.map((carrier) => CarrierOrmMapper.toOrm(carrier));
|
||||
const orms = carriers.map(carrier => CarrierOrmMapper.toOrm(carrier));
|
||||
const saved = await this.repository.save(orms);
|
||||
return CarrierOrmMapper.toDomainMany(saved);
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CsvRateConfigOrmEntity)
|
||||
private readonly repository: Repository<CsvRateConfigOrmEntity>,
|
||||
private readonly repository: Repository<CsvRateConfigOrmEntity>
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -90,7 +90,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
config: Partial<CsvRateConfigOrmEntity>,
|
||||
config: Partial<CsvRateConfigOrmEntity>
|
||||
): Promise<CsvRateConfigOrmEntity> {
|
||||
this.logger.log(`Updating CSV rate config: ${id}`);
|
||||
|
||||
@ -137,7 +137,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
||||
async updateValidationInfo(
|
||||
companyName: string,
|
||||
rowCount: number,
|
||||
validationResult: { valid: boolean; errors: string[] },
|
||||
validationResult: { valid: boolean; errors: string[] }
|
||||
): Promise<void> {
|
||||
this.logger.log(`Updating validation info for company: ${companyName}`);
|
||||
|
||||
@ -159,7 +159,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ import { NotificationOrmEntity } from '../entities/notification.orm-entity';
|
||||
export class TypeOrmNotificationRepository implements NotificationRepository {
|
||||
constructor(
|
||||
@InjectRepository(NotificationOrmEntity)
|
||||
private readonly ormRepository: Repository<NotificationOrmEntity>,
|
||||
private readonly ormRepository: Repository<NotificationOrmEntity>
|
||||
) {}
|
||||
|
||||
async save(notification: Notification): Promise<void> {
|
||||
@ -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<number> {
|
||||
@ -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<number> {
|
||||
@ -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<void> {
|
||||
@ -163,7 +163,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository {
|
||||
{
|
||||
read: true,
|
||||
read_at: new Date(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ export class TypeOrmPortRepository implements PortRepository {
|
||||
}
|
||||
|
||||
async saveMany(ports: Port[]): Promise<Port[]> {
|
||||
const orms = ports.map((port) => PortOrmMapper.toOrm(port));
|
||||
const orms = ports.map(port => PortOrmMapper.toOrm(port));
|
||||
const saved = await this.repository.save(orms);
|
||||
return PortOrmMapper.toDomainMany(saved);
|
||||
}
|
||||
@ -39,7 +39,7 @@ export class TypeOrmPortRepository implements PortRepository {
|
||||
}
|
||||
|
||||
async findByCodes(codes: string[]): Promise<Port[]> {
|
||||
const upperCodes = codes.map((c) => c.toUpperCase());
|
||||
const upperCodes = codes.map(c => c.toUpperCase());
|
||||
const orms = await this.repository
|
||||
.createQueryBuilder('port')
|
||||
.where('port.code IN (:...codes)', { codes: upperCodes })
|
||||
@ -54,14 +54,11 @@ export class TypeOrmPortRepository implements PortRepository {
|
||||
|
||||
// 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}%`,
|
||||
}
|
||||
);
|
||||
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() });
|
||||
|
||||
@ -26,7 +26,7 @@ export class TypeOrmRateQuoteRepository implements RateQuoteRepository {
|
||||
}
|
||||
|
||||
async saveMany(rateQuotes: RateQuote[]): Promise<RateQuote[]> {
|
||||
const orms = rateQuotes.map((rq) => RateQuoteOrmMapper.toOrm(rq));
|
||||
const orms = rateQuotes.map(rq => RateQuoteOrmMapper.toOrm(rq));
|
||||
const saved = await this.repository.save(orms);
|
||||
return RateQuoteOrmMapper.toDomainMany(saved);
|
||||
}
|
||||
|
||||
@ -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<WebhookOrmEntity>,
|
||||
private readonly ormRepository: Repository<WebhookOrmEntity>
|
||||
) {}
|
||||
|
||||
async save(webhook: Webhook): Promise<void> {
|
||||
@ -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<Webhook[]> {
|
||||
@ -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<Webhook[]> {
|
||||
@ -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<void> {
|
||||
|
||||
@ -76,7 +76,7 @@ export const carrierSeeds: CarrierSeed[] = [
|
||||
export function getCarriersInsertSQL(): string {
|
||||
const values = carrierSeeds
|
||||
.map(
|
||||
(carrier) =>
|
||||
carrier =>
|
||||
`('${carrier.id}', '${carrier.name}', '${carrier.code}', '${carrier.scac}', ` +
|
||||
`'${carrier.logoUrl}', '${carrier.website}', NULL, ${carrier.isActive}, ${carrier.supportsApi}, NOW(), NOW())`
|
||||
)
|
||||
|
||||
@ -64,7 +64,7 @@ export const organizationSeeds: OrganizationSeed[] = [
|
||||
export function getOrganizationsInsertSQL(): string {
|
||||
const values = organizationSeeds
|
||||
.map(
|
||||
(org) =>
|
||||
org =>
|
||||
`('${org.id}', '${org.name}', '${org.type}', ` +
|
||||
`${org.scac ? `'${org.scac}'` : 'NULL'}, ` +
|
||||
`'${org.addressStreet}', '${org.addressCity}', ` +
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -22,11 +22,7 @@ import { rateLimitConfig } from './security.config';
|
||||
},
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
FileValidationService,
|
||||
BruteForceProtectionService,
|
||||
CustomThrottlerGuard,
|
||||
],
|
||||
providers: [FileValidationService, BruteForceProtectionService, CustomThrottlerGuard],
|
||||
exports: [
|
||||
FileValidationService,
|
||||
BruteForceProtectionService,
|
||||
|
||||
@ -36,9 +36,7 @@ export class S3StorageAdapter implements StoragePort {
|
||||
const region = this.configService.get<string>('AWS_REGION', 'us-east-1');
|
||||
const endpoint = this.configService.get<string>('AWS_S3_ENDPOINT');
|
||||
const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY_ID');
|
||||
const secretAccessKey = this.configService.get<string>(
|
||||
'AWS_SECRET_ACCESS_KEY'
|
||||
);
|
||||
const secretAccessKey = this.configService.get<string>('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<string> {
|
||||
async getSignedUrl(options: DownloadOptions, expiresIn: number = 3600): Promise<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -309,7 +309,7 @@ describe('TypeOrmBookingRepository (Integration)', () => {
|
||||
const bookings = await repository.findByOrganization(testOrganization.id);
|
||||
|
||||
expect(bookings).toHaveLength(3);
|
||||
expect(bookings.every((b) => b.organizationId === testOrganization.id)).toBe(true);
|
||||
expect(bookings.every(b => b.organizationId === testOrganization.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for organization with no bookings', async () => {
|
||||
@ -338,8 +338,8 @@ describe('TypeOrmBookingRepository (Integration)', () => {
|
||||
|
||||
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);
|
||||
expect(draftBookings.every(b => b.status.value === 'draft')).toBe(true);
|
||||
expect(confirmedBookings.every(b => b.status.value === 'confirmed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -230,7 +230,7 @@ describe('MaerskConnector (Integration)', () => {
|
||||
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);
|
||||
const hasVesselInfo = quotes[0].route.some(seg => seg.vesselName);
|
||||
expect(hasVesselInfo).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user