format prettier
This commit is contained in:
parent
07b08e3014
commit
d809feecef
@ -31,9 +31,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validationSchema: Joi.object({
|
validationSchema: Joi.object({
|
||||||
NODE_ENV: Joi.string()
|
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
|
||||||
.valid('development', 'production', 'test')
|
|
||||||
.default('development'),
|
|
||||||
PORT: Joi.number().default(4000),
|
PORT: Joi.number().default(4000),
|
||||||
DATABASE_HOST: Joi.string().required(),
|
DATABASE_HOST: Joi.string().required(),
|
||||||
DATABASE_PORT: Joi.number().default(5432),
|
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 { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
@ -22,7 +28,7 @@ export class AuthService {
|
|||||||
@Inject(USER_REPOSITORY)
|
@Inject(USER_REPOSITORY)
|
||||||
private readonly userRepository: UserRepository, // ✅ Correct injection
|
private readonly userRepository: UserRepository, // ✅ Correct injection
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +39,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
firstName: string,
|
firstName: string,
|
||||||
lastName: string,
|
lastName: string,
|
||||||
organizationId?: string,
|
organizationId?: string
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Registering new user: ${email}`);
|
this.logger.log(`Registering new user: ${email}`);
|
||||||
|
|
||||||
@ -87,7 +93,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
this.logger.log(`Login attempt for: ${email}`);
|
this.logger.log(`Login attempt for: ${email}`);
|
||||||
|
|
||||||
@ -127,7 +133,9 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
*/
|
*/
|
||||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
async refreshAccessToken(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||||
secret: this.configService.get('JWT_SECRET'),
|
secret: this.configService.get('JWT_SECRET'),
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export interface JwtPayload {
|
|||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export class CsvRatesAdminController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly csvLoader: CsvRateLoaderAdapter,
|
private readonly csvLoader: CsvRateLoaderAdapter,
|
||||||
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
||||||
private readonly csvRateMapper: CsvRateMapper,
|
private readonly csvRateMapper: CsvRateMapper
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -88,7 +88,7 @@ export class CsvRatesAdminController {
|
|||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
fileSize: 10 * 1024 * 1024, // 10MB max
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ -130,11 +130,9 @@ export class CsvRatesAdminController {
|
|||||||
async uploadCsv(
|
async uploadCsv(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body() dto: CsvRateUploadDto,
|
@Body() dto: CsvRateUploadDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<CsvRateUploadResponseDto> {
|
): Promise<CsvRateUploadResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`);
|
||||||
`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new BadRequestException('File is required');
|
throw new BadRequestException('File is required');
|
||||||
@ -146,7 +144,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`,
|
`CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}`
|
||||||
);
|
);
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: 'CSV validation failed',
|
message: 'CSV validation failed',
|
||||||
@ -158,14 +156,10 @@ export class CsvRatesAdminController {
|
|||||||
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
||||||
const ratesCount = rates.length;
|
const ratesCount = rates.length;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||||
`Successfully parsed ${ratesCount} rates from ${file.filename}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if config exists for this company
|
// Check if config exists for this company
|
||||||
const existingConfig = await this.csvConfigRepository.findByCompanyName(
|
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
|
||||||
dto.companyName,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
// Update existing configuration
|
// Update existing configuration
|
||||||
@ -185,9 +179,7 @@ export class CsvRatesAdminController {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Updated CSV config for company: ${dto.companyName}`);
|
||||||
`Updated CSV config for company: ${dto.companyName}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Create new configuration
|
// Create new configuration
|
||||||
await this.csvConfigRepository.create({
|
await this.csvConfigRepository.create({
|
||||||
@ -207,9 +199,7 @@ export class CsvRatesAdminController {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Created new CSV config for company: ${dto.companyName}`);
|
||||||
`Created new CSV config for company: ${dto.companyName}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -220,10 +210,7 @@ export class CsvRatesAdminController {
|
|||||||
uploadedAt: new Date(),
|
uploadedAt: new Date(),
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
||||||
`CSV upload failed: ${error?.message || 'Unknown error'}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,17 +254,13 @@ export class CsvRatesAdminController {
|
|||||||
status: 404,
|
status: 404,
|
||||||
description: 'Company configuration not found',
|
description: 'Company configuration not found',
|
||||||
})
|
})
|
||||||
async getConfigByCompany(
|
async getConfigByCompany(@Param('companyName') companyName: string): Promise<CsvRateConfigDto> {
|
||||||
@Param('companyName') companyName: string,
|
|
||||||
): Promise<CsvRateConfigDto> {
|
|
||||||
this.logger.log(`Fetching CSV config for company: ${companyName}`);
|
this.logger.log(`Fetching CSV config for company: ${companyName}`);
|
||||||
|
|
||||||
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
|
||||||
`No CSV configuration found for company: ${companyName}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.csvRateMapper.mapConfigEntityToDto(config);
|
return this.csvRateMapper.mapConfigEntityToDto(config);
|
||||||
@ -298,28 +281,20 @@ export class CsvRatesAdminController {
|
|||||||
description: 'Validation result',
|
description: 'Validation result',
|
||||||
type: CsvFileValidationDto,
|
type: CsvFileValidationDto,
|
||||||
})
|
})
|
||||||
async validateCsvFile(
|
async validateCsvFile(@Param('companyName') companyName: string): Promise<CsvFileValidationDto> {
|
||||||
@Param('companyName') companyName: string,
|
|
||||||
): Promise<CsvFileValidationDto> {
|
|
||||||
this.logger.log(`Validating CSV file for company: ${companyName}`);
|
this.logger.log(`Validating CSV file for company: ${companyName}`);
|
||||||
|
|
||||||
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
const config = await this.csvConfigRepository.findByCompanyName(companyName);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`No CSV configuration found for company: ${companyName}`);
|
||||||
`No CSV configuration found for company: ${companyName}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
|
const result = await this.csvLoader.validateCsvFile(config.csvFilePath);
|
||||||
|
|
||||||
// Update validation timestamp
|
// Update validation timestamp
|
||||||
if (result.valid && result.rowCount) {
|
if (result.valid && result.rowCount) {
|
||||||
await this.csvConfigRepository.updateValidationInfo(
|
await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result);
|
||||||
companyName,
|
|
||||||
result.rowCount,
|
|
||||||
result,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -345,11 +320,9 @@ export class CsvRatesAdminController {
|
|||||||
})
|
})
|
||||||
async deleteConfig(
|
async deleteConfig(
|
||||||
@Param('companyName') companyName: string,
|
@Param('companyName') companyName: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.warn(
|
this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`);
|
||||||
`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.csvConfigRepository.delete(companyName);
|
await this.csvConfigRepository.delete(companyName);
|
||||||
|
|
||||||
|
|||||||
@ -66,8 +66,18 @@ export class AuditController {
|
|||||||
@ApiOperation({ summary: 'Get audit logs with filters' })
|
@ApiOperation({ summary: 'Get audit logs with filters' })
|
||||||
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
||||||
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
|
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
|
||||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true })
|
@ApiQuery({
|
||||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true })
|
name: 'action',
|
||||||
|
required: false,
|
||||||
|
description: 'Filter by action (comma-separated)',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'status',
|
||||||
|
required: false,
|
||||||
|
description: 'Filter by status (comma-separated)',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
|
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
|
||||||
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
|
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
|
||||||
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
|
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
|
||||||
@ -84,7 +94,7 @@ export class AuditController {
|
|||||||
@Query('startDate') startDate?: string,
|
@Query('startDate') startDate?: string,
|
||||||
@Query('endDate') endDate?: string,
|
@Query('endDate') endDate?: string,
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
|
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
|
||||||
page = page || 1;
|
page = page || 1;
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
@ -104,7 +114,7 @@ export class AuditController {
|
|||||||
const { logs, total } = await this.auditService.getAuditLogs(filters);
|
const { logs, total } = await this.auditService.getAuditLogs(filters);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs: logs.map((log) => this.mapToDto(log)),
|
logs: logs.map(log => this.mapToDto(log)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
@ -121,7 +131,7 @@ export class AuditController {
|
|||||||
@ApiResponse({ status: 404, description: 'Audit log not found' })
|
@ApiResponse({ status: 404, description: 'Audit log not found' })
|
||||||
async getAuditLogById(
|
async getAuditLogById(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<AuditLogResponseDto> {
|
): Promise<AuditLogResponseDto> {
|
||||||
const log = await this.auditService.getAuditLogs({
|
const log = await this.auditService.getAuditLogs({
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
@ -145,14 +155,14 @@ export class AuditController {
|
|||||||
async getResourceAuditTrail(
|
async getResourceAuditTrail(
|
||||||
@Param('type') resourceType: string,
|
@Param('type') resourceType: string,
|
||||||
@Param('id') resourceId: string,
|
@Param('id') resourceId: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<AuditLogResponseDto[]> {
|
): Promise<AuditLogResponseDto[]> {
|
||||||
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
|
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
|
||||||
|
|
||||||
// Filter by organization for security
|
// Filter by organization for security
|
||||||
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
|
||||||
|
|
||||||
return filteredLogs.map((log) => this.mapToDto(log));
|
return filteredLogs.map(log => this.mapToDto(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -165,11 +175,11 @@ export class AuditController {
|
|||||||
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
||||||
async getOrganizationActivity(
|
async getOrganizationActivity(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<AuditLogResponseDto[]> {
|
): Promise<AuditLogResponseDto[]> {
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
|
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
|
||||||
return logs.map((log) => this.mapToDto(log));
|
return logs.map(log => this.mapToDto(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -183,15 +193,15 @@ export class AuditController {
|
|||||||
async getUserActivity(
|
async getUserActivity(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<AuditLogResponseDto[]> {
|
): Promise<AuditLogResponseDto[]> {
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
const logs = await this.auditService.getUserActivity(userId, limit);
|
const logs = await this.auditService.getUserActivity(userId, limit);
|
||||||
|
|
||||||
// Filter by organization for security
|
// Filter by organization for security
|
||||||
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
const filteredLogs = logs.filter(log => log.organizationId === user.organizationId);
|
||||||
|
|
||||||
return filteredLogs.map((log) => this.mapToDto(log));
|
return filteredLogs.map(log => this.mapToDto(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,25 +1,7 @@
|
|||||||
import {
|
import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get } from '@nestjs/common';
|
||||||
Controller,
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
UseGuards,
|
|
||||||
Get,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import {
|
import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto';
|
||||||
LoginDto,
|
|
||||||
RegisterDto,
|
|
||||||
AuthResponseDto,
|
|
||||||
RefreshTokenDto,
|
|
||||||
} from '../dto/auth-login.dto';
|
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
@ -52,8 +34,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Register new user',
|
summary: 'Register new user',
|
||||||
description:
|
description: 'Create a new user account with email and password. Returns JWT tokens.',
|
||||||
'Create a new user account with email and password. Returns JWT tokens.',
|
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 201,
|
status: 201,
|
||||||
@ -74,7 +55,7 @@ export class AuthController {
|
|||||||
dto.password,
|
dto.password,
|
||||||
dto.firstName,
|
dto.firstName,
|
||||||
dto.lastName,
|
dto.lastName,
|
||||||
dto.organizationId,
|
dto.organizationId
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -147,11 +128,8 @@ export class AuthController {
|
|||||||
status: 401,
|
status: 401,
|
||||||
description: 'Invalid or expired refresh token',
|
description: 'Invalid or expired refresh token',
|
||||||
})
|
})
|
||||||
async refresh(
|
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
|
||||||
@Body() dto: RefreshTokenDto,
|
const result = await this.authService.refreshAccessToken(dto.refreshToken);
|
||||||
): Promise<{ accessToken: string }> {
|
|
||||||
const result =
|
|
||||||
await this.authService.refreshAccessToken(dto.refreshToken);
|
|
||||||
|
|
||||||
return { accessToken: result.accessToken };
|
return { accessToken: result.accessToken };
|
||||||
}
|
}
|
||||||
@ -170,8 +148,7 @@ export class AuthController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Logout',
|
summary: 'Logout',
|
||||||
description:
|
description: 'Logout the current user. Currently handled client-side by removing tokens.',
|
||||||
'Logout the current user. Currently handled client-side by removing tokens.',
|
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -32,17 +32,16 @@ import {
|
|||||||
ApiProduces,
|
ApiProduces,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import {
|
import { CreateBookingRequestDto, BookingResponseDto, BookingListResponseDto } from '../dto';
|
||||||
CreateBookingRequestDto,
|
|
||||||
BookingResponseDto,
|
|
||||||
BookingListResponseDto,
|
|
||||||
} from '../dto';
|
|
||||||
import { BookingFilterDto } from '../dto/booking-filter.dto';
|
import { BookingFilterDto } from '../dto/booking-filter.dto';
|
||||||
import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto';
|
import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto';
|
||||||
import { BookingMapper } from '../mappers';
|
import { BookingMapper } from '../mappers';
|
||||||
import { BookingService } from '../../domain/services/booking.service';
|
import { BookingService } from '../../domain/services/booking.service';
|
||||||
import { BookingRepository, BOOKING_REPOSITORY } from '../../domain/ports/out/booking.repository';
|
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 { BookingNumber } from '../../domain/value-objects/booking-number.vo';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
@ -71,7 +70,7 @@ export class BookingsController {
|
|||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly notificationsGateway: NotificationsGateway,
|
private readonly notificationsGateway: NotificationsGateway,
|
||||||
private readonly webhookService: WebhookService,
|
private readonly webhookService: WebhookService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -102,11 +101,9 @@ export class BookingsController {
|
|||||||
})
|
})
|
||||||
async createBooking(
|
async createBooking(
|
||||||
@Body() dto: CreateBookingRequestDto,
|
@Body() dto: CreateBookingRequestDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<BookingResponseDto> {
|
): Promise<BookingResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
|
||||||
`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert DTO to domain input, using authenticated user's data
|
// Convert DTO to domain input, using authenticated user's data
|
||||||
@ -129,7 +126,7 @@ export class BookingsController {
|
|||||||
const response = BookingMapper.toDto(booking, rateQuote);
|
const response = BookingMapper.toDto(booking, rateQuote);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`,
|
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Audit log: Booking created
|
// Audit log: Booking created
|
||||||
@ -147,7 +144,7 @@ export class BookingsController {
|
|||||||
status: booking.status.value,
|
status: booking.status.value,
|
||||||
carrier: rateQuote.carrierName,
|
carrier: rateQuote.carrierName,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send real-time notification
|
// Send real-time notification
|
||||||
@ -156,7 +153,7 @@ export class BookingsController {
|
|||||||
user.id,
|
user.id,
|
||||||
user.organizationId,
|
user.organizationId,
|
||||||
booking.bookingNumber.value,
|
booking.bookingNumber.value,
|
||||||
booking.id,
|
booking.id
|
||||||
);
|
);
|
||||||
await this.notificationsGateway.sendNotificationToUser(user.id, notification);
|
await this.notificationsGateway.sendNotificationToUser(user.id, notification);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -181,7 +178,7 @@ export class BookingsController {
|
|||||||
etd: rateQuote.etd?.toISOString(),
|
etd: rateQuote.etd?.toISOString(),
|
||||||
eta: rateQuote.eta?.toISOString(),
|
eta: rateQuote.eta?.toISOString(),
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Don't fail the booking creation if webhook fails
|
// Don't fail the booking creation if webhook fails
|
||||||
@ -192,7 +189,7 @@ export class BookingsController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Booking creation failed: ${error?.message || 'Unknown error'}`,
|
`Booking creation failed: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
|
|
||||||
// Audit log: Booking creation failed
|
// Audit log: Booking creation failed
|
||||||
@ -207,7 +204,7 @@ export class BookingsController {
|
|||||||
metadata: {
|
metadata: {
|
||||||
rateQuoteId: dto.rateQuoteId,
|
rateQuoteId: dto.rateQuoteId,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@ -217,8 +214,7 @@ export class BookingsController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get booking by ID',
|
summary: 'Get booking by ID',
|
||||||
description:
|
description: 'Retrieve detailed information about a specific booking. Requires authentication.',
|
||||||
'Retrieve detailed information about a specific booking. Requires authentication.',
|
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
@ -239,7 +235,7 @@ export class BookingsController {
|
|||||||
})
|
})
|
||||||
async getBooking(
|
async getBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<BookingResponseDto> {
|
): Promise<BookingResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`);
|
this.logger.log(`[User: ${user.email}] Fetching booking: ${id}`);
|
||||||
|
|
||||||
@ -287,15 +283,12 @@ export class BookingsController {
|
|||||||
})
|
})
|
||||||
async getBookingByNumber(
|
async getBookingByNumber(
|
||||||
@Param('bookingNumber') bookingNumber: string,
|
@Param('bookingNumber') bookingNumber: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<BookingResponseDto> {
|
): Promise<BookingResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`);
|
||||||
`[User: ${user.email}] Fetching booking by number: ${bookingNumber}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bookingNumberVo = BookingNumber.fromString(bookingNumber);
|
const bookingNumberVo = BookingNumber.fromString(bookingNumber);
|
||||||
const booking =
|
const booking = await this.bookingRepository.findByBookingNumber(bookingNumberVo);
|
||||||
await this.bookingRepository.findByBookingNumber(bookingNumberVo);
|
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${bookingNumber} not found`);
|
throw new NotFoundException(`Booking ${bookingNumber} not found`);
|
||||||
@ -337,14 +330,7 @@ export class BookingsController {
|
|||||||
name: 'status',
|
name: 'status',
|
||||||
required: false,
|
required: false,
|
||||||
description: 'Filter by booking status',
|
description: 'Filter by booking status',
|
||||||
enum: [
|
enum: ['draft', 'pending_confirmation', 'confirmed', 'in_transit', 'delivered', 'cancelled'],
|
||||||
'draft',
|
|
||||||
'pending_confirmation',
|
|
||||||
'confirmed',
|
|
||||||
'in_transit',
|
|
||||||
'delivered',
|
|
||||||
'cancelled',
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.OK,
|
status: HttpStatus.OK,
|
||||||
@ -359,18 +345,17 @@ export class BookingsController {
|
|||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||||
@Query('status') status: string | undefined,
|
@Query('status') status: string | undefined,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<BookingListResponseDto> {
|
): Promise<BookingListResponseDto> {
|
||||||
this.logger.log(
|
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
|
// Use authenticated user's organization ID
|
||||||
const organizationId = user.organizationId;
|
const organizationId = user.organizationId;
|
||||||
|
|
||||||
// Fetch bookings for the user's organization
|
// Fetch bookings for the user's organization
|
||||||
const bookings =
|
const bookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||||
await this.bookingRepository.findByOrganization(organizationId);
|
|
||||||
|
|
||||||
// Filter by status if provided
|
// Filter by status if provided
|
||||||
const filteredBookings = status
|
const filteredBookings = status
|
||||||
@ -385,11 +370,9 @@ export class BookingsController {
|
|||||||
// Fetch rate quotes for all bookings
|
// Fetch rate quotes for all bookings
|
||||||
const bookingsWithQuotes = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async (booking: any) => {
|
paginatedBookings.map(async (booking: any) => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
booking.rateQuoteId,
|
|
||||||
);
|
|
||||||
return { booking, rateQuote: rateQuote! };
|
return { booking, rateQuote: rateQuote! };
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
@ -436,7 +419,7 @@ export class BookingsController {
|
|||||||
async fuzzySearch(
|
async fuzzySearch(
|
||||||
@Query('q') searchTerm: string,
|
@Query('q') searchTerm: string,
|
||||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<BookingResponseDto[]> {
|
): Promise<BookingResponseDto[]> {
|
||||||
this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`);
|
this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`);
|
||||||
|
|
||||||
@ -448,21 +431,21 @@ export class BookingsController {
|
|||||||
const bookingOrms = await this.fuzzySearchService.search(
|
const bookingOrms = await this.fuzzySearchService.search(
|
||||||
searchTerm,
|
searchTerm,
|
||||||
user.organizationId,
|
user.organizationId,
|
||||||
limit,
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map ORM entities to domain and fetch rate quotes
|
// Map ORM entities to domain and fetch rate quotes
|
||||||
const bookingsWithQuotes = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookingOrms.map(async (bookingOrm) => {
|
bookingOrms.map(async bookingOrm => {
|
||||||
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
||||||
return { booking: booking!, rateQuote: rateQuote! };
|
return { booking: booking!, rateQuote: rateQuote! };
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
||||||
BookingMapper.toDto(booking, rateQuote),
|
BookingMapper.toDto(booking, rateQuote)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`);
|
this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`);
|
||||||
@ -487,10 +470,10 @@ export class BookingsController {
|
|||||||
})
|
})
|
||||||
async advancedSearch(
|
async advancedSearch(
|
||||||
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<BookingListResponseDto> {
|
): Promise<BookingListResponseDto> {
|
||||||
this.logger.log(
|
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
|
// Fetch all bookings for organization
|
||||||
@ -512,10 +495,10 @@ export class BookingsController {
|
|||||||
|
|
||||||
// Fetch rate quotes
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotes = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async (booking) => {
|
paginatedBookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote: rateQuote! };
|
return { booking, rateQuote: rateQuote! };
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
@ -539,7 +522,11 @@ export class BookingsController {
|
|||||||
description:
|
description:
|
||||||
'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.',
|
'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({
|
@ApiResponse({
|
||||||
status: HttpStatus.OK,
|
status: HttpStatus.OK,
|
||||||
description: 'Export file generated successfully',
|
description: 'Export file generated successfully',
|
||||||
@ -552,20 +539,18 @@ export class BookingsController {
|
|||||||
@Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto,
|
@Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto,
|
||||||
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
this.logger.log(
|
this.logger.log(`[User: ${user.email}] Exporting bookings to ${exportDto.format}`);
|
||||||
`[User: ${user.email}] Exporting bookings to ${exportDto.format}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let bookings: any[];
|
let bookings: any[];
|
||||||
|
|
||||||
// If specific booking IDs provided, use those
|
// If specific booking IDs provided, use those
|
||||||
if (exportDto.bookingIds && exportDto.bookingIds.length > 0) {
|
if (exportDto.bookingIds && exportDto.bookingIds.length > 0) {
|
||||||
bookings = await Promise.all(
|
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 {
|
} else {
|
||||||
// Otherwise, use filter criteria
|
// Otherwise, use filter criteria
|
||||||
bookings = await this.bookingRepository.findByOrganization(user.organizationId);
|
bookings = await this.bookingRepository.findByOrganization(user.organizationId);
|
||||||
@ -574,17 +559,17 @@ export class BookingsController {
|
|||||||
|
|
||||||
// Fetch rate quotes
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotes = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookings.map(async (booking) => {
|
bookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote: rateQuote! };
|
return { booking, rateQuote: rateQuote! };
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate export file
|
// Generate export file
|
||||||
const exportResult = await this.exportService.exportBookings(
|
const exportResult = await this.exportService.exportBookings(
|
||||||
bookingsWithQuotes,
|
bookingsWithQuotes,
|
||||||
exportDto.format,
|
exportDto.format,
|
||||||
exportDto.fields,
|
exportDto.fields
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set response headers
|
// Set response headers
|
||||||
@ -607,7 +592,7 @@ export class BookingsController {
|
|||||||
fields: exportDto.fields?.join(', ') || 'all',
|
fields: exportDto.fields?.join(', ') || 'all',
|
||||||
filename: exportResult.filename,
|
filename: exportResult.filename,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return new StreamableFile(exportResult.buffer);
|
return new StreamableFile(exportResult.buffer);
|
||||||
@ -621,41 +606,35 @@ export class BookingsController {
|
|||||||
|
|
||||||
// Filter by status
|
// Filter by status
|
||||||
if (filter.status && filter.status.length > 0) {
|
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)
|
// Filter by search (booking number partial match)
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
const searchLower = filter.search.toLowerCase();
|
const searchLower = filter.search.toLowerCase();
|
||||||
filtered = filtered.filter((b) =>
|
filtered = filtered.filter(b => b.bookingNumber.value.toLowerCase().includes(searchLower));
|
||||||
b.bookingNumber.value.toLowerCase().includes(searchLower),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by shipper
|
// Filter by shipper
|
||||||
if (filter.shipper) {
|
if (filter.shipper) {
|
||||||
const shipperLower = filter.shipper.toLowerCase();
|
const shipperLower = filter.shipper.toLowerCase();
|
||||||
filtered = filtered.filter((b) =>
|
filtered = filtered.filter(b => b.shipper.name.toLowerCase().includes(shipperLower));
|
||||||
b.shipper.name.toLowerCase().includes(shipperLower),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by consignee
|
// Filter by consignee
|
||||||
if (filter.consignee) {
|
if (filter.consignee) {
|
||||||
const consigneeLower = filter.consignee.toLowerCase();
|
const consigneeLower = filter.consignee.toLowerCase();
|
||||||
filtered = filtered.filter((b) =>
|
filtered = filtered.filter(b => b.consignee.name.toLowerCase().includes(consigneeLower));
|
||||||
b.consignee.name.toLowerCase().includes(consigneeLower),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by creation date range
|
// Filter by creation date range
|
||||||
if (filter.createdFrom) {
|
if (filter.createdFrom) {
|
||||||
const fromDate = new Date(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) {
|
if (filter.createdTo) {
|
||||||
const toDate = new Date(filter.createdTo);
|
const toDate = new Date(filter.createdTo);
|
||||||
filtered = filtered.filter((b) => b.createdAt <= toDate);
|
filtered = filtered.filter(b => b.createdAt <= toDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
|
|||||||
@ -41,17 +41,14 @@ export class GDPRController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'Data export successful',
|
description: 'Data export successful',
|
||||||
})
|
})
|
||||||
async exportData(
|
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const exportData = await this.gdprService.exportUserData(user.id);
|
const exportData = await this.gdprService.exportUserData(user.id);
|
||||||
|
|
||||||
// Set headers for file download
|
// Set headers for file download
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`,
|
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(exportData);
|
res.json(exportData);
|
||||||
@ -69,10 +66,7 @@ export class GDPRController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'CSV export successful',
|
description: 'CSV export successful',
|
||||||
})
|
})
|
||||||
async exportDataCSV(
|
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const exportData = await this.gdprService.exportUserData(user.id);
|
const exportData = await this.gdprService.exportUserData(user.id);
|
||||||
|
|
||||||
// Convert to CSV (simplified version)
|
// Convert to CSV (simplified version)
|
||||||
@ -87,7 +81,7 @@ export class GDPRController {
|
|||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`,
|
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
|
||||||
);
|
);
|
||||||
|
|
||||||
res.send(csv);
|
res.send(csv);
|
||||||
@ -108,7 +102,7 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
async deleteAccount(
|
async deleteAccount(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: { reason?: string; confirmEmail: string },
|
@Body() body: { reason?: string; confirmEmail: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Verify email confirmation (security measure)
|
// Verify email confirmation (security measure)
|
||||||
if (body.confirmEmail !== user.email) {
|
if (body.confirmEmail !== user.email) {
|
||||||
@ -133,7 +127,7 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
async recordConsent(
|
async recordConsent(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: Omit<ConsentData, 'userId'>,
|
@Body() body: Omit<ConsentData, 'userId'>
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
await this.gdprService.recordConsent({
|
await this.gdprService.recordConsent({
|
||||||
...body,
|
...body,
|
||||||
@ -158,7 +152,7 @@ export class GDPRController {
|
|||||||
})
|
})
|
||||||
async withdrawConsent(
|
async withdrawConsent(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: { consentType: 'marketing' | 'analytics' },
|
@Body() body: { consentType: 'marketing' | 'analytics' }
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||||
|
|
||||||
@ -177,9 +171,7 @@ export class GDPRController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: 'Consent status retrieved',
|
description: 'Consent status retrieved',
|
||||||
})
|
})
|
||||||
async getConsentStatus(
|
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<any> {
|
|
||||||
return this.gdprService.getConsentStatus(user.id);
|
return this.gdprService.getConsentStatus(user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,13 +17,7 @@ import {
|
|||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiQuery,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { NotificationService } from '../services/notification.service';
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
@ -62,7 +56,7 @@ export class NotificationsController {
|
|||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Query('read') read?: string,
|
@Query('read') read?: string,
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number
|
||||||
): Promise<{
|
): Promise<{
|
||||||
notifications: NotificationResponseDto[];
|
notifications: NotificationResponseDto[];
|
||||||
total: number;
|
total: number;
|
||||||
@ -82,7 +76,7 @@ export class NotificationsController {
|
|||||||
const { notifications, total } = await this.notificationService.getNotifications(filters);
|
const { notifications, total } = await this.notificationService.getNotifications(filters);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications: notifications.map((n) => this.mapToDto(n)),
|
notifications: notifications.map(n => this.mapToDto(n)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
@ -95,14 +89,18 @@ export class NotificationsController {
|
|||||||
@Get('unread')
|
@Get('unread')
|
||||||
@ApiOperation({ summary: 'Get unread notifications' })
|
@ApiOperation({ summary: 'Get unread notifications' })
|
||||||
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' })
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
required: false,
|
||||||
|
description: 'Number of notifications (default: 50)',
|
||||||
|
})
|
||||||
async getUnreadNotifications(
|
async getUnreadNotifications(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number
|
||||||
): Promise<NotificationResponseDto[]> {
|
): Promise<NotificationResponseDto[]> {
|
||||||
limit = limit || 50;
|
limit = limit || 50;
|
||||||
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
|
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
|
||||||
return notifications.map((n) => this.mapToDto(n));
|
return notifications.map(n => this.mapToDto(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,7 +123,7 @@ export class NotificationsController {
|
|||||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
async getNotificationById(
|
async getNotificationById(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string
|
||||||
): Promise<NotificationResponseDto> {
|
): Promise<NotificationResponseDto> {
|
||||||
const notification = await this.notificationService.getNotificationById(id);
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
@ -145,7 +143,7 @@ export class NotificationsController {
|
|||||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
async markAsRead(
|
async markAsRead(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const notification = await this.notificationService.getNotificationById(id);
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
@ -177,7 +175,7 @@ export class NotificationsController {
|
|||||||
@ApiResponse({ status: 404, description: 'Notification not found' })
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
async deleteNotification(
|
async deleteNotification(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const notification = await this.notificationService.getNotificationById(id);
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,10 @@ import {
|
|||||||
OrganizationListResponseDto,
|
OrganizationListResponseDto,
|
||||||
} from '../dto/organization.dto';
|
} from '../dto/organization.dto';
|
||||||
import { OrganizationMapper } from '../mappers/organization.mapper';
|
import { OrganizationMapper } from '../mappers/organization.mapper';
|
||||||
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '../../domain/ports/out/organization.repository';
|
import {
|
||||||
|
OrganizationRepository,
|
||||||
|
ORGANIZATION_REPOSITORY,
|
||||||
|
} from '../../domain/ports/out/organization.repository';
|
||||||
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
|
import { Organization, OrganizationType } from '../../domain/entities/organization.entity';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
@ -61,7 +64,7 @@ export class OrganizationsController {
|
|||||||
private readonly logger = new Logger(OrganizationsController.name);
|
private readonly logger = new Logger(OrganizationsController.name);
|
||||||
|
|
||||||
constructor(
|
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 }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create new organization',
|
summary: 'Create new organization',
|
||||||
description:
|
description: 'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
|
||||||
'Create a new organization (freight forwarder, carrier, or shipper). Admin-only.',
|
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.CREATED,
|
status: HttpStatus.CREATED,
|
||||||
@ -96,28 +98,22 @@ export class OrganizationsController {
|
|||||||
})
|
})
|
||||||
async createOrganization(
|
async createOrganization(
|
||||||
@Body() dto: CreateOrganizationDto,
|
@Body() dto: CreateOrganizationDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<OrganizationResponseDto> {
|
): Promise<OrganizationResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`);
|
||||||
`[Admin: ${user.email}] Creating organization: ${dto.name} (${dto.type})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for duplicate name
|
// Check for duplicate name
|
||||||
const existingByName = await this.organizationRepository.findByName(dto.name);
|
const existingByName = await this.organizationRepository.findByName(dto.name);
|
||||||
if (existingByName) {
|
if (existingByName) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(`Organization with name "${dto.name}" already exists`);
|
||||||
`Organization with name "${dto.name}" already exists`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate SCAC if provided
|
// Check for duplicate SCAC if provided
|
||||||
if (dto.scac) {
|
if (dto.scac) {
|
||||||
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
|
const existingBySCAC = await this.organizationRepository.findBySCAC(dto.scac);
|
||||||
if (existingBySCAC) {
|
if (existingBySCAC) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(`Organization with SCAC "${dto.scac}" already exists`);
|
||||||
`Organization with SCAC "${dto.scac}" already exists`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,15 +132,13 @@ export class OrganizationsController {
|
|||||||
// Save to database
|
// Save to database
|
||||||
const savedOrg = await this.organizationRepository.save(organization);
|
const savedOrg = await this.organizationRepository.save(organization);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`);
|
||||||
`Organization created successfully: ${savedOrg.name} (${savedOrg.id})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return OrganizationMapper.toDto(savedOrg);
|
return OrganizationMapper.toDto(savedOrg);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Organization creation failed: ${error?.message || 'Unknown error'}`,
|
`Organization creation failed: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -181,7 +175,7 @@ export class OrganizationsController {
|
|||||||
})
|
})
|
||||||
async getOrganization(
|
async getOrganization(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<OrganizationResponseDto> {
|
): Promise<OrganizationResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
|
this.logger.log(`[User: ${user.email}] Fetching organization: ${id}`);
|
||||||
|
|
||||||
@ -235,11 +229,9 @@ export class OrganizationsController {
|
|||||||
async updateOrganization(
|
async updateOrganization(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateOrganizationDto,
|
@Body() dto: UpdateOrganizationDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<OrganizationResponseDto> {
|
): Promise<OrganizationResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);
|
||||||
`[User: ${user.email}] Updating organization: ${id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
const organization = await this.organizationRepository.findById(id);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
@ -323,10 +315,10 @@ export class OrganizationsController {
|
|||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||||
@Query('type') type: OrganizationType | undefined,
|
@Query('type') type: OrganizationType | undefined,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<OrganizationListResponseDto> {
|
): Promise<OrganizationListResponseDto> {
|
||||||
this.logger.log(
|
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
|
// Fetch organizations
|
||||||
@ -342,9 +334,7 @@ export class OrganizationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter by type if provided
|
// Filter by type if provided
|
||||||
const filteredOrgs = type
|
const filteredOrgs = type ? organizations.filter(org => org.type === type) : organizations;
|
||||||
? organizations.filter(org => org.type === type)
|
|
||||||
: organizations;
|
|
||||||
|
|
||||||
// Paginate
|
// Paginate
|
||||||
const startIndex = (page - 1) * pageSize;
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export class RatesController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly rateSearchService: RateSearchService,
|
private readonly rateSearchService: RateSearchService,
|
||||||
private readonly csvRateSearchService: CsvRateSearchService,
|
private readonly csvRateSearchService: CsvRateSearchService,
|
||||||
private readonly csvRateMapper: CsvRateMapper,
|
private readonly csvRateMapper: CsvRateMapper
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
@ -73,11 +73,11 @@ export class RatesController {
|
|||||||
})
|
})
|
||||||
async searchRates(
|
async searchRates(
|
||||||
@Body() dto: RateSearchRequestDto,
|
@Body() dto: RateSearchRequestDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<RateSearchResponseDto> {
|
): Promise<RateSearchResponseDto> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`,
|
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -102,9 +102,7 @@ export class RatesController {
|
|||||||
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
|
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
|
||||||
|
|
||||||
const responseTimeMs = Date.now() - startTime;
|
const responseTimeMs = Date.now() - startTime;
|
||||||
this.logger.log(
|
this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`);
|
||||||
`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quotes: quoteDtos,
|
quotes: quoteDtos,
|
||||||
@ -118,10 +116,7 @@ export class RatesController {
|
|||||||
responseTimeMs,
|
responseTimeMs,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
||||||
`Rate search failed: ${error?.message || 'Unknown error'}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,11 +147,11 @@ export class RatesController {
|
|||||||
})
|
})
|
||||||
async searchCsvRates(
|
async searchCsvRates(
|
||||||
@Body() dto: CsvRateSearchDto,
|
@Body() dto: CsvRateSearchDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<CsvRateSearchResponseDto> {
|
): Promise<CsvRateSearchResponseDto> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
this.logger.log(
|
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 {
|
try {
|
||||||
@ -179,14 +174,14 @@ export class RatesController {
|
|||||||
|
|
||||||
const responseTimeMs = Date.now() - startTime;
|
const responseTimeMs = Date.now() - startTime;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`,
|
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`CSV rate search failed: ${error?.message || 'Unknown error'}`,
|
`CSV rate search failed: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -220,7 +215,7 @@ export class RatesController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to fetch companies: ${error?.message || 'Unknown error'}`,
|
`Failed to fetch companies: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -259,7 +254,7 @@ export class RatesController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
|
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,13 +16,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
WebhookService,
|
||||||
ApiOperation,
|
CreateWebhookInput,
|
||||||
ApiResponse,
|
UpdateWebhookInput,
|
||||||
ApiBearerAuth,
|
} from '../services/webhook.service';
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service';
|
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
@ -74,7 +73,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 201, description: 'Webhook created successfully' })
|
@ApiResponse({ status: 201, description: 'Webhook created successfully' })
|
||||||
async createWebhook(
|
async createWebhook(
|
||||||
@Body() dto: CreateWebhookDto,
|
@Body() dto: CreateWebhookDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<WebhookResponseDto> {
|
): Promise<WebhookResponseDto> {
|
||||||
const input: CreateWebhookInput = {
|
const input: CreateWebhookInput = {
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
@ -96,10 +95,8 @@ export class WebhooksController {
|
|||||||
@ApiOperation({ summary: 'Get all webhooks for organization' })
|
@ApiOperation({ summary: 'Get all webhooks for organization' })
|
||||||
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
|
||||||
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
|
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
|
||||||
const webhooks = await this.webhookService.getWebhooksByOrganization(
|
const webhooks = await this.webhookService.getWebhooksByOrganization(user.organizationId);
|
||||||
user.organizationId,
|
return webhooks.map(w => this.mapToDto(w));
|
||||||
);
|
|
||||||
return webhooks.map((w) => this.mapToDto(w));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,7 +109,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async getWebhookById(
|
async getWebhookById(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<WebhookResponseDto> {
|
): Promise<WebhookResponseDto> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -139,7 +136,7 @@ export class WebhooksController {
|
|||||||
async updateWebhook(
|
async updateWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateWebhookDto,
|
@Body() dto: UpdateWebhookDto,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<WebhookResponseDto> {
|
): Promise<WebhookResponseDto> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -166,7 +163,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async activateWebhook(
|
async activateWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -193,7 +190,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async deactivateWebhook(
|
async deactivateWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
@ -220,7 +217,7 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
async deleteWebhook(
|
async deleteWebhook(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
const webhook = await this.webhookService.getWebhookById(id);
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
|||||||
@ -38,5 +38,5 @@ export const CurrentUser = createParamDecorator(
|
|||||||
|
|
||||||
// If a specific property is requested, return only that property
|
// If a specific property is requested, return only that property
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,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 { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -45,7 +54,9 @@ export class PartyDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: '+31612345678' })
|
@ApiProperty({ example: '+31612345678' })
|
||||||
@IsString()
|
@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;
|
contactPhone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,14 +68,19 @@ export class ContainerDto {
|
|||||||
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
|
@ApiPropertyOptional({ example: 'ABCU1234567', description: 'Container number (11 characters)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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;
|
containerNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
|
@ApiPropertyOptional({ example: 22000, description: 'Verified Gross Mass in kg' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
vgm?: number;
|
vgm?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: -18, description: 'Temperature in Celsius (for reefer containers)' })
|
@ApiPropertyOptional({
|
||||||
|
example: -18,
|
||||||
|
description: 'Temperature in Celsius (for reefer containers)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|
||||||
@ -77,7 +93,7 @@ export class ContainerDto {
|
|||||||
export class CreateBookingRequestDto {
|
export class CreateBookingRequestDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
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' })
|
@IsUUID(4, { message: 'Rate quote ID must be a valid UUID' })
|
||||||
rateQuoteId: string;
|
rateQuoteId: string;
|
||||||
@ -94,7 +110,7 @@ export class CreateBookingRequestDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Electronics and consumer goods',
|
example: 'Electronics and consumer goods',
|
||||||
description: 'Cargo description'
|
description: 'Cargo description',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
|
@MinLength(10, { message: 'Cargo description must be at least 10 characters' })
|
||||||
@ -102,7 +118,7 @@ export class CreateBookingRequestDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [ContainerDto],
|
type: [ContainerDto],
|
||||||
description: 'Container details (can be empty for initial booking)'
|
description: 'Container details (can be empty for initial booking)',
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@ -111,7 +127,7 @@ export class CreateBookingRequestDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'Please handle with care. Delivery before 5 PM.',
|
example: 'Please handle with care. Delivery before 5 PM.',
|
||||||
description: 'Special instructions for the carrier'
|
description: 'Special instructions for the carrier',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -1,12 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
|
||||||
IsNotEmpty,
|
|
||||||
IsString,
|
|
||||||
IsNumber,
|
|
||||||
Min,
|
|
||||||
IsOptional,
|
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
||||||
|
|
||||||
@ -152,7 +145,7 @@ export class CsvRateResultDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Calculated price in USD',
|
description: 'Calculated price in USD',
|
||||||
example: 1850.50,
|
example: 1850.5,
|
||||||
})
|
})
|
||||||
priceUSD: number;
|
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';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class RateSearchRequestDto {
|
export class RateSearchRequestDto {
|
||||||
@ -17,7 +26,9 @@ export class RateSearchRequestDto {
|
|||||||
pattern: '^[A-Z]{5}$',
|
pattern: '^[A-Z]{5}$',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[A-Z]{5}$/, { message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)' })
|
@Matches(/^[A-Z]{5}$/, {
|
||||||
|
message: 'Destination must be a valid 5-character UN/LOCODE (e.g., CNSHA)',
|
||||||
|
})
|
||||||
destination: string;
|
destination: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@ -92,6 +103,8 @@ export class RateSearchRequestDto {
|
|||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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;
|
imoClass?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,8 @@ export class CreateUserDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'TempPassword123!',
|
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,
|
minLength: 12,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,12 +81,12 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
// Send recent notifications on connection
|
// Send recent notifications on connection
|
||||||
const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10);
|
const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10);
|
||||||
client.emit('recent_notifications', {
|
client.emit('recent_notifications', {
|
||||||
notifications: recentNotifications.map((n) => this.mapNotificationToDto(n)),
|
notifications: recentNotifications.map(n => this.mapNotificationToDto(n)),
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error during client connection: ${error?.message || 'Unknown error'}`,
|
`Error during client connection: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
@ -112,7 +112,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
@SubscribeMessage('mark_as_read')
|
@SubscribeMessage('mark_as_read')
|
||||||
async handleMarkAsRead(
|
async handleMarkAsRead(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: { notificationId: string },
|
@MessageBody() data: { notificationId: string }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = client.data.userId;
|
const userId = client.data.userId;
|
||||||
|
|||||||
@ -23,11 +23,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
/**
|
/**
|
||||||
* Custom error message (override for new API)
|
* Custom error message (override for new API)
|
||||||
*/
|
*/
|
||||||
protected async throwThrottlingException(
|
protected async throwThrottlingException(context: ExecutionContext): Promise<void> {
|
||||||
context: ExecutionContext,
|
throw new ThrottlerException('Too many requests. Please try again later.');
|
||||||
): Promise<void> {
|
|
||||||
throw new ThrottlerException(
|
|
||||||
'Too many requests. Please try again later.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,7 @@
|
|||||||
* Tracks request duration and logs metrics
|
* Tracks request duration and logs metrics
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NestInterceptor,
|
|
||||||
ExecutionContext,
|
|
||||||
CallHandler,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { tap, catchError } from 'rxjs/operators';
|
import { tap, catchError } from 'rxjs/operators';
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
@ -25,33 +19,31 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
tap((data) => {
|
tap(data => {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const response = context.switchToHttp().getResponse();
|
const response = context.switchToHttp().getResponse();
|
||||||
|
|
||||||
// Log performance
|
// Log performance
|
||||||
if (duration > 1000) {
|
if (duration > 1000) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`,
|
`Slow request: ${method} ${url} took ${duration}ms (userId: ${user?.sub || 'anonymous'})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful request
|
// Log successful request
|
||||||
this.logger.log(
|
this.logger.log(`${method} ${url} - ${response.statusCode} - ${duration}ms`);
|
||||||
`${method} ${url} - ${response.statusCode} - ${duration}ms`,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
catchError((error) => {
|
catchError(error => {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
// Log error
|
// Log error
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
|
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
|
||||||
error.stack,
|
error.stack
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture exception in Sentry
|
// Capture exception in Sentry
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope(scope => {
|
||||||
scope.setContext('request', {
|
scope.setContext('request', {
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
@ -62,7 +54,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export class BookingMapper {
|
|||||||
contactPhone: dto.consignee.contactPhone,
|
contactPhone: dto.consignee.contactPhone,
|
||||||
},
|
},
|
||||||
cargoDescription: dto.cargoDescription,
|
cargoDescription: dto.cargoDescription,
|
||||||
containers: dto.containers.map((c) => ({
|
containers: dto.containers.map(c => ({
|
||||||
type: c.type,
|
type: c.type,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
vgm: c.vgm,
|
vgm: c.vgm,
|
||||||
@ -91,7 +91,7 @@ export class BookingMapper {
|
|||||||
contactPhone: booking.consignee.contactPhone,
|
contactPhone: booking.consignee.contactPhone,
|
||||||
},
|
},
|
||||||
cargoDescription: booking.cargoDescription,
|
cargoDescription: booking.cargoDescription,
|
||||||
containers: booking.containers.map((c) => ({
|
containers: booking.containers.map(c => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
@ -116,7 +116,7 @@ export class BookingMapper {
|
|||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
baseFreight: rateQuote.pricing.baseFreight,
|
baseFreight: rateQuote.pricing.baseFreight,
|
||||||
surcharges: rateQuote.pricing.surcharges.map((s) => ({
|
surcharges: rateQuote.pricing.surcharges.map(s => ({
|
||||||
type: s.type,
|
type: s.type,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
amount: s.amount,
|
amount: s.amount,
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CsvRate } from '@domain/entities/csv-rate.entity';
|
import { CsvRate } from '@domain/entities/csv-rate.entity';
|
||||||
import { Volume } from '@domain/value-objects/volume.vo';
|
import { Volume } from '@domain/value-objects/volume.vo';
|
||||||
import {
|
import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||||
CsvRateResultDto,
|
|
||||||
CsvRateSearchResponseDto,
|
|
||||||
} from '../dto/csv-rate-search.dto';
|
|
||||||
import {
|
import {
|
||||||
CsvRateSearchInput,
|
CsvRateSearchInput,
|
||||||
CsvRateSearchOutput,
|
CsvRateSearchOutput,
|
||||||
@ -77,7 +74,7 @@ export class CsvRateMapper {
|
|||||||
*/
|
*/
|
||||||
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
||||||
return {
|
return {
|
||||||
results: output.results.map((result) => this.mapSearchResultToDto(result)),
|
results: output.results.map(result => this.mapSearchResultToDto(result)),
|
||||||
totalResults: output.totalResults,
|
totalResults: output.totalResults,
|
||||||
searchedFiles: output.searchedFiles,
|
searchedFiles: output.searchedFiles,
|
||||||
searchedAt: output.searchedAt,
|
searchedAt: output.searchedAt,
|
||||||
@ -107,6 +104,6 @@ export class CsvRateMapper {
|
|||||||
* Map multiple config entities to DTOs
|
* Map multiple config entities to DTOs
|
||||||
*/
|
*/
|
||||||
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
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
|
* Map Document entity to DTO
|
||||||
*/
|
*/
|
||||||
private static mapDocumentToDto(
|
private static mapDocumentToDto(document: OrganizationDocument): OrganizationDocumentDto {
|
||||||
document: OrganizationDocument,
|
|
||||||
): OrganizationDocumentDto {
|
|
||||||
return {
|
return {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
type: document.type,
|
type: document.type,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export class RateQuoteMapper {
|
|||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
baseFreight: entity.pricing.baseFreight,
|
baseFreight: entity.pricing.baseFreight,
|
||||||
surcharges: entity.pricing.surcharges.map((s) => ({
|
surcharges: entity.pricing.surcharges.map(s => ({
|
||||||
type: s.type,
|
type: s.type,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
amount: s.amount,
|
amount: s.amount,
|
||||||
@ -43,7 +43,7 @@ export class RateQuoteMapper {
|
|||||||
etd: entity.etd.toISOString(),
|
etd: entity.etd.toISOString(),
|
||||||
eta: entity.eta.toISOString(),
|
eta: entity.eta.toISOString(),
|
||||||
transitDays: entity.transitDays,
|
transitDays: entity.transitDays,
|
||||||
route: entity.route.map((segment) => ({
|
route: entity.route.map(segment => ({
|
||||||
portCode: segment.portCode,
|
portCode: segment.portCode,
|
||||||
portName: segment.portName,
|
portName: segment.portName,
|
||||||
arrival: segment.arrival?.toISOString(),
|
arrival: segment.arrival?.toISOString(),
|
||||||
@ -64,6 +64,6 @@ export class RateQuoteMapper {
|
|||||||
* Map array of RateQuote entities to DTOs
|
* Map array of RateQuote entities to DTOs
|
||||||
*/
|
*/
|
||||||
static toDtoArray(entities: RateQuote[]): RateQuoteDto[] {
|
static toDtoArray(entities: RateQuote[]): RateQuoteDto[] {
|
||||||
return entities.map((entity) => this.toDto(entity));
|
return entities.map(entity => this.toDto(entity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,33 +45,14 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: RateSearchService,
|
provide: RateSearchService,
|
||||||
useFactory: (
|
useFactory: (cache: any, rateQuoteRepo: any, portRepo: any, carrierRepo: any) => {
|
||||||
cache: any,
|
|
||||||
rateQuoteRepo: any,
|
|
||||||
portRepo: any,
|
|
||||||
carrierRepo: any,
|
|
||||||
) => {
|
|
||||||
// For now, create service with empty connectors array
|
// For now, create service with empty connectors array
|
||||||
// TODO: Inject actual carrier connectors
|
// TODO: Inject actual carrier connectors
|
||||||
return new RateSearchService(
|
return new RateSearchService([], cache, rateQuoteRepo, portRepo, carrierRepo);
|
||||||
[],
|
|
||||||
cache,
|
|
||||||
rateQuoteRepo,
|
|
||||||
portRepo,
|
|
||||||
carrierRepo,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
inject: [
|
inject: [CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
|
||||||
CACHE_PORT,
|
|
||||||
RATE_QUOTE_REPOSITORY,
|
|
||||||
PORT_REPOSITORY,
|
|
||||||
CARRIER_REPOSITORY,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
||||||
RATE_QUOTE_REPOSITORY,
|
|
||||||
RateSearchService,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class RatesModule {}
|
export class RatesModule {}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export class AnalyticsService {
|
|||||||
@Inject(BOOKING_REPOSITORY)
|
@Inject(BOOKING_REPOSITORY)
|
||||||
private readonly bookingRepository: BookingRepository,
|
private readonly bookingRepository: BookingRepository,
|
||||||
@Inject(RATE_QUOTE_REPOSITORY)
|
@Inject(RATE_QUOTE_REPOSITORY)
|
||||||
private readonly rateQuoteRepository: RateQuoteRepository,
|
private readonly rateQuoteRepository: RateQuoteRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,13 +70,11 @@ export class AnalyticsService {
|
|||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||||
|
|
||||||
// This month bookings
|
// This month bookings
|
||||||
const thisMonthBookings = allBookings.filter(
|
const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart);
|
||||||
(b) => b.createdAt >= thisMonthStart
|
|
||||||
);
|
|
||||||
|
|
||||||
// Last month bookings
|
// Last month bookings
|
||||||
const lastMonthBookings = allBookings.filter(
|
const lastMonthBookings = allBookings.filter(
|
||||||
(b) => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
|
b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
|
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
|
||||||
@ -118,10 +116,10 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
// Pending confirmations (status = pending_confirmation)
|
// Pending confirmations (status = pending_confirmation)
|
||||||
const pendingThisMonth = thisMonthBookings.filter(
|
const pendingThisMonth = thisMonthBookings.filter(
|
||||||
(b) => b.status.value === 'pending_confirmation'
|
b => b.status.value === 'pending_confirmation'
|
||||||
).length;
|
).length;
|
||||||
const pendingLastMonth = lastMonthBookings.filter(
|
const pendingLastMonth = lastMonthBookings.filter(
|
||||||
(b) => b.status.value === 'pending_confirmation'
|
b => b.status.value === 'pending_confirmation'
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Calculate percentage changes
|
// Calculate percentage changes
|
||||||
@ -135,15 +133,9 @@ export class AnalyticsService {
|
|||||||
totalTEUs: totalTEUsThisMonth,
|
totalTEUs: totalTEUsThisMonth,
|
||||||
estimatedRevenue: estimatedRevenueThisMonth,
|
estimatedRevenue: estimatedRevenueThisMonth,
|
||||||
pendingConfirmations: pendingThisMonth,
|
pendingConfirmations: pendingThisMonth,
|
||||||
bookingsThisMonthChange: calculateChange(
|
bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length),
|
||||||
thisMonthBookings.length,
|
|
||||||
lastMonthBookings.length
|
|
||||||
),
|
|
||||||
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
|
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
|
||||||
estimatedRevenueChange: calculateChange(
|
estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth),
|
||||||
estimatedRevenueThisMonth,
|
|
||||||
estimatedRevenueLastMonth
|
|
||||||
),
|
|
||||||
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
|
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -172,7 +164,7 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
// Count bookings in this month
|
// Count bookings in this month
|
||||||
const count = allBookings.filter(
|
const count = allBookings.filter(
|
||||||
(b) => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
b => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
||||||
).length;
|
).length;
|
||||||
data.push(count);
|
data.push(count);
|
||||||
}
|
}
|
||||||
@ -187,13 +179,16 @@ export class AnalyticsService {
|
|||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
||||||
|
|
||||||
// Group by route (origin-destination)
|
// Group by route (origin-destination)
|
||||||
const routeMap = new Map<string, {
|
const routeMap = new Map<
|
||||||
originPort: string;
|
string,
|
||||||
destinationPort: string;
|
{
|
||||||
bookingCount: number;
|
originPort: string;
|
||||||
totalTEUs: number;
|
destinationPort: string;
|
||||||
totalPrice: number;
|
bookingCount: number;
|
||||||
}>();
|
totalTEUs: number;
|
||||||
|
totalPrice: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
for (const booking of allBookings) {
|
for (const booking of allBookings) {
|
||||||
try {
|
try {
|
||||||
@ -231,16 +226,14 @@ export class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert to array and sort by booking count
|
// Convert to array and sort by booking count
|
||||||
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(
|
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
|
||||||
([route, data]) => ({
|
route,
|
||||||
route,
|
originPort: data.originPort,
|
||||||
originPort: data.originPort,
|
destinationPort: data.destinationPort,
|
||||||
destinationPort: data.destinationPort,
|
bookingCount: data.bookingCount,
|
||||||
bookingCount: data.bookingCount,
|
totalTEUs: data.totalTEUs,
|
||||||
totalTEUs: data.totalTEUs,
|
avgPrice: data.totalPrice / data.bookingCount,
|
||||||
avgPrice: data.totalPrice / data.bookingCount,
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by booking count and return top 5
|
// Sort by booking count and return top 5
|
||||||
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
|
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
|
||||||
@ -256,7 +249,7 @@ export class AnalyticsService {
|
|||||||
// Check for pending confirmations (older than 24h)
|
// Check for pending confirmations (older than 24h)
|
||||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
const oldPendingBookings = allBookings.filter(
|
const oldPendingBookings = allBookings.filter(
|
||||||
(b) => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const booking of oldPendingBookings) {
|
for (const booking of oldPendingBookings) {
|
||||||
|
|||||||
@ -4,7 +4,10 @@
|
|||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
import { AUDIT_LOG_REPOSITORY, AuditLogRepository } from '../../domain/ports/out/audit-log.repository';
|
import {
|
||||||
|
AUDIT_LOG_REPOSITORY,
|
||||||
|
AuditLogRepository,
|
||||||
|
} from '../../domain/ports/out/audit-log.repository';
|
||||||
import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity';
|
import { AuditAction, AuditStatus, AuditLog } from '../../domain/entities/audit-log.entity';
|
||||||
|
|
||||||
describe('AuditService', () => {
|
describe('AuditService', () => {
|
||||||
|
|||||||
@ -7,11 +7,7 @@
|
|||||||
|
|
||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
|
||||||
AuditLog,
|
|
||||||
AuditAction,
|
|
||||||
AuditStatus,
|
|
||||||
} from '../../domain/entities/audit-log.entity';
|
|
||||||
import {
|
import {
|
||||||
AuditLogRepository,
|
AuditLogRepository,
|
||||||
AUDIT_LOG_REPOSITORY,
|
AUDIT_LOG_REPOSITORY,
|
||||||
@ -39,7 +35,7 @@ export class AuditService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AUDIT_LOG_REPOSITORY)
|
@Inject(AUDIT_LOG_REPOSITORY)
|
||||||
private readonly auditLogRepository: AuditLogRepository,
|
private readonly auditLogRepository: AuditLogRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,14 +50,12 @@ export class AuditService {
|
|||||||
|
|
||||||
await this.auditLogRepository.save(auditLog);
|
await this.auditLogRepository.save(auditLog);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`);
|
||||||
`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`,
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Never throw on audit logging failure - log the error and continue
|
// Never throw on audit logging failure - log the error and continue
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to create audit log: ${error?.message || 'Unknown error'}`,
|
`Failed to create audit log: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,7 +75,7 @@ export class AuditService {
|
|||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
},
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.log({
|
await this.log({
|
||||||
action,
|
action,
|
||||||
@ -108,7 +102,7 @@ export class AuditService {
|
|||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
},
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.log({
|
await this.log({
|
||||||
action,
|
action,
|
||||||
@ -139,20 +133,14 @@ export class AuditService {
|
|||||||
/**
|
/**
|
||||||
* Get audit trail for a specific resource
|
* Get audit trail for a specific resource
|
||||||
*/
|
*/
|
||||||
async getResourceAuditTrail(
|
async getResourceAuditTrail(resourceType: string, resourceId: string): Promise<AuditLog[]> {
|
||||||
resourceType: string,
|
|
||||||
resourceId: string,
|
|
||||||
): Promise<AuditLog[]> {
|
|
||||||
return this.auditLogRepository.findByResource(resourceType, resourceId);
|
return this.auditLogRepository.findByResource(resourceType, resourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recent activity for an organization
|
* Get recent activity for an organization
|
||||||
*/
|
*/
|
||||||
async getOrganizationActivity(
|
async getOrganizationActivity(organizationId: string, limit: number = 50): Promise<AuditLog[]> {
|
||||||
organizationId: string,
|
|
||||||
limit: number = 50,
|
|
||||||
): Promise<AuditLog[]> {
|
|
||||||
return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
|
return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,12 +8,12 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
|
|||||||
import { Booking } from '../../domain/entities/booking.entity';
|
import { Booking } from '../../domain/entities/booking.entity';
|
||||||
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '../../domain/ports/out/email.port';
|
||||||
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
|
import { PdfPort, PDF_PORT, BookingPdfData } from '../../domain/ports/out/pdf.port';
|
||||||
import {
|
import { StoragePort, STORAGE_PORT } from '../../domain/ports/out/storage.port';
|
||||||
StoragePort,
|
|
||||||
STORAGE_PORT,
|
|
||||||
} from '../../domain/ports/out/storage.port';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '../../domain/ports/out/user.repository';
|
||||||
import { RateQuoteRepository, RATE_QUOTE_REPOSITORY } from '../../domain/ports/out/rate-quote.repository';
|
import {
|
||||||
|
RateQuoteRepository,
|
||||||
|
RATE_QUOTE_REPOSITORY,
|
||||||
|
} from '../../domain/ports/out/rate-quote.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingAutomationService {
|
export class BookingAutomationService {
|
||||||
@ -24,16 +24,14 @@ export class BookingAutomationService {
|
|||||||
@Inject(PDF_PORT) private readonly pdfPort: PdfPort,
|
@Inject(PDF_PORT) private readonly pdfPort: PdfPort,
|
||||||
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
|
@Inject(STORAGE_PORT) private readonly storagePort: StoragePort,
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository,
|
@Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute all post-booking automation tasks
|
* Execute all post-booking automation tasks
|
||||||
*/
|
*/
|
||||||
async executePostBookingTasks(booking: Booking): Promise<void> {
|
async executePostBookingTasks(booking: Booking): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(`Starting post-booking automation for booking: ${booking.bookingNumber.value}`);
|
||||||
`Starting post-booking automation for booking: ${booking.bookingNumber.value}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get user and rate quote details
|
// Get user and rate quote details
|
||||||
@ -42,9 +40,7 @@ export class BookingAutomationService {
|
|||||||
throw new Error(`User not found: ${booking.userId}`);
|
throw new Error(`User not found: ${booking.userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
booking.rateQuoteId
|
|
||||||
);
|
|
||||||
if (!rateQuote) {
|
if (!rateQuote) {
|
||||||
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
|
throw new Error(`Rate quote not found: ${booking.rateQuoteId}`);
|
||||||
}
|
}
|
||||||
@ -79,7 +75,7 @@ export class BookingAutomationService {
|
|||||||
email: booking.consignee.contactEmail,
|
email: booking.consignee.contactEmail,
|
||||||
phone: booking.consignee.contactPhone,
|
phone: booking.consignee.contactPhone,
|
||||||
},
|
},
|
||||||
containers: booking.containers.map((c) => ({
|
containers: booking.containers.map(c => ({
|
||||||
type: c.type,
|
type: c.type,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
containerNumber: c.containerNumber,
|
containerNumber: c.containerNumber,
|
||||||
@ -173,10 +169,7 @@ export class BookingAutomationService {
|
|||||||
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
|
`Sent ${updateType} notification for booking: ${booking.bookingNumber.value}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(`Failed to send booking update notification`, error);
|
||||||
`Failed to send booking update notification`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,13 +38,11 @@ export class BruteForceProtectionService {
|
|||||||
|
|
||||||
// Calculate block time with exponential backoff
|
// Calculate block time with exponential backoff
|
||||||
if (existing.count > bruteForceConfig.freeRetries) {
|
if (existing.count > bruteForceConfig.freeRetries) {
|
||||||
const waitTime = this.calculateWaitTime(
|
const waitTime = this.calculateWaitTime(existing.count - bruteForceConfig.freeRetries);
|
||||||
existing.count - bruteForceConfig.freeRetries,
|
|
||||||
);
|
|
||||||
existing.blockedUntil = new Date(now.getTime() + waitTime);
|
existing.blockedUntil = new Date(now.getTime() + waitTime);
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`,
|
`Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +97,7 @@ export class BruteForceProtectionService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const remaining = Math.max(
|
const remaining = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000),
|
Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000)
|
||||||
);
|
);
|
||||||
|
|
||||||
return remaining;
|
return remaining;
|
||||||
@ -116,8 +114,7 @@ export class BruteForceProtectionService {
|
|||||||
* Calculate wait time with exponential backoff
|
* Calculate wait time with exponential backoff
|
||||||
*/
|
*/
|
||||||
private calculateWaitTime(failedAttempts: number): number {
|
private calculateWaitTime(failedAttempts: number): number {
|
||||||
const waitTime =
|
const waitTime = bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1);
|
||||||
bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1);
|
|
||||||
return Math.min(waitTime, bruteForceConfig.maxWait);
|
return Math.min(waitTime, bruteForceConfig.maxWait);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,10 +160,7 @@ export class BruteForceProtectionService {
|
|||||||
return {
|
return {
|
||||||
totalAttempts,
|
totalAttempts,
|
||||||
currentlyBlocked,
|
currentlyBlocked,
|
||||||
averageAttempts:
|
averageAttempts: this.attempts.size > 0 ? Math.round(totalAttempts / this.attempts.size) : 0,
|
||||||
this.attempts.size > 0
|
|
||||||
? Math.round(totalAttempts / this.attempts.size)
|
|
||||||
: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,9 +184,7 @@ export class BruteForceProtectionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(`Manually blocked ${identifier} for ${durationMs / 1000} seconds`);
|
||||||
`Manually blocked ${identifier} for ${durationMs / 1000} seconds`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -25,10 +25,10 @@ export class ExportService {
|
|||||||
async exportBookings(
|
async exportBookings(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`,
|
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
@ -48,17 +48,17 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportToCSV(
|
private async exportToCSV(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
const selectedFields = fields || Object.values(ExportField);
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
// Build CSV header
|
// Build CSV header
|
||||||
const header = selectedFields.map((field) => this.getFieldLabel(field)).join(',');
|
const header = selectedFields.map(field => this.getFieldLabel(field)).join(',');
|
||||||
|
|
||||||
// Build CSV rows
|
// Build CSV rows
|
||||||
const csvRows = rows.map((row) =>
|
const csvRows = rows.map(row =>
|
||||||
selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','),
|
selectedFields.map(field => this.escapeCSVValue(row[field] || '')).join(',')
|
||||||
);
|
);
|
||||||
|
|
||||||
const csv = [header, ...csvRows].join('\n');
|
const csv = [header, ...csvRows].join('\n');
|
||||||
@ -79,10 +79,10 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportToExcel(
|
private async exportToExcel(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
const selectedFields = fields || Object.values(ExportField);
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
workbook.creator = 'Xpeditis';
|
workbook.creator = 'Xpeditis';
|
||||||
@ -91,9 +91,7 @@ export class ExportService {
|
|||||||
const worksheet = workbook.addWorksheet('Bookings');
|
const worksheet = workbook.addWorksheet('Bookings');
|
||||||
|
|
||||||
// Add header row with styling
|
// Add header row with styling
|
||||||
const headerRow = worksheet.addRow(
|
const headerRow = worksheet.addRow(selectedFields.map(field => this.getFieldLabel(field)));
|
||||||
selectedFields.map((field) => this.getFieldLabel(field)),
|
|
||||||
);
|
|
||||||
headerRow.font = { bold: true };
|
headerRow.font = { bold: true };
|
||||||
headerRow.fill = {
|
headerRow.fill = {
|
||||||
type: 'pattern',
|
type: 'pattern',
|
||||||
@ -102,15 +100,15 @@ export class ExportService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add data rows
|
// Add data rows
|
||||||
rows.forEach((row) => {
|
rows.forEach(row => {
|
||||||
const values = selectedFields.map((field) => row[field] || '');
|
const values = selectedFields.map(field => row[field] || '');
|
||||||
worksheet.addRow(values);
|
worksheet.addRow(values);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-fit columns
|
// Auto-fit columns
|
||||||
worksheet.columns.forEach((column) => {
|
worksheet.columns.forEach(column => {
|
||||||
let maxLength = 10;
|
let maxLength = 10;
|
||||||
column.eachCell?.({ includeEmpty: false }, (cell) => {
|
column.eachCell?.({ includeEmpty: false }, cell => {
|
||||||
const columnLength = cell.value ? String(cell.value).length : 10;
|
const columnLength = cell.value ? String(cell.value).length : 10;
|
||||||
if (columnLength > maxLength) {
|
if (columnLength > maxLength) {
|
||||||
maxLength = columnLength;
|
maxLength = columnLength;
|
||||||
@ -136,10 +134,10 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportToJSON(
|
private async exportToJSON(
|
||||||
data: BookingExportData[],
|
data: BookingExportData[],
|
||||||
fields?: ExportField[],
|
fields?: ExportField[]
|
||||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
const selectedFields = fields || Object.values(ExportField);
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
const rows = data.map(item => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
const json = JSON.stringify(
|
const json = JSON.stringify(
|
||||||
{
|
{
|
||||||
@ -148,7 +146,7 @@ export class ExportService {
|
|||||||
bookings: rows,
|
bookings: rows,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = Buffer.from(json, 'utf-8');
|
const buffer = Buffer.from(json, 'utf-8');
|
||||||
@ -166,14 +164,11 @@ export class ExportService {
|
|||||||
/**
|
/**
|
||||||
* Extract specified fields from booking data
|
* Extract specified fields from booking data
|
||||||
*/
|
*/
|
||||||
private extractFields(
|
private extractFields(data: BookingExportData, fields: ExportField[]): Record<string, any> {
|
||||||
data: BookingExportData,
|
|
||||||
fields: ExportField[],
|
|
||||||
): Record<string, any> {
|
|
||||||
const { booking, rateQuote } = data;
|
const { booking, rateQuote } = data;
|
||||||
const result: Record<string, any> = {};
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
fields.forEach((field) => {
|
fields.forEach(field => {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case ExportField.BOOKING_NUMBER:
|
case ExportField.BOOKING_NUMBER:
|
||||||
result[field] = booking.bookingNumber.value;
|
result[field] = booking.bookingNumber.value;
|
||||||
@ -206,7 +201,7 @@ export class ExportService {
|
|||||||
result[field] = booking.consignee.name;
|
result[field] = booking.consignee.name;
|
||||||
break;
|
break;
|
||||||
case ExportField.CONTAINER_TYPE:
|
case ExportField.CONTAINER_TYPE:
|
||||||
result[field] = booking.containers.map((c) => c.type).join(', ');
|
result[field] = booking.containers.map(c => c.type).join(', ');
|
||||||
break;
|
break;
|
||||||
case ExportField.CONTAINER_COUNT:
|
case ExportField.CONTAINER_COUNT:
|
||||||
result[field] = booking.containers.length;
|
result[field] = booking.containers.length;
|
||||||
@ -217,7 +212,8 @@ export class ExportService {
|
|||||||
}, 0);
|
}, 0);
|
||||||
break;
|
break;
|
||||||
case ExportField.PRICE:
|
case ExportField.PRICE:
|
||||||
result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
result[field] =
|
||||||
|
`${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -253,11 +249,7 @@ export class ExportService {
|
|||||||
*/
|
*/
|
||||||
private escapeCSVValue(value: string): string {
|
private escapeCSVValue(value: string): string {
|
||||||
const stringValue = String(value);
|
const stringValue = String(value);
|
||||||
if (
|
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||||
stringValue.includes(',') ||
|
|
||||||
stringValue.includes('"') ||
|
|
||||||
stringValue.includes('\n')
|
|
||||||
) {
|
|
||||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
return stringValue;
|
return stringValue;
|
||||||
|
|||||||
@ -32,14 +32,14 @@ export class FileValidationService {
|
|||||||
// Validate file size
|
// Validate file size
|
||||||
if (file.size > fileUploadConfig.maxFileSize) {
|
if (file.size > fileUploadConfig.maxFileSize) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`,
|
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate MIME type
|
// Validate MIME type
|
||||||
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
|
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`,
|
`File type ${file.mimetype} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ export class FileValidationService {
|
|||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
|
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`,
|
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ export class FileValidationService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const lowerFilename = filename.toLowerCase();
|
const lowerFilename = filename.toLowerCase();
|
||||||
return dangerousExtensions.some((ext) => lowerFilename.includes(ext));
|
return dangerousExtensions.some(ext => lowerFilename.includes(ext));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -180,9 +180,7 @@ export class FileValidationService {
|
|||||||
|
|
||||||
// TODO: Integrate with ClamAV or similar virus scanner
|
// TODO: Integrate with ClamAV or similar virus scanner
|
||||||
// For now, just log
|
// For now, just log
|
||||||
this.logger.log(
|
this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`);
|
||||||
`Virus scan requested for file: ${file.originalname} (not implemented)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -190,9 +188,7 @@ export class FileValidationService {
|
|||||||
/**
|
/**
|
||||||
* Validate multiple files
|
* Validate multiple files
|
||||||
*/
|
*/
|
||||||
async validateFiles(
|
async validateFiles(files: Express.Multer.File[]): Promise<FileValidationResult> {
|
||||||
files: Express.Multer.File[],
|
|
||||||
): Promise<FileValidationResult> {
|
|
||||||
const allErrors: string[] = [];
|
const allErrors: string[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export class FuzzySearchService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(BookingOrmEntity)
|
@InjectRepository(BookingOrmEntity)
|
||||||
private readonly bookingOrmRepository: Repository<BookingOrmEntity>,
|
private readonly bookingOrmRepository: Repository<BookingOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,15 +26,13 @@ export class FuzzySearchService {
|
|||||||
async fuzzySearchBookings(
|
async fuzzySearchBookings(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
limit: number = 20,
|
limit: number = 20
|
||||||
): Promise<BookingOrmEntity[]> {
|
): Promise<BookingOrmEntity[]> {
|
||||||
if (!searchTerm || searchTerm.length < 2) {
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Fuzzy search for "${searchTerm}" in organization ${organizationId}`);
|
||||||
`Fuzzy search for "${searchTerm}" in organization ${organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use PostgreSQL full-text search with similarity
|
// Use PostgreSQL full-text search with similarity
|
||||||
// This requires pg_trgm extension to be enabled
|
// This requires pg_trgm extension to be enabled
|
||||||
@ -54,7 +52,7 @@ export class FuzzySearchService {
|
|||||||
{
|
{
|
||||||
searchTerm,
|
searchTerm,
|
||||||
likeTerm: `%${searchTerm}%`,
|
likeTerm: `%${searchTerm}%`,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
`GREATEST(
|
`GREATEST(
|
||||||
@ -62,7 +60,7 @@ export class FuzzySearchService {
|
|||||||
similarity(booking.shipper_name, :searchTerm),
|
similarity(booking.shipper_name, :searchTerm),
|
||||||
similarity(booking.consignee_name, :searchTerm)
|
similarity(booking.consignee_name, :searchTerm)
|
||||||
)`,
|
)`,
|
||||||
'DESC',
|
'DESC'
|
||||||
)
|
)
|
||||||
.setParameter('searchTerm', searchTerm)
|
.setParameter('searchTerm', searchTerm)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -80,21 +78,19 @@ export class FuzzySearchService {
|
|||||||
async fullTextSearch(
|
async fullTextSearch(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
limit: number = 20,
|
limit: number = 20
|
||||||
): Promise<BookingOrmEntity[]> {
|
): Promise<BookingOrmEntity[]> {
|
||||||
if (!searchTerm || searchTerm.length < 2) {
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Full-text search for "${searchTerm}" in organization ${organizationId}`);
|
||||||
`Full-text search for "${searchTerm}" in organization ${organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert search term to tsquery format
|
// Convert search term to tsquery format
|
||||||
const tsquery = searchTerm
|
const tsquery = searchTerm
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter((term) => term.length > 0)
|
.filter(term => term.length > 0)
|
||||||
.map((term) => `${term}:*`)
|
.map(term => `${term}:*`)
|
||||||
.join(' & ');
|
.join(' & ');
|
||||||
|
|
||||||
const results = await this.bookingOrmRepository
|
const results = await this.bookingOrmRepository
|
||||||
@ -111,7 +107,7 @@ export class FuzzySearchService {
|
|||||||
{
|
{
|
||||||
tsquery,
|
tsquery,
|
||||||
likeTerm: `%${searchTerm}%`,
|
likeTerm: `%${searchTerm}%`,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
.orderBy('booking.created_at', 'DESC')
|
.orderBy('booking.created_at', 'DESC')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -128,7 +124,7 @@ export class FuzzySearchService {
|
|||||||
async search(
|
async search(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
limit: number = 20,
|
limit: number = 20
|
||||||
): Promise<BookingOrmEntity[]> {
|
): Promise<BookingOrmEntity[]> {
|
||||||
// Try fuzzy search first (more tolerant to typos)
|
// Try fuzzy search first (more tolerant to typos)
|
||||||
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
|
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export class GDPRService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserOrmEntity)
|
@InjectRepository(UserOrmEntity)
|
||||||
private readonly userRepository: Repository<UserOrmEntity>,
|
private readonly userRepository: Repository<UserOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,7 +63,8 @@ export class GDPRService {
|
|||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
userId,
|
userId,
|
||||||
userData: sanitizedUser,
|
userData: sanitizedUser,
|
||||||
message: 'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
message:
|
||||||
|
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Data export completed for user ${userId}`);
|
this.logger.log(`Data export completed for user ${userId}`);
|
||||||
@ -76,7 +77,9 @@ export class GDPRService {
|
|||||||
* Note: This is a simplified version. In production, implement full anonymization logic.
|
* Note: This is a simplified version. In production, implement full anonymization logic.
|
||||||
*/
|
*/
|
||||||
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||||
this.logger.warn(`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`);
|
this.logger.warn(
|
||||||
|
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||||
|
);
|
||||||
|
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
@ -117,7 +120,9 @@ export class GDPRService {
|
|||||||
|
|
||||||
// In production, store in separate consent table
|
// In production, store in separate consent table
|
||||||
// For now, just log the consent
|
// For now, just log the consent
|
||||||
this.logger.log(`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`);
|
this.logger.log(
|
||||||
|
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,8 +4,15 @@
|
|||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { NotificationService } from './notification.service';
|
import { NotificationService } from './notification.service';
|
||||||
import { NOTIFICATION_REPOSITORY, NotificationRepository } from '../../domain/ports/out/notification.repository';
|
import {
|
||||||
import { Notification, NotificationType, NotificationPriority } from '../../domain/entities/notification.entity';
|
NOTIFICATION_REPOSITORY,
|
||||||
|
NotificationRepository,
|
||||||
|
} from '../../domain/ports/out/notification.repository';
|
||||||
|
import {
|
||||||
|
Notification,
|
||||||
|
NotificationType,
|
||||||
|
NotificationPriority,
|
||||||
|
} from '../../domain/entities/notification.entity';
|
||||||
|
|
||||||
describe('NotificationService', () => {
|
describe('NotificationService', () => {
|
||||||
let service: NotificationService;
|
let service: NotificationService;
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export class NotificationService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(NOTIFICATION_REPOSITORY)
|
@Inject(NOTIFICATION_REPOSITORY)
|
||||||
private readonly notificationRepository: NotificationRepository,
|
private readonly notificationRepository: NotificationRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,14 +50,14 @@ export class NotificationService {
|
|||||||
await this.notificationRepository.save(notification);
|
await this.notificationRepository.save(notification);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`,
|
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return notification;
|
return notification;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to create notification: ${error?.message || 'Unknown error'}`,
|
`Failed to create notification: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -147,7 +147,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
bookingNumber: string,
|
bookingNumber: string,
|
||||||
bookingId: string,
|
bookingId: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
@ -166,7 +166,7 @@ export class NotificationService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
bookingNumber: string,
|
bookingNumber: string,
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
status: string,
|
status: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
@ -184,7 +184,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
bookingNumber: string,
|
bookingNumber: string,
|
||||||
bookingId: string,
|
bookingId: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
@ -202,7 +202,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
documentName: string,
|
documentName: string,
|
||||||
bookingId: string,
|
bookingId: string
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
return this.createNotification({
|
return this.createNotification({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -123,11 +123,9 @@ describe('WebhookService', () => {
|
|||||||
of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any })
|
of({ status: 200, statusText: 'OK', data: {}, headers: {}, config: {} as any })
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.triggerWebhooks(
|
await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', {
|
||||||
WebhookEvent.BOOKING_CREATED,
|
bookingId: 'booking-123',
|
||||||
'org-123',
|
});
|
||||||
{ bookingId: 'booking-123' }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(httpService.post).toHaveBeenCalledWith(
|
expect(httpService.post).toHaveBeenCalledWith(
|
||||||
'https://example.com/webhook',
|
'https://example.com/webhook',
|
||||||
@ -151,11 +149,9 @@ describe('WebhookService', () => {
|
|||||||
repository.findActiveByEvent.mockResolvedValue([webhook]);
|
repository.findActiveByEvent.mockResolvedValue([webhook]);
|
||||||
httpService.post.mockReturnValue(throwError(() => new Error('Network error')));
|
httpService.post.mockReturnValue(throwError(() => new Error('Network error')));
|
||||||
|
|
||||||
await service.triggerWebhooks(
|
await service.triggerWebhooks(WebhookEvent.BOOKING_CREATED, 'org-123', {
|
||||||
WebhookEvent.BOOKING_CREATED,
|
bookingId: 'booking-123',
|
||||||
'org-123',
|
});
|
||||||
{ bookingId: 'booking-123' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should be saved as failed after retries
|
// Should be saved as failed after retries
|
||||||
expect(repository.save).toHaveBeenCalledWith(
|
expect(repository.save).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import { HttpService } from '@nestjs/axios';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import {
|
import { Webhook, WebhookEvent, WebhookStatus } from '../../domain/entities/webhook.entity';
|
||||||
Webhook,
|
|
||||||
WebhookEvent,
|
|
||||||
WebhookStatus,
|
|
||||||
} from '../../domain/entities/webhook.entity';
|
|
||||||
import {
|
import {
|
||||||
WebhookRepository,
|
WebhookRepository,
|
||||||
WEBHOOK_REPOSITORY,
|
WEBHOOK_REPOSITORY,
|
||||||
@ -51,7 +47,7 @@ export class WebhookService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(WEBHOOK_REPOSITORY)
|
@Inject(WEBHOOK_REPOSITORY)
|
||||||
private readonly webhookRepository: WebhookRepository,
|
private readonly webhookRepository: WebhookRepository,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -72,9 +68,7 @@ export class WebhookService {
|
|||||||
|
|
||||||
await this.webhookRepository.save(webhook);
|
await this.webhookRepository.save(webhook);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Webhook created: ${webhook.id} for organization ${input.organizationId}`);
|
||||||
`Webhook created: ${webhook.id} for organization ${input.organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return webhook;
|
return webhook;
|
||||||
}
|
}
|
||||||
@ -158,11 +152,7 @@ export class WebhookService {
|
|||||||
/**
|
/**
|
||||||
* Trigger webhooks for an event
|
* Trigger webhooks for an event
|
||||||
*/
|
*/
|
||||||
async triggerWebhooks(
|
async triggerWebhooks(event: WebhookEvent, organizationId: string, data: any): Promise<void> {
|
||||||
event: WebhookEvent,
|
|
||||||
organizationId: string,
|
|
||||||
data: any,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
|
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
|
||||||
|
|
||||||
@ -179,17 +169,13 @@ export class WebhookService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Trigger all webhooks in parallel
|
// Trigger all webhooks in parallel
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(webhooks.map(webhook => this.triggerWebhook(webhook, payload)));
|
||||||
webhooks.map((webhook) => this.triggerWebhook(webhook, payload)),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Triggered ${webhooks.length} webhooks for event: ${event}`);
|
||||||
`Triggered ${webhooks.length} webhooks for event: ${event}`,
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error triggering webhooks: ${error?.message || 'Unknown error'}`,
|
`Error triggering webhooks: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,10 +183,7 @@ export class WebhookService {
|
|||||||
/**
|
/**
|
||||||
* Trigger a single webhook with retries
|
* Trigger a single webhook with retries
|
||||||
*/
|
*/
|
||||||
private async triggerWebhook(
|
private async triggerWebhook(webhook: Webhook, payload: WebhookPayload): Promise<void> {
|
||||||
webhook: Webhook,
|
|
||||||
payload: WebhookPayload,
|
|
||||||
): Promise<void> {
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
|
||||||
@ -226,7 +209,7 @@ export class WebhookService {
|
|||||||
this.httpService.post(webhook.url, payload, {
|
this.httpService.post(webhook.url, payload, {
|
||||||
headers,
|
headers,
|
||||||
timeout: 10000, // 10 seconds
|
timeout: 10000, // 10 seconds
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response && response.status >= 200 && response.status < 300) {
|
if (response && response.status >= 200 && response.status < 300) {
|
||||||
@ -234,17 +217,17 @@ export class WebhookService {
|
|||||||
const updatedWebhook = webhook.recordTrigger();
|
const updatedWebhook = webhook.recordTrigger();
|
||||||
await this.webhookRepository.save(updatedWebhook);
|
await this.webhookRepository.save(updatedWebhook);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`);
|
||||||
`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`);
|
lastError = new Error(
|
||||||
|
`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`,
|
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -254,7 +237,7 @@ export class WebhookService {
|
|||||||
await this.webhookRepository.save(failedWebhook);
|
await this.webhookRepository.save(failedWebhook);
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`,
|
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,16 +262,13 @@ export class WebhookService {
|
|||||||
*/
|
*/
|
||||||
verifySignature(payload: any, signature: string, secret: string): boolean {
|
verifySignature(payload: any, signature: string, secret: string): boolean {
|
||||||
const expectedSignature = this.generateSignature(payload, secret);
|
const expectedSignature = this.generateSignature(payload, secret);
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||||
Buffer.from(signature),
|
|
||||||
Buffer.from(expectedSignature),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delay helper for retries
|
* Delay helper for retries
|
||||||
*/
|
*/
|
||||||
private delay(ms: number): Promise<void> {
|
private delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -174,9 +174,7 @@ export class Booking {
|
|||||||
*/
|
*/
|
||||||
updateStatus(newStatus: BookingStatus): Booking {
|
updateStatus(newStatus: BookingStatus): Booking {
|
||||||
if (!this.status.canTransitionTo(newStatus)) {
|
if (!this.status.canTransitionTo(newStatus)) {
|
||||||
throw new Error(
|
throw new Error(`Cannot transition from ${this.status.value} to ${newStatus.value}`);
|
||||||
`Cannot transition from ${this.status.value} to ${newStatus.value}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Booking({
|
return new Booking({
|
||||||
@ -209,7 +207,7 @@ export class Booking {
|
|||||||
throw new Error('Cannot modify containers after booking is confirmed');
|
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) {
|
if (containerIndex === -1) {
|
||||||
throw new Error(`Container ${containerId} not found`);
|
throw new Error(`Container ${containerId} not found`);
|
||||||
}
|
}
|
||||||
@ -237,7 +235,7 @@ export class Booking {
|
|||||||
|
|
||||||
return new Booking({
|
return new Booking({
|
||||||
...this.props,
|
...this.props,
|
||||||
containers: this.props.containers.filter((c) => c.id !== containerId),
|
containers: this.props.containers.filter(c => c.id !== containerId),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,9 @@ export class Carrier {
|
|||||||
|
|
||||||
// Validate carrier code
|
// Validate carrier code
|
||||||
if (!Carrier.isValidCarrierCode(props.code)) {
|
if (!Carrier.isValidCarrierCode(props.code)) {
|
||||||
throw new Error('Invalid carrier code format. Must be uppercase letters and underscores only.');
|
throw new Error(
|
||||||
|
'Invalid carrier code format. Must be uppercase letters and underscores only.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate API config if carrier supports API
|
// Validate API config if carrier supports API
|
||||||
|
|||||||
@ -233,7 +233,10 @@ export class Container {
|
|||||||
// Twenty-foot Equivalent Unit
|
// Twenty-foot Equivalent Unit
|
||||||
if (this.props.size === ContainerSize.TWENTY) {
|
if (this.props.size === ContainerSize.TWENTY) {
|
||||||
return 1;
|
return 1;
|
||||||
} else if (this.props.size === ContainerSize.FORTY || this.props.size === ContainerSize.FORTY_FIVE) {
|
} else if (
|
||||||
|
this.props.size === ContainerSize.FORTY ||
|
||||||
|
this.props.size === ContainerSize.FORTY_FIVE
|
||||||
|
) {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export class CsvRate {
|
|||||||
public readonly currency: string, // Primary currency (USD or EUR)
|
public readonly currency: string, // Primary currency (USD or EUR)
|
||||||
public readonly surcharges: SurchargeCollection,
|
public readonly surcharges: SurchargeCollection,
|
||||||
public readonly transitDays: number,
|
public readonly transitDays: number,
|
||||||
public readonly validity: DateRange,
|
public readonly validity: DateRange
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
@ -111,7 +111,7 @@ export class CsvRate {
|
|||||||
// Freight class rule: max(volume price, weight price)
|
// Freight class rule: max(volume price, weight price)
|
||||||
const freightPrice = volume.calculateFreightPrice(
|
const freightPrice = volume.calculateFreightPrice(
|
||||||
this.pricing.pricePerCBM,
|
this.pricing.pricePerCBM,
|
||||||
this.pricing.pricePerKG,
|
this.pricing.pricePerKG
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create Money object in the rate's currency
|
// Create Money object in the rate's currency
|
||||||
@ -138,19 +138,13 @@ export class CsvRate {
|
|||||||
// Otherwise, use the pre-calculated base price in target currency
|
// Otherwise, use the pre-calculated base price in target currency
|
||||||
// and recalculate proportionally
|
// and recalculate proportionally
|
||||||
const basePriceInPrimaryCurrency =
|
const basePriceInPrimaryCurrency =
|
||||||
this.currency === 'USD'
|
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||||
? this.pricing.basePriceUSD
|
|
||||||
: this.pricing.basePriceEUR;
|
|
||||||
|
|
||||||
const basePriceInTargetCurrency =
|
const basePriceInTargetCurrency =
|
||||||
targetCurrency === 'USD'
|
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
|
||||||
? this.pricing.basePriceUSD
|
|
||||||
: this.pricing.basePriceEUR;
|
|
||||||
|
|
||||||
// Calculate conversion ratio
|
// Calculate conversion ratio
|
||||||
const ratio =
|
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
|
||||||
basePriceInTargetCurrency.getAmount() /
|
|
||||||
basePriceInPrimaryCurrency.getAmount();
|
|
||||||
|
|
||||||
// Apply ratio to calculated price
|
// Apply ratio to calculated price
|
||||||
const convertedAmount = price.getAmount() * ratio;
|
const convertedAmount = price.getAmount() * ratio;
|
||||||
@ -179,7 +173,7 @@ export class CsvRate {
|
|||||||
this.volumeRange.minCBM,
|
this.volumeRange.minCBM,
|
||||||
this.volumeRange.maxCBM,
|
this.volumeRange.maxCBM,
|
||||||
this.weightRange.minKG,
|
this.weightRange.minKG,
|
||||||
this.weightRange.maxKG,
|
this.weightRange.maxKG
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class Notification {
|
|||||||
private constructor(private readonly props: NotificationProps) {}
|
private constructor(private readonly props: NotificationProps) {}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string },
|
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string }
|
||||||
): Notification {
|
): Notification {
|
||||||
return new Notification({
|
return new Notification({
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@ -154,8 +154,12 @@ export class Port {
|
|||||||
const R = 6371; // Earth's radius in kilometers
|
const R = 6371; // Earth's radius in kilometers
|
||||||
const lat1 = this.toRadians(this.props.coordinates.latitude);
|
const lat1 = this.toRadians(this.props.coordinates.latitude);
|
||||||
const lat2 = this.toRadians(otherPort.coordinates.latitude);
|
const lat2 = this.toRadians(otherPort.coordinates.latitude);
|
||||||
const deltaLat = this.toRadians(otherPort.coordinates.latitude - this.props.coordinates.latitude);
|
const deltaLat = this.toRadians(
|
||||||
const deltaLon = this.toRadians(otherPort.coordinates.longitude - this.props.coordinates.longitude);
|
otherPort.coordinates.latitude - this.props.coordinates.latitude
|
||||||
|
);
|
||||||
|
const deltaLon = this.toRadians(
|
||||||
|
otherPort.coordinates.longitude - this.props.coordinates.longitude
|
||||||
|
);
|
||||||
|
|
||||||
const a =
|
const a =
|
||||||
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
||||||
|
|||||||
@ -11,10 +11,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
ADMIN = 'admin', // Full system access
|
ADMIN = 'admin', // Full system access
|
||||||
MANAGER = 'manager', // Manage bookings and users within organization
|
MANAGER = 'manager', // Manage bookings and users within organization
|
||||||
USER = 'user', // Create and view bookings
|
USER = 'user', // Create and view bookings
|
||||||
VIEWER = 'viewer', // Read-only access
|
VIEWER = 'viewer', // Read-only access
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProps {
|
export interface UserProps {
|
||||||
@ -45,7 +45,10 @@ export class User {
|
|||||||
* Factory method to create a new User
|
* Factory method to create a new User
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(
|
||||||
props: Omit<UserProps, 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'>
|
props: Omit<
|
||||||
|
UserProps,
|
||||||
|
'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
|
||||||
|
>
|
||||||
): User {
|
): User {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,10 @@ export class Webhook {
|
|||||||
private constructor(private readonly props: WebhookProps) {}
|
private constructor(private readonly props: WebhookProps) {}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
props: Omit<WebhookProps, 'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'> & { id: string },
|
props: Omit<
|
||||||
|
WebhookProps,
|
||||||
|
'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'
|
||||||
|
> & { id: string }
|
||||||
): Webhook {
|
): Webhook {
|
||||||
return new Webhook({
|
return new Webhook({
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@ -43,9 +43,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
// Apply container type filter if specified
|
// Apply container type filter if specified
|
||||||
if (input.containerType) {
|
if (input.containerType) {
|
||||||
const containerType = ContainerType.create(input.containerType);
|
const containerType = ContainerType.create(input.containerType);
|
||||||
matchingRates = matchingRates.filter((rate) =>
|
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||||
rate.containerType.equals(containerType),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply advanced filters
|
// Apply advanced filters
|
||||||
@ -54,7 +52,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate prices and create results
|
// Calculate prices and create results
|
||||||
const results: CsvRateSearchResult[] = matchingRates.map((rate) => {
|
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
||||||
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
||||||
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
||||||
|
|
||||||
@ -73,13 +71,9 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
// Sort by price (ascending) in primary currency
|
// Sort by price (ascending) in primary currency
|
||||||
results.sort((a, b) => {
|
results.sort((a, b) => {
|
||||||
const priceA =
|
const priceA =
|
||||||
a.calculatedPrice.primaryCurrency === 'USD'
|
a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur;
|
||||||
? a.calculatedPrice.usd
|
|
||||||
: a.calculatedPrice.eur;
|
|
||||||
const priceB =
|
const priceB =
|
||||||
b.calculatedPrice.primaryCurrency === 'USD'
|
b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur;
|
||||||
? b.calculatedPrice.usd
|
|
||||||
: b.calculatedPrice.eur;
|
|
||||||
return priceA - priceB;
|
return priceA - priceB;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,13 +88,13 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
async getAvailableCompanies(): Promise<string[]> {
|
async getAvailableCompanies(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
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();
|
return Array.from(companies).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableContainerTypes(): Promise<string[]> {
|
async getAvailableContainerTypes(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
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();
|
return Array.from(types).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,9 +103,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
*/
|
*/
|
||||||
private async loadAllRates(): Promise<CsvRate[]> {
|
private async loadAllRates(): Promise<CsvRate[]> {
|
||||||
const files = await this.csvRateLoader.getAvailableCsvFiles();
|
const files = await this.csvRateLoader.getAvailableCsvFiles();
|
||||||
const ratePromises = files.map((file) =>
|
const ratePromises = files.map(file => this.csvRateLoader.loadRatesFromCsv(file));
|
||||||
this.csvRateLoader.loadRatesFromCsv(file),
|
|
||||||
);
|
|
||||||
const rateArrays = await Promise.all(ratePromises);
|
const rateArrays = await Promise.all(ratePromises);
|
||||||
return rateArrays.flat();
|
return rateArrays.flat();
|
||||||
}
|
}
|
||||||
@ -119,26 +111,22 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
/**
|
/**
|
||||||
* Filter rates by route (origin/destination)
|
* Filter rates by route (origin/destination)
|
||||||
*/
|
*/
|
||||||
private filterByRoute(
|
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
|
||||||
rates: CsvRate[],
|
return rates.filter(rate => rate.matchesRoute(origin, destination));
|
||||||
origin: PortCode,
|
|
||||||
destination: PortCode,
|
|
||||||
): CsvRate[] {
|
|
||||||
return rates.filter((rate) => rate.matchesRoute(origin, destination));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter rates by volume/weight range
|
* Filter rates by volume/weight range
|
||||||
*/
|
*/
|
||||||
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
|
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
|
* Filter rates by pallet count
|
||||||
*/
|
*/
|
||||||
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
|
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(
|
private applyAdvancedFilters(
|
||||||
rates: CsvRate[],
|
rates: CsvRate[],
|
||||||
filters: RateSearchFilters,
|
filters: RateSearchFilters,
|
||||||
volume: Volume,
|
volume: Volume
|
||||||
): CsvRate[] {
|
): CsvRate[] {
|
||||||
let filtered = rates;
|
let filtered = rates;
|
||||||
|
|
||||||
// Company filter
|
// Company filter
|
||||||
if (filters.companies && filters.companies.length > 0) {
|
if (filters.companies && filters.companies.length > 0) {
|
||||||
filtered = filtered.filter((rate) =>
|
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
|
||||||
filters.companies!.includes(rate.companyName),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume CBM filter
|
// Volume CBM filter
|
||||||
if (filters.minVolumeCBM !== undefined) {
|
if (filters.minVolumeCBM !== undefined) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
|
||||||
(rate) => rate.volumeRange.maxCBM >= filters.minVolumeCBM!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (filters.maxVolumeCBM !== undefined) {
|
if (filters.maxVolumeCBM !== undefined) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
|
||||||
(rate) => rate.volumeRange.minCBM <= filters.maxVolumeCBM!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weight KG filter
|
// Weight KG filter
|
||||||
if (filters.minWeightKG !== undefined) {
|
if (filters.minWeightKG !== undefined) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
|
||||||
(rate) => rate.weightRange.maxKG >= filters.minWeightKG!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (filters.maxWeightKG !== undefined) {
|
if (filters.maxWeightKG !== undefined) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
|
||||||
(rate) => rate.weightRange.minKG <= filters.maxWeightKG!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pallet count filter
|
// Pallet count filter
|
||||||
if (filters.palletCount !== undefined) {
|
if (filters.palletCount !== undefined) {
|
||||||
filtered = filtered.filter((rate) =>
|
filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
|
||||||
rate.matchesPalletCount(filters.palletCount!),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Price filter (calculate price first)
|
// Price filter (calculate price first)
|
||||||
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
||||||
const currency = filters.currency || 'USD';
|
const currency = filters.currency || 'USD';
|
||||||
filtered = filtered.filter((rate) => {
|
filtered = filtered.filter(rate => {
|
||||||
const price = rate.getPriceInCurrency(volume, currency);
|
const price = rate.getPriceInCurrency(volume, currency);
|
||||||
const amount = price.getAmount();
|
const amount = price.getAmount();
|
||||||
|
|
||||||
@ -208,33 +184,27 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
// Transit days filter
|
// Transit days filter
|
||||||
if (filters.minTransitDays !== undefined) {
|
if (filters.minTransitDays !== undefined) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
|
||||||
(rate) => rate.transitDays >= filters.minTransitDays!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (filters.maxTransitDays !== undefined) {
|
if (filters.maxTransitDays !== undefined) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
|
||||||
(rate) => rate.transitDays <= filters.maxTransitDays!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container type filter
|
// Container type filter
|
||||||
if (filters.containerTypes && filters.containerTypes.length > 0) {
|
if (filters.containerTypes && filters.containerTypes.length > 0) {
|
||||||
filtered = filtered.filter((rate) =>
|
filtered = filtered.filter(rate =>
|
||||||
filters.containerTypes!.includes(rate.containerType.getValue()),
|
filters.containerTypes!.includes(rate.containerType.getValue())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All-in prices only filter
|
// All-in prices only filter
|
||||||
if (filters.onlyAllInPrices) {
|
if (filters.onlyAllInPrices) {
|
||||||
filtered = filtered.filter((rate) => rate.isAllInPrice());
|
filtered = filtered.filter(rate => rate.isAllInPrice());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Departure date / validity filter
|
// Departure date / validity filter
|
||||||
if (filters.departureDate) {
|
if (filters.departureDate) {
|
||||||
filtered = filtered.filter((rate) =>
|
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
||||||
rate.isValidForDate(filters.departureDate!),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
@ -244,10 +214,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
* Calculate match score (0-100) based on how well rate matches input
|
* Calculate match score (0-100) based on how well rate matches input
|
||||||
* Higher score = better match
|
* Higher score = better match
|
||||||
*/
|
*/
|
||||||
private calculateMatchScore(
|
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
|
||||||
rate: CsvRate,
|
|
||||||
input: CsvRateSearchInput,
|
|
||||||
): number {
|
|
||||||
let score = 100;
|
let score = 100;
|
||||||
|
|
||||||
// Reduce score if volume/weight is near boundaries
|
// Reduce score if volume/weight is near boundaries
|
||||||
@ -270,8 +237,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
// Reduce score for rates expiring soon
|
// Reduce score for rates expiring soon
|
||||||
const daysUntilExpiry = Math.floor(
|
const daysUntilExpiry = Math.floor(
|
||||||
(rate.validity.getEndDate().getTime() - Date.now()) /
|
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
(1000 * 60 * 60 * 24),
|
|
||||||
);
|
);
|
||||||
if (daysUntilExpiry < 7) {
|
if (daysUntilExpiry < 7) {
|
||||||
score -= 10;
|
score -= 10;
|
||||||
|
|||||||
@ -10,7 +10,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Port } from '../entities/port.entity';
|
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 { PortRepository } from '../ports/out/port.repository';
|
||||||
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
|
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
|
||||||
|
|
||||||
@ -53,8 +58,8 @@ export class PortSearchService implements GetPortsPort {
|
|||||||
const ports = await this.portRepository.findByCodes(portCodes);
|
const ports = await this.portRepository.findByCodes(portCodes);
|
||||||
|
|
||||||
// Check if all ports were found
|
// Check if all ports were found
|
||||||
const foundCodes = ports.map((p) => p.code);
|
const foundCodes = ports.map(p => p.code);
|
||||||
const missingCodes = portCodes.filter((code) => !foundCodes.includes(code));
|
const missingCodes = portCodes.filter(code => !foundCodes.includes(code));
|
||||||
|
|
||||||
if (missingCodes.length > 0) {
|
if (missingCodes.length > 0) {
|
||||||
throw new PortNotFoundException(missingCodes[0]);
|
throw new PortNotFoundException(missingCodes[0]);
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export class RateSearchService implements SearchRatesPort {
|
|||||||
|
|
||||||
// Query all carriers in parallel with Promise.allSettled
|
// Query all carriers in parallel with Promise.allSettled
|
||||||
const carrierResults = await Promise.allSettled(
|
const carrierResults = await Promise.allSettled(
|
||||||
connectorsToQuery.map((connector) => this.queryCarrier(connector, input))
|
connectorsToQuery.map(connector => this.queryCarrier(connector, input))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process results
|
// Process results
|
||||||
@ -140,7 +140,7 @@ export class RateSearchService implements SearchRatesPort {
|
|||||||
return this.carrierConnectors;
|
return this.carrierConnectors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.carrierConnectors.filter((connector) =>
|
return this.carrierConnectors.filter(connector =>
|
||||||
carrierPreferences.includes(connector.getCarrierCode())
|
carrierPreferences.includes(connector.getCarrierCode())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,9 +73,7 @@ export class BookingStatus {
|
|||||||
*/
|
*/
|
||||||
transitionTo(newStatus: BookingStatus): BookingStatus {
|
transitionTo(newStatus: BookingStatus): BookingStatus {
|
||||||
if (!this.canTransitionTo(newStatus)) {
|
if (!this.canTransitionTo(newStatus)) {
|
||||||
throw new Error(
|
throw new Error(`Invalid status transition from ${this._value} to ${newStatus._value}`);
|
||||||
`Invalid status transition from ${this._value} to ${newStatus._value}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return newStatus;
|
return newStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,9 +76,7 @@ export class DateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
overlaps(other: DateRange): boolean {
|
overlaps(other: DateRange): boolean {
|
||||||
return (
|
return this.startDate <= other.endDate && this.endDate >= other.startDate;
|
||||||
this.startDate <= other.endDate && this.endDate >= other.startDate
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isFutureRange(): boolean {
|
isFutureRange(): boolean {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export class Surcharge {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly type: SurchargeType,
|
public readonly type: SurchargeType,
|
||||||
public readonly amount: Money,
|
public readonly amount: Money,
|
||||||
public readonly description?: string,
|
public readonly description?: string
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
@ -46,10 +46,7 @@ export class Surcharge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Surcharge): boolean {
|
equals(other: Surcharge): boolean {
|
||||||
return (
|
return this.type === other.type && this.amount.isEqualTo(other.amount);
|
||||||
this.type === other.type &&
|
|
||||||
this.amount.isEqualTo(other.amount)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
@ -70,15 +67,16 @@ export class SurchargeCollection {
|
|||||||
* In production, currency conversion would be needed
|
* In production, currency conversion would be needed
|
||||||
*/
|
*/
|
||||||
getTotalAmount(currency: string): Money {
|
getTotalAmount(currency: string): Money {
|
||||||
const relevantSurcharges = this.surcharges
|
const relevantSurcharges = this.surcharges.filter(s => s.amount.getCurrency() === currency);
|
||||||
.filter((s) => s.amount.getCurrency() === currency);
|
|
||||||
|
|
||||||
if (relevantSurcharges.length === 0) {
|
if (relevantSurcharges.length === 0) {
|
||||||
return Money.zero(currency);
|
return Money.zero(currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return relevantSurcharges
|
return relevantSurcharges.reduce(
|
||||||
.reduce((total, surcharge) => total.add(surcharge.amount), Money.zero(currency));
|
(total, surcharge) => total.add(surcharge.amount),
|
||||||
|
Money.zero(currency)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,7 +90,7 @@ export class SurchargeCollection {
|
|||||||
* Get surcharges by type
|
* Get surcharges by type
|
||||||
*/
|
*/
|
||||||
getByType(type: SurchargeType): Surcharge[] {
|
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()) {
|
if (this.isEmpty()) {
|
||||||
return 'All-in price (no separate surcharges)';
|
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 {
|
export class Volume {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly cbm: number,
|
public readonly cbm: number,
|
||||||
public readonly weightKG: number,
|
public readonly weightKG: number
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestr
|
|||||||
port,
|
port,
|
||||||
password,
|
password,
|
||||||
db,
|
db,
|
||||||
retryStrategy: (times) => {
|
retryStrategy: times => {
|
||||||
const delay = Math.min(times * 50, 2000);
|
const delay = Math.min(times * 50, 2000);
|
||||||
return delay;
|
return delay;
|
||||||
},
|
},
|
||||||
@ -42,7 +42,7 @@ export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestr
|
|||||||
this.logger.log(`Connected to Redis at ${host}:${port}`);
|
this.logger.log(`Connected to Redis at ${host}:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
this.client.on('error', err => {
|
||||||
this.logger.error(`Redis connection error: ${err.message}`);
|
this.logger.error(`Redis connection error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,7 +112,9 @@ export class RedisCacheAdapter implements CachePort, OnModuleInit, OnModuleDestr
|
|||||||
const result = await this.client.exists(key);
|
const result = await this.client.exists(key);
|
||||||
return result === 1;
|
return result === 1;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`);
|
this.logger.error(
|
||||||
|
`Error checking key existence ${key}: ${error?.message || 'Unknown error'}`
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,15 +44,9 @@ import { ONERequestMapper } from './one/one.mapper';
|
|||||||
mscConnector: MSCConnectorAdapter,
|
mscConnector: MSCConnectorAdapter,
|
||||||
cmacgmConnector: CMACGMConnectorAdapter,
|
cmacgmConnector: CMACGMConnectorAdapter,
|
||||||
hapagConnector: HapagLloydConnectorAdapter,
|
hapagConnector: HapagLloydConnectorAdapter,
|
||||||
oneConnector: ONEConnectorAdapter,
|
oneConnector: ONEConnectorAdapter
|
||||||
) => {
|
) => {
|
||||||
return [
|
return [maerskConnector, mscConnector, cmacgmConnector, hapagConnector, oneConnector];
|
||||||
maerskConnector,
|
|
||||||
mscConnector,
|
|
||||||
cmacgmConnector,
|
|
||||||
hapagConnector,
|
|
||||||
oneConnector,
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
inject: [
|
inject: [
|
||||||
MaerskConnector,
|
MaerskConnector,
|
||||||
|
|||||||
@ -9,24 +9,21 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import {
|
import {
|
||||||
CarrierConnectorPort,
|
CarrierConnectorPort,
|
||||||
CarrierRateSearchInput,
|
CarrierRateSearchInput,
|
||||||
CarrierAvailabilityInput
|
CarrierAvailabilityInput,
|
||||||
} from '../../../domain/ports/out/carrier-connector.port';
|
} from '../../../domain/ports/out/carrier-connector.port';
|
||||||
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
||||||
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
||||||
import { CMACGMRequestMapper } from './cma-cgm.mapper';
|
import { CMACGMRequestMapper } from './cma-cgm.mapper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CMACGMConnectorAdapter
|
export class CMACGMConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort {
|
||||||
extends BaseCarrierConnector
|
|
||||||
implements CarrierConnectorPort
|
|
||||||
{
|
|
||||||
private readonly apiUrl: string;
|
private readonly apiUrl: string;
|
||||||
private readonly clientId: string;
|
private readonly clientId: string;
|
||||||
private readonly clientSecret: string;
|
private readonly clientSecret: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly requestMapper: CMACGMRequestMapper,
|
private readonly requestMapper: CMACGMRequestMapper
|
||||||
) {
|
) {
|
||||||
const config: CarrierConfig = {
|
const config: CarrierConfig = {
|
||||||
name: 'CMA CGM',
|
name: 'CMA CGM',
|
||||||
|
|||||||
@ -30,11 +30,31 @@ export class CMACGMRequestMapper {
|
|||||||
|
|
||||||
return cgmResponse.quotations.map((quotation: any) => {
|
return cgmResponse.quotations.map((quotation: any) => {
|
||||||
const surcharges: Surcharge[] = [
|
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: 'BAF',
|
||||||
{ type: 'PSS', description: 'Peak Season', amount: quotation.charges?.peak_season || 0, currency: quotation.charges?.currency || 'USD' },
|
description: 'Bunker Surcharge',
|
||||||
{ type: 'THC', description: 'Terminal Handling', amount: quotation.charges?.thc || 0, currency: quotation.charges?.currency || 'USD' },
|
amount: quotation.charges?.bunker_surcharge || 0,
|
||||||
].filter((s) => s.amount > 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 baseFreight = quotation.charges?.ocean_freight || 0;
|
||||||
const totalSurcharges = surcharges.reduce((sum, s) => sum + s.amount, 0);
|
const totalSurcharges = surcharges.reduce((sum, s) => sum + s.amount, 0);
|
||||||
@ -53,7 +73,10 @@ export class CMACGMRequestMapper {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Transshipment ports
|
// 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) => {
|
quotation.routing.transshipment_ports.forEach((port: any) => {
|
||||||
route.push({
|
route.push({
|
||||||
portCode: port.code || port,
|
portCode: port.code || port,
|
||||||
@ -69,7 +92,12 @@ export class CMACGMRequestMapper {
|
|||||||
arrival: new Date(quotation.schedule?.arrival_date),
|
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({
|
return RateQuote.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
|||||||
@ -65,14 +65,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
||||||
this.csvDirectory = path.join(
|
this.csvDirectory = path.join(__dirname, '..', '..', 'storage', 'csv-storage', 'rates');
|
||||||
__dirname,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'storage',
|
|
||||||
'csv-storage',
|
|
||||||
'rates',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRatesFromCsv(filePath: string): Promise<CsvRate[]> {
|
async loadRatesFromCsv(filePath: string): Promise<CsvRate[]> {
|
||||||
@ -104,12 +97,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
return this.mapToCsvRate(record);
|
return this.mapToCsvRate(record);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(
|
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
|
||||||
`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`,
|
throw new Error(`Invalid data in row ${index + 1} of ${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(
|
async validateCsvFile(
|
||||||
filePath: string,
|
filePath: string
|
||||||
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
|
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
@ -205,7 +194,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = await fs.readdir(this.csvDirectory);
|
const files = await fs.readdir(this.csvDirectory);
|
||||||
return files.filter((file) => file.endsWith('.csv'));
|
return files.filter(file => file.endsWith('.csv'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
|
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
|
||||||
@ -243,14 +232,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstRecord = records[0];
|
const firstRecord = records[0];
|
||||||
const missingColumns = requiredColumns.filter(
|
const missingColumns = requiredColumns.filter(col => !(col in firstRecord));
|
||||||
(col) => !(col in firstRecord),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingColumns.length > 0) {
|
if (missingColumns.length > 0) {
|
||||||
throw new Error(
|
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
|
||||||
`Missing required columns: ${missingColumns.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,19 +269,13 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
{
|
{
|
||||||
pricePerCBM: parseFloat(record.pricePerCBM),
|
pricePerCBM: parseFloat(record.pricePerCBM),
|
||||||
pricePerKG: parseFloat(record.pricePerKG),
|
pricePerKG: parseFloat(record.pricePerKG),
|
||||||
basePriceUSD: Money.create(
|
basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'),
|
||||||
parseFloat(record.basePriceUSD),
|
basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'),
|
||||||
'USD',
|
|
||||||
),
|
|
||||||
basePriceEUR: Money.create(
|
|
||||||
parseFloat(record.basePriceEUR),
|
|
||||||
'EUR',
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
record.currency.toUpperCase(),
|
record.currency.toUpperCase(),
|
||||||
new SurchargeCollection(surcharges),
|
new SurchargeCollection(surcharges),
|
||||||
parseInt(record.transitDays, 10),
|
parseInt(record.transitDays, 10),
|
||||||
validity,
|
validity
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,8 +298,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
new Surcharge(
|
new Surcharge(
|
||||||
SurchargeType.BAF,
|
SurchargeType.BAF,
|
||||||
Money.create(parseFloat(record.surchargeBAF), currency),
|
Money.create(parseFloat(record.surchargeBAF), currency),
|
||||||
'Bunker Adjustment Factor',
|
'Bunker Adjustment Factor'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,8 +309,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
new Surcharge(
|
new Surcharge(
|
||||||
SurchargeType.CAF,
|
SurchargeType.CAF,
|
||||||
Money.create(parseFloat(record.surchargeCAF), currency),
|
Money.create(parseFloat(record.surchargeCAF), currency),
|
||||||
'Currency Adjustment Factor',
|
'Currency Adjustment Factor'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import {
|
import {
|
||||||
CarrierConnectorPort,
|
CarrierConnectorPort,
|
||||||
CarrierRateSearchInput,
|
CarrierRateSearchInput,
|
||||||
CarrierAvailabilityInput
|
CarrierAvailabilityInput,
|
||||||
} from '../../../domain/ports/out/carrier-connector.port';
|
} from '../../../domain/ports/out/carrier-connector.port';
|
||||||
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
||||||
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
||||||
@ -25,7 +25,7 @@ export class HapagLloydConnectorAdapter
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly requestMapper: HapagLloydRequestMapper,
|
private readonly requestMapper: HapagLloydRequestMapper
|
||||||
) {
|
) {
|
||||||
const config: CarrierConfig = {
|
const config: CarrierConfig = {
|
||||||
name: 'Hapag-Lloyd',
|
name: 'Hapag-Lloyd',
|
||||||
@ -91,7 +91,9 @@ export class HapagLloydConnectorAdapter
|
|||||||
|
|
||||||
return (response.data as any).available_capacity || 0;
|
return (response.data as any).available_capacity || 0;
|
||||||
} catch (error: any) {
|
} 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;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,7 +91,12 @@ export class HapagLloydRequestMapper {
|
|||||||
arrival: new Date(quote.estimated_time_of_arrival),
|
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({
|
return RateQuote.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export class MaerskResponseMapper {
|
|||||||
originCode: string,
|
originCode: string,
|
||||||
destinationCode: string
|
destinationCode: string
|
||||||
): RateQuote[] {
|
): 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,
|
originCode: string,
|
||||||
destinationCode: string
|
destinationCode: string
|
||||||
): RateQuote {
|
): RateQuote {
|
||||||
const surcharges = result.pricing.charges.map((charge) => ({
|
const surcharges = result.pricing.charges.map(charge => ({
|
||||||
type: charge.chargeCode,
|
type: charge.chargeCode,
|
||||||
description: charge.chargeName,
|
description: charge.chargeName,
|
||||||
amount: charge.amount,
|
amount: charge.amount,
|
||||||
currency: charge.currency,
|
currency: charge.currency,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const route = result.schedule.routeSchedule.map((segment) =>
|
const route = result.schedule.routeSchedule.map(segment => this.mapRouteSegment(segment));
|
||||||
this.mapRouteSegment(segment)
|
|
||||||
);
|
|
||||||
|
|
||||||
return RateQuote.create({
|
return RateQuote.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
|||||||
@ -9,23 +9,20 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import {
|
import {
|
||||||
CarrierConnectorPort,
|
CarrierConnectorPort,
|
||||||
CarrierRateSearchInput,
|
CarrierRateSearchInput,
|
||||||
CarrierAvailabilityInput
|
CarrierAvailabilityInput,
|
||||||
} from '../../../domain/ports/out/carrier-connector.port';
|
} from '../../../domain/ports/out/carrier-connector.port';
|
||||||
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
||||||
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
||||||
import { MSCRequestMapper } from './msc.mapper';
|
import { MSCRequestMapper } from './msc.mapper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MSCConnectorAdapter
|
export class MSCConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort {
|
||||||
extends BaseCarrierConnector
|
|
||||||
implements CarrierConnectorPort
|
|
||||||
{
|
|
||||||
private readonly apiUrl: string;
|
private readonly apiUrl: string;
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly requestMapper: MSCRequestMapper,
|
private readonly requestMapper: MSCRequestMapper
|
||||||
) {
|
) {
|
||||||
const config: CarrierConfig = {
|
const config: CarrierConfig = {
|
||||||
name: 'MSC',
|
name: 'MSC',
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export class MSCRequestMapper {
|
|||||||
amount: quote.surcharges?.pss || 0,
|
amount: quote.surcharges?.pss || 0,
|
||||||
currency: quote.currency || 'USD',
|
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 totalSurcharges = surcharges.reduce((sum, s) => sum + s.amount, 0);
|
||||||
const baseFreight = quote.ocean_freight || 0;
|
const baseFreight = quote.ocean_freight || 0;
|
||||||
|
|||||||
@ -9,24 +9,21 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import {
|
import {
|
||||||
CarrierConnectorPort,
|
CarrierConnectorPort,
|
||||||
CarrierRateSearchInput,
|
CarrierRateSearchInput,
|
||||||
CarrierAvailabilityInput
|
CarrierAvailabilityInput,
|
||||||
} from '../../../domain/ports/out/carrier-connector.port';
|
} from '../../../domain/ports/out/carrier-connector.port';
|
||||||
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
import { RateQuote } from '../../../domain/entities/rate-quote.entity';
|
||||||
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
import { BaseCarrierConnector, CarrierConfig } from '../base-carrier.connector';
|
||||||
import { ONERequestMapper } from './one.mapper';
|
import { ONERequestMapper } from './one.mapper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ONEConnectorAdapter
|
export class ONEConnectorAdapter extends BaseCarrierConnector implements CarrierConnectorPort {
|
||||||
extends BaseCarrierConnector
|
|
||||||
implements CarrierConnectorPort
|
|
||||||
{
|
|
||||||
private readonly apiUrl: string;
|
private readonly apiUrl: string;
|
||||||
private readonly username: string;
|
private readonly username: string;
|
||||||
private readonly password: string;
|
private readonly password: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly requestMapper: ONERequestMapper,
|
private readonly requestMapper: ONERequestMapper
|
||||||
) {
|
) {
|
||||||
const config: CarrierConfig = {
|
const config: CarrierConfig = {
|
||||||
name: 'ONE',
|
name: 'ONE',
|
||||||
|
|||||||
@ -78,7 +78,8 @@ export class ONERequestMapper {
|
|||||||
arrival: new Date(quote.arrival_date),
|
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({
|
return RateQuote.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@ -130,7 +131,7 @@ export class ONERequestMapper {
|
|||||||
private formatChargeName(key: string): string {
|
private formatChargeName(key: string): string {
|
||||||
return key
|
return key
|
||||||
.split('_')
|
.split('_')
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,10 +7,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import {
|
import { EmailPort, EmailOptions } from '../../domain/ports/out/email.port';
|
||||||
EmailPort,
|
|
||||||
EmailOptions,
|
|
||||||
} from '../../domain/ports/out/email.port';
|
|
||||||
import { EmailTemplates } from './templates/email-templates';
|
import { EmailTemplates } from './templates/email-templates';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -39,17 +36,12 @@ export class EmailAdapter implements EmailPort {
|
|||||||
auth: user && pass ? { user, pass } : undefined,
|
auth: user && pass ? { user, pass } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Email adapter initialized with SMTP host: ${host}:${port}`);
|
||||||
`Email adapter initialized with SMTP host: ${host}:${port}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(options: EmailOptions): Promise<void> {
|
async send(options: EmailOptions): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const from = this.configService.get<string>(
|
const from = this.configService.get<string>('SMTP_FROM', 'noreply@xpeditis.com');
|
||||||
'SMTP_FROM',
|
|
||||||
'noreply@xpeditis.com'
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.transporter.sendMail({
|
await this.transporter.sendMail({
|
||||||
from,
|
from,
|
||||||
|
|||||||
@ -155,10 +155,7 @@ export class EmailTemplates {
|
|||||||
/**
|
/**
|
||||||
* Render welcome email
|
* Render welcome email
|
||||||
*/
|
*/
|
||||||
async renderWelcomeEmail(data: {
|
async renderWelcomeEmail(data: { firstName: string; dashboardUrl: string }): Promise<string> {
|
||||||
firstName: string;
|
|
||||||
dashboardUrl: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const mjmlTemplate = `
|
const mjmlTemplate = `
|
||||||
<mjml>
|
<mjml>
|
||||||
<mj-head>
|
<mj-head>
|
||||||
|
|||||||
@ -23,9 +23,7 @@ export function initializeSentry(config: SentryConfig): void {
|
|||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: config.dsn,
|
dsn: config.dsn,
|
||||||
environment: config.environment,
|
environment: config.environment,
|
||||||
integrations: [
|
integrations: [nodeProfilingIntegration()],
|
||||||
nodeProfilingIntegration(),
|
|
||||||
],
|
|
||||||
// Performance Monitoring
|
// Performance Monitoring
|
||||||
tracesSampleRate: config.tracesSampleRate,
|
tracesSampleRate: config.tracesSampleRate,
|
||||||
// Profiling
|
// Profiling
|
||||||
@ -58,9 +56,7 @@ export function initializeSentry(config: SentryConfig): void {
|
|||||||
maxBreadcrumbs: 50,
|
maxBreadcrumbs: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(`✅ Sentry monitoring initialized for ${config.environment} environment`);
|
||||||
`✅ 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>) {
|
export function captureException(error: Error, context?: Record<string, any>) {
|
||||||
if (context) {
|
if (context) {
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope(scope => {
|
||||||
Object.entries(context).forEach(([key, value]) => {
|
Object.entries(context).forEach(([key, value]) => {
|
||||||
scope.setExtra(key, value);
|
scope.setExtra(key, value);
|
||||||
});
|
});
|
||||||
@ -85,10 +81,10 @@ export function captureException(error: Error, context?: Record<string, any>) {
|
|||||||
export function captureMessage(
|
export function captureMessage(
|
||||||
message: string,
|
message: string,
|
||||||
level: Sentry.SeverityLevel = 'info',
|
level: Sentry.SeverityLevel = 'info',
|
||||||
context?: Record<string, any>,
|
context?: Record<string, any>
|
||||||
) {
|
) {
|
||||||
if (context) {
|
if (context) {
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope(scope => {
|
||||||
Object.entries(context).forEach(([key, value]) => {
|
Object.entries(context).forEach(([key, value]) => {
|
||||||
scope.setExtra(key, value);
|
scope.setExtra(key, value);
|
||||||
});
|
});
|
||||||
@ -106,7 +102,7 @@ export function addBreadcrumb(
|
|||||||
category: string,
|
category: string,
|
||||||
message: string,
|
message: string,
|
||||||
data?: Record<string, any>,
|
data?: Record<string, any>,
|
||||||
level: Sentry.SeverityLevel = 'info',
|
level: Sentry.SeverityLevel = 'info'
|
||||||
) {
|
) {
|
||||||
Sentry.addBreadcrumb({
|
Sentry.addBreadcrumb({
|
||||||
category,
|
category,
|
||||||
|
|||||||
@ -24,17 +24,12 @@ export class PdfAdapter implements PdfPort {
|
|||||||
doc.on('data', buffers.push.bind(buffers));
|
doc.on('data', buffers.push.bind(buffers));
|
||||||
doc.on('end', () => {
|
doc.on('end', () => {
|
||||||
const pdfBuffer = Buffer.concat(buffers);
|
const pdfBuffer = Buffer.concat(buffers);
|
||||||
this.logger.log(
|
this.logger.log(`Generated booking confirmation PDF for ${data.bookingNumber}`);
|
||||||
`Generated booking confirmation PDF for ${data.bookingNumber}`
|
|
||||||
);
|
|
||||||
resolve(pdfBuffer);
|
resolve(pdfBuffer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
doc
|
doc.fontSize(24).fillColor('#0066cc').text('BOOKING CONFIRMATION', { align: 'center' });
|
||||||
.fontSize(24)
|
|
||||||
.fillColor('#0066cc')
|
|
||||||
.text('BOOKING CONFIRMATION', { align: 'center' });
|
|
||||||
|
|
||||||
doc.moveDown();
|
doc.moveDown();
|
||||||
|
|
||||||
@ -60,9 +55,7 @@ export class PdfAdapter implements PdfPort {
|
|||||||
|
|
||||||
doc.fontSize(12).fillColor('#333333');
|
doc.fontSize(12).fillColor('#333333');
|
||||||
doc.text(`Origin: ${data.origin.name} (${data.origin.code})`);
|
doc.text(`Origin: ${data.origin.name} (${data.origin.code})`);
|
||||||
doc.text(
|
doc.text(`Destination: ${data.destination.name} (${data.destination.code})`);
|
||||||
`Destination: ${data.destination.name} (${data.destination.code})`
|
|
||||||
);
|
|
||||||
doc.text(`Carrier: ${data.carrier.name}`);
|
doc.text(`Carrier: ${data.carrier.name}`);
|
||||||
doc.text(`ETD: ${data.etd.toLocaleDateString()}`);
|
doc.text(`ETD: ${data.etd.toLocaleDateString()}`);
|
||||||
doc.text(`ETA: ${data.eta.toLocaleDateString()}`);
|
doc.text(`ETA: ${data.eta.toLocaleDateString()}`);
|
||||||
@ -105,9 +98,7 @@ export class PdfAdapter implements PdfPort {
|
|||||||
|
|
||||||
doc.fontSize(12).fillColor('#333333');
|
doc.fontSize(12).fillColor('#333333');
|
||||||
data.containers.forEach((container, index) => {
|
data.containers.forEach((container, index) => {
|
||||||
doc.text(
|
doc.text(`${index + 1}. Type: ${container.type} | Quantity: ${container.quantity}`);
|
||||||
`${index + 1}. Type: ${container.type} | Quantity: ${container.quantity}`
|
|
||||||
);
|
|
||||||
if (container.containerNumber) {
|
if (container.containerNumber) {
|
||||||
doc.text(` Container #: ${container.containerNumber}`);
|
doc.text(` Container #: ${container.containerNumber}`);
|
||||||
}
|
}
|
||||||
@ -127,16 +118,10 @@ export class PdfAdapter implements PdfPort {
|
|||||||
|
|
||||||
if (data.specialInstructions) {
|
if (data.specialInstructions) {
|
||||||
doc.moveDown();
|
doc.moveDown();
|
||||||
doc
|
doc.fontSize(14).fillColor('#0066cc').text('Special Instructions');
|
||||||
.fontSize(14)
|
|
||||||
.fillColor('#0066cc')
|
|
||||||
.text('Special Instructions');
|
|
||||||
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
||||||
doc.moveDown();
|
doc.moveDown();
|
||||||
doc
|
doc.fontSize(12).fillColor('#333333').text(data.specialInstructions);
|
||||||
.fontSize(12)
|
|
||||||
.fillColor('#333333')
|
|
||||||
.text(data.specialInstructions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.moveDown(2);
|
doc.moveDown(2);
|
||||||
@ -149,10 +134,9 @@ export class PdfAdapter implements PdfPort {
|
|||||||
doc
|
doc
|
||||||
.fontSize(16)
|
.fontSize(16)
|
||||||
.fillColor('#333333')
|
.fillColor('#333333')
|
||||||
.text(
|
.text(`${data.price.currency} ${data.price.amount.toLocaleString()}`, {
|
||||||
`${data.price.currency} ${data.price.amount.toLocaleString()}`,
|
align: 'center',
|
||||||
{ align: 'center' }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
doc.moveDown(3);
|
doc.moveDown(3);
|
||||||
|
|
||||||
@ -160,10 +144,7 @@ export class PdfAdapter implements PdfPort {
|
|||||||
doc
|
doc
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.fillColor('#666666')
|
.fillColor('#666666')
|
||||||
.text(
|
.text('This is a system-generated document. No signature required.', { align: 'center' });
|
||||||
'This is a system-generated document. No signature required.',
|
|
||||||
{ align: 'center' }
|
|
||||||
);
|
|
||||||
|
|
||||||
doc.text('© 2025 Xpeditis. All rights reserved.', { align: 'center' });
|
doc.text('© 2025 Xpeditis. All rights reserved.', { align: 'center' });
|
||||||
|
|
||||||
@ -193,10 +174,7 @@ export class PdfAdapter implements PdfPort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
doc
|
doc.fontSize(20).fillColor('#0066cc').text('RATE QUOTE COMPARISON', { align: 'center' });
|
||||||
.fontSize(20)
|
|
||||||
.fillColor('#0066cc')
|
|
||||||
.text('RATE QUOTE COMPARISON', { align: 'center' });
|
|
||||||
|
|
||||||
doc.moveDown(2);
|
doc.moveDown(2);
|
||||||
|
|
||||||
@ -210,20 +188,18 @@ export class PdfAdapter implements PdfPort {
|
|||||||
doc.text('ETA', 430, startY, { width: 80 });
|
doc.text('ETA', 430, startY, { width: 80 });
|
||||||
doc.text('Route', 520, startY, { width: 200 });
|
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();
|
doc.moveDown();
|
||||||
|
|
||||||
// Table Rows
|
// Table Rows
|
||||||
doc.fontSize(9).fillColor('#333333');
|
doc.fontSize(9).fillColor('#333333');
|
||||||
quotes.forEach((quote) => {
|
quotes.forEach(quote => {
|
||||||
const rowY = doc.y;
|
const rowY = doc.y;
|
||||||
doc.text(quote.carrier.name, 50, rowY, { width: 100 });
|
doc.text(quote.carrier.name, 50, rowY, { width: 100 });
|
||||||
doc.text(
|
doc.text(`${quote.price.currency} ${quote.price.amount}`, 160, rowY, { width: 80 });
|
||||||
`${quote.price.currency} ${quote.price.amount}`,
|
|
||||||
160,
|
|
||||||
rowY,
|
|
||||||
{ width: 80 }
|
|
||||||
);
|
|
||||||
doc.text(quote.transitDays.toString(), 250, rowY, { width: 80 });
|
doc.text(quote.transitDays.toString(), 250, rowY, { width: 80 });
|
||||||
doc.text(new Date(quote.etd).toLocaleDateString(), 340, rowY, {
|
doc.text(new Date(quote.etd).toLocaleDateString(), 340, rowY, {
|
||||||
width: 80,
|
width: 80,
|
||||||
@ -240,10 +216,7 @@ export class PdfAdapter implements PdfPort {
|
|||||||
doc.moveDown(2);
|
doc.moveDown(2);
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
doc
|
doc.fontSize(10).fillColor('#666666').text('Generated by Xpeditis', { align: 'center' });
|
||||||
.fontSize(10)
|
|
||||||
.fillColor('#666666')
|
|
||||||
.text('Generated by Xpeditis', { align: 'center' });
|
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export class BookingOrmEntity {
|
|||||||
@Column({ name: 'cargo_description', type: 'text' })
|
@Column({ name: 'cargo_description', type: 'text' })
|
||||||
cargoDescription: string;
|
cargoDescription: string;
|
||||||
|
|
||||||
@OneToMany(() => ContainerOrmEntity, (container) => container.booking, {
|
@OneToMany(() => ContainerOrmEntity, container => container.booking, {
|
||||||
cascade: true,
|
cascade: true,
|
||||||
eager: true,
|
eager: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,14 +4,7 @@
|
|||||||
* TypeORM entity for container persistence
|
* TypeORM entity for container persistence
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
Entity,
|
|
||||||
Column,
|
|
||||||
PrimaryColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { BookingOrmEntity } from './booking.orm-entity';
|
import { BookingOrmEntity } from './booking.orm-entity';
|
||||||
|
|
||||||
@Entity('containers')
|
@Entity('containers')
|
||||||
@ -24,7 +17,7 @@ export class ContainerOrmEntity {
|
|||||||
@Column({ name: 'booking_id', type: 'uuid' })
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|
||||||
@ManyToOne(() => BookingOrmEntity, (booking) => booking.containers, {
|
@ManyToOne(() => BookingOrmEntity, booking => booking.containers, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'booking_id' })
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
|||||||
@ -2,13 +2,7 @@
|
|||||||
* Notification ORM Entity
|
* Notification ORM Entity
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('notifications')
|
@Entity('notifications')
|
||||||
@Index(['user_id', 'read', 'created_at'])
|
@Index(['user_id', 'read', 'created_at'])
|
||||||
|
|||||||
@ -2,14 +2,7 @@
|
|||||||
* Webhook ORM Entity
|
* Webhook ORM Entity
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('webhooks')
|
@Entity('webhooks')
|
||||||
@Index(['organization_id', 'status'])
|
@Index(['organization_id', 'status'])
|
||||||
|
|||||||
@ -12,10 +12,7 @@ import {
|
|||||||
} from '../../../../domain/entities/booking.entity';
|
} from '../../../../domain/entities/booking.entity';
|
||||||
import { BookingNumber } from '../../../../domain/value-objects/booking-number.vo';
|
import { BookingNumber } from '../../../../domain/value-objects/booking-number.vo';
|
||||||
import { BookingStatus } from '../../../../domain/value-objects/booking-status.vo';
|
import { BookingStatus } from '../../../../domain/value-objects/booking-status.vo';
|
||||||
import {
|
import { BookingOrmEntity, PartyJson } from '../entities/booking.orm-entity';
|
||||||
BookingOrmEntity,
|
|
||||||
PartyJson,
|
|
||||||
} from '../entities/booking.orm-entity';
|
|
||||||
import { ContainerOrmEntity } from '../entities/container.orm-entity';
|
import { ContainerOrmEntity } from '../entities/container.orm-entity';
|
||||||
|
|
||||||
export class BookingOrmMapper {
|
export class BookingOrmMapper {
|
||||||
@ -39,9 +36,7 @@ export class BookingOrmMapper {
|
|||||||
orm.updatedAt = domain.updatedAt;
|
orm.updatedAt = domain.updatedAt;
|
||||||
|
|
||||||
// Map containers
|
// Map containers
|
||||||
orm.containers = domain.containers.map((container) =>
|
orm.containers = domain.containers.map(container => this.containerToOrm(container, domain.id));
|
||||||
this.containerToOrm(container, domain.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return orm;
|
return orm;
|
||||||
}
|
}
|
||||||
@ -60,9 +55,7 @@ export class BookingOrmMapper {
|
|||||||
shipper: this.jsonToParty(orm.shipper),
|
shipper: this.jsonToParty(orm.shipper),
|
||||||
consignee: this.jsonToParty(orm.consignee),
|
consignee: this.jsonToParty(orm.consignee),
|
||||||
cargoDescription: orm.cargoDescription,
|
cargoDescription: orm.cargoDescription,
|
||||||
containers: orm.containers
|
containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [],
|
||||||
? orm.containers.map((c) => this.ormToContainer(c))
|
|
||||||
: [],
|
|
||||||
specialInstructions: orm.specialInstructions || undefined,
|
specialInstructions: orm.specialInstructions || undefined,
|
||||||
createdAt: orm.createdAt,
|
createdAt: orm.createdAt,
|
||||||
updatedAt: orm.updatedAt,
|
updatedAt: orm.updatedAt,
|
||||||
@ -79,7 +72,7 @@ export class BookingOrmMapper {
|
|||||||
* Map array of ORM entities to domain entities
|
* Map array of ORM entities to domain entities
|
||||||
*/
|
*/
|
||||||
static toDomainMany(orms: BookingOrmEntity[]): Booking[] {
|
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
|
* Map array of ORM entities to domain entities
|
||||||
*/
|
*/
|
||||||
static toDomainMany(orms: CarrierOrmEntity[]): Carrier[] {
|
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
|
* Map array of ORM entities to domain entities
|
||||||
*/
|
*/
|
||||||
static toDomainMany(orms: OrganizationOrmEntity[]): Organization[] {
|
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
|
* Map array of ORM entities to domain entities
|
||||||
*/
|
*/
|
||||||
static toDomainMany(orms: PortOrmEntity[]): Port[] {
|
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
|
* Map array of ORM entities to domain entities
|
||||||
*/
|
*/
|
||||||
static toDomainMany(orms: RateQuoteOrmEntity[]): RateQuote[] {
|
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
|
* Map array of ORM entities to domain entities
|
||||||
*/
|
*/
|
||||||
static toDomainMany(orms: UserOrmEntity[]): User[] {
|
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
|
// Create indexes for efficient querying
|
||||||
@ -95,7 +95,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface {
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_audit_logs_organization_timestamp',
|
name: 'idx_audit_logs_organization_timestamp',
|
||||||
columnNames: ['organization_id', 'timestamp'],
|
columnNames: ['organization_id', 'timestamp'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryRunner.createIndex(
|
await queryRunner.createIndex(
|
||||||
@ -103,7 +103,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface {
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_audit_logs_user_timestamp',
|
name: 'idx_audit_logs_user_timestamp',
|
||||||
columnNames: ['user_id', 'timestamp'],
|
columnNames: ['user_id', 'timestamp'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryRunner.createIndex(
|
await queryRunner.createIndex(
|
||||||
@ -111,7 +111,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface {
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_audit_logs_resource',
|
name: 'idx_audit_logs_resource',
|
||||||
columnNames: ['resource_type', 'resource_id'],
|
columnNames: ['resource_type', 'resource_id'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryRunner.createIndex(
|
await queryRunner.createIndex(
|
||||||
@ -119,7 +119,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface {
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_audit_logs_action',
|
name: 'idx_audit_logs_action',
|
||||||
columnNames: ['action'],
|
columnNames: ['action'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryRunner.createIndex(
|
await queryRunner.createIndex(
|
||||||
@ -127,7 +127,7 @@ export class CreateAuditLogsTable1700000001000 implements MigrationInterface {
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_audit_logs_timestamp',
|
name: 'idx_audit_logs_timestamp',
|
||||||
columnNames: ['timestamp'],
|
columnNames: ['timestamp'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
true,
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create indexes for efficient querying
|
// Create indexes for efficient querying
|
||||||
@ -83,7 +83,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_notifications_user_read_created',
|
name: 'idx_notifications_user_read_created',
|
||||||
columnNames: ['user_id', 'read', 'created_at'],
|
columnNames: ['user_id', 'read', 'created_at'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryRunner.createIndex(
|
await queryRunner.createIndex(
|
||||||
@ -91,7 +91,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_notifications_organization_created',
|
name: 'idx_notifications_organization_created',
|
||||||
columnNames: ['organization_id', 'created_at'],
|
columnNames: ['organization_id', 'created_at'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryRunner.createIndex(
|
await queryRunner.createIndex(
|
||||||
@ -99,7 +99,7 @@ export class CreateNotificationsTable1700000002000 implements MigrationInterface
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_notifications_user_created',
|
name: 'idx_notifications_user_created',
|
||||||
columnNames: ['user_id', 'created_at'],
|
columnNames: ['user_id', 'created_at'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export class CreateWebhooksTable1700000003000 implements MigrationInterface {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
true,
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create index for efficient querying
|
// Create index for efficient querying
|
||||||
@ -89,7 +89,7 @@ export class CreateWebhooksTable1700000003000 implements MigrationInterface {
|
|||||||
new TableIndex({
|
new TableIndex({
|
||||||
name: 'idx_webhooks_organization_status',
|
name: 'idx_webhooks_organization_status',
|
||||||
columnNames: ['organization_id', 'status'],
|
columnNames: ['organization_id', 'status'],
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,11 @@ export class SeedCarriersAndOrganizations1730000000006 implements MigrationInter
|
|||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
// Delete seeded data
|
// Delete seeded data
|
||||||
await queryRunner.query(`DELETE FROM "carriers" WHERE "code" IN ('MAERSK', 'MSC', 'CMA_CGM', 'HAPAG_LLOYD', 'ONE')`);
|
await queryRunner.query(
|
||||||
await queryRunner.query(`DELETE FROM "organizations" WHERE "name" IN ('Test Freight Forwarder Inc.', 'Demo Shipping Company', 'Sample Shipper Ltd.')`);
|
`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) {
|
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;
|
const organizationId = result[0].id;
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { AuditLogOrmEntity } from '../entities/audit-log.orm-entity';
|
|||||||
export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AuditLogOrmEntity)
|
@InjectRepository(AuditLogOrmEntity)
|
||||||
private readonly ormRepository: Repository<AuditLogOrmEntity>,
|
private readonly ormRepository: Repository<AuditLogOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async save(auditLog: AuditLog): Promise<void> {
|
async save(auditLog: AuditLog): Promise<void> {
|
||||||
@ -77,7 +77,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ormEntities = await query.getMany();
|
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> {
|
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[]> {
|
async findRecentByOrganization(organizationId: string, limit: number): Promise<AuditLog[]> {
|
||||||
@ -145,7 +145,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ormEntities.map((e) => this.toDomain(e));
|
return ormEntities.map(e => this.toDomain(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUser(userId: string, limit: number): Promise<AuditLog[]> {
|
async findByUser(userId: string, limit: number): Promise<AuditLog[]> {
|
||||||
@ -159,7 +159,7 @@ export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
|||||||
take: limit,
|
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[]> {
|
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);
|
const saved = await this.repository.save(orms);
|
||||||
return CarrierOrmMapper.toDomainMany(saved);
|
return CarrierOrmMapper.toDomainMany(saved);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(CsvRateConfigOrmEntity)
|
@InjectRepository(CsvRateConfigOrmEntity)
|
||||||
private readonly repository: Repository<CsvRateConfigOrmEntity>,
|
private readonly repository: Repository<CsvRateConfigOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,7 +90,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
|||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
config: Partial<CsvRateConfigOrmEntity>,
|
config: Partial<CsvRateConfigOrmEntity>
|
||||||
): Promise<CsvRateConfigOrmEntity> {
|
): Promise<CsvRateConfigOrmEntity> {
|
||||||
this.logger.log(`Updating CSV rate config: ${id}`);
|
this.logger.log(`Updating CSV rate config: ${id}`);
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
|||||||
async updateValidationInfo(
|
async updateValidationInfo(
|
||||||
companyName: string,
|
companyName: string,
|
||||||
rowCount: number,
|
rowCount: number,
|
||||||
validationResult: { valid: boolean; errors: string[] },
|
validationResult: { valid: boolean; errors: string[] }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`Updating validation info for company: ${companyName}`);
|
this.logger.log(`Updating validation info for company: ${companyName}`);
|
||||||
|
|
||||||
@ -159,7 +159,7 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { NotificationOrmEntity } from '../entities/notification.orm-entity';
|
|||||||
export class TypeOrmNotificationRepository implements NotificationRepository {
|
export class TypeOrmNotificationRepository implements NotificationRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(NotificationOrmEntity)
|
@InjectRepository(NotificationOrmEntity)
|
||||||
private readonly ormRepository: Repository<NotificationOrmEntity>,
|
private readonly ormRepository: Repository<NotificationOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async save(notification: Notification): Promise<void> {
|
async save(notification: Notification): Promise<void> {
|
||||||
@ -79,7 +79,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ormEntities = await query.getMany();
|
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> {
|
async count(filters: NotificationFilters): Promise<number> {
|
||||||
@ -131,7 +131,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository {
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ormEntities.map((e) => this.toDomain(e));
|
return ormEntities.map(e => this.toDomain(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUnreadByUser(userId: string): Promise<number> {
|
async countUnreadByUser(userId: string): Promise<number> {
|
||||||
@ -147,7 +147,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository {
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ormEntities.map((e) => this.toDomain(e));
|
return ormEntities.map(e => this.toDomain(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRead(id: string): Promise<void> {
|
async markAsRead(id: string): Promise<void> {
|
||||||
@ -163,7 +163,7 @@ export class TypeOrmNotificationRepository implements NotificationRepository {
|
|||||||
{
|
{
|
||||||
read: true,
|
read: true,
|
||||||
read_at: new Date(),
|
read_at: new Date(),
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export class TypeOrmPortRepository implements PortRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveMany(ports: Port[]): Promise<Port[]> {
|
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);
|
const saved = await this.repository.save(orms);
|
||||||
return PortOrmMapper.toDomainMany(saved);
|
return PortOrmMapper.toDomainMany(saved);
|
||||||
}
|
}
|
||||||
@ -39,7 +39,7 @@ export class TypeOrmPortRepository implements PortRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findByCodes(codes: string[]): Promise<Port[]> {
|
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
|
const orms = await this.repository
|
||||||
.createQueryBuilder('port')
|
.createQueryBuilder('port')
|
||||||
.where('port.code IN (:...codes)', { codes: upperCodes })
|
.where('port.code IN (:...codes)', { codes: upperCodes })
|
||||||
@ -54,14 +54,11 @@ export class TypeOrmPortRepository implements PortRepository {
|
|||||||
|
|
||||||
// Fuzzy search using pg_trgm (trigram similarity)
|
// Fuzzy search using pg_trgm (trigram similarity)
|
||||||
// First try exact match on code
|
// First try exact match on code
|
||||||
qb.andWhere(
|
qb.andWhere('(port.code ILIKE :code OR port.name ILIKE :name OR port.city ILIKE :city)', {
|
||||||
'(port.code ILIKE :code OR port.name ILIKE :name OR port.city ILIKE :city)',
|
code: `${query}%`,
|
||||||
{
|
name: `%${query}%`,
|
||||||
code: `${query}%`,
|
city: `%${query}%`,
|
||||||
name: `%${query}%`,
|
});
|
||||||
city: `%${query}%`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (countryFilter) {
|
if (countryFilter) {
|
||||||
qb.andWhere('port.country = :country', { country: countryFilter.toUpperCase() });
|
qb.andWhere('port.country = :country', { country: countryFilter.toUpperCase() });
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export class TypeOrmRateQuoteRepository implements RateQuoteRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveMany(rateQuotes: RateQuote[]): Promise<RateQuote[]> {
|
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);
|
const saved = await this.repository.save(orms);
|
||||||
return RateQuoteOrmMapper.toDomainMany(saved);
|
return RateQuoteOrmMapper.toDomainMany(saved);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import {
|
import { WebhookRepository, WebhookFilters } from '../../../../domain/ports/out/webhook.repository';
|
||||||
WebhookRepository,
|
|
||||||
WebhookFilters,
|
|
||||||
} from '../../../../domain/ports/out/webhook.repository';
|
|
||||||
import { Webhook, WebhookEvent, WebhookStatus } from '../../../../domain/entities/webhook.entity';
|
import { Webhook, WebhookEvent, WebhookStatus } from '../../../../domain/entities/webhook.entity';
|
||||||
import { WebhookOrmEntity } from '../entities/webhook.orm-entity';
|
import { WebhookOrmEntity } from '../entities/webhook.orm-entity';
|
||||||
|
|
||||||
@ -16,7 +13,7 @@ import { WebhookOrmEntity } from '../entities/webhook.orm-entity';
|
|||||||
export class TypeOrmWebhookRepository implements WebhookRepository {
|
export class TypeOrmWebhookRepository implements WebhookRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WebhookOrmEntity)
|
@InjectRepository(WebhookOrmEntity)
|
||||||
private readonly ormRepository: Repository<WebhookOrmEntity>,
|
private readonly ormRepository: Repository<WebhookOrmEntity>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async save(webhook: Webhook): Promise<void> {
|
async save(webhook: Webhook): Promise<void> {
|
||||||
@ -35,7 +32,7 @@ export class TypeOrmWebhookRepository implements WebhookRepository {
|
|||||||
order: { created_at: 'DESC' },
|
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[]> {
|
async findActiveByEvent(event: WebhookEvent, organizationId: string): Promise<Webhook[]> {
|
||||||
@ -46,7 +43,7 @@ export class TypeOrmWebhookRepository implements WebhookRepository {
|
|||||||
.andWhere(':event = ANY(webhook.events)', { event })
|
.andWhere(':event = ANY(webhook.events)', { event })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
return ormEntities.map((e) => this.toDomain(e));
|
return ormEntities.map(e => this.toDomain(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByFilters(filters: WebhookFilters): Promise<Webhook[]> {
|
async findByFilters(filters: WebhookFilters): Promise<Webhook[]> {
|
||||||
@ -69,7 +66,7 @@ export class TypeOrmWebhookRepository implements WebhookRepository {
|
|||||||
query.orderBy('webhook.created_at', 'DESC');
|
query.orderBy('webhook.created_at', 'DESC');
|
||||||
|
|
||||||
const ormEntities = await query.getMany();
|
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> {
|
async delete(id: string): Promise<void> {
|
||||||
|
|||||||
@ -76,7 +76,7 @@ export const carrierSeeds: CarrierSeed[] = [
|
|||||||
export function getCarriersInsertSQL(): string {
|
export function getCarriersInsertSQL(): string {
|
||||||
const values = carrierSeeds
|
const values = carrierSeeds
|
||||||
.map(
|
.map(
|
||||||
(carrier) =>
|
carrier =>
|
||||||
`('${carrier.id}', '${carrier.name}', '${carrier.code}', '${carrier.scac}', ` +
|
`('${carrier.id}', '${carrier.name}', '${carrier.code}', '${carrier.scac}', ` +
|
||||||
`'${carrier.logoUrl}', '${carrier.website}', NULL, ${carrier.isActive}, ${carrier.supportsApi}, NOW(), NOW())`
|
`'${carrier.logoUrl}', '${carrier.website}', NULL, ${carrier.isActive}, ${carrier.supportsApi}, NOW(), NOW())`
|
||||||
)
|
)
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export const organizationSeeds: OrganizationSeed[] = [
|
|||||||
export function getOrganizationsInsertSQL(): string {
|
export function getOrganizationsInsertSQL(): string {
|
||||||
const values = organizationSeeds
|
const values = organizationSeeds
|
||||||
.map(
|
.map(
|
||||||
(org) =>
|
org =>
|
||||||
`('${org.id}', '${org.name}', '${org.type}', ` +
|
`('${org.id}', '${org.name}', '${org.type}', ` +
|
||||||
`${org.scac ? `'${org.scac}'` : 'NULL'}, ` +
|
`${org.scac ? `'${org.scac}'` : 'NULL'}, ` +
|
||||||
`'${org.addressStreet}', '${org.addressCity}', ` +
|
`'${org.addressStreet}', '${org.addressCity}', ` +
|
||||||
|
|||||||
@ -102,12 +102,7 @@ export const corsConfig = {
|
|||||||
origin: process.env.FRONTEND_URL || ['http://localhost:3000', 'http://localhost:3001'],
|
origin: process.env.FRONTEND_URL || ['http://localhost:3000', 'http://localhost:3001'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: [
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
|
||||||
'Content-Type',
|
|
||||||
'Authorization',
|
|
||||||
'X-Requested-With',
|
|
||||||
'X-CSRF-Token',
|
|
||||||
],
|
|
||||||
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
|
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
|
||||||
maxAge: 86400, // 24 hours
|
maxAge: 86400, // 24 hours
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,11 +22,7 @@ import { rateLimitConfig } from './security.config';
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [FileValidationService, BruteForceProtectionService, CustomThrottlerGuard],
|
||||||
FileValidationService,
|
|
||||||
BruteForceProtectionService,
|
|
||||||
CustomThrottlerGuard,
|
|
||||||
],
|
|
||||||
exports: [
|
exports: [
|
||||||
FileValidationService,
|
FileValidationService,
|
||||||
BruteForceProtectionService,
|
BruteForceProtectionService,
|
||||||
|
|||||||
@ -36,9 +36,7 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
const region = this.configService.get<string>('AWS_REGION', 'us-east-1');
|
const region = this.configService.get<string>('AWS_REGION', 'us-east-1');
|
||||||
const endpoint = this.configService.get<string>('AWS_S3_ENDPOINT');
|
const endpoint = this.configService.get<string>('AWS_S3_ENDPOINT');
|
||||||
const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY_ID');
|
const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY_ID');
|
||||||
const secretAccessKey = this.configService.get<string>(
|
const secretAccessKey = this.configService.get<string>('AWS_SECRET_ACCESS_KEY');
|
||||||
'AWS_SECRET_ACCESS_KEY'
|
|
||||||
);
|
|
||||||
|
|
||||||
this.s3Client = new S3Client({
|
this.s3Client = new S3Client({
|
||||||
region,
|
region,
|
||||||
@ -73,9 +71,7 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
|
|
||||||
const url = this.buildUrl(options.bucket, options.key);
|
const url = this.buildUrl(options.bucket, options.key);
|
||||||
const size =
|
const size =
|
||||||
typeof options.body === 'string'
|
typeof options.body === 'string' ? Buffer.byteLength(options.body) : options.body.length;
|
||||||
? Buffer.byteLength(options.body)
|
|
||||||
: options.body.length;
|
|
||||||
|
|
||||||
this.logger.log(`Uploaded file to S3: ${options.key}`);
|
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}`);
|
this.logger.log(`Downloaded file from S3: ${options.key}`);
|
||||||
return Buffer.concat(chunks);
|
return Buffer.concat(chunks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(`Failed to download file from S3: ${options.key}`, error);
|
||||||
`Failed to download file from S3: ${options.key}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -132,10 +125,7 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSignedUrl(
|
async getSignedUrl(options: DownloadOptions, expiresIn: number = 3600): Promise<string> {
|
||||||
options: DownloadOptions,
|
|
||||||
expiresIn: number = 3600
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
try {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: options.bucket,
|
Bucket: options.bucket,
|
||||||
@ -143,15 +133,10 @@ export class S3StorageAdapter implements StoragePort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||||
this.logger.log(
|
this.logger.log(`Generated signed URL for: ${options.key} (expires in ${expiresIn}s)`);
|
||||||
`Generated signed URL for: ${options.key} (expires in ${expiresIn}s)`
|
|
||||||
);
|
|
||||||
return url;
|
return url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(`Failed to generate signed URL for: ${options.key}`, error);
|
||||||
`Failed to generate signed URL for: ${options.key}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,7 @@ import helmet from 'helmet';
|
|||||||
import * as compression from 'compression';
|
import * as compression from 'compression';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import {
|
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
||||||
helmetConfig,
|
|
||||||
corsConfig,
|
|
||||||
} from './infrastructure/security/security.config';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
@ -50,14 +47,14 @@ async function bootstrap() {
|
|||||||
transformOptions: {
|
transformOptions: {
|
||||||
enableImplicitConversion: true,
|
enableImplicitConversion: true,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Swagger documentation
|
// Swagger documentation
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('Xpeditis API')
|
.setTitle('Xpeditis API')
|
||||||
.setDescription(
|
.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')
|
.setVersion('1.0')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
|
|||||||
@ -19,7 +19,7 @@ describe('AppController (e2e)', () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get('/api/v1/health')
|
.get('/api/v1/health')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect((res) => {
|
.expect(res => {
|
||||||
expect(res.body).toHaveProperty('status', 'ok');
|
expect(res.body).toHaveProperty('status', 'ok');
|
||||||
expect(res.body).toHaveProperty('timestamp');
|
expect(res.body).toHaveProperty('timestamp');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -309,7 +309,7 @@ describe('TypeOrmBookingRepository (Integration)', () => {
|
|||||||
const bookings = await repository.findByOrganization(testOrganization.id);
|
const bookings = await repository.findByOrganization(testOrganization.id);
|
||||||
|
|
||||||
expect(bookings).toHaveLength(3);
|
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 () => {
|
it('should return empty array for organization with no bookings', async () => {
|
||||||
@ -338,8 +338,8 @@ describe('TypeOrmBookingRepository (Integration)', () => {
|
|||||||
|
|
||||||
expect(draftBookings).toHaveLength(2);
|
expect(draftBookings).toHaveLength(2);
|
||||||
expect(confirmedBookings).toHaveLength(1);
|
expect(confirmedBookings).toHaveLength(1);
|
||||||
expect(draftBookings.every((b) => b.status.value === 'draft')).toBe(true);
|
expect(draftBookings.every(b => b.status.value === 'draft')).toBe(true);
|
||||||
expect(confirmedBookings.every((b) => b.status.value === 'confirmed')).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(quotes[0].route).toBeDefined();
|
||||||
expect(Array.isArray(quotes[0].route)).toBe(true);
|
expect(Array.isArray(quotes[0].route)).toBe(true);
|
||||||
// Vessel name should be in route segments
|
// 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);
|
expect(hasVesselInfo).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user